games: a deck you can seed and a blackjack you can replay

The first two pieces of games.parodia.dev, both pure: no HTTP, no timers, no
euros, nothing that knows a player's name.

cards/ is the shared deck gogobee never had — blackjack carried its own, UNO
carried another, hold'em leaned on a third-party one. The RNG is threaded rather
than the package global, so a hand is reproducible from its seed. That's what
makes the engine testable, and what lets a disputed hand be dealt again exactly
as it fell.

blackjack/ is ApplyMove(state, move) -> (state, events, error), where an error
means the move was illegal and nothing else. gogobee's engine *was* the message
sender, so its errors meant "the send failed"; there was no seam to test against.
State is a plain value, so a hand survives a redeploy.

House terms match the Matrix table — six decks, 3:2, dealer hits soft 17 — plus a
5% rake. The rake comes off winnings only: a push returns the stake untouched and
a loss is never charged for the privilege.
This commit is contained in:
prosolis
2026-07-13 22:44:45 -07:00
parent 6ccd18452c
commit 8310b30439
4 changed files with 989 additions and 0 deletions

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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]
}

View File

@@ -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")
}
}