From 5ca056bf204f148e1f847f76a962a35fe04a2c50 Mon Sep 17 00:00:00 2001
From: prosolis <5590409+prosolis@users.noreply.github.com>
Date: Tue, 14 Jul 2026 01:40:14 -0700
Subject: [PATCH] games: you buy the deck, and win it back a card at a time
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
internal/games/klondike/klondike.go | 684 +++++++++++++++++++++
internal/games/klondike/klondike_test.go | 730 +++++++++++++++++++++++
internal/web/games_pages.go | 18 +-
internal/web/games_play.go | 14 +-
internal/web/games_solitaire.go | 286 +++++++++
internal/web/server.go | 2 +-
internal/web/static/css/input.css | 214 ++++++-
internal/web/static/css/output.css | 2 +-
internal/web/static/js/blackjack.js | 239 +-------
internal/web/static/js/casino-cards.js | 172 ++++++
internal/web/static/js/casino-fx.js | 73 +++
internal/web/static/js/solitaire.js | 729 ++++++++++++++++++++++
internal/web/templates/blackjack.html | 1 +
internal/web/templates/games.html | 16 +
internal/web/templates/solitaire.html | 168 ++++++
pete_games_plan.md | 53 +-
16 files changed, 3185 insertions(+), 216 deletions(-)
create mode 100644 internal/games/klondike/klondike.go
create mode 100644 internal/games/klondike/klondike_test.go
create mode 100644 internal/web/games_solitaire.go
create mode 100644 internal/web/static/js/casino-cards.js
create mode 100644 internal/web/static/js/solitaire.js
create mode 100644 internal/web/templates/solitaire.html
diff --git a/internal/games/klondike/klondike.go b/internal/games/klondike/klondike.go
new file mode 100644
index 0000000..ccccbf4
--- /dev/null
+++ b/internal/games/klondike/klondike.go
@@ -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
+}
diff --git a/internal/games/klondike/klondike_test.go b/internal/games/klondike/klondike_test.go
new file mode 100644
index 0000000..76d8b10
--- /dev/null
+++ b/internal/games/klondike/klondike_test.go
@@ -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)
+ }
+ }
+ }
+ }
+}
diff --git a/internal/web/games_pages.go b/internal/web/games_pages.go
index 88f335a..6846823 100644
--- a/internal/web/games_pages.go
+++ b/internal/web/games_pages.go
@@ -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))
+}
diff --git a/internal/web/games_play.go b/internal/web/games_play.go
index d3961ab..4027ef7 100644
--- a/internal/web/games_play.go
+++ b/internal/web/games_play.go
@@ -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
diff --git a/internal/web/games_solitaire.go b/internal/web/games_solitaire.go
new file mode 100644
index 0000000..445d40f
--- /dev/null
+++ b/internal/web/games_solitaire.go
@@ -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)
+}
diff --git a/internal/web/server.go b/internal/web/server.go
index 4330b2b..04d3b5b 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -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 {
diff --git a/internal/web/static/css/input.css b/internal/web/static/css/input.css
index aef6d05..aafbefd 100644
--- a/internal/web/static/css/input.css
+++ b/internal/web/static/css/input.css
@@ -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; }
}
}
diff --git a/internal/web/static/css/output.css b/internal/web/static/css/output.css
index 87e522e..1c313e0 100644
--- a/internal/web/static/css/output.css
+++ b/internal/web/static/css/output.css
@@ -1 +1 @@
-*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}body{font-family:Nunito,system-ui,sans-serif}html{scroll-behavior:smooth}body,html{overflow-x:hidden}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.pete-channel-nav{scrollbar-width:none;-ms-overflow-style:none}.pete-channel-nav::-webkit-scrollbar{display:none}.pete-reader-backdrop{position:absolute;inset:0;background:rgba(20,14,6,.55);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}html[data-phase=night] .pete-reader-backdrop{background:rgba(6,8,20,.65)}.pete-reader-shell{position:relative;height:100%;width:100%;max-width:44rem;margin:0 auto;display:flex;flex-direction:column;padding:max(env(safe-area-inset-top),.75rem) 1rem .75rem;gap:.75rem}.pete-reader-bar{display:flex;align-items:center;justify-content:space-between;gap:.75rem;color:#fff}.pete-reader-progress{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:600;font-size:.85rem;letter-spacing:.02em;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-nav{display:flex;align-items:center;gap:.4rem}.pete-reader-btn{display:inline-flex;align-items:center;justify-content:center;min-width:2.25rem;height:2.25rem;padding:0 .7rem;border-radius:9999px;background:hsla(0,0%,100%,.14);color:#fff;font-weight:700;font-size:.95rem;border:2px solid hsla(0,0%,100%,.18);cursor:pointer;transition:background .15s ease,transform .15s ease,opacity .15s ease}.pete-reader-btn:hover{background:hsla(0,0%,100%,.28);transform:translateY(-1px)}.pete-reader-btn:disabled{opacity:.35;cursor:default;transform:none}.pete-reader-btn-open{background:var(--accent);border-color:transparent;color:#1c1305;text-decoration:none}.pete-reader-btn-open:hover{background:var(--accent);filter:brightness(1.08)}.pete-reader-btn[aria-expanded=true],.pete-reader-btn[aria-pressed=true]{background:var(--accent);border-color:transparent;color:#1c1305}.pete-reader-type{position:relative;display:inline-flex}.pete-reader-typemenu[hidden]{display:none}.pete-reader-typemenu{position:absolute;top:calc(100% + .5rem);right:0;z-index:10;width:15rem;padding:.75rem;border-radius:1rem;background:var(--card);color:var(--ink);border:2px solid rgba(0,0,0,.08);box-shadow:0 10px 30px rgba(20,14,6,.28);display:flex;flex-direction:column;gap:.6rem}html[data-phase=night] .pete-reader-typemenu{border-color:hsla(0,0%,100%,.12)}.pete-reader-typerow{display:flex;align-items:center;justify-content:space-between;gap:.6rem}.pete-reader-typelabel{font-size:.8rem;font-weight:700;opacity:.7}.pete-reader-typebtns{display:inline-flex;gap:.25rem}.pete-reader-typebtns button{min-width:2rem;height:2rem;padding:0 .55rem;border-radius:.6rem;background:rgba(20,14,6,.06);color:var(--ink);font-weight:700;font-size:.82rem;border:2px solid transparent;cursor:pointer;transition:background .15s ease}html[data-phase=night] .pete-reader-typebtns button{background:hsla(0,0%,100%,.08)}.pete-reader-typebtns button:hover{background:rgba(20,14,6,.12)}.pete-reader-typebtns button.is-active{background:var(--accent);color:#1c1305;border-color:transparent}.pete-reader-voicerow[hidden]{display:none}.pete-reader-voice{font:inherit;font-size:.8rem;font-weight:600;padding:.25rem .4rem;border-radius:.55rem;border:1px solid rgba(20,14,6,.18);background:rgba(20,14,6,.06);color:inherit;max-width:11rem;cursor:pointer}html[data-phase=night] .pete-reader-voice{border-color:hsla(0,0%,100%,.14);background:hsla(0,0%,100%,.08)}.pete-reader-scroll{flex:1 1 auto;overflow-y:auto;-webkit-overflow-scrolling:touch;border-radius:1.75rem}.pete-reader-scroll:focus{outline:none}.pete-reader-article{background:var(--card);color:var(--ink);border-radius:1.75rem;border:2px solid rgba(0,0,0,.06);box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.18);padding:clamp(1.25rem,4vw,2.5rem)}html[data-phase=night] .pete-reader-article{border-color:hsla(0,0%,100%,.08)}.pete-reader-eyebrow{display:flex;align-items:center;flex-wrap:wrap;gap:.5rem;font-size:.75rem;margin-bottom:.85rem}.pete-reader-chip{display:inline-flex;align-items:center;gap:.35rem;border-radius:9999px;padding:.15rem .65rem;color:#fff;font-weight:700;text-transform:uppercase;letter-spacing:.06em}.pete-reader-source{font-weight:700;opacity:.75}.pete-reader-time{opacity:.55}.pete-reader-title{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:700;line-height:1.15;letter-spacing:-.01em;font-size:clamp(1.6rem,4.5vw,2.4rem);margin-bottom:1rem}.pete-reader-hero{width:100%;border-radius:1.1rem;margin-bottom:1.25rem;background:rgba(0,0,0,.05)}.pete-reader-scroll{--reader-fs:1.08rem}.pete-reader-body{font-size:var(--reader-fs);line-height:1.75}.pete-reader-body p{margin-bottom:1.05em}.pete-reader-body p:last-child{margin-bottom:0}.pete-reader-scroll.is-size-s{--reader-fs:0.98rem}.pete-reader-scroll.is-size-m{--reader-fs:1.08rem}.pete-reader-scroll.is-size-l{--reader-fs:1.22rem}.pete-reader-scroll.is-size-xl{--reader-fs:1.4rem}.pete-reader-scroll.is-serif .pete-reader-body{font-family:Georgia,Iowan Old Style,Times New Roman,serif}.pete-reader-scroll.is-sepia .pete-reader-article{background:#f6ecd6;color:#4a3a28;border-color:rgba(74,58,40,.16)}.pete-reader-scroll.is-sepia .pete-reader-note{border-top-color:rgba(74,58,40,.18);opacity:.8}.pete-reader-scroll.is-sepia .pete-reader-hero{background:rgba(74,58,40,.08)}.pete-reader-body p.is-speaking{background:color-mix(in srgb,var(--accent) 32%,transparent);border-radius:.4rem;box-shadow:0 0 0 .35rem color-mix(in srgb,var(--accent) 32%,transparent)}.pete-reader-note{margin-top:1.25rem;padding-top:1rem;border-top:1px solid rgba(0,0,0,.1);font-size:.85rem;opacity:.7}html[data-phase=night] .pete-reader-note{border-color:hsla(0,0%,100%,.12)}.pete-reader-note a{color:var(--accent);font-weight:700}.pete-reader-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:.75rem;padding:clamp(2rem,8vw,4rem) 1rem;color:var(--ink)}.pete-reader-empty-emoji{font-size:2.5rem}.pete-reader-toast{position:absolute;left:50%;bottom:4.5rem;transform:translate(-50%,.5rem);z-index:20;padding:.5rem .9rem;border-radius:9999px;background:rgba(20,14,6,.9);color:#fff;font-weight:700;font-size:.85rem;box-shadow:0 8px 24px rgba(0,0,0,.3);opacity:0;pointer-events:none;transition:opacity .2s ease,transform .2s ease}.pete-reader-toast.is-shown{opacity:1;transform:translate(-50%)}.pete-reader-hint{text-align:center;color:hsla(0,0%,100%,.85);font-size:.75rem;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-hint kbd{font-family:ui-monospace,monospace;background:hsla(0,0%,100%,.16);border-radius:.3rem;padding:.05rem .3rem}@media (max-width:640px){.pete-reader-hint{display:none}.pete-reader-bar{flex-wrap:wrap}.pete-reader-nav{flex-wrap:wrap;justify-content:flex-end;row-gap:.4rem}}.pete-reader-related{width:100%;margin:.85rem auto 0}.pete-reader-related-title{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:700;font-size:.95rem;color:#fff;margin:0 0 .6rem;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-related-grid{display:grid;gap:.6rem}.pete-reader-related-card{display:flex;align-items:center;gap:.75rem;background:var(--card);color:var(--ink);border-radius:1rem;border:2px solid rgba(0,0,0,.06);padding:.6rem;text-decoration:none;transition:transform .15s ease,box-shadow .15s ease}.pete-reader-related-card:hover{transform:translateY(-1px);box-shadow:0 6px 16px rgba(60,40,20,.16)}html[data-phase=night] .pete-reader-related-card{border-color:hsla(0,0%,100%,.08)}.pete-reader-related-thumb{width:4.5rem;height:3.25rem;flex-shrink:0;-o-object-fit:cover;object-fit:cover;border-radius:.6rem;background:rgba(0,0,0,.06)}.pete-reader-related-meta{min-width:0;display:flex;flex-direction:column;gap:.25rem}.pete-reader-related-eyebrow{display:flex;align-items:center;flex-wrap:wrap;gap:.4rem;font-size:.7rem}.pete-reader-related-source{font-weight:700;opacity:.7}.pete-reader-related-headline{font-weight:700;line-height:1.25;font-size:.92rem;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}[data-story-card][data-read="1"]{opacity:.5;transition:opacity .2s ease}[data-story-card][data-read="1"]:hover{opacity:1}[data-story-card][data-read="1"]:after{content:"✓";position:absolute;top:.6rem;right:.6rem;z-index:6;display:grid;place-items:center;height:1.5rem;width:1.5rem;border-radius:9999px;background:rgba(20,14,6,.72);color:#fff;font-size:.8rem;font-weight:700}.pete-felt{background:radial-gradient(120% 90% at 50% -10%,hsla(0,0%,100%,.1),transparent 60%),linear-gradient(160deg,#2f7d5b,#24614a 55%,#1c4d3c)}.pete-shoe{position:absolute;top:1.25rem;right:1.25rem;height:4.2rem;width:3rem;border-radius:.55rem;background:linear-gradient(150deg,#b4553f,#8d3f2f);border:2px solid rgba(0,0,0,.18);box-shadow:0 4px 0 rgba(0,0,0,.18),inset 0 0 0 3px hsla(0,0%,100%,.12)}.pete-shoe:after{content:"";position:absolute;inset:.45rem .4rem auto .4rem;height:.5rem;border-radius:.2rem;background:rgba(0,0,0,.22)}.pete-hand{display:flex;flex-wrap:wrap;gap:.6rem;min-height:8.6rem}.pete-card{perspective:700px;height:8.4rem;width:6rem;animation:pete-deal .42s cubic-bezier(.22,1,.36,1) backwards}.pete-card-inner{position:relative;height:100%;width:100%;transform-style:preserve-3d;transition:transform .45s cubic-bezier(.4,0,.2,1)}.pete-card[data-face=down] .pete-card-inner{transform:rotateY(180deg)}.pete-card-back,.pete-card-front{position:absolute;inset:0;display:grid;border-radius:.55rem;backface-visibility:hidden;-webkit-backface-visibility:hidden;box-shadow:0 3px 0 rgba(0,0,0,.18),0 6px 14px rgba(0,0,0,.22)}.pete-card-front{place-items:stretch;background:#fdfaf2;border:2px solid rgba(30,20,10,.12);color:#2b2118;font-family:Fredoka,Nunito,system-ui,sans-serif;line-height:1;overflow:hidden}.pete-card-front[data-red="1"]{color:#cc3d4a}.pete-card-back{transform:rotateY(180deg);background:repeating-linear-gradient(45deg,hsla(0,0%,100%,.1) 0 6px,transparent 6px 12px),linear-gradient(150deg,#b4553f,#8d3f2f);border:2px solid rgba(0,0,0,.15)}.pete-card-svg{width:100%;height:100%;display:block;fill:currentColor}.pete-card-idx{font-size:26px;font-weight:700;text-anchor:middle;fill:currentColor}.pete-card-panel{fill:currentColor;fill-opacity:.06;stroke:currentColor;stroke-opacity:.35;stroke-width:1.5}.pete-card-court{font-size:34px;font-weight:700;text-anchor:middle;fill:currentColor}@keyframes pete-deal{0%{opacity:0;transform:translate(var(--deal-x,14rem),var(--deal-y,-7rem)) scale(.72) rotate(9deg)}62%{opacity:1;transform:translateY(.35rem) scale(1.05) rotate(calc(var(--tilt, 0deg) - 2deg))}to{opacity:1;transform:translate(0) scale(1) rotate(var(--tilt,0deg))}}.pete-card:after{content:"";position:absolute;inset:auto 6% -.35rem 6%;height:.6rem;border-radius:999px;background:rgba(0,0,0,.35);filter:blur(5px);animation:pete-thud .42s cubic-bezier(.22,1,.36,1) backwards}@keyframes pete-thud{0%,55%{opacity:0;transform:scale(.4)}72%{opacity:.9;transform:scale(1.15)}to{opacity:.45;transform:scale(1)}}.pete-hand[data-won="1"] .pete-card{animation:pete-deal .42s cubic-bezier(.22,1,.36,1) backwards,pete-win .6s ease .1s}@keyframes pete-win{0%,to{transform:rotate(var(--tilt,0deg)) translateY(0)}40%{transform:rotate(var(--tilt,0deg)) translateY(-.6rem)}}.pete-hand[data-won="-1"] .pete-card{filter:saturate(.45) brightness(.78);transition:filter .5s ease .2s}.pete-disc{position:relative;border-radius:999px;background:radial-gradient(circle at 50% 30%,hsla(0,0%,100%,.3),transparent 62%),var(--chip,#e07a5f);border:2px solid rgba(0,0,0,.22);box-shadow:0 3px 0 rgba(0,0,0,.28),0 6px 14px rgba(0,0,0,.2)}.pete-disc:before{content:"";position:absolute;inset:11%;border-radius:999px;border:2px dashed hsla(0,0%,100%,.55)}.pete-disc[data-chip="5"]{--chip:#5aa9e6}.pete-disc[data-chip="25"]{--chip:#4caf7d}.pete-disc[data-chip="100"]{--chip:#2b2118}.pete-disc[data-chip="500"]{--chip:#b079d6}.pete-chip{transition:transform .12s ease}.pete-chip>span{position:relative}.pete-chip:active{transform:translateY(1px) scale(.94)}.pete-spot{position:relative;display:grid;place-items:center;height:7rem;width:7rem;flex:none;border-radius:999px;border:2px dashed hsla(0,0%,100%,.35);background:radial-gradient(circle at 50% 40%,hsla(0,0%,100%,.1),transparent 70%);box-shadow:inset 0 0 0 6px rgba(0,0,0,.08);transition:box-shadow .3s ease,border-color .3s ease}.pete-spot[data-live="1"]{border-color:rgba(var(--glow,242,181,61),.85);box-shadow:inset 0 0 0 6px rgba(0,0,0,.08),0 0 22px rgba(var(--glow,242,181,61),.35)}.pete-spot-label{font-size:.65rem;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:hsla(0,0%,100%,.35)}.pete-spot[data-live="1"] .pete-spot-label{opacity:0}.pete-stack{position:absolute;inset:0;pointer-events:none}.pete-stack .pete-disc{position:absolute;left:50%;top:50%;height:2.6rem;width:2.6rem;margin:-1.3rem 0 0 -1.3rem;transform:translateY(calc(-.5rem*var(--i, 0))) rotate(var(--spin,0deg));box-shadow:0 2px 0 rgba(0,0,0,.35),0 4px 8px rgba(0,0,0,.25);animation:pete-chip-land .28s cubic-bezier(.34,1.56,.64,1) backwards}@keyframes pete-chip-land{0%{transform:translateY(calc(-.5rem*var(--i, 0) - 1.1rem)) rotate(var(--spin,0deg)) scale(1.18)}}.pete-spot-total{position:absolute;bottom:-.6rem;left:50%;transform:translateX(-50%);white-space:nowrap;border-radius:999px;background:rgba(0,0,0,.45);padding:.1rem .6rem;font-size:.72rem;font-weight:700;font-variant-numeric:tabular-nums;color:#fff}.pete-fly-layer{position:fixed;inset:0;z-index:60;pointer-events:none;overflow:hidden}.pete-fly{position:absolute;height:2.6rem;width:2.6rem;margin:-1.3rem 0 0 -1.3rem;will-change:transform}.pete-rack{position:absolute;top:1.25rem;right:5.75rem;display:flex;align-items:flex-end;gap:.4rem;padding:.5rem .6rem .45rem;border-radius:.75rem;background:rgba(0,0,0,.2);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.06)}.pete-rack span{display:block;width:1.7rem;border-radius:.35rem;background:linear-gradient(90deg,rgba(0,0,0,.28),transparent 35%,transparent 65%,rgba(0,0,0,.28)),repeating-linear-gradient(180deg,hsla(0,0%,100%,.18) 0 .06rem,var(--chip,#e07a5f) .06rem .3rem,rgba(0,0,0,.35) .3rem .36rem);border:1px solid rgba(0,0,0,.35);height:calc(.36rem*var(--stack, 3))}.pete-rack span[data-chip="5"]{--chip:#5aa9e6}.pete-rack span[data-chip="25"]{--chip:#4caf7d}.pete-rack span[data-chip="100"]{--chip:#2b2118}.pete-rack span[data-chip="500"]{--chip:#b079d6}.pete-spark{position:absolute;height:.75rem;width:.45rem;border-radius:2px;box-shadow:0 1px 3px rgba(0,0,0,.35);will-change:transform,opacity}.pete-dealer-think{animation:pete-think .9s ease-in-out infinite}@keyframes pete-think{0%,to{opacity:.6}50%{opacity:1;transform:translateY(-1px)}}.pete-gallows{overflow:visible;fill:none;stroke-linecap:round;stroke-linejoin:round}.pete-gallows-frame path{stroke:rgba(0,0,0,.35);stroke-width:6}.pete-gallows-frame path:last-child{stroke:rgba(0,0,0,.3);stroke-width:3.5}.pete-gallows-body>*{stroke:#fff;stroke-width:5;opacity:0;filter:drop-shadow(0 2px 0 rgba(0,0,0,.25))}.pete-gallows-body>[data-on="1"]{opacity:1}.pete-part-draw{stroke-dasharray:260;animation:pete-part .45s cubic-bezier(.22,1,.36,1) backwards}@keyframes pete-part{0%{stroke-dashoffset:260}to{stroke-dashoffset:0}}.pete-shake{animation:pete-shake .42s cubic-bezier(.36,.07,.19,.97)}@keyframes pete-shake{10%,90%{transform:translateX(-1.5px)}20%,80%{transform:translateX(3px)}30%,50%,70%{transform:translateX(-5px)}40%,60%{transform:translateX(5px)}}.pete-board{display:flex;flex-wrap:wrap;gap:.35rem 1.1rem;min-height:5rem;align-items:center}.pete-word{display:flex;gap:.3rem}.pete-tile{display:grid;place-items:center;height:2.9rem;width:2.2rem;border-radius:.5rem;font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:1.4rem;font-weight:700;color:#fff;background:rgba(0,0,0,.22);border-bottom:3px solid rgba(0,0,0,.28);text-transform:uppercase}.pete-tile[data-up="1"]{background:hsla(0,0%,100%,.95);color:#2b2118;border-bottom-color:rgba(0,0,0,.35)}.pete-tile[data-punct="1"]{background:none;border:0;width:auto;min-width:.7rem;color:hsla(0,0%,100%,.55)}.pete-tile-hit{animation:pete-tile-hit .34s cubic-bezier(.34,1.56,.64,1)}@keyframes pete-tile-hit{0%{transform:rotateX(90deg) scale(1.1)}to{transform:rotateX(0) scale(1)}}.pete-missed{display:grid;place-items:center;height:1.5rem;min-width:1.5rem;padding:0 .3rem;border-radius:.375rem;background:rgba(0,0,0,.25);font-size:.75rem;font-weight:700;color:hsla(0,0%,100%,.5);text-decoration:line-through;text-transform:uppercase}.pete-meter{display:flex;align-items:baseline;gap:.5rem;border-radius:999px;background:rgba(0,0,0,.28);padding:.4rem .9rem;box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.1);transition:box-shadow .3s ease}.pete-meter-label{font-size:.65rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:hsla(0,0%,100%,.45)}.pete-meter-value{font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:1.5rem;font-weight:700;font-variant-numeric:tabular-nums;color:var(--accent)}.pete-meter[data-cold="1"] .pete-meter-value{color:hsla(0,0%,100%,.55)}.pete-meter[data-hit="1"]{box-shadow:inset 0 0 0 2px rgba(204,61,74,.9);animation:pete-meter-hit .4s ease}@keyframes pete-meter-hit{0%{transform:scale(1)}35%{transform:scale(.94)}to{transform:scale(1)}}.pete-keys{display:grid;gap:.35rem}.pete-key-row{display:flex;justify-content:center;gap:.35rem}.pete-key-row[data-digits="1"]{margin-top:.25rem;opacity:.75}.pete-key-row[data-digits="1"] .pete-key{height:2rem;min-width:1.8rem;font-size:.8rem}.pete-key{height:2.75rem;min-width:2.2rem;flex:0 1 2.4rem;border-radius:.6rem;background:color-mix(in srgb,var(--ink) 6%,transparent);border:2px solid color-mix(in srgb,var(--ink) 10%,transparent);font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-weight:700;color:var(--ink);transition:transform .08s ease,background .15s ease,opacity .15s ease}.pete-key:hover:not(:disabled){background:color-mix(in srgb,var(--ink) 12%,transparent)}.pete-key:active:not(:disabled){transform:translateY(1px) scale(.96)}.pete-key:disabled{cursor:default}.pete-key[data-state=hit]{background:#4caf7d;border-color:#3d9367;color:#fff;opacity:1}.pete-key[data-state=miss]{background:color-mix(in srgb,var(--ink) 4%,transparent);color:color-mix(in srgb,var(--ink) 30%,transparent);text-decoration:line-through}.pete-key:disabled[data-state=""]{opacity:.4}.pete-tier{background:color-mix(in srgb,var(--ink) 4%,transparent);border-color:color-mix(in srgb,var(--ink) 10%,transparent)}.pete-tier:hover{background:color-mix(in srgb,var(--ink) 8%,transparent)}.pete-tier[data-on="1"]{background:color-mix(in srgb,var(--accent) 18%,transparent);border-color:var(--accent)}@media (prefers-reduced-motion:reduce){.pete-card,.pete-card:after,.pete-dealer-think,.pete-stack .pete-disc{animation:none}.pete-card-inner{transition:none}.pete-hand[data-won="1"] .pete-card,.pete-meter[data-hit="1"],.pete-part-draw,.pete-shake,.pete-tile-hit{animation:none}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-right-6{right:-1.5rem}.-top-6{top:-1.5rem}.-z-10{z-index:-10}.-z-\[5\]{z-index:-5}.z-50{z-index:50}.order-none{order:0}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-auto{margin-left:auto;margin-right:auto}.-mt-1{margin-top:-.25rem}.mb-1{margin-bottom:.25rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-8{margin-bottom:2rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mt-0{margin-top:0}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-8{margin-top:2rem}.line-clamp-2{-webkit-line-clamp:2}.line-clamp-2,.line-clamp-3{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical}.line-clamp-3{-webkit-line-clamp:3}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.aspect-\[16\/10\]{aspect-ratio:16/10}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-20{height:5rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-52{height:13rem}.h-6{height:1.5rem}.h-9{height:2.25rem}.h-full{height:100%}.max-h-\[55vh\]{max-height:55vh}.max-h-\[60vh\]{max-height:60vh}.min-h-\[1\.75rem\]{min-height:1.75rem}.min-h-\[2\.75rem\]{min-height:2.75rem}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-2{width:.5rem}.w-2\.5{width:.625rem}.w-20{width:5rem}.w-24{width:6rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-9{width:2.25rem}.w-auto{width:auto}.w-full{width:100%}.min-w-0{min-width:0}.min-w-\[28rem\]{min-width:28rem}.min-w-\[52rem\]{min-width:52rem}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-\[7rem\]{max-width:7rem}.max-w-full{max-width:100%}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.basis-full{flex-basis:100%}.-translate-y-0{--tw-translate-y:-0px}.-translate-y-0,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-10{-moz-column-gap:2.5rem;column-gap:2.5rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-4{row-gap:1rem}.gap-y-8{row-gap:2rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.375rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-transparent{border-color:transparent}.bg-\[color\:var\(--accent\)\]{background-color:var(--accent)}.bg-\[color\:var\(--bg\)\]{background-color:var(--bg)}.bg-\[color\:var\(--bg-grad\)\]{background-color:var(--bg-grad)}.bg-\[color\:var\(--card\)\]{background-color:var(--card)}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-black\/25{background-color:rgba(0,0,0,.25)}.bg-emerald-500{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity,1))}.bg-emerald-500\/15{background-color:rgba(16,185,129,.15)}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/15{background-color:rgba(239,68,68,.15)}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/95{background-color:hsla(0,0%,100%,.95)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-0\.5{padding:.125rem}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0{padding-top:0;padding-bottom:0}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-10{padding-bottom:2.5rem}.pb-20{padding-bottom:5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pr-24{padding-right:6rem}.pt-1{padding-top:.25rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-8{padding-top:2rem}.pt-\[10vh\]{padding-top:10vh}.pt-\[12vh\]{padding-top:12vh}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.align-\[-4px\]{vertical-align:-4px}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12rem\]{font-size:12rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-\[0\.18em\]{letter-spacing:.18em}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-\[\#1c1305\]{--tw-text-opacity:1;color:rgb(28 19 5/var(--tw-text-opacity,1))}.text-\[\#20180c\]{--tw-text-opacity:1;color:rgb(32 24 12/var(--tw-text-opacity,1))}.text-\[\#2b2118\]{--tw-text-opacity:1;color:rgb(43 33 24/var(--tw-text-opacity,1))}.text-\[color\:var\(--accent\)\]{color:var(--accent)}.text-\[color\:var\(--ink\)\]{color:var(--ink)}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-emerald-700{--tw-text-opacity:1;color:rgb(4 120 87/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-white\/40{color:hsla(0,0%,100%,.4)}.text-white\/50{color:hsla(0,0%,100%,.5)}.text-white\/60{color:hsla(0,0%,100%,.6)}.text-white\/85{color:hsla(0,0%,100%,.85)}.text-white\/90{color:hsla(0,0%,100%,.9)}.underline{text-decoration-line:underline}.decoration-4{text-decoration-thickness:4px}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.accent-\[color\:var\(--accent\)\]{accent-color:var(--accent)}.opacity-20{opacity:.2}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.blur{--tw-blur:blur(8px)}.blur,.drop-shadow{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.drop-shadow{--tw-drop-shadow:drop-shadow(0 1px 2px rgba(0,0,0,.1)) drop-shadow(0 1px 1px rgba(0,0,0,.06))}.grayscale{--tw-grayscale:grayscale(100%)}.grayscale,.sepia{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.sepia{--tw-sepia:sepia(100%)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.backdrop-blur-sm,.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-1000{transition-duration:1s}.duration-500{transition-duration:.5s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}.font-display{font-family:Fredoka,Nunito,system-ui,sans-serif;letter-spacing:-.01em}.shadow-pete{box-shadow:0 4px 0 rgba(60,40,20,.1),0 8px 24px rgba(60,40,20,.08)}.shadow-pete-lg{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.bg-theme-gaming{background-color:#4caf7d}.bg-theme-tech{background-color:#5aa9e6}.bg-theme-politics{background-color:#e07a5f}.bg-theme-eu{background-color:#039}.bg-theme-music{background-color:#b079d6}.bg-theme-anime{background-color:#ec5e8a}.bg-theme-foss{background-color:#d97706}.bg-theme-kids{background-color:#14b8a6}.bg-theme-finance{background-color:#10b981}.bg-theme-lego{background-color:#d01012}.bg-theme-adventure{background-color:#6d4bd8}.text-theme-gaming{color:#2d8a5a}.text-theme-tech{color:#2f7fb8}.text-theme-politics{color:#b8523a}.text-theme-eu{color:#039}.text-theme-music{color:#8a4fb8}.text-theme-anime{color:#c33a6a}.text-theme-foss{color:#b8530a}.text-theme-kids{color:#0f766e}.text-theme-finance{color:#059669}.text-theme-lego{color:#b00d0e}.text-theme-adventure{color:#5836b8}.decoration-theme-gaming{text-decoration-color:#4caf7d}.decoration-theme-tech{text-decoration-color:#5aa9e6}.decoration-theme-politics{text-decoration-color:#e07a5f}.decoration-theme-eu{text-decoration-color:#039}.decoration-theme-music{text-decoration-color:#b079d6}.decoration-theme-anime{text-decoration-color:#ec5e8a}.decoration-theme-foss{text-decoration-color:#d97706}.decoration-theme-kids{text-decoration-color:#14b8a6}.decoration-theme-finance{text-decoration-color:#10b981}.decoration-theme-lego{text-decoration-color:#d01012}.decoration-theme-adventure{text-decoration-color:#6d4bd8}.border-theme-gaming{border-color:#4caf7d}.border-theme-tech{border-color:#5aa9e6}.border-theme-politics{border-color:#e07a5f}.border-theme-eu{border-color:#039}.border-theme-music{border-color:#b079d6}.border-theme-anime{border-color:#ec5e8a}.border-theme-foss{border-color:#d97706}.border-theme-kids{border-color:#14b8a6}.border-theme-finance{border-color:#10b981}.border-theme-lego{border-color:#d01012}.border-theme-adventure{border-color:#6d4bd8}.glow-theme-gaming{box-shadow:0 0 0 2px rgba(76,175,125,.35),0 0 24px 4px rgba(76,175,125,.45)}.glow-theme-gaming,.glow-theme-tech{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-tech{box-shadow:0 0 0 2px rgba(90,169,230,.35),0 0 24px 4px rgba(90,169,230,.45)}.glow-theme-politics{box-shadow:0 0 0 2px rgba(224,122,95,.35),0 0 24px 4px rgba(224,122,95,.45)}.glow-theme-eu,.glow-theme-politics{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-eu{box-shadow:0 0 0 2px rgba(0,51,153,.35),0 0 24px 4px rgba(0,51,153,.45)}.glow-theme-music{box-shadow:0 0 0 2px rgba(176,121,214,.35),0 0 24px 4px rgba(176,121,214,.45)}.glow-theme-anime,.glow-theme-music{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-anime{box-shadow:0 0 0 2px rgba(236,94,138,.35),0 0 24px 4px rgba(236,94,138,.45)}.glow-theme-foss{box-shadow:0 0 0 2px rgba(217,119,6,.35),0 0 24px 4px rgba(217,119,6,.45)}.glow-theme-foss,.glow-theme-kids{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-kids{box-shadow:0 0 0 2px rgba(20,184,166,.35),0 0 24px 4px rgba(20,184,166,.45)}.glow-theme-finance{box-shadow:0 0 0 2px rgba(16,185,129,.35),0 0 24px 4px rgba(16,185,129,.45)}.glow-theme-finance,.glow-theme-lego{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-lego{box-shadow:0 0 0 2px rgba(208,16,18,.35),0 0 24px 4px rgba(208,16,18,.45)}.glow-theme-adventure{box-shadow:0 0 0 2px rgba(109,75,216,.35),0 0 24px 4px rgba(109,75,216,.45);animation:pete-glow-pulse 2.4s ease-in-out infinite}@keyframes pete-glow-pulse{0%,to{filter:brightness(1)}50%{filter:brightness(1.08)}}.line-clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.paywall-stamp{position:absolute;inset:0;pointer-events:none;display:flex;align-items:center;justify-content:center;z-index:5;background:rgba(255,250,240,.18)}.paywall-stamp:before{content:"PAYWALLED";font-family:var(--font-display,ui-sans-serif),system-ui,sans-serif;font-weight:900;font-size:clamp(1rem,3.2vw,1.75rem);letter-spacing:.12em;color:rgba(180,30,30,.85);border:.28rem double rgba(180,30,30,.85);padding:.3rem .9rem;transform:rotate(-15deg);text-shadow:0 1px 0 hsla(0,0%,100%,.4);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.15);background:rgba(255,240,230,.55);border-radius:.25rem;filter:contrast(1.05)}:root,html[data-phase=day]{--bg:#fff7e4;--bg-grad:#ffeec2;--card:#fff;--ink:#3a2e1f;--accent:#f2a541}html[data-phase=dawn]{--bg:#ffe7d6;--bg-grad:#ffc9c9;--card:#fff4ea;--ink:#4a2e2a;--accent:#ff8a65}html[data-phase=dusk]{--bg:#ffd6a8;--bg-grad:#f7a07e;--card:#fff1de;--ink:#3d2417;--accent:#e6553a}html[data-phase=night]{--bg:#1a1f3a;--bg-grad:#2a3358;--card:#2d365a;--ink:#f1ecd8;--accent:#f9d976}html[data-room=casinopolis]{--bg:#16211c;--card:#23342b;--ink:#f6ecd8;--accent:#f2b53d;--felt-a:#2f7d5b;--felt-b:#24614a;--felt-c:#1c4d3c;--glow:242,181,61}html[data-room=casino-night]{--bg:#140f2e;--card:#241a4d;--ink:#f2ecff;--accent:#ffcc2f;--felt-a:#4a2fa8;--felt-b:#351f80;--felt-c:#241659;--glow:126,86,255}html[data-room] .cs-room{background:radial-gradient(80% 55% at 50% -8%,rgba(var(--glow),.18),transparent 62%),radial-gradient(120% 90% at 50% 120%,rgba(0,0,0,.45),transparent 55%)}html[data-room=casino-night] .cs-room:before{content:"";position:absolute;inset:0 -50% auto -50%;height:.5rem;background:repeating-linear-gradient(90deg,rgba(255,204,47,.55) 0 .5rem,transparent .5rem 2.25rem);filter:blur(1px);animation:cs-bulbs 2.4s linear infinite}@keyframes cs-bulbs{0%{transform:translateX(0)}to{transform:translateX(2.75rem)}}html[data-room] .shadow-pete{box-shadow:0 4px 0 rgba(0,0,0,.3),0 10px 26px rgba(0,0,0,.3)}html[data-room] .shadow-pete-lg{box-shadow:0 6px 0 rgba(0,0,0,.34),0 20px 40px rgba(0,0,0,.38)}html[data-room] .cs-comb svg{filter:drop-shadow(0 3px 0 rgba(0,0,0,.35))}html[data-room] .pete-felt{background:radial-gradient(120% 90% at 50% -10%,hsla(0,0%,100%,.1),transparent 60%),linear-gradient(160deg,var(--felt-a) 0,var(--felt-b) 55%,var(--felt-c) 100%)}@media (prefers-reduced-motion:reduce){html[data-room=casino-night] .cs-room:before{animation:none}}.cs-stack{display:block;width:2.75rem;height:calc(.45rem*var(--stack, 1) + .55rem);border-radius:999px;background:radial-gradient(circle at 50% 30%,hsla(0,0%,100%,.3),transparent 60%),var(--chip,#e07a5f);border:2px solid rgba(0,0,0,.25);box-shadow:0 3px 0 rgba(0,0,0,.3),inset 0 -.4rem 0 rgba(0,0,0,.16),inset 0 .35rem 0 hsla(0,0%,100%,.14)}.cs-stack[data-chip="5"]{--chip:#5aa9e6}.cs-stack[data-chip="25"]{--chip:#4caf7d}.cs-stack[data-chip="100"]{--chip:#2b2118}.cs-stack[data-chip="500"]{--chip:#b079d6}.last\:border-0:last-child{border-width:0}.empty\:hidden:empty{display:none}.hover\:-translate-y-0\.5:hover{--tw-translate-y:-0.125rem}.hover\:-translate-y-0\.5:hover,.hover\:-translate-y-1:hover{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:-translate-y-1:hover{--tw-translate-y:-0.25rem}.hover\:text-\[color\:var\(--ink\)\]:hover{color:var(--ink)}.hover\:brightness-105:hover{--tw-brightness:brightness(1.05);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.hover\:shadow-pete-lg:hover{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.focus\:border-\[color\:var\(--accent\)\]:focus{border-color:var(--accent)}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.active\:translate-y-px:active{--tw-translate-y:1px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:-rotate-6{--tw-rotate:-6deg}.group:hover .group-hover\:-rotate-6,.group:hover .group-hover\:scale-105{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:scale-105{--tw-scale-x:1.05;--tw-scale-y:1.05}.group:hover .group-hover\:underline{text-decoration-line:underline}@media (min-width:640px){.sm\:ml-auto{margin-left:auto}.sm\:block{display:block}.sm\:inline{display:inline}.sm\:flex{display:flex}.sm\:inline-flex{display:inline-flex}.sm\:h-56{height:14rem}.sm\:basis-auto{flex-basis:auto}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:gap-2{gap:.5rem}.sm\:gap-3{gap:.75rem}.sm\:gap-4{gap:1rem}.sm\:p-10{padding:2.5rem}.sm\:p-6{padding:1.5rem}.sm\:p-8{padding:2rem}.sm\:pr-28{padding-right:7rem}.sm\:pt-10{padding-top:2.5rem}.sm\:pt-12{padding-top:3rem}.sm\:text-2xl{font-size:1.5rem;line-height:2rem}.sm\:text-3xl{font-size:1.875rem;line-height:2.25rem}.sm\:text-4xl{font-size:2.25rem;line-height:2.5rem}.sm\:text-5xl{font-size:3rem;line-height:1}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-\[auto\2c 1fr\]{grid-template-columns:auto 1fr}.lg\:items-start{align-items:flex-start}}@media (prefers-color-scheme:dark){.dark\:text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dark\:text-emerald-300{--tw-text-opacity:1;color:rgb(110 231 183/var(--tw-text-opacity,1))}.dark\:text-red-300{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.dark\:text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}}
\ No newline at end of file
+*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}body{font-family:Nunito,system-ui,sans-serif}html{scroll-behavior:smooth}body,html{overflow-x:hidden}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.pete-channel-nav{scrollbar-width:none;-ms-overflow-style:none}.pete-channel-nav::-webkit-scrollbar{display:none}.pete-reader-backdrop{position:absolute;inset:0;background:rgba(20,14,6,.55);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}html[data-phase=night] .pete-reader-backdrop{background:rgba(6,8,20,.65)}.pete-reader-shell{position:relative;height:100%;width:100%;max-width:44rem;margin:0 auto;display:flex;flex-direction:column;padding:max(env(safe-area-inset-top),.75rem) 1rem .75rem;gap:.75rem}.pete-reader-bar{display:flex;align-items:center;justify-content:space-between;gap:.75rem;color:#fff}.pete-reader-progress{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:600;font-size:.85rem;letter-spacing:.02em;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-nav{display:flex;align-items:center;gap:.4rem}.pete-reader-btn{display:inline-flex;align-items:center;justify-content:center;min-width:2.25rem;height:2.25rem;padding:0 .7rem;border-radius:9999px;background:hsla(0,0%,100%,.14);color:#fff;font-weight:700;font-size:.95rem;border:2px solid hsla(0,0%,100%,.18);cursor:pointer;transition:background .15s ease,transform .15s ease,opacity .15s ease}.pete-reader-btn:hover{background:hsla(0,0%,100%,.28);transform:translateY(-1px)}.pete-reader-btn:disabled{opacity:.35;cursor:default;transform:none}.pete-reader-btn-open{background:var(--accent);border-color:transparent;color:#1c1305;text-decoration:none}.pete-reader-btn-open:hover{background:var(--accent);filter:brightness(1.08)}.pete-reader-btn[aria-expanded=true],.pete-reader-btn[aria-pressed=true]{background:var(--accent);border-color:transparent;color:#1c1305}.pete-reader-type{position:relative;display:inline-flex}.pete-reader-typemenu[hidden]{display:none}.pete-reader-typemenu{position:absolute;top:calc(100% + .5rem);right:0;z-index:10;width:15rem;padding:.75rem;border-radius:1rem;background:var(--card);color:var(--ink);border:2px solid rgba(0,0,0,.08);box-shadow:0 10px 30px rgba(20,14,6,.28);display:flex;flex-direction:column;gap:.6rem}html[data-phase=night] .pete-reader-typemenu{border-color:hsla(0,0%,100%,.12)}.pete-reader-typerow{display:flex;align-items:center;justify-content:space-between;gap:.6rem}.pete-reader-typelabel{font-size:.8rem;font-weight:700;opacity:.7}.pete-reader-typebtns{display:inline-flex;gap:.25rem}.pete-reader-typebtns button{min-width:2rem;height:2rem;padding:0 .55rem;border-radius:.6rem;background:rgba(20,14,6,.06);color:var(--ink);font-weight:700;font-size:.82rem;border:2px solid transparent;cursor:pointer;transition:background .15s ease}html[data-phase=night] .pete-reader-typebtns button{background:hsla(0,0%,100%,.08)}.pete-reader-typebtns button:hover{background:rgba(20,14,6,.12)}.pete-reader-typebtns button.is-active{background:var(--accent);color:#1c1305;border-color:transparent}.pete-reader-voicerow[hidden]{display:none}.pete-reader-voice{font:inherit;font-size:.8rem;font-weight:600;padding:.25rem .4rem;border-radius:.55rem;border:1px solid rgba(20,14,6,.18);background:rgba(20,14,6,.06);color:inherit;max-width:11rem;cursor:pointer}html[data-phase=night] .pete-reader-voice{border-color:hsla(0,0%,100%,.14);background:hsla(0,0%,100%,.08)}.pete-reader-scroll{flex:1 1 auto;overflow-y:auto;-webkit-overflow-scrolling:touch;border-radius:1.75rem}.pete-reader-scroll:focus{outline:none}.pete-reader-article{background:var(--card);color:var(--ink);border-radius:1.75rem;border:2px solid rgba(0,0,0,.06);box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.18);padding:clamp(1.25rem,4vw,2.5rem)}html[data-phase=night] .pete-reader-article{border-color:hsla(0,0%,100%,.08)}.pete-reader-eyebrow{display:flex;align-items:center;flex-wrap:wrap;gap:.5rem;font-size:.75rem;margin-bottom:.85rem}.pete-reader-chip{display:inline-flex;align-items:center;gap:.35rem;border-radius:9999px;padding:.15rem .65rem;color:#fff;font-weight:700;text-transform:uppercase;letter-spacing:.06em}.pete-reader-source{font-weight:700;opacity:.75}.pete-reader-time{opacity:.55}.pete-reader-title{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:700;line-height:1.15;letter-spacing:-.01em;font-size:clamp(1.6rem,4.5vw,2.4rem);margin-bottom:1rem}.pete-reader-hero{width:100%;border-radius:1.1rem;margin-bottom:1.25rem;background:rgba(0,0,0,.05)}.pete-reader-scroll{--reader-fs:1.08rem}.pete-reader-body{font-size:var(--reader-fs);line-height:1.75}.pete-reader-body p{margin-bottom:1.05em}.pete-reader-body p:last-child{margin-bottom:0}.pete-reader-scroll.is-size-s{--reader-fs:0.98rem}.pete-reader-scroll.is-size-m{--reader-fs:1.08rem}.pete-reader-scroll.is-size-l{--reader-fs:1.22rem}.pete-reader-scroll.is-size-xl{--reader-fs:1.4rem}.pete-reader-scroll.is-serif .pete-reader-body{font-family:Georgia,Iowan Old Style,Times New Roman,serif}.pete-reader-scroll.is-sepia .pete-reader-article{background:#f6ecd6;color:#4a3a28;border-color:rgba(74,58,40,.16)}.pete-reader-scroll.is-sepia .pete-reader-note{border-top-color:rgba(74,58,40,.18);opacity:.8}.pete-reader-scroll.is-sepia .pete-reader-hero{background:rgba(74,58,40,.08)}.pete-reader-body p.is-speaking{background:color-mix(in srgb,var(--accent) 32%,transparent);border-radius:.4rem;box-shadow:0 0 0 .35rem color-mix(in srgb,var(--accent) 32%,transparent)}.pete-reader-note{margin-top:1.25rem;padding-top:1rem;border-top:1px solid rgba(0,0,0,.1);font-size:.85rem;opacity:.7}html[data-phase=night] .pete-reader-note{border-color:hsla(0,0%,100%,.12)}.pete-reader-note a{color:var(--accent);font-weight:700}.pete-reader-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:.75rem;padding:clamp(2rem,8vw,4rem) 1rem;color:var(--ink)}.pete-reader-empty-emoji{font-size:2.5rem}.pete-reader-toast{position:absolute;left:50%;bottom:4.5rem;transform:translate(-50%,.5rem);z-index:20;padding:.5rem .9rem;border-radius:9999px;background:rgba(20,14,6,.9);color:#fff;font-weight:700;font-size:.85rem;box-shadow:0 8px 24px rgba(0,0,0,.3);opacity:0;pointer-events:none;transition:opacity .2s ease,transform .2s ease}.pete-reader-toast.is-shown{opacity:1;transform:translate(-50%)}.pete-reader-hint{text-align:center;color:hsla(0,0%,100%,.85);font-size:.75rem;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-hint kbd{font-family:ui-monospace,monospace;background:hsla(0,0%,100%,.16);border-radius:.3rem;padding:.05rem .3rem}@media (max-width:640px){.pete-reader-hint{display:none}.pete-reader-bar{flex-wrap:wrap}.pete-reader-nav{flex-wrap:wrap;justify-content:flex-end;row-gap:.4rem}}.pete-reader-related{width:100%;margin:.85rem auto 0}.pete-reader-related-title{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:700;font-size:.95rem;color:#fff;margin:0 0 .6rem;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-related-grid{display:grid;gap:.6rem}.pete-reader-related-card{display:flex;align-items:center;gap:.75rem;background:var(--card);color:var(--ink);border-radius:1rem;border:2px solid rgba(0,0,0,.06);padding:.6rem;text-decoration:none;transition:transform .15s ease,box-shadow .15s ease}.pete-reader-related-card:hover{transform:translateY(-1px);box-shadow:0 6px 16px rgba(60,40,20,.16)}html[data-phase=night] .pete-reader-related-card{border-color:hsla(0,0%,100%,.08)}.pete-reader-related-thumb{width:4.5rem;height:3.25rem;flex-shrink:0;-o-object-fit:cover;object-fit:cover;border-radius:.6rem;background:rgba(0,0,0,.06)}.pete-reader-related-meta{min-width:0;display:flex;flex-direction:column;gap:.25rem}.pete-reader-related-eyebrow{display:flex;align-items:center;flex-wrap:wrap;gap:.4rem;font-size:.7rem}.pete-reader-related-source{font-weight:700;opacity:.7}.pete-reader-related-headline{font-weight:700;line-height:1.25;font-size:.92rem;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}[data-story-card][data-read="1"]{opacity:.5;transition:opacity .2s ease}[data-story-card][data-read="1"]:hover{opacity:1}[data-story-card][data-read="1"]:after{content:"✓";position:absolute;top:.6rem;right:.6rem;z-index:6;display:grid;place-items:center;height:1.5rem;width:1.5rem;border-radius:9999px;background:rgba(20,14,6,.72);color:#fff;font-size:.8rem;font-weight:700}.pete-felt{background:radial-gradient(120% 90% at 50% -10%,hsla(0,0%,100%,.1),transparent 60%),linear-gradient(160deg,#2f7d5b,#24614a 55%,#1c4d3c)}.pete-shoe{position:absolute;top:1.25rem;right:1.25rem;height:4.2rem;width:3rem;border-radius:.55rem;background:linear-gradient(150deg,#b4553f,#8d3f2f);border:2px solid rgba(0,0,0,.18);box-shadow:0 4px 0 rgba(0,0,0,.18),inset 0 0 0 3px hsla(0,0%,100%,.12)}.pete-shoe:after{content:"";position:absolute;inset:.45rem .4rem auto .4rem;height:.5rem;border-radius:.2rem;background:rgba(0,0,0,.22)}.pete-hand{display:flex;flex-wrap:wrap;gap:.6rem;min-height:8.6rem}.pete-card{perspective:700px;height:var(--card-h,8.4rem);width:var(--card-w,6rem);animation:pete-deal .42s cubic-bezier(.22,1,.36,1) backwards}.pete-card-inner{position:relative;height:100%;width:100%;transform-style:preserve-3d;transition:transform .45s cubic-bezier(.4,0,.2,1)}.pete-card[data-face=down] .pete-card-inner{transform:rotateY(180deg)}.pete-card-back,.pete-card-front{position:absolute;inset:0;display:grid;border-radius:.55rem;backface-visibility:hidden;-webkit-backface-visibility:hidden;box-shadow:0 3px 0 rgba(0,0,0,.18),0 6px 14px rgba(0,0,0,.22)}.pete-card-front{place-items:stretch;background:#fdfaf2;border:2px solid rgba(30,20,10,.12);color:#2b2118;font-family:Fredoka,Nunito,system-ui,sans-serif;line-height:1;overflow:hidden}.pete-card-front[data-red="1"]{color:#cc3d4a}.pete-card-back{transform:rotateY(180deg);background:repeating-linear-gradient(45deg,hsla(0,0%,100%,.1) 0 6px,transparent 6px 12px),linear-gradient(150deg,#b4553f,#8d3f2f);border:2px solid rgba(0,0,0,.15)}.pete-card-svg{width:100%;height:100%;display:block;fill:currentColor}.pete-card-idx{font-size:26px;font-weight:700;text-anchor:middle;fill:currentColor}.pete-card-panel{fill:currentColor;fill-opacity:.06;stroke:currentColor;stroke-opacity:.35;stroke-width:1.5}.pete-card-court{font-size:34px;font-weight:700;text-anchor:middle;fill:currentColor}@keyframes pete-deal{0%{opacity:0;transform:translate(var(--deal-x,14rem),var(--deal-y,-7rem)) scale(.72) rotate(9deg)}62%{opacity:1;transform:translateY(.35rem) scale(1.05) rotate(calc(var(--tilt, 0deg) - 2deg))}to{opacity:1;transform:translate(0) scale(1) rotate(var(--tilt,0deg))}}.pete-card:after{content:"";position:absolute;inset:auto 6% -.35rem 6%;height:.6rem;border-radius:999px;background:rgba(0,0,0,.35);filter:blur(5px);animation:pete-thud .42s cubic-bezier(.22,1,.36,1) backwards}@keyframes pete-thud{0%,55%{opacity:0;transform:scale(.4)}72%{opacity:.9;transform:scale(1.15)}to{opacity:.45;transform:scale(1)}}.pete-hand[data-won="1"] .pete-card{animation:pete-deal .42s cubic-bezier(.22,1,.36,1) backwards,pete-win .6s ease .1s}@keyframes pete-win{0%,to{transform:rotate(var(--tilt,0deg)) translateY(0)}40%{transform:rotate(var(--tilt,0deg)) translateY(-.6rem)}}.pete-hand[data-won="-1"] .pete-card{filter:saturate(.45) brightness(.78);transition:filter .5s ease .2s}.pete-disc{position:relative;border-radius:999px;background:radial-gradient(circle at 50% 30%,hsla(0,0%,100%,.3),transparent 62%),var(--chip,#e07a5f);border:2px solid rgba(0,0,0,.22);box-shadow:0 3px 0 rgba(0,0,0,.28),0 6px 14px rgba(0,0,0,.2)}.pete-disc:before{content:"";position:absolute;inset:11%;border-radius:999px;border:2px dashed hsla(0,0%,100%,.55)}.pete-disc[data-chip="5"]{--chip:#5aa9e6}.pete-disc[data-chip="25"]{--chip:#4caf7d}.pete-disc[data-chip="100"]{--chip:#2b2118}.pete-disc[data-chip="500"]{--chip:#b079d6}.pete-chip{transition:transform .12s ease}.pete-chip>span{position:relative}.pete-chip:active{transform:translateY(1px) scale(.94)}.pete-spot{position:relative;display:grid;place-items:center;height:7rem;width:7rem;flex:none;border-radius:999px;border:2px dashed hsla(0,0%,100%,.35);background:radial-gradient(circle at 50% 40%,hsla(0,0%,100%,.1),transparent 70%);box-shadow:inset 0 0 0 6px rgba(0,0,0,.08);transition:box-shadow .3s ease,border-color .3s ease}.pete-spot[data-live="1"]{border-color:rgba(var(--glow,242,181,61),.85);box-shadow:inset 0 0 0 6px rgba(0,0,0,.08),0 0 22px rgba(var(--glow,242,181,61),.35)}.pete-spot-label{font-size:.65rem;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:hsla(0,0%,100%,.35)}.pete-spot[data-live="1"] .pete-spot-label{opacity:0}.pete-stack{position:absolute;inset:0;pointer-events:none}.pete-stack .pete-disc{position:absolute;left:50%;top:50%;height:2.6rem;width:2.6rem;margin:-1.3rem 0 0 -1.3rem;transform:translateY(calc(-.5rem*var(--i, 0))) rotate(var(--spin,0deg));box-shadow:0 2px 0 rgba(0,0,0,.35),0 4px 8px rgba(0,0,0,.25);animation:pete-chip-land .28s cubic-bezier(.34,1.56,.64,1) backwards}@keyframes pete-chip-land{0%{transform:translateY(calc(-.5rem*var(--i, 0) - 1.1rem)) rotate(var(--spin,0deg)) scale(1.18)}}.pete-spot-total{position:absolute;bottom:-.6rem;left:50%;transform:translateX(-50%);white-space:nowrap;border-radius:999px;background:rgba(0,0,0,.45);padding:.1rem .6rem;font-size:.72rem;font-weight:700;font-variant-numeric:tabular-nums;color:#fff}.pete-fly-layer{position:fixed;inset:0;z-index:60;pointer-events:none;overflow:hidden}.pete-fly{position:absolute;height:2.6rem;width:2.6rem;margin:-1.3rem 0 0 -1.3rem;will-change:transform}.pete-rack{position:absolute;top:1.25rem;right:5.75rem;display:flex;align-items:flex-end;gap:.4rem;padding:.5rem .6rem .45rem;border-radius:.75rem;background:rgba(0,0,0,.2);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.06)}.pete-rack span{display:block;width:1.7rem;border-radius:.35rem;background:linear-gradient(90deg,rgba(0,0,0,.28),transparent 35%,transparent 65%,rgba(0,0,0,.28)),repeating-linear-gradient(180deg,hsla(0,0%,100%,.18) 0 .06rem,var(--chip,#e07a5f) .06rem .3rem,rgba(0,0,0,.35) .3rem .36rem);border:1px solid rgba(0,0,0,.35);height:calc(.36rem*var(--stack, 3))}.pete-rack span[data-chip="5"]{--chip:#5aa9e6}.pete-rack span[data-chip="25"]{--chip:#4caf7d}.pete-rack span[data-chip="100"]{--chip:#2b2118}.pete-rack span[data-chip="500"]{--chip:#b079d6}.pete-spark{position:absolute;height:.75rem;width:.45rem;border-radius:2px;box-shadow:0 1px 3px rgba(0,0,0,.35);will-change:transform,opacity}.pete-dealer-think{animation:pete-think .9s ease-in-out infinite}@keyframes pete-think{0%,to{opacity:.6}50%{opacity:1;transform:translateY(-1px)}}.pete-gallows{overflow:visible;fill:none;stroke-linecap:round;stroke-linejoin:round}.pete-gallows-frame path{stroke:rgba(0,0,0,.35);stroke-width:6}.pete-gallows-frame path:last-child{stroke:rgba(0,0,0,.3);stroke-width:3.5}.pete-gallows-body>*{stroke:#fff;stroke-width:5;opacity:0;filter:drop-shadow(0 2px 0 rgba(0,0,0,.25))}.pete-gallows-body>[data-on="1"]{opacity:1}.pete-part-draw{stroke-dasharray:260;animation:pete-part .45s cubic-bezier(.22,1,.36,1) backwards}@keyframes pete-part{0%{stroke-dashoffset:260}to{stroke-dashoffset:0}}.pete-shake{animation:pete-shake .42s cubic-bezier(.36,.07,.19,.97)}@keyframes pete-shake{10%,90%{transform:translateX(-1.5px)}20%,80%{transform:translateX(3px)}30%,50%,70%{transform:translateX(-5px)}40%,60%{transform:translateX(5px)}}.pete-board{display:flex;flex-wrap:wrap;gap:.35rem 1.1rem;min-height:5rem;align-items:center}.pete-word{display:flex;gap:.3rem}.pete-tile{display:grid;place-items:center;height:2.9rem;width:2.2rem;border-radius:.5rem;font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:1.4rem;font-weight:700;color:#fff;background:rgba(0,0,0,.22);border-bottom:3px solid rgba(0,0,0,.28);text-transform:uppercase}.pete-tile[data-up="1"]{background:hsla(0,0%,100%,.95);color:#2b2118;border-bottom-color:rgba(0,0,0,.35)}.pete-tile[data-punct="1"]{background:none;border:0;width:auto;min-width:.7rem;color:hsla(0,0%,100%,.55)}.pete-tile-hit{animation:pete-tile-hit .34s cubic-bezier(.34,1.56,.64,1)}@keyframes pete-tile-hit{0%{transform:rotateX(90deg) scale(1.1)}to{transform:rotateX(0) scale(1)}}.pete-missed{display:grid;place-items:center;height:1.5rem;min-width:1.5rem;padding:0 .3rem;border-radius:.375rem;background:rgba(0,0,0,.25);font-size:.75rem;font-weight:700;color:hsla(0,0%,100%,.5);text-decoration:line-through;text-transform:uppercase}.pete-meter{display:flex;align-items:baseline;gap:.5rem;border-radius:999px;background:rgba(0,0,0,.28);padding:.4rem .9rem;box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.1);transition:box-shadow .3s ease}.pete-meter-label{font-size:.65rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:hsla(0,0%,100%,.45)}.pete-meter-value{font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:1.5rem;font-weight:700;font-variant-numeric:tabular-nums;color:var(--accent)}.pete-meter[data-cold="1"] .pete-meter-value{color:hsla(0,0%,100%,.55)}.pete-meter[data-hit="1"]{box-shadow:inset 0 0 0 2px rgba(204,61,74,.9);animation:pete-meter-hit .4s ease}@keyframes pete-meter-hit{0%{transform:scale(1)}35%{transform:scale(.94)}to{transform:scale(1)}}.pete-keys{display:grid;gap:.35rem}.pete-key-row{display:flex;justify-content:center;gap:.35rem}.pete-key-row[data-digits="1"]{margin-top:.25rem;opacity:.75}.pete-key-row[data-digits="1"] .pete-key{height:2rem;min-width:1.8rem;font-size:.8rem}.pete-key{height:2.75rem;min-width:2.2rem;flex:0 1 2.4rem;border-radius:.6rem;background:color-mix(in srgb,var(--ink) 6%,transparent);border:2px solid color-mix(in srgb,var(--ink) 10%,transparent);font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-weight:700;color:var(--ink);transition:transform .08s ease,background .15s ease,opacity .15s ease}.pete-key:hover:not(:disabled){background:color-mix(in srgb,var(--ink) 12%,transparent)}.pete-key:active:not(:disabled){transform:translateY(1px) scale(.96)}.pete-key:disabled{cursor:default}.pete-key[data-state=hit]{background:#4caf7d;border-color:#3d9367;color:#fff;opacity:1}.pete-key[data-state=miss]{background:color-mix(in srgb,var(--ink) 4%,transparent);color:color-mix(in srgb,var(--ink) 30%,transparent);text-decoration:line-through}.pete-key:disabled[data-state=""]{opacity:.4}.pete-tier{background:color-mix(in srgb,var(--ink) 4%,transparent);border-color:color-mix(in srgb,var(--ink) 10%,transparent)}.pete-tier:hover{background:color-mix(in srgb,var(--ink) 8%,transparent)}.pete-tier[data-on="1"]{background:color-mix(in srgb,var(--accent) 18%,transparent);border-color:var(--accent)}.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);--fan-down:calc(var(--card-h)*0.14)}.pete-slot{position:relative;display:grid;place-items:center;height:var(--card-h);width:var(--card-w);flex:none;border-radius:.55rem;border:2px dashed hsla(0,0%,100%,.25);background:rgba(0,0,0,.12);box-shadow:inset 0 2px 8px rgba(0,0,0,.18);transition:border-color .18s ease,background .18s ease,transform .12s ease}.pete-slot-glyph{font-size:calc(var(--card-w)*.42);line-height:1;color:hsla(0,0%,100%,.3)}.pete-slot-glyph[data-red="1"]{color:hsla(0,100%,83%,.35)}.pete-slot>.pete-card{position:absolute;inset:0;height:100%;width:100%}.pete-stock{border-style:solid;border-color:rgba(0,0,0,.15);background:repeating-linear-gradient(45deg,hsla(0,0%,100%,.1) 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(.98)}.pete-stock[data-empty="1"]{background:rgba(0,0,0,.12);border-style:dashed;border-color:hsla(0,0%,100%,.25)}.pete-stock[data-dead="1"]{cursor:default;opacity:.4}.pete-stock[data-dead="1"]:hover{filter:none}.pete-stock[data-dead="1"]:active{transform:none}.pete-slot-count{position:absolute;bottom:-.5rem;left:50%;transform:translateX(-50%);border-radius:999px;background:rgba(0,0,0,.5);padding:.05rem .5rem;font-size:.7rem;font-weight:700;font-variant-numeric:tabular-nums;color:#fff}.pete-slot-recycle{color:hsla(0,0%,100%,.45)}.pete-slot-recycle svg{height:1.4rem;width:1.4rem}.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)*-.72)}.pete-waste .pete-card{position:relative}.pete-tableau{display:grid;grid-template-columns:repeat(7,var(--card-w));justify-content:space-between;gap:.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)}.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))}.pete-card[data-live="1"]{cursor:pointer}.pete-card[data-live="1"]:hover .pete-card-front{filter:brightness(1.06)}.pete-card[data-held="1"]{z-index:5;transform:translateY(-.55rem);transition:transform .14s cubic-bezier(.34,1.56,.64,1)}.pete-card[data-held="1"] .pete-card-front{box-shadow:0 3px 0 rgba(0,0,0,.18),0 10px 22px rgba(0,0,0,.32),0 0 0 3px rgba(242,181,61,.9)}.pete-col[data-drop="1"] .pete-card:last-child .pete-card-front,.pete-col[data-drop="1"] .pete-slot,.pete-slot[data-drop="1"]{border-color:rgba(242,181,61,.9);box-shadow:0 0 0 3px rgba(242,181,61,.45)}.pete-col[data-drop="1"] .pete-slot,.pete-slot[data-drop="1"]{background:rgba(242,181,61,.12)}.pete-nope{animation:pete-shake .4s cubic-bezier(.36,.07,.19,.97)}.pete-home-flash{animation:pete-home .5s ease-out}@keyframes pete-home{0%{box-shadow:0 0 0 0 rgba(242,181,61,.9)}to{box-shadow:0 0 0 1.4rem rgba(242,181,61,0)}}.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}}.pete-rack[data-at=rail]{position:static;justify-content:center}@media (prefers-reduced-motion:reduce){.pete-card,.pete-card:after,.pete-dealer-think,.pete-stack .pete-disc{animation:none}.pete-card-inner{transition:none}.pete-hand[data-won="1"] .pete-card,.pete-home-flash,.pete-meter[data-hit="1"],.pete-nope,.pete-part-draw,.pete-shake,.pete-tile-hit{animation:none}.pete-card[data-held="1"]{transition:none}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-right-6{right:-1.5rem}.-top-6{top:-1.5rem}.-z-10{z-index:-10}.-z-\[5\]{z-index:-5}.z-50{z-index:50}.order-none{order:0}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-auto{margin-left:auto;margin-right:auto}.-mt-1{margin-top:-.25rem}.mb-1{margin-bottom:.25rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-8{margin-bottom:2rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mt-0{margin-top:0}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-8{margin-top:2rem}.line-clamp-2{-webkit-line-clamp:2}.line-clamp-2,.line-clamp-3{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical}.line-clamp-3{-webkit-line-clamp:3}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.aspect-\[16\/10\]{aspect-ratio:16/10}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-20{height:5rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-52{height:13rem}.h-6{height:1.5rem}.h-9{height:2.25rem}.h-full{height:100%}.max-h-\[55vh\]{max-height:55vh}.max-h-\[60vh\]{max-height:60vh}.min-h-\[1\.75rem\]{min-height:1.75rem}.min-h-\[2\.75rem\]{min-height:2.75rem}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-2{width:.5rem}.w-2\.5{width:.625rem}.w-20{width:5rem}.w-24{width:6rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-9{width:2.25rem}.w-auto{width:auto}.w-full{width:100%}.min-w-0{min-width:0}.min-w-\[28rem\]{min-width:28rem}.min-w-\[52rem\]{min-width:52rem}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-\[7rem\]{max-width:7rem}.max-w-full{max-width:100%}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink,.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.basis-full{flex-basis:100%}.-translate-y-0{--tw-translate-y:-0px}.-translate-y-0,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-10{-moz-column-gap:2.5rem;column-gap:2.5rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-4{row-gap:1rem}.gap-y-8{row-gap:2rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.375rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-transparent{border-color:transparent}.bg-\[color\:var\(--accent\)\]{background-color:var(--accent)}.bg-\[color\:var\(--bg\)\]{background-color:var(--bg)}.bg-\[color\:var\(--bg-grad\)\]{background-color:var(--bg-grad)}.bg-\[color\:var\(--card\)\]{background-color:var(--card)}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-black\/25{background-color:rgba(0,0,0,.25)}.bg-emerald-500{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity,1))}.bg-emerald-500\/15{background-color:rgba(16,185,129,.15)}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/15{background-color:rgba(239,68,68,.15)}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/95{background-color:hsla(0,0%,100%,.95)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-0\.5{padding:.125rem}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0{padding-top:0;padding-bottom:0}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-10{padding-bottom:2.5rem}.pb-20{padding-bottom:5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pr-24{padding-right:6rem}.pt-1{padding-top:.25rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-8{padding-top:2rem}.pt-\[10vh\]{padding-top:10vh}.pt-\[12vh\]{padding-top:12vh}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.align-\[-4px\]{vertical-align:-4px}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12rem\]{font-size:12rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-\[0\.18em\]{letter-spacing:.18em}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-\[\#1c1305\]{--tw-text-opacity:1;color:rgb(28 19 5/var(--tw-text-opacity,1))}.text-\[\#20180c\]{--tw-text-opacity:1;color:rgb(32 24 12/var(--tw-text-opacity,1))}.text-\[\#2b2118\]{--tw-text-opacity:1;color:rgb(43 33 24/var(--tw-text-opacity,1))}.text-\[color\:var\(--accent\)\]{color:var(--accent)}.text-\[color\:var\(--ink\)\]{color:var(--ink)}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-emerald-700{--tw-text-opacity:1;color:rgb(4 120 87/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-white\/40{color:hsla(0,0%,100%,.4)}.text-white\/50{color:hsla(0,0%,100%,.5)}.text-white\/55{color:hsla(0,0%,100%,.55)}.text-white\/60{color:hsla(0,0%,100%,.6)}.text-white\/85{color:hsla(0,0%,100%,.85)}.text-white\/90{color:hsla(0,0%,100%,.9)}.underline{text-decoration-line:underline}.decoration-4{text-decoration-thickness:4px}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.accent-\[color\:var\(--accent\)\]{accent-color:var(--accent)}.opacity-20{opacity:.2}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.blur{--tw-blur:blur(8px)}.blur,.drop-shadow{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.drop-shadow{--tw-drop-shadow:drop-shadow(0 1px 2px rgba(0,0,0,.1)) drop-shadow(0 1px 1px rgba(0,0,0,.06))}.grayscale{--tw-grayscale:grayscale(100%)}.grayscale,.sepia{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.sepia{--tw-sepia:sepia(100%)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.backdrop-blur-sm,.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-1000{transition-duration:1s}.duration-500{transition-duration:.5s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}.font-display{font-family:Fredoka,Nunito,system-ui,sans-serif;letter-spacing:-.01em}.shadow-pete{box-shadow:0 4px 0 rgba(60,40,20,.1),0 8px 24px rgba(60,40,20,.08)}.shadow-pete-lg{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.bg-theme-gaming{background-color:#4caf7d}.bg-theme-tech{background-color:#5aa9e6}.bg-theme-politics{background-color:#e07a5f}.bg-theme-eu{background-color:#039}.bg-theme-music{background-color:#b079d6}.bg-theme-anime{background-color:#ec5e8a}.bg-theme-foss{background-color:#d97706}.bg-theme-kids{background-color:#14b8a6}.bg-theme-finance{background-color:#10b981}.bg-theme-lego{background-color:#d01012}.bg-theme-adventure{background-color:#6d4bd8}.text-theme-gaming{color:#2d8a5a}.text-theme-tech{color:#2f7fb8}.text-theme-politics{color:#b8523a}.text-theme-eu{color:#039}.text-theme-music{color:#8a4fb8}.text-theme-anime{color:#c33a6a}.text-theme-foss{color:#b8530a}.text-theme-kids{color:#0f766e}.text-theme-finance{color:#059669}.text-theme-lego{color:#b00d0e}.text-theme-adventure{color:#5836b8}.decoration-theme-gaming{text-decoration-color:#4caf7d}.decoration-theme-tech{text-decoration-color:#5aa9e6}.decoration-theme-politics{text-decoration-color:#e07a5f}.decoration-theme-eu{text-decoration-color:#039}.decoration-theme-music{text-decoration-color:#b079d6}.decoration-theme-anime{text-decoration-color:#ec5e8a}.decoration-theme-foss{text-decoration-color:#d97706}.decoration-theme-kids{text-decoration-color:#14b8a6}.decoration-theme-finance{text-decoration-color:#10b981}.decoration-theme-lego{text-decoration-color:#d01012}.decoration-theme-adventure{text-decoration-color:#6d4bd8}.border-theme-gaming{border-color:#4caf7d}.border-theme-tech{border-color:#5aa9e6}.border-theme-politics{border-color:#e07a5f}.border-theme-eu{border-color:#039}.border-theme-music{border-color:#b079d6}.border-theme-anime{border-color:#ec5e8a}.border-theme-foss{border-color:#d97706}.border-theme-kids{border-color:#14b8a6}.border-theme-finance{border-color:#10b981}.border-theme-lego{border-color:#d01012}.border-theme-adventure{border-color:#6d4bd8}.glow-theme-gaming{box-shadow:0 0 0 2px rgba(76,175,125,.35),0 0 24px 4px rgba(76,175,125,.45)}.glow-theme-gaming,.glow-theme-tech{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-tech{box-shadow:0 0 0 2px rgba(90,169,230,.35),0 0 24px 4px rgba(90,169,230,.45)}.glow-theme-politics{box-shadow:0 0 0 2px rgba(224,122,95,.35),0 0 24px 4px rgba(224,122,95,.45)}.glow-theme-eu,.glow-theme-politics{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-eu{box-shadow:0 0 0 2px rgba(0,51,153,.35),0 0 24px 4px rgba(0,51,153,.45)}.glow-theme-music{box-shadow:0 0 0 2px rgba(176,121,214,.35),0 0 24px 4px rgba(176,121,214,.45)}.glow-theme-anime,.glow-theme-music{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-anime{box-shadow:0 0 0 2px rgba(236,94,138,.35),0 0 24px 4px rgba(236,94,138,.45)}.glow-theme-foss{box-shadow:0 0 0 2px rgba(217,119,6,.35),0 0 24px 4px rgba(217,119,6,.45)}.glow-theme-foss,.glow-theme-kids{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-kids{box-shadow:0 0 0 2px rgba(20,184,166,.35),0 0 24px 4px rgba(20,184,166,.45)}.glow-theme-finance{box-shadow:0 0 0 2px rgba(16,185,129,.35),0 0 24px 4px rgba(16,185,129,.45)}.glow-theme-finance,.glow-theme-lego{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-lego{box-shadow:0 0 0 2px rgba(208,16,18,.35),0 0 24px 4px rgba(208,16,18,.45)}.glow-theme-adventure{box-shadow:0 0 0 2px rgba(109,75,216,.35),0 0 24px 4px rgba(109,75,216,.45);animation:pete-glow-pulse 2.4s ease-in-out infinite}@keyframes pete-glow-pulse{0%,to{filter:brightness(1)}50%{filter:brightness(1.08)}}.line-clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.paywall-stamp{position:absolute;inset:0;pointer-events:none;display:flex;align-items:center;justify-content:center;z-index:5;background:rgba(255,250,240,.18)}.paywall-stamp:before{content:"PAYWALLED";font-family:var(--font-display,ui-sans-serif),system-ui,sans-serif;font-weight:900;font-size:clamp(1rem,3.2vw,1.75rem);letter-spacing:.12em;color:rgba(180,30,30,.85);border:.28rem double rgba(180,30,30,.85);padding:.3rem .9rem;transform:rotate(-15deg);text-shadow:0 1px 0 hsla(0,0%,100%,.4);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.15);background:rgba(255,240,230,.55);border-radius:.25rem;filter:contrast(1.05)}:root,html[data-phase=day]{--bg:#fff7e4;--bg-grad:#ffeec2;--card:#fff;--ink:#3a2e1f;--accent:#f2a541}html[data-phase=dawn]{--bg:#ffe7d6;--bg-grad:#ffc9c9;--card:#fff4ea;--ink:#4a2e2a;--accent:#ff8a65}html[data-phase=dusk]{--bg:#ffd6a8;--bg-grad:#f7a07e;--card:#fff1de;--ink:#3d2417;--accent:#e6553a}html[data-phase=night]{--bg:#1a1f3a;--bg-grad:#2a3358;--card:#2d365a;--ink:#f1ecd8;--accent:#f9d976}html[data-room=casinopolis]{--bg:#16211c;--card:#23342b;--ink:#f6ecd8;--accent:#f2b53d;--felt-a:#2f7d5b;--felt-b:#24614a;--felt-c:#1c4d3c;--glow:242,181,61}html[data-room=casino-night]{--bg:#140f2e;--card:#241a4d;--ink:#f2ecff;--accent:#ffcc2f;--felt-a:#4a2fa8;--felt-b:#351f80;--felt-c:#241659;--glow:126,86,255}html[data-room] .cs-room{background:radial-gradient(80% 55% at 50% -8%,rgba(var(--glow),.18),transparent 62%),radial-gradient(120% 90% at 50% 120%,rgba(0,0,0,.45),transparent 55%)}html[data-room=casino-night] .cs-room:before{content:"";position:absolute;inset:0 -50% auto -50%;height:.5rem;background:repeating-linear-gradient(90deg,rgba(255,204,47,.55) 0 .5rem,transparent .5rem 2.25rem);filter:blur(1px);animation:cs-bulbs 2.4s linear infinite}@keyframes cs-bulbs{0%{transform:translateX(0)}to{transform:translateX(2.75rem)}}html[data-room] .shadow-pete{box-shadow:0 4px 0 rgba(0,0,0,.3),0 10px 26px rgba(0,0,0,.3)}html[data-room] .shadow-pete-lg{box-shadow:0 6px 0 rgba(0,0,0,.34),0 20px 40px rgba(0,0,0,.38)}html[data-room] .cs-comb svg{filter:drop-shadow(0 3px 0 rgba(0,0,0,.35))}html[data-room] .pete-felt{background:radial-gradient(120% 90% at 50% -10%,hsla(0,0%,100%,.1),transparent 60%),linear-gradient(160deg,var(--felt-a) 0,var(--felt-b) 55%,var(--felt-c) 100%)}@media (prefers-reduced-motion:reduce){html[data-room=casino-night] .cs-room:before{animation:none}}.cs-stack{display:block;width:2.75rem;height:calc(.45rem*var(--stack, 1) + .55rem);border-radius:999px;background:radial-gradient(circle at 50% 30%,hsla(0,0%,100%,.3),transparent 60%),var(--chip,#e07a5f);border:2px solid rgba(0,0,0,.25);box-shadow:0 3px 0 rgba(0,0,0,.3),inset 0 -.4rem 0 rgba(0,0,0,.16),inset 0 .35rem 0 hsla(0,0%,100%,.14)}.cs-stack[data-chip="5"]{--chip:#5aa9e6}.cs-stack[data-chip="25"]{--chip:#4caf7d}.cs-stack[data-chip="100"]{--chip:#2b2118}.cs-stack[data-chip="500"]{--chip:#b079d6}.last\:border-0:last-child{border-width:0}.empty\:hidden:empty{display:none}.hover\:-translate-y-0\.5:hover{--tw-translate-y:-0.125rem}.hover\:-translate-y-0\.5:hover,.hover\:-translate-y-1:hover{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:-translate-y-1:hover{--tw-translate-y:-0.25rem}.hover\:text-\[color\:var\(--ink\)\]:hover{color:var(--ink)}.hover\:brightness-105:hover{--tw-brightness:brightness(1.05);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.hover\:shadow-pete-lg:hover{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.focus\:border-\[color\:var\(--accent\)\]:focus{border-color:var(--accent)}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.active\:translate-y-px:active{--tw-translate-y:1px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:-rotate-6{--tw-rotate:-6deg}.group:hover .group-hover\:-rotate-6,.group:hover .group-hover\:scale-105{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:scale-105{--tw-scale-x:1.05;--tw-scale-y:1.05}.group:hover .group-hover\:underline{text-decoration-line:underline}@media (min-width:640px){.sm\:ml-auto{margin-left:auto}.sm\:block{display:block}.sm\:inline{display:inline}.sm\:flex{display:flex}.sm\:inline-flex{display:inline-flex}.sm\:h-56{height:14rem}.sm\:basis-auto{flex-basis:auto}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:gap-2{gap:.5rem}.sm\:gap-3{gap:.75rem}.sm\:gap-4{gap:1rem}.sm\:space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.sm\:p-10{padding:2.5rem}.sm\:p-6{padding:1.5rem}.sm\:p-8{padding:2rem}.sm\:pr-28{padding-right:7rem}.sm\:pt-10{padding-top:2.5rem}.sm\:pt-12{padding-top:3rem}.sm\:text-2xl{font-size:1.5rem;line-height:2rem}.sm\:text-3xl{font-size:1.875rem;line-height:2.25rem}.sm\:text-4xl{font-size:2.25rem;line-height:2.5rem}.sm\:text-5xl{font-size:3rem;line-height:1}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-\[auto\2c 1fr\]{grid-template-columns:auto 1fr}.lg\:grid-cols-\[minmax\(0\2c 1fr\)\2c auto\]{grid-template-columns:minmax(0,1fr) auto}.lg\:items-start{align-items:flex-start}.lg\:gap-8{gap:2rem}.lg\:p-8{padding:2rem}}@media (prefers-color-scheme:dark){.dark\:text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dark\:text-emerald-300{--tw-text-opacity:1;color:rgb(110 231 183/var(--tw-text-opacity,1))}.dark\:text-red-300{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.dark\:text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}}
\ No newline at end of file
diff --git a/internal/web/static/js/blackjack.js b/internal/web/static/js/blackjack.js
index 305514e..ba86c45 100644
--- a/internal/web/static/js/blackjack.js
+++ b/internal/web/static/js/blackjack.js
@@ -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 = {
- "♠": '
Buy the deck, win it back a card at a time.
++ 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. +
+ + {{range .Soon}}You buy the deck. Every card you get home buys some of it back.
++ Click a card, then where it goes. Double-click sends it home. +
+ + ++ 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. +
+ +