Files
Pete/internal/games/blackjack/blackjack_test.go
prosolis 8310b30439 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.
2026-07-13 22:44:45 -07:00

416 lines
12 KiB
Go

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