package casino import ( "math/rand" "strings" "testing" ) func TestBlackjackHandTreatsAceAsElevenWhenPossible(t *testing.T) { hand := &blackjackHand{cards: []card{{rank: "A"}, {rank: "6"}}} total, soft := hand.total() if total != 17 || !soft { t.Fatalf("total=%d soft=%v, want soft 17", total, soft) } hand.cards = append(hand.cards, card{rank: "K"}) total, soft = hand.total() if total != 17 || soft { t.Fatalf("total=%d soft=%v, want hard 17", total, soft) } } func TestBlackjackHandScoreShowsBlackjackAs21(t *testing.T) { if got := handScore(&blackjackHand{cards: []card{{rank: "A"}, {rank: "K"}}}); got != "21" { t.Fatalf("blackjack score=%q, want 21", got) } if got := handScore(&blackjackHand{cards: []card{{rank: "A"}, {rank: "6"}}}); got != "7 or 17" { t.Fatalf("soft 17 score=%q, want 7 or 17", got) } if got := handScore(&blackjackHand{cards: []card{{rank: "10"}, {rank: "8"}}}); got != "18" { t.Fatalf("hard 18 score=%q, want 18", got) } } func TestBlackjackSettlementRules(t *testing.T) { rules := (BlackjackConfig{}).Normalize() natural := &blackjackHand{bet: 10, cards: []card{{rank: "A"}, {rank: "K"}}} if result, payout := settleHand(natural, 20, false, rules); result != "blackjack" || payout != 25 { t.Fatalf("natural result=%s payout=%d", result, payout) } smallNatural := &blackjackHand{bet: 1, cards: []card{{rank: "A"}, {rank: "K"}}} if result, payout := settleHand(smallNatural, 20, false, rules); result != "blackjack" || payout != 2 { t.Fatalf("small natural result=%s payout=%d, want blackjack/2 (winnings round down)", result, payout) } dealerNatural := &blackjackHand{bet: 10, cards: []card{{rank: "A"}, {rank: "Q"}}} if result, payout := settleHand(dealerNatural, 21, true, rules); result != "push" || payout != 10 { t.Fatalf("dealer natural result=%s payout=%d", result, payout) } if result, payout := settleHand(&blackjackHand{bet: 10, cards: []card{{rank: "10"}, {rank: "8"}}}, 21, true, rules); result != "dealer blackjack" || payout != 0 { t.Fatalf("dealer blackjack result=%s payout=%d", result, payout) } if result, payout := settleHand(&blackjackHand{bet: 10, cards: []card{{rank: "10"}, {rank: "8"}}}, 22, false, rules); result != "win" || payout != 20 { t.Fatalf("dealer bust result=%s payout=%d", result, payout) } } func TestBlackjackTableStartsAfterAllPlayersBet(t *testing.T) { table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, MaxPlayers: 2}, newTestRand()) table.join("alice") table.join("bob") wa, wb := &testWallet{credits: 100}, &testWallet{credits: 100} if events := table.bet("alice", "", 10, wa); hasMessage(events, "round begins") { t.Fatal("round started before all players bet") } events := table.bet("bob", "", 10, wb) if !hasSnapshot(events) { t.Fatalf("round did not start after all players bet: %+v", events) } } func TestSoloBlackjackTurnDoesNotTimeout(t *testing.T) { table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, Blackjack: BlackjackConfig{ActionTicks: 1}}, newTestRand()) table.players.add("alice", &blackjackPlayer{ Participant: Participant{Name: "alice", HasBet: true, Connected: true}, hands: []*blackjackHand{{bet: 10, cards: []card{{rank: "10"}, {rank: "6"}}}}, }) table.phase = blackjackPlaying if events := table.tick(); len(events) != 0 { t.Fatalf("solo action timed out: %+v", events) } if table.phase != blackjackPlaying { t.Fatalf("solo table phase changed to %s", table.phase) } } func TestBlackjackDisconnectedSoloPlayerTimesOut(t *testing.T) { table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, Blackjack: BlackjackConfig{ActionTicks: 1}}, newTestRand()) table.shoe = &shoe{cards: []card{{rank: "9"}}} // dealer stands on 17; spare card just in case w := &testWallet{credits: 100} table.players.add("alice", &blackjackPlayer{ Participant: Participant{Name: "alice", HasBet: true, Connected: false, Wallet: w, Wager: Wager{Total: 10}}, hands: []*blackjackHand{{bet: 10, cards: []card{{rank: "10"}, {rank: "6"}}}}, }) table.dealer.cards = []card{{rank: "10"}, {rank: "7"}} table.phase = blackjackPlaying table.timer.start(1) // beginPlay starts the action timer in production events := table.tick() if !hasMessage(events, "Your action timed out; standing.") { t.Fatalf("disconnected player was not timed out: %+v", events) } if table.phase != blackjackWaiting { t.Fatalf("phase=%s, want waiting after the round settles", table.phase) } if w.paid != 0 { t.Fatalf("stood 16 against dealer 17 should lose, paid=%d", w.paid) } // The table recovers: a new player can sit and bet. table.shoe = tableauShoe(card{rank: "10"}, card{rank: "7"}, card{rank: "10"}, card{rank: "8"}) table.join("bob") wb := &testWallet{credits: 100} if events := table.bet("bob", "", 10, wb); !hasSnapshot(events) { t.Fatalf("table did not recover after the disconnected player timed out: %+v", events) } } func TestBlackjackRebetKeepsLastWager(t *testing.T) { table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, MinBet: 5, MaxBet: 100}, newTestRand()) table.players.add("alice", &blackjackPlayer{Participant: Participant{Name: "alice"}, lastBet: 25}) if spot, got := table.rebet("alice"); got != 25 || spot != "" { t.Fatalf("rebet=(%q, %d), want (\"\", 25)", spot, got) } } func TestBlackjackPromptNamesActiveHandAndOnlyOffersAvailableActions(t *testing.T) { table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand()) table.players.add("alice", &blackjackPlayer{Participant: Participant{Name: "alice", HasBet: true, Connected: true}, hands: []*blackjackHand{{bet: 10, cards: []card{{rank: "K"}, {rank: "6"}}}}}) table.activePlayer = 0 table.activeHand = 0 table.dealer.cards = []card{{rank: "10"}, {rank: "7"}} event := table.prompt()[0] if event.Message != "Your turn (K,6).\nActions: hit, stand, double" { t.Fatalf("prompt=%q", event.Message) } if event.Menu == nil || event.Menu.Kind != MenuBlackjackTurn || event.Menu.Title != "Your turn (K,6)" || len(event.Menu.Actions) != 3 || event.Menu.Actions[0] != "hit" || event.Menu.Actions[1] != "stand" || event.Menu.Actions[2] != "double" { t.Fatalf("prompt menu=%+v", event.Menu) } } func TestBlackjackMenuEventCarriesStructuredMenu(t *testing.T) { table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand()) event := table.menuEvent("alice") if event.Menu == nil || event.Menu.Kind != MenuBlackjackBet { t.Fatalf("menu event=%+v", event.Menu) } if len(event.Menu.Commands) != 3 || event.Menu.Commands[0].Command != "bet" || event.Menu.Commands[2].Command != "stop" { t.Fatalf("bet menu commands=%+v", event.Menu.Commands) } } func TestBlackjackActionCommandsDescribeEachAction(t *testing.T) { commands := blackjackActionCommands([]string{"hit", "stand", "double", "split", "surrender"}) if len(commands) != 5 { t.Fatalf("commands=%+v", commands) } for _, c := range commands { if c.Desc == "" { t.Fatalf("action %q missing a description", c.Command) } } } func TestBlackjackInsurancePaysOnDealerBlackjack(t *testing.T) { table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand()) w := &testWallet{credits: 100} table.players.add("alice", &blackjackPlayer{ Participant: Participant{Name: "alice", HasBet: true, Connected: true, Wallet: w, Wager: Wager{Total: 10}}, hands: []*blackjackHand{{bet: 10, cards: []card{{rank: "10"}, {rank: "8"}}}}, }) table.dealer.cards = []card{{rank: "K"}, {rank: "A"}} table.phase = blackjackInsurance events := table.action("alice", "insurance") if w.credits != 95 { t.Fatalf("insurance stake credits=%d, want 95", w.credits) } if w.paid != 15 { t.Fatalf("insurance payout=%d, want 15 (stake + 2x)", w.paid) } if !hasMessage(events, "Insurance pays 15 credits.") { t.Fatalf("missing insurance payout message: %+v", events) } if table.phase != blackjackWaiting { t.Fatalf("phase=%s, want waiting after dealer blackjack settles", table.phase) } } func TestBlackjackInsuranceDeclineContinuesRound(t *testing.T) { table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand()) w := &testWallet{credits: 100} table.players.add("alice", &blackjackPlayer{ Participant: Participant{Name: "alice", HasBet: true, Connected: true, Wallet: w, Wager: Wager{Total: 10}}, hands: []*blackjackHand{{bet: 10, cards: []card{{rank: "10"}, {rank: "8"}}}}, }) table.dealer.cards = []card{{rank: "9"}, {rank: "A"}} table.phase = blackjackInsurance events := table.action("alice", "stand") if !hasMessage(events, "You decline insurance.") { t.Fatalf("missing decline message: %+v", events) } if table.phase != blackjackPlaying { t.Fatalf("phase=%s, want playing after insurance declined", table.phase) } if !hasMessage(events, "Your turn (10,8).") { t.Fatalf("round did not continue after insurance: %+v", events) } } func TestBlackjackDoubleSetsBust(t *testing.T) { table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand()) // decks 0 disables the shuffle threshold so the forced card is drawn. table.shoe = &shoe{cards: []card{{rank: "K"}}} w := &testWallet{credits: 100} table.players.add("alice", &blackjackPlayer{ Participant: Participant{Name: "alice", HasBet: true, Connected: true, Wallet: w, Wager: Wager{Total: 10}}, hands: []*blackjackHand{{bet: 10, cards: []card{{rank: "10"}, {rank: "6"}}}}, }) table.dealer.cards = []card{{rank: "10"}, {rank: "7"}} table.phase = blackjackPlaying hand := table.players.get("alice").hands[0] events := table.action("alice", "double") if !hand.bust { t.Fatal("doubling into 26 did not set the bust flag") } if !hasMessage(events, "loses") { t.Fatalf("busted double did not settle as a loss: %+v", events) } } func TestBlackjackSplitAcesOnlyAllowResplit(t *testing.T) { resplit := true table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, Blackjack: BlackjackConfig{ResplitAces: &resplit}}, newTestRand()) w := &testWallet{credits: 100} table.players.add("alice", &blackjackPlayer{ Participant: Participant{Name: "alice", HasBet: true, Connected: true, Wallet: w, Wager: Wager{Total: 10}}, hands: []*blackjackHand{{bet: 10, fromSplit: true, cards: []card{{rank: "A"}, {rank: "A"}}}}, }) table.dealer.cards = []card{{rank: "10"}, {rank: "7"}} table.phase = blackjackPlaying p := table.players.get("alice") actions := table.availableActions(p, p.hands[0]) if len(actions) != 2 || actions[0] != "stand" || actions[1] != "split" { t.Fatalf("split ace actions=%v, want [stand split]", actions) } if events := table.action("alice", "hit"); !hasMessage(events, "Split aces cannot be hit.") { t.Fatalf("hit on split aces not rejected: %+v", events) } } func TestBlackjackBettingIgnoresDisconnected(t *testing.T) { table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, MaxPlayers: 2}, newTestRand()) table.join("alice") table.join("bob") table.markDisconnected("bob") // removes bob; alice is the only bettor w := &testWallet{credits: 100} events := table.bet("alice", "", 10, w) if !hasSnapshot(events) { t.Fatalf("removed player stalled the round: %+v", events) } } func TestBlackjackDisconnectActivePassesPlay(t *testing.T) { // Two players; the active one disconnects. Play passes to the next // bettor and the forfeited wager is never paid out. table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, MaxPlayers: 2, Blackjack: BlackjackConfig{ActionTicks: 1}}, newTestRand()) table.shoe = tableauShoe(card{rank: "10"}, card{rank: "7"}, card{rank: "K"}, card{rank: "Q"}, card{rank: "10"}, card{rank: "6"}) wa, wb := &testWallet{credits: 100}, &testWallet{credits: 100} table.join("alice") table.join("bob") table.bet("alice", "", 10, wa) events := table.bet("bob", "", 10, wb) if !hasSnapshot(events) { t.Fatalf("round did not start: %+v", events) } // Both players dealt; alice active. Alice disconnects. disEvt := table.markDisconnected("alice") if !hasMessage(disEvt, "alice stands up") || len(disEvt) == 0 { t.Fatalf("missing disconnect event: %+v", disEvt) } // Alice forfeits (never paid). if wa.paid != 0 { t.Fatalf("alice's forfeited wager was paid: %d", wa.paid) } // Bob is now prompted for his turn (10,6 against dealer 10-up). if !hasMessage(disEvt, "Your turn (10,6)") { t.Fatalf("play did not pass to bob after alice left: %+v", disEvt) } // Bob stands; round settles. settle := table.action("bob", "stand") if !hasMessage(settle, "You lose!") { t.Fatalf("bob 16 vs dealer 17: expected loss, got %+v", settle) } if wb.paid != 0 { t.Fatalf("bob should lose 16v17, paid=%d", wb.paid) } } func TestBlackjackDisconnectEarlierSeatPreservesTurn(t *testing.T) { // Two players; the earlier seat disconnects during play. The active // player's turn is unaffected (index fixup). table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, MaxPlayers: 2, Blackjack: BlackjackConfig{ActionTicks: 1}}, newTestRand()) table.shoe = tableauShoe(card{rank: "10"}, card{rank: "7"}, card{rank: "10"}, card{rank: "9"}, card{rank: "K"}, card{rank: "Q"}) table.join("alice") table.join("bob") wa := &testWallet{credits: 100} table.bet("alice", "", 10, wa) events := table.bet("bob", "", 10, &testWallet{credits: 100}) if !hasSnapshot(events) { t.Fatalf("round did not start: %+v", events) } // Advance past alice's turn (bob is picked up after skipCompletedHands). // Dealer 10-7. Let alice stand. table.action("alice", "stand") // Bob is now active. Remove alice (seat index 0; bob at 1). disEvt := table.markDisconnected("alice") // Bob (now seat 0) is still prompted — no "your turn" means finishHand // skipped straight to settle (one bettor, bob 10-9 stands, 19 vs 17 → win). // Actually finishHand → skipCompletedHands → bob's hand incomplete → // prompt. Let's verify: if !hasMessage(disEvt, "Your turn") { t.Fatalf("bob's turn not preserved after alice was removed: %+v", disEvt) } if wa.paid != 0 { t.Fatalf("alice should not be paid after removal, got %d", wa.paid) } } func hasMessage(events []Event, fragment string) bool { for _, event := range events { if contains(event.Message, fragment) || contains(event.PrivateMessage, fragment) { return true } } return false } func hasSnapshot(events []Event) bool { for _, event := range events { if event.Blackjack != nil { return true } } return false } func contains(value, fragment string) bool { return strings.Contains(value, fragment) } func newTestRand() *rand.Rand { return rand.New(rand.NewSource(7)) }