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

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

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

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

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

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

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

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

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

View File

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

View File

@@ -0,0 +1,730 @@
package klondike
import (
"encoding/json"
"math/rand/v2"
"strconv"
"testing"
"pete/internal/games/cards"
)
const rake = 0.05
func vegas() Tier { t, _ := TierBySlug("vegas"); return t }
func patient() Tier { t, _ := TierBySlug("patient"); return t }
func cut() Tier { t, _ := TierBySlug("cutthroat"); return t }
func card(r cards.Rank, s cards.Suit) cards.Card { return cards.Card{Rank: r, Suit: s} }
// ordered builds the 52 cards in a fixed order — the deck deal() would get if
// the shuffle were the identity. Tests that care about the board build their own.
func ordered() cards.Deck { return cards.NewDeck(1) }
func mustDeal(t *testing.T, bet int64, tier Tier, d cards.Deck) State {
t.Helper()
s, evs, err := deal(bet, tier, d, rake)
if err != nil {
t.Fatalf("deal: %v", err)
}
if len(evs) != 1 || evs[0].Kind != "deal" {
t.Fatalf("deal events = %+v, want one deal", evs)
}
return s
}
func apply(t *testing.T, s State, m Move) (State, []Event) {
t.Helper()
next, evs, err := ApplyMove(s, m)
if err != nil {
t.Fatalf("ApplyMove(%+v): %v", m, err)
}
return next, evs
}
func refuses(t *testing.T, s State, m Move, want error) {
t.Helper()
next, evs, err := ApplyMove(s, m)
if err == nil {
t.Fatalf("ApplyMove(%+v) was allowed, want %v", m, want)
}
if want != nil && err != want {
t.Fatalf("ApplyMove(%+v) = %v, want %v", m, err, want)
}
if evs != nil {
t.Errorf("an illegal move emitted events: %+v", evs)
}
// The board an illegal move hands back must be the one it was given. This is
// the whole contract of the reducer, and it's cheap to check by value.
if !sameBoard(next, s) {
t.Errorf("an illegal move changed the board")
}
}
func sameBoard(a, b State) bool {
x, _ := json.Marshal(a)
y, _ := json.Marshal(b)
return string(x) == string(y)
}
// ---- the deal --------------------------------------------------------------
func TestDealLaysOutTheBoard(t *testing.T) {
s := mustDeal(t, 520, vegas(), ordered())
seen := 0
for i := 0; i < Piles; i++ {
p := s.Table[i]
if len(p.Up) != 1 {
t.Errorf("column %d has %d face up, want 1", i, len(p.Up))
}
if len(p.Down) != i {
t.Errorf("column %d has %d face down, want %d", i, len(p.Down), i)
}
seen += len(p.Up) + len(p.Down)
}
if seen != 28 {
t.Errorf("tableau holds %d cards, want 28", seen)
}
if len(s.Stock) != 24 {
t.Errorf("stock is %d, want 24", len(s.Stock))
}
if s.Home() != 0 || s.Pays() != 0 {
t.Errorf("a fresh board is worth %d from %d home, want nothing", s.Pays(), s.Home())
}
}
func TestDealRefusesABadStake(t *testing.T) {
if _, _, err := deal(0, vegas(), ordered(), rake); err != ErrBadBet {
t.Fatalf("deal(0) = %v, want ErrBadBet", err)
}
if _, _, err := New(-5, vegas(), rake, cards.NewRNG(1, 2)); err != ErrBadBet {
t.Fatalf("New(-5) = %v, want ErrBadBet", err)
}
}
// ---- the stock -------------------------------------------------------------
func TestDrawTurnsTheTiersCount(t *testing.T) {
for _, tier := range []Tier{patient(), vegas()} {
s := mustDeal(t, 100, tier, ordered())
next, evs := apply(t, s, Move{Kind: "draw"})
if len(next.Waste) != tier.Draw {
t.Errorf("%s: waste is %d after one draw, want %d", tier.Slug, len(next.Waste), tier.Draw)
}
if len(next.Stock) != 24-tier.Draw {
t.Errorf("%s: stock is %d, want %d", tier.Slug, len(next.Stock), 24-tier.Draw)
}
if len(evs) != 1 || evs[0].Kind != "draw" || len(evs[0].Cards) != tier.Draw {
t.Errorf("%s: draw events = %+v", tier.Slug, evs)
}
}
}
// The last pull off a short stock turns over what's left rather than refusing.
func TestDrawTakesWhatIsLeft(t *testing.T) {
s := mustDeal(t, 100, vegas(), ordered()) // 24 in the stock, drawing 3
for i := 0; i < 7; i++ {
s, _ = apply(t, s, Move{Kind: "draw"}) // 21 drawn, 3 left
}
s, _ = apply(t, s, Move{Kind: "draw"})
if len(s.Stock) != 0 || len(s.Waste) != 24 {
t.Fatalf("stock %d waste %d, want 0 and 24", len(s.Stock), len(s.Waste))
}
refuses(t, drained(t, s), Move{Kind: "draw"}, ErrNoDraw)
}
// drained empties the waste too, so there is genuinely nothing to turn over.
func drained(t *testing.T, s State) State {
t.Helper()
s = s.clone()
s.Waste = nil
s.Stock = nil
return s
}
// The waste goes back under the stock in the order it came out — a recycle is a
// pile being turned over, not reshuffled. If this ever reshuffled, the seed in
// the audit log would stop replaying the game.
func TestRecycleTurnsTheWasteOverInOrder(t *testing.T) {
s := mustDeal(t, 100, patient(), ordered())
want := append(cards.Deck(nil), s.Stock...)
for i := 0; i < 24; i++ {
s, _ = apply(t, s, Move{Kind: "draw"})
}
next, evs := apply(t, s, Move{Kind: "draw"})
if len(evs) != 1 || evs[0].Kind != "recycle" {
t.Fatalf("events = %+v, want a recycle", evs)
}
if len(next.Waste) != 0 {
t.Errorf("waste is %d after a recycle, want empty", len(next.Waste))
}
for i := range want {
if next.Stock[i] != want[i] {
t.Fatalf("stock[%d] = %v after recycle, want %v — the pile was reshuffled",
i, next.Stock[i], want[i])
}
}
if next.Recycles != 1 {
t.Errorf("recycles = %d, want 1", next.Recycles)
}
}
// Passes is how many times you may go *through* the stock, so it is one more
// than the number of times you may turn it back over.
func TestPassesRunOut(t *testing.T) {
tests := []struct {
tier Tier
recycles int // how many turn-overs the tier should allow
}{
{cut(), 0}, // one pass: you never get to turn it back over
{vegas(), 2}, // three passes: two turn-overs
{patient(), -1}, // unlimited
}
for _, tc := range tests {
s := mustDeal(t, 100, tc.tier, ordered())
if got := s.PassesLeft(); tc.recycles < 0 && got != -1 {
t.Errorf("%s: PassesLeft = %d, want -1 (unlimited)", tc.tier.Slug, got)
}
allowed := 0
for i := 0; i < 5; i++ {
// Empty the stock, then try to turn it over.
for len(s.Stock) > 0 {
s, _ = apply(t, s, Move{Kind: "draw"})
}
next, _, err := ApplyMove(s, Move{Kind: "draw"})
if err == ErrNoPasses {
break
}
if err != nil {
t.Fatalf("%s: %v", tc.tier.Slug, err)
}
s = next
allowed++
}
if tc.recycles < 0 {
if allowed != 5 {
t.Errorf("%s: only %d recycles allowed, want unlimited", tc.tier.Slug, allowed)
}
continue
}
if allowed != tc.recycles {
t.Errorf("%s: %d recycles allowed, want %d", tc.tier.Slug, allowed, tc.recycles)
}
if s.PassesLeft() != 1 {
t.Errorf("%s: PassesLeft = %d on the last pass, want 1", tc.tier.Slug, s.PassesLeft())
}
}
}
// ---- the rules -------------------------------------------------------------
// board builds a State directly, so a rule can be tested against the position
// that exercises it rather than against whatever a shuffle happened to deal.
func board(tier Tier, bet int64) State {
return State{Tier: tier, Bet: bet, RakePct: rake, Phase: PhasePlaying}
}
func TestTableauTakesDescendingAlternatingColour(t *testing.T) {
s := board(vegas(), 520)
s.Table[0].Up = []cards.Card{card(8, cards.Spades)} // black 8
s.Table[1].Up = []cards.Card{card(7, cards.Hearts)} // red 7 — goes on the 8
s.Table[2].Up = []cards.Card{card(7, cards.Clubs)} // black 7 — does not
s.Table[3].Up = []cards.Card{card(6, cards.Hearts)} // red 6 — wrong rank for the 8
next, evs := apply(t, s, Move{Kind: "move", From: "t1", To: "t0"})
if len(next.Table[0].Up) != 2 || next.Table[0].Up[1] != card(7, cards.Hearts) {
t.Fatalf("the red seven didn't land on the black eight: %+v", next.Table[0].Up)
}
if len(next.Table[1].Up) != 0 {
t.Errorf("the seven is still in its old column")
}
if len(evs) != 1 || evs[0].Kind != "move" {
t.Errorf("events = %+v, want one move", evs)
}
refuses(t, s, Move{Kind: "move", From: "t2", To: "t0"}, ErrWontGo) // same colour
refuses(t, s, Move{Kind: "move", From: "t3", To: "t0"}, ErrWontGo) // two below
}
func TestOnlyAKingTakesAnEmptyColumn(t *testing.T) {
s := board(vegas(), 520)
// t0 is empty and has nothing under it.
s.Table[1].Up = []cards.Card{card(cards.King, cards.Hearts)}
s.Table[2].Up = []cards.Card{card(cards.Queen, cards.Spades)}
refuses(t, s, Move{Kind: "move", From: "t2", To: "t0"}, ErrWontGo)
next, _ := apply(t, s, Move{Kind: "move", From: "t1", To: "t0"})
if len(next.Table[0].Up) != 1 || next.Table[0].Up[0].Rank != cards.King {
t.Fatalf("the king didn't take the empty column: %+v", next.Table[0].Up)
}
}
// A run comes off the tableau as a block, and only if it is a run.
func TestLiftingARun(t *testing.T) {
s := board(vegas(), 520)
s.Table[0].Up = []cards.Card{
card(9, cards.Hearts), // red
card(8, cards.Spades), // black
card(7, cards.Diamonds), // red
}
s.Table[1].Up = []cards.Card{card(10, cards.Clubs)} // black 10 takes the red 9
next, _ := apply(t, s, Move{Kind: "move", From: "t0", To: "t1", Count: 3})
if len(next.Table[1].Up) != 4 || len(next.Table[0].Up) != 0 {
t.Fatalf("the run didn't move as a block: t0=%v t1=%v", next.Table[0].Up, next.Table[1].Up)
}
// Not a run: same colour in the middle of it.
bad := board(vegas(), 520)
bad.Table[0].Up = []cards.Card{
card(9, cards.Hearts),
card(8, cards.Diamonds), // red on red
}
bad.Table[1].Up = []cards.Card{card(10, cards.Clubs)}
refuses(t, bad, Move{Kind: "move", From: "t0", To: "t1", Count: 2}, ErrNotASequence)
// And you can't lift more cards than the column has.
refuses(t, bad, Move{Kind: "move", From: "t0", To: "t1", Count: 9}, ErrNotASequence)
}
// Taking the last face-up card off a column turns the next one over. This is the
// only thing in the game that reveals a card, which is the point of the test.
func TestTakingTheLastCardFlipsTheNextOne(t *testing.T) {
s := board(vegas(), 520)
hidden := card(cards.Queen, cards.Clubs)
s.Table[0].Down = []cards.Card{card(2, cards.Spades), hidden}
s.Table[0].Up = []cards.Card{card(7, cards.Hearts)}
s.Table[1].Up = []cards.Card{card(8, cards.Spades)}
next, evs := apply(t, s, Move{Kind: "move", From: "t0", To: "t1"})
if len(next.Table[0].Up) != 1 || next.Table[0].Up[0] != hidden {
t.Fatalf("the hidden card didn't turn over: %+v", next.Table[0].Up)
}
if len(next.Table[0].Down) != 1 {
t.Errorf("face-down stack is %d, want 1", len(next.Table[0].Down))
}
if len(evs) != 2 || evs[1].Kind != "flip" || evs[1].Cards[0] != hidden {
t.Fatalf("events = %+v, want a move then a flip carrying the card", evs)
}
}
func TestFoundationsBuildUpBySuitFromTheAce(t *testing.T) {
s := board(vegas(), 520)
s.Table[0].Up = []cards.Card{card(cards.Ace, cards.Hearts)}
s.Table[1].Up = []cards.Card{card(2, cards.Hearts)}
s.Table[2].Up = []cards.Card{card(2, cards.Spades)}
s.Table[3].Up = []cards.Card{card(3, cards.Hearts)}
// A two can't start a foundation.
refuses(t, s, Move{Kind: "home", From: "t1"}, ErrWontGo)
s, evs := apply(t, s, Move{Kind: "home", From: "t0"})
if len(s.Found[cards.Hearts]) != 1 {
t.Fatalf("the ace didn't go home: %+v", s.Found)
}
if evs[0].Kind != "home" || evs[0].To != "f"+strconv.Itoa(int(cards.Hearts)) {
t.Fatalf("event = %+v, want a home to the hearts pile", evs[0])
}
if evs[0].Home != 1 {
t.Errorf("event carries Home=%d, want 1", evs[0].Home)
}
// The three can't jump the two, and the two of spades can't go on hearts.
refuses(t, s, Move{Kind: "home", From: "t3"}, ErrWontGo)
refuses(t, s, Move{Kind: "move", From: "t2", To: "f" + strconv.Itoa(int(cards.Hearts))}, ErrWontGo)
s, _ = apply(t, s, Move{Kind: "home", From: "t1"})
if s.Home() != 2 {
t.Errorf("Home = %d, want 2", s.Home())
}
}
// A card can come back off a foundation — a real rule, and one that matters when
// you need a low card to move a column. The payout follows it back down, because
// the payout reads the board rather than counting events.
func TestACardComesBackOffAFoundation(t *testing.T) {
s := board(vegas(), 5200)
s.Found[cards.Hearts] = []cards.Card{card(cards.Ace, cards.Hearts), card(2, cards.Hearts)}
s.Table[0].Up = []cards.Card{card(3, cards.Spades)}
before := s.Pays()
next, _ := apply(t, s, Move{Kind: "move", From: "f" + strconv.Itoa(int(cards.Hearts)), To: "t0"})
if len(next.Found[cards.Hearts]) != 1 || len(next.Table[0].Up) != 2 {
t.Fatalf("the two didn't come back down: found=%v t0=%v", next.Found[cards.Hearts], next.Table[0].Up)
}
if next.Home() != 1 {
t.Errorf("Home = %d after taking a card back, want 1", next.Home())
}
if next.Pays() >= before {
t.Errorf("Pays = %d after taking a card back, want less than %d", next.Pays(), before)
}
}
func TestWasteGivesUpItsTopCardOnly(t *testing.T) {
s := board(vegas(), 520)
s.Waste = []cards.Card{card(5, cards.Spades), card(7, cards.Hearts)}
s.Table[0].Up = []cards.Card{card(8, cards.Spades)}
// The 5 is under the 7 and is not available, however much you'd like it.
refuses(t, s, Move{Kind: "move", From: "waste", To: "t0", Count: 2}, ErrNotASequence)
next, _ := apply(t, s, Move{Kind: "move", From: "waste", To: "t0"})
if len(next.Waste) != 1 || next.Waste[0] != card(5, cards.Spades) {
t.Fatalf("the wrong card left the waste: %+v", next.Waste)
}
}
func TestEmptyPilesAndNonsensePiles(t *testing.T) {
s := board(vegas(), 520)
s.Table[0].Up = []cards.Card{card(8, cards.Spades)}
refuses(t, s, Move{Kind: "move", From: "waste", To: "t0"}, ErrEmptyPile)
refuses(t, s, Move{Kind: "move", From: "t3", To: "t0"}, ErrEmptyPile)
refuses(t, s, Move{Kind: "move", From: "t9", To: "t0"}, ErrBadPile)
refuses(t, s, Move{Kind: "move", From: "t0", To: "t9"}, ErrWontGo)
refuses(t, s, Move{Kind: "move", From: "banana", To: "t0"}, ErrBadPile)
refuses(t, s, Move{Kind: "sing"}, ErrUnknownMove)
}
// ---- auto ------------------------------------------------------------------
func TestAutoSendsEverythingItCanHome(t *testing.T) {
s := board(vegas(), 5200)
// Two aces and the hearts two, sitting on top of three columns.
s.Table[0].Up = []cards.Card{card(cards.Ace, cards.Hearts)}
s.Table[1].Up = []cards.Card{card(2, cards.Hearts)}
s.Table[2].Up = []cards.Card{card(cards.Ace, cards.Spades)}
s.Table[3].Up = []cards.Card{card(9, cards.Clubs)} // goes nowhere
next, evs := apply(t, s, Move{Kind: "auto"})
if next.Home() != 3 {
t.Fatalf("Home = %d after auto, want 3 (two aces and the two)", next.Home())
}
if len(next.Table[3].Up) != 1 {
t.Errorf("the nine went somewhere it couldn't go")
}
homes := 0
for _, e := range evs {
if e.Kind == "home" {
homes++
}
}
if homes != 3 {
t.Errorf("auto emitted %d home events, want 3 — the table has to animate each one", homes)
}
// Nothing left to do: the button says so rather than doing nothing quietly.
if next.CanAuto() {
t.Errorf("CanAuto is true with only a nine on the board")
}
refuses(t, next, Move{Kind: "auto"}, ErrNothingHome)
}
// ---- the money -------------------------------------------------------------
// The number the felt quotes while you play and the number settle() lands on are
// the same function. Hangman had these as two sums once and the table advertised
// a payout the house didn't honour; this asserts they can't drift here.
func TestTheQuoteIsThePayout(t *testing.T) {
s := board(vegas(), 1000)
for home := 0; home <= FullDeck; home++ {
s.Found = [Foundations][]cards.Card{}
left := home
for suit := 0; suit < Foundations && left > 0; suit++ {
n := left
if n > 13 {
n = 13
}
for r := 1; r <= n; r++ {
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
}
left -= n
}
if s.Home() != home {
t.Fatalf("built a board with %d home, wanted %d", s.Home(), home)
}
quoted := s.Pays()
var evs []Event
done := s.clone()
done.settle(OutcomeCashed, &evs)
if done.Payout != quoted {
t.Fatalf("%d home: the felt quoted %d and settle paid %d", home, quoted, done.Payout)
}
if done.Payout+done.Rake != done.Earned() {
t.Fatalf("%d home: payout %d + rake %d != earned %d",
home, done.Payout, done.Rake, done.Earned())
}
}
}
func TestAFullBoardPaysTheTiersMultiple(t *testing.T) {
for _, tier := range Tiers {
s := board(tier, 1000)
for suit := 0; suit < Foundations; suit++ {
for r := 1; r <= 13; r++ {
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
}
}
// Gross is the multiple exactly — computed from the total, not summed 52
// times, so it doesn't bleed a rounding per card.
want := int64(float64(s.Bet) * tier.Base)
if s.Earned() != want {
t.Errorf("%s: a cleared board earns %d, want %d", tier.Slug, s.Earned(), want)
}
// And the rake comes out of the winnings, never the stake.
profit := want - s.Bet
if s.Pays() != want-int64(float64(profit)*rake) {
t.Errorf("%s: pays %d, want %d less %v%% of the profit", tier.Slug, s.Pays(), want, rake*100)
}
}
}
// An empty board owes nothing, and is not charged a fee for owing nothing.
func TestNothingHomePaysNothing(t *testing.T) {
s := board(cut(), 500)
if s.Pays() != 0 || s.rakeNow() != 0 {
t.Fatalf("an empty board pays %d and rakes %d, want nothing either way", s.Pays(), s.rakeNow())
}
var evs []Event
s.settle(OutcomeCashed, &evs)
if s.Payout != 0 || s.Net() != -500 {
t.Errorf("payout %d net %d, want 0 and -500", s.Payout, s.Net())
}
}
// Below break-even the player is down but is not raked: there is no profit to
// take a cut of.
func TestNoRakeBelowTheStake(t *testing.T) {
tier := vegas()
s := board(tier, 5200)
for i := 0; i < tier.BreakEven()-1; i++ {
suit, r := i/13, i%13+1
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
}
if s.Earned() > s.Bet {
t.Fatalf("break-even is meant to be the first card that gets you square, but %d earns %d on a %d stake",
s.Home(), s.Earned(), s.Bet)
}
if s.rakeNow() != 0 {
t.Errorf("raked %d off a losing board", s.rakeNow())
}
if s.Pays() != s.Earned() {
t.Errorf("pays %d, want the full %d — nothing to rake", s.Pays(), s.Earned())
}
}
func TestBreakEvenIsTheCardThatGetsYouSquare(t *testing.T) {
for _, tier := range Tiers {
s := board(tier, 5200)
for i := 0; i < tier.BreakEven(); i++ {
suit, r := i/13, i%13+1
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
}
if s.Earned() < s.Bet {
t.Errorf("%s: %d cards home earns %d on a %d stake — break-even is quoted too low",
tier.Slug, s.Home(), s.Earned(), s.Bet)
}
}
}
// ---- settling --------------------------------------------------------------
func TestConcedeCashesTheBoard(t *testing.T) {
s := board(vegas(), 5200)
s.Found[cards.Hearts] = []cards.Card{card(cards.Ace, cards.Hearts), card(2, cards.Hearts)}
want := s.Pays()
next, evs := apply(t, s, Move{Kind: "concede"})
if next.Phase != PhaseDone || next.Outcome != OutcomeCashed {
t.Fatalf("phase %q outcome %q, want done/cashed", next.Phase, next.Outcome)
}
if next.Payout != want {
t.Errorf("cashed for %d, want the %d the board was quoting", next.Payout, want)
}
if evs[len(evs)-1].Kind != "settle" {
t.Errorf("no settle event: %+v", evs)
}
refuses(t, next, Move{Kind: "draw"}, ErrGameOver)
}
// The last card home ends the game on its own — the player doesn't have to tell
// the table they've won.
func TestTheLastCardHomeClearsTheBoard(t *testing.T) {
s := board(vegas(), 1000)
for suit := 0; suit < Foundations; suit++ {
top := 13
if suit == int(cards.Clubs) {
top = 12 // the king of clubs is the one card still out
}
for r := 1; r <= top; r++ {
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
}
}
s.Table[0].Up = []cards.Card{card(cards.King, cards.Clubs)}
next, evs := apply(t, s, Move{Kind: "home", From: "t0"})
if next.Phase != PhaseDone || next.Outcome != OutcomeCleared {
t.Fatalf("phase %q outcome %q, want done/cleared", next.Phase, next.Outcome)
}
if next.Payout != int64(float64(1000)*vegas().Base)-int64(float64(int64(float64(1000)*vegas().Base)-1000)*rake) {
t.Errorf("a cleared board paid %d", next.Payout)
}
if evs[len(evs)-1].Kind != "settle" {
t.Errorf("the winning card didn't settle the game: %+v", evs)
}
}
// ---- the shape of the thing ------------------------------------------------
// A game survives a redeploy: the whole state, shoe and face-down cards and all,
// goes through JSON and comes back the same board.
func TestAGameSurvivesJSON(t *testing.T) {
s, _, err := New(500, cut(), rake, cards.NewRNG(7, 11))
if err != nil {
t.Fatal(err)
}
for i := 0; i < 6; i++ {
s, _, _ = ApplyMove(s, Move{Kind: "draw"})
}
blob, err := json.Marshal(s)
if err != nil {
t.Fatal(err)
}
var back State
if err := json.Unmarshal(blob, &back); err != nil {
t.Fatal(err)
}
if !sameBoard(s, back) {
t.Fatal("the board didn't come back the same")
}
}
// The same seed deals the same board. This is what lets a disputed game be dealt
// again exactly as it fell, and it is why the RNG is threaded rather than global.
func TestASeedDealsTheSameBoard(t *testing.T) {
a, _, err := New(100, vegas(), rake, cards.NewRNG(42, 99))
if err != nil {
t.Fatal(err)
}
b, _, err := New(100, vegas(), rake, cards.NewRNG(42, 99))
if err != nil {
t.Fatal(err)
}
if !sameBoard(a, b) {
t.Fatal("the same seed dealt two different boards")
}
c, _, _ := New(100, vegas(), rake, cards.NewRNG(43, 99))
if sameBoard(a, c) {
t.Fatal("two seeds dealt the same board")
}
}
// Every card is on the board exactly once, whatever you do to it. A move that
// duplicated a card would be a move that printed money.
func TestNoCardIsEverLostOrDuplicated(t *testing.T) {
rng := rand.New(rand.NewPCG(3, 5))
s, _, err := New(1000, patient(), rake, rng)
if err != nil {
t.Fatal(err)
}
countDeck(t, s, "the deal")
// Play a long random game: whatever the fuzzer stumbles into, the deck holds.
for i := 0; i < 4000 && s.Phase == PhasePlaying; i++ {
m := randomMove(rng)
next, _, err := ApplyMove(s, m)
if err != nil {
continue // an illegal move is a fine thing for a fuzzer to find
}
s = next
countDeck(t, s, "after "+m.Kind)
}
}
func randomMove(rng *rand.Rand) Move {
pile := func() string {
switch rng.IntN(3) {
case 0:
return "waste"
case 1:
return "t" + strconv.Itoa(rng.IntN(Piles))
default:
return "f" + strconv.Itoa(rng.IntN(Foundations))
}
}
switch rng.IntN(10) {
case 0, 1, 2, 3:
return Move{Kind: "draw"}
case 4:
return Move{Kind: "home", From: pile()}
case 5:
return Move{Kind: "auto"}
default:
return Move{Kind: "move", From: pile(), To: pile(), Count: 1 + rng.IntN(4)}
}
}
func countDeck(t *testing.T, s State, when string) {
t.Helper()
seen := map[cards.Card]int{}
add := func(cs []cards.Card) {
for _, c := range cs {
seen[c]++
}
}
add(s.Stock)
add(s.Waste)
for _, p := range s.Table {
add(p.Down)
add(p.Up)
}
for _, f := range s.Found {
add(f)
}
if len(seen) != FullDeck {
t.Fatalf("%s: %d distinct cards on the board, want 52", when, len(seen))
}
for c, n := range seen {
if n != 1 {
t.Fatalf("%s: %v appears %d times", when, c, n)
}
}
}
// The face-up run in every tableau column is always a legal run, and a column
// with cards face-up never has an unturned card left under it. Both are things
// the *rules* keep true, so a fuzzer that breaks them has found a real bug.
func TestTheBoardStaysWellFormed(t *testing.T) {
rng := rand.New(rand.NewPCG(11, 13))
s, _, err := New(1000, vegas(), rake, rng)
if err != nil {
t.Fatal(err)
}
for i := 0; i < 4000 && s.Phase == PhasePlaying; i++ {
next, _, err := ApplyMove(s, randomMove(rng))
if err != nil {
continue
}
s = next
for j, p := range s.Table {
if !isRun(p.Up) {
t.Fatalf("column %d holds a run that isn't one: %v", j, p.Up)
}
if len(p.Up) == 0 && len(p.Down) > 0 {
t.Fatalf("column %d has %d cards face down and nothing turned over", j, len(p.Down))
}
}
for suit, f := range s.Found {
for r, c := range f {
if int(c.Suit) != suit || int(c.Rank) != r+1 {
t.Fatalf("foundation %d holds %v at position %d", suit, c, r)
}
}
}
}
}

View File

@@ -6,6 +6,7 @@ import (
"pete/internal/games/blackjack"
"pete/internal/games/hangman"
"pete/internal/games/klondike"
"pete/internal/storage"
)
@@ -26,9 +27,9 @@ type gameTeaser struct {
}
var comingSoon = []gameTeaser{
{Name: "Trivia", Emoji: "🧠", Blurb: "Faster answers score higher."},
{Name: "Hold'em", Emoji: "♠️", Blurb: "Six seats, and the house bots know how to play."},
{Name: "UNO", Emoji: "🎴", Blurb: "Normal rules, or no mercy."},
{Name: "Trivia", Emoji: "🧠", Blurb: "Faster answers score higher."},
}
// betDenominations are the chips you build a bet out of.
@@ -72,6 +73,8 @@ type gamesPage struct {
Denominations []int64
Tiers []hangman.Tier // hangman's three lengths, and what each pays
MaxWrong int
Deals []klondike.Tier // solitaire's three deals
FullDeck int
}
// casinoRoutes hangs every table off the mux.
@@ -84,6 +87,7 @@ func (s *Server) casinoRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /games", s.handleLobby)
mux.HandleFunc("GET /games/blackjack", s.handleBlackjack)
mux.HandleFunc("GET /games/hangman", s.handleHangman)
mux.HandleFunc("GET /games/solitaire", s.handleSolitaire)
mux.HandleFunc("GET /api/games/table", s.handleTable)
mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn)
@@ -94,6 +98,9 @@ func (s *Server) casinoRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/games/hangman/start", s.handleHangmanStart)
mux.HandleFunc("POST /api/games/hangman/guess", s.handleHangmanGuess)
mux.HandleFunc("POST /api/games/solitaire/start", s.handleSolitaireStart)
mux.HandleFunc("POST /api/games/solitaire/move", s.handleSolitaireMove)
}
// requirePlayer sends an anonymous visitor to sign in and comes back here after.
@@ -122,6 +129,8 @@ func (s *Server) gamesPage(r *http.Request) gamesPage {
Denominations: betDenominations,
Tiers: hangman.Tiers,
MaxWrong: hangman.MaxWrong,
Deals: klondike.Tiers,
FullDeck: klondike.FullDeck,
}
}
@@ -145,3 +154,10 @@ func (s *Server) handleHangman(w http.ResponseWriter, r *http.Request) {
}
s.render(w, "hangman", s.gamesPage(r))
}
func (s *Server) handleSolitaire(w http.ResponseWriter, r *http.Request) {
if !s.requirePlayer(w, r) {
return
}
s.render(w, "solitaire", s.gamesPage(r))
}

View File

@@ -13,6 +13,7 @@ import (
"pete/internal/games/blackjack"
"pete/internal/games/cards"
"pete/internal/games/hangman"
"pete/internal/games/klondike"
"pete/internal/storage"
)
@@ -177,7 +178,7 @@ type tableView struct {
Euros float64 `json:"euros"` // advisory, and up to a couple of minutes stale
Cap int64 `json:"cap"`
Game string `json:"game,omitempty"` // "blackjack" | "hangman", if one is live
Game string `json:"game,omitempty"` // "blackjack" | "hangman" | "solitaire", if one is live
Hand *handView `json:"hand,omitempty"` // blackjack
Events []eventView `json:"events,omitempty"` // blackjack, only on a move
@@ -185,6 +186,9 @@ type tableView struct {
Hangman *hangmanView `json:"hangman,omitempty"`
HangEvents []hangman.Event `json:"hang_events,omitempty"`
Solitaire *solitaireView `json:"solitaire,omitempty"`
SolEvents []solEventView `json:"sol_events,omitempty"`
Rake float64 `json:"rake_pct"`
}
@@ -228,6 +232,13 @@ func (s *Server) table(user string) (tableView, error) {
}
hv := viewHangman(g)
v.Hangman = &hv
case gameSolitaire:
var g klondike.State
if err := json.Unmarshal(live.State, &g); err != nil {
return s.dropUnreadable(user, v, err)
}
sv := viewSolitaire(g)
v.Solitaire = &sv
default:
return s.dropUnreadable(user, v, fmt.Errorf("unknown game %q", live.Game))
}
@@ -482,6 +493,7 @@ func (s *Server) handleMove(w http.ResponseWriter, r *http.Request) {
const (
gameBlackjack = "blackjack"
gameHangman = "hangman"
gameSolitaire = "solitaire"
)
// finished is what commit needs to know about a game it's writing back: enough

View File

@@ -0,0 +1,286 @@
package web
import (
"encoding/json"
"errors"
"log/slog"
"math/rand/v2"
"net/http"
"pete/internal/games/blackjack"
"pete/internal/games/cards"
"pete/internal/games/klondike"
"pete/internal/storage"
)
// Solitaire, played for chips. Vegas scoring: you buy the deck, and every card
// you get home pays a slice of it back.
//
// The withheld information here is bigger than blackjack's single hole card —
// it's the stock and every face-down card in the tableau, which between them are
// most of the deck. So the view sends *counts* for both: a column says how many
// cards are face-down under it, never which. A browser that held the stock would
// be a browser that knows whether the next pull is worth taking, and this game is
// nothing but that decision, repeated.
//
// The events, on the other hand, need no filtering at all, and that's worth
// saying out loud because blackjack's do. Every card a klondike event carries is
// a card the move itself just turned face up: the draw puts cards in the waste,
// the flip turns a column's top card over, a move carries cards that were already
// face up. There is no event here that mentions a card the player isn't now
// looking at.
// solPileView is one tableau column: how deep the face-down stack is, and the
// run sitting face up on top of it.
type solPileView struct {
Down int `json:"down"`
Up []cardView `json:"up"`
}
// solFoundView is one foundation. Only the top card matters — it's the only one
// that can be played back off — so it's the only one sent, with a count for the
// height of the pile.
type solFoundView struct {
Suit string `json:"suit"` // the glyph, so the empty pile can show what it wants
Red bool `json:"red"`
N int `json:"n"`
Top *cardView `json:"top,omitempty"`
}
// solitaireView is a board as its player may see it.
type solitaireView struct {
Tier klondike.Tier `json:"tier"`
Stock int `json:"stock"` // how many cards are left in it, not which
Waste []cardView `json:"waste"` // the top few, in the order they were turned
WasteN int `json:"waste_n"`
Table []solPileView `json:"table"`
Found []solFoundView `json:"found"`
Passes int `json:"passes"` // through the stock, counting this one; -1 unlimited
Moves int `json:"moves"`
CanAuto bool `json:"can_auto"`
Home int `json:"home"` // cards on the foundations
PerCard float64 `json:"per_card"` // what one more is worth
BreakEven int `json:"break_even"` // how many gets you square with the house
Bet int64 `json:"bet"`
Stands int64 `json:"stands"` // what cashing out right now actually pays
Phase string `json:"phase"`
Outcome string `json:"outcome,omitempty"`
Payout int64 `json:"payout,omitempty"`
Rake int64 `json:"rake,omitempty"`
Net int64 `json:"net"`
}
// wasteShown is how much of the waste the felt fans out. Three, because that is
// what a three-card draw puts down and the rest of the pile is just a pile.
const wasteShown = 3
func viewSolitaire(g klondike.State) solitaireView {
v := solitaireView{
Tier: g.Tier,
Stock: len(g.Stock),
WasteN: len(g.Waste),
Passes: g.PassesLeft(),
Moves: g.Moves,
CanAuto: g.CanAuto(),
Home: g.Home(),
PerCard: g.PerCard(),
BreakEven: g.Tier.BreakEven(),
Bet: g.Bet,
// What cashing out right now would actually land on the stack, rake already
// out of it. The pre-rake figure would have the felt advertising a number
// the house doesn't hand over.
Stands: g.Pays(),
Phase: string(g.Phase),
Outcome: string(g.Outcome),
Payout: g.Payout,
Rake: g.Rake,
Net: g.Net(),
}
from := len(g.Waste) - wasteShown
if from < 0 {
from = 0
}
for _, c := range g.Waste[from:] {
v.Waste = append(v.Waste, viewCard(c))
}
v.Table = make([]solPileView, klondike.Piles)
for i, p := range g.Table {
v.Table[i] = solPileView{Down: len(p.Down)}
for _, c := range p.Up {
v.Table[i].Up = append(v.Table[i].Up, viewCard(c))
}
}
v.Found = make([]solFoundView, klondike.Foundations)
for i, f := range g.Found {
suit := cards.Suit(i)
fv := solFoundView{Suit: suit.String(), Red: suit == cards.Hearts || suit == cards.Diamonds, N: len(f)}
if len(f) > 0 {
top := viewCard(f[len(f)-1])
fv.Top = &top
}
v.Found[i] = fv
}
return v
}
// solEventView is one thing the table animates. See the note at the top: unlike
// blackjack's, these need nothing stripped out of them.
type solEventView struct {
Kind string `json:"kind"`
Cards []cardView `json:"cards,omitempty"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Home int `json:"home"`
Pays int64 `json:"pays"`
}
func viewSolEvents(evs []klondike.Event) []solEventView {
out := make([]solEventView, 0, len(evs))
for _, e := range evs {
v := solEventView{Kind: e.Kind, From: e.From, To: e.To, Home: e.Home, Pays: e.Pays}
for _, c := range e.Cards {
v.Cards = append(v.Cards, viewCard(c))
}
out = append(out, v)
}
return out
}
// handleSolitaireStart takes the stake and deals the board. Same order as a
// blackjack deal: the chips are staked first, in the same statement that checks
// they exist, so two starts fired at once cannot buy the same deck twice.
func (s *Server) handleSolitaireStart(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
var req struct {
Bet int64 `json:"bet"`
Tier string `json:"tier"`
}
if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"})
return
}
tier, err := klondike.TierBySlug(req.Tier)
if err != nil {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a deal"})
return
}
if err := storage.Stake(user, req.Bet); err != nil {
if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips for that deck"})
return
}
slog.Error("games: solitaire stake", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
seed1, seed2 := newSeeds()
rng := rand.New(rand.NewPCG(seed1, seed2))
g, evs, err := klondike.New(req.Bet, tier, blackjack.DefaultRules().RakePct, rng)
if err != nil {
// The board never happened, so the stake never should have left.
_ = storage.Award(user, req.Bet)
slog.Error("games: solitaire deal", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.persistSolitaire(w, user, g, evs, seed1, seed2, true)
}
// solitaireErrors are the illegal moves a player makes by playing, rather than
// by tampering. Each gets said back to them in words, because "that move isn't
// legal" over a board with 60 legal-looking targets on it is not an answer.
var solitaireErrors = map[error]string{
klondike.ErrWontGo: "that card doesn't go there",
klondike.ErrNotASequence: "you can only lift a run that goes down in rank and alternates colour",
klondike.ErrEmptyPile: "there's nothing there",
klondike.ErrNoDraw: "the stock is empty",
klondike.ErrNoPasses: "that was your last pass through the stock",
klondike.ErrNothingHome: "nothing can go home right now",
klondike.ErrGameOver: "that board is finished",
}
// handleSolitaireMove plays one move: a draw, a card moved, a card sent home, an
// auto-finish, or cashing the board in.
func (s *Server) handleSolitaireMove(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
var move klondike.Move
if err := decodeJSON(r, &move); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
live, err := storage.LoadLiveHand(user)
if errors.Is(err, storage.ErrNoLiveHand) {
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "no game in progress"})
return
}
if err != nil {
slog.Error("games: solitaire load", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if live.Game != gameSolitaire {
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the hand you're in first"})
return
}
var g klondike.State
if err := json.Unmarshal(live.State, &g); err != nil {
slog.Error("games: unreadable solitaire board", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
next, evs, err := klondike.ApplyMove(g, move)
if err != nil {
msg, known := solitaireErrors[err]
if !known {
msg = "that move isn't legal here"
}
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
return
}
s.persistSolitaire(w, user, next, evs, live.Seed1, live.Seed2, false)
}
// persistSolitaire writes the board back and answers the browser.
func (s *Server) persistSolitaire(w http.ResponseWriter, user string, g klondike.State, evs []klondike.Event, seed1, seed2 uint64, fresh bool) {
blob, err := json.Marshal(g)
if err != nil {
slog.Error("games: marshal solitaire", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
done := g.Phase == klondike.PhaseDone
v, ok := s.commit(w, user, finished{
Game: gameSolitaire, Blob: blob,
Bet: g.Bet, Payout: g.Payout, Rake: g.Rake,
Outcome: string(g.Outcome), Done: done,
Seed1: seed1, Seed2: seed2, Fresh: fresh,
})
if !ok {
return
}
// A finished board is gone from storage, so the table has none to show — but
// the browser still needs the final one to animate the last cards onto.
if done {
sv := viewSolitaire(g)
v.Solitaire = &sv
}
v.SolEvents = viewSolEvents(evs)
writeJSON(w, v)
}

View File

@@ -95,7 +95,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
pages []string
}{
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}},
{"games_layout", []string{"_chipbar"}, []string{"games", "blackjack", "hangman"}},
{"games_layout", []string{"_chipbar"}, []string{"games", "blackjack", "hangman", "solitaire"}},
}
tpls := make(map[string]*template.Template)
for _, set := range sets {

View File

@@ -570,10 +570,13 @@ html[data-phase="night"] {
/* One card. The wrapper does the flight, the inner face does the flip, so the
two never fight over the same transform. */
/* The size is a variable because solitaire has seven columns to fit and
blackjack has two hands. Everything below is in terms of it, so a table can
shrink its cards without a second set of rules. */
.pete-card {
perspective: 700px;
height: 8.4rem;
width: 6rem;
height: var(--card-h, 8.4rem);
width: var(--card-w, 6rem);
animation: pete-deal 0.42s cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
.pete-card-inner {
@@ -1078,6 +1081,210 @@ html[data-phase="night"] {
border-color: var(--accent);
}
/* ---- solitaire -----------------------------------------------------------
Seven columns, four foundations, a stock and a waste, all of which have to
fit across the felt on a phone as well as a desk. So the card size is one
variable derived from the width available, and every gap and fan below is
derived from *that* — the board scales as one thing rather than as nine
things that each stop fitting at a different width.
The cards themselves don't move by CSS here. A move in solitaire can take a
card from anywhere to anywhere, so the table re-renders the board and then
animates each card from where it just was (see the FLIP note in
solitaire.js). CSS's job is only to say where things sit. */
.pete-solitaire {
--card-w: clamp(2.5rem, 10.6vw, 5.2rem);
--card-h: calc(var(--card-w) * 1.4);
--fan-up: calc(var(--card-h) * 0.29); /* how much of a face-up card shows under the next */
--fan-down: calc(var(--card-h) * 0.14); /* a face-down one shows less: there's nothing to read */
}
/* An empty place a card could be: the stock when it's spent, a foundation
waiting for its ace, a column waiting for a king. Same footprint as a card,
so nothing on the board reflows when one empties. */
.pete-slot {
position: relative;
display: grid;
place-items: center;
height: var(--card-h);
width: var(--card-w);
flex: none;
border-radius: 0.55rem;
border: 2px dashed rgba(255, 255, 255, 0.25);
background: rgba(0, 0, 0, 0.12);
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.18);
transition: border-color 0.18s ease, background 0.18s ease, transform 0.12s ease;
}
.pete-slot-glyph {
font-size: calc(var(--card-w) * 0.42);
line-height: 1;
color: rgba(255, 255, 255, 0.3);
}
.pete-slot-glyph[data-red="1"] { color: rgba(255, 170, 170, 0.35); }
/* A card sitting *in* a slot — the top of a foundation. The slot keeps its
footprint whether or not there's a card on it, so an ace going home doesn't
reflow the row, and a drop target has somewhere to be while it's empty. */
.pete-slot > .pete-card {
position: absolute;
inset: 0;
height: 100%;
width: 100%;
}
/* The stock is a button, and it looks like the back of a card because that is
what it is: a pile you can turn over. */
.pete-stock {
border-style: solid;
border-color: rgba(0, 0, 0, 0.15);
background:
repeating-linear-gradient(45deg, rgba(255,255,255,0.10) 0 6px, transparent 6px 12px),
linear-gradient(150deg, #b4553f, #8d3f2f);
cursor: pointer;
}
.pete-stock:hover { filter: brightness(1.08); }
.pete-stock:active { transform: translateY(1px) scale(0.98); }
/* Spent, but there's a pass left: it stops being a pile of cards and starts
being the gesture of turning the waste back over. */
.pete-stock[data-empty="1"] {
background: rgba(0, 0, 0, 0.12);
border-style: dashed;
border-color: rgba(255, 255, 255, 0.25);
}
/* Spent, and no passes left. Not a button any more — and it says so by looking
like the dead thing it is rather than by silently refusing clicks. */
.pete-stock[data-dead="1"] {
cursor: default;
opacity: 0.4;
}
.pete-stock[data-dead="1"]:hover { filter: none; }
.pete-stock[data-dead="1"]:active { transform: none; }
.pete-slot-count {
position: absolute;
bottom: -0.5rem;
left: 50%;
transform: translateX(-50%);
border-radius: 999px;
background: rgba(0, 0, 0, 0.5);
padding: 0.05rem 0.5rem;
font-size: 0.7rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #fff;
}
.pete-slot-recycle { color: rgba(255, 255, 255, 0.45); }
.pete-slot-recycle svg { height: 1.4rem; width: 1.4rem; }
/* The waste. Three cards fanned sideways, the top one on the right — you can
see what's under it, and only the top one is yours to take. */
.pete-waste {
position: relative;
display: flex;
height: var(--card-h);
min-width: var(--card-w);
}
.pete-waste .pete-card + .pete-card {
margin-left: calc(var(--card-w) * -0.72);
}
.pete-waste .pete-card { position: relative; }
/* The seven columns. They share the width evenly and never scroll: a board you
have to scroll sideways is a board you can't plan on. */
.pete-tableau {
display: grid;
grid-template-columns: repeat(7, var(--card-w));
justify-content: space-between;
gap: 0.5rem;
align-items: start;
min-height: calc(var(--card-h) * 2.6);
}
.pete-col {
display: flex;
flex-direction: column;
align-items: center;
min-height: var(--card-h);
}
/* Cards overlap up the column, and how much of one shows depends on whether
there's anything to see. The rule keys off the card *above*, which is why
it's an adjacent-sibling selector and not something the JS has to compute. */
.pete-col .pete-card { position: relative; }
.pete-col .pete-card[data-face="down"] + .pete-card {
margin-top: calc(var(--fan-down) - var(--card-h));
}
.pete-col .pete-card[data-face="up"] + .pete-card {
margin-top: calc(var(--fan-up) - var(--card-h));
}
/* A card you can pick up says so. */
.pete-card[data-live="1"] { cursor: pointer; }
.pete-card[data-live="1"]:hover .pete-card-front {
filter: brightness(1.06);
}
/* The card (or run) in your hand: lifted off the felt, and glowing, so it's
obvious what a click on a column is about to move. */
.pete-card[data-held="1"] {
z-index: 5;
transform: translateY(-0.55rem);
transition: transform 0.14s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.pete-card[data-held="1"] .pete-card-front {
box-shadow:
0 3px 0 rgba(0,0,0,0.18),
0 10px 22px rgba(0,0,0,0.32),
0 0 0 3px rgba(242, 181, 61, 0.9);
}
/* A place the held card would actually go. Lit *before* you click it, because
the alternative is learning the rules by being told no. */
.pete-slot[data-drop="1"],
.pete-col[data-drop="1"] .pete-slot,
.pete-col[data-drop="1"] .pete-card:last-child .pete-card-front {
border-color: rgba(242, 181, 61, 0.9);
box-shadow: 0 0 0 3px rgba(242, 181, 61, 0.45);
}
.pete-slot[data-drop="1"],
.pete-col[data-drop="1"] .pete-slot {
background: rgba(242, 181, 61, 0.12);
}
/* A move that won't go. Said in the one language a board can speak. */
.pete-nope { animation: pete-shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97); }
/* A card arriving on a foundation lands with a flash: it is the only move in
the game that pays you, so it is the only one that gets a noise. */
.pete-home-flash { animation: pete-home 0.5s ease-out; }
@keyframes pete-home {
0% { box-shadow: 0 0 0 0 rgba(242, 181, 61, 0.9); }
100% { box-shadow: 0 0 0 1.4rem rgba(242, 181, 61, 0); }
}
/* The rail: the house's rack, what you've banked, the meter. On a wide screen
it's a column down the right of the felt; on a narrow one it lies down and
sits under the board. */
.pete-rail {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 1rem 1.5rem;
}
@media (min-width: 1024px) {
.pete-rail {
flex-direction: column;
justify-content: flex-start;
width: 9.5rem;
gap: 1.5rem;
}
}
/* In the rail the rack is just another thing in a column, so it drops the
absolute positioning it uses when it's pinned to the corner of a felt. */
.pete-rack[data-at="rail"] {
position: static;
justify-content: center;
}
@media (prefers-reduced-motion: reduce) {
.pete-card,
.pete-card::after,
@@ -1089,6 +1296,9 @@ html[data-phase="night"] {
.pete-shake,
.pete-tile-hit,
.pete-meter[data-hit="1"] { animation: none; }
.pete-nope,
.pete-home-flash { animation: none; }
.pete-card[data-held="1"] { transition: none; }
}
}

File diff suppressed because one or more lines are too long

View File

@@ -44,11 +44,12 @@
var spotTotalEl = root.querySelector("[data-spot-total]");
var houseEl = root.querySelector("[data-house]");
// Nothing is bet until a chip is on the felt. The number in the panel is a
// readout of the pile, so it starts where the pile does — at nothing rather
// than at a default stake nobody put down.
// The spot owns the chips on the felt and the number under them — see PeteFX.
// Nothing is bet until a chip is on it, so `bet` starts at nothing rather than
// at a default stake nobody put down.
var spot = FX.spot({ spot: spotEl, stack: stackEl, total: spotTotalEl });
var bet = 0; // what you're building between hands
var staked = 0; // what is actually sitting on the spot right now
var busy = false; // a request is in flight, or cards are still landing
var hand = null; // the hand as the server last described it
@@ -68,194 +69,23 @@
}
// ---- drawing --------------------------------------------------------------
var dealt = 0; // how many cards this table has put down, ever — the tilt seed
// cardEl builds one card. face === null means face-down: the card is dealt,
// but this browser has not been told what it is.
function cardEl(face) {
var wrap = document.createElement("div");
wrap.className = "pete-card";
wrap.dataset.face = face ? "up" : "down";
// Every card flies out of the shoe, which sits in the top-right of the felt.
// The offset is per-card, so a card landing further left flies further.
wrap.style.setProperty("--deal-x", "14rem");
wrap.style.setProperty("--deal-y", "-6rem");
// Where it comes to rest. A degree or two either way is the whole difference
// between cards that were dealt onto a table and cards that were laid out in
// a grid, and it costs one custom property.
wrap.style.setProperty("--tilt", FX.jitter(dealt++, 2.4).toFixed(2) + "deg");
var inner = document.createElement("div");
inner.className = "pete-card-inner";
var front = document.createElement("div");
front.className = "pete-card-front";
var back = document.createElement("div");
back.className = "pete-card-back";
inner.appendChild(front);
inner.appendChild(back);
wrap.appendChild(inner);
if (face) paintFace(front, face);
return wrap;
}
// ---- the face ------------------------------------------------------------
//
// A card is drawn, not typed. The first attempt set the pips as text — "♠" in a
// span — and at the size a card actually is, a suit character renders as a
// speck: the shape is whatever font happened to answer, it doesn't scale, and
// it can't be positioned to the half-row a real pip layout needs.
//
// So each face is one SVG on a 100×140 field (the proportions of a real card),
// with the suits as vector shapes. Everything below is coordinates on that
// field, which is why the pips land where a printed deck puts them instead of
// where a flexbox felt like putting them.
// The deck itself — the faces, the pips, the flip — is PeteCards, shared with
// every other table in the room. Here a card is always dealt out of the shoe
// and always lands with a degree or two of tilt on it, which are this table's
// two opinions about a card and the only ones it has.
var SUIT_ART = {
"♠": '<path d="M50 6C50 6 16 34 16 58a17 17 0 0 0 28 13c-1 12-5 19-12 25h36c-7-6-11-13-12-25a17 17 0 0 0 28-13C84 34 50 6 50 6Z"/>',
"♥": '<path d="M50 96C50 96 8 66 8 38A22 22 0 0 1 50 24 22 22 0 0 1 92 38c0 28-42 58-42 58Z"/>',
"♦": '<path d="M50 4 88 50 50 96 12 50Z"/>',
"♣": '<g><circle cx="50" cy="28" r="19"/><circle cx="24" cy="62" r="19"/><circle cx="76" cy="62" r="19"/>' +
'<path d="M44 60h12l7 36H37Z"/></g>',
};
var CARDS = window.PeteCards;
// Pip layouts, the way a real deck lays them out — which is not "N suits in a
// row". [x, y] on the 100×140 field. The seven canonical rows sit at y = 27,
// 41, 56, 70, 84, 99, 113; sevens, eights and tens carry a pip *between* two of
// them, which is the whole reason this is a table of coordinates and not a
// grid. Anything below the middle is printed upside down, so it is.
var R = [0, 27, 41.4, 55.7, 70, 84.3, 98.6, 113]; // 1-indexed, R[4] is the middle
var L = 30, C = 50, Rr = 70; // the three columns
var PIPS = {
"A": [[C, 70, 2.1]],
"2": [[C, R[1]], [C, R[7]]],
"3": [[C, R[1]], [C, R[4]], [C, R[7]]],
"4": [[L, R[1]], [Rr, R[1]], [L, R[7]], [Rr, R[7]]],
"5": [[L, R[1]], [Rr, R[1]], [C, R[4]], [L, R[7]], [Rr, R[7]]],
"6": [[L, R[1]], [Rr, R[1]], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]],
"7": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]],
"8": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [C, 91.5], [L, R[7]], [Rr, R[7]]],
"9": [[L, R[1]], [Rr, R[1]], [L, R[3]], [Rr, R[3]], [C, R[4]], [L, R[5]], [Rr, R[5]], [L, R[7]], [Rr, R[7]]],
"10": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[3]], [Rr, R[3]], [L, R[5]], [Rr, R[5]], [C, 91.5], [L, R[7]], [Rr, R[7]]],
};
var COURT = { "J": "Jack", "Q": "Queen", "K": "King" };
// One pip: the suit art, scaled and dropped at [x, y], turned over if it sits
// below the middle of the card.
function pipAt(suit, x, y, scale) {
var s = (scale || 1) * 0.17;
var turn = y > 70 ? " rotate(180 50 50)" : "";
return '<g transform="translate(' + x + ' ' + y + ') scale(' + s + ') translate(-50 -50)' + turn + '">' +
SUIT_ART[suit] + "</g>";
}
// The corner index: rank over suit. Printed in both corners, the second one
// upside down, which is what lets you read a card from a fanned hand.
function index(face) {
var g =
'<g>' +
'<text x="12" y="24" class="pete-card-idx">' + face.rank + "</text>" +
'<g transform="translate(12 36) scale(0.13) translate(-50 -50)">' + SUIT_ART[face.suit] + "</g>" +
"</g>";
return g + '<g transform="rotate(180 50 70)">' + g + "</g>";
}
// paintFace draws the card. The dealer's cards and yours use the same face,
// because they came out of the same shoe.
function paintFace(front, face) {
front.dataset.red = face.red ? "1" : "0";
var body = "";
if (COURT[face.rank]) {
// Court cards: a framed panel, the suit above the letter and again below it
// the other way up. A real court mirrors a *figure*; mirroring a letter just
// stacks two of them into a blob, which is exactly what the first attempt
// did. No portrait either — a drawn king would fight the room, and this
// reads instantly at the size a card actually is.
body =
'<rect x="20" y="22" width="60" height="96" rx="6" class="pete-card-panel"/>' +
pipAt(face.suit, 50, 38, 0.95) +
'<text x="50" y="82" class="pete-card-court">' + face.rank + "</text>" +
pipAt(face.suit, 50, 102, 0.95);
} else {
var spots = PIPS[face.rank] || [];
for (var i = 0; i < spots.length; i++) {
body += pipAt(face.suit, spots[i][0], spots[i][1], spots[i][2]);
}
}
front.innerHTML =
'<svg class="pete-card-svg" viewBox="0 0 100 140" xmlns="http://www.w3.org/2000/svg" ' +
'role="img" aria-label="' + ariaFor(face) + '">' + index(face) + body + "</svg>";
}
// "A♠" is not something a screen reader should be asked to pronounce.
function ariaFor(face) {
var SUITS = { "♠": "spades", "♥": "hearts", "♦": "diamonds", "♣": "clubs" };
var name = COURT[face.rank] || (face.rank === "A" ? "Ace" : face.rank);
var suit = SUITS[face.suit];
return suit ? name + " of " + suit : face.label;
}
// turnOver flips a face-down card up, now that we've been told what it is.
function turnOver(wrap, face) {
if (!wrap) return;
paintFace(wrap.querySelector(".pete-card-front"), face);
wrap.dataset.face = "up";
}
function cardEl(face) { return CARDS.el(face); }
var turnOver = CARDS.turnOver;
// ---- the money on the felt -------------------------------------------------
//
// `staked` is what the spot is holding. Every path that changes it also moves
// chips to say so, so the two can't come apart: renderStack draws the pile,
// and the fly* calls are what put it there.
function renderStack(amount) {
staked = amount || 0;
stackEl.innerHTML = "";
spotEl.dataset.live = staked > 0 ? "1" : "0";
if (!staked) {
spotTotalEl.classList.add("hidden");
return;
}
FX.chipsFor(staked).forEach(function (d, i) {
var c = FX.disc(d);
c.style.setProperty("--i", i);
c.style.setProperty("--spin", FX.jitter(i, 12).toFixed(1) + "deg");
c.style.animationDelay = pace(i * 40) + "ms";
stackEl.appendChild(c);
});
spotTotalEl.textContent = staked.toLocaleString();
spotTotalEl.classList.remove("hidden");
}
// pour throws a run of chips from one place to another and grows the pile on
// the spot as each one lands — by the value of the chip that landed, so the
// total under the pile counts up the way the chips do. The last chip carries
// the remainder, because chipsFor caps how many chips it will make you watch
// and the pile still has to end on the real number.
function pour(from, to, amount, opts) {
if (amount <= 0) return Promise.resolve();
var base = staked;
var chips = FX.chipsFor(amount, 8);
var run = 0;
return FX.flyMany(from, to, chips, Object.assign({
onLand: function (d, i) {
run += d;
renderStack(base + (i === chips.length - 1 ? amount : run));
},
}, opts || {}));
}
// stake moves chips from your pile onto the spot: the bet you build before a
// deal, and the second bet a double puts down beside it.
function stake(amount, from) {
return pour(from || purseEl, spotEl, amount);
return spot.pour(from || purseEl, amount);
}
// settleChips is what the felt does about the outcome, after the cards have
@@ -268,25 +98,17 @@
if (payout <= 0) {
// The house takes it. The stack goes to the rack and doesn't come back.
var lost = FX.chipsFor(final.bet, 8);
var chain = FX.flyMany(spotEl, houseEl, lost, { gap: 45, lift: 0.6, fade: true });
renderStack(0);
return chain;
return spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true });
}
// The house pays first, into the spot beside your stake, so you watch the
// winnings arrive on top of the bet that earned them.
var pay = pour(houseEl, spotEl, back, { gap: 60 });
// Paid, then swept up: the whole lot comes back to your pile, and only then
// does the number in the bar move.
return pay
return spot
.pour(houseEl, back, { gap: 60 })
.then(function () { return wait(back > 0 ? 380 : 200); })
.then(function () {
var home = FX.flyMany(spotEl, purseEl, FX.chipsFor(payout, 8), { gap: 40, lift: 0.8 });
renderStack(0);
return home;
});
// Paid, then swept up: the whole lot comes back to your pile, and only then
// does the number in the bar move.
.then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); });
}
function totals(v) {
@@ -312,12 +134,12 @@
function paint(v) {
dealerEl.innerHTML = "";
playerEl.innerHTML = "";
if (!v) { setPhase(null); renderStack(0); return; }
if (!v) { setPhase(null); spot.render(0); return; }
v.player.forEach(function (c) { playerEl.appendChild(cardEl(c)); });
v.dealer.forEach(function (c) { dealerEl.appendChild(cardEl(c)); });
if (v.hole) dealerEl.appendChild(cardEl(null));
renderStack(v.phase === "done" ? 0 : v.bet);
spot.render(v.phase === "done" ? 0 : v.bet);
totals(v);
setPhase(v);
}
@@ -386,8 +208,8 @@
// first, and a deal whose bet was typed rather than stacked (you kept last
// hand's number and just pressed Deal). Either way the chips go down before
// the card they're buying does.
if (final && final.bet > staked) {
var extra = final.bet - staked;
if (final && final.bet > spot.amount) {
var extra = final.bet - spot.amount;
chain = chain.then(function () { return stake(extra); });
}
@@ -499,7 +321,7 @@
say(err.message, "bad");
// Whatever we thought was on the felt, the server is the authority on it.
return window.PeteGames.refresh().then(function (v) {
if (v && !v.hand) renderStack(0);
if (v && !v.hand) spot.render(0);
});
})
.then(function () { busy = false; });
@@ -531,12 +353,12 @@
// The chip you clicked is the chip that flies: same colour, same size, off
// the button and onto the felt. The pile only grows once it gets there —
// but `staked` moves now, so a Deal pressed mid-flight still knows the chip
// is on its way and doesn't put a second one down.
// but the spot's total moves now, so a Deal pressed mid-flight still knows
// the chip is on its way and doesn't put a second one down.
var target = bet;
staked = bet;
spot.amount = bet;
FX.fly(btn, spotEl, { denom: d }).then(function () {
if (bet >= target) renderStack(target); // unless Clear got there first
if (bet >= target) spot.render(target); // unless Clear got there first
});
});
});
@@ -544,10 +366,9 @@
var clearBtn = root.querySelector("[data-bet-clear]");
if (clearBtn) {
clearBtn.addEventListener("click", function () {
if (busy || !staked) { bet = 0; showBet(); return; }
FX.flyMany(spotEl, purseEl, FX.chipsFor(staked, 8), { gap: 40, lift: 0.7 });
if (busy || !spot.amount) { bet = 0; showBet(); return; }
spot.sweep(purseEl, null, { gap: 40, lift: 0.7 });
bet = 0;
renderStack(0);
showBet();
});
}

View File

@@ -0,0 +1,172 @@
// The deck, as the room draws it.
//
// A card is drawn, not typed. The first attempt set the pips as text — "♠" in a
// span — and at the size a card actually is, a suit character renders as a speck:
// the shape is whatever font happened to answer, it doesn't scale, and it can't
// be positioned to the half-row a real pip layout needs.
//
// So each face is one SVG on a 100×140 field (the proportions of a real card),
// with the suits as vector shapes. Everything below is coordinates on that field,
// which is why the pips land where a printed deck puts them instead of where a
// flexbox felt like putting them.
//
// This started life inside blackjack.js. It's out here because solitaire deals
// off the same deck, and the second table in a casino is exactly the moment a
// copied card renderer becomes two card renderers that drift.
//
// Exposed as window.PeteCards. Nothing in here knows what game is being played.
(function () {
"use strict";
var SUIT_ART = {
"♠": '<path d="M50 6C50 6 16 34 16 58a17 17 0 0 0 28 13c-1 12-5 19-12 25h36c-7-6-11-13-12-25a17 17 0 0 0 28-13C84 34 50 6 50 6Z"/>',
"♥": '<path d="M50 96C50 96 8 66 8 38A22 22 0 0 1 50 24 22 22 0 0 1 92 38c0 28-42 58-42 58Z"/>',
"♦": '<path d="M50 4 88 50 50 96 12 50Z"/>',
"♣": '<g><circle cx="50" cy="28" r="19"/><circle cx="24" cy="62" r="19"/><circle cx="76" cy="62" r="19"/>' +
'<path d="M44 60h12l7 36H37Z"/></g>',
};
// Pip layouts, the way a real deck lays them out — which is not "N suits in a
// row". [x, y] on the 100×140 field. The seven canonical rows sit at y = 27,
// 41, 56, 70, 84, 99, 113; sevens, eights and tens carry a pip *between* two of
// them, which is the whole reason this is a table of coordinates and not a
// grid. Anything below the middle is printed upside down, so it is.
var R = [0, 27, 41.4, 55.7, 70, 84.3, 98.6, 113]; // 1-indexed, R[4] is the middle
var L = 30, C = 50, Rr = 70; // the three columns
var PIPS = {
"A": [[C, 70, 2.1]],
"2": [[C, R[1]], [C, R[7]]],
"3": [[C, R[1]], [C, R[4]], [C, R[7]]],
"4": [[L, R[1]], [Rr, R[1]], [L, R[7]], [Rr, R[7]]],
"5": [[L, R[1]], [Rr, R[1]], [C, R[4]], [L, R[7]], [Rr, R[7]]],
"6": [[L, R[1]], [Rr, R[1]], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]],
"7": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]],
"8": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [C, 91.5], [L, R[7]], [Rr, R[7]]],
"9": [[L, R[1]], [Rr, R[1]], [L, R[3]], [Rr, R[3]], [C, R[4]], [L, R[5]], [Rr, R[5]], [L, R[7]], [Rr, R[7]]],
"10": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[3]], [Rr, R[3]], [L, R[5]], [Rr, R[5]], [C, 91.5], [L, R[7]], [Rr, R[7]]],
};
var COURT = { "J": "Jack", "Q": "Queen", "K": "King" };
var SUIT_NAMES = { "♠": "spades", "♥": "hearts", "♦": "diamonds", "♣": "clubs" };
// One pip: the suit art, scaled and dropped at [x, y], turned over if it sits
// below the middle of the card.
function pipAt(suit, x, y, scale) {
var s = (scale || 1) * 0.17;
var turn = y > 70 ? " rotate(180 50 50)" : "";
return '<g transform="translate(' + x + ' ' + y + ') scale(' + s + ') translate(-50 -50)' + turn + '">' +
SUIT_ART[suit] + "</g>";
}
// The corner index: rank over suit. Printed in both corners, the second one
// upside down, which is what lets you read a card from a fanned hand — and in
// solitaire, from a column where all you can see is the top eighth of it.
function index(face) {
var g =
'<g>' +
'<text x="12" y="24" class="pete-card-idx">' + face.rank + "</text>" +
'<g transform="translate(12 36) scale(0.13) translate(-50 -50)">' + SUIT_ART[face.suit] + "</g>" +
"</g>";
return g + '<g transform="rotate(180 50 70)">' + g + "</g>";
}
// paint draws the face. Every table uses this one, because they all deal out of
// the same deck.
function paint(front, face) {
front.dataset.red = face.red ? "1" : "0";
var body = "";
if (COURT[face.rank]) {
// Court cards: a framed panel, the suit above the letter and again below it
// the other way up. A real court mirrors a *figure*; mirroring a letter just
// stacks two of them into a blob, which is exactly what the first attempt
// did. No portrait either — a drawn king would fight the room, and this
// reads instantly at the size a card actually is.
body =
'<rect x="20" y="22" width="60" height="96" rx="6" class="pete-card-panel"/>' +
pipAt(face.suit, 50, 38, 0.95) +
'<text x="50" y="82" class="pete-card-court">' + face.rank + "</text>" +
pipAt(face.suit, 50, 102, 0.95);
} else {
var spots = PIPS[face.rank] || [];
for (var i = 0; i < spots.length; i++) {
body += pipAt(face.suit, spots[i][0], spots[i][1], spots[i][2]);
}
}
front.innerHTML =
'<svg class="pete-card-svg" viewBox="0 0 100 140" xmlns="http://www.w3.org/2000/svg" ' +
'role="img" aria-label="' + aria(face) + '">' + index(face) + body + "</svg>";
}
// "A♠" is not something a screen reader should be asked to pronounce.
function aria(face) {
var name = COURT[face.rank] || (face.rank === "A" ? "Ace" : face.rank);
var suit = SUIT_NAMES[face.suit];
return suit ? name + " of " + suit : face.label;
}
var dealt = 0; // how many cards this page has put down, ever — the tilt seed
// el builds one card. face === null means face-down: the card is on the table,
// but this browser has not been told what it is.
//
// opts.deal — fly it in from the shoe (blackjack). Solitaire turns this off:
// it animates its own cards from wherever they actually came from,
// and a board that re-renders would otherwise re-deal itself out of
// the corner on every single move.
// opts.tilt — a degree or two of resting angle. A dealt hand wants it; a
// solitaire column does not, because thirteen tilted cards stacked
// an eighth of an inch apart read as a mistake rather than as a
// hand that was handled.
function el(face, opts) {
opts = opts || {};
var deal = opts.deal !== false;
var tilt = opts.tilt !== false;
var wrap = document.createElement("div");
wrap.className = "pete-card";
wrap.dataset.face = face ? "up" : "down";
if (face) wrap.dataset.key = face.label; // one deck, so the label is an id
if (deal) {
// Every card flies out of the shoe, which sits in the top-right of the felt.
wrap.style.setProperty("--deal-x", "14rem");
wrap.style.setProperty("--deal-y", "-6rem");
} else {
wrap.style.animation = "none";
}
wrap.style.setProperty("--tilt", tilt ? window.PeteFX.jitter(dealt++, 2.4).toFixed(2) + "deg" : "0deg");
var inner = document.createElement("div");
inner.className = "pete-card-inner";
var front = document.createElement("div");
front.className = "pete-card-front";
var back = document.createElement("div");
back.className = "pete-card-back";
inner.appendChild(front);
inner.appendChild(back);
wrap.appendChild(inner);
if (face) paint(front, face);
return wrap;
}
// turnOver flips a face-down card up, now that we've been told what it is.
function turnOver(wrap, face) {
if (!wrap) return;
paint(wrap.querySelector(".pete-card-front"), face);
wrap.dataset.face = "up";
wrap.dataset.key = face.label;
}
window.PeteCards = {
el: el,
paint: paint,
turnOver: turnOver,
aria: aria,
art: SUIT_ART,
};
})();

View File

@@ -144,6 +144,78 @@
return Promise.all(each);
}
// A bet spot: the pile of chips sitting on it, and the number printed under
// the pile.
//
// The rule the whole room is built on lives in here, which is why it's one
// object and not two variables on a table: **the number is a readout of the
// pile, never the other way round.** There is no way to change one without the
// other, so a settled game can't leave "your bet: 300" printed over an empty
// circle, and a payout can't be counted before the chips that justify it have
// landed.
//
// els: {spot, stack, total}. Blackjack's spot holds the stake; solitaire's
// holds what you've banked, which grows a card at a time. Same object.
function spot(els) {
var api = {
// What the pile is holding. Written by render, and readable by a table that
// needs to know whether a chip is already on its way down.
amount: 0,
render: function (n) {
api.amount = n || 0;
els.stack.innerHTML = "";
if (els.spot) els.spot.dataset.live = api.amount > 0 ? "1" : "0";
if (!api.amount) {
if (els.total) els.total.classList.add("hidden");
return;
}
chipsFor(api.amount).forEach(function (d, i) {
var c = disc(d);
c.style.setProperty("--i", i);
c.style.setProperty("--spin", jitter(i, 12).toFixed(1) + "deg");
c.style.animationDelay = (reduced ? 0 : i * 40) + "ms";
els.stack.appendChild(c);
});
if (els.total) {
els.total.textContent = api.amount.toLocaleString();
els.total.classList.remove("hidden");
}
},
// pour throws a run of chips onto the spot and grows the pile as each one
// lands — by the value of the chip that landed, so the total under the pile
// counts up the way the chips do. The last chip carries the remainder,
// because chipsFor caps how many chips it will make you watch and the pile
// still has to end on the real number.
pour: function (from, amount, opts) {
if (amount <= 0) return Promise.resolve();
var base = api.amount;
var chips = chipsFor(amount, 8);
var run = 0;
return flyMany(from, els.spot, chips, Object.assign({
onLand: function (d, i) {
run += d;
api.render(base + (i === chips.length - 1 ? amount : run));
},
}, opts || {}));
},
// sweep sends chips off the spot to somewhere else — your pile, or the
// house's rack. The spot is emptied *now* rather than when they land, so
// nothing that is already in the air can be bet a second time.
sweep: function (to, amount, opts) {
var n = amount == null ? api.amount : amount;
var left = api.amount - n;
if (n <= 0) return Promise.resolve();
var chain = flyMany(els.spot, to, chipsFor(n, 8), Object.assign({ gap: 40, lift: 0.8 }, opts || {}));
api.render(left > 0 ? left : 0);
return chain;
},
};
return api;
}
// burst: confetti out of a point. Saved for the things worth celebrating.
function burst(target, opts) {
if (reduced) return Promise.resolve();
@@ -242,6 +314,7 @@
jitter: jitter,
fly: fly,
flyMany: flyMany,
spot: spot,
burst: burst,
count: count,
centre: centre,

View File

@@ -0,0 +1,729 @@
// The solitaire table.
//
// Blackjack plays back a *script*: the server sends one event per card off the
// shoe and the table deals them out in order. That works because a blackjack hand
// only ever grows at one end. Solitaire doesn't: a move takes a run from anywhere
// and puts it anywhere, an auto-finish moves eleven cards at once, and a single
// move can turn a card over three columns away. A script of "append this card
// there" would be a second engine over here, and it would be the one that's wrong.
//
// So this table re-renders the whole board from the server's view after every
// move, and then animates the difference — FLIP: measure where every card *was*,
// re-render, measure where it *is*, and play each card from its old place to its
// new one. The board on screen is therefore always exactly the board the server
// says exists, and the animation is derived from it rather than the other way
// round. The events are still used, for the two things a diff genuinely cannot
// tell you: where a newly-revealed card came from (out of the stock, or turned
// over in place), and what the board is now worth.
//
// The money follows the same rule as every other table in the room: nothing about
// it changes without a chip crossing the felt to make it change. Here the stake
// buys the deck — it goes to the house and does not come back — and the spot in
// the rail holds what you've *banked*, which grows by one card's worth every time
// a card reaches a foundation and shrinks again if you take one back off.
(function () {
"use strict";
var root = document.querySelector("[data-solitaire]");
if (!root) return;
var FX = window.PeteFX;
var CARDS = window.PeteCards;
var stockEl = root.querySelector("[data-stock]");
var stockCountEl = root.querySelector("[data-stock-count]");
var stockRecycleEl = root.querySelector("[data-stock-recycle]");
var wasteEl = root.querySelector("[data-waste]");
var foundEl = root.querySelector("[data-foundations]");
var tableauEl = root.querySelector("[data-tableau]");
var verdictEl = root.querySelector("[data-verdict]");
var homeEl = root.querySelector("[data-home]");
var perCardEl = root.querySelector("[data-per-card]");
var breakEvenEl = root.querySelector("[data-break-even]");
var meterEl = root.querySelector("[data-meter]");
var playing = root.querySelector("[data-playing]");
var betting = root.querySelector("[data-betting]");
var autoBtn = root.querySelector("[data-auto]");
var cashBtn = root.querySelector("[data-cash]");
var cashAmountEl = root.querySelector("[data-cash-amount]");
var startBtn = root.querySelector("[data-start]");
var betAmountEl = root.querySelector("[data-bet-amount]");
var gameMsg = root.querySelector("[data-game-msg]");
var tableMsg = root.querySelector("[data-table-msg]");
var purseEl = document.querySelector("[data-chips]");
var spotEl = root.querySelector("[data-spot]");
var houseEl = root.querySelector("[data-house]");
// The spot in the rail. On this table it holds what you've banked, not a bet.
var spot = FX.spot({
spot: spotEl,
stack: root.querySelector("[data-stack]"),
total: root.querySelector("[data-spot-total]"),
});
var FULL = 52;
var MOVE_MS = 300; // one card's journey across the board
var STEP_MS = 95; // the gap between two cards in a cascade
var reduced = FX.reduced;
var bet = 0; // the deck you're building the price of
var tier = null; // which deal
var busy = false; // a request is in flight, or cards are still moving
var board = null; // the board as the server last described it
var held = null; // {pile, count, cards} — the run in your hand
function wait(ms) {
return new Promise(function (r) { setTimeout(r, reduced ? 0 : ms); });
}
function say(el, text, tone) {
if (!text) { el.classList.add("hidden"); return; }
el.textContent = text;
el.classList.remove("hidden");
el.style.color = tone === "bad" ? "#cc3d4a" : "";
}
// ---- the rules, as the *browser* understands them --------------------------
//
// These mirror the engine, and they are not the engine: the server decides every
// move and will refuse one this file thought was fine. They exist because you
// cannot light up the columns a card can go to without knowing which those are,
// and a table that makes you find that out by being told no is a table that
// teaches Klondike by refusal. When the two disagree the server wins and the
// board snaps back to whatever it says — see send().
var RANKS = { A: 1, J: 11, Q: 12, K: 13 };
function rank(c) { return RANKS[c.rank] || parseInt(c.rank, 10); }
// isRun: descending by one, alternating colour — the only thing you may lift
// off a column as a block.
function isRun(cs) {
for (var i = 1; i < cs.length; i++) {
if (rank(cs[i]) !== rank(cs[i - 1]) - 1 || cs[i].red === cs[i - 1].red) return false;
}
return true;
}
// accepts: what may be put where. A foundation takes its own suit in order from
// the ace; a column takes a run that descends and alternates from its top card,
// and an empty column takes a King and nothing else.
function accepts(pile, cs) {
if (!board || !cs || !cs.length) return false;
if (pile.charAt(0) === "f") {
var f = board.found[parseInt(pile.slice(1), 10)];
return !!f && cs.length === 1 && cs[0].suit === f.suit && rank(cs[0]) === f.n + 1;
}
var col = board.table[parseInt(pile.slice(1), 10)];
if (!col || !isRun(cs)) return false;
if (!col.up || !col.up.length) return col.down === 0 && rank(cs[0]) === 13;
var top = col.up[col.up.length - 1];
return rank(cs[0]) === rank(top) - 1 && cs[0].red !== top.red;
}
// cardsAt is the run you'd be picking up by clicking this card.
function cardsAt(pile, idx) {
if (!board) return null;
if (pile === "waste") {
return board.waste && board.waste.length ? [board.waste[board.waste.length - 1]] : null;
}
if (pile.charAt(0) === "f") {
var f = board.found[parseInt(pile.slice(1), 10)];
return f && f.top ? [f.top] : null;
}
var col = board.table[parseInt(pile.slice(1), 10)];
if (!col || !col.up || idx >= col.up.length) return null;
return col.up.slice(idx);
}
// ---- drawing the board -----------------------------------------------------
// face builds a card element wired up for clicking. No deal flight and no tilt:
// this table animates its own cards from wherever they actually came from, and a
// column of thirteen tilted cards overlapping by an eighth of an inch reads as a
// mistake rather than as a hand that was handled.
function face(c, pile, idx) {
var el = CARDS.el(c, { deal: false, tilt: false });
el.dataset.pile = pile;
el.dataset.idx = String(idx);
return el;
}
function slot(pile, glyph, red) {
var el = document.createElement("div");
el.className = "pete-slot";
el.dataset.pile = pile;
if (glyph) {
el.innerHTML = '<span class="pete-slot-glyph" data-red="' + (red ? "1" : "0") + '">' + glyph + "</span>";
}
return el;
}
function render(v) {
board = v;
if (!v) {
wasteEl.innerHTML = "";
foundEl.innerHTML = "";
tableauEl.innerHTML = "";
stockEl.dataset.dead = "1";
stockCountEl.classList.add("hidden");
stockRecycleEl.classList.add("hidden");
meter(null);
return;
}
// The stock. Empty with a pass left is not the same thing as empty with none:
// one is a gesture you can still make and the other is a dead pile, and the
// difference is worth showing rather than leaving to a click that gets refused.
var canRecycle = v.stock === 0 && v.waste_n > 0 && (v.passes < 0 || v.passes > 1);
stockEl.dataset.empty = v.stock === 0 ? "1" : "0";
stockEl.dataset.dead = v.stock === 0 && !canRecycle ? "1" : "0";
stockCountEl.textContent = String(v.stock);
stockCountEl.classList.toggle("hidden", v.stock === 0);
stockRecycleEl.classList.toggle("hidden", !canRecycle);
// The waste: the last three, fanned, and only the top one is yours to take.
wasteEl.innerHTML = "";
(v.waste || []).forEach(function (c, i, all) {
var el = face(c, "waste", i);
if (i === all.length - 1) el.dataset.live = "1";
wasteEl.appendChild(el);
});
// The foundations. Each is a slot that stays put whether or not there's a card
// on it, so the board doesn't reflow the moment an ace goes home — and so a
// drop target has somewhere to be even when it's empty.
foundEl.innerHTML = "";
v.found.forEach(function (f, i) {
var s = slot("f" + i, f.suit, f.red);
if (f.top) {
var el = face(f.top, "f" + i, 0);
el.dataset.live = "1";
s.appendChild(el);
}
foundEl.appendChild(s);
});
// The seven columns.
tableauEl.innerHTML = "";
v.table.forEach(function (col, i) {
var c = document.createElement("div");
c.className = "pete-col";
c.dataset.pile = "t" + i;
if (!col.down && !(col.up && col.up.length)) {
c.appendChild(slot("t" + i));
}
for (var d = 0; d < col.down; d++) {
c.appendChild(CARDS.el(null, { deal: false, tilt: false }));
}
(col.up || []).forEach(function (card, j) {
var el = face(card, "t" + i, j);
// A card is pickable if the run from it down is a run. Anything else is a
// card you can see and can't lift, and it shouldn't offer.
if (isRun(col.up.slice(j))) el.dataset.live = "1";
c.appendChild(el);
});
tableauEl.appendChild(c);
});
meter(v);
controls(v);
if (held) mark(); // a selection survives a re-render, so redraw what it lit
}
// meter is what the board is worth. Every number in it comes off the server —
// this file does no arithmetic about money, which is the point.
function meter(v) {
if (!v) {
homeEl.innerHTML = '0<span class="text-white/40">/' + FULL + "</span>";
perCardEl.textContent = "—";
breakEvenEl.textContent = "";
return;
}
homeEl.innerHTML = v.home + '<span class="text-white/40">/' + FULL + "</span>";
perCardEl.textContent = "+" + v.per_card.toFixed(1);
breakEvenEl.textContent =
v.home >= v.break_even
? "You're ahead of the house"
: v.break_even - v.home + " more to break even";
meterEl.dataset.cold = v.home === 0 ? "1" : "0";
}
function controls(v) {
var live = !!v && v.phase === "playing";
playing.classList.toggle("hidden", !live);
betting.classList.toggle("hidden", live);
if (!live) return;
autoBtn.disabled = !v.can_auto;
cashAmountEl.textContent = (v.stands || 0).toLocaleString();
}
// ---- FLIP ------------------------------------------------------------------
//
// Where every card is, right now. One deck, so a card's label is its identity.
function snapshot() {
var map = {};
root.querySelectorAll(".pete-card[data-key]").forEach(function (el) {
map[el.dataset.key] = el.getBoundingClientRect();
});
map["#stock"] = stockEl.getBoundingClientRect();
return map;
}
// plan reads the events for the two things a before/after diff can't tell you:
// where a card that is *new to the board* came from, and how much of a beat to
// leave before it moves, so that an auto-finish cascades rather than teleporting.
function planOf(events) {
var origins = {}, delays = {}, at = 0;
(events || []).forEach(function (e) {
(e.cards || []).forEach(function (c) {
if (e.kind === "draw") origins[c.label] = "draw";
if (e.kind === "flip") origins[c.label] = "flip";
delays[c.label] = at;
});
if (e.kind === "move" || e.kind === "home") at += STEP_MS;
});
return { origins: origins, delays: delays };
}
// animate plays every card from where it was to where it is.
function animate(before, plan) {
if (reduced) return Promise.resolve();
var waits = [];
root.querySelectorAll(".pete-card[data-key]").forEach(function (el) {
var key = el.dataset.key;
var now = el.getBoundingClientRect();
var was = before[key];
var delay = plan.delays[key] || 0;
var origin = plan.origins[key];
// A card that was already on the board: play it from its old place. This is
// every ordinary move, and it is also what makes an eleven-card auto-finish
// animate itself for free.
if (was && !origin) {
var dx = was.left - now.left;
var dy = was.top - now.top;
if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) return;
waits.push(slide(el, dx, dy, delay));
return;
}
// A card that has just been turned over. Out of the stock it flies as well as
// turns; in a column it turns where it lies.
if (origin === "draw") {
var from = before["#stock"];
waits.push(slide(el, from.left - now.left, from.top - now.top, delay));
waits.push(turn(el, delay));
} else if (origin === "flip") {
waits.push(turn(el, delay));
}
});
return Promise.all(waits);
}
function slide(el, dx, dy, delay) {
return el.animate(
[{ transform: "translate(" + dx + "px," + dy + "px)" }, { transform: "none" }],
{
duration: MOVE_MS,
delay: delay,
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
fill: "backwards",
}
).finished.catch(noop);
}
// The card turns over on its own axis. The wrapper is doing the travelling, so
// this has to be the inner face or the two transforms would fight.
function turn(el, delay) {
var inner = el.querySelector(".pete-card-inner");
return inner.animate(
[{ transform: "rotateY(180deg)" }, { transform: "rotateY(0deg)" }],
{ duration: MOVE_MS, delay: delay, easing: "cubic-bezier(0.4, 0, 0.2, 1)", fill: "backwards" }
).finished.catch(noop);
}
// The recycle is the one move where cards *leave* the board, so FLIP has nothing
// to animate: they're gone from the new render before it can measure them. They
// get their flight here instead, out of the old DOM, before it's replaced.
function recycleOut() {
if (reduced) return Promise.resolve();
var to = stockEl.getBoundingClientRect();
var waits = [];
wasteEl.querySelectorAll(".pete-card").forEach(function (el, i) {
var now = el.getBoundingClientRect();
waits.push(
el.animate(
[
{ transform: "none", opacity: 1 },
{ transform: "translate(" + (to.left - now.left) + "px," + (to.top - now.top) + "px) rotateY(180deg)", opacity: 1 },
],
{ duration: 260, delay: i * 50, easing: "cubic-bezier(0.4, 0, 1, 1)", fill: "forwards" }
).finished.catch(noop)
);
});
return Promise.all(waits);
}
function noop() {}
// A card reaching a foundation is the only move in this game that pays you, so
// it's the only one that makes a noise about it.
function flashHome(events) {
if (reduced) return;
var at = 0;
(events || []).forEach(function (e) {
if (e.kind !== "home" || !e.to) {
if (e.kind === "move") at += STEP_MS;
return;
}
var when = at + MOVE_MS;
at += STEP_MS;
setTimeout(function () {
var pile = foundEl.querySelector('[data-pile="' + e.to + '"]');
if (!pile) return;
pile.classList.remove("pete-home-flash");
void pile.offsetWidth; // restart the animation if it's still running
pile.classList.add("pete-home-flash");
}, when);
});
}
// ---- the money -------------------------------------------------------------
// bank moves the spot to what the server says the board is worth. Up is chips
// out of the house's rack; down — a card taken back off a foundation — is chips
// going back to it. Either way the pile moves before the number does.
function bank(pays) {
var delta = (pays || 0) - spot.amount;
if (delta === 0) return Promise.resolve();
if (delta > 0) return spot.pour(houseEl, delta, { gap: 55 });
return spot.sweep(houseEl, -delta, { gap: 40, lift: 0.6, fade: true });
}
// ---- picking cards up ------------------------------------------------------
function mark() {
root.querySelectorAll('[data-held="1"]').forEach(function (el) { delete el.dataset.held; });
root.querySelectorAll('[data-drop="1"]').forEach(function (el) { delete el.dataset.drop; });
if (!held) return;
// The run in your hand lifts off the felt.
root.querySelectorAll('.pete-card[data-pile="' + held.pile + '"]').forEach(function (el) {
if (held.pile === "waste" || held.pile.charAt(0) === "f") {
if (el.dataset.live === "1") el.dataset.held = "1";
} else if (parseInt(el.dataset.idx, 10) >= held.idx) {
el.dataset.held = "1";
}
});
// And everywhere it could go lights up. This is the whole reason the rules are
// mirrored over here: being shown where a card goes is the game teaching you,
// and being told no after you commit is the game scolding you.
root.querySelectorAll("[data-pile]").forEach(function (el) {
var pile = el.dataset.pile;
if (pile === held.pile || pile === "waste" || el.classList.contains("pete-card")) return;
if (accepts(pile, held.cards)) el.dataset.drop = "1";
});
}
function pick(pile, idx) {
var cs = cardsAt(pile, idx);
if (!cs || !isRun(cs)) return;
held = { pile: pile, idx: idx, count: cs.length, cards: cs };
mark();
}
function drop() {
held = null;
mark();
}
function nope(el) {
if (!el || reduced) return;
el.classList.remove("pete-nope");
void el.offsetWidth;
el.classList.add("pete-nope");
}
// A click on the board. The order matters: if something is in your hand and the
// thing you clicked will take it, that's a move — otherwise it's you picking up
// something else. Which means you never have to put a card down before choosing
// a different one.
root.querySelector(".pete-felt").addEventListener("click", function (e) {
if (busy || !board || board.phase !== "playing") return;
if (e.target.closest("[data-stock]")) return; // the stock has its own handler
var pileEl = e.target.closest("[data-pile]");
if (!pileEl) { drop(); return; }
var cardEl = e.target.closest(".pete-card[data-key]");
var pile = pileEl.dataset.pile;
if (held) {
if (pile === held.pile) { drop(); return; } // clicking your own run puts it down
if (accepts(pile, held.cards)) {
var move = { kind: "move", from: held.pile, to: pile, count: held.count };
drop();
send(move);
return;
}
// Not a place it goes. If what you clicked is a card you *could* pick up,
// this was a change of mind rather than a bad move; only shake at a genuine
// dead end.
if (!cardEl || cardEl.dataset.live !== "1") { nope(pileEl); return; }
}
if (cardEl && cardEl.dataset.live === "1") {
pick(pile, parseInt(cardEl.dataset.idx, 10));
} else {
drop();
}
});
// Double-click sends a card home. It's the idiom every solitaire has used for
// thirty years, and the alternative is asking the player which foundation — a
// question with exactly one right answer.
root.addEventListener("dblclick", function (e) {
if (busy || !board || board.phase !== "playing") return;
var cardEl = e.target.closest('.pete-card[data-live="1"]');
if (!cardEl) return;
var pile = cardEl.dataset.pile;
if (pile.charAt(0) === "f") return; // it's already home
e.preventDefault();
drop();
send({ kind: "home", from: pile });
});
stockEl.addEventListener("click", function () {
if (busy || !board || board.phase !== "playing") return;
if (stockEl.dataset.dead === "1") {
say(gameMsg, "That was your last pass through the stock.", "bad");
nope(stockEl);
return;
}
drop();
send({ kind: "draw" });
});
autoBtn.addEventListener("click", function () { drop(); send({ kind: "auto" }); });
cashBtn.addEventListener("click", function () {
drop();
send({ kind: "concede" });
});
document.addEventListener("keydown", function (e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (/^(input|textarea|select)$/i.test(e.target.tagName || "")) return;
if (e.key === "Escape") { drop(); return; }
if (busy || !board || board.phase !== "playing") return;
var k = e.key.toLowerCase();
if (k === " " || k === "d") { e.preventDefault(); stockEl.click(); }
else if (k === "a" && !autoBtn.disabled) { e.preventDefault(); autoBtn.click(); }
});
// ---- talking to the table --------------------------------------------------
var VERDICTS = {
cleared: "Cleared the board! 🎉",
cashed: "Board cashed.",
};
function verdict(v) {
var text = VERDICTS[v.outcome] || "";
if (!text) { verdictEl.classList.add("hidden"); return; }
if (v.net > 0) text += " +" + v.net.toLocaleString();
else if (v.net < 0) text += " " + v.net.toLocaleString();
verdictEl.textContent = text;
verdictEl.classList.remove("hidden");
// Clearing 52 cards out of a Vegas deal is the rarest thing that happens in
// this room, so it's the one that gets the confetti.
if (v.outcome === "cleared") FX.burst(verdictEl, { count: 40 });
}
// play walks a server response onto the felt: the board is re-rendered, the
// cards animate from where they were, and the chips follow.
//
// `money` — the chip bar catching up — is held back on a *settling* board until
// the payout has physically swept home. A counter that pays you before the chips
// arrive is a counter that has told you the ending.
function play(view, money) {
var v = view.solitaire;
var events = view.sol_events || [];
var settles = !!v && v.phase === "done";
var recycled = events.some(function (e) { return e.kind === "recycle"; });
var pre = recycled ? recycleOut() : Promise.resolve();
return pre
.then(function () {
var before = snapshot();
render(v);
var plan = planOf(events);
flashHome(events);
return animate(before, plan);
})
.then(function () {
if (!v) { money(); return; }
return bank(v.stands).then(function () {
if (!settles) { money(); return; }
// The board is finished. Everything banked comes home, and only then
// does the number in the bar move.
verdict(v);
return wait(300)
.then(function () { return spot.sweep(purseEl, v.payout, { gap: 40, lift: 0.8 }); })
.then(money)
.then(function () { controls(null); showBet(); });
});
});
}
function send(move) {
if (busy) return;
busy = true;
say(gameMsg, "");
return window.PeteGames.post("/api/games/solitaire/move", move)
.then(function (view) { return play(view, function () { window.PeteGames.apply(view); }); })
.catch(function (err) {
say(gameMsg, err.message, "bad");
// Whatever this file thought the board was, the server is the authority on
// it. Ask, and draw what it says.
return window.PeteGames.refresh().then(function (v) {
if (v) { render(v.solitaire || null); spot.render(v.solitaire ? v.solitaire.stands : 0); }
});
})
.then(function () { busy = false; });
}
// ---- buying a deck ---------------------------------------------------------
function showBet() {
betAmountEl.textContent = bet.toLocaleString();
var money = window.PeteGames.view();
startBtn.disabled = bet <= 0 || !tier || !money || money.chips < bet;
}
root.querySelectorAll("[data-tier]").forEach(function (btn) {
btn.addEventListener("click", function () {
tier = btn.dataset.tier;
root.querySelectorAll("[data-tier]").forEach(function (b) {
b.dataset.on = b === btn ? "1" : "0";
});
showBet();
});
});
// The chip you click is the chip that flies. It lands on the spot in the rail,
// which is where the price of the deck is stacked up before you pay it.
root.querySelectorAll("[data-chip]").forEach(function (btn) {
if (!btn.dataset.chip || btn.tagName !== "BUTTON") return;
btn.addEventListener("click", function () {
if (busy) return;
var d = parseInt(btn.dataset.chip, 10);
var money = window.PeteGames.view();
if (money && bet + d > money.chips) {
say(tableMsg, "You haven't got that many chips.", "bad");
return;
}
bet += d;
showBet();
var target = bet;
spot.amount = bet;
FX.fly(btn, spotEl, { denom: d }).then(function () {
if (bet >= target) spot.render(target); // unless Clear got there first
});
});
});
root.querySelector("[data-bet-clear]").addEventListener("click", function () {
if (busy) return;
if (spot.amount) spot.sweep(purseEl, null, { gap: 40, lift: 0.7 });
bet = 0;
showBet();
});
startBtn.addEventListener("click", function () {
if (busy) return;
if (!tier) { say(tableMsg, "Pick a deal first.", "bad"); return; }
if (bet <= 0) { say(tableMsg, "Put something down for the deck.", "bad"); return; }
busy = true;
say(tableMsg, "");
var price = bet;
window.PeteGames.post("/api/games/solitaire/start", { bet: price, tier: tier })
.then(function (view) {
// The deck is *bought*: the chips on the spot go across to the house and
// do not come back. Then the spot starts again at nothing, and from here
// on it holds what the board has earned back.
return spot
.sweep(houseEl, price, { gap: 45, lift: 0.6 })
.then(function () {
bet = 0;
showBet();
window.PeteGames.apply(view);
return dealOut(view.solitaire);
});
})
.catch(function (err) {
say(tableMsg, err.message, "bad");
return window.PeteGames.refresh();
})
.then(function () { busy = false; });
});
// dealOut lays the board and flies every card onto it out of the stock, one at a
// time, across the columns — the way a deal actually goes down.
function dealOut(v) {
render(v);
if (reduced || !v) return Promise.resolve();
var from = stockEl.getBoundingClientRect();
var waits = [];
// The order a real deal goes in: one card to each column, then round again,
// starting a column further along each time.
var order = 0;
for (var row = 0; row < 7; row++) {
for (var col = row; col < 7; col++) {
var colEl = tableauEl.children[col];
var cardEl = colEl.children[row];
if (!cardEl) continue;
var now = cardEl.getBoundingClientRect();
waits.push(slide(cardEl, from.left - now.left, from.top - now.top, order * 34));
if (cardEl.dataset.face === "up") waits.push(turn(cardEl, order * 34));
order++;
}
}
return Promise.all(waits);
}
// ---- coming in ------------------------------------------------------------
// The money bar owns the first fetch. The table picks up whatever it found —
// including a board left mid-game by a reload or a redeploy, which comes back
// exactly as it was, right down to what it has banked.
var resumed = false;
window.PeteGames.onUpdate(function (v) {
if (!resumed) {
resumed = true;
if (v.solitaire) {
render(v.solitaire);
spot.render(v.solitaire.stands);
} else {
controls(null);
}
}
showBet();
});
})();

View File

@@ -127,6 +127,7 @@
{{define "scripts"}}
<script src="/static/js/casino-fx.js" defer></script>
<script src="/static/js/casino-cards.js" defer></script>
<script src="/static/js/games.js" defer></script>
<script src="/static/js/blackjack.js" defer></script>
{{end}}

View File

@@ -58,6 +58,22 @@
</p>
</a>
<a href="/games/solitaire"
class="group rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10 hover:-translate-y-0.5 hover:shadow-pete-lg transition">
<div class="flex items-center gap-3">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[color:var(--accent)]/25 text-2xl">🂡</span>
<div class="min-w-0">
<h3 class="font-display text-xl font-bold">Solitaire</h3>
<p class="text-sm text-[color:var(--ink)]/60">Buy the deck, win it back a card at a time.</p>
</div>
<span class="ml-auto shrink-0 rounded-full bg-theme-gaming px-3 py-1 text-xs font-bold uppercase tracking-wider text-white">Open</span>
</div>
<p class="mt-4 text-sm text-[color:var(--ink)]/70">
Vegas rules. Your stake buys the deck and doesn't come back — every card you
get home pays a slice of it in. Cash the board whenever you like.
</p>
</a>
{{range .Soon}}
<div class="rounded-3xl bg-[color:var(--card)]/60 p-6 shadow-pete border-2 border-dashed border-[color:var(--ink)]/15">
<div class="flex items-center gap-3">

View File

@@ -0,0 +1,168 @@
{{define "title"}}Solitaire · {{.Room.Name}}{{end}}
{{define "main"}}
<div class="space-y-6" data-solitaire>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<a href="/games" class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[color:var(--card)] shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition" title="Back to the casino">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5" aria-hidden="true">
<path d="M19 12H5"></path><polyline points="12 19 5 12 12 5"></polyline>
</svg>
<span class="sr-only">Back to the casino</span>
</a>
<h1 class="font-display text-3xl font-bold">Solitaire</h1>
</div>
<p class="text-sm text-[color:var(--ink)]/50">You buy the deck. Every card you get home buys some of it back.</p>
</div>
{{template "_chipbar" .}}
<!-- The felt. The board takes the room; the money lives in a rail down the
right of it, where the house rack can't collide with the foundations. -->
<section class="pete-felt pete-solitaire relative overflow-hidden rounded-3xl p-4 sm:p-6 lg:p-8 shadow-pete-lg border-2 border-[color:var(--ink)]/10">
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr),auto] lg:gap-8">
<!-- The board. -->
<div class="min-w-0 space-y-4 sm:space-y-6">
<!-- The stock and the waste on the left, the foundations on the right,
exactly where a real layout puts them. -->
<div class="flex items-start justify-between gap-3">
<div class="flex items-start gap-2 sm:gap-3">
<button type="button" data-stock class="pete-slot pete-stock" aria-label="Turn over the stock">
<span data-stock-count class="pete-slot-count">0</span>
<span data-stock-recycle class="pete-slot-recycle hidden" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path><polyline points="21 3 21 8 16 8"></polyline>
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path><polyline points="3 21 3 16 8 16"></polyline>
</svg>
</span>
</button>
<div data-waste class="pete-waste" aria-label="The waste"></div>
</div>
<div data-foundations class="flex items-start gap-1.5 sm:gap-2" aria-label="The foundations"></div>
</div>
<!-- The seven columns. -->
<div data-tableau class="pete-tableau" aria-label="The tableau"></div>
<!-- What just happened. -->
<div class="flex min-h-[2.75rem] items-center">
<p data-verdict class="hidden rounded-full bg-white/95 px-5 py-2 font-display text-lg font-bold text-[#2b2118] shadow-pete"></p>
</div>
</div>
<!-- The rail: the house's rack, what you've banked, and the meter that
reads it. Everything the money does happens between these two. -->
<aside class="pete-rail">
<div class="pete-rack" data-at="rail" data-house aria-hidden="true">
<span data-chip="500" style="--stack: 5"></span>
<span data-chip="100" style="--stack: 7"></span>
<span data-chip="25" style="--stack: 4"></span>
<span data-chip="5" style="--stack: 6"></span>
</div>
<div class="pete-spot" data-spot>
<span class="pete-spot-label">Banked</span>
<div class="pete-stack" data-stack></div>
<span data-spot-total class="pete-spot-total hidden"></span>
</div>
<div class="pete-meter w-full justify-center" data-meter>
<span class="pete-meter-label">Home</span>
<span data-home class="pete-meter-value">0<span class="text-white/40">/{{.FullDeck}}</span></span>
</div>
<p class="text-center text-xs leading-relaxed text-white/55">
<span data-per-card class="font-bold text-white/85"></span> a card<br>
<span data-break-even></span>
</p>
</aside>
</div>
</section>
<!-- Playing: shown while a board is live. -->
<section data-playing class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<div class="flex flex-wrap items-center gap-3">
<button type="button" data-auto
class="rounded-full bg-[color:var(--ink)]/5 px-5 py-2.5 font-display font-bold border-2 border-[color:var(--ink)]/10
hover:bg-[color:var(--ink)]/10 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Send home ⤴
</button>
<p class="text-xs text-[color:var(--ink)]/45">
Click a card, then where it goes. Double-click sends it home.
</p>
<button type="button" data-cash
class="ml-auto rounded-full bg-[color:var(--accent)] px-6 py-2.5 font-display font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Cash the board · <span data-cash-amount class="tabular-nums">0</span>
</button>
</div>
<p data-game-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
</section>
<!-- Buying a deck: shown between games. -->
<section data-betting class="rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Which deal?</div>
<div class="mt-2 grid gap-2 sm:grid-cols-3">
{{range .Deals}}
<button type="button" data-tier="{{.Slug}}"
class="pete-tier rounded-2xl border-2 p-3 text-left transition">
<div class="flex items-baseline justify-between gap-2">
<span class="font-display text-lg font-bold">{{.Name}}</span>
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{printf "%.1f" .Base}}×</span>
</div>
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
<p class="mt-1.5 text-xs font-semibold text-[color:var(--ink)]/40">
Square with the house at {{.BreakEven}} cards home.
</p>
</button>
{{end}}
</div>
<div class="mt-5 flex flex-wrap items-center gap-x-6 gap-y-4">
<div>
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">The deck costs</div>
<div class="font-display text-3xl font-bold tabular-nums"><span data-bet-amount>0</span></div>
</div>
<div class="flex flex-wrap items-center gap-2">
{{range .Denominations}}
<button type="button" data-chip="{{.}}" aria-label="Put {{.}} more down"
class="pete-chip pete-disc grid h-12 w-12 place-items-center font-display text-sm font-bold text-white">
<span>{{.}}</span>
</button>
{{end}}
<button type="button" data-bet-clear
class="rounded-full px-3 py-2 text-sm font-semibold text-[color:var(--ink)]/50 hover:text-[color:var(--ink)] transition">Clear</button>
</div>
<button type="button" data-start
class="ml-auto rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Buy the deck
</button>
</div>
<p class="mt-3 text-xs text-[color:var(--ink)]/40">
The stake buys the deck outright, so it doesn't come back. What comes back is
whatever you get home, a fifty-second of the multiple at a time. Stop whenever
you like and keep it. There's no undo.
</p>
<p data-table-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
</section>
</div>
{{end}}
{{define "scripts"}}
<script src="/static/js/casino-fx.js" defer></script>
<script src="/static/js/casino-cards.js" defer></script>
<script src="/static/js/games.js" defer></script>
<script src="/static/js/solitaire.js" defer></script>
{{end}}