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:
684
internal/games/klondike/klondike.go
Normal file
684
internal/games/klondike/klondike.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user