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.
731 lines
23 KiB
Go
731 lines
23 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)
|
|
}
|
|
|
|
// ---- 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|