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) } }