Blackjack has a split. It was the last rule missing from a game that has been live for a week, and it is the only move in blackjack that takes chips out of your stack *after* the cards are out — which is most of what there is to get wrong about it. So the state stops pretending. State.Player is gone; there is a slice of Hands, each with its own cards, its own bet, its own outcome and its own payout, and an Active index the player works left to right. Settle runs per hand and rakes per hand: netting them against each other first would mean a player who won one and lost one paid no rake at all, which is not a rake, it's a discount for splitting. The web layer takes the second bet before the move and hands it straight back if the engine refuses — the same shape double already used, except double was staking st.Bet, the whole table's stake, which was the same number as the hand's until today and is now emphatically not. DoubleCost/SplitCost are the active hand's, and the felt would have found this by charging you 300 to double the third hand of a split. The rules that cost money if you guess them: split aces get one card each and no say (a pair of aces is otherwise the best hand in the game, forever), 21 on a split hand is twenty-one and not a natural (it does not pay 3:2 — the test that pins this is the most expensive one in the file), same rank rather than same value (a king and a queen are not a pair), four hands maximum, double after split allowed, and if every hand busts the dealer does not turn over. A live hand outlives a deploy, so State.UnmarshalJSON still reads the old blobs: "player" with no "hands" becomes one hand holding the whole stake. Without it, a player mid-hand at restart is a player whose cards vanished — which is not a decode error, and would not have looked like one. On the felt a hand is now a box with its own spot, and a split is a card lifting out of one hand into a new one with a second stack of chips flying after it from your pile. Verified in a browser against a real pair: chips 4738 -> 4638 on the split, two hands played out, one push and one loss, "Down on the deal. -100", 4738 back. Three hands stack without collision at 390px. Settled hands come back to full brightness — dimming means "not your turn", and when the deal is over they are the thing you are reading. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
416 lines
12 KiB
Go
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, Hands: []Hand{{Cards: player, Bet: bet}}, 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.Hands[0].Cards) != 2 || len(s.Dealer) != 2 {
|
|
t.Fatalf("dealt %d/%d cards, want 2/2", len(s.Hands[0].Cards), 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.Hands[0].Cards) && !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.Hands[0].Cards) {
|
|
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.Hands[0].Cards); 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.Hands[0].Doubled || s.Bet != 200 {
|
|
t.Fatalf("bet = %d doubled = %v, want 200/true", s.Bet, s.Hands[0].Doubled)
|
|
}
|
|
if len(s.Hands[0].Cards) != 3 {
|
|
t.Fatalf("player has %d cards after a double, want exactly 3", len(s.Hands[0].Cards))
|
|
}
|
|
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.Hands[0].Cards) != cards.Hand(b.Hands[0].Cards) || cards.Hand(a.Dealer) != cards.Hand(b.Dealer) {
|
|
t.Fatalf("same seed dealt different hands: %s/%s vs %s/%s",
|
|
cards.Hand(a.Hands[0].Cards), cards.Hand(a.Dealer), cards.Hand(b.Hands[0].Cards), 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.Hands[0].Cards)
|
|
|
|
a, _, err := ApplyMove(s, Hit)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
aHand := cards.Hand(a.Hands[0].Cards)
|
|
|
|
if _, _, err := ApplyMove(s, Hit); err != nil { // same start, applied again
|
|
t.Fatal(err)
|
|
}
|
|
if got := cards.Hand(a.Hands[0].Cards); got != aHand {
|
|
t.Fatalf("the first hand changed under us: %q became %q", aHand, got)
|
|
}
|
|
if got := cards.Hand(s.Hands[0].Cards); got != before {
|
|
t.Fatalf("ApplyMove mutated the state it was given: %q became %q", before, got)
|
|
}
|
|
}
|