From 2317022c16b520dd5272facfd5092a4c58cac44b Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Wed, 30 Sep 2026 17:49:28 -0400 Subject: fix: simplified simplified craps, removed unnecessary commands and instructions --- data/help/craps.yaml | 8 ++- internal/casino/command_hints_test.go | 18 +++-- internal/casino/craps.go | 22 +++++- internal/casino/craps_clock.go | 71 ++++++++++++++++++++ internal/casino/craps_simplified_test.go | 111 ++++++++++++++++++++++++++++++- internal/casino/craps_ux.go | 32 ++++++--- internal/casino/craps_variants.go | 27 +++++++- internal/game/casino.go | 6 +- internal/game/casino_commands_test.go | 24 ++++--- internal/game/render_craps.go | 15 ++++- internal/game/render_craps_paytable.go | 15 ++--- 11 files changed, 300 insertions(+), 49 deletions(-) diff --git a/data/help/craps.yaml b/data/help/craps.yaml index bb6e972..f5ce844 100644 --- a/data/help/craps.yaml +++ b/data/help/craps.yaml @@ -69,9 +69,11 @@ description: | be removed. Disconnecting forfeits live bets; shutdown refunds them. HOUSE EDITIONS (regional versions may differ): - Simplified: pass only, one roll of two dice. Net payouts: 2 pays 3:1; - 3/4 pay 1:1; 10/11 pay 2:1; 12 pays 5:1. Winners also get their stake - back. 5/6/7/8/9 lose. No point, odds or side bets. House edge: 2.78%. + Simplified: pass only, one roll of two dice. The dice roll automatically + 3 seconds after the first bet; there is no manual roll. Bet with + 'bet ' (always pass; a bare bet repeats your last wager). + 2 pays 3:1; 3/4 pay 1:1; 10/11 pay 2:1; 12 pays 5:1. 5/6/7/8/9 lose. + No point, odds or side bets. House edge: 2.78%. High Point: pass only, 1:1. Come-out 2/3 roll again; 11/12 win; 4-10 become the point. The next roll must EXCEED it to win. Die Rich: pass only, ONE die, 1:1. Come-out 6 wins; 1 loses; diff --git a/internal/casino/command_hints_test.go b/internal/casino/command_hints_test.go index c570e15..1de70bd 100644 --- a/internal/casino/command_hints_test.go +++ b/internal/casino/command_hints_test.go @@ -36,8 +36,22 @@ func TestCrapsHintsFollowPlayerState(t *testing.T) { t.Fatal(v, "idle", c) } } + if watch := tb.commands("watcher"); len(watch) != 1 || watch[0].Command != "table" { + t.Fatal(v, "spectator actions", watch) + } tb.bet("a", "pass", 10, w) commands = tb.commands("a") + if v == "simplified" { + // Simplified craps rolls by itself: no manual shooting actions + // while the pass contract is live. + if hintHas(commands, "roll") || hintHas(commands, "autoroll") || hintHas(commands, "unbet / unbet all") || hintBet(commands) { + t.Fatal(v, "manual actions", commands) + } + if !hintHas(commands, "stop") { + t.Fatal(v, "missing stop", commands) + } + continue + } if !hintHas(commands, "roll") || !hintHas(commands, "autoroll") || hintHas(commands, "unbet / unbet all") { t.Fatal(v, "contract actions", commands) } @@ -58,10 +72,6 @@ func TestCrapsHintsFollowPlayerState(t *testing.T) { 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) { diff --git a/internal/casino/craps.go b/internal/casino/craps.go index 410a372..e9b87ec 100644 --- a/internal/casino/craps.go +++ b/internal/casino/craps.go @@ -37,6 +37,7 @@ type crapsTable struct { roundElapsed time.Duration multiplayer bool lastCall bool + countdown int // simplified craps: automatic-roll countdown lines emitted } type CrapsBetView struct { Name, Target string @@ -73,9 +74,14 @@ func crapsTarget(b *crapsBet) string { } func (t *crapsTable) view() *CrapsSnapshot { 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) + if t.hasLiveBets() { + if t.Config.Craps.variant() == "simplified" { + // One automatic roll: the snapshot counts down the shared window. + s.ShootIn = crapsSeconds(crapsSimplifiedDelay - t.roundElapsed) + } else if t.multiplayer { + s.ShootIn = crapsSeconds(crapsBetDelay - t.roundElapsed) + s.DealerIn = crapsSeconds(crapsBetDelay + crapsDealerDelay - t.roundElapsed) + } } if p := t.shooter(); p != nil && p.autoRoll { s.AutoRoll = true @@ -116,6 +122,7 @@ func (t *crapsTable) join(name string) []Event { return append([]Event{e, t.menu(name)}, timing...) } func (t *crapsTable) remove(name string) { + t.countdown = 0 t.removeSeatLocked(name) for i, n := range t.queue { if n == name { @@ -126,6 +133,7 @@ func (t *crapsTable) remove(name string) { if t.players.count() == 0 { t.point = 0 t.rolled = false + t.countdown = 0 t.made = map[int]bool{} t.seen = map[int]bool{} t.phase = phaseWaiting @@ -186,6 +194,10 @@ func (t *crapsTable) bet(name, spot string, amount int, w Wallet) []Event { if spot == "" { spot = p.lastSpot } + if spot == "" && t.Config.Craps.variant() == "simplified" { + // Pass is the only target, so "bet " needs no target at all. + spot = "pass" + } spot = strings.ToLower(strings.TrimSpace(spot)) if amount < t.Config.MinBet || amount > t.Config.MaxBet { return reject("Craps bets must be " + betRangeText(t.Config.MinBet, t.Config.MaxBet) + ".") @@ -446,6 +458,9 @@ func (t *crapsTable) action(name, action string) []Event { events = append(events, t.finishCrapsControls()...) return append(events, t.syncRoundClock()...) } + if t.Config.Craps.variant() == "simplified" && (action == "roll" || action == "autoroll") { + return []Event{t.private(name, "Simplified craps rolls by itself 3 seconds after a bet; there is no manual roll.")} + } if action == "autoroll" { if !p.HasBet { return []Event{t.private(name, "Place a bet before enabling autoroll.")} @@ -616,6 +631,7 @@ func (t *crapsTable) refundAll() []Event { } t.point = 0 t.rolled = false + t.countdown = 0 t.made = map[int]bool{} t.seen = map[int]bool{} t.phase = phaseWaiting diff --git a/internal/casino/craps_clock.go b/internal/casino/craps_clock.go index f5cb690..bbba19e 100644 --- a/internal/casino/craps_clock.go +++ b/internal/casino/craps_clock.go @@ -9,6 +9,10 @@ const ( crapsBetDelay = 10 * time.Second crapsDealerDelay = 60 * time.Second crapsAutoDelay = 3 * time.Second + + // Simplified craps has no manual roll: one shared automatic countdown + // starts with the first live bet and resolves every wager. + crapsSimplifiedDelay = 3 * time.Second ) func crapsSeconds(d time.Duration) int { @@ -46,6 +50,29 @@ func (t *crapsTable) activePlayers() int { // extends it. A solo player has no forced deadline at all. func (t *crapsTable) syncRoundClock() []Event { multi := t.activePlayers() > 1 + if t.Config.Craps.variant() == "simplified" { + // Simplified craps never opens a manual shooting window; the first + // live bet starts one shared automatic countdown for everyone. + if !t.hasLiveBets() { + t.phase = phaseWaiting + t.roundElapsed = 0 + t.countdown = 0 + t.lastCall = false + t.multiplayer = multi + return nil + } + fresh := t.phase == phaseWaiting + t.multiplayer = multi + if fresh { + t.roundElapsed = 0 + t.countdown = 0 + } + t.phase = phasePlaying + if fresh && multi { + return []Event{t.event("{casino_emphasis}Bets open{/} — the dice roll in 3 seconds.", true)} + } + return nil + } if !t.hasLiveBets() { t.phase = phaseWaiting t.roundElapsed = 0 @@ -73,6 +100,24 @@ func (t *crapsTable) tickElapsed(elapsed time.Duration) []Event { t.mu.Lock() defer t.mu.Unlock() events := t.syncRoundClock() + if t.Config.Craps.variant() == "simplified" { + if elapsed <= 0 || !t.hasLiveBets() { + return events + } + t.roundElapsed += elapsed + shooter := t.shooter() + if shooter == nil { + return events + } + for t.countdown < len(crapsCountdownFlavor) && t.roundElapsed >= time.Duration(t.countdown+1)*time.Second { + events = append(events, crapsCountdownEvent(t, shooter.Name, t.countdown)) + t.countdown++ + } + if t.roundElapsed >= crapsSimplifiedDelay { + events = append(events, t.roll()...) + } + return events + } if elapsed <= 0 || !t.hasLiveBets() { return events } @@ -101,6 +146,32 @@ func (t *crapsTable) tickElapsed(elapsed time.Duration) []Event { return events } +// crapsCountdownFlavor broadcasts one line per second before the automatic +// simplified roll. Each step carries a few variants: the public line names +// the shooter, the private line speaks to them. +var crapsCountdownFlavor = [][3][2]string{ + { + {"{casino_emphasis}%s grabs the dice...{/}", "{casino_emphasis}You grab the dice...{/}"}, + {"{casino_emphasis}%s scoops up the dice...{/}", "{casino_emphasis}You scoop up the dice...{/}"}, + {"{casino_emphasis}%s weighs the dice in one hand...{/}", "{casino_emphasis}You weigh the dice in one hand...{/}"}, + }, + { + {"{casino_emphasis}%s blows on them for luck...{/}", "{casino_emphasis}You blow on them for luck...{/}"}, + {"{casino_emphasis}%s blows on the dice for luck...{/}", "{casino_emphasis}You blow on the dice for luck...{/}"}, + {"{casino_emphasis}%s rubs them for luck...{/}", "{casino_emphasis}You rub them for luck...{/}"}, + }, + { + {"{casino_emphasis}%s shakes, shakes, shakes the dice...{/}", "{casino_emphasis}Shake, shake, shake...{/}"}, + {"{casino_emphasis}%s rattles the dice... shake, shake...{/}", "{casino_emphasis}Shake, shake... here they come!{/}"}, + {"{casino_emphasis}%s gives the dice one last shake...{/}", "{casino_emphasis}You shake them hard... here they come!{/}"}, + }, +} + +func crapsCountdownEvent(t *crapsTable, shooter string, step int) Event { + line := crapsCountdownFlavor[step][t.rng.Intn(3)] + return t.playerAction(shooter, fmt.Sprintf(line[0], shooter), line[1]) +} + // StopAutoRoll is atomic with ticking and does not queue a departure or touch // any wager. A second stop, in manual mode, retains normal casino leave behavior. func (t *Table) StopAutoRoll(name string) ([]Event, bool) { diff --git a/internal/casino/craps_simplified_test.go b/internal/casino/craps_simplified_test.go index e36bc2d..b900c4f 100644 --- a/internal/casino/craps_simplified_test.go +++ b/internal/casino/craps_simplified_test.go @@ -1,6 +1,10 @@ package casino -import "testing" +import ( + "strings" + "testing" + "time" +) func TestSimplifiedCrapsAll36SettlementsAndHouseEdge(t *testing.T) { // Total returns for a one-credit wager, including the winning stake. @@ -33,3 +37,108 @@ func TestSimplifiedCrapsAll36SettlementsAndHouseEdge(t *testing.T) { t.Fatalf("all 36 rolls: returned %d, wins %d", totalReturned, wins) } } + +func TestSimplifiedCrapsAutomaticCountdownAndRoll(t *testing.T) { + tb := variantFixture("simplified") + tb.join("alice") + w := &testWallet{chips: 100} + events := tb.bet("alice", "pass", 10, w) + if text := crapsEventText(events); strings.Contains(strings.ToLower(text), "shake") { + t.Fatal("countdown started before the clock", text) + } + if v := tb.view(); v.ShootIn != 3 || v.DealerIn != 0 { + t.Fatal("no 3-second automatic window", v.ShootIn, v.DealerIn) + } + if text := crapsEventText(tb.tickElapsed(600 * time.Millisecond)); strings.Contains(strings.ToLower(text), "dice") { + t.Fatal("countdown fired early", text) + } + events = tb.tickElapsed(600 * time.Millisecond) // 1.2s + text := crapsEventText(events) + if !strings.Contains(text, "dice") || !strings.Contains(text, "alice") { + t.Fatal("first countdown line missing", text) + } + if !events[0].Public || events[0].PrivateMessage == "" { + t.Fatal("countdown must broadcast and speak to the shooter", events[0]) + } + if text := crapsEventText(tb.tickElapsed(600 * time.Millisecond)); strings.Contains(strings.ToLower(text), "luck") { + t.Fatal("second countdown fired early", text) + } + events = tb.tickElapsed(600 * time.Millisecond) // 2.4s + if text := crapsEventText(events); !strings.Contains(text, "luck") { + t.Fatal("second countdown line missing", text) + } + if tb.dice != [2]int{} { + t.Fatal("rolled before 3 seconds", tb.dice) + } + events = tb.tickElapsed(600 * time.Millisecond) // 3.0s + text = strings.ToLower(crapsEventText(events)) + if !strings.Contains(text, "shake") { + t.Fatal("third countdown line missing", crapsEventText(events)) + } + if tb.dice == [2]int{} { + t.Fatal("dice did not roll automatically") + } + if tb.view().ShootIn != 0 { + t.Fatal("window stayed open after the roll") + } + // A rejected manual roll says the game rolls by itself. + tb = variantFixture("simplified") + tb.join("alice") + w = &testWallet{chips: 100} + tb.bet("alice", "pass", 10, w) + if text := crapsEventText(tb.action("alice", "roll")); !strings.Contains(text, "rolls by itself") { + t.Fatal(text) + } + if text := crapsEventText(tb.action("alice", "autoroll")); !strings.Contains(text, "rolls by itself") { + t.Fatal(text) + } + // Multiplayer: a single shared countdown, no 10-second manual window. + tb = variantFixture("simplified") + tb.join("alice") + tb.join("bob") + w = &testWallet{chips: 1000} + tb.bet("alice", "pass", 10, w) + if text := crapsEventText(tb.syncRoundClock()); strings.Contains(text, "10 seconds") { + t.Fatal("simplified opened a manual betting window", text) + } + tb.tickElapsed(3 * time.Second) + if tb.dice == [2]int{} { + t.Fatal("multiplayer countdown did not roll") + } +} + +func TestSimplifiedCrapsCommandsHideRollAndRebet(t *testing.T) { + tb := variantFixture("simplified") + tb.join("alice") + w := &testWallet{chips: 100} + tb.bet("alice", "pass", 10, w) + tb.tickElapsed(3 * time.Second) + cmds := tb.commands("alice") + for _, c := range cmds { + if c.Command == "roll" || c.Command == "autoroll" { + t.Fatal("simplified offered a roll command", c) + } + } + joined := make([]string, len(cmds)) + for i, c := range cmds { + joined[i] = c.Command + } + if strings.Contains(strings.Join(joined, "|"), "on pass") { + t.Fatal("simplified bet command still names a target", joined) + } + spot, amount := tb.rebet("alice") + if spot != "pass" || amount != 10 { + t.Fatal("bare bet must rebet the last wager", spot, amount) + } +} + +func TestSimplifiedCrapsBetDefaultsToPass(t *testing.T) { + tb := variantFixture("simplified") + tb.join("alice") + w := &testWallet{chips: 100} + tb.bet("alice", "", 10, w) + bets := tb.players.get("alice").bets + if len(bets) != 1 || bets[0].kind != "pass" { + t.Fatal("targetless bet did not land on pass", bets) + } +} diff --git a/internal/casino/craps_ux.go b/internal/casino/craps_ux.go index 948ab99..10f7fcb 100644 --- a/internal/casino/craps_ux.go +++ b/internal/casino/craps_ux.go @@ -8,22 +8,36 @@ import ( func (t *crapsTable) commands(name string) []MenuCommand { commands := []MenuCommand{} p := t.players.get(name) + simplified := t.Config.Craps.variant() == "simplified" 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 := "" - if !t.Config.Craps.allows("buy") { - target = "pass" + if simplified { + // Pass is the only target, so the bare bet is a rebet. + if p.lastBet > 0 { + commands = append(commands, + MenuCommand{Command: "bet", Desc: fmt.Sprintf("Rebet %d on pass (also: rebet)", p.lastBet)}, + MenuCommand{Command: "bet ", Desc: "New amount on pass; the dice roll 3 seconds after a bet"}) + } else { + commands = append(commands, MenuCommand{Command: "bet ", Desc: "Wager on pass; the dice roll 3 seconds after a bet"}) + } + } else { + target := "" + if !t.Config.Craps.allows("buy") { + target = "pass" + } + commands = append(commands, MenuCommand{Command: "bet on " + target, Desc: "Place a wager; targets: table rules"}) } - commands = append(commands, MenuCommand{Command: "bet 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"}) + if !simplified { + 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 { diff --git a/internal/casino/craps_variants.go b/internal/casino/craps_variants.go index e64fd0f..a3c078f 100644 --- a/internal/casino/craps_variants.go +++ b/internal/casino/craps_variants.go @@ -1,6 +1,9 @@ package casino -import "fmt" +import ( + "fmt" + "strings" +) // Variants are explicit house editions; regional games with the same names // need not use these rules. Random sources never affect wallet/queue handling. @@ -169,7 +172,7 @@ func (c CrapsConfig) variantRulesText() string { rules := "" switch c.variant() { case "simplified": - rules = "Pass only, one roll of two dice. Net payouts: 2 pays 3:1; 3/4 pay 1:1; 10/11 pay 2:1; 12 pays 5:1. Totals 5/6/7/8/9 lose. Winning bets also return their stake. No point, odds, come, place, side bets or multi-roll wagers. House edge: 1/36 (approximately 2.78%). A loss passes the dice." + rules = "Pass only, one roll of two dice. Payouts: 2 pays 3:1; 3/4 pay 1:1; 10/11 pay 2:1; 12 pays 5:1. Totals 5/6/7/8/9 lose. Winning bets also return their stake. No point, odds, come, place, side bets or multi-roll wagers. The dice roll automatically 3 seconds after the first bet; there is no manual roll. House edge: 1/36 (approximately 2.78%). 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": @@ -188,5 +191,23 @@ func (c CrapsConfig) variantRulesText() string { 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 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 / 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) + cadence := "Solo roll anytime; multiplayer waits 10 seconds, then dealer rolls after 60 seconds." + autoroll := "Bets stay open until the roll. autoroll repeats every 3 seconds, respecting the betting window." + betLine := "Bet: bet on pass; table rules is authoritative for this edition." + stopLine := "stop cancels autoroll; manual stop/sitout waits for live bets." + if c.variant() == "simplified" { + betLine = "Bet: bet — always on pass; bare bet rebets your last wager." + cadence = "The dice roll automatically 3 seconds after the first bet." + autoroll = "" + stopLine = "stop/sitout waits for live bets." + } + lines := []string{c.title() + " — house rules", rules, betLine, + fmt.Sprintf("Free odds: %d (0 = 3-4-5x on ordinary points, don't 6x laid). No odds on Simplified, High Point or Die Rich.", c.Odds), + "Each wager obeys table limits and exact payout units. Wins return stake and remove the bet.", + "Pass/come contracts cannot be removed; optional bets/odds can: unbet / unbet all.", + cadence, stopLine + " Disconnect forfeits; shutdown refunds."} + if autoroll != "" { + lines = append(lines[:7], append([]string{autoroll}, lines[7:]...)...) + } + return strings.Join(lines, "\n") } diff --git a/internal/game/casino.go b/internal/game/casino.go index 517cde0..744eac6 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -639,12 +639,12 @@ func (g *Game) casinoBoardReminder(sess *net.Session, category string, menu *cas } // composeTableThenReminder joins a menu header, the board graphic, and the -// wager reminder. The board snapshot already leads with a blank line, so only -// the reminder needs an extra gap before it. +// wager reminder. The board snapshot already leads with a blank line, and the +// reminder keeps exactly one blank line below the board's last visible row. func composeTableThenReminder(header, table, reminder string) string { out := header if table != "" { - out += "\n" + table + out += "\n" + strings.TrimRight(table, "\n") } if reminder != "" { out += "\n\n" + reminder diff --git a/internal/game/casino_commands_test.go b/internal/game/casino_commands_test.go index 0ae1593..5e05d4e 100644 --- a/internal/game/casino_commands_test.go +++ b/internal/game/casino_commands_test.go @@ -102,25 +102,27 @@ func TestCasinoCompactHintsUseOnlyAvailableCommands(t *testing.T) { } func TestSimplifiedFeltSpacingAndNoIdlePrompt(t *testing.T) { text := renderCrapsSnapshot(&casino.CrapsSnapshot{Variant: "simplified"}, "none", true) - for _, want := range []string{"DICE TOTAL", "NET PROFIT", "3 or 4", "1:1 (even money)", "10 or 11", "LOSE STAKE", "ONE ROLL", "stake back"} { + for _, want := range []string{"SIMPLIFIED CRAPS", "DICE TOTAL", "PAYOUT", "3 or 4", "1:1", "10 or 11", "LOSE STAKE"} { if !strings.Contains(text, want) { t.Fatalf("missing %q: %s", want, text) } } - if strings.Contains(text, "WINS PAY 1:1") || strings.Contains(text, "COME-OUT") { - t.Fatal("stale simplified rules", text) + for _, stale := range []string{"even money", "NET PROFIT", "stake back", "PASS ONLY", "ONE ROLL", "COME-OUT"} { + if strings.Contains(text, stale) { + t.Fatalf("stale simplified rules %q", stale) + } } 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+1 >= len(rows) || strings.Trim(rows[i+1], "│ ") != "" || !strings.HasPrefix(rows[i+2], "└") { - t.Fatal("expected exactly one blank footer row", text) - } - return + rows := strings.Split(strings.TrimSuffix(text, "\n"), "\n") + for i := 0; i < len(rows)-1; i++ { + if strings.TrimSpace(rows[i]) == "" && strings.TrimSpace(rows[i+1]) == "" { + t.Fatalf("consecutive blank lines at %d: %q", i, text) } } - t.Fatal("missing pass label") + // The paytable ends at its own bottom border: no footer block remains. + if strings.Count(text, "─") == 0 || strings.Contains(text, "NO POINT, ODDS") { + t.Fatalf("stale footer: %q", text) + } } diff --git a/internal/game/render_craps.go b/internal/game/render_craps.go index d6db43f..64c910e 100644 --- a/internal/game/render_craps.go +++ b/internal/game/render_craps.go @@ -334,13 +334,18 @@ func renderCrapsSnapshot(s *casino.CrapsSnapshot, mode string, unicode bool, sty point = fmt.Sprintf("POINT %d", s.Point) } if s.Variant == "simplified" { - point = "ONE ROLL" + // The title alone says it: simplified craps has no point phase. + point = "" } variant := s.Variant if variant == "" { variant = "bank" } - out.WriteString("\n" + title(" "+strings.ToUpper(strings.ReplaceAll(variant, "_", " "))+" CRAPS") + subtle(" / "+point) + "\n") + out.WriteString("\n" + title(" "+strings.ToUpper(strings.ReplaceAll(variant, "_", " "))+" CRAPS")) + if point != "" { + out.WriteString(subtle(" / "+point)) + } + out.WriteString("\n") if variant == "bank" || variant == "diceless" { out.WriteString(renderCrapsTrackers(s, mode, unicode)) out.WriteByte('\n') @@ -383,7 +388,11 @@ func renderCrapsSnapshot(s *casino.CrapsSnapshot, mode string, unicode bool, sty hint := "" if s.Phase != "waiting" && s.Phase != "" { hint = "" - if s.ShootIn > 0 { + if s.Variant == "simplified" { + if s.ShootIn > 0 { + hint = fmt.Sprintf("The dice roll in %ds.", s.ShootIn) + } + } else if s.ShootIn > 0 { hint = fmt.Sprintf("Bets open. Shooter ready in %ds.", s.ShootIn) } else if s.DealerIn > 0 { hint = fmt.Sprintf("Shooter ready. Dealer rolls in %ds; bets still open.", s.DealerIn) diff --git a/internal/game/render_craps_paytable.go b/internal/game/render_craps_paytable.go index 346ed83..5acf92a 100644 --- a/internal/game/render_craps_paytable.go +++ b/internal/game/render_craps_paytable.go @@ -1,19 +1,19 @@ package game -// Keep each outcome beside its net profit instead of packing a series of -// total:odds pairs into a sentence. The footer distinguishes profit from the -// returned stake and retains one blank felt row below the command summary. +// Keep each outcome beside its payout instead of packing a series of +// total:odds pairs into a sentence. One blank felt row sits below the +// command summary. func renderSimplifiedCrapsFelt(mode string, unicode bool) string { - c := newCrapsCanvas(13) + c := newCrapsCanvas(9) for _, x := range []int{0, 38} { c.box(x, 0, x+38, 2) c.box(x, 2, x+38, 8) } c.center(1, 37, 1, "DICE TOTAL", crapsGold) - c.center(39, 75, 1, "NET PROFIT", crapsGold) + c.center(39, 75, 1, "PAYOUT", crapsGold) rows := [][2]string{ {"2", "3:1"}, - {"3 or 4", "1:1 (even money)"}, + {"3 or 4", "1:1"}, {"10 or 11", "2:1"}, {"12", "5:1"}, {"5, 6, 7, 8 or 9", "LOSE STAKE"}, @@ -22,8 +22,5 @@ func renderSimplifiedCrapsFelt(mode string, unicode bool) string { c.center(1, 37, i+3, row[0], crapsInk) c.center(39, 75, i+3, row[1], crapsInk) } - c.box(0, 8, 76, 12) - c.center(1, 75, 9, "Winners also receive their original stake back.", crapsMuted) - c.center(1, 75, 10, "PASS ONLY | ONE ROLL | NO POINT, ODDS OR SIDE BETS", crapsGold) return c.render(mode, unicode) } -- cgit v1.2.3