diff options
| author | historia <[not public]> | 2026-09-30 16:16:17 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-30 16:16:17 -0400 |
| commit | a4c09c2d38d1f3bc1d3ad4bdb0234bdea7c37e73 (patch) | |
| tree | 00c9567e620f331a59dd44f1f1fa779ecd94a845 /internal | |
| parent | 724079f8de1ae69965c6267dca3d7fc3cc86afa2 (diff) | |
| download | thehouseoficarus-a4c09c2d38d1f3bc1d3ad4bdb0234bdea7c37e73.tar.gz | |
feat: additional craps variants added, standardizard casino command hints
Diffstat (limited to 'internal')
23 files changed, 1332 insertions, 292 deletions
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go index 56677b2..0890d59 100644 --- a/internal/casino/baccarat.go +++ b/internal/casino/baccarat.go @@ -47,11 +47,14 @@ func newBaccaratTable(roomID int, cfg TableConfig, rng *rand.Rand) *baccaratTabl } func (t *baccaratTable) menuEvent(name string) Event { + if p := t.players.get(name); p != nil && p.HasBet { + return t.committedMenu(name) + } commands := []MenuCommand{} if p := t.players.get(name); p != nil && p.lastBet > 0 { commands = append(commands, MenuCommand{Command: "bet", Desc: fmt.Sprintf("Repeat %d on %s (also: rebet)", p.lastBet, p.lastSpot)}, MenuCommand{Command: "bet <amount>", Desc: "Change amount; keep " + p.lastSpot}) } - commands = append(commands, MenuCommand{Command: fmt.Sprintf("bet %d banker", t.Config.MinBet), Desc: "Choose banker (b), player (p), or tie (t); min/max accepted"}, MenuCommand{Command: "bet <amount> on " + t.Rules.betChoices(), Desc: "One wager per round; 'on' is optional"}, MenuCommand{Command: "stop", Desc: "Leave; during a round, leave afterward"}, MenuCommand{Command: "table / sitout", Desc: "Check the table / skip betting until you rejoin"}) + commands = append(commands, MenuCommand{Command: fmt.Sprintf("bet %d banker", t.Config.MinBet), Desc: "Choose banker (b), player (p), or tie (t)"}, MenuCommand{Command: "bet <amount> on <target>", Desc: "One wager per round; targets: table rules"}, MenuCommand{Command: "stop", Desc: "Leave; during a round, leave afterward"}, MenuCommand{Command: "table / sitout", Desc: "Check the table / skip betting until you rejoin"}) e := t.betMenuEvent(name, commands) if p := t.players.get(name); p != nil { e.Menu.Repeat = p.lastBet > 0 diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go index 5fb8b0b..925b7ef 100644 --- a/internal/casino/blackjack.go +++ b/internal/casino/blackjack.go @@ -101,6 +101,15 @@ func newBlackjackTable(roomID int, cfg TableConfig, rng *rand.Rand) *blackjackTa } func (t *blackjackTable) menuEvent(name string) Event { + if p := t.players.get(name); p != nil && p.HasBet { + if t.phase == blackjackPlaying && t.activeName() == name { + return t.turnMenuLocked(name) + } + if t.phase == blackjackInsurance && !p.declinedInsurance && p.insurance == 0 { + return t.insuranceMenuLocked(p) + } + return t.committedMenu(name) + } if t.phase == blackjackPlaying || t.phase == blackjackInsurance { return t.private(name, "Round in progress. You’ll join the next hand; table shows the current action.") } diff --git a/internal/casino/command_hints_test.go b/internal/casino/command_hints_test.go new file mode 100644 index 0000000..c570e15 --- /dev/null +++ b/internal/casino/command_hints_test.go @@ -0,0 +1,155 @@ +package casino + +import ( + "strings" + "testing" + "time" +) + +func hintHas(commands []MenuCommand, command string) bool { + for _, c := range commands { + if c.Command == command { + return true + } + } + return false +} +func hintBet(commands []MenuCommand) bool { + for _, c := range commands { + if strings.HasPrefix(c.Command, "bet") { + return true + } + } + return false +} +func TestCrapsHintsFollowPlayerState(t *testing.T) { + for _, v := range crapsVariants { + tb := variantFixture(v) + tb.join("a") + w := &testWallet{chips: 1000} + commands := tb.commands("a") + if !hintBet(commands) || !hintHas(commands, "stop") { + t.Fatal(v, "missing idle actions", commands) + } + for _, c := range []string{"roll", "autoroll", "unbet <target> / unbet all"} { + if hintHas(commands, c) { + t.Fatal(v, "idle", c) + } + } + tb.bet("a", "pass", 10, w) + commands = tb.commands("a") + if !hintHas(commands, "roll") || !hintHas(commands, "autoroll") || hintHas(commands, "unbet <target> / unbet all") { + t.Fatal(v, "contract actions", commands) + } + if !tb.Config.Craps.allows("buy") && hintBet(commands) { + t.Fatal(v, "duplicate pass advertised") + } + tb.action("a", "autoroll") + if hintHas(tb.commands("a"), "autoroll") { + t.Fatal(v, "already enabled") + } + tb.join("b") + if hintHas(tb.commands("a"), "roll") { + t.Fatal(v, "roll before betting window ends") + } + tb.players.get("a").autoRoll = false + tb.bet("b", "pass", 10, &testWallet{chips: 1000}) + tb.tickElapsed(10 * time.Second) + if !hintHas(tb.commands("a"), "roll") || hintHas(tb.commands("b"), "roll") { + t.Fatal(v, "wrong shooter hints") + } + watch := tb.commands("watcher") + if len(watch) != 1 || watch[0].Command != "table" { + t.Fatal(v, "spectator actions", watch) + } + } +} +func TestCrapsHintsOnlyOfferRemovableWagers(t *testing.T) { + tb := crapsFixture() + tb.join("a") + w := &testWallet{chips: 1000} + tb.bet("a", "pass", 10, w) + tb.resolveRoll(2, 2) + if hintHas(tb.commands("a"), "unbet <target> / unbet all") { + t.Fatal("contract removable") + } + tb.bet("a", "odds pass", 10, w) + if !hintHas(tb.commands("a"), "unbet <target> / unbet all") { + t.Fatal("odds not removable") + } + tb.action("a", "unbet odds pass") + if hintHas(tb.commands("a"), "unbet <target> / unbet all") { + t.Fatal("removed odds still advertised") + } + tb.bet("a", "place 6", 12, w) + if !hintHas(tb.commands("a"), "unbet <target> / unbet all") { + t.Fatal("place removal missing") + } +} +func TestOtherCasinoHintsRespectCommitment(t *testing.T) { + bj := newBlackjackTable(1, TableConfig{ID: "bj", Game: GameBlackjack, BettingTicks: 10}, newTestRand()) + bj.join("a") + bj.join("b") + bj.bet("a", "", 10, &testWallet{chips: 1000}) + if hintBet(bj.menuEvent("a").Menu.Commands) { + t.Fatal("blackjack duplicate bet advertised") + } + if !hintBet(bj.menuEvent("b").Menu.Commands) { + t.Fatal("blackjack missing eligible bet") + } + bac := newBaccaratTable(1, TableConfig{ID: "bac", Game: GameBaccarat, BettingTicks: 10}, newTestRand()) + bac.join("a") + bac.join("b") + bac.bet("a", "banker", 10, &testWallet{chips: 1000}) + if hintBet(bac.menuEvent("a").Menu.Commands) { + t.Fatal("baccarat duplicate bet advertised") + } + if !hintBet(bac.menuEvent("b").Menu.Commands) { + t.Fatal("baccarat missing eligible bet") + } + r := newRouletteTable(1, TableConfig{ID: "r", Game: GameRoulette, BettingTicks: 10}, newTestRand()) + r.join("a") + r.join("b") + if hintHas(r.menuEvent("a").Menu.Commands, "bet / rebet") { + t.Fatal("roulette no wager to repeat") + } + r.bet("a", "red", 10, &testWallet{chips: 1000}) + if hintBet(r.menuEvent("a").Menu.Commands) { + t.Fatal("roulette duplicate bet advertised") + } + if !hintBet(r.menuEvent("b").Menu.Commands) { + t.Fatal("roulette missing eligible bet") + } + r.phase = phasePlaying + if hintBet(r.menuEvent("b").Menu.Commands) { + t.Fatal("roulette spinning bets advertised") + } + r.phase = phaseWaiting + r.players.get("a").HasBet = false + if !hintHas(r.menuEvent("a").Menu.Commands, "bet / rebet") { + t.Fatal("roulette repeat missing") + } +} +func TestSlotHintsRequirePriorWager(t *testing.T) { + m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, newTestRand()) + m.join("a") + for _, c := range []string{"spin", "autospin"} { + if hintHas(m.commands(), c) { + t.Fatal("fresh slots", c) + } + } + m.bet = 10 + for _, c := range []string{"spin", "autospin"} { + if !hintHas(m.commands(), c) { + t.Fatal("configured slots", c) + } + } + m.autoBet = true + if commands := m.commands(); len(commands) != 1 || commands[0].Command != "stop" { + t.Fatal("autospin hints", commands) + } + m.phase = machineSpinning + if events := m.privateMenu(""); events[0].Menu != nil { + t.Fatal("manual commands during spin") + } +} diff --git a/internal/casino/craps.go b/internal/casino/craps.go index 5e5e876..9e48667 100644 --- a/internal/casino/craps.go +++ b/internal/casino/craps.go @@ -43,6 +43,7 @@ type CrapsBetView struct { Amount, Odds int } type CrapsSnapshot struct { + Variant string Phase string Point int Dice [2]int @@ -71,7 +72,7 @@ func crapsTarget(b *crapsBet) string { return fmt.Sprintf("%s %d", b.kind, b.number) } func (t *crapsTable) view() *CrapsSnapshot { - s := &CrapsSnapshot{Phase: string(t.phase), Point: t.point, Dice: t.dice, Queue: append([]string(nil), t.queue...), Field12: t.Config.Craps.Field12} + s := &CrapsSnapshot{Variant: t.Config.Craps.variant(), Phase: string(t.phase), Point: t.point, Dice: t.dice, Queue: append([]string(nil), t.queue...), Field12: t.Config.Craps.Field12} if t.hasLiveBets() && t.multiplayer { s.ShootIn = crapsSeconds(crapsBetDelay - t.roundElapsed) s.DealerIn = crapsSeconds(crapsBetDelay + crapsDealerDelay - t.roundElapsed) @@ -96,7 +97,7 @@ func (t *crapsTable) view() *CrapsSnapshot { return s } func (t *crapsTable) menu(name string) Event { - e := t.betMenuEvent(name, t.commands()) + e := t.betMenuEvent(name, t.commands(name)) e.Snapshot = t.view() return e } @@ -159,7 +160,7 @@ func parseCrapsTarget(spot string) (kind string, n int, ok bool) { return } n, _ = strconv.Atoi(f[1]) - ok = crapsNumber(n) && (kind != "hard" || n%2 == 0) + ok = n >= 2 && n <= 12 && n != 7 && (kind != "hard" || (crapsNumber(n) && n%2 == 0)) return case "hop": if len(f) != 3 { @@ -167,6 +168,9 @@ func parseCrapsTarget(spot string) (kind string, n int, ok bool) { } a, _ := strconv.Atoi(f[1]) b, _ := strconv.Atoi(f[2]) + if a > b { + a, b = b, a + } return kind, a*10 + b, a >= 1 && a <= 6 && b >= 1 && b <= 6 } return @@ -191,6 +195,9 @@ func (t *crapsTable) bet(name, spot string, amount int, w Wallet) []Event { return reject("That potential payout is too large.") } if strings.HasPrefix(spot, "odds ") { + if !t.Config.Craps.allows("odds") { + return reject("This variant has no free odds.") + } target := strings.TrimPrefix(spot, "odds ") for _, b := range p.bets { if crapsTarget(b) != target && !(b.kind == target && (target == "pass" || target == "dontpass")) { @@ -209,7 +216,8 @@ func (t *crapsTable) bet(name, spot string, amount int, w Wallet) []Event { if b.odds.Total > 0 { continue } - if int64(amount) > int64(b.stake.Total)*int64(limit) || amount%y != 0 { + // Compare without multiplying a potentially very large stake by Nx. + if (amount-1)/limit >= b.stake.Total || amount%y != 0 { return reject("Odds already placed, above the free-odds limit, or not in exact payout units.") } stake, err := w.Wager(amount) @@ -219,13 +227,13 @@ func (t *crapsTable) bet(name, spot string, amount int, w Wallet) []Event { b.odds = stake p.Wallet = w p.lastSpot, p.lastBet = spot, amount - return []Event{t.private(name, fmt.Sprintf("You add {casino_chips}%d{/} free odds on %s.", amount, target)), t.betSummary(p)} + return []Event{t.private(name, fmt.Sprintf("You add {casino_chips}%d{/} free odds on %s.", amount, target)), t.betSummary(p), t.reminder(name)} } return reject("Place an established line/come bet before taking odds.") } kind, n, ok := parseCrapsTarget(spot) - if !ok { - return reject("Unknown craps target. Use table rules.") + if !ok || !t.Config.Craps.allows(kind) || ((kind == "place" || kind == "buy" || kind == "lay") && !t.Config.Craps.number(n)) { + return reject("Unknown or unavailable craps target. Use table rules.") } if (kind == "pass" || kind == "dontpass") && t.point != 0 { return reject("Line bets are accepted only on come-out.") @@ -244,12 +252,7 @@ func (t *crapsTable) bet(name, spot string, amount int, w Wallet) []Event { unit := 1 switch kind { case "place": - switch n { - case 4, 10, 5, 9: - unit = 5 - default: - unit = 6 - } + _, unit = crapsPlaceOdds(n) case "buy": _, unit = crapsTrueOdds(n) case "lay": @@ -271,35 +274,13 @@ func (t *crapsTable) bet(name, spot string, amount int, w Wallet) []Event { p.lastSpot, p.lastBet = spot, amount events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d on %s.", name, amount, spot), fmt.Sprintf("You bet {casino_chips}%d{/} on %s.", amount, spot))} events = append(events, t.syncRoundClock()...) - return append(events, t.betSummary(p)) + return append(events, t.betSummary(p), t.reminder(name)) } func (t *crapsTable) pay(p *crapsPlayer, b *crapsBet, profit, den int, oddsProfit int, refundOdds bool) (settlementLine, bool) { - payout := 0 - if profit >= 0 { - payout = b.stake.Total + b.stake.Total/den*profit - if b.kind == "buy" || b.kind == "lay" { - commissionBase := payout - b.stake.Total - if b.kind == "buy" { - commissionBase = b.stake.Total - } - // Removals (zero profit) return the full stake without commission. - if profit > 0 { - payout -= (commissionBase + 19) / 20 - } - } - } + payout := crapsReturn(b, profit, den, oddsProfit, refundOdds) ids := []string{b.stake.ID} if b.odds.Total > 0 { ids = append(ids, b.odds.ID) - if refundOdds { - payout += b.odds.Total - } else if oddsProfit >= 0 { - x, y := crapsTrueOdds(b.number) - if strings.HasPrefix(b.kind, "dont") { - x, y = y, x - } - payout += b.odds.Total + b.odds.Total/y*x - } } line := settlementLine{Label: crapsBetLabel(b), Stake: b.stake.Total + b.odds.Total, Returned: payout, Result: "push"} if payout > line.Stake { @@ -310,7 +291,17 @@ func (t *crapsTable) pay(p *crapsPlayer, b *crapsBet, profit, den int, oddsProfi return line, p.Wallet.PayCredits(payout, ids...) != nil } func (t *crapsTable) roll() []Event { - a, b := t.rng.Intn(6)+1, t.rng.Intn(6)+1 + a, b := 0, 0 + switch t.Config.Craps.variant() { + case "die_rich": + a = t.rng.Intn(6) + 1 + case "diceless": + // Each independent pack is replaced and reshuffled for every draw. + a = t.rng.Perm(6)[0] + 1 + b = t.rng.Perm(6)[0] + 1 + default: + a, b = t.rng.Intn(6)+1, t.rng.Intn(6)+1 + } return t.resolveRoll(a, b) } @@ -319,19 +310,24 @@ func (t *crapsTable) resolveRoll(a, b int) []Event { s := a + b oldPoint := t.point sevenOut := oldPoint != 0 && s == 7 + pointMade := oldPoint != 0 && oldPoint == s + r := t.Config.Craps + switch r.variant() { + case "simplified", "high_point", "die_rich": + done, profit, _ := r.outcome("pass", oldPoint, a, b, oldPoint) + sevenOut = done && profit < 0 + pointMade = done && profit > 0 && oldPoint != 0 + } t.dice = [2]int{a, b} t.rolled = true - if oldPoint == s { - t.made[s] = true - } - if s != 7 { - t.seen[s] = true - } shooter := "Dealer" if len(t.queue) > 0 { shooter = t.queue[0] } events := []Event{t.event(fmt.Sprintf("{casino_emphasis}%s shoots{/} {casino_payout}%d{/}", shooter, s), true)} + if r.variant() == "diceless" { + events[0].Message = fmt.Sprintf("{casino_emphasis}%s draws cards{/} [%d] [%d] = %d", shooter, a, b, s) + } var results []Event for _, p := range t.players.ordered() { p.autoElapsed = 0 @@ -339,46 +335,7 @@ func (t *crapsTable) resolveRoll(a, b int) []Event { failed := false var live []*crapsBet for _, bet := range p.bets { - done, x, y := crapsOutcome(bet.kind, bet.number, a, b, oldPoint, t.Config.Craps.Field12) - switch bet.kind { - case "fire": - done = sevenOut - y = 1 - x = -1 - if done { - switch len(t.made) { - case 4: - x = 24 - case 5: - x = 249 - case 6: - x = 999 - } - } - case "small", "tall", "all": - lo, hi := 2, 12 - if bet.kind == "small" { - hi = 6 - } - if bet.kind == "tall" { - lo = 8 - } - hit := true - for n := lo; n <= hi; n++ { - if n != 7 && !t.seen[n] { - hit = false - } - } - done = hit || s == 7 - x = -1 - y = 1 - if hit { - x = 34 - if bet.kind == "all" { - x = 175 - } - } - } + done, x, y := t.outcomeFor(bet, a, b) if done { refundOdds := oldPoint == 0 && bet.kind == "come" line, err := t.pay(p, bet, x, y, x, refundOdds) @@ -389,7 +346,7 @@ func (t *crapsTable) resolveRoll(a, b int) []Event { } continue } - if bet.number == 0 && (bet.kind == "pass" || bet.kind == "dontpass" || bet.kind == "come" || bet.kind == "dontcome") && crapsNumber(s) { + if bet.number == 0 && (bet.kind == "pass" || bet.kind == "dontpass" || bet.kind == "come" || bet.kind == "dontcome") && r.pointNumber(s) { bet.number = s } live = append(live, bet) @@ -400,15 +357,20 @@ func (t *crapsTable) resolveRoll(a, b int) []Event { results = append(results, t.settlementEvent(p, lines, failed)) } } + if pointMade { + t.made[oldPoint] = true + } if s == 7 { t.seen = map[int]bool{} + } else { + t.seen[s] = true } - if oldPoint == 0 && crapsNumber(s) { + if oldPoint == 0 && r.pointNumber(s) { t.point = s events = append(events, t.event(fmt.Sprintf("{casino_emphasis}POINT %d{/} / Puck ON. Make your point!", s), true)) - } else if oldPoint == s || sevenOut { - if oldPoint == s { - events = append(events, t.event(fmt.Sprintf("{casino_result_win}POINT %d MADE!{/} / Puck OFF. Same shooter, coming out.", s), true)) + } else if pointMade || sevenOut { + if pointMade { + events = append(events, t.event(fmt.Sprintf("{casino_result_win}POINT %d MADE!{/} / Puck OFF. Same shooter, coming out.", oldPoint), true)) } t.point = 0 } @@ -418,7 +380,11 @@ func (t *crapsTable) resolveRoll(a, b int) []Event { if len(t.queue) > 1 { t.queue = append(t.queue[1:], t.queue[0]) } - events = append(events, t.event("{casino_warning}SEVEN OUT{/} / Puck OFF. Dice pass to the next shooter.", true)) + message := "{casino_warning}SEVEN OUT{/} / Puck OFF. Dice pass to the next shooter." + if r.variant() == "simplified" || r.variant() == "high_point" || r.variant() == "die_rich" { + message = "{casino_warning}SHOOTER OUT{/} / Puck OFF. Next shooter." + } + events = append(events, t.event(message, true)) } events = append(events, t.finishCrapsControls()...) t.phase = phaseWaiting @@ -483,7 +449,7 @@ func (t *crapsTable) action(name, action string) []Event { p.autoRoll = true p.autoElapsed = 0 } - return []Event{t.private(name, "{casino_emphasis}Autoroll ON{/} — every 3 seconds on your turn, respecting the multiplayer betting window. {command}stop{/} returns to manual play.")} + return []Event{t.private(name, "{casino_emphasis}Autoroll ON{/} — every 3 seconds on your turn, respecting the multiplayer betting window. {command}stop{/} returns to manual play."), t.reminder(name)} } if action == "roll" { events := t.syncRoundClock() @@ -505,6 +471,9 @@ func (t *crapsTable) action(name, action string) []Event { } oddsOnly := strings.HasPrefix(target, "odds ") target = strings.TrimPrefix(target, "odds ") + if kind, n, ok := parseCrapsTarget(target); ok && kind == "hop" { + target = crapsTarget(&crapsBet{kind: kind, number: n}) + } for i, b := range p.bets { if crapsTarget(b) != target && !(b.kind == target && (target == "pass" || target == "dontpass")) { continue @@ -518,9 +487,9 @@ func (t *crapsTable) action(name, action string) []Event { e.Message = payoutFailedMessage() } b.odds = Wager{} - return []Event{e, t.betSummary(p)} + return []Event{e, t.betSummary(p), t.reminder(name)} } - if b.kind == "pass" || b.kind == "come" || b.kind == "fire" || b.kind == "small" || b.kind == "tall" || b.kind == "all" { + if crapsContract(b.kind) { return []Event{t.private(name, "That contract bet cannot be removed.")} } line, failed := t.pay(p, b, 0, 1, 0, true) @@ -534,7 +503,7 @@ func (t *crapsTable) action(name, action string) []Event { events := append([]Event{e}, t.finishCrapsControls()...) events = append(events, t.syncRoundClock()...) if t.players.has(name) { - events = append(events, t.betSummary(p)) + events = append(events, t.betSummary(p), t.reminder(name)) } return events } diff --git a/internal/casino/craps_clock.go b/internal/casino/craps_clock.go index cb43731..f5cb690 100644 --- a/internal/casino/craps_clock.go +++ b/internal/casino/craps_clock.go @@ -92,7 +92,7 @@ func (t *crapsTable) tickElapsed(elapsed time.Duration) []Event { return events } if t.phase == phaseBetting { - events = append(events, t.event(fmt.Sprintf("{casino_emphasis}%s can shoot!{/} Bets stay open until the roll. Dealer rolls in 60 seconds if needed.", p.Name), true)) + events = append(events, t.event(fmt.Sprintf("{casino_emphasis}%s can shoot!{/} Bets stay open until the roll. Dealer rolls in 60 seconds if needed.", p.Name), true), t.reminder(p.Name)) } t.phase = phasePlaying if (p.autoRoll && p.HasBet && p.autoElapsed >= crapsAutoDelay) || (t.multiplayer && t.roundElapsed >= crapsBetDelay+crapsDealerDelay) { @@ -116,5 +116,5 @@ func (t *Table) StopAutoRoll(name string) ([]Event, bool) { } p.autoRoll = false p.autoElapsed = 0 - return []Event{game.private(name, "{casino_emphasis}Autoroll OFF{/} — manual play resumed. Your bets stay on the table.")}, true + return []Event{game.private(name, "{casino_emphasis}Autoroll OFF{/} — manual play resumed. Your bets stay on the table."), game.reminder(name)}, true } diff --git a/internal/casino/craps_clock_test.go b/internal/casino/craps_clock_test.go index 0e68e6d..518d18d 100644 --- a/internal/casino/craps_clock_test.go +++ b/internal/casino/craps_clock_test.go @@ -282,7 +282,7 @@ func TestCrapsRollTableHardPairLabels(t *testing.T) { tb.bet("alice", "hop 2 3", 10, w) tb.point = 8 table := tb.rollTable(tb.players.get("alice")) - for _, want := range []string{"{casino_payout}4+4{/}", "{casino_result_win}+90{/}", "{casino_payout}2+3{/}", "{casino_result_win}+150{/}", "{casino_payout}easy 8{/}", "{casino_payout}any other{/}"} { + for _, want := range []string{"{casino_payout}4+4{/}", "{casino_result_win}+80{/}", "{casino_payout}2+3{/}", "{casino_result_win}+150{/}", "{casino_payout}2+6{/}", "{casino_result_loss}-20{/}"} { if !strings.Contains(table, want) { t.Fatal("missing", want, table) } diff --git a/internal/casino/craps_projection.go b/internal/casino/craps_projection.go index 5be2f28..96d64b7 100644 --- a/internal/casino/craps_projection.go +++ b/internal/casino/craps_projection.go @@ -6,22 +6,17 @@ import ( "strings" ) -// crapsReturn is the pure payout math shared by settlement and by the -// player-facing "what could win on the next roll" projection, so the two can -// never disagree. It mirrors the money path of crapsTable.pay exactly. +// crapsReturn is shared by wallet settlement and next-roll previews. func crapsReturn(b *crapsBet, profit, den, oddsProfit int, refundOdds bool) int { payout := 0 if profit >= 0 { payout = b.stake.Total + b.stake.Total/den*profit - if b.kind == "buy" || b.kind == "lay" { + if (b.kind == "buy" || b.kind == "lay") && profit > 0 { base := payout - b.stake.Total if b.kind == "buy" { base = b.stake.Total } - // Removals (zero profit) return the full stake without commission. - if profit > 0 { - payout -= (base + 19) / 20 - } + payout -= (base + 19) / 20 } } if b.odds.Total > 0 { @@ -37,69 +32,50 @@ func crapsReturn(b *crapsBet, profit, den, oddsProfit int, refundOdds bool) int } return payout } - -// crapsNet returns settlement profit: returns (stake included) less commit. func crapsNet(b *crapsBet, profit, den, oddsProfit int, refundOdds bool) int { - return crapsReturn(b, profit, den, oddsProfit, refundOdds) - (b.stake.Total + b.odds.Total) + return crapsReturn(b, profit, den, oddsProfit, refundOdds) - b.stake.Total - b.odds.Total } - -// crapsRefundOdds mirrors come bets' come-out refundOdds policy. func crapsRefundOdds(b *crapsBet, point int) bool { return point == 0 && b.kind == "come" } -// crapsRollView is one possible dice pair: its total, the pair shown, and the -// player's combined settlement profit if it rolled next. type CrapsRollView struct { Total int Rolls string Net int } -// projectionFor evaluates one player's live bets as if the listed pair rolled -// with the current table point, via the same rules engine used for settlement. +func (t *crapsTable) rollPairs() [][2]int { + var pairs [][2]int + for a := 1; a <= 6; a++ { + if t.Config.Craps.variant() == "die_rich" { + pairs = append(pairs, [2]int{a, 0}) + continue + } + for b := a; b <= 6; b++ { + pairs = append(pairs, [2]int{a, b}) + } + } + return pairs +} func (t *crapsTable) projectionFor(p *crapsPlayer) []CrapsRollView { var rows []CrapsRollView - for total := 2; total <= 12; total++ { - for a := 1; a <= 6; a++ { - b := total - a - if b < 1 || b > 6 || a > b { - continue - } - net := 0 - for _, bet := range p.bets { - done, x, y := crapsOutcome(bet.kind, bet.number, a, b, t.point, t.Config.Craps.Field12) - if !done { - // An unresolved contract keeps its stake; only bonuses - // can complete within a single non-settling roll. - if bet.kind == "fire" { - x = crapsFireProfit(len(t.made)) - } else if bet.kind == "small" || bet.kind == "tall" || bet.kind == "all" { - lo, hi := 2, 12 - if bet.kind == "small" { - hi = 6 - } - if bet.kind == "tall" { - lo = 8 - } - if crapsBonusComplete(lo, hi, t.seen, a+b) { - x = 34 - if bet.kind == "all" { - x = 175 - } - } else { - continue - } - } else { - continue - } - } + for _, pair := range t.rollPairs() { + a, b := pair[0], pair[1] + net := 0 + for _, bet := range p.bets { + done, x, y := t.outcomeFor(bet, a, b) + if done { net += crapsNet(bet, x, y, x, crapsRefundOdds(bet, t.point)) } - rows = append(rows, CrapsRollView{total, fmt.Sprintf("%d+%d", a, b), net}) } + label := fmt.Sprintf("%d+%d", a, b) + if b == 0 { + label = fmt.Sprint(a) + } + rows = append(rows, CrapsRollView{a + b, label, net}) } + sort.SliceStable(rows, func(i, j int) bool { return rows[i].Total < rows[j].Total }) return rows } - func crapsFireProfit(made int) int { switch made { case 4: @@ -111,7 +87,6 @@ func crapsFireProfit(made int) int { } return 0 } - func crapsBonusComplete(lo, hi int, seen map[int]bool, next int) bool { for n := lo; n <= hi; n++ { if n != 7 && !seen[n] && n != next { @@ -120,9 +95,6 @@ func crapsBonusComplete(lo, hi int, seen map[int]bool, next int) bool { } return true } - -// crapsRollLabel names the roll result a bet cares about: hardways and hop -// bets distinguish their exact pair ("4+4"); every other bet reads by total. func crapsRollLabel(bet *crapsBet, a, b int) string { s := a + b switch bet.kind { @@ -142,59 +114,28 @@ func crapsRollLabel(bet *crapsBet, a, b int) string { } return fmt.Sprint(s) } - -// rollTable lists each roll result and its combined payout across the -// player's bets, one row per roll. Dice-distinguishing bets (hardways, hop) -// name their pair; everything else reads by total. Pairs that share a -// label and outcome collapse; across bets, same-roll payouts add. Tags -// stay single-level: the tag expander does not nest. func (t *crapsTable) rollTable(p *crapsPlayer) string { agg := map[string]int{} + pairsMatter := false for _, bet := range p.bets { - perLabel := map[string]int{} - for a := 1; a <= 6; a++ { - for b := a; b <= 6; b++ { - done, x, y := crapsOutcome(bet.kind, bet.number, a, b, t.point, t.Config.Craps.Field12) - if !done { - // Unresolved contracts stay put; only bonuses can - // complete within a single non-settling roll. - switch bet.kind { - case "fire": - x = crapsFireProfit(len(t.made)) - case "small", "tall", "all": - lo, hi := 2, 12 - if bet.kind == "small" { - hi = 6 - } - if bet.kind == "tall" { - lo = 8 - } - if !crapsBonusComplete(lo, hi, t.seen, a+b) { - continue - } - x = 34 - if bet.kind == "all" { - x = 175 - } - default: - continue - } - } - net := crapsNet(bet, x, y, x, crapsRefundOdds(bet, t.point)) - if net != 0 { - perLabel[crapsRollLabel(bet, a, b)] = net - } - } + if bet.kind == "hard" || bet.kind == "hop" { + pairsMatter = true + } + } + for _, row := range t.projectionFor(p) { + label := fmt.Sprint(row.Total) + if pairsMatter { + label = row.Rolls } - for label, net := range perLabel { - agg[label] += net + if row.Net != 0 { + agg[label] = row.Net } } if len(agg) == 0 { if len(p.bets) == 0 { return "No open wagers." } - return "{casino_action}Your place, buy and hardway bets are OFF on the come-out — nothing pays until a point is set.{/}" + return "{casino_action}No wager settles on the next roll; contracts remain live. Place/buy/hardways are OFF on the come-out.{/}" } labels := []string{} for label := range agg { diff --git a/internal/casino/craps_regression_test.go b/internal/casino/craps_regression_test.go new file mode 100644 index 0000000..322325c --- /dev/null +++ b/internal/casino/craps_regression_test.go @@ -0,0 +1,51 @@ +package casino + +import ( + "testing" +) + +func TestCrapsLargestStakeAndOddsLimit(t *testing.T) { + tb := crapsFixture() + tb.Config.MaxBet = MaxCredits / 1000 + tb.Config.Craps.Odds = 1000000 + tb.join("a") + w := &testWallet{chips: MaxCredits} + amount := tb.Config.MaxBet + tb.bet("a", "pass", amount, w) + tb.resolveRoll(2, 2) + tb.bet("a", "odds pass", amount, w) + if len(w.open) != 2 { + t.Fatal("valid large odds rejected") + } +} +func TestCrapsHopIsUnordered(t *testing.T) { + tb := crapsFixture() + tb.join("a") + w := &testWallet{chips: 100} + tb.bet("a", "hop 3 2", 10, w) + tb.bet("a", "hop 2 3", 10, w) + if len(w.open) != 1 || w.chips != 90 { + t.Fatal("duplicate unordered hop accepted") + } + tb.action("a", "unbet hop 3 2") + if len(w.open) != 0 || w.paid != 10 || w.chips != 90 { + t.Fatal("reverse-order removal failed") + } +} +func TestCrapsMixedPairPreviewCombinesAllBets(t *testing.T) { + tb := crapsFixture() + tb.join("a") + w := &testWallet{chips: 1000} + tb.point = 8 + tb.bet("a", "hard 8", 10, w) + tb.bet("a", "hop 2 3", 10, w) + tb.bet("a", "field", 10, w) + for _, row := range tb.projectionFor(tb.players.get("a")) { + if row.Rolls == "4+4" && row.Net != 70 { + t.Fatal("hard win minus hop and field losses", row) + } + if row.Rolls == "2+3" && row.Net != 140 { + t.Fatal("hop win minus field loss", row) + } + } +} diff --git a/internal/casino/craps_rules.go b/internal/casino/craps_rules.go index 86aa9bb..8d2f7d4 100644 --- a/internal/casino/craps_rules.go +++ b/internal/casino/craps_rules.go @@ -2,8 +2,7 @@ package casino import "fmt" -// CrapsConfig intentionally supports bank craps only. Other games must supply -// their own outcome rules, not silently inherit bank-craps probabilities. +// CrapsConfig selects explicit house rules and the shared wager limits. type CrapsConfig struct { Odds int `yaml:"odds,omitempty"` // 0 = 3-4-5x; positive = uniform Nx Field12 int `yaml:"field_12,omitempty"` @@ -23,14 +22,23 @@ func validateCraps(c TableConfig) error { if c.Craps.Field12 != 0 && c.Craps.Field12 != 2 && c.Craps.Field12 != 3 { return fmt.Errorf("craps field_12 must be 2 or 3") } - if c.Craps.Variant != "" && c.Craps.Variant != "bank" { - return fmt.Errorf("only bank craps is implemented") + switch c.Craps.variant() { + case "bank", "simplified", "high_point", "crapless", "new_york", "die_rich", "diceless": + default: + return fmt.Errorf("unknown craps variant %q", c.Craps.Variant) + } + if !c.Craps.allows("odds") && c.Craps.Odds != 0 { + return fmt.Errorf("this craps variant does not support odds") } return nil } func crapsNumber(n int) bool { return n == 4 || n == 5 || n == 6 || n == 8 || n == 9 || n == 10 } func crapsTrueOdds(n int) (int, int) { switch n { + case 2, 12: + return 6, 1 + case 3, 11: + return 3, 1 case 4, 10: return 2, 1 case 5, 9: @@ -43,6 +51,9 @@ func (c CrapsConfig) oddsLimit(n int, against bool) int { if c.Odds > 0 { return c.Odds } + if c.variant() == "crapless" && !crapsNumber(n) { + return 1 + } if against { return 6 } @@ -57,11 +68,18 @@ func (c CrapsConfig) oddsLimit(n int, against bool) int { } func (c TableConfig) CrapsRulesText() string { r := c.Craps.Normalize() + if r.variant() != "bank" && r.variant() != "diceless" { + return r.variantRulesText() + } + prefix := "" + if r.variant() == "diceless" { + prefix = "Diceless Craps — bank rules using cards, not dice. Two independent six-card packs labelled 1-6; draw one from each, replace and reshuffle BOTH after EVERY roll. All 36 ordered pairs are equally likely. No finite shoe or card counting.\n" + } odds := "3-4-5x (don't: 6x laid)" if r.Odds > 0 { odds = fmt.Sprintf("%dx", r.Odds) } - return fmt.Sprintf("Bank Craps | Free odds %s | Field 12 pays %d:1\n"+ + return prefix + fmt.Sprintf("Bank Craps | Free odds %s | Field 12 pays %d:1\n"+ "bet <amount> on pass / dontpass / come / dontcome / field\n"+ "bet <amount> on place 6 / buy 4 / lay 10 / hard 8 / hop 2 3\n"+ "bet <amount> on seven / craps / eleven / ace-deuce / aces / boxcars / horn\n"+ @@ -79,7 +97,7 @@ func (c TableConfig) CrapsRulesText() string { "Bets remain open until the roll. Dice rotate on seven-out.\n"+ "autoroll: repeat every 3 seconds on your turn (multiplayer waits 10 seconds).\n"+ "unbet <target>: remove non-contract bets/odds; pass/come contracts stay.\n"+ - "unbet all: return every optional bet and odds at once.\n"+ + "unbet all: return every optional bet and odds at once.\n"+ "stop cancels autoroll without leaving; in manual mode stop/sitout wait for bets.\n"+ "Disconnect forfeits live bets.\n"+ "Winning bets return stake and are removed (no automatic pressing).", odds, r.Field12) @@ -134,14 +152,7 @@ func crapsOutcome(kind string, n, a, b, point, field12 int) (bool, int, int) { x, y = y, x } if kind == "place" { - switch n { - case 4, 10: - x, y = 9, 5 - case 5, 9: - x, y = 7, 5 - default: - x, y = 7, 6 - } + x, y = crapsPlaceOdds(n) } return true, x, y case "hard": diff --git a/internal/casino/craps_test.go b/internal/casino/craps_test.go index 5c7b0df..f49b5d0 100644 --- a/internal/casino/craps_test.go +++ b/internal/casino/craps_test.go @@ -300,7 +300,7 @@ func TestCrapsTimersControlsAndRefunds(t *testing.T) { } } func TestCrapsValidation(t *testing.T) { - for _, r := range []CrapsConfig{{Odds: -1}, {Field12: 4}, {Variant: "crapless"}} { + for _, r := range []CrapsConfig{{Odds: -1}, {Field12: 4}, {Variant: "unknown"}} { if err := (TableConfig{ID: "c", Game: GameCraps, Craps: r}).Validate(); err == nil { t.Fatal("accepted", r) } diff --git a/internal/casino/craps_ux.go b/internal/casino/craps_ux.go index 41f671a..948ab99 100644 --- a/internal/casino/craps_ux.go +++ b/internal/casino/craps_ux.go @@ -5,12 +5,51 @@ import ( "strings" ) -func (t *crapsTable) commands() []MenuCommand { - return []MenuCommand{ - {Command: "bet <amount> on <target>", Desc: "Place bets until the dice roll; table rules lists targets"}, - {Command: "roll / autoroll / stop", Desc: "Shoot / repeat on your turn / return to manual play"}, - {Command: "unbet <target> | unbet all", Desc: "Remove one bet / every optional bet"}, - {Command: "table", Desc: "View the felt"}, +func (t *crapsTable) commands(name string) []MenuCommand { + commands := []MenuCommand{} + p := t.players.get(name) + if p != nil { + // Pass-only editions cannot accept another contract while a point or + // the player's existing pass bet is live. + if t.Config.Craps.allows("come") || t.Config.Craps.allows("buy") || (t.point == 0 && !p.HasBet) { + target := "<target>" + if !t.Config.Craps.allows("buy") { + target = "pass" + } + commands = append(commands, MenuCommand{Command: "bet <amount> on " + target, Desc: "Place a wager; targets: table rules"}) + } + if p.HasBet { + if t.shooter() == p && (!t.multiplayer || t.roundElapsed >= crapsBetDelay) { + commands = append(commands, MenuCommand{Command: "roll", Desc: "Shoot now"}) + } + if !p.autoRoll { + commands = append(commands, MenuCommand{Command: "autoroll", Desc: "Roll automatically on your turn"}) + } + for _, b := range p.bets { + if !crapsContract(b.kind) || b.odds.Total > 0 { + commands = append(commands, MenuCommand{Command: "unbet <target> / unbet all", Desc: "Return optional bets or odds"}) + break + } + } + } + stop := "Leave the table" + if p.HasBet { + stop = "Leave after your live bets settle" + } + if p.autoRoll { + stop = "Cancel autoroll; keep your bets" + } + commands = append(commands, MenuCommand{Command: "stop", Desc: stop}) + } + return append(commands, MenuCommand{Command: "table", Desc: "View the felt"}) +} + +func crapsContract(kind string) bool { + switch kind { + case "pass", "come", "fire", "small", "tall", "all": + return true + default: + return false } } func crapsBetLabel(b *crapsBet) string { @@ -62,7 +101,7 @@ func (t *crapsTable) betSummary(p *crapsPlayer) Event { func (t *crapsTable) reminder(name string) Event { e := t.private(name, "Your move") e.Color = "casino_menu" - e.Menu = &MenuView{Heading: "Your move", Commands: t.commands()} + e.Menu = &MenuView{Heading: "Your move", Commands: t.commands(name)} return e } func (t *crapsTable) settlementEvent(p *crapsPlayer, lines []settlementLine, failed bool) Event { diff --git a/internal/casino/craps_variants.go b/internal/casino/craps_variants.go new file mode 100644 index 0000000..b3be51b --- /dev/null +++ b/internal/casino/craps_variants.go @@ -0,0 +1,184 @@ +package casino + +import "fmt" + +// Variants are explicit house editions; regional games with the same names +// need not use these rules. Random sources never affect wallet/queue handling. +func (c CrapsConfig) variant() string { + if c.Variant == "" { + return "bank" + } + return c.Variant +} +func (c CrapsConfig) title() string { + switch c.variant() { + case "simplified": + return "Simplified Craps" + case "high_point": + return "High Point Craps" + case "crapless": + return "Crapless Craps" + case "new_york": + return "New York Craps" + case "die_rich": + return "Die Rich Craps" + case "diceless": + return "Diceless Craps" + default: + return "Bank Craps" + } +} +func (c CrapsConfig) allows(kind string) bool { + switch c.variant() { + case "simplified", "high_point", "die_rich": + return kind == "pass" + case "new_york": + return kind == "pass" || kind == "dontpass" || kind == "buy" || kind == "lay" || kind == "odds" + case "crapless": + return kind != "dontpass" && kind != "dontcome" && kind != "fire" && kind != "small" && kind != "tall" && kind != "all" + default: + return true + } +} +func (c CrapsConfig) pointNumber(n int) bool { + switch c.variant() { + case "simplified": + return false + case "high_point": + return n >= 4 && n <= 10 + case "die_rich": + return n >= 2 && n <= 5 + case "crapless": + return n >= 2 && n <= 12 && n != 7 + default: + return crapsNumber(n) + } +} +func (c CrapsConfig) number(n int) bool { + return crapsNumber(n) || (c.variant() == "crapless" && n >= 2 && n <= 12 && n != 7) +} +func crapsPlaceOdds(n int) (int, int) { + switch n { + case 2, 12: + return 11, 2 + case 3, 11: + return 11, 4 + case 4, 10: + return 9, 5 + case 5, 9: + return 7, 5 + default: + return 7, 6 + } +} +func (c CrapsConfig) outcome(kind string, n, a, b, point int) (bool, int, int) { + s := a + b + switch c.variant() { + case "simplified": + if s == 2 || s == 3 || s == 4 || s == 10 || s == 11 || s == 12 { + return true, 1, 1 + } + return true, -1, 1 + case "high_point": + if n == 0 { + if s == 2 || s == 3 { + return false, 0, 1 + } + if s >= 11 { + return true, 1, 1 + } + return false, 0, 1 + } + if s > n { + return true, 1, 1 + } + return true, -1, 1 + case "die_rich": + if n == 0 { + if s == 6 { + return true, 1, 1 + } + if s == 1 { + return true, -1, 1 + } + return false, 0, 1 + } + if s == n { + return true, 1, 1 + } + if s == 1 { + return true, -1, 1 + } + return false, 0, 1 + case "crapless": + if (kind == "pass" || kind == "come") && n == 0 { + if s == 7 { + return true, 1, 1 + } + return false, 0, 1 + } + } + return crapsOutcome(kind, n, a, b, point, c.Field12) +} + +// outcomeFor includes bonus progress without mutating it. Both previews and +// actual settlement call this before recording the new roll. +func (t *crapsTable) outcomeFor(bet *crapsBet, a, b int) (bool, int, int) { + s := a + b + switch bet.kind { + case "fire": + if t.point == 0 || s != 7 { + return false, 0, 1 + } + x := crapsFireProfit(len(t.made)) + if x == 0 { + x = -1 + } + return true, x, 1 + case "small", "tall", "all": + if s == 7 { + return true, -1, 1 + } + lo, hi := 2, 12 + if bet.kind == "small" { + hi = 6 + } + if bet.kind == "tall" { + lo = 8 + } + if !crapsBonusComplete(lo, hi, t.seen, s) { + return false, 0, 1 + } + if bet.kind == "all" { + return true, 175, 1 + } + return true, 34, 1 + default: + return t.Config.Craps.outcome(bet.kind, bet.number, a, b, t.point) + } +} +func (c CrapsConfig) variantRulesText() string { + rules := "" + switch c.variant() { + case "simplified": + rules = "Pass only, one roll: 2/3/4/10/11/12 win 1:1; 5/6/7/8/9 lose. No point. A loss passes the dice." + case "high_point": + rules = "Pass only, 1:1. Come-out 2/3 roll again, 11/12 win; 4-10 establish a point. The very next roll wins only if HIGHER than the point; equal/lower loses. Loss passes the dice." + case "die_rich": + rules = "Pass only, ONE six-sided die, 1:1. Come-out 6 wins, 1 loses; 2-5 establish a point. Repeat the point before 1 to win; other faces roll again. Any 1 passes the die. No free odds." + case "new_york": + rules = "House New York: bank pass/dontpass (bar 12), free odds, buy and lay only. No come, place, field, propositions, hardways or bonuses. Buy/lay use true odds with 5% commission on buy stake / lay profit, rounded up, wins only. Buy is off on come-out; lay works." + case "crapless": + rules = "Come-out 7 wins; ALL other totals become points, including 2/3/11/12. Pass/come only, no don't bets or bonuses. All bank side bets supported. Extra-number place: 2/12 pay 11:2; 3/11 pay 11:4. Buy and free odds: 2/12 pay 6:1; 3/11 pay 3:1. Lay pays inverse true odds. Default extra-number odds limit: 1x stake." + } + if c.variant() == "new_york" || c.variant() == "crapless" { + rules += "\nTrue odds on 4/10: 2:1; 5/9: 3:2; 6/8: 6:5. Lay and don't odds invert these ratios. Buy/lay commission: 5% buy STAKE / lay PROFIT, rounded up, on wins only; removals have no commission." + } + if c.variant() == "crapless" { + rules += fmt.Sprintf("\nPass/come pay 1:1 on their point before 7; 7 loses. Line bets only on come-out; come requires a table point.\nTargets: pass / come / odds pass / odds come <number> / place <number> / buy <number> / lay <number>.\nPlace 4/10 pays 9:5; 5/9 7:5; 6/8 7:6.\nfield: 2 pays 2:1, 12 pays %d:1, 3/4/9/10/11 pay 1:1; others lose.\nhard 4/10 pays 7:1; hard 6/8 pays 9:1; easy number or 7 loses.\nseven 4:1; craps 7:1; eleven / ace-deuce 15:1; aces / boxcars 30:1.\nhorn splits equally among 2/3/11/12; hop <a> <b> pays 15:1 easy / 30:1 double, unordered.\nPlace/buy/hardways and come odds are off on come-out; established come contracts and lay always work.\nNo Fire or ATS. Making a point retains the shooter; seven-out rotates.", c.Field12) + } + if c.variant() == "new_york" { + rules += "\nPass come-out 7/11 wins, 2/3/12 loses; 4/5/6/8/9/10 establish a point. Make it before 7 to win, all 1:1. Don't pass is opposite, except come-out 12 pushes. Line bets only on come-out. Making a point retains the shooter; seven-out rotates." + } + return fmt.Sprintf("%s — house rules\n%s\nBet: bet <amount> on pass; table rules is authoritative for this edition.\nFree odds: %d (0 = 3-4-5x on ordinary points, don't 6x laid). No odds on Simplified, High Point or Die Rich.\nEach wager obeys table limits and exact payout units. Wins return stake and remove the bet.\nPass/come contracts cannot be removed; optional bets/odds can: unbet <target> / unbet all.\nSolo roll anytime; multiplayer waits 10 seconds, then dealer rolls after 60 seconds.\nBets stay open until the roll. autoroll repeats every 3 seconds, respecting the betting window.\nstop cancels autoroll; manual stop/sitout waits for live bets. Disconnect forfeits; shutdown refunds.", c.title(), rules, c.Odds) +} diff --git a/internal/casino/craps_variants_test.go b/internal/casino/craps_variants_test.go new file mode 100644 index 0000000..687a496 --- /dev/null +++ b/internal/casino/craps_variants_test.go @@ -0,0 +1,308 @@ +package casino + +import ( + "fmt" + "strings" + "testing" +) + +var crapsVariants = []string{"bank", "simplified", "high_point", "crapless", "new_york", "die_rich", "diceless"} + +func variantFixture(v string) *crapsTable { + t := crapsFixture() + t.Config.Craps.Variant = v + return t +} +func TestCrapsVariantValidationAndMenus(t *testing.T) { + for _, v := range crapsVariants { + tb := variantFixture(v) + if err := tb.Config.Validate(); err != nil { + t.Fatal(v, err) + } + if !strings.Contains(tb.Config.CrapsRulesText(), tb.Config.Craps.title()) { + t.Fatal(v, "missing title") + } + if tb.view().Variant != v { + t.Fatal(v, "snapshot") + } + } + for _, v := range []string{"simplified", "high_point", "die_rich"} { + tb := variantFixture(v) + tb.Config.Craps.Odds = 2 + if tb.Config.Validate() == nil { + t.Fatal(v, "accepted unsupported odds") + } + } +} +func TestVariantAllOutcomes(t *testing.T) { + for _, v := range crapsVariants { + c := CrapsConfig{Variant: v, Field12: 2} + for n := 0; n <= 12; n++ { + if n != 0 && !c.pointNumber(n) { + continue + } + for a := 1; a <= 6; a++ { + for b := 0; b <= 6; b++ { + if (v == "die_rich") != (b == 0) { + continue + } + s := a + b + done, profit := false, 0 + switch v { + case "simplified": + done = true + profit = -1 + if s <= 4 || s >= 10 { + profit = 1 + } + case "high_point": + if n == 0 { + done = s >= 11 + profit = 1 + } else { + done = true + profit = -1 + if s > n { + profit = 1 + } + } + case "die_rich": + done = s == 1 || (n == 0 && s == 6) || (n != 0 && s == n) + profit = 1 + if s == 1 { + profit = -1 + } + case "crapless": + if n == 0 { + done = s == 7 + profit = 1 + } else { + done = s == 7 || s == n + profit = 1 + if s == 7 { + profit = -1 + } + } + default: + if n == 0 { + done = s == 2 || s == 3 || s == 7 || s == 11 || s == 12 + profit = -1 + if s == 7 || s == 11 { + profit = 1 + } + } else { + done = s == n || s == 7 + profit = 1 + if s == 7 { + profit = -1 + } + } + } + got, x, y := c.outcome("pass", n, a, b, n) + if got != done || (done && (x != profit || y != 1)) { + t.Fatalf("%s point %d roll %d,%d: %v %d/%d want %v %d", v, n, a, b, got, x, y, done, profit) + } + } + } + } + } +} +func TestVariantBetRestrictionsNoDebit(t *testing.T) { + for _, v := range crapsVariants { + for _, spot := range []string{"pass", "dontpass", "come", "dontcome", "field", "place 6", "buy 4", "lay 10", "hard 8", "hop 2 3", "seven", "craps", "eleven", "ace-deuce", "aces", "boxcars", "horn", "fire", "small", "tall", "all", "place 2", "buy 11", "lay 12"} { + tb := variantFixture(v) + tb.join("a") + w := &testWallet{chips: 10000} + kind, n, _ := parseCrapsTarget(spot) + if kind == "come" || kind == "dontcome" { + tb.point = 6 + } + allowed := tb.Config.Craps.allows(kind) + if kind == "place" || kind == "buy" || kind == "lay" { + allowed = allowed && tb.Config.Craps.number(n) + } + tb.bet("a", spot, 120, w) + if (len(w.open) > 0) != allowed { + t.Fatal(v, spot, "acceptance", allowed) + } + if !allowed && w.chips != 10000 { + t.Fatal("rejected wager debited") + } + } + } +} +func TestCraplessExtraPointOddsPayouts(t *testing.T) { + for _, n := range []int{2, 3, 11, 12} { + t.Run(fmt.Sprint(n), func(t *testing.T) { + tb := variantFixture("crapless") + tb.join("a") + w := &testWallet{chips: 1000} + a, b := 1, n-1 + if n > 7 { + a, b = n-6, 6 + } + tb.bet("a", "pass", 10, w) + tb.resolveRoll(a, b) + if tb.point != n || tb.players.get("a").bets[0].number != n { + t.Fatal("point not established") + } + tb.bet("a", "odds pass", 11, w) + if len(w.open) != 1 { + t.Fatal("extra point default odds limit") + } + tb.bet("a", "odds pass", 10, w) + projection := tb.projectionFor(tb.players.get("a")) + x, _ := crapsTrueOdds(n) + want := 20 + 10*(x+1) + for _, r := range projection { + if r.Total == n && r.Net != want-20 { + t.Fatal("preview", r) + } + } + tb.resolveRoll(a, b) + if w.paid != want || len(w.open) != 0 || tb.point != 0 { + t.Fatal(w.paid, want, tb.point) + } + }) + } + for _, c := range []struct { + spot string + amount, a, b, want int + }{ + {"place 2", 2, 1, 1, 13}, {"place 3", 4, 1, 2, 15}, {"place 11", 4, 5, 6, 15}, {"place 12", 2, 6, 6, 13}, + {"buy 2", 20, 1, 1, 139}, {"buy 11", 20, 5, 6, 79}, {"lay 12", 120, 3, 4, 139}, {"lay 3", 60, 3, 4, 79}, + } { + tb := variantFixture("crapless") + tb.point = 6 + tb.join("a") + w := &testWallet{chips: 1000} + tb.bet("a", c.spot, c.amount, w) + tb.resolveRoll(c.a, c.b) + if w.paid != c.want { + t.Fatal(c.spot, w.paid, c.want) + } + } +} +func TestVariantShooterLifecycleAndWallets(t *testing.T) { + for _, v := range crapsVariants { + t.Run(v, func(t *testing.T) { + tb := variantFixture(v) + tb.join("a") + tb.join("b") + w := &testWallet{chips: 1000} + tb.bet("a", "pass", 10, w) + a, b := 2, 2 + if v == "die_rich" { + a, b = 4, 0 + } + if v == "simplified" { + a, b = 2, 3 + } + tb.resolveRoll(a, b) + if v != "simplified" { + if tb.point != 4 { + t.Fatal("point", tb.point) + } + a, b = 3, 4 + if v == "high_point" { + a, b = 1, 2 + } + if v == "die_rich" { + a, b = 1, 0 + } + tb.resolveRoll(a, b) + } + if tb.point != 0 || tb.queue[0] != "b" || len(w.open) != 0 || w.chips != 990 { + t.Fatal("loss lifecycle", tb.point, tb.queue, w) + } + tb.bet("b", "pass", 10, w) + tb.refundAll() + tb.refundAll() + if w.chips != 990 || len(w.open) != 0 { + t.Fatal("shutdown refund") + } + tb.bet("b", "pass", 10, w) + tb.markDisconnected("b") + if w.chips != 980 || len(w.open) != 0 { + t.Fatal("disconnect") + } + }) + } +} +func TestHighPointAndDieRichPointWin(t *testing.T) { + for _, v := range []string{"high_point", "die_rich"} { + tb := variantFixture(v) + tb.join("a") + w := &testWallet{chips: 100} + tb.bet("a", "pass", 10, w) + if v == "high_point" { + tb.resolveRoll(2, 2) + tb.resolveRoll(2, 3) + } else { + tb.resolveRoll(4, 0) + tb.resolveRoll(6, 0) + if tb.point != 4 { + t.Fatal("six must not win established point") + } + tb.resolveRoll(4, 0) + } + if w.paid != 20 || tb.point != 0 || tb.queue[0] != "a" { + t.Fatal(v, w.paid, tb.point) + } + } +} +func TestCrapsBonusPreviewMatchesSettlement(t *testing.T) { + for _, point := range []int{0, 6} { + for a := 1; a <= 6; a++ { + for b := a; b <= 6; b++ { + tb := crapsFixture() + tb.join("a") + w := &testWallet{chips: 1000} + for _, spot := range []string{"fire", "small", "tall", "all"} { + tb.bet("a", spot, 1, w) + } + tb.point = point + tb.made = map[int]bool{4: true, 5: true, 6: true, 8: true} + tb.seen = map[int]bool{2: true, 3: true, 4: true, 5: true, 8: true, 9: true, 10: true, 11: true, 12: true} + // Four made points must NOT advertise Fire profit except at seven-out. + for _, row := range tb.projectionFor(tb.players.get("a")) { + if row.Rolls != fmt.Sprintf("%d+%d", a, b) { + continue + } + tb.resolveRoll(a, b) + // Net = wallet change excluding returned unresolved commitments. + live := 0 + for _, bet := range tb.players.get("a").bets { + live += bet.stake.Total + } + actual := w.paid - (4 - live) + if row.Net != actual { + t.Fatalf("point %d roll %d+%d: preview %d actual %d", point, a, b, row.Net, actual) + } + break + } + } + } + } +} +func TestVariantRandomSources(t *testing.T) { + for _, v := range []string{"diceless", "die_rich"} { + tb := variantFixture(v) + seen := map[[2]int]bool{} + for i := 0; i < 2000; i++ { + tb.roll() + d := tb.dice + seen[d] = true + if d[0] < 1 || d[0] > 6 || (v == "die_rich" && d[1] != 0) || (v == "diceless" && (d[1] < 1 || d[1] > 6)) { + t.Fatal(v, d) + } + } + want := 36 + if v == "die_rich" { + want = 6 + } + if len(seen) != want { + t.Fatal(v, "source support", len(seen)) + } + } +} diff --git a/internal/casino/machine.go b/internal/casino/machine.go index f8730be..36576d0 100644 --- a/internal/casino/machine.go +++ b/internal/casino/machine.go @@ -499,19 +499,25 @@ func formatWins(wins []SlotWin, ways int) string { return message[:len(message)-1] } -var slotCommands = []MenuCommand{ - {Command: "bet <amount>", Desc: "Set your wager"}, - {Command: "bet max", Desc: "Set the maximum wager"}, - {Command: "spin", Desc: "Spin using your current wager"}, - {Command: "autospin", Desc: "Start automatic betting"}, - {Command: "stop", Desc: "Leave the slot machine"}, +func (m *Machine) commands() []MenuCommand { + if m.autoBet { + return []MenuCommand{{Command: "stop", Desc: "Stop automatic betting"}} + } + commands := []MenuCommand{ + {Command: "bet <amount>", Desc: "Set your wager and spin"}, + {Command: "bet max", Desc: "Bet the maximum and spin"}, + } + if m.bet > 0 { + commands = append(commands, MenuCommand{Command: "spin", Desc: "Spin using your current wager", Cost: m.bet}, MenuCommand{Command: "autospin", Desc: "Start automatic betting", Cost: m.bet}) + } + return append(commands, MenuCommand{Command: "stop", Desc: "Leave the slot machine"}) } func (m *Machine) privateMenu(prefix string) []Event { events := m.private(prefix) events[0].Color = "casino_menu" if m.phase == machineReady { - events[0].Menu = &MenuView{Heading: prefix, ShowBalance: true, Commands: slotCommands} + events[0].Menu = &MenuView{Heading: prefix, ShowBalance: true, Commands: m.commands()} } return events } diff --git a/internal/casino/roulette.go b/internal/casino/roulette.go index b4dcdf0..2453fed 100644 --- a/internal/casino/roulette.go +++ b/internal/casino/roulette.go @@ -490,11 +490,19 @@ func (t *rouletteTable) view() *RouletteSnapshot { return s } func (t *rouletteTable) menuEvent(name string) Event { - e := t.betMenuEvent(name, []MenuCommand{ - {Command: "bet <amount> on <target>", Desc: "Place one wager per spin (help roulette)"}, - {Command: "bet / rebet", Desc: "Repeat your previous wager"}, - {Command: "table / sitout / stop", Desc: "View layout / sit out / leave after this spin"}, - }) + commands := []MenuCommand{} + p := t.players.get(name) + if t.phase != phasePlaying && p != nil && !p.HasBet { + commands = append(commands, MenuCommand{Command: "bet <amount> on <target>", Desc: "Place one wager; targets: help roulette"}) + if p.lastBet > 0 { + commands = append(commands, MenuCommand{Command: "bet / rebet", Desc: "Repeat your previous wager"}) + } + } + if p != nil { + commands = append(commands, MenuCommand{Command: "sitout / stop", Desc: "Sit out / leave when your wager settles"}) + } + commands = append(commands, MenuCommand{Command: "table", Desc: "View the layout"}) + e := t.betMenuEvent(name, commands) e.Snapshot = t.view() return e } @@ -701,8 +709,10 @@ func (t *rouletteTable) status(name string) []Event { if text := t.seatStatusLocked(name); text != "" { message += "\n" + text } - e := t.private(name, message) - e.Snapshot = t.view() + e := t.menuEvent(name) + e.Message = message + e.Menu.Heading = message + e.Menu.ShowBalance = false events := []Event{e} if t.phase == phaseWaiting && t.lastResult != nil { last := t.private(name, "Last spin:") diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go index 9590baf..f4255be 100644 --- a/internal/casino/table_ux.go +++ b/internal/casino/table_ux.go @@ -79,6 +79,18 @@ func (c *tableCore[P]) warning(name, text string) Event { return e } +// committedMenu never advertises another bet over an already committed wager. +func (c *tableCore[P]) committedMenu(name string) Event { + e := c.private(name, "Your wager is committed; waiting for the round.") + e.Color = "casino_menu" + e.Menu = &MenuView{Heading: e.Message, Commands: []MenuCommand{ + {Command: "table", Desc: "View the table"}, + {Command: "sitout", Desc: "Sit out after this round"}, + {Command: "stop", Desc: "Leave after this round"}, + }} + return e +} + // controlLocked queues departures without changing committed hands or stakes. func (c *tableCore[P]) controlLocked(name, action string) ([]Event, bool) { if action != "leave_after" && action != "sitout" { diff --git a/internal/game/casino.go b/internal/game/casino.go index e9334fc..c2c4c4f 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -695,18 +695,8 @@ func (g *Game) casinoPrivateMessage(sess *net.Session, category string, menu *ca commands[i].Desc += " [unavailable: insufficient funds]" } } - if sess.Player.OptionString("casino_display") == "compact" && menu.Repeat { - var repeat string - for _, c := range commands { - if c.Command == "bet" { - repeat = g.colorize(sess, "command", "bet / rebet") + " " + c.Desc - break - } - } - return g.colorize(sess, "casino_action", heading) + "\n" + balance + "\n " + repeat + "\n " + g.colorize(sess, "command", "bet <amount> [target] | bet min/max | table | sitout | stop") - } if sess.Player.OptionString("casino_display") == "compact" && !menu.ShowBalance { - var hints []string + var hints []casino.MenuCommand for _, c := range commands { label := c.Command switch c.Command { @@ -722,14 +712,9 @@ func (g *Game) casinoPrivateMessage(sess *net.Session, category string, menu *ca if c.Cost > 0 { label += fmt.Sprintf(" (+%d)", c.Cost) } - category := "command" - if c.Cost > casinoAffordable(sess.Player) { - label += " [unavailable]" - category = "casino_warning" - } - hints = append(hints, g.colorize(sess, category, label)) + hints = append(hints, casino.MenuCommand{Command: label, Cost: c.Cost}) } - return g.colorize(sess, "casino_action", heading) + "\n " + strings.Join(hints, " ") + return g.renderCasinoCommands(sess, balance, heading, hints) } return g.renderCasinoCommands(sess, balance, heading, commands) } @@ -796,27 +781,7 @@ func (g *Game) casinoMenuHeader(sess *net.Session, balance, heading string) stri // casinoMenuCommands formats an aligned, colored command list. func (g *Game) casinoMenuCommands(sess *net.Session, commands []casino.MenuCommand) string { - width := 0 - for _, c := range commands { - if n := utf8.RuneCountInString(c.Command); n > width { - width = n - } - } - var sb strings.Builder - for _, c := range commands { - padded := c.Command + strings.Repeat(" ", width-utf8.RuneCountInString(c.Command)) - category := "command" - if sess.Player != nil && c.Cost > casinoAffordable(sess.Player) { - category = "casino_warning" - } - cmd := g.colorize(sess, category, padded) - if c.Desc != "" { - fmt.Fprintf(&sb, " %s %s\n", cmd, c.Desc) - } else { - fmt.Fprintf(&sb, " %s\n", cmd) - } - } - return strings.TrimRight(sb.String(), "\n") + return g.casinoCommandColumns(sess, commands) } func renderBlackjackSnapshotFor(snapshot *casino.BlackjackSnapshot, mode string, unicode bool, size int, style string, art *cardart.Store, viewer string) string { diff --git a/internal/game/casino_commands.go b/internal/game/casino_commands.go new file mode 100644 index 0000000..f4ca79f --- /dev/null +++ b/internal/game/casino_commands.go @@ -0,0 +1,83 @@ +package game + +import ( + "strings" + "unicode/utf8" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/net" +) + +// Wrap before Session.WriteLine: generic prose wrapping strips alignment from +// overlong lines. Every casino menu uses the same two-space command indent and +// hanging description column, capped at 80 even when prose wrapping is off. +func (g *Game) casinoCommandColumns(sess *net.Session, commands []casino.MenuCommand) string { + limit := 80 + if sess.Player != nil { + if wrap := sess.Player.OptionInt("wrap_width"); wrap > 0 { + limit = min(limit, wrap) + } + } + width := 0 + for _, c := range commands { + n := utf8.RuneCountInString(c.Command) + // Unusually long commands get their own lines instead of squeezing every + // other command's description into a tiny column. + if n <= limit/2 { + width = max(width, n) + } + } + var lines []string + for _, c := range commands { + if sess.Player != nil && c.Cost > casinoAffordable(sess.Player) { + continue + } + n := utf8.RuneCountInString(c.Command) + if n > limit/2 { + for _, line := range casinoWrapWords(c.Command, max(1, limit-2)) { + lines = append(lines, " "+g.colorize(sess, "command", line)) + } + for _, line := range casinoWrapWords(c.Desc, max(1, limit-4)) { + lines = append(lines, " "+line) + } + continue + } + if strings.TrimSpace(c.Desc) == "" { + lines = append(lines, " "+g.colorize(sess, "command", c.Command)) + continue + } + indent := width + 4 + desc := casinoWrapWords(c.Desc, max(1, limit-indent)) + cmd := c.Command + strings.Repeat(" ", width-n) + lines = append(lines, " "+g.colorize(sess, "command", cmd)+" "+desc[0]) + for _, line := range desc[1:] { + lines = append(lines, strings.Repeat(" ", indent)+line) + } + } + return strings.Join(lines, "\n") +} + +// Input is uncolored command/description text; paint only after measuring. +func casinoWrapWords(text string, width int) []string { + var lines []string + line := "" + for _, word := range strings.Fields(text) { + if line != "" && utf8.RuneCountInString(line)+1+utf8.RuneCountInString(word) > width { + lines = append(lines, line) + line = "" + } + runes := []rune(word) + for len(runes) > width { + lines = append(lines, string(runes[:width])) + runes = runes[width:] + } + if line != "" { + line += " " + } + line += string(runes) + } + if line != "" { + lines = append(lines, line) + } + return lines +} diff --git a/internal/game/casino_commands_test.go b/internal/game/casino_commands_test.go new file mode 100644 index 0000000..7820650 --- /dev/null +++ b/internal/game/casino_commands_test.go @@ -0,0 +1,118 @@ +package game + +import ( + "strings" + "testing" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func TestCasinoCommandsWrapBeforeSessionWithoutLosingIndent(t *testing.T) { + commands := []casino.MenuCommand{ + {Command: "bet <amount> on <target>", Desc: "Place bets until the dice roll; table rules lists targets"}, + {Command: "roll / autoroll / stop", Desc: "Shoot / repeat on your turn / return to manual play"}, + {Command: "unbet <target> / unbet all", Desc: "Remove one bet / every optional bet"}, + {Command: "table", Desc: "View the felt"}, + {Command: "insurance", Desc: "Buy insurance; a deliberately long description which requires a hanging continuation line"}, + {Command: "decline", Desc: "Continue without insurance (also the timeout default)"}, + {Command: "bet <amount> on player/banker/tie/player_pair/banker_pair", Desc: "One wager per round; 'on' is optional"}, + {Command: "option casino_display compact", Desc: "Compact action hints (detailed for full command menus)"}, + {Command: "spin", Desc: "Spin using your current wager"}, + {Command: "autospin", Desc: "Start automatic betting"}, + {Command: "unicode", Desc: strings.Repeat("é", 100)}, + } + for _, wrap := range []int{0, 40, 60, 80, 120} { + p := player.New("a") + p.Options = map[string]any{"wrap_width": wrap} + conn := &casinoTestConn{} + sess := &net.Session{Player: p, Conn: conn} + g := &Game{} + text := g.casinoMenuCommands(sess, commands) + sess.WriteLine(text) + limit := 80 + if wrap > 0 { + limit = min(limit, wrap) + } + for _, line := range strings.Split(strings.ReplaceAll(plainRoulette(string(conn.data)), "\r\n", "\n"), "\n") { + if line == "" { + continue + } + if color.VisibleLen(line) > limit { + t.Fatalf("wrap %d overlong %d: %q", wrap, color.VisibleLen(line), line) + } + if !strings.HasPrefix(line, " ") { + t.Fatalf("wrap %d lost indent: %q", wrap, line) + } + } + expected := strings.ReplaceAll(plainRoulette(text), "\r\n", "\n") + actual := strings.TrimRight(strings.ReplaceAll(plainRoulette(string(conn.data)), "\r\n", "\n"), "\n") + if expected != actual { + t.Fatalf("session reflowed prewrapped menu at %d", wrap) + } + } +} +func TestCasinoCommandsHangingIndentAndShortDescription(t *testing.T) { + sess := &net.Session{Player: player.New("a")} + g := &Game{} + commands := []casino.MenuCommand{ + {Command: "bet <amount> on <target>", Desc: "Place a wager; targets: table rules"}, + {Command: "table", Desc: "View the felt"}, + } + text := plainRoulette(g.casinoMenuCommands(sess, commands)) + lines := strings.Split(text, "\n") + if len(lines) != 2 { + t.Fatal("short commands should not wrap", text) + } + col := strings.Index(lines[0], "Place") + if col != strings.Index(lines[1], "View") { + t.Fatal("descriptions not aligned", text) + } + commands[0].Desc = strings.Repeat("long description ", 10) + text = plainRoulette(g.casinoMenuCommands(sess, commands)) + lines = strings.Split(text, "\n") + for _, line := range lines[1 : len(lines)-1] { + if !strings.HasPrefix(line, strings.Repeat(" ", col)) { + t.Fatal("missing hanging indent", text) + } + } +} +func TestCasinoCompactHintsUseOnlyAvailableCommands(t *testing.T) { + p := player.New("a") + p.Options = map[string]any{"casino_display": "compact"} + p.Credits = 10 + sess := &net.Session{Player: p} + g := &Game{} + menu := &casino.MenuView{Heading: "Your turn", Commands: []casino.MenuCommand{ + {Command: "hit", Desc: "Take a card"}, {Command: "stand", Desc: "Hold"}, {Command: "double", Cost: 20}, + }} + text := plainRoulette(g.casinoPrivateMessage(sess, "casino_menu", menu, "")) + if strings.Contains(text, "double") || !strings.Contains(text, " h:hit") || !strings.Contains(text, " st:stand") { + t.Fatal(text) + } + menu.ShowBalance = true + menu.Repeat = true + menu.Commands = []casino.MenuCommand{{Command: "table", Desc: "View the felt"}} + text = plainRoulette(g.casinoPrivateMessage(sess, "casino_menu", menu, "")) + if strings.Contains(text, "bet") || strings.Contains(text, "sitout") { + t.Fatal("compact mode invented actions", text) + } +} +func TestSimplifiedFeltSpacingAndNoIdlePrompt(t *testing.T) { + text := renderCrapsSnapshot(&casino.CrapsSnapshot{Variant: "simplified"}, "none", true) + if strings.Contains(text, "Place a bet, then roll when ready.") { + t.Fatal(text) + } + rows := strings.Split(text, "\n") + for i, row := range rows { + if strings.Contains(row, "PASS ONLY") { + if i+2 >= len(rows) || strings.Trim(rows[i+1], "│ ") != "" || !strings.HasPrefix(rows[i+2], "└") { + t.Fatal("expected exactly one blank felt row", text) + } + return + } + } + t.Fatal("missing pass label") +} diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go index 450480f..a2e7330 100644 --- a/internal/game/casino_ux.go +++ b/internal/game/casino_ux.go @@ -129,20 +129,14 @@ func (g *Game) executeTableStatus(sess *net.Session, args []string, _ string) { } } if len(args) > 0 { + // Reuse the game's current-state menu instead of advertising actions + // (insurance, split, rebet, etc.) that may not be legal right now. + g.emitCasinoEvents(t.Status(sess.Player.Name)) commands := []casino.MenuCommand{ - {Command: "bet / rebet", Desc: "Repeat your previous wager"}, - {Command: "bet min / bet max", Desc: "Use the table minimum / affordable maximum"}, - {Command: "sitout", Desc: "Skip future rounds; bet to rejoin"}, - {Command: "stop", Desc: "Leave now, or after your committed round"}, {Command: "option casino_display compact", Desc: "Compact action hints (detailed for full command menus)"}, {Command: "table rules", Desc: "Review this table’s rules"}, {Command: "help casino", Desc: "Quick guides to all casino games"}, } - if t.Config.Game == casino.GameBlackjack { - commands = append(commands, - casino.MenuCommand{Command: "h / st / dd / sp", Desc: "Hit / stand / double / split (s and d still move)"}, - casino.MenuCommand{Command: "insurance / decline", Desc: "Answer the insurance offer explicitly"}) - } sess.WriteLine(g.renderCasinoCommands(sess, "", "Table controls", commands)) } } diff --git a/internal/game/craps_variants_test.go b/internal/game/craps_variants_test.go new file mode 100644 index 0000000..76d1424 --- /dev/null +++ b/internal/game/craps_variants_test.go @@ -0,0 +1,103 @@ +package game + +import ( + "path/filepath" + "strings" + "testing" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/world" +) + +func TestCrapsVariantRendering(t *testing.T) { + for _, v := range []string{"simplified", "high_point", "crapless", "new_york", "die_rich", "diceless"} { + for _, unicode := range []bool{false, true} { + for _, mode := range []string{"none", "xterm256"} { + for _, style := range []string{"dark", "light"} { + dice := [2]int{4, 2} + if v == "die_rich" { + dice[1] = 0 + } + text := renderCrapsSnapshot(&casino.CrapsSnapshot{Variant: v, Point: 4, Dice: dice}, mode, unicode, style) + plain := plainRoulette(text) + if !strings.Contains(plain, strings.ToUpper(strings.ReplaceAll(v, "_", " "))+" CRAPS") { + t.Fatal(v, "title") + } + if v == "diceless" { + for _, token := range []string{"PACK A", "PACK B", "Cards total: 6", "FIRE"} { + if !strings.Contains(plain, token) { + t.Fatal(v, token) + } + } + if strings.Contains(plain, "Total:") { + t.Fatal("dice rendered instead of cards") + } + } else { + if strings.Contains(plain, "FIRE") || strings.Contains(plain, "ALL SMALL") { + t.Fatal(v, "unsupported bonus display") + } + } + if v == "die_rich" && !strings.Contains(plain, "Total: 4") { + t.Fatal("single die absent") + } + if mode == "xterm256" && (v == "die_rich" || v == "diceless") { + bg := 0 + if style == "light" { + bg = 15 + } + if !strings.Contains(text, color.BgCode(mode, bg)) { + t.Fatal(v, "style") + } + } + } + } + } + } + for n := 1; n <= 6; n++ { + text := renderCrapsDice([2]int{n, 0}, "none", false, "dark") + if strings.Count(text, "+-------+") != 2 { + t.Fatal("must render exactly one die", text) + } + } +} + +func TestShippedCrapsGallery(t *testing.T) { + w := world.New(filepath.Join("..", "..", "data")) + variants := []string{"bank", "simplified", "high_point", "crapless", "new_york", "die_rich", "diceless"} + for i, v := range variants { + id := 6185 + i + room, err := w.LoadRoom(id) + if err != nil { + t.Fatal(err) + } + if len(room.CasinoTables) != 1 { + t.Fatal(id, "missing table") + } + cfg := room.CasinoTables[0] + if cfg.Game != casino.GameCraps || cfg.Craps.Variant != v { + t.Fatal(id, cfg) + } + if err := cfg.Validate(); err != nil { + t.Fatal(id, err) + } + if i > 0 && room.Exits["south"].Room != id-1 { + t.Fatal(id, "missing return exit") + } + if i < len(variants)-1 && room.Exits["north"].Room != id+1 { + t.Fatal(id, "unreachable next table") + } + m := casino.NewManager(1) + m.Join(id, cfg, "a") + if m.Table(id, cfg.ID) == nil { + t.Fatal(id, "table registration") + } + } + room, err := w.LoadRoom(6182) + if err != nil { + t.Fatal(err) + } + if room.Exits["west"].Room != 6185 { + t.Fatal("gallery unreachable from roulette") + } +} diff --git a/internal/game/render_craps.go b/internal/game/render_craps.go index 9f73b20..2270659 100644 --- a/internal/game/render_craps.go +++ b/internal/game/render_craps.go @@ -264,7 +264,7 @@ func renderCrapsFelt(s *casino.CrapsSnapshot, mode string, unicode bool) string return c.render(mode, unicode) } func renderCrapsDice(dice [2]int, mode string, unicode bool, style string) string { - if dice[0] < 1 || dice[0] > 6 || dice[1] < 1 || dice[1] > 6 { + if dice[0] < 1 || dice[0] > 6 || dice[1] < 0 || dice[1] > 6 { return "" } patterns := [][3]string{{}, {" ", " o ", " "}, {"o ", " ", " o"}, {"o ", " o ", " o"}, {"o o", " ", "o o"}, {"o o", " o ", "o o"}, {"o o", "o o", "o o"}} @@ -280,6 +280,9 @@ func renderCrapsDice(dice [2]int, mode string, unicode bool, style string) strin for row := 0; row < 5; row++ { out.WriteString(" ") for i, n := range dice { + if n == 0 { + continue + } line := top if row == 4 { line = bottom @@ -315,21 +318,51 @@ func renderCrapsSnapshot(s *casino.CrapsSnapshot, mode string, unicode bool, sty if s.Point != 0 { point = fmt.Sprintf("POINT %d", s.Point) } - out.WriteString("\n" + title(" BANK CRAPS") + subtle(" / "+point) + "\n") - out.WriteString(renderCrapsTrackers(s, mode, unicode)) - out.WriteByte('\n') - out.WriteString(renderCrapsFelt(s, mode, unicode)) + variant := s.Variant + if variant == "" { + variant = "bank" + } + out.WriteString("\n" + title(" "+strings.ToUpper(strings.ReplaceAll(variant, "_", " "))+" CRAPS") + subtle(" / "+point) + "\n") + if variant == "bank" || variant == "diceless" { + out.WriteString(renderCrapsTrackers(s, mode, unicode)) + out.WriteByte('\n') + out.WriteString(renderCrapsFelt(s, mode, unicode)) + } else { + height := 7 + if variant == "simplified" || variant == "high_point" || variant == "die_rich" { + height = 6 + } + c := newCrapsCanvas(77, height) + c.box(0, 0, 76, height-1) + c.center(1, 75, 1, "HOUSE EDITION — table rules for payouts", crapsGold) + switch variant { + case "crapless": + c.center(1, 75, 3, "POINTS: 2 3 4 5 6 8 9 10 11 12 | PASS / COME / ODDS", crapsInk) + c.center(1, 75, 4, "PLACE / BUY / LAY / FIELD / HARD / HOP / PROPOSITIONS", crapsInk) + c.center(1, 75, 5, "NO DON'T BETS OR BONUSES", crapsMuted) + case "new_york": + c.center(1, 75, 3, "PASS / DON'T PASS (BAR 12) / ODDS / BUY / LAY", crapsInk) + c.center(1, 75, 4, "BUY / LAY: 4 5 6 8 9 10 | 5% WIN-ONLY COMMISSION", crapsMuted) + default: + c.center(1, 75, 3, "PASS ONLY | WINS PAY 1:1 | NO ODDS OR BONUSES", crapsInk) + } + out.WriteString(c.render(mode, unicode)) + } out.WriteByte('\n') - out.WriteString(renderCrapsDice(s.Dice, mode, unicode, style)) + if variant == "diceless" { + out.WriteString(renderCrapsCards(s.Dice, mode, unicode, style)) + } else { + out.WriteString(renderCrapsDice(s.Dice, mode, unicode, style)) + } if len(s.Queue) > 0 { out.WriteString(title(" Shooter ") + subtle(s.Queue[0]) + "\n") if len(s.Queue) > 1 { out.WriteString(subtle(" On deck "+strings.Join(s.Queue[1:], " > ")) + "\n") } } - hint := "Place a bet, then roll when ready." + hint := "" if s.Phase != "waiting" && s.Phase != "" { - hint = "Your pace. Roll when ready." + hint = "" if s.ShootIn > 0 { hint = fmt.Sprintf("Bets open. Shooter ready in %ds.", s.ShootIn) } else if s.DealerIn > 0 { @@ -337,8 +370,10 @@ func renderCrapsSnapshot(s *casino.CrapsSnapshot, mode string, unicode bool, sty } } if s.AutoRoll { - hint = fmt.Sprintf("AUTOROLL / Next roll in %ds / stop for manual play", s.AutoIn) + hint = fmt.Sprintf("AUTOROLL / Shooter's next roll in %ds", s.AutoIn) + } + if hint != "" { + out.WriteString(subtle(" "+hint) + "\n") } - out.WriteString(subtle(" "+hint) + "\n") return out.String() } diff --git a/internal/game/render_craps_cards.go b/internal/game/render_craps_cards.go new file mode 100644 index 0000000..343f16f --- /dev/null +++ b/internal/game/render_craps_cards.go @@ -0,0 +1,44 @@ +package game + +import ( + "fmt" + "strings" + "thehouseoficarus/internal/color" +) + +// The two packs have ranks 1-6, no suits. Distinct pack labels make the +// replacement model visible without suggesting a conventional poker shoe. +func renderCrapsCards(cards [2]int, mode string, unicode bool, style string) string { + if cards[0] < 1 || cards[0] > 6 || cards[1] < 1 || cards[1] > 6 { + return "" + } + top, bottom, bar := "+-------+", "+-------+", "|" + if unicode { + top, bottom, bar = "╭───────╮", "╰───────╯", "│" + } + spec := color.ColorSpec{Fg: 15, Bg: 0, Bold: true} + if style == "light" { + spec = color.ColorSpec{Fg: 0, Bg: 15, Bold: true} + } + var out strings.Builder + for row := 0; row < 5; row++ { + out.WriteString(" ") + for i, n := range cards { + line := top + switch row { + case 1: + line = fmt.Sprintf("%s PACK %c%s", bar, 'A'+i, bar) + case 2: + line = fmt.Sprintf("%s %d %s", bar, n, bar) + case 3: + line = bar + " " + bar + case 4: + line = bottom + } + out.WriteString(color.Render(mode, spec, line) + " ") + } + out.WriteByte('\n') + } + fmt.Fprintf(&out, " Cards total: %d | Both packs replaced and reshuffled each draw.\n", cards[0]+cards[1]) + return out.String() +} |
