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