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.
777 lines
25 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|