Bust every hand and the dealer doesn't draw, which is right, but it was also not turning over: reveal is only emitted by dealerPlay, and busting out skips the dealer entirely. The browser kept the hole card face down while the settled state printed the dealer's whole total under it. Emit the reveal on that path. And standing your bet back up after a reload read the hand's bet straight off the settled state, which a double has already doubled. Reload, double 200, and the next hand starts with 400 on the spot. Plus: the share card was hand-writing a Content-Length that ServeContent overwrites anyway, and serving a zero-byte 200 for a room with no card. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
403 lines
13 KiB
Go
403 lines
13 KiB
Go
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)
|
|
}
|
|
}
|