From cac16d1725c46c82a2e62b84c93f7090b2ce0746 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 2 Oct 2026 17:51:46 -0400 Subject: feat: craps table color improvements, die improvements --- data/help/craps.yaml | 12 +- data/help/die_rich_craps.yaml | 4 +- data/help/high_point_craps.yaml | 4 +- data/help/simplified_craps.yaml | 2 +- data/rooms/casino/6186.yaml | 2 +- internal/casino/command_hints_test.go | 3 +- internal/casino/craps.go | 72 ++++++----- internal/casino/craps_clock.go | 92 +++++++------- internal/casino/craps_clock_test.go | 75 ++++++------ internal/casino/craps_projection.go | 35 +++++- internal/casino/craps_review_test.go | 51 ++++++++ internal/casino/craps_rules.go | 4 +- internal/casino/craps_simplified_test.go | 23 ++-- internal/casino/craps_test.go | 26 +++- internal/casino/craps_ux.go | 6 +- internal/casino/craps_variants.go | 8 +- internal/casino/manager.go | 17 +-- internal/game/casino.go | 7 +- internal/game/casino_ux.go | 12 +- internal/game/casino_ux_test.go | 15 +++ internal/game/craps_test.go | 2 +- internal/game/craps_variants_test.go | 6 +- internal/game/render_craps.go | 203 ++++++++++++++++--------------- internal/game/render_craps_dice_test.go | 82 +++++++++++++ internal/game/render_craps_test.go | 58 ++++++++- 25 files changed, 522 insertions(+), 299 deletions(-) create mode 100644 internal/casino/craps_review_test.go create mode 100644 internal/game/render_craps_dice_test.go diff --git a/data/help/craps.yaml b/data/help/craps.yaml index 5f7dec6..65c03fb 100644 --- a/data/help/craps.yaml +++ b/data/help/craps.yaml @@ -35,20 +35,20 @@ description: | Bonus bets must precede the shooter's first roll. Fire settles on seven-out; ATS pays on completion and loses on any seven. - roll Shoot now (solo) or after the 10-second call + roll Shoot now (solo) or after the 10-tick call autoroll Repeat on your turn until your bets finish stop Cancel autoroll; in manual mode, leave once bets resolve unbet Return every optional bet and all free odds New players join last in shooting order. Making a point keeps the dice; seven-out passes them. Solo play has no forced timers. - Multiplayer gives everyone 10 seconds to bet, with a room-wide last - call 3 seconds before the shooter can roll. Bets remain open until - the actual roll. If the shooter waits another 60 seconds, the dealer + Multiplayer gives everyone 10 ticks to bet, with a room-wide last + call 3 ticks before the shooter can roll. Bets remain open until + the actual roll. If the shooter waits another 60 ticks, the dealer rolls for them (multiplayer only). - Autoroll rolls every 3 seconds on your turn, but always respects the - 10-second multiplayer window. It stops when your last bet resolves. + Autoroll rolls every 3 ticks on your turn, but always respects the + 10-tick multiplayer window. It stops when your last bet resolves. Stop cancels autoroll immediately without removing bets or leaving. A second stop in manual mode uses the normal leave behavior. diff --git a/data/help/die_rich_craps.yaml b/data/help/die_rich_craps.yaml index c18b9e1..157624d 100644 --- a/data/help/die_rich_craps.yaml +++ b/data/help/die_rich_craps.yaml @@ -17,5 +17,5 @@ description: | the die. No odds, no come bets, no side bets. Bets stay open until the roll; - multiplayer gives 10 seconds to bet, then the dealer rolls for a - shooter who waits another 60 seconds. + multiplayer gives 10 ticks to bet, then the dealer rolls for a + shooter who waits another 60 ticks. diff --git a/data/help/high_point_craps.yaml b/data/help/high_point_craps.yaml index 8a264c2..30b61fd 100644 --- a/data/help/high_point_craps.yaml +++ b/data/help/high_point_craps.yaml @@ -16,5 +16,5 @@ description: | HIGHER than the point — equal or lower loses, and the dice pass. No odds, no come bets, no side bets. Bets stay open until the roll; - multiplayer gives 10 seconds to bet, then the dealer rolls for a - shooter who waits another 60 seconds. + multiplayer gives 10 ticks to bet, then the dealer rolls for a + shooter who waits another 60 ticks. diff --git a/data/help/simplified_craps.yaml b/data/help/simplified_craps.yaml index bb4ecd9..ad09caf 100644 --- a/data/help/simplified_craps.yaml +++ b/data/help/simplified_craps.yaml @@ -6,7 +6,7 @@ description: | Simplified Craps — the shortest table in the casino. One bet (pass), one roll of two dice, and the dice roll themselves - 3 seconds after your bet. There is no roll command and no waiting + 3 ticks after your bet. There is no roll command and no waiting for other players: everyone bets their own pass line, every round. bet 10 Wager on pass (the only bet there is) diff --git a/data/rooms/casino/6186.yaml b/data/rooms/casino/6186.yaml index efece43..6de9321 100644 --- a/data/rooms/casino/6186.yaml +++ b/data/rooms/casino/6186.yaml @@ -1,5 +1,5 @@ name: Simplified Craps Gallery -description: A brisk single-roll game — pass only; the dice roll on their own three seconds after a bet. +description: A brisk single-roll game — pass only; the dice roll on their own three ticks after a bet. exits: south: room: 6185 diff --git a/internal/casino/command_hints_test.go b/internal/casino/command_hints_test.go index 353d292..bd15883 100644 --- a/internal/casino/command_hints_test.go +++ b/internal/casino/command_hints_test.go @@ -3,7 +3,6 @@ package casino import ( "strings" "testing" - "time" ) func hintHas(commands []MenuCommand, command string) bool { @@ -68,7 +67,7 @@ func TestCrapsHintsFollowPlayerState(t *testing.T) { } tb.players.get("a").autoRoll = false tb.bet("b", "pass", 10, &testWallet{chips: 1000}) - tb.tickElapsed(10 * time.Second) + tb.advance(10) if !hintHas(tb.commands("a"), "roll") || hintHas(tb.commands("b"), "roll") { t.Fatal(v, "wrong shooter hints") } diff --git a/internal/casino/craps.go b/internal/casino/craps.go index 3bbb08f..286fe0a 100644 --- a/internal/casino/craps.go +++ b/internal/casino/craps.go @@ -5,7 +5,6 @@ import ( "math/rand" "strconv" "strings" - "time" ) type crapsBet struct { @@ -16,29 +15,29 @@ type crapsBet struct { } type crapsPlayer struct { Participant - bets []*crapsBet - lastSpot string - lastBet int - autoRoll bool - autoElapsed time.Duration + bets []*crapsBet + lastSpot string + lastBet int + autoRoll bool + autoTicks int } func (p *crapsPlayer) participantPtr() *Participant { return &p.Participant } type crapsTable struct { tableCore[*crapsPlayer] - rng *rand.Rand - queue []string - point int - dice [2]int - rolled bool - made map[int]bool - seen map[int]bool - roundElapsed time.Duration - multiplayer bool - lastCall bool - pendingRoll time.Duration // manual roll: time left before the dice resolve - countdown int // simplified craps: automatic-roll countdown lines emitted + rng *rand.Rand + queue []string + point int + dice [2]int + rolled bool + made map[int]bool + seen map[int]bool + roundTicks int + multiplayer bool + lastCall bool + pendingRollTicks int // manual roll: ticks left before the dice resolve + countdown int // simplified craps: automatic-roll countdown lines emitted } type CrapsBetView struct { Name, Target string @@ -52,7 +51,7 @@ type CrapsSnapshot struct { Queue []string Made, Seen []int Bets []CrapsBetView - Remaining int // legacy snapshot field; presentation uses seconds below + Remaining int // legacy snapshot field; presentation uses ticks below ShootIn int DealerIn int AutoRoll bool @@ -78,15 +77,15 @@ func (t *crapsTable) view() *CrapsSnapshot { 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) + s.ShootIn = max(0, crapsSimplifiedTicks-t.roundTicks) } else if t.multiplayer { - s.ShootIn = crapsSeconds(crapsBetDelay - t.roundElapsed) - s.DealerIn = crapsSeconds(crapsBetDelay + crapsDealerDelay - t.roundElapsed) + s.ShootIn = max(0, crapsBetTicks-t.roundTicks) + s.DealerIn = max(0, crapsBetTicks+crapsDealerTicks-t.roundTicks) } } if p := t.shooter(); p != nil && p.autoRoll { s.AutoRoll = true - s.AutoIn = max(s.ShootIn, crapsSeconds(crapsAutoDelay-p.autoElapsed)) + s.AutoIn = max(s.ShootIn, max(0, crapsAutoTicks-p.autoTicks)) } for n := 2; n <= 12; n++ { if t.made[n] { @@ -133,10 +132,9 @@ func (t *crapsTable) join(name string) []Event { return append([]Event{e, t.menu(name)}, timing...) } func (t *crapsTable) remove(name string) { - t.countdown = 0 if len(t.queue) > 0 && t.queue[0] == name { // The queued roll belongs to the shooter; a departing shooter cancels it. - t.pendingRoll = 0 + t.pendingRollTicks = 0 } t.removeSeatLocked(name) for i, n := range t.queue { @@ -356,7 +354,7 @@ func (t *crapsTable) resolveRoll(a, b int) []Event { } var results []Event for _, p := range t.players.ordered() { - p.autoElapsed = 0 + p.autoTicks = 0 var lines []settlementLine failed := false var live []*crapsBet @@ -437,7 +435,7 @@ func (t *crapsTable) finishCrapsControls() []Event { } if p.autoRoll { p.autoRoll = false - p.autoElapsed = 0 + p.autoTicks = 0 events = append(events, t.private(p.Name, "{casino_emphasis}Autoroll complete{/} — all your bets have settled. Manual play resumed.")) } if p.LeaveAfter { @@ -457,7 +455,7 @@ func (t *crapsTable) finishCrapsControls() []Event { } return events } -func (t *crapsTable) tick() []Event { return t.tickElapsed(600 * time.Millisecond) } +func (t *crapsTable) tick() []Event { return t.advance(1) } func (t *crapsTable) action(name, action string) []Event { t.mu.Lock() defer t.mu.Unlock() @@ -476,7 +474,7 @@ func (t *crapsTable) action(name, action string) []Event { 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.")} + return []Event{t.private(name, "Simplified craps rolls by itself 3 ticks after a bet; there is no manual roll.")} } if action == "autoroll" { if !p.HasBet { @@ -484,9 +482,9 @@ func (t *crapsTable) action(name, action string) []Event { } if !p.autoRoll { p.autoRoll = true - p.autoElapsed = 0 + p.autoTicks = 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."), t.reminder(name)} + return []Event{t.private(name, "{casino_emphasis}Autoroll ON{/} — every 3 ticks on your turn, respecting the multiplayer betting window. {command}stop{/} returns to manual play."), t.reminder(name)} } if action == "roll" { events := t.syncRoundClock() @@ -496,13 +494,13 @@ func (t *crapsTable) action(name, action string) []Event { if !t.hasLiveBets() { return append(events, t.private(name, "Place a bet, then roll whenever you're ready.")) } - if t.multiplayer && t.roundElapsed < crapsBetDelay { - return append(events, t.private(name, fmt.Sprintf("Others have time to bet: you can shoot in {casino_emphasis}%d seconds{/}. Bets remain open until you roll.", crapsSeconds(crapsBetDelay-t.roundElapsed)))) + if t.multiplayer && t.roundTicks < crapsBetTicks { + return append(events, t.private(name, fmt.Sprintf("Others have time to bet: you can shoot in {casino_emphasis}%d ticks{/}. Bets remain open until you roll.", crapsBetTicks-t.roundTicks))) } - if t.pendingRoll > 0 { + if t.pendingRollTicks > 0 { return append(events, t.private(name, "The dice are already in the air.")) } - t.pendingRoll = crapsManualRollDelay + t.pendingRollTicks = crapsManualRollTicks shake := t.playerAction(name, fmt.Sprintf("{casino_emphasis}%s shakes the dice...{/}", name), "{casino_emphasis}You shake the dice...{/}") return append(events, shake) } @@ -607,12 +605,12 @@ func (t *crapsTable) refundAll() []Event { p.bets = nil p.HasBet = false p.autoRoll = false - p.autoElapsed = 0 + p.autoTicks = 0 } t.point = 0 t.rolled = false t.countdown = 0 - t.pendingRoll = 0 + t.pendingRollTicks = 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 ab28329..5357c93 100644 --- a/internal/casino/craps_clock.go +++ b/internal/casino/craps_clock.go @@ -1,30 +1,26 @@ package casino -import ( - "fmt" - "time" -) +import "fmt" +// Every craps deadline is a tick count; the engine tick length decides the +// wall-clock duration. const ( - crapsBetDelay = 10 * time.Second - crapsDealerDelay = 60 * time.Second - crapsAutoDelay = 3 * time.Second + crapsBetTicks = 10 + crapsDealerTicks = 60 + crapsAutoTicks = 3 // Simplified craps has no manual roll: one shared automatic countdown // starts with the first live bet and resolves every wager. - crapsSimplifiedDelay = 3 * time.Second + crapsSimplifiedTicks = 3 // A requested manual roll first shows the shake, then the outcome chart, - // then resolves the dice five ticks (three seconds) later. - crapsManualRollDelay = 3 * time.Second + // then resolves the dice three ticks later. + crapsManualRollTicks = 3 + + // Last-call warning fires this many ticks before the shooter may roll. + crapsLastCallTicks = 3 ) -func crapsSeconds(d time.Duration) int { - if d <= 0 { - return 0 - } - return int((d + time.Second - 1) / time.Second) -} func (t *crapsTable) shooter() *crapsPlayer { if len(t.queue) == 0 { return nil @@ -53,13 +49,18 @@ func (t *crapsTable) activePlayers() int { // multiplayer or starting a new round opens a window; adding bets never // extends it. A solo player has no forced deadline at all. func (t *crapsTable) syncRoundClock() []Event { + // Cancel immediately, not on the next tick: a new bet must not inherit + // a roll requested for wagers that have since been taken down. + if !t.hasLiveBets() { + t.pendingRollTicks = 0 + } 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.roundTicks = 0 t.countdown = 0 t.lastCall = false t.multiplayer = multi @@ -68,18 +69,18 @@ func (t *crapsTable) syncRoundClock() []Event { fresh := t.phase == phaseWaiting t.multiplayer = multi if fresh { - t.roundElapsed = 0 + t.roundTicks = 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 []Event{t.event("{casino_emphasis}Bets open{/} — the dice roll in 3 ticks.", true)} } return nil } if !t.hasLiveBets() { t.phase = phaseWaiting - t.roundElapsed = 0 + t.roundTicks = 0 t.lastCall = false t.multiplayer = multi return nil @@ -87,54 +88,54 @@ func (t *crapsTable) syncRoundClock() []Event { fresh := t.phase == phaseWaiting || (multi && !t.multiplayer) t.multiplayer = multi if fresh { - t.roundElapsed = 0 + t.roundTicks = 0 t.lastCall = false } t.phase = phasePlaying - if multi && t.roundElapsed < crapsBetDelay { + if multi && t.roundTicks < crapsBetTicks { t.phase = phaseBetting } if fresh && multi { - return []Event{t.event("{casino_emphasis}Bets open{/} — 10 seconds before the shooter can roll. Betting stays open until the dice roll.", true)} + return []Event{t.event("{casino_emphasis}Bets open{/} — 10 ticks before the shooter can roll. Betting stays open until the dice roll.", true)} } return nil } -func (t *crapsTable) tickElapsed(elapsed time.Duration) []Event { +func (t *crapsTable) advance(ticks int) []Event { t.mu.Lock() defer t.mu.Unlock() events := t.syncRoundClock() + if ticks <= 0 { + return events + } if t.Config.Craps.variant() == "simplified" { - if elapsed <= 0 || !t.hasLiveBets() { + if !t.hasLiveBets() { return events } - t.roundElapsed += elapsed + t.roundTicks += ticks shooter := t.shooter() if shooter == nil { return events } - for t.countdown < len(crapsCountdownFlavor) && t.roundElapsed >= time.Duration(t.countdown+1)*time.Second { + for t.countdown < len(crapsCountdownFlavor) && t.roundTicks >= t.countdown+1 { events = append(events, crapsCountdownEvent(t, shooter.Name, t.countdown)) t.countdown++ } - if t.roundElapsed >= crapsSimplifiedDelay { + if t.roundTicks >= crapsSimplifiedTicks { events = append(events, t.roll()...) } return events } - if elapsed <= 0 { - return events - } - if t.pendingRoll > 0 { + if t.pendingRollTicks > 0 { // A manual roll is in the air: no other deadline may fire first. Only // the shake is announced now; the result follows when the dice land. if !t.hasLiveBets() { - t.pendingRoll = 0 + t.pendingRollTicks = 0 return events } - t.pendingRoll -= elapsed - if t.pendingRoll <= 0 { - t.pendingRoll = 0 + t.pendingRollTicks -= ticks + if t.pendingRollTicks <= 0 { + t.pendingRollTicks = 0 return append(events, t.roll()...) } return events @@ -142,32 +143,33 @@ func (t *crapsTable) tickElapsed(elapsed time.Duration) []Event { if !t.hasLiveBets() { return events } - t.roundElapsed += elapsed + t.roundTicks += ticks p := t.shooter() if p == nil { return events } if p.autoRoll && p.HasBet { - p.autoElapsed += elapsed + p.autoTicks += ticks } - if t.multiplayer && !t.lastCall && t.roundElapsed >= crapsBetDelay-3*time.Second { + if t.multiplayer && !t.lastCall && t.roundTicks >= crapsBetTicks-crapsLastCallTicks { t.lastCall = true - events = append(events, t.event(fmt.Sprintf("{casino_warning}Last call for bets! 3 seconds until %s can shoot!{/}", p.Name), true)) + remaining := max(1, crapsBetTicks-t.roundTicks) + events = append(events, t.event(fmt.Sprintf("{casino_warning}Last call for bets! %d ticks until %s can shoot!{/}", remaining, p.Name), true)) } - if t.multiplayer && t.roundElapsed < crapsBetDelay { + if t.multiplayer && t.roundTicks < crapsBetTicks { 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), t.reminder(p.Name)) + events = append(events, t.event(fmt.Sprintf("{casino_emphasis}%s can shoot!{/} Bets stay open until the roll. Dealer rolls in %d ticks if needed.", p.Name, crapsDealerTicks), true), t.reminder(p.Name)) } t.phase = phasePlaying - if (p.autoRoll && p.HasBet && p.autoElapsed >= crapsAutoDelay) || (t.multiplayer && t.roundElapsed >= crapsBetDelay+crapsDealerDelay) { + if (p.autoRoll && p.HasBet && p.autoTicks >= crapsAutoTicks) || (t.multiplayer && t.roundTicks >= crapsBetTicks+crapsDealerTicks) { return append(events, t.roll()...) } return events } -// crapsCountdownFlavor broadcasts one line per second before the automatic +// crapsCountdownFlavor broadcasts one line per tick 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{ @@ -207,6 +209,6 @@ func (t *Table) StopAutoRoll(name string) ([]Event, bool) { return nil, false } p.autoRoll = false - p.autoElapsed = 0 + p.autoTicks = 0 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 f9ad445..97758f8 100644 --- a/internal/casino/craps_clock_test.go +++ b/internal/casino/craps_clock_test.go @@ -3,7 +3,6 @@ package casino import ( "strings" "testing" - "time" ) func crapsEventText(events []Event) string { @@ -24,11 +23,11 @@ func TestCrapsMultiplayerClockAndLateBets(t *testing.T) { if tb.view().ShootIn != 10 || tb.view().DealerIn != 70 { t.Fatal(tb.view()) } - if text := crapsEventText(tb.tickElapsed(6 * time.Second)); strings.Contains(text, "Last call") { + if text := crapsEventText(tb.advance(6)); strings.Contains(text, "Last call") { t.Fatal("early warning") } - events := tb.tickElapsed(time.Second) - if !strings.Contains(crapsEventText(events), "Last call for bets! 3 seconds until alice can shoot!") { + events := tb.advance(1) + if !strings.Contains(crapsEventText(events), "Last call for bets! 3 ticks until alice can shoot!") { t.Fatal(events) } if !events[0].Public { @@ -38,7 +37,7 @@ func TestCrapsMultiplayerClockAndLateBets(t *testing.T) { if tb.dice != [2]int{} { t.Fatal("early shoot") } - tb.tickElapsed(3 * time.Second) + tb.advance(3) if tb.view().ShootIn != 0 || tb.view().DealerIn != 60 { t.Fatal(tb.view()) } @@ -50,15 +49,15 @@ func TestCrapsMultiplayerClockAndLateBets(t *testing.T) { if len(tb.players.get("alice").bets) != 1 { t.Fatal("late removal rejected") } - if strings.Contains(crapsEventText(tb.tickElapsed(59*time.Second)), "Last call") { + if strings.Contains(crapsEventText(tb.advance(59)), "Last call") { t.Fatal("warning repeated") } if tb.dice != [2]int{} { t.Fatal("early forced roll") } - tb.tickElapsed(time.Second) + tb.advance(1) if tb.dice == [2]int{} { - t.Fatal("60 second dealer fallback missing") + t.Fatal("dealer fallback missing") } } func TestCrapsSoloAndRosterTransitions(t *testing.T) { @@ -66,12 +65,12 @@ func TestCrapsSoloAndRosterTransitions(t *testing.T) { tb.join("alice") w := &testWallet{chips: 1000} tb.bet("alice", "fire", 1, w) - tb.tickElapsed(500 * time.Second) + tb.advance(500) if tb.dice != [2]int{} || tb.view().DealerIn != 0 || tb.view().ShootIn != 0 { t.Fatal("solo deadline", tb.view()) } tb.action("alice", "roll") - tb.tickElapsed(3 * time.Second) + tb.advance(3) if tb.dice == [2]int{} { t.Fatal("solo could not roll") } @@ -79,7 +78,7 @@ func TestCrapsSoloAndRosterTransitions(t *testing.T) { if tb.view().ShootIn != 10 { t.Fatal("join did not give bettors a window") } - tb.tickElapsed(4 * time.Second) + tb.advance(4) tb.bet("alice", "field", 10, w) if tb.view().ShootIn != 6 { t.Fatal("bet extended timer") @@ -104,15 +103,15 @@ func TestCrapsAutorollPaceAndStop(t *testing.T) { w := &testWallet{chips: 1000} tb.bet("alice", "fire", 1, w) tb.action("alice", "autoroll") - tb.tickElapsed(2999 * time.Millisecond) + tb.advance(2) if tb.dice != [2]int{} { t.Fatal("autoroll fired early") } - tb.tickElapsed(time.Millisecond) + tb.advance(1) if tb.dice == [2]int{} { t.Fatal("autoroll did not fire") } - if p := tb.players.get("alice"); p.HasBet && p.autoElapsed != 0 { + if p := tb.players.get("alice"); p.HasBet && p.autoTicks != 0 { t.Fatal("pace not reset") } handle := &Table{tableBase: tb.tableBase, game: tb} @@ -121,7 +120,7 @@ func TestCrapsAutorollPaceAndStop(t *testing.T) { t.Fatal("stop failed") } before := tb.dice - tb.tickElapsed(500 * time.Second) + tb.advance(500) if tb.dice != before || !tb.hasParticipant("alice") { t.Fatal("stop rolled or departed") } @@ -134,12 +133,12 @@ func TestCrapsAutorollPaceAndStop(t *testing.T) { w = &testWallet{chips: 100} tb.bet("alice", "field", 10, w) tb.action("alice", "autoroll") - tb.tickElapsed(3 * time.Second) + tb.advance(3) if tb.players.get("alice").autoRoll || tb.players.get("alice").HasBet { t.Fatal("autoroll survived last bet") } before = tb.dice - tb.tickElapsed(3 * time.Second) + tb.advance(3) if tb.dice != before { t.Fatal("extra roll after completion") } @@ -151,11 +150,11 @@ func TestCrapsAutorollRespectsOtherPlayers(t *testing.T) { w := &testWallet{chips: 100} tb.bet("alice", "field", 10, w) tb.action("alice", "autoroll") - tb.tickElapsed(3 * time.Second) + tb.advance(3) if tb.dice != [2]int{} { t.Fatal("autoroll stole multiplayer betting time") } - tb.tickElapsed(7 * time.Second) + tb.advance(7) if tb.dice == [2]int{} { t.Fatal("autoroll failed when ready") } @@ -166,38 +165,36 @@ func TestCrapsAutorollRespectsOtherPlayers(t *testing.T) { w = &testWallet{chips: 100} tb.bet("bob", "field", 10, w) tb.action("bob", "autoroll") - tb.tickElapsed(11 * time.Second) + tb.advance(11) if tb.dice != [2]int{} { t.Fatal("non-shooter autorolled") } } -func TestCrapsManagerUsesElapsedCadence(t *testing.T) { - for _, step := range []time.Duration{100 * time.Millisecond, 600 * time.Millisecond, time.Second} { - m := NewManager(4) - cfg := TableConfig{ID: "c", Game: GameCraps} - m.Join(1, cfg, "alice") - w := &testWallet{chips: 100} - m.Bet(1, "c", "alice", "field", 10, w) - m.Action(1, "c", "alice", "autoroll") - tb := m.Table(1, "c").game.(*crapsTable) - for elapsed := step; elapsed < 3*time.Second; elapsed += step { - m.TickElapsed(step) - if tb.dice != [2]int{} { - t.Fatal("early", step) - } - } - m.TickElapsed(step) - if tb.dice == [2]int{} { - t.Fatal("wrong cadence", step) +func TestCrapsManagerTickCadence(t *testing.T) { + m := NewManager(4) + cfg := TableConfig{ID: "c", Game: GameCraps} + m.Join(1, cfg, "alice") + w := &testWallet{chips: 100} + m.Bet(1, "c", "alice", "field", 10, w) + m.Action(1, "c", "alice", "autoroll") + tb := m.Table(1, "c").game.(*crapsTable) + for tick := 1; tick < crapsAutoTicks; tick++ { + m.Tick() + if tb.dice != [2]int{} { + t.Fatal("early tick", tick) } } + m.Tick() + if tb.dice == [2]int{} { + t.Fatal("autoroll did not fire on schedule") + } } func TestCrapsConsolidatedResultsAndBetNumbers(t *testing.T) { tb := crapsFixture() tb.join("alice") w := &testWallet{chips: 1000} events := tb.bet("alice", "pass", 10, w) - if !strings.Contains(crapsEventText(events), "ROLL OUTCOMES") { + if !strings.Contains(crapsEventText(events), "OUTCOMES") { t.Fatal("no post-bet roll table") } tb.bet("alice", "dontpass", 10, w) diff --git a/internal/casino/craps_projection.go b/internal/casino/craps_projection.go index 1377452..65f09ce 100644 --- a/internal/casino/craps_projection.go +++ b/internal/casino/craps_projection.go @@ -99,7 +99,9 @@ func crapsBonusComplete(lo, hi int, seen map[int]bool, next int) bool { // rollTable previews the next roll as one compact line of every total 2-12 // (1-6 on the single-die table): wins are bold green, losses bold red, and // totals no open wager settles are dim. Hardway and hop bets are pair -// specific, so the best pair outcome stands in for that total. +// specific, so the best pair outcome stands in for that total. The line wraps +// on token boundaries with a hanging indent so the continuation lines line up +// under the first total. func (t *crapsTable) rollTable(p *crapsPlayer) string { if len(p.bets) == 0 { return "" @@ -116,8 +118,11 @@ func (t *crapsTable) rollTable(p *crapsPlayer) string { if t.Config.Craps.variant() == "die_rich" { lo, hi = 1, 6 } - var sb strings.Builder - sb.WriteString("{casino_emphasis}ROLL OUTCOMES{/}:") + type outcomeToken struct { + tagged string + width int + } + tokens := make([]outcomeToken, 0, hi-lo+1) for total := lo; total <= hi; total++ { token := fmt.Sprintf("%d(-)", total) tag := "dim" @@ -127,7 +132,29 @@ func (t *crapsTable) rollTable(p *crapsPlayer) string { case found[total] && best[total] < 0: token, tag = fmt.Sprintf("%d(%d)", total, best[total]), "casino_result_loss" } - fmt.Fprintf(&sb, " {%s}%s{/}", tag, token) + tokens = append(tokens, outcomeToken{fmt.Sprintf("{%s}%s{/}", tag, token), len(token)}) + } + const ( + head = "OUTCOMES:" + width = 80 + ) + indent := strings.Repeat(" ", len(head)+1) + var sb strings.Builder + sb.WriteString("{casino_emphasis}" + head + "{/}") + lineLen := len(head) + for i, tk := range tokens { + // The first token always follows the heading; later tokens wrap whole, + // never splitting a total from its payout. + if i > 0 && lineLen+1+tk.width > width { + sb.WriteString("\n" + indent) + lineLen = len(indent) + sb.WriteString(tk.tagged) + lineLen += tk.width + continue + } + sb.WriteByte(' ') + sb.WriteString(tk.tagged) + lineLen += 1 + tk.width } return sb.String() } diff --git a/internal/casino/craps_review_test.go b/internal/casino/craps_review_test.go new file mode 100644 index 0000000..04f72b2 --- /dev/null +++ b/internal/casino/craps_review_test.go @@ -0,0 +1,51 @@ +package casino + +import "testing" + +func TestCrapsTakenDownRollDoesNotCarryIntoNewBet(t *testing.T) { + tb := crapsFixture() + tb.join("alice") + w := &testWallet{chips: 100} + tb.bet("alice", "field", 10, w) + tb.action("alice", "roll") + tb.tick() + tb.action("alice", "unbet") + if tb.pendingRollTicks != 0 { + t.Fatal("taking down the last bet did not cancel the pending roll") + } + tb.bet("alice", "field", 10, w) + tb.advance(10) + if tb.dice != [2]int{} || !tb.hasLiveBets() { + t.Fatal("new wager inherited a canceled roll") + } + tb.action("alice", "roll") + tb.advance(crapsManualRollTicks) + if tb.dice == [2]int{} || tb.hasLiveBets() { + t.Fatal("fresh manual roll did not settle") + } +} + +func TestSimplifiedCrapsDeparturePreservesCountdown(t *testing.T) { + for _, departing := range []string{"alice", "bob"} { + t.Run(departing, func(t *testing.T) { + tb := variantFixture("simplified") + tb.join("alice") + tb.join("bob") + tb.join("carol") + tb.bet("carol", "pass", 10, &testWallet{chips: 100}) + tb.tick() + tb.leave(departing) + if tb.countdown != 1 || tb.view().ShootIn != 2 { + t.Fatal("departure reset the shared countdown") + } + events := tb.tick() + if len(events) != 1 || tb.countdown != 2 { + t.Fatal("countdown repeated after departure", events) + } + tb.tick() + if tb.dice == [2]int{} || tb.hasLiveBets() { + t.Fatal("shared roll did not settle on schedule") + } + }) + } +} diff --git a/internal/casino/craps_rules.go b/internal/casino/craps_rules.go index 88649a2..a9e52af 100644 --- a/internal/casino/craps_rules.go +++ b/internal/casino/craps_rules.go @@ -93,9 +93,9 @@ func (c TableConfig) CrapsRulesText() string { "Small (2-6) / Tall (8-12) pay 34:1; All pays 175:1. Any 7 resets ATS.\n"+ "Place/buy/hard and come odds are OFF on come-out; lay and don't odds work.\n"+ "Exact payout units required; no fractional-credit rounding except commission.\n"+ - "Solo: roll anytime. Multiplayer: 10 seconds to bet, then 60 seconds for the shooter.\n"+ + "Solo: roll anytime. Multiplayer: 10 ticks to bet, then 60 ticks for the shooter.\n"+ "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"+ + "autoroll: repeat every 3 ticks on your turn (multiplayer waits 10 ticks).\n"+ "unbet: remove every non-contract bet and all free odds; pass/come contracts stay.\n"+ "stop cancels autoroll without leaving; in manual mode stop waits for live bets.\n"+ "Disconnect forfeits live bets.\n"+ diff --git a/internal/casino/craps_simplified_test.go b/internal/casino/craps_simplified_test.go index b900c4f..705204b 100644 --- a/internal/casino/craps_simplified_test.go +++ b/internal/casino/craps_simplified_test.go @@ -3,7 +3,6 @@ package casino import ( "strings" "testing" - "time" ) func TestSimplifiedCrapsAll36SettlementsAndHouseEdge(t *testing.T) { @@ -47,12 +46,9 @@ func TestSimplifiedCrapsAutomaticCountdownAndRoll(t *testing.T) { 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) + t.Fatal("no 3-tick 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 + events = tb.advance(1) text := crapsEventText(events) if !strings.Contains(text, "dice") || !strings.Contains(text, "alice") { t.Fatal("first countdown line missing", text) @@ -60,17 +56,14 @@ func TestSimplifiedCrapsAutomaticCountdownAndRoll(t *testing.T) { 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 + events = tb.advance(1) 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) + t.Fatal("rolled before the final tick", tb.dice) } - events = tb.tickElapsed(600 * time.Millisecond) // 3.0s + events = tb.advance(1) text = strings.ToLower(crapsEventText(events)) if !strings.Contains(text, "shake") { t.Fatal("third countdown line missing", crapsEventText(events)) @@ -98,10 +91,10 @@ func TestSimplifiedCrapsAutomaticCountdownAndRoll(t *testing.T) { tb.join("bob") w = &testWallet{chips: 1000} tb.bet("alice", "pass", 10, w) - if text := crapsEventText(tb.syncRoundClock()); strings.Contains(text, "10 seconds") { + if text := crapsEventText(tb.syncRoundClock()); strings.Contains(text, "10 ticks") { t.Fatal("simplified opened a manual betting window", text) } - tb.tickElapsed(3 * time.Second) + tb.advance(3) if tb.dice == [2]int{} { t.Fatal("multiplayer countdown did not roll") } @@ -112,7 +105,7 @@ func TestSimplifiedCrapsCommandsHideRollAndRebet(t *testing.T) { tb.join("alice") w := &testWallet{chips: 100} tb.bet("alice", "pass", 10, w) - tb.tickElapsed(3 * time.Second) + tb.advance(3) cmds := tb.commands("alice") for _, c := range cmds { if c.Command == "roll" || c.Command == "autoroll" { diff --git a/internal/casino/craps_test.go b/internal/casino/craps_test.go index 3d92e5e..7f3bcfa 100644 --- a/internal/casino/craps_test.go +++ b/internal/casino/craps_test.go @@ -5,7 +5,6 @@ import ( "math/rand" "strings" "testing" - "time" ) func crapsFixture() *crapsTable { @@ -227,6 +226,23 @@ func TestCrapsATSAndSevenReset(t *testing.T) { t.Fatal("late bonus allowed") } } + +// TestCrapsATSSeenTracksWithoutBonusBet: the Small/Tall tracker records every +// roll even when nobody holds an All/Small/Tall wager, so the display always +// shows progress and entices a bet. +func TestCrapsATSSeenTracksWithoutBonusBet(t *testing.T) { + tb := crapsFixture() + tb.join("a") + w := &testWallet{chips: 100} + tb.bet("a", "pass", 10, w) + tb.resolveRoll(2, 3) // point 5 + tb.resolveRoll(3, 3) // 6 + tb.resolveRoll(4, 4) // 8 + snap := tb.board("a").(*CrapsSnapshot) + if len(snap.Seen) != 3 { + t.Fatalf("ATS tracker did not track without a bonus bet: %v", snap.Seen) + } +} func TestCrapsTimersControlsAndRefunds(t *testing.T) { tb := crapsFixture() tb.join("a") @@ -237,7 +253,7 @@ func TestCrapsTimersControlsAndRefunds(t *testing.T) { if tb.dice != [2]int{} { t.Fatal("early roll") } - tb.tickElapsed(10 * time.Second) + tb.advance(10) if tb.phase != phasePlaying { t.Fatal(tb.phase) } @@ -253,7 +269,7 @@ func TestCrapsTimersControlsAndRefunds(t *testing.T) { if tb.dice != [2]int{} { t.Fatal("roll resolved before the shake") } - tb.tickElapsed(3 * time.Second) + tb.advance(3) if tb.dice == [2]int{} { t.Fatal("no roll") } @@ -261,12 +277,12 @@ func TestCrapsTimersControlsAndRefunds(t *testing.T) { tb.join("a") w = &testWallet{chips: 100} tb.bet("a", "pass", 10, w) - tb.tickElapsed(120 * time.Second) + tb.advance(120) if tb.dice != [2]int{} { t.Fatal("solo players must never be forced to roll") } tb.action("a", "roll") - tb.tickElapsed(3 * time.Second) + tb.advance(3) if tb.dice == [2]int{} { t.Fatal("solo manual roll stalled") } diff --git a/internal/casino/craps_ux.go b/internal/casino/craps_ux.go index f41ad67..7e44e39 100644 --- a/internal/casino/craps_ux.go +++ b/internal/casino/craps_ux.go @@ -17,9 +17,9 @@ func (t *crapsTable) commands(name string) []MenuCommand { 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"}) + MenuCommand{Command: "bet ", Desc: "New amount on pass; the dice roll 3 ticks after a bet"}) } else { - commands = append(commands, MenuCommand{Command: "bet ", Desc: "Wager on pass; the dice roll 3 seconds after a bet"}) + commands = append(commands, MenuCommand{Command: "bet ", Desc: "Wager on pass; the dice roll 3 ticks after a bet"}) } } else { target := "" @@ -31,7 +31,7 @@ func (t *crapsTable) commands(name string) []MenuCommand { } if p.HasBet { if !simplified { - if t.shooter() == p && (!t.multiplayer || t.roundElapsed >= crapsBetDelay) && t.pendingRoll == 0 { + if t.shooter() == p && (!t.multiplayer || t.roundTicks >= crapsBetTicks) && t.pendingRollTicks == 0 { commands = append(commands, MenuCommand{Command: "roll", Desc: "Shoot now"}) } if !p.autoRoll { diff --git a/internal/casino/craps_variants.go b/internal/casino/craps_variants.go index 096bfc5..8331d70 100644 --- a/internal/casino/craps_variants.go +++ b/internal/casino/craps_variants.go @@ -199,7 +199,7 @@ func (c CrapsConfig) variantRulesText() string { rules := "" switch c.variant() { case "simplified": - 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." + 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 ticks 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": @@ -218,13 +218,13 @@ 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." } - 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." + cadence := "Solo roll anytime; multiplayer waits 10 ticks, then dealer rolls after 60 ticks." + autoroll := "Bets stay open until the roll. autoroll repeats every 3 ticks, respecting the betting window." betLine := "Bet: bet on pass; the felt is authoritative for this edition." stopLine := "stop cancels autoroll; manual stop 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." + cadence = "The dice roll automatically 3 ticks after the first bet." autoroll = "" stopLine = "stop waits for live bets." } diff --git a/internal/casino/manager.go b/internal/casino/manager.go index 5fa6439..c9dde5a 100644 --- a/internal/casino/manager.go +++ b/internal/casino/manager.go @@ -10,7 +10,6 @@ import ( "strconv" "strings" "sync" - "time" ) type Manager struct { @@ -162,12 +161,10 @@ func (m *Manager) snapshotMachines() []*Machine { return machines } -func (m *Manager) Tick() []Event { return m.TickElapsed(600 * time.Millisecond) } - -// TickElapsed supplies the engine cadence to games with real-time deadlines. -// Legacy games retain their tick-based rules. No wall clock or goroutine is -// introduced, so owner ordering and deterministic tests remain intact. -func (m *Manager) TickElapsed(elapsed time.Duration) []Event { +// Tick advances every table and machine by one engine tick. Game rules are +// tick-based, so no wall-clock duration crosses this boundary; owner ordering +// and deterministic tests remain intact. +func (m *Manager) Tick() []Event { tables := m.snapshotTables() sort.Slice(tables, func(i, j int) bool { if tables[i].RoomID != tables[j].RoomID { @@ -177,11 +174,7 @@ func (m *Manager) TickElapsed(elapsed time.Duration) []Event { }) var events []Event for _, table := range tables { - if game, ok := table.game.(interface{ tickElapsed(time.Duration) []Event }); ok { - events = append(events, game.tickElapsed(elapsed)...) - } else { - events = append(events, table.tick()...) - } + events = append(events, table.tick()...) } machines := m.snapshotMachines() sort.Slice(machines, func(i, j int) bool { diff --git a/internal/game/casino.go b/internal/game/casino.go index 21f9627..dc9f8f0 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -6,7 +6,6 @@ import ( "os" "strconv" "strings" - "time" "unicode/utf8" "thehouseoficarus/internal/cardart" @@ -542,11 +541,7 @@ func (g *Game) executeTableAction(sess *net.Session, action string) { func (g *Game) CasinoTick() { if g.Casino != nil { - ms := 600 - if g.Ticks != nil { - ms = g.Ticks.TickLengthMilliseconds() - } - g.emitCasinoEvents(g.Casino.TickElapsed(time.Duration(ms) * time.Millisecond)) + g.emitCasinoEvents(g.Casino.Tick()) } } diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go index d65cfb9..2650d47 100644 --- a/internal/game/casino_ux.go +++ b/internal/game/casino_ux.go @@ -260,16 +260,10 @@ func (g *Game) seesCasinoDetails(sess *net.Session, event casino.Event) bool { var casinoTicksPattern = regexp.MustCompile(`([0-9]+) ticks`) -// Casino logic counts engine ticks; present deadlines in actual configured seconds. +// Casino logic counts engine ticks; deadlines are presented in ticks too, so +// the configured tick cadence never rewrites a deadline the player can count. func (g *Game) casinoTimeText(text string) string { - ms := 600 - if g.Ticks != nil { - ms = g.Ticks.TickLengthMilliseconds() - } - return casinoTicksPattern.ReplaceAllStringFunc(text, func(s string) string { - n, _ := strconv.Atoi(strings.Fields(s)[0]) - return fmt.Sprintf("%g seconds", float64(n)*float64(ms)/1000) - }) + return casinoTicksPattern.ReplaceAllString(text, "${1}t") } // betRangeWords renders the wager range in words for one-line hints. diff --git a/internal/game/casino_ux_test.go b/internal/game/casino_ux_test.go index 1a06f89..c83db27 100644 --- a/internal/game/casino_ux_test.go +++ b/internal/game/casino_ux_test.go @@ -228,3 +228,18 @@ func TestRebetRepeatsWager(t *testing.T) { t.Fatalf("rebet did not target banker: %q", stripANSI(string(conn.data))) } } + +// TestCasinoTimeTextUsesTicks pins the presentation unit: authored "N ticks" +// deadlines display as "Nt" rather than being rewritten into seconds. +func TestCasinoTimeTextUsesTicks(t *testing.T) { + g := &Game{} + for in, want := range map[string]string{ + "Your turn | 25 ticks remaining": "Your turn | 25t remaining", + "Betting closes in 5 ticks.": "Betting closes in 5t.", + "no deadline here": "no deadline here", + } { + if got := g.casinoTimeText(in); got != want { + t.Fatalf("casinoTimeText(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/game/craps_test.go b/internal/game/craps_test.go index 27610bd..6712455 100644 --- a/internal/game/craps_test.go +++ b/internal/game/craps_test.go @@ -43,7 +43,7 @@ func TestCrapsCommandsAndRendering(t *testing.T) { for _, unicode := range []bool{false, true} { art := renderCrapsSnapshot(snap, mode, unicode) plain := plainRoulette(art) - for _, word := range []string{"PASS LINE", "DON'T COME", "FIELD", "FIRE", "ALL SMALL", "Total: 7", "POINT 6"} { + for _, word := range []string{"PASS LINE", "DON'T COME", "FIELD", "FIRE", "SMALL", "Total: 7", "POINT 6"} { if !strings.Contains(plain, word) { t.Fatal("missing", word) } diff --git a/internal/game/craps_variants_test.go b/internal/game/craps_variants_test.go index 46f5be6..c538caa 100644 --- a/internal/game/craps_variants_test.go +++ b/internal/game/craps_variants_test.go @@ -34,7 +34,7 @@ func TestCrapsVariantRendering(t *testing.T) { t.Fatal("dice rendered instead of cards") } } else { - if strings.Contains(plain, "FIRE") || strings.Contains(plain, "ALL SMALL") { + if strings.Contains(plain, "FIRE") || strings.Contains(plain, "SMALL") { t.Fatal(v, "unsupported bonus display") } } @@ -59,8 +59,8 @@ func TestCrapsVariantRendering(t *testing.T) { } for n := 1; n <= 6; n++ { text := renderCrapsDice([2]int{n, 0}, "none", false) - if strings.Count(text, "+-------+") != 2 { - t.Fatal("single die needs a top and bottom outline", text) + if strings.Count(text, "\n") != 5 { + t.Fatal("single die needs three pip rows and two caps", text) } if got := strings.Count(strings.ReplaceAll(text, "Total:", ""), "o"); got != n { t.Fatalf("single die %d rendered %d pips", n, got) diff --git a/internal/game/render_craps.go b/internal/game/render_craps.go index 7ec015a..ce9825f 100644 --- a/internal/game/render_craps.go +++ b/internal/game/render_craps.go @@ -20,8 +20,7 @@ const ( crapsMarked crapsPuckOn crapsPuckOff - crapsFire - crapsAts + crapsBonus ) var crapsPaint = []color.ColorSpec{ @@ -32,8 +31,7 @@ var crapsPaint = []color.ColorSpec{ {Fg: 0, Bg: 220, Bold: true}, {Fg: 0, Bg: 15, Bold: true}, {Fg: 15, Bg: 0, Bold: true}, - {Fg: 229, Bg: 124, Bold: true}, // fire tracker: yellow on red - {Fg: 229, Bg: 24, Bold: true}, // ATS tracker: yellow on blue + {Fg: 229, Bg: 24, Bold: true}, // Fire/ATS tracker: yellow on blue } type crapsCell struct { @@ -94,8 +92,39 @@ func (c *crapsCanvas) text(x, y int, s string, ink int) { func (c *crapsCanvas) center(x1, x2, y int, s string, ink int) { c.text(x1+(x2-x1+1-color.VisibleLen(s))/2, y, s, ink) } + +// crapsJoints maps a union of box edges (N=1, E=2, S=4, W=8) to its glyph. +var crapsJoints = map[uint8]rune{3: '└', 6: '┌', 9: '┘', 12: '┐', 7: '├', 11: '┴', 13: '┤', 14: '┬', 15: '┼'} + +// crapsEdgeRune is the box glyph for a cell's edges, downgraded to ASCII when +// unicode is off. +func crapsEdgeRune(cell crapsCell, unicode bool) rune { + if cell.edges == 0 { + return ' ' + } + var r rune + switch { + case cell.edges&5 == 0: + r = '─' + case cell.edges&10 == 0: + r = '│' + default: + r = crapsJoints[cell.edges] + } + if !unicode { + switch r { + case '─': + r = '-' + case '│': + r = '|' + default: + r = '+' + } + } + return r +} + func (c *crapsCanvas) render(mode string, unicode bool) string { - joints := map[uint8]rune{3: '└', 6: '┌', 9: '┘', 12: '┐', 7: '├', 11: '┴', 13: '┤', 14: '┬', 15: '┼'} var out strings.Builder for _, row := range c.cells { var run strings.Builder @@ -113,27 +142,7 @@ func (c *crapsCanvas) render(mode string, unicode bool) string { } r := cell.glyph if r == 0 { - r = ' ' - if cell.edges != 0 { - switch { - case cell.edges&5 == 0: - r = '─' - case cell.edges&10 == 0: - r = '│' - default: - r = joints[cell.edges] - } - if !unicode { - switch r { - case '─': - r = '-' - case '│': - r = '|' - default: - r = '+' - } - } - } + r = crapsEdgeRune(cell, unicode) } run.WriteRune(r) } @@ -174,48 +183,38 @@ func crapsDrawPuck(c *crapsCanvas, x, y int, on, unicode bool) { c.text(x, y+2, d, ink) } func renderCrapsTrackers(s *casino.CrapsSnapshot, mode string, unicode bool) string { - c := newCrapsCanvas(12) - // Section backgrounds first: fire red, ATS blue, yellow lettering on top. - c.fill(0, 0, 76, 5, crapsFire) - c.fill(0, 5, 76, 11, crapsAts) + c := newCrapsCanvas(7) + // The whole Fire/ATS tracker sits on one blue field. + c.fill(0, 0, 76, 6, crapsBonus) c.box(0, 0, 76, 2) - c.box(0, 5, 38, 8) - c.box(38, 5, 76, 8) - c.text(3, 1, "FIRE", crapsFire) + c.box(0, 4, 38, 6) + c.box(38, 4, 76, 6) + c.text(3, 1, "FIRE", crapsBonus) for i, n := range []int{4, 5, 6, 8, 9, 10} { - ink, mark := crapsFire, " " + ink := crapsBonus if crapsHas(s.Made, n) { - ink, mark = crapsMarked, "*" + ink = crapsMarked } - c.text(15+i*9, 1, fmt.Sprintf("[%2d%s]", n, mark), ink) + c.text(15+i*9, 1, fmt.Sprintf("[%2d]", n), ink) } bounds := []int{0, 25, 50, 76} for i, payout := range []string{"24:1", "249:1", "999:1"} { lo, hi := bounds[i], bounds[i+1] - c.box(lo, 2, hi, 5) - c.center(lo+1, hi-1, 3, fmt.Sprintf("%d DISTINCT POINTS", i+4), crapsFire) - c.center(lo+1, hi-1, 4, payout, crapsFire) + c.box(lo, 2, hi, 4) + c.center(lo+1, hi-1, 3, fmt.Sprintf("%d POINTS %s", i+4, payout), crapsBonus) } - c.text(2, 6, "ALL SMALL", crapsAts) - c.text(40, 6, "TALL", crapsAts) + c.text(2, 5, "SMALL", crapsBonus) + c.text(40, 5, "TALL", crapsBonus) for i, n := range []int{2, 3, 4, 5, 6, 8, 9, 10, 11, 12} { x := 12 + i*5 if i >= 5 { x = 48 + (i-5)*5 } - ink, mark := crapsAts, " " + ink := crapsBonus if crapsHas(s.Seen, n) { - ink, mark = crapsMarked, "*" + ink = crapsMarked } - c.text(x, 6, fmt.Sprintf("%2d%s ", n, mark), ink) - } - c.center(1, 37, 7, "* = rolled", crapsAts) - c.center(39, 75, 7, "* = rolled", crapsAts) - for i, row := range [][2]string{{"SMALL", "34:1"}, {"ALL", "175:1"}, {"TALL", "34:1"}} { - lo, hi := bounds[i], bounds[i+1] - c.box(lo, 8, hi, 11) - c.center(lo+1, hi-1, 9, row[0], crapsAts) - c.center(lo+1, hi-1, 10, row[1], crapsAts) + c.text(x, 5, fmt.Sprintf("[%2d]", n), ink) } return c.render(mode, unicode) } @@ -261,14 +260,16 @@ func renderCrapsFelt(s *casino.CrapsSnapshot, mode string, unicode bool) string c.box(0, 22, 50, 25) c.center(1, 49, 24, "FREE ODDS / BEHIND THE LINE", crapsInk) // Centre action: four bonus spots, four hardways, six propositions, horn/hop. - bonus := []struct { - label, pays string - }{{"FIRE", "24x"}, {"SMALL", "34:1"}, {"ALL", "175:1"}, {"TALL", "34:1"}} + // The Fire and ATS spots share one blue field. + bonus := []struct{ label, pays string }{{"FIRE", "24x"}, {"SMALL", "34:1"}, {"ALL", "175:1"}, {"TALL", "34:1"}} xs := []int{50, 56, 63, 69, 76} - for i, spot := range bonus { + for i := range bonus { c.box(xs[i], 0, xs[i+1], 5) - c.center(xs[i]+1, xs[i+1]-1, 2, spot.label, crapsGold) - c.center(xs[i]+1, xs[i+1]-1, 4, spot.pays, crapsMuted) + } + c.fill(50, 0, 76, 5, crapsBonus) + for i, spot := range bonus { + c.center(xs[i]+1, xs[i+1]-1, 2, spot.label, crapsBonus) + c.center(xs[i]+1, xs[i+1]-1, 4, spot.pays, crapsBonus) } for i, n := range []int{4, 6, 8, 10} { x, y := 50+(i%2)*13, 5+(i/2)*4 @@ -315,12 +316,12 @@ func renderHighPointFelt(s *casino.CrapsSnapshot, mode string, unicode bool) str c.box(0, 0, 76, 15) c.center(1, 75, 1, "HIGH POINT CRAPS", crapsGold) c.center(1, 75, 2, "COME-OUT 2 OR 3: IGNORED | 11 OR 12: WINS", crapsInk) - nums := []int{4, 5, 6, 8, 9, 10} - labels := []string{"4", "5", "SIX", "8", "NINE", "10"} + nums := []int{4, 5, 6, 7, 8, 9, 10} + labels := []string{"4", "5", "SIX", "7", "8", "NINE", "10"} for i, n := range nums { - x := 2 + i*12 - c.box(x, 5, x+11, 10) - c.center(x+1, x+10, 9, labels[i], crapsGold) + x := 3 + i*10 + c.box(x, 5, x+10, 10) + c.center(x+1, x+9, 9, labels[i], crapsGold) if s.Point == n { crapsDrawPuck(c, x+3, 6, true, unicode) } @@ -392,47 +393,59 @@ func renderDieRichFelt(s *casino.CrapsSnapshot, mode string, unicode bool) strin return c.render(mode, unicode) } -// renderCrapsDice paints each face as a white tile with black pips. The tile -// is five interior rows tall (three pip rows plus white padding top and -// bottom) inside a box outline, so the face reads as a square die. +// renderCrapsDice uses half-block caps and white backgrounds to make a +// square tile in a terminal's roughly 1:2 character cells. Pips are inset; +// monochrome clients get an outlined face rather than disconnected dots. func renderCrapsDice(dice [2]int, mode string, unicode bool) string { 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"}} - pip := "o" - if unicode { - pip = "●" - } - top, bottom, v := "+-------+", "+-------+", "|" - if unicode { - top, bottom, v = "╭───────╮", "╰───────╯", "│" - } - spec := color.ColorSpec{Fg: 0, Bg: 15, Bold: true} + patterns := [7][3]string{ + {}, + {" ", " ● ", " "}, + {" ● ", " ", " ● "}, + {" ● ", " ● ", " ● "}, + {" ● ● ", " ", " ● ● "}, + {" ● ● ", " ● ", " ● ● "}, + {" ● ● ", " ● ● ", " ● ● "}, + } + colored := mode != "" && mode != "none" + face := color.ColorSpec{Fg: 0, Bg: 15} + cap := color.ColorSpec{Fg: 15, Bg: -1} var out strings.Builder - for row := 0; row < 7; row++ { + for row := 0; row < 5; row++ { out.WriteString(" ") for i, n := range dice { if n == 0 { continue } - var line string - switch row { - case 0: - line = top - case 6: - line = bottom - case 1, 5: - line = v + " " + v - default: - line = v + " " + strings.ReplaceAll(patterns[n][row-2], "o", pip) + " " + v - } - out.WriteString(color.Render(mode, spec, line)) - if i == 0 { + if i > 0 { out.WriteString(" ") } + line := "" + spec := face + if row == 0 || row == 4 { + line = " " + if unicode && colored { + line, spec = "▄▄▄▄▄▄▄", cap + if row == 4 { + line = "▀▀▀▀▀▀▀" + } + } else if !colored { + line = "+-----+" + } + } else { + line = patterns[n][row-1] + if !colored { + line = "|" + strings.TrimPrefix(strings.TrimSuffix(line, " "), " ") + "|" + } + if !unicode { + line = strings.ReplaceAll(line, "●", "o") + } + } + out.WriteString(color.Render(mode, spec, line)) } - if row == 3 { + if row == 2 { out.WriteString(color.Render(mode, color.ColorSpec{Fg: 229, Bg: -1, Bold: true}, fmt.Sprintf(" Total: %d", dice[0]+dice[1]))) } out.WriteByte('\n') @@ -506,16 +519,16 @@ func renderCrapsSnapshot(s *casino.CrapsSnapshot, mode string, unicode bool, sty hint = "" if s.Variant == "simplified" { if s.ShootIn > 0 { - hint = fmt.Sprintf("The dice roll in %ds.", s.ShootIn) + hint = fmt.Sprintf("The dice roll in %dt.", s.ShootIn) } } else if s.ShootIn > 0 { - hint = fmt.Sprintf("Bets open. Shooter ready in %ds.", s.ShootIn) + hint = fmt.Sprintf("Bets open. Shooter ready in %dt.", s.ShootIn) } else if s.DealerIn > 0 { - hint = fmt.Sprintf("Shooter ready. Dealer rolls in %ds; bets still open.", s.DealerIn) + hint = fmt.Sprintf("Shooter ready. Dealer rolls in %dt; bets still open.", s.DealerIn) } } if s.AutoRoll { - hint = fmt.Sprintf("AUTOROLL / Shooter's next roll in %ds", s.AutoIn) + hint = fmt.Sprintf("AUTOROLL / Shooter's next roll in %dt", s.AutoIn) } if hint != "" { out.WriteString(subtle(" "+hint) + "\n") diff --git a/internal/game/render_craps_dice_test.go b/internal/game/render_craps_dice_test.go new file mode 100644 index 0000000..e691fd5 --- /dev/null +++ b/internal/game/render_craps_dice_test.go @@ -0,0 +1,82 @@ +package game + +import ( + "fmt" + "strings" + "testing" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/color" +) + +func TestCrapsDiceTilesAndFallbacks(t *testing.T) { + for _, mode := range []string{"xterm256", "ansi", "none", ""} { + for _, unicode := range []bool{true, false} { + for a := 1; a <= 6; a++ { + for b := 0; b <= 6; b++ { + art := renderCrapsDice([2]int{a, b}, mode, unicode) + plain := plainRoulette(art) + rows := strings.Split(strings.TrimSuffix(plain, "\n"), "\n") + if len(rows) != 5 { + t.Fatal("tile height", rows) + } + width := 9 // two-column indent and one seven-column die + if b != 0 { + width += 10 + } + for i, row := range rows { + if i == 2 { + row = strings.TrimSuffix(row, fmt.Sprintf(" Total: %d", a+b)) + } + if color.VisibleLen(row) != width { + t.Fatalf("uneven dice: %q", plain) + } + } + pip := "●" + if !unicode { + pip = "o" + for _, r := range plain { + if r > 127 { + t.Fatal("Unicode in ASCII fallback") + } + } + } + if strings.Count(strings.ReplaceAll(plain, "Total:", ""), pip) != a+b { + t.Fatal("incorrect pips", a, b, plain) + } + if mode == "none" || mode == "" { + if strings.Contains(art, "\x1b") || !strings.Contains(plain, "+-----+") { + t.Fatal("missing monochrome outline", art) + } + } else { + if !strings.Contains(art, color.BgCode(mode, 15)) || !strings.Contains(art, color.FgCode(mode, 0)) { + t.Fatal("missing black-on-white face", art) + } + if unicode && (!strings.Contains(plain, "▄▄▄▄▄▄▄") || !strings.Contains(plain, "▀▀▀▀▀▀▀")) { + t.Fatal("missing block caps", art) + } + } + } + } + } + } + for _, dice := range [][2]int{{0, 0}, {-1, 2}, {7, 1}, {1, -1}, {1, 7}} { + if renderCrapsDice(dice, "xterm256", true) != "" { + t.Fatal("rendered invalid dice", dice) + } + } +} + +func TestHighPointFeltIncludesSevenPoint(t *testing.T) { + for n := 4; n <= 10; n++ { + art := plainRoulette(renderHighPointFelt(&casino.CrapsSnapshot{Point: n}, "none", true)) + if strings.Count(art, "ON ") != 1 { + t.Fatalf("point %d missing its puck:\n%s", n, art) + } + for _, row := range strings.Split(strings.TrimSuffix(art, "\n"), "\n") { + if color.VisibleLen(row) != crapsCanvasWidth { + t.Fatal("felt width changed") + } + } + } +} diff --git a/internal/game/render_craps_test.go b/internal/game/render_craps_test.go index b3cc1d8..49bda4a 100644 --- a/internal/game/render_craps_test.go +++ b/internal/game/render_craps_test.go @@ -57,22 +57,70 @@ func TestCrapsHalfTableGeometryAndPuck(t *testing.T) { } t.Log("\n" + renderCrapsSnapshot(&casino.CrapsSnapshot{Point: 6, Field12: 3, Dice: [2]int{3, 3}, Queue: []string{"Alice", "Bob"}, Made: []int{4, 5}, Seen: []int{2, 4, 5, 6, 9}, Phase: "betting", ShootIn: 10, DealerIn: 70}, "none", true)) } + +// TestCrapsFireTrackerHighlightsMadePoints pins the Fire half of the tracker: +// made points highlight, un-made points do not, and no asterisk is involved. +func TestCrapsFireTrackerHighlightsMadePoints(t *testing.T) { + s := &casino.CrapsSnapshot{Point: 6, Made: []int{4, 9}} + art := renderCrapsTrackers(s, "xterm256", true) + fireRow := "" + for _, line := range strings.Split(art, "\n") { + if strings.Contains(line, "FIRE") { + fireRow = line + } + } + if got := strings.Count(fireRow, color.BgCode("xterm256", 220)); got != 2 { + t.Fatalf("expected 2 highlighted fire points, got %d: %q", got, plainRoulette(art)) + } + plain := plainRoulette(art) + for _, want := range []string{"[ 4]", "[ 9]"} { + if !strings.Contains(plain, want) { + t.Fatalf("missing fire point %s: %q", want, plain) + } + } + if strings.Contains(plain, "*") { + t.Fatalf("fire tracker still uses an asterisk: %q", plain) + } +} func TestCrapsTrackersAndDiceStyles(t *testing.T) { s := &casino.CrapsSnapshot{Point: 6, Made: []int{4, 6}, Seen: []int{2, 12}} art := renderCrapsTrackers(s, "xterm256", true) plain := plainRoulette(art) - for _, token := range []string{"[ 4*]", "[ 5 ]", "[ 6*]", " 2*", "12*"} { + // Both trackers bracket every number; made/rolled numbers are shown by the + // highlight alone, with no asterisk. + for _, token := range []string{"[ 4]", "[ 5]", "[ 6]", "[ 2]", "[12]"} { if !strings.Contains(plain, token) { t.Fatal("tracker missing", token) } } + if strings.Contains(plain, "*") || strings.Contains(plain, "= rolled") { + t.Fatalf("tracker still uses asterisks or the rolled legend: %q", plain) + } if strings.Count(art, color.BgCode("xterm256", 220)) != 4 { - t.Fatal("wrong number of filled tracker tokens") + t.Fatal("wrong number of highlighted tracker tokens") + } + if !strings.Contains(art, color.BgCode("xterm256", 24)) { + t.Fatal("tracker background is not blue") + } + if strings.Contains(art, color.BgCode("xterm256", 124)) { + t.Fatal("tracker still uses a red background") + } + // The ATS payout panel now lives only on the main felt, and the fire odds + // share a single row. + if strings.Contains(plain, "DISTINCT POINTS") || strings.Contains(plain, "34:1") || strings.Contains(plain, "175:1") { + t.Fatalf("tracker still shows a payout panel: %q", plain) + } + if !strings.Contains(plain, "4 POINTS 24:1") { + t.Fatalf("fire odds not collapsed to one row: %q", plain) + } + feltArt := renderCrapsFelt(s, "xterm256", true) + if !strings.Contains(feltArt, color.BgCode("xterm256", 24)) { + t.Fatal("felt Fire/ATS spots are not blue") } - if !strings.Contains(art, color.BgCode("xterm256", 124)) || !strings.Contains(art, color.BgCode("xterm256", 24)) { - t.Fatal("fire/ATS tracker backgrounds are not red/blue") + if strings.Contains(feltArt, color.BgCode("xterm256", 124)) { + t.Fatal("felt still uses a red bonus background") } - if !strings.Contains(renderCrapsFelt(s, "xterm256", true), color.BgCode("xterm256", 22)) { + if !strings.Contains(feltArt, color.BgCode("xterm256", 22)) { t.Fatal("felt not dark green") } g := &Game{} -- cgit v1.2.3