games: you buy the deck, and win it back a card at a time

Solitaire, Vegas rules — the only shape solitaire has ever had as a
gambling game. You don't win or lose the deal: the stake buys the deck
outright, and every card you get home to a foundation pays a fifty-second
of the tier's multiple back. Cash the board whenever you like and keep
what you've banked, so a board that has gone dead is a decision rather
than a wall. No undo: the stake is spent the moment the deck is bought,
and an undo would be a way to walk a losing board backwards until it wins.

Three deals, and the two dials are the whole difficulty of Klondike.
Patient draws one with unlimited passes and pays 1.4x, so it takes 38
cards home to get square. Vegas draws three, three times round, 2.2x,
square at 24. Cutthroat draws three and gives you one pass, 3.4x, square
at 16 — most of those boards never clear, and you're ahead long before
they would.

internal/games/klondike is the same pure reducer as the other two, and
Pays() is one function for the same reason hangman's is. Two fuzzers hold
the deck together: no sequence of moves can lose or duplicate a card, and
the board stays well-formed. They earned their keep immediately — the
first thing they caught was a recycle that reversed the waste. It flips as
a block, so the card drawn first comes out first, and reversing it would
have dealt a different game on every pass and quietly broken the seed in
the audit log.

The browser never sees the stock or a face-down card, which here is most
of the deck rather than blackjack's one hole card: a column sends how many
cards are under it, never which.

The table re-renders and animates the difference. Blackjack plays back a
script because a hand only ever grows at one end; solitaire moves runs
from anywhere to anywhere and an auto-finish moves eleven cards at once,
so a script of "append this card there" would be a second engine over here
and it would be the one that's wrong. Instead the board on screen is
always exactly the board the server says exists, and each card is played
from where it just was to where it now is. The events supply only what a
diff can't: where a newly-revealed card came from, and what the board is
worth.

The rules are mirrored in JS on purpose, and only to light up the columns
a held card can go to. Being shown where a card goes is the game teaching
you; being told no after you commit is the game scolding you. The server
still decides, and a disagreement snaps the board back to what it says.

Two things came out into the open rather than being copied, which is the
rule this room runs on: casino-cards.js (the deck — faces, pips, the flip)
and PeteFX.spot() (the pile of chips and the number under it, which now
owns the rule that the number is a readout of the pile). Blackjack uses
both.

Not yet driven in a browser.
This commit is contained in:
prosolis
2026-07-14 01:40:14 -07:00
parent fe2195e85f
commit 5ca056bf20
16 changed files with 3185 additions and 216 deletions

View File

@@ -0,0 +1,684 @@
// Package klondike is a pure Klondike solitaire engine, played for chips.
//
// Same seam as blackjack and hangman: ApplyMove(state, move) (state, events,
// error), where an error means the move was illegal and nothing else. The state
// is a plain value, so a game survives a redeploy and replays from its seed.
//
// The casino version is Vegas scoring, which is the only way solitaire has ever
// been a gambling game and the only shape that makes sense with money on it.
// You do not win or lose the deal. You *buy the deck* for your stake, and every
// card you get home to a foundation pays a slice of it back. Fifty-two cards
// home pays the tier's full multiple; nothing home pays nothing. You can stop
// whenever you like and keep what you have banked, which is what makes a game
// that has gone dead a decision rather than a wall.
//
// There is no undo. The stake is spent the moment the deck is bought, so an undo
// would be a way to walk a losing board backwards until it wins.
package klondike
import (
"errors"
"math"
"math/rand/v2"
"strconv"
"strings"
"pete/internal/games/cards"
)
// Errors an illegal move can produce.
var (
ErrGameOver = errors.New("klondike: the game is already over")
ErrUnknownMove = errors.New("klondike: unknown move")
ErrBadBet = errors.New("klondike: bet must be positive")
ErrUnknownTier = errors.New("klondike: no such tier")
ErrBadPile = errors.New("klondike: no such pile")
ErrEmptyPile = errors.New("klondike: there is nothing there to move")
ErrNotASequence = errors.New("klondike: those cards aren't a run you can lift")
ErrWontGo = errors.New("klondike: that card doesn't go there")
ErrNoDraw = errors.New("klondike: there is nothing left to turn over")
ErrNoPasses = errors.New("klondike: you've used your passes through the stock")
ErrNothingHome = errors.New("klondike: nothing can go home right now")
)
// Piles is the number of tableau columns. Foundations is one per suit.
const (
Piles = 7
Foundations = 4
FullDeck = 52
)
// Tier is a difficulty, chosen with the bet. The two dials are how many cards
// the stock turns over at a time and how many times you may go through it —
// which between them are the whole difficulty of Klondike. Turning three at a
// time hides two of every three cards behind a card you may never reach; a
// single pass means the ones you leave behind are gone for good.
//
// The multiple pays for that. Cutthroat is the cruellest deal in the room and
// pays 3.4×, which means you are ahead from sixteen cards home even though most
// of those boards never clear.
type Tier struct {
Slug string `json:"slug"`
Name string `json:"name"`
Draw int `json:"draw"` // cards turned over per pull on the stock
Passes int `json:"passes"` // times through the stock; 0 means unlimited
Base float64 `json:"base"` // what a full 52 cards home pays, as a multiple of the stake
Blurb string `json:"blurb"`
}
// BreakEven is how many cards have to reach the foundations before the player is
// square with the house. It's the number the felt actually quotes, because
// "1.4×" tells a player nothing about a game where the multiple is paid per card.
func (t Tier) BreakEven() int {
if t.Base <= 0 {
return FullDeck
}
n := int(math.Ceil(float64(FullDeck) / t.Base))
if n > FullDeck {
return FullDeck
}
return n
}
// Tiers are the three deals.
var Tiers = []Tier{
{Slug: "patient", Name: "Patient", Draw: 1, Passes: 0, Base: 1.4,
Blurb: "One card at a time, through the stock as often as you like."},
{Slug: "vegas", Name: "Vegas", Draw: 3, Passes: 3, Base: 2.2,
Blurb: "Three at a time, three times round. The house game."},
{Slug: "cutthroat", Name: "Cutthroat", Draw: 3, Passes: 1, Base: 3.4,
Blurb: "Three at a time, one pass. What you leave behind is gone."},
}
// TierBySlug finds a tier by the name the browser sent.
func TierBySlug(slug string) (Tier, error) {
for _, t := range Tiers {
if t.Slug == slug {
return t, nil
}
}
return Tier{}, ErrUnknownTier
}
// Phase is where the game is.
type Phase string
const (
PhasePlaying Phase = "playing"
PhaseDone Phase = "done"
)
// Outcome is how it ended. Note there is no "lost": a board that goes dead is
// cashed, for whatever it made. Solitaire's failure mode is a board you can't
// improve, and the honest thing to do with one is pay out what's on it.
type Outcome string
const (
OutcomeNone Outcome = ""
OutcomeCleared Outcome = "cleared" // all 52 home
OutcomeCashed Outcome = "cashed" // the player stopped and took the board
)
// Pile is one tableau column: a face-down stack with a face-up run on top of it.
// Down is the part the browser never sees.
type Pile struct {
Down []cards.Card `json:"down"`
Up []cards.Card `json:"up"`
}
// State is one game. The stock and every Down card are in here, which is exactly
// why this value never leaves the server.
type State struct {
Tier Tier `json:"tier"`
Stock cards.Deck `json:"stock"`
Waste []cards.Card `json:"waste"`
Table [Piles]Pile `json:"table"`
Found [Foundations][]cards.Card `json:"found"` // indexed by suit
Recycles int `json:"recycles"` // times the waste has gone back under
Moves int `json:"moves"`
RakePct float64 `json:"rake_pct"`
Bet int64 `json:"bet"`
Phase Phase `json:"phase"`
Outcome Outcome `json:"outcome"`
Payout int64 `json:"payout"`
Rake int64 `json:"rake"`
}
// Event is something the table animates. The engine emits them rather than
// leaving the browser to diff two boards and guess what moved — a card that
// slides from a column to a foundation and a card that was simply redrawn there
// are the same diff and very different things to watch.
//
// Home and Pays ride on every event, so the meter on the felt is always quoting
// a number the engine worked out. The browser never does this arithmetic: it did
// once, and the felt advertised a payout the house didn't honour.
type Event struct {
Kind string `json:"kind"` // "deal" | "draw" | "recycle" | "move" | "home" | "flip" | "settle"
Cards []cards.Card `json:"cards,omitempty"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Text string `json:"text,omitempty"`
Home int `json:"home"`
Pays int64 `json:"pays"`
}
// Move is a player action.
//
// Home is its own kind rather than a Move To a foundation the player picked,
// because there is only ever one foundation a card can go to and asking the
// player to name it would be a quiz about suit ordering. The browser sends
// "this card, home"; the engine finds the pile.
type Move struct {
Kind string `json:"kind"` // "draw" | "move" | "home" | "auto" | "concede"
From string `json:"from"` // "waste" | "t0".."t6" | "f0".."f3"
To string `json:"to"` // "t0".."t6" | "f0".."f3"
Count int `json:"count"` // how many cards off the end of a tableau run; 0 means 1
}
// New deals a game.
func New(bet int64, t Tier, rakePct float64, rng *rand.Rand) (State, []Event, error) {
if bet <= 0 {
return State{}, nil, ErrBadBet
}
if t.Draw < 1 {
return State{}, nil, ErrUnknownTier
}
d := cards.NewDeck(1)
d.Shuffle(rng)
return deal(bet, t, d, rakePct)
}
// deal lays the board out. Split out from New so a test can pin the deck
// instead of the seed.
func deal(bet int64, t Tier, d cards.Deck, rakePct float64) (State, []Event, error) {
if bet <= 0 {
return State{}, nil, ErrBadBet
}
if len(d) != FullDeck {
return State{}, nil, errors.New("klondike: a solitaire deck is 52 cards")
}
s := State{Tier: t, Bet: bet, RakePct: rakePct, Phase: PhasePlaying}
// The classic lay-out: column i gets i+1 cards, the last of them face up.
for i := 0; i < Piles; i++ {
for j := 0; j <= i; j++ {
c, _ := d.Draw()
if j == i {
s.Table[i].Up = append(s.Table[i].Up, c)
} else {
s.Table[i].Down = append(s.Table[i].Down, c)
}
}
}
s.Stock = d
return s, []Event{s.event("deal", nil, "", "")}, nil
}
// ApplyMove is the engine. A legal move in, the new board and what happened out.
// An error means the move was illegal and the caller's state is untouched.
func ApplyMove(s State, m Move) (State, []Event, error) {
if s.Phase == PhaseDone {
return s, nil, ErrGameOver
}
// The move is played against a copy, and an illegal one hands the original
// back untouched. Nothing below mutates before it has decided the move is
// legal — but "nothing below mutates early" is an invariant seven functions
// have to keep, and this is one line that doesn't need them to.
orig := s
s = s.clone()
var evs []Event
var err error
switch m.Kind {
case "draw":
evs, err = s.draw()
case "move":
evs, err = s.move(m.From, m.To, m.Count)
case "home":
evs, err = s.home(m.From)
case "auto":
evs, err = s.auto()
case "concede":
s.settle(OutcomeCashed, &evs)
return s, evs, nil
default:
return orig, nil, ErrUnknownMove
}
if err != nil {
return orig, nil, err
}
s.Moves++
// A cleared board settles itself. Nothing else does: a board with no move left
// on it is not something the engine gets to decide, because "no move left" in
// Klondike depends on cards nobody has turned over yet.
if s.cleared() {
s.settle(OutcomeCleared, &evs)
}
return s, evs, nil
}
// ---- the moves -------------------------------------------------------------
// draw turns cards off the stock, or puts the waste back under it if the stock
// is spent and the tier still owes a pass.
func (s *State) draw() ([]Event, error) {
if len(s.Stock) == 0 {
if len(s.Waste) == 0 {
return nil, ErrNoDraw
}
// Passes is how many times you may go *through* the stock, so the number of
// times you may turn it back over is one less than that. Zero means unlimited.
if s.Tier.Passes > 0 && s.Recycles >= s.Tier.Passes-1 {
return nil, ErrNoPasses
}
// The waste is turned over as a block, not reshuffled — so the card that
// comes out first on the next pass is the one that came out first on this
// one. Which means no reversal: the waste's *bottom* card is the one your
// hand lands on when you flip the pile, and the bottom card is the one that
// was drawn first. Reversing here would deal a different game on every pass
// and quietly break the seed in the audit log.
s.Stock = cards.Deck(s.Waste)
s.Waste = nil
s.Recycles++
return []Event{s.event("recycle", nil, "waste", "stock")}, nil
}
n := s.Tier.Draw
if n > len(s.Stock) {
n = len(s.Stock)
}
drawn := make([]cards.Card, 0, n)
for i := 0; i < n; i++ {
c, _ := s.Stock.Draw()
drawn = append(drawn, c)
s.Waste = append(s.Waste, c)
}
return []Event{s.event("draw", drawn, "stock", "waste")}, nil
}
// move takes cards from one pile and puts them on another.
func (s *State) move(from, to string, count int) ([]Event, error) {
if count < 1 {
count = 1
}
lifted, err := s.peek(from, count)
if err != nil {
return nil, err
}
if !s.accepts(to, lifted) {
return nil, ErrWontGo
}
if err := s.take(from, count); err != nil {
return nil, err
}
s.put(to, lifted)
kind := "move"
if isFoundation(to) {
kind = "home"
}
evs := []Event{s.event(kind, lifted, from, to)}
return s.withFlip(from, evs), nil
}
// home sends the top card of a pile to the foundation that will take it. There
// is only ever one, so the player doesn't have to say which.
func (s *State) home(from string) ([]Event, error) {
top, err := s.peek(from, 1)
if err != nil {
return nil, err
}
to := "f" + strconv.Itoa(int(top[0].Suit))
if !s.accepts(to, top) {
return nil, ErrWontGo
}
return s.move(from, to, 1)
}
// auto sends everything that can go home, home, and keeps doing it until nothing
// else can. It is the finish button, and it is also the shortcut for the tail of
// a board that is already decided.
//
// It can cost you: a two you needed on the tableau is a two that has gone home.
// That is the player's call to make by pressing it, and it is the same call the
// button makes in every other solitaire ever written.
func (s *State) auto() ([]Event, error) {
var evs []Event
for {
moved := false
for _, from := range sources() {
top, err := s.peek(from, 1)
if err != nil {
continue
}
to := "f" + strconv.Itoa(int(top[0].Suit))
if !s.accepts(to, top) {
continue
}
one, err := s.move(from, to, 1)
if err != nil {
continue
}
evs = append(evs, one...)
moved = true
}
if !moved {
break
}
}
if len(evs) == 0 {
return nil, ErrNothingHome
}
return evs, nil
}
// sources are the piles auto() will lift a card off, in the order it tries them.
func sources() []string {
out := make([]string, 0, Piles+1)
out = append(out, "waste")
for i := 0; i < Piles; i++ {
out = append(out, "t"+strconv.Itoa(i))
}
return out
}
// withFlip turns up the card a tableau column was hiding, if taking from it left
// its face-down stack exposed. This is the only thing in the game that reveals a
// card the player hadn't earned yet, so it is the only place it can happen.
func (s *State) withFlip(from string, evs []Event) []Event {
i, ok := tableauIndex(from)
if !ok {
return evs
}
p := &s.Table[i]
if len(p.Up) > 0 || len(p.Down) == 0 {
return evs
}
c := p.Down[len(p.Down)-1]
p.Down = p.Down[:len(p.Down)-1]
p.Up = append(p.Up, c)
return append(evs, s.event("flip", []cards.Card{c}, from, from))
}
// ---- piles -----------------------------------------------------------------
// peek returns the top `count` cards of a pile without taking them, and refuses
// a run that isn't one you could lift: a tableau run has to descend in rank and
// alternate colour all the way down, exactly as it does on the felt.
func (s *State) peek(name string, count int) ([]cards.Card, error) {
switch {
case name == "waste":
if count != 1 {
return nil, ErrNotASequence // the waste is a pile, not a run: one card, the top one
}
if len(s.Waste) == 0 {
return nil, ErrEmptyPile
}
return []cards.Card{s.Waste[len(s.Waste)-1]}, nil
case isFoundation(name):
i, ok := foundationIndex(name)
if !ok {
return nil, ErrBadPile
}
if count != 1 {
return nil, ErrNotASequence
}
f := s.Found[i]
if len(f) == 0 {
return nil, ErrEmptyPile
}
return []cards.Card{f[len(f)-1]}, nil
default:
i, ok := tableauIndex(name)
if !ok {
return nil, ErrBadPile
}
up := s.Table[i].Up
if len(up) == 0 {
return nil, ErrEmptyPile
}
if count > len(up) {
return nil, ErrNotASequence
}
run := up[len(up)-count:]
if !isRun(run) {
return nil, ErrNotASequence
}
return append([]cards.Card(nil), run...), nil
}
}
// take removes the top `count` cards. peek has already vetted them.
func (s *State) take(name string, count int) error {
switch {
case name == "waste":
s.Waste = s.Waste[:len(s.Waste)-count]
return nil
case isFoundation(name):
i, _ := foundationIndex(name)
s.Found[i] = s.Found[i][:len(s.Found[i])-count]
return nil
default:
i, ok := tableauIndex(name)
if !ok {
return ErrBadPile
}
s.Table[i].Up = s.Table[i].Up[:len(s.Table[i].Up)-count]
return nil
}
}
// put drops cards onto a pile. accepts has already vetted them.
func (s *State) put(name string, cs []cards.Card) {
if isFoundation(name) {
i, _ := foundationIndex(name)
s.Found[i] = append(s.Found[i], cs...)
return
}
i, _ := tableauIndex(name)
s.Table[i].Up = append(s.Table[i].Up, cs...)
}
// accepts is the rule the whole game is made of: what may be put where.
//
// A foundation takes its own suit in order from the ace, one card at a time. A
// tableau column takes a run that descends by one and alternates colour from its
// top card, and an empty column takes a King and nothing else.
func (s *State) accepts(name string, cs []cards.Card) bool {
if len(cs) == 0 {
return false
}
if isFoundation(name) {
i, ok := foundationIndex(name)
if !ok || len(cs) != 1 {
return false
}
c := cs[0]
return int(c.Suit) == i && int(c.Rank) == len(s.Found[i])+1
}
i, ok := tableauIndex(name)
if !ok {
return false
}
if !isRun(cs) {
return false
}
up := s.Table[i].Up
if len(up) == 0 {
// An empty column is the most valuable thing on the board, so it costs a
// King to take one. A column with cards still face-down under it is not
// empty, and Up being empty there can't happen: withFlip turns one over.
return cs[0].Rank == cards.King && len(s.Table[i].Down) == 0
}
top := up[len(up)-1]
return int(cs[0].Rank) == int(top.Rank)-1 && cs[0].Red() != top.Red()
}
// isRun reports whether these cards, in this order, are a tableau sequence:
// descending by one, alternating colour.
func isRun(cs []cards.Card) bool {
for i := 1; i < len(cs); i++ {
if int(cs[i].Rank) != int(cs[i-1].Rank)-1 || cs[i].Red() == cs[i-1].Red() {
return false
}
}
return true
}
func isFoundation(name string) bool { return strings.HasPrefix(name, "f") }
func tableauIndex(name string) (int, bool) { return pileIndex(name, "t", Piles) }
func foundationIndex(name string) (int, bool) { return pileIndex(name, "f", Foundations) }
func pileIndex(name, prefix string, n int) (int, bool) {
if !strings.HasPrefix(name, prefix) {
return 0, false
}
i, err := strconv.Atoi(name[len(prefix):])
if err != nil || i < 0 || i >= n {
return 0, false
}
return i, true
}
// ---- the money -------------------------------------------------------------
// Home is how many cards have reached the foundations. It is the only number in
// this game that the payout depends on.
func (s State) Home() int {
n := 0
for _, f := range s.Found {
n += len(f)
}
return n
}
// PerCard is what one card home is worth, before the rake. The felt quotes this
// because "2.2×" tells a player nothing about a game where the multiple is paid
// out a fifty-second at a time.
func (s State) PerCard() float64 {
return float64(s.Bet) * s.Tier.Base / float64(FullDeck)
}
// Earned is the gross: what the cards home have bought back, before the house
// takes anything. Computed from the total rather than card by card, so 52 cards
// home pays the tier's multiple exactly instead of the multiple less 52 roundings.
func (s State) Earned() int64 {
return int64(math.Floor(float64(s.Bet) * s.Tier.Base * float64(s.Home()) / float64(FullDeck)))
}
// Pays is what stopping *right now* would actually put back on the player's
// stack: the gross, less the house's cut of anything above the stake.
//
// The felt shows this number while the game is still running and settle() lands
// on it, and they are the same function for the reason hangman's are: the moment
// they are two sums, the table is quoting a payout it doesn't honour.
//
// Unlike the other games it can be less than the stake, and can be zero. That is
// the game — you bought the deck, and a deck that gives you nothing owes you
// nothing.
func (s State) Pays() int64 {
total := s.Earned()
profit := total - s.Bet
if profit > 0 {
rake := int64(math.Floor(float64(profit) * s.RakePct))
if rake > 0 {
total -= rake
}
}
return total
}
// rakeNow is the house's cut if the board were cashed right now — the other half
// of what Pays works out.
func (s State) rakeNow() int64 {
profit := s.Earned() - s.Bet
if profit <= 0 {
return 0
}
rake := int64(math.Floor(float64(profit) * s.RakePct))
if rake < 0 {
return 0
}
return rake
}
// Net is what the game did to the player's stack.
func (s State) Net() int64 {
if s.Phase != PhaseDone {
return 0
}
return s.Payout - s.Bet
}
// cleared reports whether every card is home.
func (s State) cleared() bool { return s.Home() == FullDeck }
// CanAuto reports whether anything can go home at all — which is what greys the
// finish button out rather than letting it be pressed at a board that has nothing
// for it.
func (s State) CanAuto() bool {
for _, from := range sources() {
top, err := (&s).peek(from, 1)
if err != nil {
continue
}
if (&s).accepts("f"+strconv.Itoa(int(top[0].Suit)), top) {
return true
}
}
return false
}
// PassesLeft is how many more times the player may go through the stock,
// counting the one they are in. -1 means unlimited.
func (s State) PassesLeft() int {
if s.Tier.Passes <= 0 {
return -1
}
left := s.Tier.Passes - s.Recycles
if left < 0 {
return 0
}
return left
}
// settle closes the game at whatever is on the board. Same rule as everywhere
// else in the room: the rake comes out of winnings, never out of the stake.
func (s *State) settle(o Outcome, evs *[]Event) {
s.Outcome = o
s.Phase = PhaseDone
s.Payout = s.Pays()
s.Rake = s.rakeNow()
*evs = append(*evs, s.event("settle", nil, "", string(o)))
}
// event stamps an event with the two numbers the felt's meter reads off it, so
// the browser never has to work out what the board is worth.
func (s State) event(kind string, cs []cards.Card, from, to string) Event {
return Event{
Kind: kind, Cards: cs, From: from, To: to,
Home: s.Home(), Pays: s.Pays(),
}
}
// clone deep-copies everything with a backing array, so a derived state shares
// none of it with the one it came from and a board can be replayed freely.
func (s State) clone() State {
s.Stock = append(cards.Deck(nil), s.Stock...)
s.Waste = append([]cards.Card(nil), s.Waste...)
for i := range s.Table {
s.Table[i].Down = append([]cards.Card(nil), s.Table[i].Down...)
s.Table[i].Up = append([]cards.Card(nil), s.Table[i].Up...)
}
for i := range s.Found {
s.Found[i] = append([]cards.Card(nil), s.Found[i]...)
}
return s
}

View File

@@ -0,0 +1,730 @@
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)
}
}
}
}
}