aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-30 17:49:28 -0400
committerhistoria <[not public]>2026-09-30 17:49:28 -0400
commit2317022c16b520dd5272facfd5092a4c58cac44b (patch)
treecc7501ecfb106e462457b2ba3489e7ce14d64c5a
parent39ad552cb5f48c288e1d7a62e80ceb180882eae0 (diff)
downloadthehouseoficarus-2317022c16b520dd5272facfd5092a4c58cac44b.tar.gz
fix: simplified simplified craps, removed unnecessary commands and instructions
-rw-r--r--data/help/craps.yaml8
-rw-r--r--internal/casino/command_hints_test.go18
-rw-r--r--internal/casino/craps.go22
-rw-r--r--internal/casino/craps_clock.go71
-rw-r--r--internal/casino/craps_simplified_test.go111
-rw-r--r--internal/casino/craps_ux.go32
-rw-r--r--internal/casino/craps_variants.go27
-rw-r--r--internal/game/casino.go6
-rw-r--r--internal/game/casino_commands_test.go24
-rw-r--r--internal/game/render_craps.go15
-rw-r--r--internal/game/render_craps_paytable.go15
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 <amount>' (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 <target> / 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 <target> / 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 <amount>" 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 := "<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 <amount>", Desc: "New amount on pass; the dice roll 3 seconds after a bet"})
+ } else {
+ commands = append(commands, MenuCommand{Command: "bet <amount>", Desc: "Wager on pass; the dice roll 3 seconds after a bet"})
+ }
+ } else {
+ 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"})
}
- 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"})
+ 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 <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)
+ 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 <amount> 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 <amount> — 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 <target> / 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)
}