diff options
| author | historia <[not public]> | 2026-08-03 16:18:17 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-08-03 16:19:25 -0400 |
| commit | f2c4a58727169a03b8102704888d1579d26b28c8 (patch) | |
| tree | a334f9cd37b3c7521c11d266d797b208dac4f3be /internal/casino/baccarat_test.go | |
| parent | 612e429de438821ae8e099d6b54f87c11ff6c1b5 (diff) | |
| download | thehouseoficarus-f2c4a58727169a03b8102704888d1579d26b28c8.tar.gz | |
feat: multiplayer baccarat game
Diffstat (limited to 'internal/casino/baccarat_test.go')
| -rw-r--r-- | internal/casino/baccarat_test.go | 355 |
1 files changed, 355 insertions, 0 deletions
diff --git a/internal/casino/baccarat_test.go b/internal/casino/baccarat_test.go new file mode 100644 index 0000000..6d3acb1 --- /dev/null +++ b/internal/casino/baccarat_test.go @@ -0,0 +1,355 @@ +package casino + +import ( + "testing" +) + +// tableauShoe builds a shoe that deals the given cards in order; draw pops +// from the end of the slice, so the deal order is stored reversed. +func tableauShoe(dealOrder ...card) *shoe { + cards := make([]card, len(dealOrder)) + for i, c := range dealOrder { + cards[len(dealOrder)-1-i] = c + } + return &shoe{cards: cards} +} + +func forceShoe(t *baccaratTable, dealOrder ...card) { + t.shoe = tableauShoe(dealOrder...) +} + +func TestBaccaratTotal(t *testing.T) { + if got := baccaratTotal([]card{{rank: "K"}, {rank: "Q"}}); got != 0 { + t.Fatalf("faces total=%d, want 0", got) + } + if got := baccaratTotal([]card{{rank: "A"}, {rank: "10"}}); got != 1 { + t.Fatalf("ace+ten total=%d, want 1", got) + } + if got := baccaratTotal([]card{{rank: "9"}, {rank: "8"}, {rank: "7"}}); got != 4 { + t.Fatalf("9+8+7 total=%d, want 4 (mod 10)", got) + } +} + +func TestBaccaratTableauNaturalStands(t *testing.T) { + player, banker := playTableau(tableauShoe( + card{rank: "5"}, card{rank: "3"}, // player 8 (natural) + card{rank: "2"}, card{rank: "3"}, // banker 5 + card{rank: "9"}, card{rank: "9"}, // would-be thirds must stay in the shoe + )) + if len(player) != 2 || len(banker) != 2 { + t.Fatalf("natural drew cards: player=%d banker=%d", len(player), len(banker)) + } +} + +func TestBaccaratTableauPlayerStandsBankerDrawsToFive(t *testing.T) { + player, banker := playTableau(tableauShoe( + card{rank: "4"}, card{rank: "3"}, // player 7, stands + card{rank: "2"}, card{rank: "3"}, // banker 5, draws + card{rank: "9"}, // banker third card + )) + if len(player) != 2 || len(banker) != 3 { + t.Fatalf("player stand: player=%d banker=%d, want 2/3", len(player), len(banker)) + } +} + +func TestBaccaratTableauPlayerStandsBankerStandsOnSix(t *testing.T) { + player, banker := playTableau(tableauShoe( + card{rank: "4"}, card{rank: "3"}, // player 7, stands + card{rank: "4"}, card{rank: "2"}, // banker 6, stands + card{rank: "9"}, + )) + if len(player) != 2 || len(banker) != 2 { + t.Fatalf("banker 6 vs standing player: player=%d banker=%d, want 2/2", len(player), len(banker)) + } +} + +func TestBaccaratTableauBankerThirdCardRules(t *testing.T) { + tests := []struct { + name string + bankerCards [2]card + playerThird card + wantDraw bool + }{ + {"banker 2 always draws", [2]card{{rank: "A"}, {rank: "A"}}, card{rank: "8"}, true}, + {"banker 3 draws vs 5", [2]card{{rank: "2"}, {rank: "A"}}, card{rank: "5"}, true}, + {"banker 3 stands vs 8", [2]card{{rank: "2"}, {rank: "A"}}, card{rank: "8"}, false}, + {"banker 4 draws vs 2", [2]card{{rank: "3"}, {rank: "A"}}, card{rank: "2"}, true}, + {"banker 4 stands vs A", [2]card{{rank: "3"}, {rank: "A"}}, card{rank: "A"}, false}, + {"banker 5 draws vs 4", [2]card{{rank: "4"}, {rank: "A"}}, card{rank: "4"}, true}, + {"banker 5 stands vs 3", [2]card{{rank: "4"}, {rank: "A"}}, card{rank: "3"}, false}, + {"banker 6 draws vs 6", [2]card{{rank: "5"}, {rank: "A"}}, card{rank: "6"}, true}, + {"banker 6 stands vs 5", [2]card{{rank: "5"}, {rank: "A"}}, card{rank: "5"}, false}, + {"banker 7 always stands", [2]card{{rank: "6"}, {rank: "A"}}, card{rank: "6"}, false}, + } + for _, tt := range tests { + player, banker := playTableau(tableauShoe( + card{rank: "2"}, card{rank: "3"}, // player 5, draws + tt.bankerCards[0], tt.bankerCards[1], + tt.playerThird, // player third card + card{rank: "9"}, // banker third card (if drawn) + )) + if got := len(banker) == 3; got != tt.wantDraw { + t.Errorf("%s: banker drew=%v, want %v (player=%d cards)", tt.name, got, tt.wantDraw, len(player)) + } + if len(player) != 3 { + t.Errorf("%s: player=%d cards, want 3", tt.name, len(player)) + } + } +} + +func TestBaccaratSettlement(t *testing.T) { + rules := (BaccaratConfig{}).Normalize() + if result, payout := settleBaccaratBet(baccaratSpotPlayer, 100, 6, 4, rules); result != "win" || payout != 200 { + t.Fatalf("player win: result=%s payout=%d, want win/200", result, payout) + } + if result, payout := settleBaccaratBet(baccaratSpotBanker, 100, 6, 4, rules); result != "loss" || payout != 0 { + t.Fatalf("banker bet on player win: result=%s payout=%d, want loss/0", result, payout) + } + if result, payout := settleBaccaratBet(baccaratSpotBanker, 100, 4, 6, rules); result != "win" || payout != 195 { + t.Fatalf("banker win: result=%s payout=%d, want win/195 (5%% commission)", result, payout) + } + if result, payout := settleBaccaratBet(baccaratSpotTie, 100, 7, 7, rules); result != "win" || payout != 900 { + t.Fatalf("tie win: result=%s payout=%d, want win/900 (8:1)", result, payout) + } + if result, payout := settleBaccaratBet(baccaratSpotPlayer, 100, 7, 7, rules); result != "push" || payout != 100 { + t.Fatalf("player bet on tie: result=%s payout=%d, want push/100", result, payout) + } + if result, payout := settleBaccaratBet(baccaratSpotBanker, 100, 7, 7, rules); result != "push" || payout != 100 { + t.Fatalf("banker bet on tie: result=%s payout=%d, want push/100", result, payout) + } + if result, payout := settleBaccaratBet(baccaratSpotTie, 100, 6, 4, rules); result != "loss" || payout != 0 { + t.Fatalf("tie bet on player win: result=%s payout=%d, want loss/0", result, payout) + } +} + +func newBaccaratTestTable(cfg TableConfig) *baccaratTable { + return newBaccaratTable(1, cfg, newTestRand()) +} + +func TestBaccaratSoloBetDealsImmediately(t *testing.T) { + table := newBaccaratTestTable(TableConfig{ID: "baccarat", Game: GameBaccarat}) + table.join("alice") + forceShoe(table, + card{rank: "5"}, card{rank: "4"}, // player 9 (natural) + card{rank: "2"}, card{rank: "3"}, // banker 5 + ) + w := &testWallet{credits: 100} + events := table.bet("alice", baccaratSpotPlayer, 100, w) + if !hasMessage(events, "New baccarat hand!") || !hasMessage(events, "Player wins 9 over 5.") { + t.Fatalf("solo bet did not deal: %+v", events) + } + if w.paid != 200 { + t.Fatalf("payout=%d, want 200 (stake + 1:1)", w.paid) + } + if table.phase != baccaratWaiting || table.participants["alice"].HasBet { + t.Fatalf("table did not reset after deal: phase=%s", table.phase) + } + // The round event carries a snapshot of both hands and the bets. + var snapshot *BaccaratSnapshot + for _, event := range events { + if event.Baccarat != nil { + snapshot = event.Baccarat + } + } + if snapshot == nil || len(snapshot.Player) != 2 || len(snapshot.Banker) != 2 || + snapshot.PlayerScore != "9" || snapshot.BankerScore != "5" || + len(snapshot.Bets) != 1 || snapshot.Bets[0].Spot != baccaratSpotPlayer || snapshot.Bets[0].Amount != 100 { + t.Fatalf("unexpected snapshot: %+v", snapshot) + } +} + +func TestBaccaratWaitsForAllPlayersToBet(t *testing.T) { + table := newBaccaratTestTable(TableConfig{ID: "baccarat", Game: GameBaccarat, MaxPlayers: 2}) + table.join("alice") + table.join("bob") + forceShoe(table, + card{rank: "5"}, card{rank: "4"}, // player 9 + card{rank: "2"}, card{rank: "3"}, // banker 5 + ) + wa, wb := &testWallet{credits: 100}, &testWallet{credits: 100} + if events := table.bet("alice", baccaratSpotBanker, 10, wa); hasMessage(events, "New baccarat hand!") { + t.Fatal("deal started before all players bet") + } + events := table.bet("bob", baccaratSpotPlayer, 10, wb) + if !hasMessage(events, "New baccarat hand!") { + t.Fatalf("deal did not start after all players bet: %+v", events) + } + if wa.paid != 0 || wb.paid != 20 { + t.Fatalf("payouts alice=%d bob=%d, want 0/20", wa.paid, wb.paid) + } +} + +func TestBaccaratBettingWindowTimesOut(t *testing.T) { + table := newBaccaratTestTable(TableConfig{ID: "baccarat", Game: GameBaccarat, MaxPlayers: 2, BettingTicks: 1}) + table.join("alice") + table.join("bob") + forceShoe(table, + card{rank: "5"}, card{rank: "4"}, // player 9 + card{rank: "2"}, card{rank: "3"}, // banker 5 + ) + w := &testWallet{credits: 100} + table.bet("alice", baccaratSpotPlayer, 10, w) + events := table.tick() + if !hasMessage(events, "bob sits out this baccarat round.") { + t.Fatalf("missing sit-out message: %+v", events) + } + if !hasMessage(events, "New baccarat hand!") { + t.Fatalf("betting timeout did not deal for the bettor: %+v", events) + } + if w.paid != 20 { + t.Fatalf("payout=%d, want 20", w.paid) + } +} + +func TestBaccaratBettingIgnoresDisconnected(t *testing.T) { + table := newBaccaratTestTable(TableConfig{ID: "baccarat", Game: GameBaccarat, MaxPlayers: 2}) + table.join("alice") + table.join("bob") + table.markDisconnected("bob") + forceShoe(table, + card{rank: "5"}, card{rank: "4"}, // player 9 + card{rank: "2"}, card{rank: "3"}, // banker 5 + ) + w := &testWallet{credits: 100} + events := table.bet("alice", baccaratSpotPlayer, 10, w) + if !hasMessage(events, "New baccarat hand!") { + t.Fatalf("disconnected player stalled the round: %+v", events) + } +} + +func TestBaccaratTiePushesAndPaysJackpotColor(t *testing.T) { + table := newBaccaratTestTable(TableConfig{ID: "baccarat", Game: GameBaccarat, MaxPlayers: 2}) + table.join("alice") + table.join("bob") + forceShoe(table, + card{rank: "5"}, card{rank: "2"}, // player 7 + card{rank: "4"}, card{rank: "3"}, // banker 7 + ) + wa, wb := &testWallet{credits: 1000}, &testWallet{credits: 1000} + table.bet("alice", baccaratSpotPlayer, 100, wa) + events := table.bet("bob", baccaratSpotTie, 100, wb) + if !hasMessage(events, "Player and banker tie at 7.") { + t.Fatalf("missing tie outcome: %+v", events) + } + if wa.paid != 100 || wb.paid != 900 { + t.Fatalf("payouts alice=%d bob=%d, want 100 (push)/900 (8:1)", wa.paid, wb.paid) + } + for _, event := range events { + if event.PrivateTo == "bob" && event.PrivateMessage == "You win! +900 credits" && event.Color != "casino_jackpot_win" { + t.Fatalf("tie win color=%q, want casino_jackpot_win", event.Color) + } + } +} + +func TestBaccaratBetValidation(t *testing.T) { + table := newBaccaratTestTable(TableConfig{ID: "baccarat", Game: GameBaccarat, MinBet: 5, MaxBet: 100}) + table.join("alice") + w := &testWallet{credits: 1000} + if events := table.bet("alice", "", 10, w); !hasMessage(events, "Bet on what?") { + t.Fatalf("spotless first bet not guided: %+v", events) + } + if events := table.bet("alice", "red", 10, w); !hasMessage(events, "must be on player, banker, or tie") { + t.Fatalf("invalid spot accepted: %+v", events) + } + if events := table.bet("alice", baccaratSpotPlayer, 200, w); !hasMessage(events, "between 5 and 100") { + t.Fatalf("out-of-range bet accepted: %+v", events) + } + forceShoe(table, + card{rank: "5"}, card{rank: "4"}, // player 9 + card{rank: "2"}, card{rank: "3"}, // banker 5 + ) + table.bet("alice", baccaratSpotPlayer, 10, w) + // After the round, the last spot is remembered: an empty spot reuses it. + if spot, amount := table.rebet("alice"); spot != baccaratSpotPlayer || amount != 10 { + t.Fatalf("rebet=(%q, %d), want (player, 10)", spot, amount) + } + forceShoe(table, + card{rank: "5"}, card{rank: "4"}, // player 9 + card{rank: "2"}, card{rank: "3"}, // banker 5 + ) + if events := table.bet("alice", "", 20, w); !hasMessage(events, "You bet 20 credits on player.") { + t.Fatalf("empty spot did not reuse last spot: %+v", events) + } + if w.paid != 20+40 { + t.Fatalf("payout=%d, want 60 across both wins", w.paid) + } +} + +func TestBaccaratDoubleBetRejected(t *testing.T) { + table := newBaccaratTestTable(TableConfig{ID: "baccarat", Game: GameBaccarat, MaxPlayers: 2}) + table.join("alice") + table.join("bob") + w := &testWallet{credits: 1000} + table.bet("alice", baccaratSpotPlayer, 10, w) + if events := table.bet("alice", baccaratSpotBanker, 10, w); !hasMessage(events, "already bet") { + t.Fatalf("second bet in a round accepted: %+v", events) + } +} + +func TestBaccaratLeaveAndTableFull(t *testing.T) { + table := newBaccaratTestTable(TableConfig{ID: "baccarat", Game: GameBaccarat, MaxPlayers: 1}) + table.join("alice") + if events := table.join("bob"); !hasMessage(events, "full") { + t.Fatalf("full table admitted a second player: %+v", events) + } + w := &testWallet{credits: 100} + table.bet("alice", baccaratSpotPlayer, 10, w) // solo: deals immediately + if events := table.leave("alice"); !hasMessage(events, "You stand up.") || !hasMessage(events, "closes") { + t.Fatalf("leave events missing: %+v", events) + } + + busy := newBaccaratTestTable(TableConfig{ID: "baccarat", Game: GameBaccarat, MaxPlayers: 2}) + busy.join("alice") + busy.join("bob") + busy.bet("alice", baccaratSpotPlayer, 10, w) // opens the betting window + if events := busy.leave("alice"); !hasMessage(events, "middle of a baccarat round") { + t.Fatalf("leave during betting not blocked: %+v", events) + } +} + +func TestBaccaratMenuAndActions(t *testing.T) { + table := newBaccaratTestTable(TableConfig{ID: "baccarat", Game: GameBaccarat}) + events := table.join("alice") + var menu *MenuView + for _, event := range events { + if event.Menu != nil { + menu = event.Menu + } + } + if menu == nil || menu.Kind != MenuBaccaratBet { + t.Fatalf("join menu=%+v, want MenuBaccaratBet", menu) + } + if events := table.action("alice", "hit"); !hasMessage(events, "no player actions") { + t.Fatalf("baccarat action not rejected: %+v", events) + } +} + +func TestBaccaratConfigDefaults(t *testing.T) { + rules := (BaccaratConfig{}).Normalize() + if rules.Decks != 8 || rules.ShuffleAt != 0.25 || rules.TiePayout != 8 || rules.BankerPayout != 0.95 { + t.Fatalf("unexpected defaults: %+v", rules) + } +} + +func TestBaccaratShoeUsesConfiguredDecks(t *testing.T) { + table := newBaccaratTestTable(TableConfig{ID: "baccarat", Game: GameBaccarat, Baccarat: BaccaratConfig{Decks: 2}}) + if table.shoe.decks != 2 || len(table.shoe.cards) != 104 { + t.Fatalf("shoe decks=%d cards=%d, want 2 decks/104 cards", table.shoe.decks, len(table.shoe.cards)) + } + def := newBaccaratTestTable(TableConfig{ID: "baccarat", Game: GameBaccarat}) + if def.shoe.decks != 8 || len(def.shoe.cards) != 416 { + t.Fatalf("default shoe decks=%d cards=%d, want 8 decks/416 cards", def.shoe.decks, len(def.shoe.cards)) + } +} + +func TestBaccaratTableRoutesThroughTable(t *testing.T) { + table := newTable(1, TableConfig{ID: "baccarat", Game: GameBaccarat}, newTestRand()) + if _, isBaccarat := table.game.(*baccaratTable); !isBaccarat { + t.Fatalf("newTable dispatched %T, want *baccaratTable", table.game) + } + table.join("alice") + w := &testWallet{credits: 100} + if events := table.bet("alice", baccaratSpotPlayer, 10, w); !hasMessage(events, "New baccarat hand!") { + t.Fatalf("routed bet did not deal: %+v", events) + } +} |
