package blackjack import ( "encoding/json" "testing" "pete/internal/games/cards" ) // Split is the only move in blackjack that takes chips out of a player's stack // after the cards are already out, so most of what can go wrong with it is money // rather than cards. These tests are mostly about the money. // dealt builds a state mid-hand: the player holding `player`, the dealer showing // `dealer`, and a shoe stacked with `shoe` so the next cards are known. It skips // New() because a split needs a pair, and waiting for one out of a shuffled shoe // is not a test, it's a slot machine. func dealt(bet int64, player, dealer, shoe []cards.Card) State { return State{ Rules: DefaultRules(), Deck: cards.Deck(shoe), Dealer: dealer, Hands: []Hand{{Cards: player, Bet: bet}}, Bet: bet, Phase: PhasePlayer, } } func TestSplitDealsTwoHandsAndTakesASecondBet(t *testing.T) { s := dealt(100, hand(8, 8), hand(cards.King, 6), hand(3, 9, 5, 5), // one card to each hand, then whatever the dealer needs ) if !s.CanSplit() { t.Fatal("CanSplit says no to a pair of eights") } if s.SplitCost() != 100 { t.Fatalf("SplitCost = %d, want the same bet again (100)", s.SplitCost()) } s, evs, err := ApplyMove(s, Split) if err != nil { t.Fatal(err) } if len(s.Hands) != 2 { t.Fatalf("split made %d hands, want 2", len(s.Hands)) } for i, h := range s.Hands { if len(h.Cards) != 2 { t.Errorf("hand %d holds %d cards after the split, want 2", i, len(h.Cards)) } if h.Cards[0].Rank != 8 { t.Errorf("hand %d didn't keep an eight: %s", i, cards.Hand(h.Cards)) } if h.Bet != 100 { t.Errorf("hand %d carries a bet of %d, want 100", i, h.Bet) } if !h.Split { t.Errorf("hand %d isn't marked as split", i) } } // The whole point, in one line: there is twice as much on the table as there // was, and the shell has to have taken it. if s.Bet != 200 { t.Fatalf("total stake = %d after splitting a 100 hand, want 200", s.Bet) } if s.Active != 0 { t.Fatalf("active hand = %d, want the left one first", s.Active) } var split, cardsDealt int for _, e := range evs { switch e.Kind { case "split": split++ case "player_card": cardsDealt++ } } if split != 1 || cardsDealt != 2 { t.Fatalf("the split emitted %d split and %d card events, want 1 and 2", split, cardsDealt) } } // Split aces get one card each and no further say. Without that rule a pair of // aces is the best hand in the game every single time. func TestSplitAcesGetOneCardEachAndNoMore(t *testing.T) { s := dealt(50, hand(cards.Ace, cards.Ace), hand(9, 7), // dealer sits on 16, has to draw hand(cards.King, 9, 5), ) s, _, err := ApplyMove(s, Split) if err != nil { t.Fatal(err) } for i, h := range s.Hands { if len(h.Cards) != 2 { t.Errorf("split ace %d holds %d cards, want exactly 2", i, len(h.Cards)) } if !h.Done { t.Errorf("split ace %d is still being asked for a decision", i) } } // Both hands are finished the moment they're dealt, so the dealer plays and // the deal settles without the player ever acting again. if s.Phase != PhaseDone { t.Fatalf("phase = %q after splitting aces, want the deal to have run to the end", s.Phase) } } // An ace and a ten on a split hand is twenty-one. It is not a blackjack, and the // house does not pay 3:2 for it — which is the single most expensive rule in this // file, because splitting aces makes 21s for a living. func TestTwentyOneOnASplitHandIsNotANatural(t *testing.T) { s := dealt(100, hand(cards.Ace, cards.Ace), hand(cards.King, 7), // dealer stands on 17 hand(cards.King, cards.Queen), ) s, _, err := ApplyMove(s, Split) if err != nil { t.Fatal(err) } if s.Phase != PhaseDone { t.Fatalf("phase = %q, want done", s.Phase) } for i, h := range s.Hands { if v, _ := h.Value(); v != 21 { t.Fatalf("hand %d is %d, want the 21 this test is about", i, v) } if h.Natural() { t.Errorf("hand %d counts as a natural — a split 21 must not pay 3:2", i) } if h.Outcome != OutcomeWin { t.Errorf("hand %d settled as %q, want a plain win", i, h.Outcome) } // 100 staked, 100 profit, 5% rake off the profit: 195 back, not 245. if h.Payout != 195 { t.Errorf("hand %d paid %d, want 195 (a 1:1 win less the rake), not a 3:2 payout", i, h.Payout) } } if s.Payout != 390 { t.Fatalf("the deal paid %d, want 390", s.Payout) } } // Same rank, not same value. A king and a queen are both worth ten and are not a // pair. func TestSplitNeedsAPairOfTheSameRank(t *testing.T) { s := dealt(100, hand(cards.King, cards.Queen), hand(9, 9), hand(5)) if s.CanSplit() { t.Error("CanSplit says a king and a queen are a pair") } if _, _, err := ApplyMove(s, Split); err != ErrCantSplit { t.Errorf("splitting K+Q gave %v, want ErrCantSplit", err) } // And you cannot split a hand you have already hit. s = dealt(100, hand(8, 8, 5), hand(9, 9), hand(5)) if s.CanSplit() { t.Error("CanSplit says yes to a three-card hand") } } func TestSplittingStopsAtFourHands(t *testing.T) { // Every card an eight, so every hand splits again for as long as it's allowed. shoe := make([]cards.Card, 0, 12) for i := 0; i < 12; i++ { shoe = append(shoe, cards.Card{Rank: 8, Suit: cards.Spades}) } s := dealt(100, hand(8, 8), hand(9, 7), shoe) for i := 0; i < MaxHands-1; i++ { var err error if s, _, err = ApplyMove(s, Split); err != nil { t.Fatalf("split %d: %v", i+1, err) } } if len(s.Hands) != MaxHands { t.Fatalf("ended with %d hands, want %d", len(s.Hands), MaxHands) } if s.CanSplit() { t.Fatalf("CanSplit says yes at %d hands", MaxHands) } if _, _, err := ApplyMove(s, Split); err != ErrCantSplit { t.Errorf("the fifth split gave %v, want ErrCantSplit", err) } if s.Bet != 400 { t.Errorf("four hands of 100 = %d staked, want 400", s.Bet) } } // Each hand is raked on its own winnings. Netting the hands against each other // first would mean a player who wins one and loses one pays no rake at all, which // is not a rake, it's a discount for splitting. func TestEachSplitHandIsSettledAndRakedOnItsOwn(t *testing.T) { s := dealt(100, hand(8, 8), hand(cards.King, 9), // dealer stands on 19 // left hand gets a 2 (10, then it hits to 20 and stands); // right hand gets a 3 (11) and will bust on a king. hand(2, 3, cards.King, cards.King), ) s, _, err := ApplyMove(s, Split) if err != nil { t.Fatal(err) } if s, _, err = ApplyMove(s, Hit); err != nil { // left: 8+2+K = 20 t.Fatal(err) } if v, _ := s.Hands[0].Value(); v != 20 { t.Fatalf("left hand is %d, want 20", v) } if s, _, err = ApplyMove(s, Stand); err != nil { t.Fatal(err) } if s.Active != 1 { t.Fatalf("standing on the left hand left the active hand at %d, want 1", s.Active) } if s, _, err = ApplyMove(s, Hit); err != nil { // right: 8+3+K = 21... no: 8+3=11, +K = 21 t.Fatal(err) } if s.Phase != PhaseDone { t.Fatalf("phase = %q, want done", s.Phase) } left, right := s.Hands[0], s.Hands[1] if left.Outcome != OutcomeWin { // 20 beats 19 t.Errorf("left hand settled %q, want a win", left.Outcome) } if right.Outcome != OutcomeWin { // 21 beats 19 t.Errorf("right hand settled %q, want a win", right.Outcome) } // Two 100 hands, each winning 100, each raked 5 on its own profit. if left.Rake != 5 || right.Rake != 5 || s.Rake != 10 { t.Errorf("rake = %d/%d (total %d), want 5/5 (10) — each hand raked on its own winnings", left.Rake, right.Rake, s.Rake) } if s.Payout != 390 || s.Net() != 190 { t.Errorf("payout = %d net = %d, want 390 and 190", s.Payout, s.Net()) } } // A hand that busts loses its own bet and nothing else. The other hand is a // separate bet and settles on its own merits. func TestBustingOneSplitHandDoesNotTouchTheOther(t *testing.T) { s := dealt(100, hand(9, 9), hand(cards.King, 8), // dealer stands on 18 hand(5, 2, cards.King, cards.King), ) s, _, err := ApplyMove(s, Split) // left: 9+5=14, right: 9+2=11 if err != nil { t.Fatal(err) } if s, _, err = ApplyMove(s, Hit); err != nil { // left: 14+K = 24, bust t.Fatal(err) } if s.Hands[0].Outcome != "" && s.Hands[0].Outcome != OutcomeBust { t.Fatalf("left hand outcome %q before settling", s.Hands[0].Outcome) } if !s.Hands[0].Done || s.Active != 1 { t.Fatalf("a bust hand didn't hand over: done=%v active=%d", s.Hands[0].Done, s.Active) } if s, _, err = ApplyMove(s, Hit); err != nil { // right: 11+K = 21 t.Fatal(err) } if s.Hands[0].Outcome != OutcomeBust || s.Hands[0].Payout != 0 { t.Errorf("left hand = %q paying %d, want a bust paying nothing", s.Hands[0].Outcome, s.Hands[0].Payout) } if s.Hands[1].Outcome != OutcomeWin || s.Hands[1].Payout != 195 { t.Errorf("right hand = %q paying %d, want a win paying 195", s.Hands[1].Outcome, s.Hands[1].Payout) } // Staked 200, got 195 back: down 5 on the deal, and the word for that is lose. if s.Payout != 195 || s.Net() != -5 || s.Outcome != OutcomeLose { t.Errorf("deal settled %q paying %d (net %d), want lose/195/-5", s.Outcome, s.Payout, s.Net()) } } // If every hand busts there is nothing left to beat, and the dealer does not turn // over — same as it always was with one hand. func TestTheDealerDoesNotPlayWhenEveryHandIsBust(t *testing.T) { s := dealt(100, hand(9, 9), hand(cards.King, 6), hand(8, 8, cards.King, cards.King, 4), // both hands reach 17 then bust on a king ) s, _, err := ApplyMove(s, Split) if err != nil { t.Fatal(err) } if s, _, err = ApplyMove(s, Hit); err != nil { // left 9+8+K = 27 t.Fatal(err) } if s, _, err = ApplyMove(s, Hit); err != nil { // right 9+8+K = 27 t.Fatal(err) } if s.Phase != PhaseDone { t.Fatalf("phase = %q, want done", s.Phase) } if len(s.Dealer) != 2 { t.Errorf("the dealer drew to %d cards with every hand already bust", len(s.Dealer)) } if s.Payout != 0 { t.Errorf("two bust hands paid %d, want nothing", s.Payout) } } // The dealer not drawing is not the same as the dealer not turning over. The // browser has been showing a face-down card since the deal, and the settled state // it gets handed has the dealer's whole total on it — so a bust-out still owes it // a reveal, or the felt prints a nineteen under a card nobody has looked at. func TestBustingOutStillTurnsTheHoleCardOver(t *testing.T) { s := dealt(100, hand(cards.King, 6), hand(cards.King, 9), hand(cards.King), // 16 + K = 26 ) s, evs, err := ApplyMove(s, Hit) if err != nil { t.Fatal(err) } if s.Phase != PhaseDone || s.Outcome != OutcomeBust { t.Fatalf("phase/outcome = %q/%q, want done/bust", s.Phase, s.Outcome) } var reveal bool for _, e := range evs { if e.Kind == "reveal" { reveal = true } } if !reveal { t.Error("the player busted out and the hole card was never revealed") } } // Doubling after a split doubles *that hand's* bet, not the whole table's. func TestDoublingAfterASplitOnlyDoublesThatHand(t *testing.T) { s := dealt(100, hand(5, 5), hand(cards.King, 7), hand(6, 4, 9, cards.King), ) s, _, err := ApplyMove(s, Split) // left: 5+6=11, right: 5+4=9 if err != nil { t.Fatal(err) } if s.DoubleCost() != 100 { t.Fatalf("DoubleCost = %d on a split hand of 100, want 100", s.DoubleCost()) } s, _, err = ApplyMove(s, Double) // left doubles: 11 + 9 = 20 if err != nil { t.Fatal(err) } if s.Hands[0].Bet != 200 || s.Hands[1].Bet != 100 { t.Fatalf("bets are %d/%d after doubling the left hand, want 200/100", s.Hands[0].Bet, s.Hands[1].Bet) } if s.Bet != 300 { t.Fatalf("total staked = %d, want 300 (100 + a doubled 100 + 100)", s.Bet) } if !s.Hands[0].Done || s.Active != 1 { t.Fatal("a doubled hand takes one card and hands over") } } // A live hand outlives a deploy. The blobs written before split existed have a // "player" array and no "hands", and a state that decodes to no hands at all is a // player whose cards vanished mid-deal. func TestALiveHandDealtBeforeSplitExistedStillLoads(t *testing.T) { legacy := []byte(`{ "rules": {"decks": 6, "blackjack_pays": 1.5, "dealer_hits_soft17": true, "rake_pct": 0.05}, "deck": [], "player": [{"r": 10, "s": 1}, {"r": 7, "s": 2}], "dealer": [{"r": 9, "s": 0}, {"r": 5, "s": 3}], "bet": 250, "doubled": false, "phase": "player" }`) var s State if err := json.Unmarshal(legacy, &s); err != nil { t.Fatal(err) } if len(s.Hands) != 1 { t.Fatalf("an old live hand decoded to %d hands, want 1 — the player's cards went missing", len(s.Hands)) } if len(s.Hands[0].Cards) != 2 { t.Fatalf("the revived hand holds %d cards, want 2", len(s.Hands[0].Cards)) } if s.Hands[0].Bet != 250 || s.Bet != 250 { t.Fatalf("the revived hand carries %d of the %d staked, want all of it", s.Hands[0].Bet, s.Bet) } if v, _ := s.Hands[0].Value(); v != 17 { t.Fatalf("the revived hand is worth %d, want 17", v) } // And it can still be played to the end. if _, _, err := ApplyMove(s, Stand); err != nil { t.Fatalf("the revived hand could not be stood on: %v", err) } }