diff --git a/internal/games/blackjack/blackjack.go b/internal/games/blackjack/blackjack.go new file mode 100644 index 0000000..e5a3883 --- /dev/null +++ b/internal/games/blackjack/blackjack.go @@ -0,0 +1,346 @@ +// Package blackjack is a pure blackjack engine. +// +// It knows nothing about HTTP, sockets, timers, euros or players' names. You +// hand it a state and a move, it hands you back a new state and the list of +// things that just happened. Everything else — who is sitting there, what their +// chips are, when their clock runs out — belongs to the shell in internal/games/table. +// +// That seam is the one thing gogobee's blackjack never had: there, the engine +// *was* the message sender, so an "error" meant a Matrix send had failed rather +// than that a player had tried something illegal. Here an error means exactly +// one thing: the move was not legal in this state. +// +// The state is a plain value. It serializes, so a hand survives a redeploy, and +// it replays, so a disputed hand can be dealt again from its seed. +package blackjack + +import ( + "errors" + "math" + "math/rand/v2" + + "pete/internal/games/cards" +) + +// Errors an illegal move can produce. Callers can match on these to tell a +// player "not now" rather than "something broke". +var ( + ErrHandOver = errors.New("blackjack: the hand is already over") + ErrNotYourTurn = errors.New("blackjack: it is not the player's turn to act") + ErrUnknownMove = errors.New("blackjack: unknown move") + ErrCantDouble = errors.New("blackjack: double is only allowed on the opening two cards") + ErrDeckExhausted = errors.New("blackjack: the shoe is empty") + ErrBadBet = errors.New("blackjack: bet must be positive") +) + +// Phase is whose turn it is. +type Phase string + +const ( + PhasePlayer Phase = "player" // the player is acting + PhaseDealer Phase = "dealer" // transient: the dealer is drawing out + PhaseDone Phase = "done" // settled, Outcome and Payout are final +) + +// Outcome is how a finished hand finished, from the player's point of view. +type Outcome string + +const ( + OutcomeNone Outcome = "" + OutcomeBlackjack Outcome = "blackjack" // natural 21, paid 3:2 + OutcomeWin Outcome = "win" + OutcomeLose Outcome = "lose" + OutcomePush Outcome = "push" // tie, stake returned + OutcomeBust Outcome = "bust" // player went over 21 + OutcomeDealerBust Outcome = "dealer_bust" +) + +// Won reports whether this outcome pays the player more than their stake back. +func (o Outcome) Won() bool { + return o == OutcomeWin || o == OutcomeBlackjack || o == OutcomeDealerBust +} + +// Rules are the table's terms. They're part of the state rather than a global, +// so a hand always settles under the rules it was dealt under — even if the +// house changes them mid-session. +type Rules struct { + Decks int `json:"decks"` // shoe size + BlackjackPays float64 `json:"blackjack_pays"` // 1.5 = the honest 3:2 + DealerHitsSoft17 bool `json:"dealer_hits_soft17"` // gogobee's dealer does + RakePct float64 `json:"rake_pct"` // house cut, taken from winnings only +} + +// DefaultRules match the blackjack gogobee has been dealing in Matrix for years: +// six decks, 3:2 on a natural, dealer hits soft 17. The rake is the one new term +// — see Settle for exactly what it touches. +func DefaultRules() Rules { + return Rules{Decks: 6, BlackjackPays: 1.5, DealerHitsSoft17: true, RakePct: 0.05} +} + +// State is one hand of heads-up blackjack: one player, one dealer. Splitting +// isn't in v1, so there's exactly one player hand. +type State struct { + Rules Rules `json:"rules"` + Deck cards.Deck `json:"deck"` // the shoe, top card first — never shown to the browser + Player []cards.Card `json:"player"` + Dealer []cards.Card `json:"dealer"` + + Bet int64 `json:"bet"` // chips at risk; doubles on a double-down + Doubled bool `json:"doubled"` + + Phase Phase `json:"phase"` + Outcome Outcome `json:"outcome"` + + // Payout is what returns to the player's chip stack when the hand is done: + // stake plus winnings, net of rake. Zero on a loss. Rake is the house's cut, + // recorded so the ledger can account for every chip that left the table. + Payout int64 `json:"payout"` + Rake int64 `json:"rake"` +} + +// Event is something the table can narrate or animate. The engine emits them +// instead of drawing anything itself. +type Event struct { + Kind string `json:"kind"` // "deal" | "player_card" | "dealer_card" | "reveal" | "settle" + Card *cards.Card `json:"card,omitempty"` + Text string `json:"text,omitempty"` +} + +// Move is a player action. +type Move string + +const ( + Hit Move = "hit" + Stand Move = "stand" + Double Move = "double" +) + +// HandValue totals a hand, counting each ace as 11 until that would bust, then +// demoting them one at a time. soft reports whether an ace is still counting as +// 11 — which is what makes "soft 17" a different thing from 17. +func HandValue(hand []cards.Card) (total int, soft bool) { + aces := 0 + for _, c := range hand { + switch { + case c.Rank == cards.Ace: + aces++ + total += 11 + case c.Rank >= 10: + total += 10 + default: + total += int(c.Rank) + } + } + for total > 21 && aces > 0 { + total -= 10 // demote an ace from 11 to 1 + aces-- + } + return total, aces > 0 +} + +// IsBlackjack reports a natural: 21 on the opening two cards. A 21 assembled +// from three cards is not one, and does not get paid 3:2. +func IsBlackjack(hand []cards.Card) bool { + if len(hand) != 2 { + return false + } + v, _ := HandValue(hand) + return v == 21 +} + +// New deals a fresh hand: two to the player, two to the dealer. If either side +// has a natural the hand is already over and the returned State is settled — a +// player with blackjack never gets asked whether they'd like to hit. +func New(bet int64, r Rules, rng *rand.Rand) (State, []Event, error) { + if bet <= 0 { + return State{}, nil, ErrBadBet + } + if r.Decks < 1 { + r.Decks = 1 + } + deck := cards.NewDeck(r.Decks) + deck.Shuffle(rng) + + s := State{Rules: r, Deck: deck, Bet: bet, Phase: PhasePlayer} + evs := []Event{{Kind: "deal"}} + + for i := 0; i < 2; i++ { + if err := s.draw(&s.Player, "player_card", &evs); err != nil { + return State{}, nil, err + } + if err := s.draw(&s.Dealer, "dealer_card", &evs); err != nil { + return State{}, nil, err + } + } + + // A natural on either side ends it before the player ever acts. + if IsBlackjack(s.Player) || IsBlackjack(s.Dealer) { + s.settle(&evs) + } + return s, evs, nil +} + +// draw takes one card off the shoe onto the given hand and records the event. +// Pointer receiver: it mutates the deck and the hand together, and neither may +// end up applied to a stale copy of the state. +func (s *State) draw(hand *[]cards.Card, kind string, evs *[]Event) error { + c, ok := s.Deck.Draw() + if !ok { + return ErrDeckExhausted + } + *hand = append(*hand, c) + card := c + *evs = append(*evs, Event{Kind: kind, Card: &card}) + return nil +} + +// ApplyMove is the whole engine: a legal move in, a new state and the events it +// produced out. An error means the move was illegal and the state is unchanged. +// +// s is taken by value, so the caller's state is only replaced on success. +func ApplyMove(s State, m Move) (State, []Event, error) { + if s.Phase == PhaseDone { + return s, nil, ErrHandOver + } + // A copied State still shares its slices' backing arrays with the original. + // Two moves applied from the same starting state would then append cards over + // each other. Clone first: the caller's state is genuinely untouched, and a + // state can be replayed as many times as we like. + s = s.clone() + if s.Phase != PhasePlayer { + return s, nil, ErrNotYourTurn + } + if m == Double && len(s.Player) != 2 { + // Doubling means doubling the stake for exactly one more card. Only ever + // legal on the opening two — after that you're just describing a hit. + return s, nil, ErrCantDouble + } + if m != Hit && m != Stand && m != Double { + return s, nil, ErrUnknownMove + } + + evs := []Event{} + + if m == Double { + s.Bet *= 2 + s.Doubled = true + } + + if m == Hit || m == Double { + if err := s.draw(&s.Player, "player_card", &evs); err != nil { + return s, nil, err + } + if v, _ := HandValue(s.Player); v > 21 { + s.settle(&evs) // bust; the dealer never has to play + return s, evs, nil + } + if m == Hit { + return s, evs, nil // still the player's turn + } + } + + // Stand, or a double that survived its card: the dealer draws out. + s.Phase = PhaseDealer + s.dealerPlay(&evs) + return s, evs, nil +} + +// dealerPlay draws the dealer out to the house rule, then settles. The dealer +// has no choices to make — that's the game — so this needs no move. +func (s *State) dealerPlay(evs *[]Event) { + *evs = append(*evs, Event{Kind: "reveal"}) // the hole card turns over + for { + v, soft := HandValue(s.Dealer) + hitSoft17 := s.Rules.DealerHitsSoft17 && v == 17 && soft + if v >= 17 && !hitSoft17 { + break + } + if err := s.draw(&s.Dealer, "dealer_card", evs); err != nil { + break // shoe ran dry mid-draw; settle on what's on the table + } + } + s.settle(evs) +} + +// settle decides the outcome and the payout, and is the only place chips are +// computed. +// +// The rake comes off winnings, never off the stake: a player who pushes gets +// exactly their bet back, and a player who loses is never charged for the +// privilege. The house only takes a cut of money the house was going to hand +// over anyway. That's a rake, as opposed to a fee for showing up. +func (s *State) settle(evs *[]Event) { + playerVal, _ := HandValue(s.Player) + dealerVal, _ := HandValue(s.Dealer) + playerBJ := IsBlackjack(s.Player) + dealerBJ := IsBlackjack(s.Dealer) + + // profit is what the player wins on top of their stake. Negative means the + // stake is gone. + var profit int64 + + switch { + case playerVal > 21: + s.Outcome = OutcomeBust + profit = -s.Bet + case playerBJ && dealerBJ: + s.Outcome = OutcomePush + case playerBJ: + s.Outcome = OutcomeBlackjack + profit = int64(math.Floor(float64(s.Bet) * s.Rules.BlackjackPays)) + case dealerBJ: + s.Outcome = OutcomeLose + profit = -s.Bet + case dealerVal > 21: + s.Outcome = OutcomeDealerBust + profit = s.Bet + case playerVal > dealerVal: + s.Outcome = OutcomeWin + profit = s.Bet + case playerVal == dealerVal: + s.Outcome = OutcomePush + default: + s.Outcome = OutcomeLose + profit = -s.Bet + } + + if profit > 0 { + s.Rake = int64(math.Floor(float64(profit) * s.Rules.RakePct)) + if s.Rake < 0 { + s.Rake = 0 + } + profit -= s.Rake + } + if profit < 0 { + s.Payout = 0 // stake is lost; nothing comes back + } else { + s.Payout = s.Bet + profit + } + + s.Phase = PhaseDone + *evs = append(*evs, Event{Kind: "settle", Text: string(s.Outcome)}) +} + +// Net is what the hand did to the player's chip stack: payout minus the stake +// they put up. Negative on a loss, zero on a push. +func (s State) Net() int64 { + if s.Phase != PhaseDone { + return 0 + } + return s.Payout - s.Bet +} + +// CanDouble reports whether Double is legal right now — the shell asks this to +// decide whether to light the button up. +func (s State) CanDouble() bool { + return s.Phase == PhasePlayer && len(s.Player) == 2 +} + +// clone deep-copies the slices so a derived state shares no backing array with +// the one it came from. +func (s State) clone() State { + s.Deck = append(cards.Deck(nil), s.Deck...) + s.Player = append([]cards.Card(nil), s.Player...) + s.Dealer = append([]cards.Card(nil), s.Dealer...) + return s +} diff --git a/internal/games/blackjack/blackjack_test.go b/internal/games/blackjack/blackjack_test.go new file mode 100644 index 0000000..d3ebe7d --- /dev/null +++ b/internal/games/blackjack/blackjack_test.go @@ -0,0 +1,415 @@ +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, Player: player, 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.Player) != 2 || len(s.Dealer) != 2 { + t.Fatalf("dealt %d/%d cards, want 2/2", len(s.Player), 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.Player) && !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.Player) { + 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.Player); 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.Doubled || s.Bet != 200 { + t.Fatalf("bet = %d doubled = %v, want 200/true", s.Bet, s.Doubled) + } + if len(s.Player) != 3 { + t.Fatalf("player has %d cards after a double, want exactly 3", len(s.Player)) + } + 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.Player) != cards.Hand(b.Player) || cards.Hand(a.Dealer) != cards.Hand(b.Dealer) { + t.Fatalf("same seed dealt different hands: %s/%s vs %s/%s", + cards.Hand(a.Player), cards.Hand(a.Dealer), cards.Hand(b.Player), 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.Player) + + a, _, err := ApplyMove(s, Hit) + if err != nil { + t.Fatal(err) + } + aHand := cards.Hand(a.Player) + + if _, _, err := ApplyMove(s, Hit); err != nil { // same start, applied again + t.Fatal(err) + } + if got := cards.Hand(a.Player); got != aHand { + t.Fatalf("the first hand changed under us: %q became %q", aHand, got) + } + if got := cards.Hand(s.Player); got != before { + t.Fatalf("ApplyMove mutated the state it was given: %q became %q", before, got) + } +} diff --git a/internal/games/cards/cards.go b/internal/games/cards/cards.go new file mode 100644 index 0000000..ad5f9d0 --- /dev/null +++ b/internal/games/cards/cards.go @@ -0,0 +1,126 @@ +// Package cards holds the deck primitives every card game on Pete shares. +// +// gogobee never had this: blackjack carried its own deck, UNO carried another, +// and hold'em leaned on a third-party one. Three shuffles, three bugs to fix +// three times. The games ported over here consolidate onto this instead. +// +// Two rules hold throughout: +// +// The RNG is threaded, never global. Every shuffle takes an explicit *rand.Rand, +// so a hand is reproducible from its seed — which is what makes the engines +// testable, and what lets us re-deal a disputed hand and show the player exactly +// what the shoe did. +// +// A Deck is a plain value. No pointers into it, no timers hanging off it, so a +// game in progress serializes to JSON and survives a redeploy. +package cards + +import "math/rand/v2" + +// Suit is one of the four French suits. +type Suit uint8 + +const ( + Spades Suit = iota + Hearts + Diamonds + Clubs +) + +// Rank runs Ace(1) through King(13). Ace is low here; games that want it high +// (blackjack's soft 11, hold'em's wheel) say so themselves. +type Rank uint8 + +const ( + Ace Rank = 1 + Jack Rank = 11 + Queen Rank = 12 + King Rank = 13 +) + +var ( + suitGlyphs = [4]string{"♠", "♥", "♦", "♣"} + rankNames = [14]string{"", "A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"} +) + +// Card is one playing card. The short JSON keys keep a serialized shoe small — +// a six-deck blackjack state is 312 of these. +type Card struct { + Rank Rank `json:"r"` + Suit Suit `json:"s"` +} + +// String renders the card the way a table shows it: "A♠", "10♥". +func (c Card) String() string { + if c.Rank < Ace || c.Rank > King || c.Suit > Clubs { + return "??" + } + return rankNames[c.Rank] + suitGlyphs[c.Suit] +} + +// Red reports whether the card is a red suit — the one thing every renderer +// needs and nobody should re-derive. +func (c Card) Red() bool { return c.Suit == Hearts || c.Suit == Diamonds } + +// Deck is an ordered pile of cards. The next card to come off is at index 0. +type Deck []Card + +// NewDeck builds n standard 52-card decks in fixed order. Shuffle before use: +// an unshuffled deck is a bug at a table, but it's exactly what a test wants. +func NewDeck(n int) Deck { + if n < 1 { + n = 1 + } + d := make(Deck, 0, 52*n) + for i := 0; i < n; i++ { + for s := Spades; s <= Clubs; s++ { + for r := Ace; r <= King; r++ { + d = append(d, Card{Rank: r, Suit: s}) + } + } + } + return d +} + +// Shuffle permutes the deck in place using the supplied RNG. Passing a seeded +// *rand.Rand gives the same shuffle every time, which is the whole point. +func (d Deck) Shuffle(rng *rand.Rand) { + rng.Shuffle(len(d), func(i, j int) { d[i], d[j] = d[j], d[i] }) +} + +// Draw takes the top card. ok is false when the deck is spent; the caller +// decides whether that means reshuffle or fold, because the two games that hit +// it disagree. +func (d *Deck) Draw() (c Card, ok bool) { + if len(*d) == 0 { + return Card{}, false + } + c = (*d)[0] + *d = (*d)[1:] + return c, true +} + +// Hand renders a run of cards for display: "A♠ 10♥". +func Hand(cs []Card) string { + s := "" + for i, c := range cs { + if i > 0 { + s += " " + } + s += c.String() + } + return s +} + +// NewRNG seeds a generator from two uint64s. Games store the seed alongside the +// hand so a finished hand can be replayed exactly as it was dealt. +func NewRNG(seed1, seed2 uint64) *rand.Rand { + return rand.New(rand.NewPCG(seed1, seed2)) +} + +func (s Suit) String() string { + if s > Clubs { + return "?" + } + return suitGlyphs[s] +} diff --git a/internal/games/cards/cards_test.go b/internal/games/cards/cards_test.go new file mode 100644 index 0000000..ff52a83 --- /dev/null +++ b/internal/games/cards/cards_test.go @@ -0,0 +1,102 @@ +package cards + +import "testing" + +func TestNewDeck_IsAFullShoe(t *testing.T) { + d := NewDeck(6) + if len(d) != 312 { + t.Fatalf("six decks hold %d cards, want 312", len(d)) + } + seen := map[Card]int{} + for _, c := range d { + seen[c]++ + } + if len(seen) != 52 { + t.Fatalf("%d distinct cards, want 52", len(seen)) + } + for c, n := range seen { + if n != 6 { + t.Fatalf("%s appears %d times in a six-deck shoe, want 6", c, n) + } + } +} + +func TestNewDeck_ClampsToAtLeastOne(t *testing.T) { + if len(NewDeck(0)) != 52 { + t.Fatal("a zero-deck shoe should still hold one deck") + } +} + +func TestShuffle_SameSeedSameOrder(t *testing.T) { + a, b := NewDeck(1), NewDeck(1) + a.Shuffle(NewRNG(99, 1)) + b.Shuffle(NewRNG(99, 1)) + for i := range a { + if a[i] != b[i] { + t.Fatalf("same seed diverged at %d: %s vs %s", i, a[i], b[i]) + } + } + // And a different seed must not give the same order, or the RNG isn't wired up. + c := NewDeck(1) + c.Shuffle(NewRNG(100, 1)) + same := true + for i := range a { + if a[i] != c[i] { + same = false + break + } + } + if same { + t.Fatal("a different seed produced an identical shuffle") + } +} + +func TestShuffle_KeepsEveryCard(t *testing.T) { + d := NewDeck(1) + d.Shuffle(NewRNG(4, 4)) + seen := map[Card]bool{} + for _, c := range d { + seen[c] = true + } + if len(d) != 52 || len(seen) != 52 { + t.Fatalf("shuffle lost cards: %d cards, %d distinct", len(d), len(seen)) + } +} + +func TestDraw_TakesFromTheTopAndRunsOut(t *testing.T) { + d := NewDeck(1) + top := d[0] + c, ok := d.Draw() + if !ok || c != top { + t.Fatalf("drew %s (ok=%v), want the top card %s", c, ok, top) + } + if len(d) != 51 { + t.Fatalf("deck has %d cards after one draw, want 51", len(d)) + } + for len(d) > 0 { + d.Draw() + } + if _, ok := d.Draw(); ok { + t.Fatal("an empty deck kept dealing") + } +} + +func TestCard_String(t *testing.T) { + tests := []struct { + card Card + want string + }{ + {Card{Ace, Spades}, "A♠"}, + {Card{10, Hearts}, "10♥"}, + {Card{King, Clubs}, "K♣"}, + {Card{Rank: 99, Suit: Spades}, "??"}, + } + for _, tc := range tests { + if got := tc.card.String(); got != tc.want { + t.Errorf("String() = %q, want %q", got, tc.want) + } + } + if !(Card{Ace, Hearts}).Red() || (Card{Ace, Spades}).Red() { + t.Error("Red() disagrees about which suits are red") + } +}