package casino import ( "strings" "testing" "time" ) func crapsEventText(events []Event) string { var s strings.Builder for _, e := range events { s.WriteString(e.Message) s.WriteByte('\n') s.WriteString(e.PrivateMessage) } return s.String() } func TestCrapsMultiplayerClockAndLateBets(t *testing.T) { tb := crapsFixture() tb.join("alice") tb.join("bob") w := &testWallet{chips: 1000} tb.bet("alice", "pass", 10, w) 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") { t.Fatal("early warning") } events := tb.tickElapsed(time.Second) if !strings.Contains(crapsEventText(events), "Last call for bets! 3 seconds until alice can shoot!") { t.Fatal(events) } if !events[0].Public { t.Fatal("warning not broadcast") } tb.action("alice", "roll") if tb.dice != [2]int{} { t.Fatal("early shoot") } tb.tickElapsed(3 * time.Second) if tb.view().ShootIn != 0 || tb.view().DealerIn != 60 { t.Fatal(tb.view()) } tb.bet("alice", "field", 10, w) if len(w.open) != 2 { t.Fatal("late bet rejected") } tb.action("alice", "unbet field") if len(tb.players.get("alice").bets) != 1 { t.Fatal("late removal rejected") } if strings.Contains(crapsEventText(tb.tickElapsed(59*time.Second)), "Last call") { t.Fatal("warning repeated") } if tb.dice != [2]int{} { t.Fatal("early forced roll") } tb.tickElapsed(time.Second) if tb.dice == [2]int{} { t.Fatal("60 second dealer fallback missing") } } func TestCrapsSoloAndRosterTransitions(t *testing.T) { tb := crapsFixture() tb.join("alice") w := &testWallet{chips: 1000} tb.bet("alice", "fire", 1, w) tb.tickElapsed(500 * time.Second) if tb.dice != [2]int{} || tb.view().DealerIn != 0 || tb.view().ShootIn != 0 { t.Fatal("solo deadline", tb.view()) } tb.action("alice", "roll") if tb.dice == [2]int{} { t.Fatal("solo could not roll") } tb.join("bob") if tb.view().ShootIn != 10 { t.Fatal("join did not give bettors a window") } tb.tickElapsed(4 * time.Second) tb.bet("alice", "field", 10, w) if tb.view().ShootIn != 6 { t.Fatal("bet extended timer") } tb.leave("bob") if tb.view().ShootIn != 0 || tb.view().DealerIn != 0 { t.Fatal("departure left forced timer") } tb.join("bob") tb.action("bob", "sitout") if tb.view().ShootIn != 0 { t.Fatal("sit-out counts as playing") } tb.refundAll() if tb.hasLiveBets() { t.Fatal("refund retained wagers") } } func TestCrapsAutorollPaceAndStop(t *testing.T) { tb := crapsFixture() tb.join("alice") w := &testWallet{chips: 1000} tb.bet("alice", "fire", 1, w) tb.action("alice", "autoroll") tb.tickElapsed(2999 * time.Millisecond) if tb.dice != [2]int{} { t.Fatal("autoroll fired early") } tb.tickElapsed(time.Millisecond) if tb.dice == [2]int{} { t.Fatal("autoroll did not fire") } if p := tb.players.get("alice"); p.HasBet && p.autoElapsed != 0 { t.Fatal("pace not reset") } handle := &Table{tableBase: tb.tableBase, game: tb} tb.players.get("alice").autoRoll = true if _, stopped := handle.StopAutoRoll("alice"); !stopped { t.Fatal("stop failed") } before := tb.dice tb.tickElapsed(500 * time.Second) if tb.dice != before || !tb.hasParticipant("alice") { t.Fatal("stop rolled or departed") } if _, stopped := handle.StopAutoRoll("alice"); stopped { t.Fatal("manual stop incorrectly consumed") } // A one-roll bet ends autoroll immediately, regardless of the outcome. tb = crapsFixture() tb.join("alice") w = &testWallet{chips: 100} tb.bet("alice", "field", 10, w) tb.action("alice", "autoroll") tb.tickElapsed(3 * time.Second) 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) if tb.dice != before { t.Fatal("extra roll after completion") } } func TestCrapsAutorollRespectsOtherPlayers(t *testing.T) { tb := crapsFixture() tb.join("alice") tb.join("bob") w := &testWallet{chips: 100} tb.bet("alice", "field", 10, w) tb.action("alice", "autoroll") tb.tickElapsed(3 * time.Second) if tb.dice != [2]int{} { t.Fatal("autoroll stole multiplayer betting time") } tb.tickElapsed(7 * time.Second) if tb.dice == [2]int{} { t.Fatal("autoroll failed when ready") } // A non-shooter's opt-in never takes the dice away from the current shooter. tb = crapsFixture() tb.join("alice") tb.join("bob") w = &testWallet{chips: 100} tb.bet("bob", "field", 10, w) tb.action("bob", "autoroll") tb.tickElapsed(11 * time.Second) 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 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 RESULTS ON YOUR BETS") { t.Fatal("no post-bet roll table") } tb.bet("alice", "dontpass", 10, w) tb.resolveRoll(2, 3) events = tb.resolveRoll(3, 4) balances, settlements := 0, 0 for _, e := range events { if e.ShowBalance { balances++ } if strings.Contains(e.Message, "Pass [5]:") || strings.Contains(e.Message, "Pass [5]") { settlements++ if !strings.Contains(e.Message, "Don't pass [5]") { t.Fatal("not combined", e.Message) } } } if balances != 1 || settlements != 1 { t.Fatal("duplicate balance or separate results", balances, settlements, crapsEventText(events)) } text := crapsEventText(events) for _, ugly := range []string{"returned (stake", "Craps: bets open", "Phase: waiting"} { if strings.Contains(text, ugly) { t.Fatal(ugly) } } if !strings.Contains(text, "{casino_result_loss}") || !strings.Contains(text, "{casino_result_win}") { t.Fatal("missing outcome styles") } } func TestCrapsUnbetAllAndRollTableFormat(t *testing.T) { tb := crapsFixture() tb.join("alice") w := &testWallet{chips: 1000} tb.bet("alice", "fire", 1, w) tb.bet("alice", "pass", 10, w) tb.resolveRoll(3, 3) tb.bet("alice", "odds pass", 20, w) tb.bet("alice", "place 4", 10, w) tb.bet("alice", "field", 10, w) before := w.paid events := tb.action("alice", "unbet all") text := crapsEventText(events) if !strings.Contains(text, "take down {casino_chips}40{/}") || !strings.Contains(text, "contracts stay up") { t.Fatal(text) } if w.paid != before+40 { t.Fatal("refund amount", w.paid-before) } bets := tb.players.get("alice").bets if len(bets) != 2 || bets[0].kind != "fire" || bets[1].kind != "pass" || bets[1].odds.Total != 0 { t.Fatal("contracts or odds mishandled", bets) } if tb.hasLiveBets() != true || len(w.open) != 2 { t.Fatal("pass and fire stakes must stay pending", w.open) } tb.action("alice", "unbet all") if w.paid != before+40 { t.Fatal("second unbet all moved money", w.paid) } // Roll table formatting: grouped totals, single-level tags only. tb2 := crapsFixture() tb2.join("alice") w2 := &testWallet{chips: 100} tb2.bet("alice", "pass", 1, w2) table := tb2.rollTable(tb2.players.get("alice")) for _, tag := range []string{"{casino_payout}7{/}", "{casino_result_win}+1{/}", "{casino_payout}2{/}", "{casino_result_loss}-1{/}"} { if !strings.Contains(table, tag) { t.Fatal("missing", tag, table) } } if strings.Contains(table, "{casino_muted}") || strings.Contains(table, "{casino_result_win}+{") { t.Fatal("nested or unknown tag", table) } } func TestCrapsRollTableHardPairLabels(t *testing.T) { tb := crapsFixture() tb.join("alice") w := &testWallet{chips: 100} tb.bet("alice", "hard 8", 10, w) tb.bet("alice", "hop 2 3", 10, w) tb.point = 8 table := tb.rollTable(tb.players.get("alice")) for _, want := range []string{"{casino_payout}4+4{/}", "{casino_result_win}+90{/}", "{casino_payout}2+3{/}", "{casino_result_win}+150{/}", "{casino_payout}easy 8{/}", "{casino_payout}any other{/}"} { if !strings.Contains(table, want) { t.Fatal("missing", want, table) } } // Bets paying on the same total combine into one row. tb2 := crapsFixture() tb2.join("alice") w2 := &testWallet{chips: 100} tb2.bet("alice", "field", 10, w2) tb2.bet("alice", "place 8", 12, w2) tb2.point = 8 table = tb2.rollTable(tb2.players.get("alice")) if strings.Count(table, "{casino_payout}8{/}") != 1 || !strings.Contains(table, "{casino_result_win}+4{/}") { t.Fatal("same-label payouts must combine", table) } // Hard bets alone on the come-out pay nothing and say so. tb3 := crapsFixture() tb3.join("alice") w3 := &testWallet{chips: 100} tb3.bet("alice", "hard 8", 10, w3) if text := tb3.rollTable(tb3.players.get("alice")); !strings.Contains(text, "OFF on the come-out") { t.Fatal(text) } }