package blackjack import ( "encoding/json" "testing" "pete/internal/games/cards" ) // hand builds a hand from "A♠"-ish shorthand: rank letters/numbers only. func hand(ranks ...cards.Rank) []cards.Card { h := make([]cards.Card, len(ranks)) for i, r := range ranks { h[i] = cards.Card{Rank: r, Suit: cards.Spades} } return h } func TestHandValue(t *testing.T) { tests := []struct { name string hand []cards.Card want int soft bool }{ {"two aces are 12, not 22", hand(cards.Ace, cards.Ace), 12, true}, {"ace plus king is a soft 21", hand(cards.Ace, cards.King), 21, true}, {"faces are all ten", hand(cards.Jack, cards.Queen), 20, false}, {"ace demotes to save the hand", hand(cards.Ace, 9, 5), 15, false}, {"three aces and an eight", hand(cards.Ace, cards.Ace, cards.Ace, 8), 21, true}, {"soft 17 is an ace and a six", hand(cards.Ace, 6), 17, true}, {"hard 17 has no ace", hand(cards.King, 7), 17, false}, {"a bust stays busted", hand(cards.King, cards.Queen, 5), 25, false}, {"empty hand", nil, 0, false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got, soft := HandValue(tc.hand) if got != tc.want || soft != tc.soft { t.Fatalf("HandValue = (%d, soft=%v), want (%d, soft=%v)", got, soft, tc.want, tc.soft) } }) } } func TestIsBlackjack_OnlyOnTwoCards(t *testing.T) { if !IsBlackjack(hand(cards.Ace, cards.King)) { t.Fatal("A+K is a natural") } // 21 built from three cards is not a natural and must not be paid 3:2. if IsBlackjack(hand(7, 7, 7)) { t.Fatal("7+7+7 is 21 but not a blackjack") } if IsBlackjack(hand(cards.Ace)) { t.Fatal("one card is not a blackjack") } } // settleWith forces a finished hand and reads back the money, bypassing the // deal so the payout math can be checked case by case. func settleWith(t *testing.T, r Rules, bet int64, player, dealer []cards.Card) State { t.Helper() s := State{Rules: r, Bet: bet, Hands: []Hand{{Cards: player, Bet: bet}}, Dealer: dealer} evs := []Event{} s.settle(&evs) if s.Phase != PhaseDone { t.Fatal("settle left the hand unfinished") } return s } func TestSettle_PayoutsAndRake(t *testing.T) { r := DefaultRules() // 3:2, 5% rake tests := []struct { name string player []cards.Card dealer []cards.Card wantOutcome Outcome wantPayout int64 // chips returned to the stack wantRake int64 }{ { // 100 stake, 100 profit, 5 raked → 195 back, net +95. name: "a plain win is raked on the profit only", player: hand(cards.King, 9), dealer: hand(cards.King, 8), wantOutcome: OutcomeWin, wantPayout: 195, wantRake: 5, }, { // 3:2 on 100 is 150 profit, 7 raked (floor of 7.5) → 243 back. name: "a natural pays 3:2 less rake", player: hand(cards.Ace, cards.King), dealer: hand(cards.King, 8), wantOutcome: OutcomeBlackjack, wantPayout: 243, wantRake: 7, }, { name: "a push returns the stake untouched — the house takes nothing", player: hand(cards.King, 9), dealer: hand(cards.Queen, 9), wantOutcome: OutcomePush, wantPayout: 100, wantRake: 0, }, { name: "two naturals push", player: hand(cards.Ace, cards.King), dealer: hand(cards.Ace, cards.Queen), wantOutcome: OutcomePush, wantPayout: 100, wantRake: 0, }, { name: "a loss pays nothing and is not charged a rake", player: hand(cards.King, 8), dealer: hand(cards.King, 9), wantOutcome: OutcomeLose, wantPayout: 0, wantRake: 0, }, { name: "a bust pays nothing even if the dealer would have busted too", player: hand(cards.King, 8, 9), dealer: hand(cards.King, 6, 9), wantOutcome: OutcomeBust, wantPayout: 0, wantRake: 0, }, { name: "dealer blackjack beats the player's twenty", player: hand(cards.King, cards.Queen), dealer: hand(cards.Ace, cards.Jack), wantOutcome: OutcomeLose, wantPayout: 0, wantRake: 0, }, { name: "dealer bust pays even money less rake", player: hand(cards.King, 5), dealer: hand(cards.King, 6, 9), wantOutcome: OutcomeDealerBust, wantPayout: 195, wantRake: 5, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { s := settleWith(t, r, 100, tc.player, tc.dealer) if s.Outcome != tc.wantOutcome { t.Errorf("outcome = %q, want %q", s.Outcome, tc.wantOutcome) } if s.Payout != tc.wantPayout { t.Errorf("payout = %d, want %d", s.Payout, tc.wantPayout) } if s.Rake != tc.wantRake { t.Errorf("rake = %d, want %d", s.Rake, tc.wantRake) } // The invariant the ledger depends on: every chip the player staked // either comes back, goes to the house as rake, or is lost to the table. if s.Payout < 0 || s.Rake < 0 { t.Errorf("negative chips: payout=%d rake=%d", s.Payout, s.Rake) } }) } } func TestSettle_RakeNeverTouchesTheStake(t *testing.T) { // A 100% rake is absurd, but it must still never claw back a player's own // stake: the worst a rake can do is take all the winnings. r := Rules{Decks: 6, BlackjackPays: 1.5, RakePct: 1.0} s := settleWith(t, r, 100, hand(cards.King, 9), hand(cards.King, 8)) if s.Payout != 100 { t.Fatalf("payout = %d, want the stake back (100)", s.Payout) } if s.Net() != 0 { t.Fatalf("net = %d, want 0", s.Net()) } } func TestNew_DealsFourCardsAndAskThePlayer(t *testing.T) { rng := cards.NewRNG(1, 2) s, evs, err := New(50, DefaultRules(), rng) if err != nil { t.Fatal(err) } if len(s.Hands[0].Cards) != 2 || len(s.Dealer) != 2 { t.Fatalf("dealt %d/%d cards, want 2/2", len(s.Hands[0].Cards), len(s.Dealer)) } if len(s.Deck) != 6*52-4 { t.Fatalf("shoe has %d cards left, want %d", len(s.Deck), 6*52-4) } if len(evs) == 0 || evs[0].Kind != "deal" { t.Fatal("no deal event") } // Unless somebody was dealt a natural, it's the player's move. if !IsBlackjack(s.Hands[0].Cards) && !IsBlackjack(s.Dealer) && s.Phase != PhasePlayer { t.Fatalf("phase = %q, want %q", s.Phase, PhasePlayer) } } func TestNew_RejectsNonPositiveBet(t *testing.T) { for _, bet := range []int64{0, -100} { if _, _, err := New(bet, DefaultRules(), cards.NewRNG(1, 2)); err == nil { t.Fatalf("bet %d was accepted", bet) } } } func TestNew_NaturalSettlesImmediately(t *testing.T) { // Search seeds for a deal that gives the player a natural, then assert the // hand is already over — a player holding blackjack is never asked to hit. for seed := uint64(1); seed < 200; seed++ { s, _, err := New(100, DefaultRules(), cards.NewRNG(seed, seed)) if err != nil { t.Fatal(err) } if IsBlackjack(s.Hands[0].Cards) { if s.Phase != PhaseDone { t.Fatalf("seed %d: player has a natural but phase = %q", seed, s.Phase) } if _, _, err := ApplyMove(s, Hit); err != ErrHandOver { t.Fatalf("seed %d: hitting a settled natural gave %v, want ErrHandOver", seed, err) } return } } t.Skip("no natural dealt in 200 seeds") } func TestApplyMove_HitUntilBustSettles(t *testing.T) { s, _, err := New(100, DefaultRules(), cards.NewRNG(7, 7)) if err != nil { t.Fatal(err) } if s.Phase == PhaseDone { t.Skip("dealt a natural; not the hand under test") } for i := 0; i < 12 && s.Phase == PhasePlayer; i++ { s, _, err = ApplyMove(s, Hit) if err != nil { t.Fatal(err) } } if s.Phase != PhaseDone { t.Fatal("hitting a dozen times never ended the hand") } if v, _ := HandValue(s.Hands[0].Cards); v <= 21 { t.Fatalf("player stopped at %d without busting — the loop should have gone over", v) } if s.Outcome != OutcomeBust || s.Payout != 0 { t.Fatalf("outcome=%q payout=%d, want bust/0", s.Outcome, s.Payout) } // A busted player must not have made the dealer draw. if len(s.Dealer) != 2 { t.Fatalf("dealer drew %d cards against a busted player", len(s.Dealer)-2) } } func TestApplyMove_StandRunsTheDealerOut(t *testing.T) { s, _, err := New(100, DefaultRules(), cards.NewRNG(3, 9)) if err != nil { t.Fatal(err) } if s.Phase == PhaseDone { t.Skip("dealt a natural") } s, evs, err := ApplyMove(s, Stand) if err != nil { t.Fatal(err) } if s.Phase != PhaseDone { t.Fatalf("phase = %q after stand, want done", s.Phase) } v, soft := HandValue(s.Dealer) if v < 17 { t.Fatalf("dealer stood on %d, must draw below 17", v) } if v == 17 && soft { t.Fatal("dealer stood on soft 17; the house rule says hit") } var reveal bool for _, e := range evs { if e.Kind == "reveal" { reveal = true } } if !reveal { t.Fatal("dealer played without a reveal event") } } func TestApplyMove_DoubleTakesOneCardThenStands(t *testing.T) { s, _, err := New(100, DefaultRules(), cards.NewRNG(11, 4)) if err != nil { t.Fatal(err) } if s.Phase == PhaseDone { t.Skip("dealt a natural") } if !s.CanDouble() { t.Fatal("double should be legal on the opening two cards") } s, _, err = ApplyMove(s, Double) if err != nil { t.Fatal(err) } if !s.Hands[0].Doubled || s.Bet != 200 { t.Fatalf("bet = %d doubled = %v, want 200/true", s.Bet, s.Hands[0].Doubled) } if len(s.Hands[0].Cards) != 3 { t.Fatalf("player has %d cards after a double, want exactly 3", len(s.Hands[0].Cards)) } if s.Phase != PhaseDone { t.Fatal("a double must end the player's turn") } } func TestApplyMove_DoubleIsIllegalAfterHitting(t *testing.T) { s, _, err := New(100, DefaultRules(), cards.NewRNG(5, 5)) if err != nil { t.Fatal(err) } if s.Phase == PhaseDone { t.Skip("dealt a natural") } s, _, err = ApplyMove(s, Hit) if err != nil { t.Fatal(err) } if s.Phase != PhasePlayer { t.Skip("busted on the hit; not the hand under test") } before := s.Bet after, _, err := ApplyMove(s, Double) if err != ErrCantDouble { t.Fatalf("double after a hit gave %v, want ErrCantDouble", err) } if after.Bet != before { t.Fatalf("a rejected double still moved the bet: %d -> %d", before, after.Bet) } if s.CanDouble() { t.Fatal("CanDouble says yes on a three-card hand") } } func TestApplyMove_RejectsGarbage(t *testing.T) { s, _, err := New(100, DefaultRules(), cards.NewRNG(2, 8)) if err != nil { t.Fatal(err) } if _, _, err := ApplyMove(s, Move("surrender")); err != ErrUnknownMove { t.Fatalf("got %v, want ErrUnknownMove", err) } } // The engine's state has to survive a redeploy: no timers, no pointers, no // unexported fields that JSON would quietly drop. func TestState_RoundTripsThroughJSON(t *testing.T) { s, _, err := New(100, DefaultRules(), cards.NewRNG(13, 21)) if err != nil { t.Fatal(err) } if s.Phase == PhaseDone { t.Skip("dealt a natural") } blob, err := json.Marshal(s) if err != nil { t.Fatal(err) } var back State if err := json.Unmarshal(blob, &back); err != nil { t.Fatal(err) } // Play both forward identically; a state that survives the trip settles the same. live, _, err := ApplyMove(s, Stand) if err != nil { t.Fatal(err) } revived, _, err := ApplyMove(back, Stand) if err != nil { t.Fatal(err) } if live.Outcome != revived.Outcome || live.Payout != revived.Payout { t.Fatalf("revived hand settled differently: %q/%d vs %q/%d", revived.Outcome, revived.Payout, live.Outcome, live.Payout) } } // Same seed, same shoe — this is what lets a disputed hand be re-dealt. func TestNew_IsReproducibleFromItsSeed(t *testing.T) { a, _, err := New(100, DefaultRules(), cards.NewRNG(42, 42)) if err != nil { t.Fatal(err) } b, _, err := New(100, DefaultRules(), cards.NewRNG(42, 42)) if err != nil { t.Fatal(err) } if cards.Hand(a.Hands[0].Cards) != cards.Hand(b.Hands[0].Cards) || cards.Hand(a.Dealer) != cards.Hand(b.Dealer) { t.Fatalf("same seed dealt different hands: %s/%s vs %s/%s", cards.Hand(a.Hands[0].Cards), cards.Hand(a.Dealer), cards.Hand(b.Hands[0].Cards), cards.Hand(b.Dealer)) } } // A State handed to ApplyMove twice must produce two independent hands. If the // engine let derived states share a backing array, the second deal would scribble // over the first one's cards — and a player could watch a card change under them. func TestApplyMove_DerivedStatesDoNotShareCards(t *testing.T) { s, _, err := New(100, DefaultRules(), cards.NewRNG(23, 5)) if err != nil { t.Fatal(err) } if s.Phase == PhaseDone { t.Skip("dealt a natural") } before := cards.Hand(s.Hands[0].Cards) a, _, err := ApplyMove(s, Hit) if err != nil { t.Fatal(err) } aHand := cards.Hand(a.Hands[0].Cards) if _, _, err := ApplyMove(s, Hit); err != nil { // same start, applied again t.Fatal(err) } if got := cards.Hand(a.Hands[0].Cards); got != aHand { t.Fatalf("the first hand changed under us: %q became %q", aHand, got) } if got := cards.Hand(s.Hands[0].Cards); got != before { t.Fatalf("ApplyMove mutated the state it was given: %q became %q", before, got) } }