diff options
| author | historia <[not public]> | 2026-10-02 17:51:46 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-10-02 17:51:46 -0400 |
| commit | cac16d1725c46c82a2e62b84c93f7090b2ce0746 (patch) | |
| tree | e54f6624bdeb47421587656757ef3ce4fd7ebb3f /internal/casino | |
| parent | 4f40f116ba884111c9a66849e620ff9fbc67530a (diff) | |
| download | thehouseoficarus-cac16d1725c46c82a2e62b84c93f7090b2ce0746.tar.gz | |
feat: craps table color improvements, die improvements
Diffstat (limited to 'internal/casino')
| -rw-r--r-- | internal/casino/command_hints_test.go | 3 | ||||
| -rw-r--r-- | internal/casino/craps.go | 72 | ||||
| -rw-r--r-- | internal/casino/craps_clock.go | 92 | ||||
| -rw-r--r-- | internal/casino/craps_clock_test.go | 75 | ||||
| -rw-r--r-- | internal/casino/craps_projection.go | 35 | ||||
| -rw-r--r-- | internal/casino/craps_review_test.go | 51 | ||||
| -rw-r--r-- | internal/casino/craps_rules.go | 4 | ||||
| -rw-r--r-- | internal/casino/craps_simplified_test.go | 23 | ||||
| -rw-r--r-- | internal/casino/craps_test.go | 26 | ||||
| -rw-r--r-- | internal/casino/craps_ux.go | 6 | ||||
| -rw-r--r-- | internal/casino/craps_variants.go | 8 | ||||
| -rw-r--r-- | internal/casino/manager.go | 17 |
12 files changed, 244 insertions, 168 deletions
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 <amount>", Desc: "New amount on pass; the dice roll 3 seconds after a bet"}) + MenuCommand{Command: "bet <amount>", Desc: "New amount on pass; the dice roll 3 ticks after a bet"}) } else { - commands = append(commands, MenuCommand{Command: "bet <amount>", Desc: "Wager on pass; the dice roll 3 seconds after a bet"}) + commands = append(commands, MenuCommand{Command: "bet <amount>", Desc: "Wager on pass; the dice roll 3 ticks after a bet"}) } } else { target := "<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 <amount> 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 <amount> — 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 { |
