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 = { - "♠": '', - "♥": '', - "♦": '', - "♣": '' + - '', - }; + var CARDS = window.PeteCards; - // Pip layouts, the way a real deck lays them out — which is not "N suits in a - // row". [x, y] on the 100×140 field. The seven canonical rows sit at y = 27, - // 41, 56, 70, 84, 99, 113; sevens, eights and tens carry a pip *between* two of - // them, which is the whole reason this is a table of coordinates and not a - // grid. Anything below the middle is printed upside down, so it is. - var R = [0, 27, 41.4, 55.7, 70, 84.3, 98.6, 113]; // 1-indexed, R[4] is the middle - var L = 30, C = 50, Rr = 70; // the three columns - - var PIPS = { - "A": [[C, 70, 2.1]], - "2": [[C, R[1]], [C, R[7]]], - "3": [[C, R[1]], [C, R[4]], [C, R[7]]], - "4": [[L, R[1]], [Rr, R[1]], [L, R[7]], [Rr, R[7]]], - "5": [[L, R[1]], [Rr, R[1]], [C, R[4]], [L, R[7]], [Rr, R[7]]], - "6": [[L, R[1]], [Rr, R[1]], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]], - "7": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]], - "8": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [C, 91.5], [L, R[7]], [Rr, R[7]]], - "9": [[L, R[1]], [Rr, R[1]], [L, R[3]], [Rr, R[3]], [C, R[4]], [L, R[5]], [Rr, R[5]], [L, R[7]], [Rr, R[7]]], - "10": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[3]], [Rr, R[3]], [L, R[5]], [Rr, R[5]], [C, 91.5], [L, R[7]], [Rr, R[7]]], - }; - - var COURT = { "J": "Jack", "Q": "Queen", "K": "King" }; - - // One pip: the suit art, scaled and dropped at [x, y], turned over if it sits - // below the middle of the card. - function pipAt(suit, x, y, scale) { - var s = (scale || 1) * 0.17; - var turn = y > 70 ? " rotate(180 50 50)" : ""; - return '' + - SUIT_ART[suit] + ""; - } - - // The corner index: rank over suit. Printed in both corners, the second one - // upside down, which is what lets you read a card from a fanned hand. - function index(face) { - var g = - '' + - '' + face.rank + "" + - '' + SUIT_ART[face.suit] + "" + - ""; - return g + '' + g + ""; - } - - // paintFace draws the card. The dealer's cards and yours use the same face, - // because they came out of the same shoe. - function paintFace(front, face) { - front.dataset.red = face.red ? "1" : "0"; - - var body = ""; - if (COURT[face.rank]) { - // Court cards: a framed panel, the suit above the letter and again below it - // the other way up. A real court mirrors a *figure*; mirroring a letter just - // stacks two of them into a blob, which is exactly what the first attempt - // did. No portrait either — a drawn king would fight the room, and this - // reads instantly at the size a card actually is. - body = - '' + - pipAt(face.suit, 50, 38, 0.95) + - '' + face.rank + "" + - pipAt(face.suit, 50, 102, 0.95); - } else { - var spots = PIPS[face.rank] || []; - for (var i = 0; i < spots.length; i++) { - body += pipAt(face.suit, spots[i][0], spots[i][1], spots[i][2]); - } - } - - front.innerHTML = - '' + index(face) + body + ""; - } - - // "A♠" is not something a screen reader should be asked to pronounce. - function ariaFor(face) { - var SUITS = { "♠": "spades", "♥": "hearts", "♦": "diamonds", "♣": "clubs" }; - var name = COURT[face.rank] || (face.rank === "A" ? "Ace" : face.rank); - var suit = SUITS[face.suit]; - return suit ? name + " of " + suit : face.label; - } - - // turnOver flips a face-down card up, now that we've been told what it is. - function turnOver(wrap, face) { - if (!wrap) return; - paintFace(wrap.querySelector(".pete-card-front"), face); - wrap.dataset.face = "up"; - } + function cardEl(face) { return CARDS.el(face); } + var turnOver = CARDS.turnOver; // ---- the money on the felt ------------------------------------------------- - // - // `staked` is what the spot is holding. Every path that changes it also moves - // chips to say so, so the two can't come apart: renderStack draws the pile, - // and the fly* calls are what put it there. - - function renderStack(amount) { - staked = amount || 0; - stackEl.innerHTML = ""; - spotEl.dataset.live = staked > 0 ? "1" : "0"; - - if (!staked) { - spotTotalEl.classList.add("hidden"); - return; - } - FX.chipsFor(staked).forEach(function (d, i) { - var c = FX.disc(d); - c.style.setProperty("--i", i); - c.style.setProperty("--spin", FX.jitter(i, 12).toFixed(1) + "deg"); - c.style.animationDelay = pace(i * 40) + "ms"; - stackEl.appendChild(c); - }); - spotTotalEl.textContent = staked.toLocaleString(); - spotTotalEl.classList.remove("hidden"); - } - - // pour throws a run of chips from one place to another and grows the pile on - // the spot as each one lands — by the value of the chip that landed, so the - // total under the pile counts up the way the chips do. The last chip carries - // the remainder, because chipsFor caps how many chips it will make you watch - // and the pile still has to end on the real number. - function pour(from, to, amount, opts) { - if (amount <= 0) return Promise.resolve(); - var base = staked; - var chips = FX.chipsFor(amount, 8); - var run = 0; - return FX.flyMany(from, to, chips, Object.assign({ - onLand: function (d, i) { - run += d; - renderStack(base + (i === chips.length - 1 ? amount : run)); - }, - }, opts || {})); - } // stake moves chips from your pile onto the spot: the bet you build before a // deal, and the second bet a double puts down beside it. function stake(amount, from) { - return pour(from || purseEl, spotEl, amount); + return spot.pour(from || purseEl, amount); } // settleChips is what the felt does about the outcome, after the cards have @@ -268,25 +98,17 @@ if (payout <= 0) { // The house takes it. The stack goes to the rack and doesn't come back. - var lost = FX.chipsFor(final.bet, 8); - var chain = FX.flyMany(spotEl, houseEl, lost, { gap: 45, lift: 0.6, fade: true }); - renderStack(0); - return chain; + return spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true }); } // The house pays first, into the spot beside your stake, so you watch the // winnings arrive on top of the bet that earned them. - var pay = pour(houseEl, spotEl, back, { gap: 60 }); - - // Paid, then swept up: the whole lot comes back to your pile, and only then - // does the number in the bar move. - return pay + return spot + .pour(houseEl, back, { gap: 60 }) .then(function () { return wait(back > 0 ? 380 : 200); }) - .then(function () { - var home = FX.flyMany(spotEl, purseEl, FX.chipsFor(payout, 8), { gap: 40, lift: 0.8 }); - renderStack(0); - return home; - }); + // Paid, then swept up: the whole lot comes back to your pile, and only then + // does the number in the bar move. + .then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); }); } function totals(v) { @@ -312,12 +134,12 @@ function paint(v) { dealerEl.innerHTML = ""; playerEl.innerHTML = ""; - if (!v) { setPhase(null); renderStack(0); return; } + if (!v) { setPhase(null); spot.render(0); return; } v.player.forEach(function (c) { playerEl.appendChild(cardEl(c)); }); v.dealer.forEach(function (c) { dealerEl.appendChild(cardEl(c)); }); if (v.hole) dealerEl.appendChild(cardEl(null)); - renderStack(v.phase === "done" ? 0 : v.bet); + spot.render(v.phase === "done" ? 0 : v.bet); totals(v); setPhase(v); } @@ -386,8 +208,8 @@ // first, and a deal whose bet was typed rather than stacked (you kept last // hand's number and just pressed Deal). Either way the chips go down before // the card they're buying does. - if (final && final.bet > staked) { - var extra = final.bet - staked; + if (final && final.bet > spot.amount) { + var extra = final.bet - spot.amount; chain = chain.then(function () { return stake(extra); }); } @@ -499,7 +321,7 @@ say(err.message, "bad"); // Whatever we thought was on the felt, the server is the authority on it. return window.PeteGames.refresh().then(function (v) { - if (v && !v.hand) renderStack(0); + if (v && !v.hand) spot.render(0); }); }) .then(function () { busy = false; }); @@ -531,12 +353,12 @@ // The chip you clicked is the chip that flies: same colour, same size, off // the button and onto the felt. The pile only grows once it gets there — - // but `staked` moves now, so a Deal pressed mid-flight still knows the chip - // is on its way and doesn't put a second one down. + // but the spot's total moves now, so a Deal pressed mid-flight still knows + // the chip is on its way and doesn't put a second one down. var target = bet; - staked = bet; + spot.amount = bet; FX.fly(btn, spotEl, { denom: d }).then(function () { - if (bet >= target) renderStack(target); // unless Clear got there first + if (bet >= target) spot.render(target); // unless Clear got there first }); }); }); @@ -544,10 +366,9 @@ var clearBtn = root.querySelector("[data-bet-clear]"); if (clearBtn) { clearBtn.addEventListener("click", function () { - if (busy || !staked) { bet = 0; showBet(); return; } - FX.flyMany(spotEl, purseEl, FX.chipsFor(staked, 8), { gap: 40, lift: 0.7 }); + if (busy || !spot.amount) { bet = 0; showBet(); return; } + spot.sweep(purseEl, null, { gap: 40, lift: 0.7 }); bet = 0; - renderStack(0); showBet(); }); } diff --git a/internal/web/static/js/casino-cards.js b/internal/web/static/js/casino-cards.js new file mode 100644 index 0000000..c8d663a --- /dev/null +++ b/internal/web/static/js/casino-cards.js @@ -0,0 +1,172 @@ +// The deck, as the room draws it. +// +// A card is drawn, not typed. The first attempt set the pips as text — "♠" in a +// span — and at the size a card actually is, a suit character renders as a speck: +// the shape is whatever font happened to answer, it doesn't scale, and it can't +// be positioned to the half-row a real pip layout needs. +// +// So each face is one SVG on a 100×140 field (the proportions of a real card), +// with the suits as vector shapes. Everything below is coordinates on that field, +// which is why the pips land where a printed deck puts them instead of where a +// flexbox felt like putting them. +// +// This started life inside blackjack.js. It's out here because solitaire deals +// off the same deck, and the second table in a casino is exactly the moment a +// copied card renderer becomes two card renderers that drift. +// +// Exposed as window.PeteCards. Nothing in here knows what game is being played. +(function () { + "use strict"; + + var SUIT_ART = { + "♠": '', + "♥": '', + "♦": '', + "♣": '' + + '', + }; + + // Pip layouts, the way a real deck lays them out — which is not "N suits in a + // row". [x, y] on the 100×140 field. The seven canonical rows sit at y = 27, + // 41, 56, 70, 84, 99, 113; sevens, eights and tens carry a pip *between* two of + // them, which is the whole reason this is a table of coordinates and not a + // grid. Anything below the middle is printed upside down, so it is. + var R = [0, 27, 41.4, 55.7, 70, 84.3, 98.6, 113]; // 1-indexed, R[4] is the middle + var L = 30, C = 50, Rr = 70; // the three columns + + var PIPS = { + "A": [[C, 70, 2.1]], + "2": [[C, R[1]], [C, R[7]]], + "3": [[C, R[1]], [C, R[4]], [C, R[7]]], + "4": [[L, R[1]], [Rr, R[1]], [L, R[7]], [Rr, R[7]]], + "5": [[L, R[1]], [Rr, R[1]], [C, R[4]], [L, R[7]], [Rr, R[7]]], + "6": [[L, R[1]], [Rr, R[1]], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]], + "7": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]], + "8": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [C, 91.5], [L, R[7]], [Rr, R[7]]], + "9": [[L, R[1]], [Rr, R[1]], [L, R[3]], [Rr, R[3]], [C, R[4]], [L, R[5]], [Rr, R[5]], [L, R[7]], [Rr, R[7]]], + "10": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[3]], [Rr, R[3]], [L, R[5]], [Rr, R[5]], [C, 91.5], [L, R[7]], [Rr, R[7]]], + }; + + var COURT = { "J": "Jack", "Q": "Queen", "K": "King" }; + var SUIT_NAMES = { "♠": "spades", "♥": "hearts", "♦": "diamonds", "♣": "clubs" }; + + // One pip: the suit art, scaled and dropped at [x, y], turned over if it sits + // below the middle of the card. + function pipAt(suit, x, y, scale) { + var s = (scale || 1) * 0.17; + var turn = y > 70 ? " rotate(180 50 50)" : ""; + return '' + + SUIT_ART[suit] + ""; + } + + // The corner index: rank over suit. Printed in both corners, the second one + // upside down, which is what lets you read a card from a fanned hand — and in + // solitaire, from a column where all you can see is the top eighth of it. + function index(face) { + var g = + '' + + '' + face.rank + "" + + '' + SUIT_ART[face.suit] + "" + + ""; + return g + '' + g + ""; + } + + // paint draws the face. Every table uses this one, because they all deal out of + // the same deck. + function paint(front, face) { + front.dataset.red = face.red ? "1" : "0"; + + var body = ""; + if (COURT[face.rank]) { + // Court cards: a framed panel, the suit above the letter and again below it + // the other way up. A real court mirrors a *figure*; mirroring a letter just + // stacks two of them into a blob, which is exactly what the first attempt + // did. No portrait either — a drawn king would fight the room, and this + // reads instantly at the size a card actually is. + body = + '' + + pipAt(face.suit, 50, 38, 0.95) + + '' + face.rank + "" + + pipAt(face.suit, 50, 102, 0.95); + } else { + var spots = PIPS[face.rank] || []; + for (var i = 0; i < spots.length; i++) { + body += pipAt(face.suit, spots[i][0], spots[i][1], spots[i][2]); + } + } + + front.innerHTML = + '' + index(face) + body + ""; + } + + // "A♠" is not something a screen reader should be asked to pronounce. + function aria(face) { + var name = COURT[face.rank] || (face.rank === "A" ? "Ace" : face.rank); + var suit = SUIT_NAMES[face.suit]; + return suit ? name + " of " + suit : face.label; + } + + var dealt = 0; // how many cards this page has put down, ever — the tilt seed + + // el builds one card. face === null means face-down: the card is on the table, + // but this browser has not been told what it is. + // + // opts.deal — fly it in from the shoe (blackjack). Solitaire turns this off: + // it animates its own cards from wherever they actually came from, + // and a board that re-renders would otherwise re-deal itself out of + // the corner on every single move. + // opts.tilt — a degree or two of resting angle. A dealt hand wants it; a + // solitaire column does not, because thirteen tilted cards stacked + // an eighth of an inch apart read as a mistake rather than as a + // hand that was handled. + function el(face, opts) { + opts = opts || {}; + var deal = opts.deal !== false; + var tilt = opts.tilt !== false; + + var wrap = document.createElement("div"); + wrap.className = "pete-card"; + wrap.dataset.face = face ? "up" : "down"; + if (face) wrap.dataset.key = face.label; // one deck, so the label is an id + + if (deal) { + // Every card flies out of the shoe, which sits in the top-right of the felt. + wrap.style.setProperty("--deal-x", "14rem"); + wrap.style.setProperty("--deal-y", "-6rem"); + } else { + wrap.style.animation = "none"; + } + wrap.style.setProperty("--tilt", tilt ? window.PeteFX.jitter(dealt++, 2.4).toFixed(2) + "deg" : "0deg"); + + var inner = document.createElement("div"); + inner.className = "pete-card-inner"; + + var front = document.createElement("div"); + front.className = "pete-card-front"; + var back = document.createElement("div"); + back.className = "pete-card-back"; + + inner.appendChild(front); + inner.appendChild(back); + wrap.appendChild(inner); + if (face) paint(front, face); + return wrap; + } + + // turnOver flips a face-down card up, now that we've been told what it is. + function turnOver(wrap, face) { + if (!wrap) return; + paint(wrap.querySelector(".pete-card-front"), face); + wrap.dataset.face = "up"; + wrap.dataset.key = face.label; + } + + window.PeteCards = { + el: el, + paint: paint, + turnOver: turnOver, + aria: aria, + art: SUIT_ART, + }; +})(); diff --git a/internal/web/static/js/casino-fx.js b/internal/web/static/js/casino-fx.js index 5df5e1c..711ed82 100644 --- a/internal/web/static/js/casino-fx.js +++ b/internal/web/static/js/casino-fx.js @@ -144,6 +144,78 @@ return Promise.all(each); } + // A bet spot: the pile of chips sitting on it, and the number printed under + // the pile. + // + // The rule the whole room is built on lives in here, which is why it's one + // object and not two variables on a table: **the number is a readout of the + // pile, never the other way round.** There is no way to change one without the + // other, so a settled game can't leave "your bet: 300" printed over an empty + // circle, and a payout can't be counted before the chips that justify it have + // landed. + // + // els: {spot, stack, total}. Blackjack's spot holds the stake; solitaire's + // holds what you've banked, which grows a card at a time. Same object. + function spot(els) { + var api = { + // What the pile is holding. Written by render, and readable by a table that + // needs to know whether a chip is already on its way down. + amount: 0, + + render: function (n) { + api.amount = n || 0; + els.stack.innerHTML = ""; + if (els.spot) els.spot.dataset.live = api.amount > 0 ? "1" : "0"; + if (!api.amount) { + if (els.total) els.total.classList.add("hidden"); + return; + } + chipsFor(api.amount).forEach(function (d, i) { + var c = disc(d); + c.style.setProperty("--i", i); + c.style.setProperty("--spin", jitter(i, 12).toFixed(1) + "deg"); + c.style.animationDelay = (reduced ? 0 : i * 40) + "ms"; + els.stack.appendChild(c); + }); + if (els.total) { + els.total.textContent = api.amount.toLocaleString(); + els.total.classList.remove("hidden"); + } + }, + + // pour throws a run of chips onto the spot and grows the pile as each one + // lands — by the value of the chip that landed, so the total under the pile + // counts up the way the chips do. The last chip carries the remainder, + // because chipsFor caps how many chips it will make you watch and the pile + // still has to end on the real number. + pour: function (from, amount, opts) { + if (amount <= 0) return Promise.resolve(); + var base = api.amount; + var chips = chipsFor(amount, 8); + var run = 0; + return flyMany(from, els.spot, chips, Object.assign({ + onLand: function (d, i) { + run += d; + api.render(base + (i === chips.length - 1 ? amount : run)); + }, + }, opts || {})); + }, + + // sweep sends chips off the spot to somewhere else — your pile, or the + // house's rack. The spot is emptied *now* rather than when they land, so + // nothing that is already in the air can be bet a second time. + sweep: function (to, amount, opts) { + var n = amount == null ? api.amount : amount; + var left = api.amount - n; + if (n <= 0) return Promise.resolve(); + var chain = flyMany(els.spot, to, chipsFor(n, 8), Object.assign({ gap: 40, lift: 0.8 }, opts || {})); + api.render(left > 0 ? left : 0); + return chain; + }, + }; + return api; + } + // burst: confetti out of a point. Saved for the things worth celebrating. function burst(target, opts) { if (reduced) return Promise.resolve(); @@ -242,6 +314,7 @@ jitter: jitter, fly: fly, flyMany: flyMany, + spot: spot, burst: burst, count: count, centre: centre, diff --git a/internal/web/static/js/solitaire.js b/internal/web/static/js/solitaire.js new file mode 100644 index 0000000..d447130 --- /dev/null +++ b/internal/web/static/js/solitaire.js @@ -0,0 +1,729 @@ +// The solitaire table. +// +// Blackjack plays back a *script*: the server sends one event per card off the +// shoe and the table deals them out in order. That works because a blackjack hand +// only ever grows at one end. Solitaire doesn't: a move takes a run from anywhere +// and puts it anywhere, an auto-finish moves eleven cards at once, and a single +// move can turn a card over three columns away. A script of "append this card +// there" would be a second engine over here, and it would be the one that's wrong. +// +// So this table re-renders the whole board from the server's view after every +// move, and then animates the difference — FLIP: measure where every card *was*, +// re-render, measure where it *is*, and play each card from its old place to its +// new one. The board on screen is therefore always exactly the board the server +// says exists, and the animation is derived from it rather than the other way +// round. The events are still used, for the two things a diff genuinely cannot +// tell you: where a newly-revealed card came from (out of the stock, or turned +// over in place), and what the board is now worth. +// +// The money follows the same rule as every other table in the room: nothing about +// it changes without a chip crossing the felt to make it change. Here the stake +// buys the deck — it goes to the house and does not come back — and the spot in +// the rail holds what you've *banked*, which grows by one card's worth every time +// a card reaches a foundation and shrinks again if you take one back off. +(function () { + "use strict"; + + var root = document.querySelector("[data-solitaire]"); + if (!root) return; + + var FX = window.PeteFX; + var CARDS = window.PeteCards; + + var stockEl = root.querySelector("[data-stock]"); + var stockCountEl = root.querySelector("[data-stock-count]"); + var stockRecycleEl = root.querySelector("[data-stock-recycle]"); + var wasteEl = root.querySelector("[data-waste]"); + var foundEl = root.querySelector("[data-foundations]"); + var tableauEl = root.querySelector("[data-tableau]"); + var verdictEl = root.querySelector("[data-verdict]"); + var homeEl = root.querySelector("[data-home]"); + var perCardEl = root.querySelector("[data-per-card]"); + var breakEvenEl = root.querySelector("[data-break-even]"); + var meterEl = root.querySelector("[data-meter]"); + + var playing = root.querySelector("[data-playing]"); + var betting = root.querySelector("[data-betting]"); + var autoBtn = root.querySelector("[data-auto]"); + var cashBtn = root.querySelector("[data-cash]"); + var cashAmountEl = root.querySelector("[data-cash-amount]"); + var startBtn = root.querySelector("[data-start]"); + var betAmountEl = root.querySelector("[data-bet-amount]"); + var gameMsg = root.querySelector("[data-game-msg]"); + var tableMsg = root.querySelector("[data-table-msg]"); + + var purseEl = document.querySelector("[data-chips]"); + var spotEl = root.querySelector("[data-spot]"); + var houseEl = root.querySelector("[data-house]"); + + // The spot in the rail. On this table it holds what you've banked, not a bet. + var spot = FX.spot({ + spot: spotEl, + stack: root.querySelector("[data-stack]"), + total: root.querySelector("[data-spot-total]"), + }); + + var FULL = 52; + var MOVE_MS = 300; // one card's journey across the board + var STEP_MS = 95; // the gap between two cards in a cascade + var reduced = FX.reduced; + + var bet = 0; // the deck you're building the price of + var tier = null; // which deal + var busy = false; // a request is in flight, or cards are still moving + var board = null; // the board as the server last described it + var held = null; // {pile, count, cards} — the run in your hand + + function wait(ms) { + return new Promise(function (r) { setTimeout(r, reduced ? 0 : ms); }); + } + + function say(el, text, tone) { + if (!text) { el.classList.add("hidden"); return; } + el.textContent = text; + el.classList.remove("hidden"); + el.style.color = tone === "bad" ? "#cc3d4a" : ""; + } + + // ---- the rules, as the *browser* understands them -------------------------- + // + // These mirror the engine, and they are not the engine: the server decides every + // move and will refuse one this file thought was fine. They exist because you + // cannot light up the columns a card can go to without knowing which those are, + // and a table that makes you find that out by being told no is a table that + // teaches Klondike by refusal. When the two disagree the server wins and the + // board snaps back to whatever it says — see send(). + + var RANKS = { A: 1, J: 11, Q: 12, K: 13 }; + function rank(c) { return RANKS[c.rank] || parseInt(c.rank, 10); } + + // isRun: descending by one, alternating colour — the only thing you may lift + // off a column as a block. + function isRun(cs) { + for (var i = 1; i < cs.length; i++) { + if (rank(cs[i]) !== rank(cs[i - 1]) - 1 || cs[i].red === cs[i - 1].red) return false; + } + return true; + } + + // accepts: what may be put where. A foundation takes its own suit in order from + // the ace; a column takes a run that descends and alternates from its top card, + // and an empty column takes a King and nothing else. + function accepts(pile, cs) { + if (!board || !cs || !cs.length) return false; + + if (pile.charAt(0) === "f") { + var f = board.found[parseInt(pile.slice(1), 10)]; + return !!f && cs.length === 1 && cs[0].suit === f.suit && rank(cs[0]) === f.n + 1; + } + + var col = board.table[parseInt(pile.slice(1), 10)]; + if (!col || !isRun(cs)) return false; + if (!col.up || !col.up.length) return col.down === 0 && rank(cs[0]) === 13; + var top = col.up[col.up.length - 1]; + return rank(cs[0]) === rank(top) - 1 && cs[0].red !== top.red; + } + + // cardsAt is the run you'd be picking up by clicking this card. + function cardsAt(pile, idx) { + if (!board) return null; + if (pile === "waste") { + return board.waste && board.waste.length ? [board.waste[board.waste.length - 1]] : null; + } + if (pile.charAt(0) === "f") { + var f = board.found[parseInt(pile.slice(1), 10)]; + return f && f.top ? [f.top] : null; + } + var col = board.table[parseInt(pile.slice(1), 10)]; + if (!col || !col.up || idx >= col.up.length) return null; + return col.up.slice(idx); + } + + // ---- drawing the board ----------------------------------------------------- + + // face builds a card element wired up for clicking. No deal flight and no tilt: + // this table animates its own cards from wherever they actually came from, and a + // column of thirteen tilted cards overlapping by an eighth of an inch reads as a + // mistake rather than as a hand that was handled. + function face(c, pile, idx) { + var el = CARDS.el(c, { deal: false, tilt: false }); + el.dataset.pile = pile; + el.dataset.idx = String(idx); + return el; + } + + function slot(pile, glyph, red) { + var el = document.createElement("div"); + el.className = "pete-slot"; + el.dataset.pile = pile; + if (glyph) { + el.innerHTML = '' + glyph + ""; + } + return el; + } + + function render(v) { + board = v; + if (!v) { + wasteEl.innerHTML = ""; + foundEl.innerHTML = ""; + tableauEl.innerHTML = ""; + stockEl.dataset.dead = "1"; + stockCountEl.classList.add("hidden"); + stockRecycleEl.classList.add("hidden"); + meter(null); + return; + } + + // The stock. Empty with a pass left is not the same thing as empty with none: + // one is a gesture you can still make and the other is a dead pile, and the + // difference is worth showing rather than leaving to a click that gets refused. + var canRecycle = v.stock === 0 && v.waste_n > 0 && (v.passes < 0 || v.passes > 1); + stockEl.dataset.empty = v.stock === 0 ? "1" : "0"; + stockEl.dataset.dead = v.stock === 0 && !canRecycle ? "1" : "0"; + stockCountEl.textContent = String(v.stock); + stockCountEl.classList.toggle("hidden", v.stock === 0); + stockRecycleEl.classList.toggle("hidden", !canRecycle); + + // The waste: the last three, fanned, and only the top one is yours to take. + wasteEl.innerHTML = ""; + (v.waste || []).forEach(function (c, i, all) { + var el = face(c, "waste", i); + if (i === all.length - 1) el.dataset.live = "1"; + wasteEl.appendChild(el); + }); + + // The foundations. Each is a slot that stays put whether or not there's a card + // on it, so the board doesn't reflow the moment an ace goes home — and so a + // drop target has somewhere to be even when it's empty. + foundEl.innerHTML = ""; + v.found.forEach(function (f, i) { + var s = slot("f" + i, f.suit, f.red); + if (f.top) { + var el = face(f.top, "f" + i, 0); + el.dataset.live = "1"; + s.appendChild(el); + } + foundEl.appendChild(s); + }); + + // The seven columns. + tableauEl.innerHTML = ""; + v.table.forEach(function (col, i) { + var c = document.createElement("div"); + c.className = "pete-col"; + c.dataset.pile = "t" + i; + + if (!col.down && !(col.up && col.up.length)) { + c.appendChild(slot("t" + i)); + } + for (var d = 0; d < col.down; d++) { + c.appendChild(CARDS.el(null, { deal: false, tilt: false })); + } + (col.up || []).forEach(function (card, j) { + var el = face(card, "t" + i, j); + // A card is pickable if the run from it down is a run. Anything else is a + // card you can see and can't lift, and it shouldn't offer. + if (isRun(col.up.slice(j))) el.dataset.live = "1"; + c.appendChild(el); + }); + tableauEl.appendChild(c); + }); + + meter(v); + controls(v); + if (held) mark(); // a selection survives a re-render, so redraw what it lit + } + + // meter is what the board is worth. Every number in it comes off the server — + // this file does no arithmetic about money, which is the point. + function meter(v) { + if (!v) { + homeEl.innerHTML = '0/' + FULL + ""; + perCardEl.textContent = "—"; + breakEvenEl.textContent = ""; + return; + } + homeEl.innerHTML = v.home + '/' + FULL + ""; + perCardEl.textContent = "+" + v.per_card.toFixed(1); + breakEvenEl.textContent = + v.home >= v.break_even + ? "You're ahead of the house" + : v.break_even - v.home + " more to break even"; + meterEl.dataset.cold = v.home === 0 ? "1" : "0"; + } + + function controls(v) { + var live = !!v && v.phase === "playing"; + playing.classList.toggle("hidden", !live); + betting.classList.toggle("hidden", live); + if (!live) return; + autoBtn.disabled = !v.can_auto; + cashAmountEl.textContent = (v.stands || 0).toLocaleString(); + } + + // ---- FLIP ------------------------------------------------------------------ + // + // Where every card is, right now. One deck, so a card's label is its identity. + + function snapshot() { + var map = {}; + root.querySelectorAll(".pete-card[data-key]").forEach(function (el) { + map[el.dataset.key] = el.getBoundingClientRect(); + }); + map["#stock"] = stockEl.getBoundingClientRect(); + return map; + } + + // plan reads the events for the two things a before/after diff can't tell you: + // where a card that is *new to the board* came from, and how much of a beat to + // leave before it moves, so that an auto-finish cascades rather than teleporting. + function planOf(events) { + var origins = {}, delays = {}, at = 0; + (events || []).forEach(function (e) { + (e.cards || []).forEach(function (c) { + if (e.kind === "draw") origins[c.label] = "draw"; + if (e.kind === "flip") origins[c.label] = "flip"; + delays[c.label] = at; + }); + if (e.kind === "move" || e.kind === "home") at += STEP_MS; + }); + return { origins: origins, delays: delays }; + } + + // animate plays every card from where it was to where it is. + function animate(before, plan) { + if (reduced) return Promise.resolve(); + var waits = []; + + root.querySelectorAll(".pete-card[data-key]").forEach(function (el) { + var key = el.dataset.key; + var now = el.getBoundingClientRect(); + var was = before[key]; + var delay = plan.delays[key] || 0; + var origin = plan.origins[key]; + + // A card that was already on the board: play it from its old place. This is + // every ordinary move, and it is also what makes an eleven-card auto-finish + // animate itself for free. + if (was && !origin) { + var dx = was.left - now.left; + var dy = was.top - now.top; + if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) return; + waits.push(slide(el, dx, dy, delay)); + return; + } + + // A card that has just been turned over. Out of the stock it flies as well as + // turns; in a column it turns where it lies. + if (origin === "draw") { + var from = before["#stock"]; + waits.push(slide(el, from.left - now.left, from.top - now.top, delay)); + waits.push(turn(el, delay)); + } else if (origin === "flip") { + waits.push(turn(el, delay)); + } + }); + + return Promise.all(waits); + } + + function slide(el, dx, dy, delay) { + return el.animate( + [{ transform: "translate(" + dx + "px," + dy + "px)" }, { transform: "none" }], + { + duration: MOVE_MS, + delay: delay, + easing: "cubic-bezier(0.22, 1, 0.36, 1)", + fill: "backwards", + } + ).finished.catch(noop); + } + + // The card turns over on its own axis. The wrapper is doing the travelling, so + // this has to be the inner face or the two transforms would fight. + function turn(el, delay) { + var inner = el.querySelector(".pete-card-inner"); + return inner.animate( + [{ transform: "rotateY(180deg)" }, { transform: "rotateY(0deg)" }], + { duration: MOVE_MS, delay: delay, easing: "cubic-bezier(0.4, 0, 0.2, 1)", fill: "backwards" } + ).finished.catch(noop); + } + + // The recycle is the one move where cards *leave* the board, so FLIP has nothing + // to animate: they're gone from the new render before it can measure them. They + // get their flight here instead, out of the old DOM, before it's replaced. + function recycleOut() { + if (reduced) return Promise.resolve(); + var to = stockEl.getBoundingClientRect(); + var waits = []; + wasteEl.querySelectorAll(".pete-card").forEach(function (el, i) { + var now = el.getBoundingClientRect(); + waits.push( + el.animate( + [ + { transform: "none", opacity: 1 }, + { transform: "translate(" + (to.left - now.left) + "px," + (to.top - now.top) + "px) rotateY(180deg)", opacity: 1 }, + ], + { duration: 260, delay: i * 50, easing: "cubic-bezier(0.4, 0, 1, 1)", fill: "forwards" } + ).finished.catch(noop) + ); + }); + return Promise.all(waits); + } + + function noop() {} + + // A card reaching a foundation is the only move in this game that pays you, so + // it's the only one that makes a noise about it. + function flashHome(events) { + if (reduced) return; + var at = 0; + (events || []).forEach(function (e) { + if (e.kind !== "home" || !e.to) { + if (e.kind === "move") at += STEP_MS; + return; + } + var when = at + MOVE_MS; + at += STEP_MS; + setTimeout(function () { + var pile = foundEl.querySelector('[data-pile="' + e.to + '"]'); + if (!pile) return; + pile.classList.remove("pete-home-flash"); + void pile.offsetWidth; // restart the animation if it's still running + pile.classList.add("pete-home-flash"); + }, when); + }); + } + + // ---- the money ------------------------------------------------------------- + + // bank moves the spot to what the server says the board is worth. Up is chips + // out of the house's rack; down — a card taken back off a foundation — is chips + // going back to it. Either way the pile moves before the number does. + function bank(pays) { + var delta = (pays || 0) - spot.amount; + if (delta === 0) return Promise.resolve(); + if (delta > 0) return spot.pour(houseEl, delta, { gap: 55 }); + return spot.sweep(houseEl, -delta, { gap: 40, lift: 0.6, fade: true }); + } + + // ---- picking cards up ------------------------------------------------------ + + function mark() { + root.querySelectorAll('[data-held="1"]').forEach(function (el) { delete el.dataset.held; }); + root.querySelectorAll('[data-drop="1"]').forEach(function (el) { delete el.dataset.drop; }); + if (!held) return; + + // The run in your hand lifts off the felt. + root.querySelectorAll('.pete-card[data-pile="' + held.pile + '"]').forEach(function (el) { + if (held.pile === "waste" || held.pile.charAt(0) === "f") { + if (el.dataset.live === "1") el.dataset.held = "1"; + } else if (parseInt(el.dataset.idx, 10) >= held.idx) { + el.dataset.held = "1"; + } + }); + + // And everywhere it could go lights up. This is the whole reason the rules are + // mirrored over here: being shown where a card goes is the game teaching you, + // and being told no after you commit is the game scolding you. + root.querySelectorAll("[data-pile]").forEach(function (el) { + var pile = el.dataset.pile; + if (pile === held.pile || pile === "waste" || el.classList.contains("pete-card")) return; + if (accepts(pile, held.cards)) el.dataset.drop = "1"; + }); + } + + function pick(pile, idx) { + var cs = cardsAt(pile, idx); + if (!cs || !isRun(cs)) return; + held = { pile: pile, idx: idx, count: cs.length, cards: cs }; + mark(); + } + + function drop() { + held = null; + mark(); + } + + function nope(el) { + if (!el || reduced) return; + el.classList.remove("pete-nope"); + void el.offsetWidth; + el.classList.add("pete-nope"); + } + + // A click on the board. The order matters: if something is in your hand and the + // thing you clicked will take it, that's a move — otherwise it's you picking up + // something else. Which means you never have to put a card down before choosing + // a different one. + root.querySelector(".pete-felt").addEventListener("click", function (e) { + if (busy || !board || board.phase !== "playing") return; + if (e.target.closest("[data-stock]")) return; // the stock has its own handler + + var pileEl = e.target.closest("[data-pile]"); + if (!pileEl) { drop(); return; } + + var cardEl = e.target.closest(".pete-card[data-key]"); + var pile = pileEl.dataset.pile; + + if (held) { + if (pile === held.pile) { drop(); return; } // clicking your own run puts it down + if (accepts(pile, held.cards)) { + var move = { kind: "move", from: held.pile, to: pile, count: held.count }; + drop(); + send(move); + return; + } + // Not a place it goes. If what you clicked is a card you *could* pick up, + // this was a change of mind rather than a bad move; only shake at a genuine + // dead end. + if (!cardEl || cardEl.dataset.live !== "1") { nope(pileEl); return; } + } + + if (cardEl && cardEl.dataset.live === "1") { + pick(pile, parseInt(cardEl.dataset.idx, 10)); + } else { + drop(); + } + }); + + // Double-click sends a card home. It's the idiom every solitaire has used for + // thirty years, and the alternative is asking the player which foundation — a + // question with exactly one right answer. + root.addEventListener("dblclick", function (e) { + if (busy || !board || board.phase !== "playing") return; + var cardEl = e.target.closest('.pete-card[data-live="1"]'); + if (!cardEl) return; + var pile = cardEl.dataset.pile; + if (pile.charAt(0) === "f") return; // it's already home + e.preventDefault(); + drop(); + send({ kind: "home", from: pile }); + }); + + stockEl.addEventListener("click", function () { + if (busy || !board || board.phase !== "playing") return; + if (stockEl.dataset.dead === "1") { + say(gameMsg, "That was your last pass through the stock.", "bad"); + nope(stockEl); + return; + } + drop(); + send({ kind: "draw" }); + }); + + autoBtn.addEventListener("click", function () { drop(); send({ kind: "auto" }); }); + + cashBtn.addEventListener("click", function () { + drop(); + send({ kind: "concede" }); + }); + + document.addEventListener("keydown", function (e) { + if (e.metaKey || e.ctrlKey || e.altKey) return; + if (/^(input|textarea|select)$/i.test(e.target.tagName || "")) return; + if (e.key === "Escape") { drop(); return; } + if (busy || !board || board.phase !== "playing") return; + + var k = e.key.toLowerCase(); + if (k === " " || k === "d") { e.preventDefault(); stockEl.click(); } + else if (k === "a" && !autoBtn.disabled) { e.preventDefault(); autoBtn.click(); } + }); + + // ---- talking to the table -------------------------------------------------- + + var VERDICTS = { + cleared: "Cleared the board! 🎉", + cashed: "Board cashed.", + }; + + function verdict(v) { + var text = VERDICTS[v.outcome] || ""; + if (!text) { verdictEl.classList.add("hidden"); return; } + if (v.net > 0) text += " +" + v.net.toLocaleString(); + else if (v.net < 0) text += " " + v.net.toLocaleString(); + verdictEl.textContent = text; + verdictEl.classList.remove("hidden"); + // Clearing 52 cards out of a Vegas deal is the rarest thing that happens in + // this room, so it's the one that gets the confetti. + if (v.outcome === "cleared") FX.burst(verdictEl, { count: 40 }); + } + + // play walks a server response onto the felt: the board is re-rendered, the + // cards animate from where they were, and the chips follow. + // + // `money` — the chip bar catching up — is held back on a *settling* board until + // the payout has physically swept home. A counter that pays you before the chips + // arrive is a counter that has told you the ending. + function play(view, money) { + var v = view.solitaire; + var events = view.sol_events || []; + var settles = !!v && v.phase === "done"; + var recycled = events.some(function (e) { return e.kind === "recycle"; }); + + var pre = recycled ? recycleOut() : Promise.resolve(); + + return pre + .then(function () { + var before = snapshot(); + render(v); + var plan = planOf(events); + flashHome(events); + return animate(before, plan); + }) + .then(function () { + if (!v) { money(); return; } + return bank(v.stands).then(function () { + if (!settles) { money(); return; } + // The board is finished. Everything banked comes home, and only then + // does the number in the bar move. + verdict(v); + return wait(300) + .then(function () { return spot.sweep(purseEl, v.payout, { gap: 40, lift: 0.8 }); }) + .then(money) + .then(function () { controls(null); showBet(); }); + }); + }); + } + + function send(move) { + if (busy) return; + busy = true; + say(gameMsg, ""); + return window.PeteGames.post("/api/games/solitaire/move", move) + .then(function (view) { return play(view, function () { window.PeteGames.apply(view); }); }) + .catch(function (err) { + say(gameMsg, err.message, "bad"); + // Whatever this file thought the board was, the server is the authority on + // it. Ask, and draw what it says. + return window.PeteGames.refresh().then(function (v) { + if (v) { render(v.solitaire || null); spot.render(v.solitaire ? v.solitaire.stands : 0); } + }); + }) + .then(function () { busy = false; }); + } + + // ---- buying a deck --------------------------------------------------------- + + function showBet() { + betAmountEl.textContent = bet.toLocaleString(); + var money = window.PeteGames.view(); + startBtn.disabled = bet <= 0 || !tier || !money || money.chips < bet; + } + + root.querySelectorAll("[data-tier]").forEach(function (btn) { + btn.addEventListener("click", function () { + tier = btn.dataset.tier; + root.querySelectorAll("[data-tier]").forEach(function (b) { + b.dataset.on = b === btn ? "1" : "0"; + }); + showBet(); + }); + }); + + // The chip you click is the chip that flies. It lands on the spot in the rail, + // which is where the price of the deck is stacked up before you pay it. + root.querySelectorAll("[data-chip]").forEach(function (btn) { + if (!btn.dataset.chip || btn.tagName !== "BUTTON") return; + btn.addEventListener("click", function () { + if (busy) return; + var d = parseInt(btn.dataset.chip, 10); + var money = window.PeteGames.view(); + if (money && bet + d > money.chips) { + say(tableMsg, "You haven't got that many chips.", "bad"); + return; + } + bet += d; + showBet(); + + var target = bet; + spot.amount = bet; + FX.fly(btn, spotEl, { denom: d }).then(function () { + if (bet >= target) spot.render(target); // unless Clear got there first + }); + }); + }); + + root.querySelector("[data-bet-clear]").addEventListener("click", function () { + if (busy) return; + if (spot.amount) spot.sweep(purseEl, null, { gap: 40, lift: 0.7 }); + bet = 0; + showBet(); + }); + + startBtn.addEventListener("click", function () { + if (busy) return; + if (!tier) { say(tableMsg, "Pick a deal first.", "bad"); return; } + if (bet <= 0) { say(tableMsg, "Put something down for the deck.", "bad"); return; } + + busy = true; + say(tableMsg, ""); + var price = bet; + + window.PeteGames.post("/api/games/solitaire/start", { bet: price, tier: tier }) + .then(function (view) { + // The deck is *bought*: the chips on the spot go across to the house and + // do not come back. Then the spot starts again at nothing, and from here + // on it holds what the board has earned back. + return spot + .sweep(houseEl, price, { gap: 45, lift: 0.6 }) + .then(function () { + bet = 0; + showBet(); + window.PeteGames.apply(view); + return dealOut(view.solitaire); + }); + }) + .catch(function (err) { + say(tableMsg, err.message, "bad"); + return window.PeteGames.refresh(); + }) + .then(function () { busy = false; }); + }); + + // dealOut lays the board and flies every card onto it out of the stock, one at a + // time, across the columns — the way a deal actually goes down. + function dealOut(v) { + render(v); + if (reduced || !v) return Promise.resolve(); + + var from = stockEl.getBoundingClientRect(); + var waits = []; + + // The order a real deal goes in: one card to each column, then round again, + // starting a column further along each time. + var order = 0; + for (var row = 0; row < 7; row++) { + for (var col = row; col < 7; col++) { + var colEl = tableauEl.children[col]; + var cardEl = colEl.children[row]; + if (!cardEl) continue; + var now = cardEl.getBoundingClientRect(); + waits.push(slide(cardEl, from.left - now.left, from.top - now.top, order * 34)); + if (cardEl.dataset.face === "up") waits.push(turn(cardEl, order * 34)); + order++; + } + } + return Promise.all(waits); + } + + // ---- coming in ------------------------------------------------------------ + + // The money bar owns the first fetch. The table picks up whatever it found — + // including a board left mid-game by a reload or a redeploy, which comes back + // exactly as it was, right down to what it has banked. + var resumed = false; + window.PeteGames.onUpdate(function (v) { + if (!resumed) { + resumed = true; + if (v.solitaire) { + render(v.solitaire); + spot.render(v.solitaire.stands); + } else { + controls(null); + } + } + showBet(); + }); +})(); diff --git a/internal/web/templates/blackjack.html b/internal/web/templates/blackjack.html index e89a77f..bf13c55 100644 --- a/internal/web/templates/blackjack.html +++ b/internal/web/templates/blackjack.html @@ -127,6 +127,7 @@ {{define "scripts"}} + {{end}} diff --git a/internal/web/templates/games.html b/internal/web/templates/games.html index d30a923..1c3de16 100644 --- a/internal/web/templates/games.html +++ b/internal/web/templates/games.html @@ -58,6 +58,22 @@

+ +
+ 🂡 +
+

Solitaire

+

Buy the deck, win it back a card at a time.

+
+ Open +
+

+ 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}}
diff --git a/internal/web/templates/solitaire.html b/internal/web/templates/solitaire.html new file mode 100644 index 0000000..86cffc9 --- /dev/null +++ b/internal/web/templates/solitaire.html @@ -0,0 +1,168 @@ +{{define "title"}}Solitaire · {{.Room.Name}}{{end}} + +{{define "main"}} +
+ +
+ +

You buy the deck. Every card you get home buys some of it back.

+
+ + {{template "_chipbar" .}} + + +
+
+ + +
+ + +
+
+ +
+
+ +
+
+ + +
+ + +
+ +
+
+ + + + +
+
+ + + + + +
+ +
Which deal?
+
+ {{range .Deals}} + + {{end}} +
+ +
+
+
The deck costs
+
0
+
+ +
+ {{range .Denominations}} + + {{end}} + +
+ + +
+

+ 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. +

+ +
+ +
+{{end}} + +{{define "scripts"}} + + + + +{{end}} diff --git a/pete_games_plan.md b/pete_games_plan.md index a11efc7..1270f96 100644 --- a/pete_games_plan.md +++ b/pete_games_plan.md @@ -184,9 +184,60 @@ A multi-session build. This section is the handover; read it before anything els the rack's clearance padding was on the whole column instead of the one row level with it. +- **Solitaire, and it plays for chips.** *(2026-07-14, jumping the queue ahead of + trivia because the user asked for it.)* + - **Vegas scoring**, which is the only way solitaire has ever actually been a + gambling game. 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 fifty-second of the + tier's multiple back. Cash the board whenever you like and keep what you've + banked; a board that has gone dead is therefore a decision, not a wall. There + is no undo, because 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** + (draw 1, unlimited passes, 1.4×, square at 38 cards), **Vegas** (draw 3, three + passes, 2.2×, square at 24), **Cutthroat** (draw 3, one pass, 3.4×, square at + 16). `Tier.BreakEven()` is what the felt quotes, because "2.2×" tells a player + nothing about a game where the multiple is paid a card at a time. + - `internal/games/klondike` — the same pure reducer. `Pays()` is one function for + the same reason hangman's is. Two fuzzers hold the deck together: no card is + ever lost or duplicated by any sequence of moves, and the board stays + well-formed (every face-up run is a run, no column has cards face-down under + nothing). The first thing a test caught was a **recycle that reversed the + waste** — it flips as a block, so the card drawn first comes out first, and + reversing would have dealt a different game on every pass and broken the seed. + - **The browser never sees the stock or a face-down card.** Bigger than + blackjack's hole card: that's most of the deck. Columns send a face-down + *count*, never the cards. The events, unlike blackjack's, need no filtering — + every card they carry is one the move just turned face up. + - **The table re-renders and animates the difference (FLIP).** Blackjack plays + back a script because a hand only grows at one end; solitaire moves runs from + anywhere to anywhere and an auto-finish moves eleven cards at once. So + `solitaire.js` measures where every card is, re-renders the board the server + sent, and plays each card from its old place to its new one. The board on + screen is therefore always exactly the board the server says exists. The events + supply only what a diff can't: where a *newly revealed* card came from (the + stock, or a flip in place) and what the board is now worth. + - **The rules are mirrored in JS**, deliberately, and only to light up the columns + a held card can go to. The server still decides every move; a disagreement + snaps the board back to whatever it says. Being shown where a card goes is the + game teaching you; being told no after you commit is the game scolding you. + - Two things got extracted rather than copied, which is the rule this room runs + on: **`casino-cards.js`** (the deck — faces, pips, the flip; was inside + blackjack.js) and **`PeteFX.spot()`** (the pile of chips and the number under + it, which owns the "the number is a readout of the pile" rule so no table can + break it). Blackjack now uses both. + - **NOT YET DRIVEN IN A BROWSER.** Everything compiles and every Go test passes, + but per this plan's own hard-won rule, *that means nothing about the table*. + Next session: `PETE_DEV_CASINO=:7788 go test ./internal/web -run TestDevCasino + -timeout 0` and play it. Watch especially: the seven columns fitting on a phone + (`--card-w` is a clamp on vw), the FLIP not jumping on a re-render, the rail not + colliding with anything, and **blackjack still settling correctly** — its money + was rewired onto the shared spot and it is the thing most likely to have broken. + ### Next, in order -1. Phase 2's other half: **trivia**. Decided but not built: the question bank is +1. **Drive solitaire in a browser** (see above) — it has never been played. +2. Phase 2's other half: **trivia**. Decided but not built: the question bank is **prefetched from OpenTDB into a local table** (a per-question fetch in a web game loop is a latency and rate-limit problem gogobee never had), through `internal/safehttp`. It stakes chips too. The shape that fits the room is a