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