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:
346
internal/games/blackjack/blackjack.go
Normal file
346
internal/games/blackjack/blackjack.go
Normal 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
|
||||
}
|
||||
415
internal/games/blackjack/blackjack_test.go
Normal file
415
internal/games/blackjack/blackjack_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
126
internal/games/cards/cards.go
Normal file
126
internal/games/cards/cards.go
Normal 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]
|
||||
}
|
||||
102
internal/games/cards/cards_test.go
Normal file
102
internal/games/cards/cards_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user