Files
Pete/internal/games/klondike/klondike_test.go
prosolis b814f936a8 solitaire: detect a won board and finish it in one press
A drained, all-face-up board is a guaranteed clear that auto() sweeps home
in a single cascade, but the only way to trigger it was double-clicking
thirty cards home by hand. Add State.Won(), surface it in the view, and
swap the ordinary controls for one pulsing finish button when it's true.
2026-07-15 18:50:43 -07:00

777 lines
25 KiB
Go

package klondike
import (
"encoding/json"
"math/rand/v2"
"strconv"
"testing"
"pete/internal/games/cards"
)
const rake = 0.05
func vegas() Tier { t, _ := TierBySlug("vegas"); return t }
func patient() Tier { t, _ := TierBySlug("patient"); return t }
func cut() Tier { t, _ := TierBySlug("cutthroat"); return t }
func card(r cards.Rank, s cards.Suit) cards.Card { return cards.Card{Rank: r, Suit: s} }
// ordered builds the 52 cards in a fixed order — the deck deal() would get if
// the shuffle were the identity. Tests that care about the board build their own.
func ordered() cards.Deck { return cards.NewDeck(1) }
func mustDeal(t *testing.T, bet int64, tier Tier, d cards.Deck) State {
t.Helper()
s, evs, err := deal(bet, tier, d, rake)
if err != nil {
t.Fatalf("deal: %v", err)
}
if len(evs) != 1 || evs[0].Kind != "deal" {
t.Fatalf("deal events = %+v, want one deal", evs)
}
return s
}
func apply(t *testing.T, s State, m Move) (State, []Event) {
t.Helper()
next, evs, err := ApplyMove(s, m)
if err != nil {
t.Fatalf("ApplyMove(%+v): %v", m, err)
}
return next, evs
}
func refuses(t *testing.T, s State, m Move, want error) {
t.Helper()
next, evs, err := ApplyMove(s, m)
if err == nil {
t.Fatalf("ApplyMove(%+v) was allowed, want %v", m, want)
}
if want != nil && err != want {
t.Fatalf("ApplyMove(%+v) = %v, want %v", m, err, want)
}
if evs != nil {
t.Errorf("an illegal move emitted events: %+v", evs)
}
// The board an illegal move hands back must be the one it was given. This is
// the whole contract of the reducer, and it's cheap to check by value.
if !sameBoard(next, s) {
t.Errorf("an illegal move changed the board")
}
}
func sameBoard(a, b State) bool {
x, _ := json.Marshal(a)
y, _ := json.Marshal(b)
return string(x) == string(y)
}
// ---- the deal --------------------------------------------------------------
func TestDealLaysOutTheBoard(t *testing.T) {
s := mustDeal(t, 520, vegas(), ordered())
seen := 0
for i := 0; i < Piles; i++ {
p := s.Table[i]
if len(p.Up) != 1 {
t.Errorf("column %d has %d face up, want 1", i, len(p.Up))
}
if len(p.Down) != i {
t.Errorf("column %d has %d face down, want %d", i, len(p.Down), i)
}
seen += len(p.Up) + len(p.Down)
}
if seen != 28 {
t.Errorf("tableau holds %d cards, want 28", seen)
}
if len(s.Stock) != 24 {
t.Errorf("stock is %d, want 24", len(s.Stock))
}
if s.Home() != 0 || s.Pays() != 0 {
t.Errorf("a fresh board is worth %d from %d home, want nothing", s.Pays(), s.Home())
}
}
func TestDealRefusesABadStake(t *testing.T) {
if _, _, err := deal(0, vegas(), ordered(), rake); err != ErrBadBet {
t.Fatalf("deal(0) = %v, want ErrBadBet", err)
}
if _, _, err := New(-5, vegas(), rake, cards.NewRNG(1, 2)); err != ErrBadBet {
t.Fatalf("New(-5) = %v, want ErrBadBet", err)
}
}
// ---- the stock -------------------------------------------------------------
func TestDrawTurnsTheTiersCount(t *testing.T) {
for _, tier := range []Tier{patient(), vegas()} {
s := mustDeal(t, 100, tier, ordered())
next, evs := apply(t, s, Move{Kind: "draw"})
if len(next.Waste) != tier.Draw {
t.Errorf("%s: waste is %d after one draw, want %d", tier.Slug, len(next.Waste), tier.Draw)
}
if len(next.Stock) != 24-tier.Draw {
t.Errorf("%s: stock is %d, want %d", tier.Slug, len(next.Stock), 24-tier.Draw)
}
if len(evs) != 1 || evs[0].Kind != "draw" || len(evs[0].Cards) != tier.Draw {
t.Errorf("%s: draw events = %+v", tier.Slug, evs)
}
}
}
// The last pull off a short stock turns over what's left rather than refusing.
func TestDrawTakesWhatIsLeft(t *testing.T) {
s := mustDeal(t, 100, vegas(), ordered()) // 24 in the stock, drawing 3
for i := 0; i < 7; i++ {
s, _ = apply(t, s, Move{Kind: "draw"}) // 21 drawn, 3 left
}
s, _ = apply(t, s, Move{Kind: "draw"})
if len(s.Stock) != 0 || len(s.Waste) != 24 {
t.Fatalf("stock %d waste %d, want 0 and 24", len(s.Stock), len(s.Waste))
}
refuses(t, drained(t, s), Move{Kind: "draw"}, ErrNoDraw)
}
// drained empties the waste too, so there is genuinely nothing to turn over.
func drained(t *testing.T, s State) State {
t.Helper()
s = s.clone()
s.Waste = nil
s.Stock = nil
return s
}
// The waste goes back under the stock in the order it came out — a recycle is a
// pile being turned over, not reshuffled. If this ever reshuffled, the seed in
// the audit log would stop replaying the game.
func TestRecycleTurnsTheWasteOverInOrder(t *testing.T) {
s := mustDeal(t, 100, patient(), ordered())
want := append(cards.Deck(nil), s.Stock...)
for i := 0; i < 24; i++ {
s, _ = apply(t, s, Move{Kind: "draw"})
}
next, evs := apply(t, s, Move{Kind: "draw"})
if len(evs) != 1 || evs[0].Kind != "recycle" {
t.Fatalf("events = %+v, want a recycle", evs)
}
if len(next.Waste) != 0 {
t.Errorf("waste is %d after a recycle, want empty", len(next.Waste))
}
for i := range want {
if next.Stock[i] != want[i] {
t.Fatalf("stock[%d] = %v after recycle, want %v — the pile was reshuffled",
i, next.Stock[i], want[i])
}
}
if next.Recycles != 1 {
t.Errorf("recycles = %d, want 1", next.Recycles)
}
}
// Passes is how many times you may go *through* the stock, so it is one more
// than the number of times you may turn it back over.
func TestPassesRunOut(t *testing.T) {
tests := []struct {
tier Tier
recycles int // how many turn-overs the tier should allow
}{
{cut(), 0}, // one pass: you never get to turn it back over
{vegas(), 2}, // three passes: two turn-overs
{patient(), -1}, // unlimited
}
for _, tc := range tests {
s := mustDeal(t, 100, tc.tier, ordered())
if got := s.PassesLeft(); tc.recycles < 0 && got != -1 {
t.Errorf("%s: PassesLeft = %d, want -1 (unlimited)", tc.tier.Slug, got)
}
allowed := 0
for i := 0; i < 5; i++ {
// Empty the stock, then try to turn it over.
for len(s.Stock) > 0 {
s, _ = apply(t, s, Move{Kind: "draw"})
}
next, _, err := ApplyMove(s, Move{Kind: "draw"})
if err == ErrNoPasses {
break
}
if err != nil {
t.Fatalf("%s: %v", tc.tier.Slug, err)
}
s = next
allowed++
}
if tc.recycles < 0 {
if allowed != 5 {
t.Errorf("%s: only %d recycles allowed, want unlimited", tc.tier.Slug, allowed)
}
continue
}
if allowed != tc.recycles {
t.Errorf("%s: %d recycles allowed, want %d", tc.tier.Slug, allowed, tc.recycles)
}
if s.PassesLeft() != 1 {
t.Errorf("%s: PassesLeft = %d on the last pass, want 1", tc.tier.Slug, s.PassesLeft())
}
}
}
// ---- the rules -------------------------------------------------------------
// board builds a State directly, so a rule can be tested against the position
// that exercises it rather than against whatever a shuffle happened to deal.
func board(tier Tier, bet int64) State {
return State{Tier: tier, Bet: bet, RakePct: rake, Phase: PhasePlaying}
}
func TestTableauTakesDescendingAlternatingColour(t *testing.T) {
s := board(vegas(), 520)
s.Table[0].Up = []cards.Card{card(8, cards.Spades)} // black 8
s.Table[1].Up = []cards.Card{card(7, cards.Hearts)} // red 7 — goes on the 8
s.Table[2].Up = []cards.Card{card(7, cards.Clubs)} // black 7 — does not
s.Table[3].Up = []cards.Card{card(6, cards.Hearts)} // red 6 — wrong rank for the 8
next, evs := apply(t, s, Move{Kind: "move", From: "t1", To: "t0"})
if len(next.Table[0].Up) != 2 || next.Table[0].Up[1] != card(7, cards.Hearts) {
t.Fatalf("the red seven didn't land on the black eight: %+v", next.Table[0].Up)
}
if len(next.Table[1].Up) != 0 {
t.Errorf("the seven is still in its old column")
}
if len(evs) != 1 || evs[0].Kind != "move" {
t.Errorf("events = %+v, want one move", evs)
}
refuses(t, s, Move{Kind: "move", From: "t2", To: "t0"}, ErrWontGo) // same colour
refuses(t, s, Move{Kind: "move", From: "t3", To: "t0"}, ErrWontGo) // two below
}
func TestOnlyAKingTakesAnEmptyColumn(t *testing.T) {
s := board(vegas(), 520)
// t0 is empty and has nothing under it.
s.Table[1].Up = []cards.Card{card(cards.King, cards.Hearts)}
s.Table[2].Up = []cards.Card{card(cards.Queen, cards.Spades)}
refuses(t, s, Move{Kind: "move", From: "t2", To: "t0"}, ErrWontGo)
next, _ := apply(t, s, Move{Kind: "move", From: "t1", To: "t0"})
if len(next.Table[0].Up) != 1 || next.Table[0].Up[0].Rank != cards.King {
t.Fatalf("the king didn't take the empty column: %+v", next.Table[0].Up)
}
}
// A run comes off the tableau as a block, and only if it is a run.
func TestLiftingARun(t *testing.T) {
s := board(vegas(), 520)
s.Table[0].Up = []cards.Card{
card(9, cards.Hearts), // red
card(8, cards.Spades), // black
card(7, cards.Diamonds), // red
}
s.Table[1].Up = []cards.Card{card(10, cards.Clubs)} // black 10 takes the red 9
next, _ := apply(t, s, Move{Kind: "move", From: "t0", To: "t1", Count: 3})
if len(next.Table[1].Up) != 4 || len(next.Table[0].Up) != 0 {
t.Fatalf("the run didn't move as a block: t0=%v t1=%v", next.Table[0].Up, next.Table[1].Up)
}
// Not a run: same colour in the middle of it.
bad := board(vegas(), 520)
bad.Table[0].Up = []cards.Card{
card(9, cards.Hearts),
card(8, cards.Diamonds), // red on red
}
bad.Table[1].Up = []cards.Card{card(10, cards.Clubs)}
refuses(t, bad, Move{Kind: "move", From: "t0", To: "t1", Count: 2}, ErrNotASequence)
// And you can't lift more cards than the column has.
refuses(t, bad, Move{Kind: "move", From: "t0", To: "t1", Count: 9}, ErrNotASequence)
}
// Taking the last face-up card off a column turns the next one over. This is the
// only thing in the game that reveals a card, which is the point of the test.
func TestTakingTheLastCardFlipsTheNextOne(t *testing.T) {
s := board(vegas(), 520)
hidden := card(cards.Queen, cards.Clubs)
s.Table[0].Down = []cards.Card{card(2, cards.Spades), hidden}
s.Table[0].Up = []cards.Card{card(7, cards.Hearts)}
s.Table[1].Up = []cards.Card{card(8, cards.Spades)}
next, evs := apply(t, s, Move{Kind: "move", From: "t0", To: "t1"})
if len(next.Table[0].Up) != 1 || next.Table[0].Up[0] != hidden {
t.Fatalf("the hidden card didn't turn over: %+v", next.Table[0].Up)
}
if len(next.Table[0].Down) != 1 {
t.Errorf("face-down stack is %d, want 1", len(next.Table[0].Down))
}
if len(evs) != 2 || evs[1].Kind != "flip" || evs[1].Cards[0] != hidden {
t.Fatalf("events = %+v, want a move then a flip carrying the card", evs)
}
}
func TestFoundationsBuildUpBySuitFromTheAce(t *testing.T) {
s := board(vegas(), 520)
s.Table[0].Up = []cards.Card{card(cards.Ace, cards.Hearts)}
s.Table[1].Up = []cards.Card{card(2, cards.Hearts)}
s.Table[2].Up = []cards.Card{card(2, cards.Spades)}
s.Table[3].Up = []cards.Card{card(3, cards.Hearts)}
// A two can't start a foundation.
refuses(t, s, Move{Kind: "home", From: "t1"}, ErrWontGo)
s, evs := apply(t, s, Move{Kind: "home", From: "t0"})
if len(s.Found[cards.Hearts]) != 1 {
t.Fatalf("the ace didn't go home: %+v", s.Found)
}
if evs[0].Kind != "home" || evs[0].To != "f"+strconv.Itoa(int(cards.Hearts)) {
t.Fatalf("event = %+v, want a home to the hearts pile", evs[0])
}
if evs[0].Home != 1 {
t.Errorf("event carries Home=%d, want 1", evs[0].Home)
}
// The three can't jump the two, and the two of spades can't go on hearts.
refuses(t, s, Move{Kind: "home", From: "t3"}, ErrWontGo)
refuses(t, s, Move{Kind: "move", From: "t2", To: "f" + strconv.Itoa(int(cards.Hearts))}, ErrWontGo)
s, _ = apply(t, s, Move{Kind: "home", From: "t1"})
if s.Home() != 2 {
t.Errorf("Home = %d, want 2", s.Home())
}
}
// A card can come back off a foundation — a real rule, and one that matters when
// you need a low card to move a column. The payout follows it back down, because
// the payout reads the board rather than counting events.
func TestACardComesBackOffAFoundation(t *testing.T) {
s := board(vegas(), 5200)
s.Found[cards.Hearts] = []cards.Card{card(cards.Ace, cards.Hearts), card(2, cards.Hearts)}
s.Table[0].Up = []cards.Card{card(3, cards.Spades)}
before := s.Pays()
next, _ := apply(t, s, Move{Kind: "move", From: "f" + strconv.Itoa(int(cards.Hearts)), To: "t0"})
if len(next.Found[cards.Hearts]) != 1 || len(next.Table[0].Up) != 2 {
t.Fatalf("the two didn't come back down: found=%v t0=%v", next.Found[cards.Hearts], next.Table[0].Up)
}
if next.Home() != 1 {
t.Errorf("Home = %d after taking a card back, want 1", next.Home())
}
if next.Pays() >= before {
t.Errorf("Pays = %d after taking a card back, want less than %d", next.Pays(), before)
}
}
func TestWasteGivesUpItsTopCardOnly(t *testing.T) {
s := board(vegas(), 520)
s.Waste = []cards.Card{card(5, cards.Spades), card(7, cards.Hearts)}
s.Table[0].Up = []cards.Card{card(8, cards.Spades)}
// The 5 is under the 7 and is not available, however much you'd like it.
refuses(t, s, Move{Kind: "move", From: "waste", To: "t0", Count: 2}, ErrNotASequence)
next, _ := apply(t, s, Move{Kind: "move", From: "waste", To: "t0"})
if len(next.Waste) != 1 || next.Waste[0] != card(5, cards.Spades) {
t.Fatalf("the wrong card left the waste: %+v", next.Waste)
}
}
func TestEmptyPilesAndNonsensePiles(t *testing.T) {
s := board(vegas(), 520)
s.Table[0].Up = []cards.Card{card(8, cards.Spades)}
refuses(t, s, Move{Kind: "move", From: "waste", To: "t0"}, ErrEmptyPile)
refuses(t, s, Move{Kind: "move", From: "t3", To: "t0"}, ErrEmptyPile)
refuses(t, s, Move{Kind: "move", From: "t9", To: "t0"}, ErrBadPile)
refuses(t, s, Move{Kind: "move", From: "t0", To: "t9"}, ErrWontGo)
refuses(t, s, Move{Kind: "move", From: "banana", To: "t0"}, ErrBadPile)
refuses(t, s, Move{Kind: "sing"}, ErrUnknownMove)
}
// ---- auto ------------------------------------------------------------------
func TestAutoSendsEverythingItCanHome(t *testing.T) {
s := board(vegas(), 5200)
// Two aces and the hearts two, sitting on top of three columns.
s.Table[0].Up = []cards.Card{card(cards.Ace, cards.Hearts)}
s.Table[1].Up = []cards.Card{card(2, cards.Hearts)}
s.Table[2].Up = []cards.Card{card(cards.Ace, cards.Spades)}
s.Table[3].Up = []cards.Card{card(9, cards.Clubs)} // goes nowhere
next, evs := apply(t, s, Move{Kind: "auto"})
if next.Home() != 3 {
t.Fatalf("Home = %d after auto, want 3 (two aces and the two)", next.Home())
}
if len(next.Table[3].Up) != 1 {
t.Errorf("the nine went somewhere it couldn't go")
}
homes := 0
for _, e := range evs {
if e.Kind == "home" {
homes++
}
}
if homes != 3 {
t.Errorf("auto emitted %d home events, want 3 — the table has to animate each one", homes)
}
// Nothing left to do: the button says so rather than doing nothing quietly.
if next.CanAuto() {
t.Errorf("CanAuto is true with only a nine on the board")
}
refuses(t, next, Move{Kind: "auto"}, ErrNothingHome)
}
// Won is the state the finish button finishes: every card face up, the stock and
// waste drained, so a single auto sweeps the lot home. The button is only honest
// if these two agree — Won lighting up on a board auto can't clear would stall.
func TestWonIsExactlyWhatAutoCanFinish(t *testing.T) {
s := board(patient(), 5200)
// A won board: three short face-up runs across the columns, nothing face down,
// nothing in the stock or waste. Every card is reachable.
s.Table[0].Up = []cards.Card{card(2, cards.Spades), card(cards.Ace, cards.Hearts)}
s.Table[1].Up = []cards.Card{card(2, cards.Hearts), card(cards.Ace, cards.Spades)}
s.Table[2].Up = []cards.Card{card(2, cards.Diamonds), card(cards.Ace, cards.Clubs)}
s.Table[3].Up = []cards.Card{card(2, cards.Clubs), card(cards.Ace, cards.Diamonds)}
if !s.Won() {
t.Fatalf("a board with everything face up and the piles empty is won")
}
next, _ := apply(t, s, Move{Kind: "auto"})
if next.Home() != 8 { // the eight cards laid out above
t.Fatalf("auto homed %d cards, want 8 — the whole won board", next.Home())
}
// Anything still hidden or still in a pile is not won: auto would stall on it.
withStock := s.clone()
withStock.Stock = cards.Deck{card(5, cards.Spades)}
if withStock.Won() {
t.Errorf("a board with a card still in the stock is not won — auto won't turn it over")
}
withWaste := s.clone()
withWaste.Waste = []cards.Card{card(5, cards.Spades)}
if withWaste.Won() {
t.Errorf("a board with a card still in the waste is not won")
}
withDown := s.clone()
withDown.Table[5].Down = []cards.Card{card(5, cards.Spades)}
if withDown.Won() {
t.Errorf("a board with a face-down card is not won")
}
// A finished board is never won: the button belongs to a game still in play.
cleared := s.clone()
cleared.Phase = PhaseDone
if cleared.Won() {
t.Errorf("a settled board reports won")
}
}
// ---- the money -------------------------------------------------------------
// The number the felt quotes while you play and the number settle() lands on are
// the same function. Hangman had these as two sums once and the table advertised
// a payout the house didn't honour; this asserts they can't drift here.
func TestTheQuoteIsThePayout(t *testing.T) {
s := board(vegas(), 1000)
for home := 0; home <= FullDeck; home++ {
s.Found = [Foundations][]cards.Card{}
left := home
for suit := 0; suit < Foundations && left > 0; suit++ {
n := left
if n > 13 {
n = 13
}
for r := 1; r <= n; r++ {
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
}
left -= n
}
if s.Home() != home {
t.Fatalf("built a board with %d home, wanted %d", s.Home(), home)
}
quoted := s.Pays()
var evs []Event
done := s.clone()
done.settle(OutcomeCashed, &evs)
if done.Payout != quoted {
t.Fatalf("%d home: the felt quoted %d and settle paid %d", home, quoted, done.Payout)
}
if done.Payout+done.Rake != done.Earned() {
t.Fatalf("%d home: payout %d + rake %d != earned %d",
home, done.Payout, done.Rake, done.Earned())
}
}
}
func TestAFullBoardPaysTheTiersMultiple(t *testing.T) {
for _, tier := range Tiers {
s := board(tier, 1000)
for suit := 0; suit < Foundations; suit++ {
for r := 1; r <= 13; r++ {
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
}
}
// Gross is the multiple exactly — computed from the total, not summed 52
// times, so it doesn't bleed a rounding per card.
want := int64(float64(s.Bet) * tier.Base)
if s.Earned() != want {
t.Errorf("%s: a cleared board earns %d, want %d", tier.Slug, s.Earned(), want)
}
// And the rake comes out of the winnings, never the stake.
profit := want - s.Bet
if s.Pays() != want-int64(float64(profit)*rake) {
t.Errorf("%s: pays %d, want %d less %v%% of the profit", tier.Slug, s.Pays(), want, rake*100)
}
}
}
// An empty board owes nothing, and is not charged a fee for owing nothing.
func TestNothingHomePaysNothing(t *testing.T) {
s := board(cut(), 500)
if s.Pays() != 0 || s.rakeNow() != 0 {
t.Fatalf("an empty board pays %d and rakes %d, want nothing either way", s.Pays(), s.rakeNow())
}
var evs []Event
s.settle(OutcomeCashed, &evs)
if s.Payout != 0 || s.Net() != -500 {
t.Errorf("payout %d net %d, want 0 and -500", s.Payout, s.Net())
}
}
// Below break-even the player is down but is not raked: there is no profit to
// take a cut of.
func TestNoRakeBelowTheStake(t *testing.T) {
tier := vegas()
s := board(tier, 5200)
for i := 0; i < tier.BreakEven()-1; i++ {
suit, r := i/13, i%13+1
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
}
if s.Earned() > s.Bet {
t.Fatalf("break-even is meant to be the first card that gets you square, but %d earns %d on a %d stake",
s.Home(), s.Earned(), s.Bet)
}
if s.rakeNow() != 0 {
t.Errorf("raked %d off a losing board", s.rakeNow())
}
if s.Pays() != s.Earned() {
t.Errorf("pays %d, want the full %d — nothing to rake", s.Pays(), s.Earned())
}
}
func TestBreakEvenIsTheCardThatGetsYouSquare(t *testing.T) {
for _, tier := range Tiers {
s := board(tier, 5200)
for i := 0; i < tier.BreakEven(); i++ {
suit, r := i/13, i%13+1
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
}
if s.Earned() < s.Bet {
t.Errorf("%s: %d cards home earns %d on a %d stake — break-even is quoted too low",
tier.Slug, s.Home(), s.Earned(), s.Bet)
}
}
}
// ---- settling --------------------------------------------------------------
func TestConcedeCashesTheBoard(t *testing.T) {
s := board(vegas(), 5200)
s.Found[cards.Hearts] = []cards.Card{card(cards.Ace, cards.Hearts), card(2, cards.Hearts)}
want := s.Pays()
next, evs := apply(t, s, Move{Kind: "concede"})
if next.Phase != PhaseDone || next.Outcome != OutcomeCashed {
t.Fatalf("phase %q outcome %q, want done/cashed", next.Phase, next.Outcome)
}
if next.Payout != want {
t.Errorf("cashed for %d, want the %d the board was quoting", next.Payout, want)
}
if evs[len(evs)-1].Kind != "settle" {
t.Errorf("no settle event: %+v", evs)
}
refuses(t, next, Move{Kind: "draw"}, ErrGameOver)
}
// The last card home ends the game on its own — the player doesn't have to tell
// the table they've won.
func TestTheLastCardHomeClearsTheBoard(t *testing.T) {
s := board(vegas(), 1000)
for suit := 0; suit < Foundations; suit++ {
top := 13
if suit == int(cards.Clubs) {
top = 12 // the king of clubs is the one card still out
}
for r := 1; r <= top; r++ {
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
}
}
s.Table[0].Up = []cards.Card{card(cards.King, cards.Clubs)}
next, evs := apply(t, s, Move{Kind: "home", From: "t0"})
if next.Phase != PhaseDone || next.Outcome != OutcomeCleared {
t.Fatalf("phase %q outcome %q, want done/cleared", next.Phase, next.Outcome)
}
if next.Payout != int64(float64(1000)*vegas().Base)-int64(float64(int64(float64(1000)*vegas().Base)-1000)*rake) {
t.Errorf("a cleared board paid %d", next.Payout)
}
if evs[len(evs)-1].Kind != "settle" {
t.Errorf("the winning card didn't settle the game: %+v", evs)
}
}
// ---- the shape of the thing ------------------------------------------------
// A game survives a redeploy: the whole state, shoe and face-down cards and all,
// goes through JSON and comes back the same board.
func TestAGameSurvivesJSON(t *testing.T) {
s, _, err := New(500, cut(), rake, cards.NewRNG(7, 11))
if err != nil {
t.Fatal(err)
}
for i := 0; i < 6; i++ {
s, _, _ = ApplyMove(s, Move{Kind: "draw"})
}
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)
}
if !sameBoard(s, back) {
t.Fatal("the board didn't come back the same")
}
}
// The same seed deals the same board. This is what lets a disputed game be dealt
// again exactly as it fell, and it is why the RNG is threaded rather than global.
func TestASeedDealsTheSameBoard(t *testing.T) {
a, _, err := New(100, vegas(), rake, cards.NewRNG(42, 99))
if err != nil {
t.Fatal(err)
}
b, _, err := New(100, vegas(), rake, cards.NewRNG(42, 99))
if err != nil {
t.Fatal(err)
}
if !sameBoard(a, b) {
t.Fatal("the same seed dealt two different boards")
}
c, _, _ := New(100, vegas(), rake, cards.NewRNG(43, 99))
if sameBoard(a, c) {
t.Fatal("two seeds dealt the same board")
}
}
// Every card is on the board exactly once, whatever you do to it. A move that
// duplicated a card would be a move that printed money.
func TestNoCardIsEverLostOrDuplicated(t *testing.T) {
rng := rand.New(rand.NewPCG(3, 5))
s, _, err := New(1000, patient(), rake, rng)
if err != nil {
t.Fatal(err)
}
countDeck(t, s, "the deal")
// Play a long random game: whatever the fuzzer stumbles into, the deck holds.
for i := 0; i < 4000 && s.Phase == PhasePlaying; i++ {
m := randomMove(rng)
next, _, err := ApplyMove(s, m)
if err != nil {
continue // an illegal move is a fine thing for a fuzzer to find
}
s = next
countDeck(t, s, "after "+m.Kind)
}
}
func randomMove(rng *rand.Rand) Move {
pile := func() string {
switch rng.IntN(3) {
case 0:
return "waste"
case 1:
return "t" + strconv.Itoa(rng.IntN(Piles))
default:
return "f" + strconv.Itoa(rng.IntN(Foundations))
}
}
switch rng.IntN(10) {
case 0, 1, 2, 3:
return Move{Kind: "draw"}
case 4:
return Move{Kind: "home", From: pile()}
case 5:
return Move{Kind: "auto"}
default:
return Move{Kind: "move", From: pile(), To: pile(), Count: 1 + rng.IntN(4)}
}
}
func countDeck(t *testing.T, s State, when string) {
t.Helper()
seen := map[cards.Card]int{}
add := func(cs []cards.Card) {
for _, c := range cs {
seen[c]++
}
}
add(s.Stock)
add(s.Waste)
for _, p := range s.Table {
add(p.Down)
add(p.Up)
}
for _, f := range s.Found {
add(f)
}
if len(seen) != FullDeck {
t.Fatalf("%s: %d distinct cards on the board, want 52", when, len(seen))
}
for c, n := range seen {
if n != 1 {
t.Fatalf("%s: %v appears %d times", when, c, n)
}
}
}
// The face-up run in every tableau column is always a legal run, and a column
// with cards face-up never has an unturned card left under it. Both are things
// the *rules* keep true, so a fuzzer that breaks them has found a real bug.
func TestTheBoardStaysWellFormed(t *testing.T) {
rng := rand.New(rand.NewPCG(11, 13))
s, _, err := New(1000, vegas(), rake, rng)
if err != nil {
t.Fatal(err)
}
for i := 0; i < 4000 && s.Phase == PhasePlaying; i++ {
next, _, err := ApplyMove(s, randomMove(rng))
if err != nil {
continue
}
s = next
for j, p := range s.Table {
if !isRun(p.Up) {
t.Fatalf("column %d holds a run that isn't one: %v", j, p.Up)
}
if len(p.Up) == 0 && len(p.Down) > 0 {
t.Fatalf("column %d has %d cards face down and nothing turned over", j, len(p.Down))
}
}
for suit, f := range s.Found {
for r, c := range f {
if int(c.Suit) != suit || int(c.Rank) != r+1 {
t.Fatalf("foundation %d holds %v at position %d", suit, c, r)
}
}
}
}
}