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