From 79c857023f13a8061989ec4d9efb17995a424a7a Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:07:17 -0700 Subject: [PATCH] games: a table of bots you have to beat to the last card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UNO, played for chips. You stake once, sit down against one to three bots, and going out first pays the table: 2.2x heads up, 3.6x against a full house. Anybody else going out first takes the stake. The table size is the tier, because it is the only dial UNO has. The bots move inside ApplyMove. A game with opponents is normally where you reach for a socket, and the plan says solo UNO must not — so one request plays your move and every bot turn behind it, and hands back the whole lap as a script the felt plays in order. The RNG is in the state rather than an argument to it: the bots choose and a spent deck reshuffles, so the engine needs randomness mid-game, and there is no generator alive across requests to pass in. The seed rides in the state and each step derives its own. The game still replays exactly as it fell. The zero value of Color is Wild, and that is the whole point of it: a wild played with the colour field missing from the JSON must be refused, not quietly played as a red one. It was red for an hour. The browser never sees a bot's card — not the deck, not a hand, not the face of a card a bot drew, which is most of the deck. Seats cross the wire as a name and a count. The multiples are measured, not guessed: playing the first legal card you hold wins 43/32/27% of the time against these bots, so the tiers price that to lose about 8% a game and leave good play worth roughly the house's edge. PeteFX.flyNode is the throw with the chip taken out of it, so a card can be thrown across the felt the same way. fly() is now that with a chip in it. Not yet driven in a browser, which in this room means not yet finished. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ --- internal/games/uno/bot.go | 132 +++++ internal/games/uno/uno.go | 810 ++++++++++++++++++++++++++++ internal/games/uno/uno_test.go | 736 +++++++++++++++++++++++++ internal/web/games_pages.go | 15 +- internal/web/games_play.go | 12 + internal/web/games_uno.go | 284 ++++++++++ internal/web/games_uno_test.go | 147 +++++ internal/web/static/css/input.css | 340 ++++++++++++ internal/web/static/css/output.css | 2 +- internal/web/static/js/casino-fx.js | 37 +- internal/web/static/js/uno.js | 686 +++++++++++++++++++++++ internal/web/templates/games.html | 16 + internal/web/templates/uno.html | 178 ++++++ pete_games_plan.md | 60 ++- 14 files changed, 3438 insertions(+), 17 deletions(-) create mode 100644 internal/games/uno/bot.go create mode 100644 internal/games/uno/uno.go create mode 100644 internal/games/uno/uno_test.go create mode 100644 internal/web/games_uno.go create mode 100644 internal/web/games_uno_test.go create mode 100644 internal/web/static/js/uno.js create mode 100644 internal/web/templates/uno.html diff --git a/internal/games/uno/bot.go b/internal/games/uno/bot.go new file mode 100644 index 0000000..9aa02e0 --- /dev/null +++ b/internal/games/uno/bot.go @@ -0,0 +1,132 @@ +package uno + +import "math/rand/v2" + +// The bots. +// +// Lifted from the ones gogobee's UNO plays in Matrix, which are genuinely decent +// company — they hold their wild draw fours back until you're close to going +// out, they follow the colour in play when they can, and they get out of the way +// of their own hand. Two things changed on the way over: +// +// The RNG is threaded. gogobee's bots reach for the package global, which is why +// its own tests can only assert that a bot played *something* legal. These take +// the game's generator, so a bot's choice is part of what a seed replays. +// +// They are not the same bot at every table. A single deterministic policy is a +// puzzle: play round it once and it never surprises you again. So the bot takes +// the best card it sees most of the time, and now and then takes the second best +// — enough that you cannot count what it is holding by what it plays. + +// botSlip is how often a bot takes its second choice instead of its first. Low +// enough that it still plays well, high enough that it isn't a lookup table. +const botSlip = 6 // one turn in six + +// botPick chooses a card to play, or reports -1 when the bot has nothing legal. +func botPick(hand []Card, top Card, topColor Color, minOpponent int, rng *rand.Rand) (Card, int) { + var playable []int + for i, c := range hand { + if c.CanPlayOn(top, topColor) { + playable = append(playable, i) + } + } + if len(playable) == 0 { + return Card{}, -1 + } + + order := botRank(hand, topColor, playable, minOpponent) + pick := order[0] + if len(order) > 1 && rng.IntN(botSlip) == 0 { + pick = order[1] + } + return hand[pick], pick +} + +// botRank sorts the playable cards best-first, by the bot's own priorities. +// +// The shape of it: hurt the leader if there is one, otherwise get rid of +// something useful and keep the wilds back. A wild draw four spent early is a +// wild draw four you don't have when somebody is sitting on one card. +func botRank(hand []Card, topColor Color, playable []int, minOpponent int) []int { + var wd4, wilds, actions, numbers []int + for _, i := range playable { + switch c := hand[i]; { + case c.Value == WildDrawFour: + wd4 = append(wd4, i) + case c.Value == WildCard: + wilds = append(wilds, i) + case c.Value.Action(): + actions = append(actions, i) + default: + numbers = append(numbers, i) + } + } + + // Following the colour in play is worth more than not, because it keeps the + // bot's hand flexible — so within each group, the cards already in colour go + // first. + inColorFirst := func(idx []int) []int { + var same, other []int + for _, i := range idx { + if hand[i].Color == topColor { + same = append(same, i) + } else { + other = append(other, i) + } + } + return append(same, other...) + } + + var out []int + if minOpponent >= 0 && minOpponent <= 2 { + // Somebody is about to go out. This is what the +4 was being saved for. + out = append(out, wd4...) + out = append(out, inColorFirst(actions)...) + out = append(out, wilds...) + out = append(out, inColorFirst(numbers)...) + return out + } + out = append(out, inColorFirst(actions)...) + out = append(out, inColorFirst(numbers)...) + out = append(out, wilds...) + out = append(out, wd4...) + return out +} + +// botColor names a colour for a wild: whichever the bot holds most of, so the +// card it plays next is one it already has. A hand of nothing but wilds picks +// at random rather than always saying red, which would be a tell. +func botColor(hand []Card, rng *rand.Rand) Color { + counts := [5]int{} + for _, c := range hand { + if c.Color.Playable() { + counts[c.Color]++ + } + } + best, bestN := Wild, 0 + for col := Red; col <= Green; col++ { + if counts[col] > bestN { + best, bestN = col, counts[col] + } + } + if bestN == 0 { + return Red + Color(rng.IntN(4)) + } + return best +} + +// botNames deals the bots their names. Flavour, and load-bearing flavour: "Kiwi +// played a +4" is a table, "Bot 2 played a +4" is a test fixture. +func botNames(n int, rng *rand.Rand) []string { + pool := append([]string(nil), botPool...) + rng.Shuffle(len(pool), func(i, j int) { pool[i], pool[j] = pool[j], pool[i] }) + if n > len(pool) { + n = len(pool) + } + return pool[:n] +} + +var botPool = []string{ + "Kiwi", "Mochi", "Bramble", "Pixel", "Gus", "Nori", "Waffle", "Marzipan", + "Tuck", "Bebop", "Olive", "Rascal", "Peaches", "Dot", "Sable", "Clementine", +} diff --git a/internal/games/uno/uno.go b/internal/games/uno/uno.go new file mode 100644 index 0000000..be464b5 --- /dev/null +++ b/internal/games/uno/uno.go @@ -0,0 +1,810 @@ +// Package uno is a pure UNO engine, played for chips against bots. +// +// Same seam as the other four tables: ApplyMove(state, move) (state, events, +// error), where an error means the move was illegal and nothing else. No HTTP, +// no timers, no sockets, no player names off the wire. The state is a plain +// value, so a game survives a redeploy and replays from its seed. +// +// Two things make UNO different from the tables already on the felt. +// +// The bots move inside ApplyMove. A turn-based game against opponents is +// normally where you reach for a socket, and the plan says solo UNO must not: +// so one call from the browser plays the player's move *and* every bot turn that +// follows it, and hands back the whole run as events. The table animates them in +// order. The browser is never waiting on the server to think of something. +// +// The RNG is in the state, not an argument. The bots make choices and a spent +// deck gets reshuffled, so the engine needs randomness mid-game — but a reducer +// that takes an rng is a reducer whose caller has to keep one alive across +// requests, and there isn't one: every move is a fresh process for all it knows. +// So the seed rides in the state (which never leaves the server; the deck is in +// there too) and each step derives its own generator from seed and step count. +// Value in, value out, and the game still replays exactly as it was dealt. +package uno + +import ( + "errors" + "math" + "math/rand/v2" +) + +// Errors an illegal move can produce. +var ( + ErrGameOver = errors.New("uno: the game is already over") + ErrNotYourTurn = errors.New("uno: it isn't your turn") + ErrNoSuchCard = errors.New("uno: you don't have that card") + ErrCantPlay = errors.New("uno: that card can't go on this one") + ErrNeedColor = errors.New("uno: pick a colour for the wild") + ErrCantPass = errors.New("uno: you can only pass on a card you just drew") + ErrMustPlayNow = errors.New("uno: play the card you drew, or pass") + ErrUnknownMove = errors.New("uno: unknown move") + ErrBadBet = errors.New("uno: bet must be positive") + ErrUnknownTier = errors.New("uno: no such tier") +) + +// You are always seat zero. The bots are the seats after you. +const You = 0 + +// HandSize is the deal. Seven each, as printed on the box. +const HandSize = 7 + +// Color is a card's colour. Wild has none until it's played. +// +// Wild is deliberately the zero value. A wild played with no colour named is the +// one move in this game that must never be allowed to mean something, and a +// browser that leaves `color` out of the JSON sends a zero — so the zero has to +// be "no colour", not red. It was red for about an hour, and a wild with the +// field missing quietly went down as a red one. +type Color uint8 + +const ( + Wild Color = iota + Red + Blue + Yellow + Green +) + +var colorNames = [5]string{"wild", "red", "blue", "yellow", "green"} + +func (c Color) String() string { + if c > Green { + return "?" + } + return colorNames[c] +} + +// Playable reports whether a colour is one a wild may name — Wild itself isn't. +func (c Color) Playable() bool { return c >= Red && c <= Green } + +// Value is what's printed on the face. +type Value uint8 + +const ( + Zero Value = iota + One + Two + Three + Four + Five + Six + Seven + Eight + Nine + Skip + Reverse + DrawTwo + WildCard + WildDrawFour +) + +var valueNames = [15]string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", + "skip", "reverse", "+2", "wild", "+4"} + +func (v Value) String() string { + if v > WildDrawFour { + return "?" + } + return valueNames[v] +} + +// Action reports whether a card does something beyond being a number. +func (v Value) Action() bool { return v >= Skip } + +// Card is one card. Short JSON keys: a hand of these crosses the wire on every +// poll, and a state holds all 108. +type Card struct { + Color Color `json:"c"` + Value Value `json:"v"` +} + +// IsWild reports whether the card has no colour of its own. +func (c Card) IsWild() bool { return c.Value == WildCard || c.Value == WildDrawFour } + +// CanPlayOn is the whole rule of UNO: match the colour in play, or match the +// face, or be a wild. Note it takes the colour *in play* rather than the top +// card's own colour — after a wild those are different, and the one that counts +// is the colour that was named. +func (c Card) CanPlayOn(top Card, topColor Color) bool { + if c.IsWild() { + return true + } + return c.Color == topColor || c.Value == top.Value +} + +// NewDeck builds the 108: one zero and two each of 1-9, skip, reverse and +2 in +// every colour, plus four wilds and four wild draw fours. Unshuffled — Deal +// shuffles, and a test wants the fixed order. +func NewDeck() []Card { + d := make([]Card, 0, 108) + for _, col := range []Color{Red, Blue, Yellow, Green} { + d = append(d, Card{col, Zero}) + for v := One; v <= DrawTwo; v++ { + d = append(d, Card{col, v}, Card{col, v}) + } + } + for i := 0; i < 4; i++ { + d = append(d, Card{Wild, WildCard}, Card{Wild, WildDrawFour}) + } + return d +} + +// Tier is a table, and the table size *is* the difficulty. More bots is a longer +// shot — three of them going out before you is three ways to lose — so it pays +// more. This is the tier dial every other game here has, pointed at the one knob +// UNO actually has. +type Tier struct { + Slug string `json:"slug"` + Name string `json:"name"` + Bots int `json:"bots"` + Base float64 `json:"base"` // what going out first pays, before the rake + Blurb string `json:"blurb"` +} + +// Tiers are the three tables. +// +// The multiples are not guesses. A player who simply plays the first legal card +// they hold — which is a real strategy, and a bad one — goes out first 43% of +// the time heads up, 32% at three seats and 27% at four. These pay a little +// under what that costs, so bad play loses slowly and good play (holding the +// wilds, dumping the colour you're long in, counting what a bot picked up) is +// worth roughly the house's edge. That is the game being about something. +var Tiers = []Tier{ + {Slug: "duel", Name: "Duel", Bots: 1, Base: 2.2, + Blurb: "One bot, head to head. A reverse is a skip with two at the table."}, + {Slug: "table", Name: "Table", Bots: 2, Base: 2.9, + Blurb: "Two bots. Twice the +4s pointed at you."}, + {Slug: "full", Name: "Full House", Bots: 3, Base: 3.6, + Blurb: "Three bots, and any of them going out first takes your stake."}, +} + +// 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 ( + PhasePlay Phase = "play" // your turn, play or draw + PhaseDrawn Phase = "drawn" // you drew a card you can play: play it or pass + PhaseDone Phase = "done" +) + +// Outcome is how it ended. +type Outcome string + +const ( + OutcomeNone Outcome = "" + OutcomeWon Outcome = "won" // you went out first + OutcomeLost Outcome = "lost" // a bot did + OutcomeStuck Outcome = "stuck" // nobody can move and there are no cards left +) + +// Won reports whether this outcome pays. +func (o Outcome) Won() bool { return o == OutcomeWon } + +// State is one game. The bots' hands and the deck are in here, which is exactly +// why this value never crosses the wire — the browser gets counts instead. +type State struct { + Tier Tier `json:"tier"` + Hands [][]Card `json:"hands"` // seat 0 is you; the rest are bots + Bots []string `json:"bots"` // their names, one per bot seat (seat i is Bots[i-1]) + Deck []Card `json:"deck"` + Discard []Card `json:"discard"` // the top card is the last one + Color Color `json:"color"` // the colour in play, which a wild renames + Turn int `json:"turn"` + Dir int `json:"dir"` // +1 clockwise, -1 after a reverse + + Seed1 uint64 `json:"seed1"` + Seed2 uint64 `json:"seed2"` + Step uint64 `json:"step"` // how many moves have been applied; the rng's other half + + 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 bots' turns arrive as a run of +// these on the back of the player's own move, and the felt plays them in order. +type Event struct { + Kind string `json:"kind"` // see below + Seat int `json:"seat"` // who it happened to + Card *Card `json:"card,omitempty"` // the card played, or the one *you* drew + Color Color `json:"color,omitempty"` // the colour now in play, on a wild + N int `json:"n,omitempty"` // how many cards were drawn + Left int `json:"left,omitempty"` // cards left in that seat's hand afterwards + Text string `json:"text,omitempty"` +} + +// The kinds an Event comes in. +// +// deal the hands are dealt and the first card turned over +// play a card goes on the pile +// wild the colour was named (rides with the play it belongs to) +// draw cards come off the deck. A bot's are face down: Card is nil. +// forced the same, but not by choice — a +2 or a +4 landed on them +// pass the turn moves on with nothing played +// skip a seat loses its turn +// reverse the direction flips +// uno a hand is down to one card +// reshuffle the discard goes back under +// settle it's over +const ( + EvDeal = "deal" + EvPlay = "play" + EvDraw = "draw" + EvForced = "forced" + EvPass = "pass" + EvSkip = "skip" + EvReverse = "reverse" + EvUno = "uno" + EvReshuffle = "reshuffle" + EvSettle = "settle" +) + +// Move is what the player sends: play this card, take one off the deck, or — +// having taken one you can play — decline to play it. +type Move struct { + Kind string `json:"kind"` // "play" | "draw" | "pass" + Index int `json:"index"` // which card of your hand, for a play + Color Color `json:"color"` // the colour you name, for a wild +} + +// Move kinds. +const ( + MovePlay = "play" + MoveDraw = "draw" + MovePass = "pass" +) + +// New deals a game: a shuffled deck, seven each, and a card turned over. +// +// The turned card is dealt until it's a number. The official rules have the +// first player eat a +2 that lands there, and turn a wild into a colour vote — +// both of which are a game that opens by doing something to you before you have +// touched it. A number card up top is the same game, minus the paperwork. +func New(bet int64, t Tier, rakePct float64, seed1, seed2 uint64) (State, []Event, error) { + if bet <= 0 { + return State{}, nil, ErrBadBet + } + if t.Bots < 1 { + return State{}, nil, ErrUnknownTier + } + rng := stepRNG(seed1, seed2, 0) + + deck := NewDeck() + rng.Shuffle(len(deck), func(i, j int) { deck[i], deck[j] = deck[j], deck[i] }) + + s := State{ + Tier: t, Deck: deck, Dir: 1, Turn: You, + Seed1: seed1, Seed2: seed2, + RakePct: rakePct, Bet: bet, Phase: PhasePlay, + Bots: botNames(t.Bots, rng), + } + + seats := t.Bots + 1 + s.Hands = make([][]Card, seats) + for i := range s.Hands { + s.Hands[i] = make([]Card, 0, HandSize) + } + for c := 0; c < HandSize; c++ { + for seat := 0; seat < seats; seat++ { + card, _ := s.pop() + s.Hands[seat] = append(s.Hands[seat], card) + } + } + + // Turn cards over until one of them is a plain number. + for { + card, ok := s.pop() + if !ok { + return State{}, nil, errors.New("uno: deck ran out on the deal") // 108 cards; unreachable + } + if card.Value.Action() { + s.Discard = append(s.Discard, card) // it stays buried, out of play + continue + } + s.Discard = append(s.Discard, card) + s.Color = card.Color + break + } + + return s, []Event{{Kind: EvDeal, Card: s.topPtr(), Color: s.Color}}, nil +} + +// ApplyMove is the engine. Your move goes in; your move, and every bot turn it +// hands off to, comes back 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 + } + if s.Turn != You { + // Can't happen through this door — ApplyMove always runs the bots out + // before it returns — but a state restored from a row that predates a bug + // shouldn't wedge the player, it should say what's wrong. + return s, nil, ErrNotYourTurn + } + + next := s.clone() + next.Step++ + rng := stepRNG(next.Seed1, next.Seed2, next.Step) + + var evs []Event + var err error + switch m.Kind { + case MovePlay: + evs, err = next.playerPlays(m, rng) + case MoveDraw: + evs, err = next.playerDraws(rng) + case MovePass: + evs, err = next.playerPasses() + default: + return s, nil, ErrUnknownMove + } + if err != nil { + return s, nil, err // the caller's state, untouched + } + + // The bots take their turns on the back of yours, and the whole run comes + // back as one script. This is the reason solo UNO needs no socket. + next.runBots(&evs, rng) + return next, evs, nil +} + +// playerPlays puts one of your cards on the pile. +func (s *State) playerPlays(m Move, rng *rand.Rand) ([]Event, error) { + hand := s.Hands[You] + if m.Index < 0 || m.Index >= len(hand) { + return nil, ErrNoSuchCard + } + // Having drawn a playable card, the only card you may play is that one. Being + // allowed to draw and *then* play something else would make drawing a free + // look at the deck with no cost attached. + if s.Phase == PhaseDrawn && m.Index != len(hand)-1 { + return nil, ErrMustPlayNow + } + card := hand[m.Index] + if !card.CanPlayOn(s.top(), s.Color) { + return nil, ErrCantPlay + } + if card.IsWild() && !m.Color.Playable() { + return nil, ErrNeedColor + } + + s.Hands[You] = append(hand[:m.Index:m.Index], hand[m.Index+1:]...) + var evs []Event + s.discard(You, card, m.Color, &evs) + s.after(You, card, &evs, rng) + return evs, nil +} + +// playerDraws takes one off the deck. If it can be played you get the choice — +// that's PhaseDrawn, and it's the only place the turn pauses mid-move. If it +// can't, the turn passes on the spot: there is nothing to decide. +func (s *State) playerDraws(rng *rand.Rand) ([]Event, error) { + if s.Phase == PhaseDrawn { + return nil, ErrMustPlayNow // you already drew; play it or pass + } + var evs []Event + drawn := s.deal(You, 1, false, &evs, rng) + if len(drawn) == 1 && drawn[0].CanPlayOn(s.top(), s.Color) { + s.Phase = PhaseDrawn + return evs, nil + } + evs = append(evs, Event{Kind: EvPass, Seat: You}) + s.advance(1) + return evs, nil +} + +// playerPasses declines the card you just drew. +func (s *State) playerPasses() ([]Event, error) { + if s.Phase != PhaseDrawn { + return nil, ErrCantPass + } + s.Phase = PhasePlay + s.advance(1) + return []Event{{Kind: EvPass, Seat: You}}, nil +} + +// runBots plays every bot turn between you and your next one. It stops the +// moment the game is over or the turn comes back round. +func (s *State) runBots(evs *[]Event, rng *rand.Rand) { + // A bot that can neither play nor draw passes, and if every seat does that in + // a row the game is stuck: the deck is spent and the discard has nothing under + // its top card to become a new one. Rare, but a game that can't end is worse + // than one that ends badly, so it ends — see stuck(). + passes := 0 + for s.Phase != PhaseDone && s.Turn != You { + if s.botTurn(s.Turn, evs, rng) { + passes++ + if passes > len(s.Hands) { + s.stuck(evs) + return + } + continue + } + passes = 0 + } +} + +// botTurn plays one bot's turn. It reports whether the bot passed with nothing. +func (s *State) botTurn(seat int, evs *[]Event, rng *rand.Rand) (passed bool) { + card, idx := botPick(s.Hands[seat], s.top(), s.Color, s.minOpponent(seat), rng) + if idx < 0 { + // Nothing playable: draw one, and play it if it happens to go. + drawn := s.deal(seat, 1, false, evs, rng) + if len(drawn) == 1 && drawn[0].CanPlayOn(s.top(), s.Color) { + card, idx = drawn[0], len(s.Hands[seat])-1 + } else { + *evs = append(*evs, Event{Kind: EvPass, Seat: seat}) + s.advance(1) + return len(drawn) == 0 // a bot that couldn't even draw is a stuck table + } + } + + hand := s.Hands[seat] + s.Hands[seat] = append(hand[:idx:idx], hand[idx+1:]...) + + color := card.Color + if card.IsWild() { + color = botColor(s.Hands[seat], rng) + } + s.discard(seat, card, color, evs) + s.after(seat, card, evs, rng) + return false +} + +// discard puts a card on the pile and names the colour now in play. +// +// A wild is stamped with the colour it was played as, so the pile shows what was +// called rather than a black card and a note beside it. That stamp is undone if +// the card ever comes back out — see reshuffle, which would otherwise bleed four +// extra reds into the deck. +func (s *State) discard(seat int, card Card, color Color, evs *[]Event) { + if card.IsWild() { + s.Color = color + card.Color = color + } else { + s.Color = card.Color + } + s.Discard = append(s.Discard, card) + e := Event{Kind: EvPlay, Seat: seat, Card: &card, Color: s.Color, Left: len(s.Hands[seat])} + *evs = append(*evs, e) + if len(s.Hands[seat]) == 1 { + *evs = append(*evs, Event{Kind: EvUno, Seat: seat}) + } +} + +// after resolves what the card just played does, and moves the turn on. It is +// the one place the rules of skip, reverse and the draw cards live. +func (s *State) after(seat int, card Card, evs *[]Event, rng *rand.Rand) { + if len(s.Hands[seat]) == 0 { + s.settle(seat, evs) + return + } + s.Phase = PhasePlay + + switch card.Value { + case Skip: + victim := s.seatAt(1) + *evs = append(*evs, Event{Kind: EvSkip, Seat: victim}) + s.advance(2) + + case Reverse: + // Two at the table and a reverse has nobody to hand the turn back to, so it + // is a skip — which, with two players, means you go again. + if len(s.Hands) == 2 { + *evs = append(*evs, Event{Kind: EvSkip, Seat: s.seatAt(1)}) + s.advance(2) + return + } + s.Dir = -s.Dir + *evs = append(*evs, Event{Kind: EvReverse, Seat: seat}) + s.advance(1) + + case DrawTwo: + s.punish(s.seatAt(1), 2, evs, rng) + + case WildDrawFour: + s.punish(s.seatAt(1), 4, evs, rng) + + default: + s.advance(1) + } +} + +// punish makes the next seat eat a draw card and lose its turn. No stacking: a +// +2 played onto a +2 is a house rule, and the one this table plays is the one +// on the box. +func (s *State) punish(victim, n int, evs *[]Event, rng *rand.Rand) { + s.deal(victim, n, true, evs, rng) + *evs = append(*evs, Event{Kind: EvSkip, Seat: victim}) + s.advance(2) +} + +// deal gives a seat n cards, reshuffling the discard back under the deck if it +// runs dry. The cards it hands back are the ones actually drawn — which can be +// fewer than asked for, when there is nothing left anywhere to draw. +// +// A bot's cards go face down: the event carries the count, never the card. The +// only hand whose faces cross the wire is yours. +func (s *State) deal(seat, n int, forced bool, evs *[]Event, rng *rand.Rand) []Card { + got := make([]Card, 0, n) + for i := 0; i < n; i++ { + if len(s.Deck) == 0 && !s.reshuffle(evs, rng) { + break + } + c, ok := s.pop() + if !ok { + break + } + s.Hands[seat] = append(s.Hands[seat], c) + got = append(got, c) + } + if len(got) == 0 { + return got + } + kind := EvDraw + if forced { + kind = EvForced + } + e := Event{Kind: kind, Seat: seat, N: len(got), Left: len(s.Hands[seat])} + if seat == You && len(got) == 1 { + c := got[0] + e.Card = &c // your own card, and only yours, comes face up + } + *evs = append(*evs, e) + return got +} + +// reshuffle turns the discard back into a deck, keeping the card in play on top +// of the pile. It reports whether there was anything to reshuffle. +func (s *State) reshuffle(evs *[]Event, rng *rand.Rand) bool { + if len(s.Discard) < 2 { + return false // nothing under the top card: the table is out of cards + } + top := s.Discard[len(s.Discard)-1] + rest := append([]Card(nil), s.Discard[:len(s.Discard)-1]...) + rng.Shuffle(len(rest), func(i, j int) { rest[i], rest[j] = rest[j], rest[i] }) + + // A wild goes back in as a wild. It was played as a colour, and leaving that + // colour stamped on it would quietly bleed four extra reds into the deck. + for i := range rest { + if rest[i].Value == WildCard || rest[i].Value == WildDrawFour { + rest[i].Color = Wild + } + } + s.Deck = rest + s.Discard = []Card{top} + *evs = append(*evs, Event{Kind: EvReshuffle, N: len(rest)}) + return true +} + +// settle ends the game. Going out first pays the tier; anyone else going out +// takes the stake. The rake, as everywhere in this casino, comes out of the +// winnings and never out of the stake. +func (s *State) settle(winner int, evs *[]Event) { + s.Phase = PhaseDone + if winner == You { + s.Outcome = OutcomeWon + s.Payout = s.Pays() + s.Rake = s.rakeNow() + } else { + s.Outcome = OutcomeLost + s.Payout = 0 + } + *evs = append(*evs, Event{Kind: EvSettle, Seat: winner, Text: string(s.Outcome)}) +} + +// stuck ends a game nobody can move in: the deck is spent, the discard is one +// card deep, and every seat has passed. The shortest hand takes it — and a tie +// is not a win, because a win here has to be somebody actually going out. +func (s *State) stuck(evs *[]Event) { + best, tied := 0, false + for seat := range s.Hands { + switch { + case len(s.Hands[seat]) < len(s.Hands[best]): + best, tied = seat, false + case seat != best && len(s.Hands[seat]) == len(s.Hands[best]): + tied = true + } + } + s.Phase = PhaseDone + if best == You && !tied { + s.Outcome = OutcomeWon + s.Payout = s.Pays() + s.Rake = s.rakeNow() + } else { + s.Outcome = OutcomeStuck + s.Payout = 0 + } + *evs = append(*evs, Event{Kind: EvSettle, Seat: best, Text: string(s.Outcome)}) +} + +// Pays is what going out *right now* would put back on the player's stack: the +// stake, plus the winnings, less the house's cut of the winnings. +// +// It exists because the felt quotes this number while the game is still running, +// and settle() is the only other thing that works it out. Hangman learned this +// the hard way: two sums drift, and the table ends up advertising a payout it +// doesn't honour. So settle calls this rather than doing it again. +func (s State) Pays() int64 { + total := int64(math.Floor(float64(s.Bet) * s.Tier.Base)) + if total < s.Bet { + total = s.Bet + } + profit := total - s.Bet + if profit > 0 { + profit -= s.rakeOn(profit) + } + return s.Bet + profit +} + +// rakeNow is the other half of what Pays works out: the house's cut of a win +// taken right now. +func (s State) rakeNow() int64 { + total := int64(math.Floor(float64(s.Bet) * s.Tier.Base)) + if total <= s.Bet { + return 0 + } + return s.rakeOn(total - s.Bet) +} + +func (s State) rakeOn(profit int64) int64 { + 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 +} + +// Playable reports which cards of your hand can legally go on the pile. The +// browser lights these up: being shown what you can play is the game teaching +// you, and the server still decides every move regardless. +// +// While you're sitting on a card you just drew, that card is the only one you +// may play — so it is the only one that lights up. +func (s State) Playable() []int { + if s.Phase == PhaseDone || s.Turn != You { + return nil + } + hand := s.Hands[You] + if s.Phase == PhaseDrawn { + if len(hand) > 0 && hand[len(hand)-1].CanPlayOn(s.top(), s.Color) { + return []int{len(hand) - 1} + } + return nil + } + var out []int + for i, c := range hand { + if c.CanPlayOn(s.top(), s.Color) { + out = append(out, i) + } + } + return out +} + +// Counts is how many cards each seat holds — what the browser gets instead of +// the bots' hands. +func (s State) Counts() []int { + out := make([]int, len(s.Hands)) + for i := range s.Hands { + out[i] = len(s.Hands[i]) + } + return out +} + +// Top is the card in play. +func (s State) Top() Card { return s.top() } + +// Left is how many cards are still in the deck. +func (s State) Left() int { return len(s.Deck) } + +// ---- the plumbing --------------------------------------------------------- + +func (s State) top() Card { + if len(s.Discard) == 0 { + return Card{} + } + return s.Discard[len(s.Discard)-1] +} + +func (s State) topPtr() *Card { + c := s.top() + return &c +} + +// pop takes the next card off the deck. +func (s *State) pop() (Card, bool) { + if len(s.Deck) == 0 { + return Card{}, false + } + c := s.Deck[0] + s.Deck = s.Deck[1:] + return c, true +} + +// seatAt is the seat n places round from the one whose turn it is. +func (s State) seatAt(n int) int { + seats := len(s.Hands) + return ((s.Turn+s.Dir*n)%seats + seats) % seats +} + +// advance moves the turn on n places. +func (s *State) advance(n int) { s.Turn = s.seatAt(n) } + +// minOpponent is the smallest hand at the table that isn't this seat's — how +// close the bot is to being beaten, which is the only thing it plays around. +func (s State) minOpponent(seat int) int { + min := -1 + for i := range s.Hands { + if i == seat { + continue + } + if min < 0 || len(s.Hands[i]) < min { + min = len(s.Hands[i]) + } + } + return min +} + +// clone deep-copies everything a move can touch, so a derived state shares no +// backing array with the one it came from. +func (s State) clone() State { + hands := make([][]Card, len(s.Hands)) + for i, h := range s.Hands { + hands[i] = append([]Card(nil), h...) + } + s.Hands = hands + s.Deck = append([]Card(nil), s.Deck...) + s.Discard = append([]Card(nil), s.Discard...) + s.Bots = append([]string(nil), s.Bots...) + return s +} + +// stepRNG is the generator for one step of the game. The seed is the game's; +// the step number is what stops every move from replaying the same numbers. +// Mixing with the golden ratio's odd 64-bit constant keeps consecutive steps +// from producing streams that share a bit pattern. +func stepRNG(seed1, seed2, step uint64) *rand.Rand { + return rand.New(rand.NewPCG(seed1, seed2^(step*0x9E3779B97F4A7C15))) +} diff --git a/internal/games/uno/uno_test.go b/internal/games/uno/uno_test.go new file mode 100644 index 0000000..a12c33f --- /dev/null +++ b/internal/games/uno/uno_test.go @@ -0,0 +1,736 @@ +package uno + +import ( + "encoding/json" + "math/rand/v2" + "testing" +) + +const rake = 0.05 + +func duel() Tier { t, _ := TierBySlug("duel"); return t } +func full() Tier { t, _ := TierBySlug("full"); return t } +func table() Tier { t, _ := TierBySlug("table"); return t } + +// deal starts a game, failing the test if it can't. +func deal(t *testing.T, tier Tier, bet int64, seed uint64) State { + t.Helper() + s, evs, err := New(bet, tier, rake, seed, 0xC0FFEE) + if err != nil { + t.Fatalf("New: %v", err) + } + if len(evs) != 1 || evs[0].Kind != EvDeal { + t.Fatalf("New should deal exactly one event, got %+v", evs) + } + return s +} + +// census counts every card in the game, wherever it is. It is the invariant the +// whole engine has to hold: 108 cards, each of them in exactly one place. +func census(s State) map[Card]int { + m := map[Card]int{} + for _, h := range s.Hands { + for _, c := range h { + m[c]++ + } + } + for _, c := range s.Deck { + m[c]++ + } + for _, c := range s.Discard { + // A wild is stamped with the colour it was played as while it sits on the + // pile, so it counts as the wild it really is. + if c.Value == WildCard || c.Value == WildDrawFour { + c.Color = Wild + } + m[c]++ + } + return m +} + +func total(m map[Card]int) int { + n := 0 + for _, v := range m { + n += v + } + return n +} + +func TestNewDeckIsADeck(t *testing.T) { + m := census(State{Deck: NewDeck()}) + if got := total(m); got != 108 { + t.Fatalf("deck has %d cards, want 108", got) + } + if m[Card{Red, Zero}] != 1 { + t.Errorf("want one red zero, got %d", m[Card{Red, Zero}]) + } + if m[Card{Blue, Seven}] != 2 { + t.Errorf("want two blue sevens, got %d", m[Card{Blue, Seven}]) + } + if m[Card{Wild, WildDrawFour}] != 4 { + t.Errorf("want four +4s, got %d", m[Card{Wild, WildDrawFour}]) + } +} + +func TestNewDeals(t *testing.T) { + s := deal(t, full(), 100, 7) + if len(s.Hands) != 4 { + t.Fatalf("full house is four seats, got %d", len(s.Hands)) + } + for i, h := range s.Hands { + if len(h) != HandSize { + t.Errorf("seat %d holds %d cards, want %d", i, len(h), HandSize) + } + } + if len(s.Bots) != 3 { + t.Fatalf("want three bot names, got %v", s.Bots) + } + if s.Turn != You { + t.Errorf("you play first, turn is %d", s.Turn) + } + if got := total(census(s)); got != 108 { + t.Fatalf("the deal lost cards: %d of 108", got) + } +} + +// The card turned over to start is never an action card — see New. +func TestOpeningCardIsANumber(t *testing.T) { + for seed := uint64(0); seed < 300; seed++ { + s := deal(t, table(), 50, seed) + if s.Top().Value.Action() { + t.Fatalf("seed %d opened on %v", seed, s.Top()) + } + if s.Color != s.Top().Color { + t.Fatalf("seed %d: colour in play is %v, top card is %v", seed, s.Color, s.Top()) + } + } +} + +// ---- the rules ------------------------------------------------------------ + +// rig builds a state by hand, so a rule can be tested without hunting a seed +// that happens to deal it. +// +// The deck is the rest of the deck: every card not in a hand and not the one in +// play. So a rigged game still holds 108 cards, and the census invariant means +// something in these tests too. +func rig(hands [][]Card, top Card, color Color) State { + left := map[Card]int{} + for _, c := range NewDeck() { + left[c]++ + } + take := func(c Card) { + if c.IsWild() { + c.Color = Wild + } + left[c]-- + } + for _, h := range hands { + for _, c := range h { + take(c) + } + } + take(top) + + var deck []Card + for _, c := range NewDeck() { + key := c + if left[key] > 0 { + left[key]-- + deck = append(deck, c) + } + } + return State{ + Tier: full(), Hands: hands, Discard: []Card{top}, Color: color, + Deck: deck, Dir: 1, Turn: You, Phase: PhasePlay, + Bet: 100, RakePct: rake, Seed1: 1, Seed2: 2, + } +} + +func TestPlayMustMatch(t *testing.T) { + s := rig([][]Card{{{Blue, Three}}, {{Red, Five}}}, Card{Red, Nine}, Red) + if _, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}); err != ErrCantPlay { + t.Fatalf("a blue 3 on a red 9 should be refused, got %v", err) + } +} + +func TestPlayMatchesFaceOrColor(t *testing.T) { + // Same face, different colour: legal. + s := rig([][]Card{{{Blue, Nine}, {Red, Two}}, {{Green, Five}}}, Card{Red, Nine}, Red) + next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) + if err != nil { + t.Fatalf("a blue 9 on a red 9 is legal: %v", err) + } + if next.Color != Blue { + t.Errorf("colour in play should follow the card: %v", next.Color) + } + if evs[0].Kind != EvPlay || evs[0].Seat != You { + t.Errorf("first event should be your play, got %+v", evs[0]) + } +} + +func TestWildNeedsAColor(t *testing.T) { + s := rig([][]Card{{{Wild, WildCard}}, {{Green, Five}}}, Card{Red, Nine}, Red) + if _, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}); err != ErrNeedColor { + t.Fatalf("a wild with no colour should be refused, got %v", err) + } + if _, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Color: Wild}); err != ErrNeedColor { + t.Fatalf("naming 'wild' is not naming a colour, got %v", err) + } +} + +func TestWildNamesTheColor(t *testing.T) { + s := rig([][]Card{{{Wild, WildCard}, {Green, One}}, {{Green, Five}}}, Card{Red, Nine}, Red) + next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Color: Green}) + if err != nil { + t.Fatalf("play wild: %v", err) + } + // The bot moved after us, so the colour in play is whatever it left behind — + // what we can check is that the wild itself went down as green. + top := next.Discard + if len(top) < 2 { + t.Fatalf("expected the wild and the bot's card on the pile: %v", top) + } + if top[1] != (Card{Green, WildCard}) { + t.Errorf("the wild should sit on the pile as green, got %v", top[1]) + } +} + +func TestDrawTwoHitsTheNextSeat(t *testing.T) { + // Two seats, so the +2 lands on the bot and the turn comes straight back. + s := rig([][]Card{{{Red, DrawTwo}, {Red, One}}, {{Blue, Five}, {Blue, Six}}}, Card{Red, Nine}, Red) + s.Tier = duel() + next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) + if err != nil { + t.Fatalf("play +2: %v", err) + } + if len(next.Hands[1]) != 4 { + t.Errorf("the bot should hold 2 + 2 = 4 cards, got %d", len(next.Hands[1])) + } + if next.Turn != You { + t.Errorf("the bot was skipped, so it should be your turn: %d", next.Turn) + } + if !hasKind(evs, EvForced) || !hasKind(evs, EvSkip) { + t.Errorf("a +2 is a forced draw and a skip: %+v", evs) + } + if got := total(census(next)); got != 108 { + t.Fatalf("the +2 lost cards: %d of 108", got) + } +} + +func TestReverseIsASkipHeadsUp(t *testing.T) { + s := rig([][]Card{{{Red, Reverse}, {Red, One}}, {{Blue, Five}}}, Card{Red, Nine}, Red) + s.Tier = duel() + next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) + if err != nil { + t.Fatalf("play reverse: %v", err) + } + if next.Dir != 1 { + t.Errorf("with two at the table a reverse doesn't turn the table around: dir %d", next.Dir) + } + if next.Turn != You { + t.Errorf("the bot should have been skipped, turn is %d", next.Turn) + } + if !hasKind(evs, EvSkip) || hasKind(evs, EvReverse) { + t.Errorf("heads up, a reverse reads as a skip: %+v", evs) + } + if len(next.Hands[1]) != 1 { + t.Errorf("the bot never played, so it still holds one card: %d", len(next.Hands[1])) + } +} + +func TestReverseTurnsTheTableAround(t *testing.T) { + // Every bot holds a red card, so each of them can play the moment the turn + // reaches it — which is what makes the *order* they play in observable. + s := rig([][]Card{ + {{Red, Reverse}, {Red, One}}, + {{Red, Five}, {Blue, Six}}, + {{Red, Six}, {Green, Six}}, + {{Red, Seven}, {Yellow, Six}}, + }, Card{Red, Nine}, Red) + next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) + if err != nil { + t.Fatalf("play reverse: %v", err) + } + if next.Dir != -1 { + t.Errorf("four at the table: a reverse turns it around, dir %d", next.Dir) + } + if !hasKind(evs, EvReverse) { + t.Errorf("want a reverse event: %+v", evs) + } + if next.Turn != You { + t.Errorf("the bots should have played round to you, turn is %d", next.Turn) + } + // The table now runs anticlockwise: seat 3 plays, then 2, then 1. + var order []int + for _, e := range evs { + if e.Kind == EvPlay && e.Seat != You { + order = append(order, e.Seat) + } + } + if len(order) != 3 || order[0] != 3 || order[1] != 2 || order[2] != 1 { + t.Errorf("the bots played in the order %v, want [3 2 1]", order) + } +} + +func TestSkipSkips(t *testing.T) { + // Both bots hold a playable red, so the only reason either of them doesn't + // play is that it wasn't asked to. + s := rig([][]Card{ + {{Red, Skip}, {Red, One}}, + {{Red, Five}, {Blue, Six}}, + {{Red, Six}, {Green, Six}}, + }, Card{Red, Nine}, Red) + s.Tier = table() + next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) + if err != nil { + t.Fatalf("play skip: %v", err) + } + if !hasKind(evs, EvSkip) { + t.Errorf("want a skip event: %+v", evs) + } + for _, e := range evs { + if e.Kind == EvPlay && e.Seat == 1 { + t.Errorf("seat 1 was skipped and should not have played: %+v", e) + } + } + if len(next.Hands[1]) != 2 { + t.Errorf("seat 1 was skipped and should still hold two: %d", len(next.Hands[1])) + } + if len(next.Hands[2]) != 1 { + t.Errorf("seat 2 was not skipped and should have played: %d", len(next.Hands[2])) + } +} + +// ---- drawing -------------------------------------------------------------- + +func TestDrawnPlayableWaitsForYou(t *testing.T) { + s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red) + s.Deck = []Card{{Red, Four}} // exactly what you'll draw, and it plays + next, evs, err := ApplyMove(s, Move{Kind: MoveDraw}) + if err != nil { + t.Fatalf("draw: %v", err) + } + if next.Phase != PhaseDrawn { + t.Fatalf("a playable draw should stop and ask, phase is %v", next.Phase) + } + if next.Turn != You { + t.Fatalf("the turn should still be yours: %d", next.Turn) + } + if evs[0].Kind != EvDraw || evs[0].Card == nil || *evs[0].Card != (Card{Red, Four}) { + t.Fatalf("your own drawn card comes face up: %+v", evs[0]) + } + if got := next.Playable(); len(got) != 1 || got[0] != 1 { + t.Errorf("the drawn card, and only it, is playable: %v", got) + } + // You may not play the *other* card instead — drawing would otherwise be a + // free look with no cost. + if _, _, err := ApplyMove(next, Move{Kind: MovePlay, Index: 0}); err != ErrMustPlayNow { + t.Fatalf("only the drawn card may be played, got %v", err) + } + if _, _, err := ApplyMove(next, Move{Kind: MoveDraw}); err != ErrMustPlayNow { + t.Fatalf("you can't draw twice, got %v", err) + } + after, _, err := ApplyMove(next, Move{Kind: MovePass}) + if err != nil { + t.Fatalf("pass: %v", err) + } + if after.Phase != PhasePlay || after.Turn != You { + t.Fatalf("after passing the bot plays and it comes back to you: phase %v turn %d", after.Phase, after.Turn) + } + if len(after.Hands[You]) != 2 { + t.Errorf("you kept the card you drew: %d", len(after.Hands[You])) + } +} + +func TestUnplayableDrawPassesTheTurn(t *testing.T) { + s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red) + s.Deck = []Card{{Blue, Four}, {Red, Two}} // draw a blue 4: it doesn't go on a red 9 + next, evs, err := ApplyMove(s, Move{Kind: MoveDraw}) + if err != nil { + t.Fatalf("draw: %v", err) + } + if next.Phase != PhasePlay { + t.Errorf("nothing to decide, so no pause: %v", next.Phase) + } + if !hasKind(evs, EvPass) { + t.Errorf("the turn passed, and the table should be told: %+v", evs) + } +} + +func TestPassOnlyAfterADraw(t *testing.T) { + s := rig([][]Card{{{Red, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red) + if _, _, err := ApplyMove(s, Move{Kind: MovePass}); err != ErrCantPass { + t.Fatalf("you can't pass a turn you haven't drawn on, got %v", err) + } +} + +func TestReshuffleRebuildsTheDeck(t *testing.T) { + s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red) + // An empty deck, and a discard with something under the top card to become one. + // The buried wild went down as green; it has to come back as a wild. + s.Deck = nil + s.Discard = []Card{{Green, WildCard}, {Red, Two}, {Red, Nine}} + next, evs, err := ApplyMove(s, Move{Kind: MoveDraw}) + if err != nil { + t.Fatalf("draw on an empty deck: %v", err) + } + if !hasKind(evs, EvReshuffle) { + t.Fatalf("want a reshuffle: %+v", evs) + } + if len(next.Discard) == 0 || next.Discard[0] != (Card{Red, Nine}) { + t.Errorf("the card in play stays on the pile: %v", next.Discard) + } + for _, c := range next.Deck { + if c.Value == WildCard && c.Color != Wild { + t.Errorf("a wild went back into the deck stamped %v", c.Color) + } + } + for _, h := range next.Hands { + for _, c := range h { + if c.Value == WildCard && c.Color != Wild { + t.Errorf("a wild was dealt out stamped %v", c.Color) + } + } + } +} + +// ---- the money ------------------------------------------------------------ + +// The rule every game in this casino has had to be taught: the number the felt +// quotes and the number the settle lands on are one function, not two. +func TestQuoteIsThePayout(t *testing.T) { + for _, tier := range Tiers { + s := rig([][]Card{{{Red, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red) + s.Tier = tier + s.Hands = make([][]Card, tier.Bots+1) + s.Hands[You] = []Card{{Red, Three}} + for i := 1; i <= tier.Bots; i++ { + s.Hands[i] = []Card{{Green, Five}, {Green, Six}} + } + quoted := s.Pays() + + next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) // your last card + if err != nil { + t.Fatalf("%s: go out: %v", tier.Slug, err) + } + if next.Outcome != OutcomeWon { + t.Fatalf("%s: playing your last card wins, got %q", tier.Slug, next.Outcome) + } + if next.Payout != quoted { + t.Errorf("%s: the felt quoted %d, the house paid %d", tier.Slug, quoted, next.Payout) + } + if next.Net() != quoted-s.Bet { + t.Errorf("%s: net is %d, want %d", tier.Slug, next.Net(), quoted-s.Bet) + } + } +} + +// The rake comes out of the winnings, never the stake. +func TestRakeIsOnWinningsOnly(t *testing.T) { + s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red) + s.Tier = duel() // 2.2x on 100: 220 back, 120 of it profit, 6 of that to the house + s.Bet = 100 + next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) + if err != nil { + t.Fatalf("go out: %v", err) + } + if next.Payout != 214 { + t.Errorf("payout %d, want 214 (100 stake + 120 winnings - 6 rake)", next.Payout) + } + if next.Rake != 6 { + t.Errorf("rake %d, want 6", next.Rake) + } + if next.Net() != 114 { + t.Errorf("net %d, want 114", next.Net()) + } +} + +func TestLosingPaysNothingAndIsNotCharged(t *testing.T) { + // The bot holds one card that plays on the pile, so it goes out the moment the + // turn reaches it. + s := rig([][]Card{{{Red, Three}, {Red, Four}}, {{Red, Five}}}, Card{Red, Nine}, Red) + s.Tier = duel() + next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) + if err != nil { + t.Fatalf("play: %v", err) + } + if next.Outcome != OutcomeLost { + t.Fatalf("the bot went out, so you lost: %q", next.Outcome) + } + if next.Payout != 0 || next.Rake != 0 { + t.Errorf("a loss pays nothing and is charged nothing: payout %d rake %d", next.Payout, next.Rake) + } + if next.Net() != -s.Bet { + t.Errorf("a loss costs the stake and no more: %d", next.Net()) + } + last := evs[len(evs)-1] + if last.Kind != EvSettle || last.Seat != 1 { + t.Errorf("the settle should name the winner: %+v", last) + } +} + +func TestNoMoveAfterItIsOver(t *testing.T) { + s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red) + s.Tier = duel() + done, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) + if err != nil { + t.Fatalf("go out: %v", err) + } + if _, _, err := ApplyMove(done, Move{Kind: MoveDraw}); err != ErrGameOver { + t.Fatalf("a finished game takes no more moves, got %v", err) + } +} + +func TestBadBet(t *testing.T) { + if _, _, err := New(0, duel(), rake, 1, 2); err != ErrBadBet { + t.Fatalf("want ErrBadBet, got %v", err) + } +} + +// ---- the whole game ------------------------------------------------------- + +// playOut plays a game to its end with a simple strategy: play the first legal +// card, otherwise draw, otherwise pass. It asserts the invariants at every step. +func playOut(t *testing.T, s State, maxTurns int) State { + t.Helper() + for turn := 0; s.Phase != PhaseDone; turn++ { + if turn > maxTurns { + t.Fatalf("the game never ended in %d turns", maxTurns) + } + if s.Turn != You { + t.Fatalf("ApplyMove left the turn with seat %d — the bots should always run out", s.Turn) + } + + var m Move + if p := s.Playable(); len(p) > 0 { + m = Move{Kind: MovePlay, Index: p[0]} + if s.Hands[You][p[0]].IsWild() { + m.Color = Green + } + } else if s.Phase == PhaseDrawn { + m = Move{Kind: MovePass} + } else { + m = Move{Kind: MoveDraw} + } + + next, evs, err := ApplyMove(s, m) + if err != nil { + t.Fatalf("turn %d: %v (move %+v, phase %v)", turn, err, m, s.Phase) + } + if len(evs) == 0 { + t.Fatalf("turn %d: a move that happened emitted nothing", turn) + } + if got := total(census(next)); got != 108 { + t.Fatalf("turn %d: %d cards of 108 — a card was lost or minted", turn, got) + } + for c, n := range census(next) { + if want := deckCount(c); n != want { + t.Fatalf("turn %d: %d of %v, want %d — a card was duplicated", turn, n, c, want) + } + } + // No event ever names a bot's card. That is the hole card of this game, and + // it is most of the deck. + for _, e := range evs { + if (e.Kind == EvDraw || e.Kind == EvForced) && e.Seat != You && e.Card != nil { + t.Fatalf("turn %d: a bot's drawn card crossed the wire: %+v", turn, e) + } + } + s = next + } + return s +} + +// deckCount is how many of a given card a 108 deck holds. +func deckCount(c Card) int { + switch { + case c.Color == Wild: + return 4 + case c.Value == Zero: + return 1 + default: + return 2 + } +} + +// A hundred games, played out, with the invariants checked at every step. This +// is the test that would have caught a deck that leaks cards through the +// reshuffle, a turn the bots don't hand back, or a game that can't end. +func TestGamesPlayOut(t *testing.T) { + wins, losses, stuck := 0, 0, 0 + for seed := uint64(0); seed < 100; seed++ { + tier := Tiers[seed%3] + end := playOut(t, deal(t, tier, 100, seed), 500) + switch end.Outcome { + case OutcomeWon: + wins++ + if end.Payout != end.Pays() { + t.Fatalf("seed %d: paid %d, quoted %d", seed, end.Payout, end.Pays()) + } + case OutcomeLost: + losses++ + case OutcomeStuck: + stuck++ + default: + t.Fatalf("seed %d ended as %q", seed, end.Outcome) + } + if len(end.Hands[end.winnerSeat()]) != 0 && end.Outcome != OutcomeStuck { + t.Fatalf("seed %d: the winner is still holding cards", seed) + } + } + // Playing the first legal card is a poor strategy against bots that hold their + // +4s back, so this is not a fairness assertion — it's a check that both + // outcomes actually happen. A table that never pays is a bug in the bots. + if wins == 0 || losses == 0 { + t.Fatalf("100 games gave %d wins, %d losses, %d stuck — one side never happens", wins, losses, stuck) + } + t.Logf("100 games: %d won, %d lost, %d stuck", wins, losses, stuck) +} + +// winnerSeat is the seat the settle event named — only used by the tests. +func (s State) winnerSeat() int { + best := 0 + for i := range s.Hands { + if len(s.Hands[i]) < len(s.Hands[best]) { + best = i + } + } + return best +} + +// The same seed deals the same game and the bots make the same choices — which +// is what lets a disputed game be replayed exactly as it fell. +func TestReplaysFromTheSeed(t *testing.T) { + a := playOut(t, deal(t, full(), 250, 42), 500) + b := playOut(t, deal(t, full(), 250, 42), 500) + + ja, _ := json.Marshal(a) + jb, _ := json.Marshal(b) + if string(ja) != string(jb) { + t.Fatal("the same seed played the same way gave two different games") + } + if a.Outcome == "" { + t.Fatal("the replay didn't finish") + } +} + +// A game in progress survives a redeploy: it is a plain value, so it round-trips +// through the JSON it is stored as. +func TestStateSurvivesStorage(t *testing.T) { + s := deal(t, table(), 100, 9) + s, _, err := ApplyMove(s, Move{Kind: MoveDraw}) + if err != nil { + t.Fatalf("draw: %v", err) + } + + blob, err := json.Marshal(s) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var back State + if err := json.Unmarshal(blob, &back); err != nil { + t.Fatalf("unmarshal: %v", err) + } + again, _ := json.Marshal(back) + if string(again) != string(blob) { + t.Fatal("a stored game came back different") + } + // And it plays on from there. + playOut(t, back, 500) +} + +// A move the engine refuses leaves the caller's state exactly as it was — no +// card half-played, no turn half-passed. +func TestARefusedMoveChangesNothing(t *testing.T) { + s := rig([][]Card{{{Blue, Three}, {Wild, WildCard}}, {{Green, Five}}}, Card{Red, Nine}, Red) + before, _ := json.Marshal(s) + + for _, m := range []Move{ + {Kind: MovePlay, Index: 0}, // doesn't match + {Kind: MovePlay, Index: 1}, // wild with no colour + {Kind: MovePlay, Index: 9}, // no such card + {Kind: MovePass}, // nothing drawn + {Kind: "shuffle-the-deck-in-my-favour"}, // no + } { + if _, _, err := ApplyMove(s, m); err == nil { + t.Fatalf("%+v should have been refused", m) + } + } + after, _ := json.Marshal(s) + if string(before) != string(after) { + t.Fatal("a refused move touched the state") + } +} + +// The bots choose. Two different seeds should not play the same bot game, or the +// bot is a lookup table you can memorise. +func TestBotsAreNotDeterministicAcrossSeeds(t *testing.T) { + same := 0 + for seed := uint64(0); seed < 20; seed++ { + a := playOut(t, deal(t, duel(), 100, seed), 500) + b := playOut(t, deal(t, duel(), 100, seed+1000), 500) + if len(a.Discard) == len(b.Discard) { + same++ + } + } + if same == 20 { + t.Fatal("every seed played out to the same length — the bots aren't choosing") + } +} + +// botPick holds its +4 back while it's comfortable, and reaches for it when +// somebody is about to go out. +func TestBotSavesTheDrawFour(t *testing.T) { + hand := []Card{{Wild, WildDrawFour}, {Red, Five}} + top, color := Card{Red, Nine}, Red + rng := rand.New(rand.NewPCG(1, 2)) + + held := 0 + for i := 0; i < 50; i++ { + if _, idx := botPick(hand, top, color, 5, rng); idx == 1 { + held++ + } + } + if held < 30 { + t.Errorf("with the table comfortable the bot should mostly play the red 5, held %d/50", held) + } + + reached := 0 + for i := 0; i < 50; i++ { + if _, idx := botPick(hand, top, color, 1, rng); idx == 0 { + reached++ + } + } + if reached < 30 { + t.Errorf("with a player on one card the bot should mostly play the +4, reached %d/50", reached) + } +} + +func TestBotPicksItsBestColor(t *testing.T) { + rng := rand.New(rand.NewPCG(3, 4)) + hand := []Card{{Blue, One}, {Blue, Two}, {Green, Three}, {Wild, WildCard}} + if got := botColor(hand, rng); got != Blue { + t.Errorf("the bot holds two blues: it should call blue, got %v", got) + } + // A hand of nothing but wilds still has to name something. + for i := 0; i < 20; i++ { + if got := botColor([]Card{{Wild, WildCard}}, rng); !got.Playable() { + t.Fatalf("botColor named %v, which is not a colour", got) + } + } +} + +func TestBotHasNothingToPlay(t *testing.T) { + if _, idx := botPick([]Card{{Blue, Three}}, Card{Red, Nine}, Red, 3, rand.New(rand.NewPCG(1, 1))); idx != -1 { + t.Errorf("a hand with nothing legal should report -1, got %d", idx) + } +} + +func hasKind(evs []Event, kind string) bool { + for _, e := range evs { + if e.Kind == kind { + return true + } + } + return false +} diff --git a/internal/web/games_pages.go b/internal/web/games_pages.go index b46a5db..838cf27 100644 --- a/internal/web/games_pages.go +++ b/internal/web/games_pages.go @@ -8,6 +8,7 @@ import ( "pete/internal/games/hangman" "pete/internal/games/klondike" "pete/internal/games/trivia" + "pete/internal/games/uno" "pete/internal/storage" ) @@ -29,7 +30,6 @@ type gameTeaser struct { var comingSoon = []gameTeaser{ {Name: "Hold'em", Emoji: "♠️", Blurb: "Six seats, and the house bots know how to play."}, - {Name: "UNO", Emoji: "🎴", Blurb: "Normal rules, or no mercy."}, } // betDenominations are the chips you build a bet out of. @@ -77,6 +77,7 @@ type gamesPage struct { FullDeck int Quizzes []trivia.Tier // trivia's three difficulties Rungs int // how long the trivia ladder is + Tables []uno.Tier // uno's three tables, and how many bots sit at each } // casinoRoutes hangs every table off the mux. @@ -91,6 +92,7 @@ func (s *Server) casinoRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /games/hangman", s.handleHangman) mux.HandleFunc("GET /games/solitaire", s.handleSolitaire) mux.HandleFunc("GET /games/trivia", s.handleTrivia) + mux.HandleFunc("GET /games/uno", s.handleUno) mux.HandleFunc("GET /api/games/table", s.handleTable) mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn) @@ -107,6 +109,9 @@ func (s *Server) casinoRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /api/games/trivia/start", s.handleTriviaStart) mux.HandleFunc("POST /api/games/trivia/answer", s.handleTriviaAnswer) + + mux.HandleFunc("POST /api/games/uno/start", s.handleUnoStart) + mux.HandleFunc("POST /api/games/uno/move", s.handleUnoMove) } // requirePlayer sends an anonymous visitor to sign in and comes back here after. @@ -139,6 +144,7 @@ func (s *Server) gamesPage(r *http.Request) gamesPage { FullDeck: klondike.FullDeck, Quizzes: trivia.Tiers, Rungs: trivia.Rungs, + Tables: uno.Tiers, } } @@ -176,3 +182,10 @@ func (s *Server) handleTrivia(w http.ResponseWriter, r *http.Request) { } s.render(w, "trivia", s.gamesPage(r)) } + +func (s *Server) handleUno(w http.ResponseWriter, r *http.Request) { + if !s.requirePlayer(w, r) { + return + } + s.render(w, "uno", s.gamesPage(r)) +} diff --git a/internal/web/games_play.go b/internal/web/games_play.go index c929ebc..1f7987e 100644 --- a/internal/web/games_play.go +++ b/internal/web/games_play.go @@ -15,6 +15,7 @@ import ( "pete/internal/games/hangman" "pete/internal/games/klondike" "pete/internal/games/trivia" + "pete/internal/games/uno" "pete/internal/storage" ) @@ -193,6 +194,9 @@ type tableView struct { Trivia *triviaView `json:"trivia,omitempty"` TrivEvents []trivia.Event `json:"triv_events,omitempty"` + Uno *unoView `json:"uno,omitempty"` + UnoEvents []unoEventView `json:"uno_events,omitempty"` + Rake float64 `json:"rake_pct"` } @@ -253,6 +257,13 @@ func (s *Server) table(user string) (tableView, error) { // twenty seconds finds the countdown exactly where they left it. tv := viewTrivia(g, time.Now()) v.Trivia = &tv + case gameUno: + var g uno.State + if err := json.Unmarshal(live.State, &g); err != nil { + return s.dropUnreadable(user, v, err) + } + uv := viewUno(g) + v.Uno = &uv default: return s.dropUnreadable(user, v, fmt.Errorf("unknown game %q", live.Game)) } @@ -509,6 +520,7 @@ const ( gameHangman = "hangman" gameSolitaire = "solitaire" gameTrivia = "trivia" + gameUno = "uno" ) // finished is what commit needs to know about a game it's writing back: enough diff --git a/internal/web/games_uno.go b/internal/web/games_uno.go new file mode 100644 index 0000000..fb6d030 --- /dev/null +++ b/internal/web/games_uno.go @@ -0,0 +1,284 @@ +package web + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + + "pete/internal/games/blackjack" + "pete/internal/games/uno" + "pete/internal/storage" +) + +// UNO, played for chips against bots. +// +// The seam is the same as every other table, but there is one thing here that no +// other table has: opponents. The obvious way to give a browser opponents is a +// socket, and the plan says solo UNO must not need one — so it doesn't. A move +// goes up, and what comes back is the player's move *plus every bot turn it +// handed off to*, as a script of events. One request, one round of the table. +// +// What the browser is allowed to see: its own hand, the card in play, the colour +// in play, and how many cards each bot is holding. Not the deck, not a bot's +// hand, not even the face of a card a bot drew. That last one is most of the +// deck, and it is the thing that would turn a game of counting cards into a game +// of reading the network tab. + +// unoCardView is one card, ready to draw. The browser gets the colour and the +// face as words, not as the engine's integers — the same bargain the blackjack +// table makes, and for the same reason: the browser draws faces, not logic. +type unoCardView struct { + Color string `json:"color"` // "red" | "blue" | "yellow" | "green" | "wild" + Value string `json:"value"` // "0"…"9" | "skip" | "reverse" | "+2" | "wild" | "+4" + Wild bool `json:"wild"` // it's a wild, whatever colour it was played as +} + +func viewUnoCard(c uno.Card) unoCardView { + return unoCardView{ + Color: c.Color.String(), + Value: c.Value.String(), + Wild: c.IsWild(), + } +} + +// unoSeatView is one seat at the table: a name, and a number of cards. A bot's +// cards are a *count*. There is no field here for what they are. +type unoSeatView struct { + Name string `json:"name"` + Cards int `json:"cards"` + You bool `json:"you"` + Uno bool `json:"uno"` // down to one card +} + +// unoView is a game as its player may see it. +type unoView struct { + Tier uno.Tier `json:"tier"` + Seats []unoSeatView `json:"seats"` + + Hand []unoCardView `json:"hand"` // yours, and only yours + Playable []int `json:"playable"` // which of them can legally go down + Top unoCardView `json:"top"` // the card in play + Color string `json:"color"` // the colour in play, which a wild renames + Deck int `json:"deck"` // cards left to draw + + Turn int `json:"turn"` + Dir int `json:"dir"` + + Bet int64 `json:"bet"` + Pays int64 `json:"pays"` // what going out right now would actually pay + Phase string `json:"phase"` + Outcome string `json:"outcome,omitempty"` + Winner int `json:"winner"` + Payout int64 `json:"payout,omitempty"` + Rake int64 `json:"rake,omitempty"` + Net int64 `json:"net"` +} + +func viewUno(g uno.State) unoView { + v := unoView{ + Tier: g.Tier, + Top: viewUnoCard(g.Top()), + Color: g.Color.String(), + Deck: g.Left(), + Turn: g.Turn, + Dir: g.Dir, + Bet: g.Bet, + Pays: g.Pays(), + Phase: string(g.Phase), + Outcome: string(g.Outcome), + Winner: -1, + Payout: g.Payout, + Rake: g.Rake, + Net: g.Net(), + } + for i, n := range g.Counts() { + seat := unoSeatView{Cards: n, You: i == uno.You, Uno: n == 1} + if i == uno.You { + seat.Name = "You" + } else if i-1 < len(g.Bots) { + seat.Name = g.Bots[i-1] + } + v.Seats = append(v.Seats, seat) + if n == 0 { + v.Winner = i + } + } + for _, c := range g.Hands[uno.You] { + v.Hand = append(v.Hand, viewUnoCard(c)) + } + v.Playable = g.Playable() + if v.Playable == nil { + v.Playable = []int{} + } + return v +} + +// unoEventView is one beat of the script the table plays back: a card going +// down, a seat eating a +4, the turn coming round. The engine's own events carry +// engine types, so they are re-rendered here rather than shipped raw — and this +// is also the wall where a bot's drawn card is dropped on the floor. +type unoEventView struct { + Kind string `json:"kind"` + Seat int `json:"seat"` + Card *unoCardView `json:"card,omitempty"` + Color string `json:"color,omitempty"` + N int `json:"n,omitempty"` + Left int `json:"left,omitempty"` + Text string `json:"text,omitempty"` +} + +func viewUnoEvents(evs []uno.Event) []unoEventView { + out := make([]unoEventView, 0, len(evs)) + for _, e := range evs { + v := unoEventView{Kind: e.Kind, Seat: e.Seat, N: e.N, Left: e.Left, Text: e.Text} + if e.Color != uno.Wild { + v.Color = e.Color.String() + } + if e.Card != nil { + // The engine only ever attaches a card to an event the seat is entitled + // to see it in — a card played face up, or one *you* drew. This check is + // the belt to that pair of braces: a bot's draw never carries a face, + // whatever the engine thinks it's doing. + if e.Kind == uno.EvDraw || e.Kind == uno.EvForced { + if e.Seat == uno.You { + c := viewUnoCard(*e.Card) + v.Card = &c + } + } else { + c := viewUnoCard(*e.Card) + v.Card = &c + } + } + out = append(out, v) + } + return out +} + +// handleUnoStart takes the bet and deals. Same order as every other table: the +// chips are staked first, in the same statement that checks they exist, so two +// deals fired at once cannot bet the same chip. +func (s *Server) handleUnoStart(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 := uno.TierBySlug(req.Tier) + if err != nil { + writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a table"}) + 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 bet"}) + return + } + slog.Error("games: uno stake", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + seed1, seed2 := newSeeds() + g, evs, err := uno.New(req.Bet, tier, blackjack.DefaultRules().RakePct, seed1, seed2) + if err != nil { + // The game never happened, so the stake never should have left. + _ = storage.Award(user, req.Bet) + slog.Error("games: uno deal", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + s.persistUno(w, user, g, evs, seed1, seed2, true) +} + +// handleUnoMove plays one turn: a card, a draw, or passing on the card you drew. +// The bots' turns come back with it. +func (s *Server) handleUnoMove(w http.ResponseWriter, r *http.Request) { + user, ok := s.player(w, r) + if !ok { + return + } + var move uno.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: uno load", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if live.Game != gameUno { + writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"}) + return + } + var g uno.State + if err := json.Unmarshal(live.State, &g); err != nil { + slog.Error("games: unreadable uno game", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + next, evs, err := uno.ApplyMove(g, move) + if err != nil { + // The refusals a player can actually cause, said in words rather than as + // "that move isn't legal here" — which, in a game with this many rules, is + // the table refusing to explain itself. + msg := "that move isn't legal here" + switch { + case errors.Is(err, uno.ErrCantPlay): + msg = "that card doesn't go on this one" + case errors.Is(err, uno.ErrNeedColor): + msg = "pick a colour for the wild" + case errors.Is(err, uno.ErrMustPlayNow): + msg = "play the card you drew, or pass" + case errors.Is(err, uno.ErrCantPass): + msg = "draw first, then you can pass" + } + writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg}) + return + } + s.persistUno(w, user, next, evs, live.Seed1, live.Seed2, false) +} + +// persistUno writes the game back and answers the browser. +func (s *Server) persistUno(w http.ResponseWriter, user string, g uno.State, evs []uno.Event, seed1, seed2 uint64, fresh bool) { + blob, err := json.Marshal(g) + if err != nil { + slog.Error("games: marshal uno", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + done := g.Phase == uno.PhaseDone + v, ok := s.commit(w, user, finished{ + Game: gameUno, 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 game is gone from storage, so the table has none to show — but the + // browser still needs the final board to land the verdict on. + if done { + uv := viewUno(g) + v.Uno = &uv + } + v.UnoEvents = viewUnoEvents(evs) + writeJSON(w, v) +} diff --git a/internal/web/games_uno_test.go b/internal/web/games_uno_test.go new file mode 100644 index 0000000..56d4da6 --- /dev/null +++ b/internal/web/games_uno_test.go @@ -0,0 +1,147 @@ +package web + +import ( + "testing" + + "pete/internal/games/uno" + "pete/internal/storage" +) + +// The one thing this table cannot get wrong: the stake leaves the stack, and no +// card a player is not entitled to see leaves the server. At UNO that is not one +// hole card — it is the deck and every bot's hand, which is most of the game. +func TestUnoStartTakesTheStakeAndKeepsTheCards(t *testing.T) { + s := newCasino(t) + fund(t, 1000) + + v, code := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start", + map[string]any{"bet": 100, "tier": "full"})) + if code != 200 { + t.Fatalf("start = %d, want 200", code) + } + if v.Chips != 900 { + t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips) + } + if v.Game != gameUno || v.Uno == nil { + t.Fatalf("start returned no uno game: game=%q", v.Game) + } + + g := v.Uno + if len(g.Hand) != uno.HandSize { + t.Errorf("you were dealt %d cards, want %d", len(g.Hand), uno.HandSize) + } + if len(g.Seats) != 4 { + t.Fatalf("a full house is four seats, got %d", len(g.Seats)) + } + // A bot is a name and a number. There is no field here that could carry a + // card, which is the point — this is a compile-time guarantee, and the test + // exists to make deleting it loud. + for i, seat := range g.Seats { + if i == 0 { + if !seat.You || seat.Name != "You" { + t.Errorf("seat 0 should be you: %+v", seat) + } + continue + } + if seat.You || seat.Name == "" { + t.Errorf("seat %d should be a named bot: %+v", i, seat) + } + if seat.Cards != uno.HandSize { + t.Errorf("seat %d holds %d cards, want %d", i, seat.Cards, uno.HandSize) + } + } + if g.Top.Value == "" { + t.Error("no card in play") + } + if g.Turn != 0 { + t.Errorf("you play first, turn is %d", g.Turn) + } +} + +// A move plays your turn and every bot turn behind it, and the script that comes +// back never carries a bot's drawn card. +func TestUnoMovePlaysTheWholeLap(t *testing.T) { + s := newCasino(t) + fund(t, 1000) + + v, _ := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start", + map[string]any{"bet": 100, "tier": "table"})) + if v.Uno == nil { + t.Fatal("no game") + } + + // Draw, which is legal from any hand: the turn passes to the bots and comes + // back, unless the card drawn happens to be playable. + v, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move", + map[string]any{"kind": "draw"})) + if code != 200 { + t.Fatalf("draw = %d, want 200", code) + } + if v.Uno == nil { + t.Fatal("the move returned no game") + } + if len(v.UnoEvents) == 0 { + t.Fatal("a move that happened sent back no events") + } + if v.Uno.Phase != "drawn" && v.Uno.Turn != 0 { + t.Errorf("the bots should have played back round to you, turn is %d", v.Uno.Turn) + } + for _, e := range v.UnoEvents { + if (e.Kind == uno.EvDraw || e.Kind == uno.EvForced) && e.Seat != 0 && e.Card != nil { + t.Fatalf("a bot's drawn card crossed the wire: %+v", e) + } + } +} + +// You cannot play a wild without naming a colour — and the zero value of the +// colour field is not red, so a move that simply omits it is refused rather than +// quietly played as one. +func TestUnoWildWithNoColourIsRefused(t *testing.T) { + s := newCasino(t) + fund(t, 1000) + + // Deal until a wild lands in the opening hand: it's four cards in 108, so it + // doesn't take long, and this is the only way to get one without rigging the + // deck through a door the server doesn't have. + var wild = -1 + for try := 0; try < 40 && wild < 0; try++ { + v, _ := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start", + map[string]any{"bet": 10, "tier": "duel"})) + if v.Uno == nil { + t.Fatal("no game") + } + for i, c := range v.Uno.Hand { + if c.Wild { + wild = i + break + } + } + if wild < 0 { + // Abandon this deal and take another. The live row is keyed on the + // player, so it has to go before the next start can be seated. + if err := storage.ClearLiveHand(testPlayer); err != nil { + t.Fatal(err) + } + } + } + if wild < 0 { + t.Skip("40 deals and not one wild — vanishingly unlikely, but not a failure") + } + + _, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move", + map[string]any{"kind": "play", "index": wild})) + if code != 400 { + t.Fatalf("a wild with no colour = %d, want 400", code) + } + + v, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move", + map[string]any{"kind": "play", "index": wild, "color": int(uno.Green)})) + if code != 200 { + t.Fatalf("a wild played as green = %d, want 200", code) + } + if v.Uno != nil && v.Uno.Phase != "done" && v.Uno.Color != "green" && v.Uno.Color != "" { + // The bots have played since, so the colour may have moved on — what must + // not happen is the wild going down as anything we didn't ask for. + t.Logf("colour in play after the bots: %s", v.Uno.Color) + } +} diff --git a/internal/web/static/css/input.css b/internal/web/static/css/input.css index 2f02ff8..5ef437b 100644 --- a/internal/web/static/css/input.css +++ b/internal/web/static/css/input.css @@ -1421,6 +1421,346 @@ html[data-phase="night"] { justify-content: center; } + /* ---- uno ------------------------------------------------------------------ + The card is the whole look: a coloured rectangle with a white oval laid + across it at an angle. Drop the oval and it reads as a button, so the oval + is not decoration — it is what makes the thing a card. + + The bots are drawn from a *count*. That is all the server sends, and the fan + of backs up there is generated from a number rather than from cards, because + there are no cards to draw. */ + + /* The felt takes a tint of the colour in play. After a wild, the card on the + pile is not the colour you have to follow — this is the table saying so. */ + .pete-uno[data-c="red"] { --play: #d64545; } + .pete-uno[data-c="blue"] { --play: #3d7fd6; } + .pete-uno[data-c="yellow"] { --play: #e0b02c; } + .pete-uno[data-c="green"] { --play: #46a86b; } + .pete-uno::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: radial-gradient(80% 55% at 50% 45%, var(--play, transparent), transparent 70%); + opacity: 0.16; + transition: opacity 0.5s ease, background 0.5s ease; + } + + .pete-uno-card { + position: relative; + height: var(--uno-h, 6.4rem); + width: var(--uno-w, 4.3rem); + border-radius: 0.55rem; + padding: 0.28rem; + background: #fff; + box-shadow: 0 3px 0 rgba(0,0,0,0.22), 0 6px 14px rgba(0,0,0,0.18); + flex: none; + } + .pete-uno-face { + position: relative; + display: grid; + place-items: center; + height: 100%; + width: 100%; + border-radius: 0.38rem; + overflow: hidden; + background: var(--uno, #2b2118); + color: #fff; + } + .pete-uno-card[data-c="red"] { --uno: #d64545; } + .pete-uno-card[data-c="blue"] { --uno: #3d7fd6; } + .pete-uno-card[data-c="yellow"] { --uno: #e0b02c; } + .pete-uno-card[data-c="green"] { --uno: #46a86b; } + .pete-uno-card[data-c="wild"] { --uno: #2b2118; } + + /* A wild that has been played wears the colour it was called as — the pile has + to show what you must follow, not what was printed. */ + .pete-uno-card[data-named="red"] { box-shadow: 0 0 0 3px #d64545, 0 3px 0 rgba(0,0,0,0.22); } + .pete-uno-card[data-named="blue"] { box-shadow: 0 0 0 3px #3d7fd6, 0 3px 0 rgba(0,0,0,0.22); } + .pete-uno-card[data-named="yellow"] { box-shadow: 0 0 0 3px #e0b02c, 0 3px 0 rgba(0,0,0,0.22); } + .pete-uno-card[data-named="green"] { box-shadow: 0 0 0 3px #46a86b, 0 3px 0 rgba(0,0,0,0.22); } + + /* The oval. Tilted, because it is tilted on the card in your hand. */ + .pete-uno-oval { + position: relative; + z-index: 1; + display: grid; + place-items: center; + height: 78%; + width: 62%; + border-radius: 50%; + transform: rotate(-24deg); + background: #fff; + color: var(--uno, #2b2118); + font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif; + font-size: 1.45rem; + font-weight: 700; + line-height: 1; + text-shadow: 0 1px 0 rgba(0,0,0,0.10); + } + .pete-uno-card[data-c="wild"] .pete-uno-oval { color: #2b2118; font-size: 1.15rem; } + + .pete-uno-corner { + position: absolute; + z-index: 1; + font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif; + font-size: 0.62rem; + font-weight: 700; + line-height: 1; + color: #fff; + text-shadow: 0 1px 1px rgba(0,0,0,0.35); + } + .pete-uno-corner[data-at="tl"] { top: 0.22rem; left: 0.28rem; } + .pete-uno-corner[data-at="br"] { bottom: 0.22rem; right: 0.28rem; transform: rotate(180deg); } + + /* The four quadrants of a wild, under the oval. */ + .pete-uno-wheel { + position: absolute; + inset: 0; + background: + conic-gradient(#d64545 0 25%, #e0b02c 0 50%, #46a86b 0 75%, #3d7fd6 0); + opacity: 0.92; + } + + /* The back. Every card you are not entitled to see is one of these. */ + .pete-uno-card-back { padding: 0.28rem; } + .pete-uno-back { + display: grid; + place-items: center; + height: 100%; + width: 100%; + border-radius: 0.38rem; + background: + radial-gradient(120% 80% at 50% 0%, rgba(255,255,255,0.16), transparent 60%), + #2b2118; + } + .pete-uno-back::after { + content: "UNO"; + display: block; + transform: rotate(-24deg); + padding: 0.1rem 0.35rem; + border-radius: 999px; + background: #e0b02c; + color: #2b2118; + font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif; + font-size: 0.6rem; + font-weight: 700; + letter-spacing: 0.02em; + } + + /* Your hand. It fans, it wraps, and a card you can play stands up out of it — + being shown what goes is the game teaching you, and the server still decides. */ + .pete-uno-hand { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.4rem; + min-height: 6.8rem; + padding-top: 0.6rem; + } + .pete-uno-hand .pete-uno-card { + animation: pete-uno-in 0.34s cubic-bezier(0.22, 1, 0.36, 1) backwards; + animation-delay: calc(var(--i, 0) * 45ms); + transition: transform 0.16s ease, filter 0.16s ease; + } + .pete-uno-hand .pete-uno-card[data-on="1"] { + cursor: pointer; + transform: translateY(-0.5rem); + } + .pete-uno-hand .pete-uno-card[data-on="1"]:hover { transform: translateY(-1.1rem) scale(1.03); } + .pete-uno-hand .pete-uno-card[data-on="0"] { filter: brightness(0.62) saturate(0.7); } + @keyframes pete-uno-in { + from { transform: translateY(2rem) rotate(6deg); opacity: 0; } + to { transform: translateY(-0.5rem); opacity: 1; } + } + + /* A seat: a fan of backs, a name, a count. The fan overlaps, so eight cards + read as a hand rather than a row. */ + .pete-uno-seat { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.3rem; + position: relative; + padding: 0.5rem 0.7rem; + border-radius: 1rem; + transition: background 0.25s ease, transform 0.25s ease; + } + .pete-uno-seat[data-turn="1"] { + background: rgba(255,255,255,0.12); + transform: translateY(-2px); + } + .pete-uno-seat[data-uno="1"] .pete-uno-count { color: #ffd76e; } + + .pete-uno-fan { + display: flex; + --uno-h: 3.2rem; + --uno-w: 2.15rem; + } + .pete-uno-fan .pete-uno-card { + margin-left: -1.35rem; + transform: rotate(calc((var(--i, 0) - (var(--n, 1) - 1) / 2) * 4deg)); + transform-origin: bottom center; + box-shadow: 0 2px 0 rgba(0,0,0,0.22); + } + .pete-uno-fan .pete-uno-card:first-child { margin-left: 0; } + .pete-uno-fan .pete-uno-back::after { font-size: 0.4rem; padding: 0.06rem 0.22rem; } + + .pete-uno-name { + font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif; + font-size: 0.9rem; + font-weight: 700; + color: #fff; + line-height: 1; + } + .pete-uno-count { + font-size: 0.7rem; + font-weight: 700; + color: rgba(255,255,255,0.55); + line-height: 1; + } + + /* The deck you draw from, and the pile you play onto. */ + .pete-uno-deck { + position: relative; + --uno-h: 6.4rem; + --uno-w: 4.3rem; + height: var(--uno-h); + width: var(--uno-w); + border-radius: 0.55rem; + padding: 0.28rem; + background: #fff; + box-shadow: + 0 3px 0 rgba(0,0,0,0.22), + 0.28rem 0.28rem 0 -0.06rem rgba(255,255,255,0.35), + 0.5rem 0.5rem 0 -0.12rem rgba(255,255,255,0.18); + transition: transform 0.15s ease; + } + .pete-uno-deck:not(:disabled):hover { transform: translateY(-3px); } + .pete-uno-deck:disabled { opacity: 0.75; } + .pete-uno-deck-count { + position: absolute; + bottom: -0.55rem; + left: 50%; + transform: translateX(-50%); + padding: 0.1rem 0.5rem; + border-radius: 999px; + background: rgba(0,0,0,0.55); + color: #fff; + font-size: 0.65rem; + font-weight: 700; + line-height: 1.4; + } + .pete-uno-shuffle { animation: pete-uno-shuffle 0.42s ease; } + @keyframes pete-uno-shuffle { + 0%, 100% { transform: none; } + 30% { transform: translateY(-4px) rotate(-3deg); } + 65% { transform: translateY(2px) rotate(2deg); } + } + + .pete-uno-discard { + display: grid; + place-items: center; + height: 6.4rem; + width: 4.3rem; + border-radius: 0.55rem; + box-shadow: inset 0 0 0 2px rgba(255,255,255,0.14); + } + .pete-uno-land { animation: pete-uno-land 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); } + @keyframes pete-uno-land { + from { transform: scale(1.14) rotate(-4deg); } + to { transform: none; } + } + + .pete-uno-colour { + font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 0.15rem 0.6rem; + border-radius: 999px; + color: #fff; + background: rgba(0,0,0,0.3); + } + .pete-uno-colour[data-c="red"] { background: #d64545; } + .pete-uno-colour[data-c="blue"] { background: #3d7fd6; } + .pete-uno-colour[data-c="yellow"] { background: #e0b02c; color: #2b2118; } + .pete-uno-colour[data-c="green"] { background: #46a86b; } + + /* A word thrown at a seat: SKIPPED, UNO!, +4. */ + .pete-uno-badge { + position: absolute; + top: -0.4rem; + left: 50%; + z-index: 5; + padding: 0.2rem 0.6rem; + border-radius: 999px; + background: #fff; + color: #2b2118; + font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif; + font-size: 0.75rem; + font-weight: 700; + white-space: nowrap; + box-shadow: 0 3px 0 rgba(0,0,0,0.2); + animation: pete-uno-badge 0.9s ease forwards; + } + .pete-uno-badge[data-tone="bad"] { background: #cc3d4a; color: #fff; } + .pete-uno-badge[data-tone="uno"] { background: #e0b02c; } + @keyframes pete-uno-badge { + 0% { transform: translate(-50%, 0.4rem) scale(0.7); opacity: 0; } + 25% { transform: translate(-50%, -0.5rem) scale(1.06); opacity: 1; } + 75% { transform: translate(-50%, -0.9rem) scale(1); opacity: 1; } + 100% { transform: translate(-50%, -1.6rem) scale(0.95); opacity: 0; } + } + + /* Naming a colour. It covers the felt because until it's answered there is no + legal move on the table underneath it. */ + .pete-uno-wild { + position: absolute; + inset: 0; + z-index: 10; + display: grid; + place-items: center; + background: rgba(10, 20, 15, 0.55); + backdrop-filter: blur(2px); + } + .pete-uno-wild-box { + padding: 1.25rem 1.5rem; + border-radius: 1.25rem; + background: rgba(20, 40, 30, 0.92); + box-shadow: 0 12px 30px rgba(0,0,0,0.35); + text-align: center; + } + .pete-uno-swatch { + padding: 0.7rem 1.4rem; + border-radius: 0.75rem; + font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif; + font-size: 1rem; + font-weight: 700; + color: #fff; + background: var(--sw, #666); + box-shadow: 0 3px 0 rgba(0,0,0,0.3); + transition: transform 0.12s ease, filter 0.12s ease; + } + .pete-uno-swatch:hover { transform: translateY(-2px); filter: brightness(1.08); } + .pete-uno-swatch:active { transform: translateY(1px); } + .pete-uno-swatch[data-c="red"] { --sw: #d64545; } + .pete-uno-swatch[data-c="blue"] { --sw: #3d7fd6; } + .pete-uno-swatch[data-c="yellow"] { --sw: #e0b02c; color: #2b2118; } + .pete-uno-swatch[data-c="green"] { --sw: #46a86b; } + + /* A phone. The cards get smaller so a hand of ten still fits without the felt + scrolling sideways, which is the thing to check at 390px with a game on it. */ + @media (max-width: 639px) { + .pete-uno-hand { --uno-h: 5.2rem; --uno-w: 3.5rem; gap: 0.3rem; } + .pete-uno-deck { --uno-h: 5.2rem; --uno-w: 3.5rem; } + .pete-uno-discard { height: 5.2rem; width: 3.5rem; } + .pete-uno-hand .pete-uno-card { padding: 0.22rem; } + .pete-uno-oval { font-size: 1.15rem; } + .pete-uno-seat { padding: 0.35rem 0.45rem; } + } + @media (prefers-reduced-motion: reduce) { .pete-card, .pete-card::after, diff --git a/internal/web/static/css/output.css b/internal/web/static/css/output.css index 47372cb..514ce02 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: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}@media (max-width:639px){.pete-rack:not([data-at=rail]){gap:.25rem;padding:.35rem .4rem .3rem}.pete-rack:not([data-at=rail]) span{width:1.15rem}.pete-rack:not([data-at]){right:1rem}}.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-clock{position:relative;height:.6rem;border-radius:999px;background:rgba(0,0,0,.3);overflow:hidden;box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.08)}.pete-clock-fill{height:100%;width:100%;border-radius:999px;background:var(--accent);transform-origin:left center;transform:scaleX(1)}.pete-clock[data-hot="1"] .pete-clock-fill{background:#cc3d4a}.pete-clock[data-hot="1"]{animation:pete-clock-pulse .7s ease-in-out infinite}@keyframes pete-clock-pulse{0%,to{box-shadow:inset 0 0 0 2px rgba(204,61,74,.35)}50%{box-shadow:inset 0 0 0 2px rgba(204,61,74,.95)}}.pete-answer{position:relative;display:flex;align-items:center;gap:.75rem;width:100%;border-radius:1rem;padding:.85rem 1rem;text-align:left;font-weight:600;color:#fff;background:rgba(0,0,0,.26);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.1);transition:background .15s ease,transform .1s ease,box-shadow .2s ease}.pete-answer:hover:not(:disabled){background:rgba(0,0,0,.36);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.28)}.pete-answer:active:not(:disabled){transform:translateY(1px)}.pete-answer:disabled{cursor:default}.pete-answer-key{display:grid;place-items:center;height:1.75rem;width:1.75rem;flex:none;border-radius:.6rem;background:hsla(0,0%,100%,.12);font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:.8rem;font-weight:700;color:hsla(0,0%,100%,.7)}.pete-answer[data-state=right]{background:rgba(46,160,103,.9);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.5)}.pete-answer[data-state=wrong]{background:rgba(204,61,74,.9);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.35);animation:pete-answer-no .45s ease}.pete-answer[data-state=missed]{box-shadow:inset 0 0 0 2px rgba(46,160,103,.95)}.pete-answer[data-state=dim]{opacity:.4}@keyframes pete-answer-no{0%,to{transform:translateX(0)}20%{transform:translateX(-6px)}45%{transform:translateX(5px)}70%{transform:translateX(-3px)}}.pete-ladder{display:flex;flex-wrap:wrap;gap:.3rem}.pete-rung{height:.5rem;width:1.1rem;border-radius:999px;background:hsla(0,0%,100%,.14);transition:background .3s ease,transform .3s ease}.pete-rung[data-on="1"]{background:var(--accent);transform:scaleY(1.35)}.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}.pete-answer[data-state=wrong],.pete-clock[data-hot="1"]{animation:none}.pete-rung{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-7{margin-top:1.75rem}.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-\[16rem\]{min-height:16rem}.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-2\.5{gap:.625rem}.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-4{-moz-column-gap:1rem;column-gap:1rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-3{row-gap:.75rem}.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}.pr-32{padding-right:8rem}.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\/70{color:hsla(0,0%,100%,.7)}.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 +*,: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}@media (max-width:639px){.pete-rack:not([data-at=rail]){gap:.25rem;padding:.35rem .4rem .3rem}.pete-rack:not([data-at=rail]) span{width:1.15rem}.pete-rack:not([data-at]){right:1rem}}.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-clock{position:relative;height:.6rem;border-radius:999px;background:rgba(0,0,0,.3);overflow:hidden;box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.08)}.pete-clock-fill{height:100%;width:100%;border-radius:999px;background:var(--accent);transform-origin:left center;transform:scaleX(1)}.pete-clock[data-hot="1"] .pete-clock-fill{background:#cc3d4a}.pete-clock[data-hot="1"]{animation:pete-clock-pulse .7s ease-in-out infinite}@keyframes pete-clock-pulse{0%,to{box-shadow:inset 0 0 0 2px rgba(204,61,74,.35)}50%{box-shadow:inset 0 0 0 2px rgba(204,61,74,.95)}}.pete-answer{position:relative;display:flex;align-items:center;gap:.75rem;width:100%;border-radius:1rem;padding:.85rem 1rem;text-align:left;font-weight:600;color:#fff;background:rgba(0,0,0,.26);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.1);transition:background .15s ease,transform .1s ease,box-shadow .2s ease}.pete-answer:hover:not(:disabled){background:rgba(0,0,0,.36);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.28)}.pete-answer:active:not(:disabled){transform:translateY(1px)}.pete-answer:disabled{cursor:default}.pete-answer-key{display:grid;place-items:center;height:1.75rem;width:1.75rem;flex:none;border-radius:.6rem;background:hsla(0,0%,100%,.12);font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:.8rem;font-weight:700;color:hsla(0,0%,100%,.7)}.pete-answer[data-state=right]{background:rgba(46,160,103,.9);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.5)}.pete-answer[data-state=wrong]{background:rgba(204,61,74,.9);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.35);animation:pete-answer-no .45s ease}.pete-answer[data-state=missed]{box-shadow:inset 0 0 0 2px rgba(46,160,103,.95)}.pete-answer[data-state=dim]{opacity:.4}@keyframes pete-answer-no{0%,to{transform:translateX(0)}20%{transform:translateX(-6px)}45%{transform:translateX(5px)}70%{transform:translateX(-3px)}}.pete-ladder{display:flex;flex-wrap:wrap;gap:.3rem}.pete-rung{height:.5rem;width:1.1rem;border-radius:999px;background:hsla(0,0%,100%,.14);transition:background .3s ease,transform .3s ease}.pete-rung[data-on="1"]{background:var(--accent);transform:scaleY(1.35)}.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}.pete-uno[data-c=red]{--play:#d64545}.pete-uno[data-c=blue]{--play:#3d7fd6}.pete-uno[data-c=yellow]{--play:#e0b02c}.pete-uno[data-c=green]{--play:#46a86b}.pete-uno:before{content:"";position:absolute;inset:0;pointer-events:none;background:radial-gradient(80% 55% at 50% 45%,var(--play,transparent),transparent 70%);opacity:.16;transition:opacity .5s ease,background .5s ease}.pete-uno-card{position:relative;height:var(--uno-h,6.4rem);width:var(--uno-w,4.3rem);border-radius:.55rem;padding:.28rem;background:#fff;box-shadow:0 3px 0 rgba(0,0,0,.22),0 6px 14px rgba(0,0,0,.18);flex:none}.pete-uno-face{position:relative;display:grid;place-items:center;height:100%;width:100%;border-radius:.38rem;overflow:hidden;background:var(--uno,#2b2118);color:#fff}.pete-uno-card[data-c=red]{--uno:#d64545}.pete-uno-card[data-c=blue]{--uno:#3d7fd6}.pete-uno-card[data-c=yellow]{--uno:#e0b02c}.pete-uno-card[data-c=green]{--uno:#46a86b}.pete-uno-card[data-c=wild]{--uno:#2b2118}.pete-uno-card[data-named=red]{box-shadow:0 0 0 3px #d64545,0 3px 0 rgba(0,0,0,.22)}.pete-uno-card[data-named=blue]{box-shadow:0 0 0 3px #3d7fd6,0 3px 0 rgba(0,0,0,.22)}.pete-uno-card[data-named=yellow]{box-shadow:0 0 0 3px #e0b02c,0 3px 0 rgba(0,0,0,.22)}.pete-uno-card[data-named=green]{box-shadow:0 0 0 3px #46a86b,0 3px 0 rgba(0,0,0,.22)}.pete-uno-oval{position:relative;z-index:1;display:grid;place-items:center;height:78%;width:62%;border-radius:50%;transform:rotate(-24deg);background:#fff;color:var(--uno,#2b2118);font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:1.45rem;font-weight:700;line-height:1;text-shadow:0 1px 0 rgba(0,0,0,.1)}.pete-uno-card[data-c=wild] .pete-uno-oval{color:#2b2118;font-size:1.15rem}.pete-uno-corner{position:absolute;z-index:1;font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:.62rem;font-weight:700;line-height:1;color:#fff;text-shadow:0 1px 1px rgba(0,0,0,.35)}.pete-uno-corner[data-at=tl]{top:.22rem;left:.28rem}.pete-uno-corner[data-at=br]{bottom:.22rem;right:.28rem;transform:rotate(180deg)}.pete-uno-wheel{position:absolute;inset:0;background:conic-gradient(#d64545 0 25%,#e0b02c 0 50%,#46a86b 0 75%,#3d7fd6 0);opacity:.92}.pete-uno-card-back{padding:.28rem}.pete-uno-back{display:grid;place-items:center;height:100%;width:100%;border-radius:.38rem;background:radial-gradient(120% 80% at 50% 0,hsla(0,0%,100%,.16),transparent 60%),#2b2118}.pete-uno-back:after{content:"UNO";display:block;transform:rotate(-24deg);padding:.1rem .35rem;border-radius:999px;background:#e0b02c;color:#2b2118;font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:.6rem;font-weight:700;letter-spacing:.02em}.pete-uno-hand{display:flex;flex-wrap:wrap;justify-content:center;gap:.4rem;min-height:6.8rem;padding-top:.6rem}.pete-uno-hand .pete-uno-card{animation:pete-uno-in .34s cubic-bezier(.22,1,.36,1) backwards;animation-delay:calc(var(--i, 0)*45ms);transition:transform .16s ease,filter .16s ease}.pete-uno-hand .pete-uno-card[data-on="1"]{cursor:pointer;transform:translateY(-.5rem)}.pete-uno-hand .pete-uno-card[data-on="1"]:hover{transform:translateY(-1.1rem) scale(1.03)}.pete-uno-hand .pete-uno-card[data-on="0"]{filter:brightness(.62) saturate(.7)}@keyframes pete-uno-in{0%{transform:translateY(2rem) rotate(6deg);opacity:0}to{transform:translateY(-.5rem);opacity:1}}.pete-uno-seat{display:flex;flex-direction:column;align-items:center;gap:.3rem;position:relative;padding:.5rem .7rem;border-radius:1rem;transition:background .25s ease,transform .25s ease}.pete-uno-seat[data-turn="1"]{background:hsla(0,0%,100%,.12);transform:translateY(-2px)}.pete-uno-seat[data-uno="1"] .pete-uno-count{color:#ffd76e}.pete-uno-fan{display:flex;--uno-h:3.2rem;--uno-w:2.15rem}.pete-uno-fan .pete-uno-card{margin-left:-1.35rem;transform:rotate(calc((var(--i, 0) - (var(--n, 1) - 1)/2)*4deg));transform-origin:bottom center;box-shadow:0 2px 0 rgba(0,0,0,.22)}.pete-uno-fan .pete-uno-card:first-child{margin-left:0}.pete-uno-fan .pete-uno-back:after{font-size:.4rem;padding:.06rem .22rem}.pete-uno-name{font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:.9rem;font-weight:700;color:#fff;line-height:1}.pete-uno-count{font-size:.7rem;font-weight:700;color:hsla(0,0%,100%,.55);line-height:1}.pete-uno-deck{position:relative;--uno-h:6.4rem;--uno-w:4.3rem;height:var(--uno-h);width:var(--uno-w);border-radius:.55rem;padding:.28rem;background:#fff;box-shadow:0 3px 0 rgba(0,0,0,.22),.28rem .28rem 0 -.06rem hsla(0,0%,100%,.35),.5rem .5rem 0 -.12rem hsla(0,0%,100%,.18);transition:transform .15s ease}.pete-uno-deck:not(:disabled):hover{transform:translateY(-3px)}.pete-uno-deck:disabled{opacity:.75}.pete-uno-deck-count{position:absolute;bottom:-.55rem;left:50%;transform:translateX(-50%);padding:.1rem .5rem;border-radius:999px;background:rgba(0,0,0,.55);color:#fff;font-size:.65rem;font-weight:700;line-height:1.4}.pete-uno-shuffle{animation:pete-uno-shuffle .42s ease}@keyframes pete-uno-shuffle{0%,to{transform:none}30%{transform:translateY(-4px) rotate(-3deg)}65%{transform:translateY(2px) rotate(2deg)}}.pete-uno-discard{display:grid;place-items:center;height:6.4rem;width:4.3rem;border-radius:.55rem;box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.14)}.pete-uno-land{animation:pete-uno-land .3s cubic-bezier(.34,1.56,.64,1)}@keyframes pete-uno-land{0%{transform:scale(1.14) rotate(-4deg)}to{transform:none}}.pete-uno-colour{font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;padding:.15rem .6rem;border-radius:999px;color:#fff;background:rgba(0,0,0,.3)}.pete-uno-colour[data-c=red]{background:#d64545}.pete-uno-colour[data-c=blue]{background:#3d7fd6}.pete-uno-colour[data-c=yellow]{background:#e0b02c;color:#2b2118}.pete-uno-colour[data-c=green]{background:#46a86b}.pete-uno-badge{position:absolute;top:-.4rem;left:50%;z-index:5;padding:.2rem .6rem;border-radius:999px;background:#fff;color:#2b2118;font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:.75rem;font-weight:700;white-space:nowrap;box-shadow:0 3px 0 rgba(0,0,0,.2);animation:pete-uno-badge .9s ease forwards}.pete-uno-badge[data-tone=bad]{background:#cc3d4a;color:#fff}.pete-uno-badge[data-tone=uno]{background:#e0b02c}@keyframes pete-uno-badge{0%{transform:translate(-50%,.4rem) scale(.7);opacity:0}25%{transform:translate(-50%,-.5rem) scale(1.06);opacity:1}75%{transform:translate(-50%,-.9rem) scale(1);opacity:1}to{transform:translate(-50%,-1.6rem) scale(.95);opacity:0}}.pete-uno-wild{position:absolute;inset:0;z-index:10;display:grid;place-items:center;background:rgba(10,20,15,.55);-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px)}.pete-uno-wild-box{padding:1.25rem 1.5rem;border-radius:1.25rem;background:rgba(20,40,30,.92);box-shadow:0 12px 30px rgba(0,0,0,.35);text-align:center}.pete-uno-swatch{padding:.7rem 1.4rem;border-radius:.75rem;font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:1rem;font-weight:700;color:#fff;background:var(--sw,#666);box-shadow:0 3px 0 rgba(0,0,0,.3);transition:transform .12s ease,filter .12s ease}.pete-uno-swatch:hover{transform:translateY(-2px);filter:brightness(1.08)}.pete-uno-swatch:active{transform:translateY(1px)}.pete-uno-swatch[data-c=red]{--sw:#d64545}.pete-uno-swatch[data-c=blue]{--sw:#3d7fd6}.pete-uno-swatch[data-c=yellow]{--sw:#e0b02c;color:#2b2118}.pete-uno-swatch[data-c=green]{--sw:#46a86b}@media (max-width:639px){.pete-uno-hand{gap:.3rem}.pete-uno-deck,.pete-uno-hand{--uno-h:5.2rem;--uno-w:3.5rem}.pete-uno-discard{height:5.2rem;width:3.5rem}.pete-uno-hand .pete-uno-card{padding:.22rem}.pete-uno-oval{font-size:1.15rem}.pete-uno-seat{padding:.35rem .45rem}}@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}.pete-answer[data-state=wrong],.pete-clock[data-hot="1"]{animation:none}.pete-rung{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-7{margin-top:1.75rem}.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-\[16rem\]{min-height:16rem}.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-2\.5{gap:.625rem}.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-4{-moz-column-gap:1rem;column-gap:1rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-3{row-gap:.75rem}.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-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*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}.pr-32{padding-right:8rem}.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\/70{color:hsla(0,0%,100%,.7)}.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\:text-white\/80:hover{color:hsla(0,0%,100%,.8)}.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\:gap-5{gap:1.25rem}.sm\:gap-8{gap:2rem}.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/casino-fx.js b/internal/web/static/js/casino-fx.js index 711ed82..983ef0c 100644 --- a/internal/web/static/js/casino-fx.js +++ b/internal/web/static/js/casino-fx.js @@ -73,19 +73,22 @@ return ((n - Math.floor(n)) * 2 - 1) * (spread || 1); } - // fly moves one chip from one place to another and resolves when it lands. + // flyNode moves *anything* from one place to another and resolves when it + // lands: a chip, a playing card, a card face down. // // The arc is the point. A straight line between two rects reads as a UI - // transition; a chip that rises, travels and drops reads as a throw. WAAPI does - // this in one keyframe list because it can interpolate a midpoint — a CSS + // transition; something that rises, travels and drops reads as a throw. WAAPI + // does this in one keyframe list because it can interpolate a midpoint — a CSS // transition can't, which is why this isn't a class toggle. - function fly(from, to, opts) { + // + // The node is yours: build it, hand it over, and it is gone when the promise + // resolves. Nothing in here knows or cares what it was. + function flyNode(node, from, to, opts) { opts = opts || {}; var a = centre(from); var b = centre(to); - var el = disc(opts.denom || 25); - el.className += " pete-fly"; - stage().appendChild(el); + node.classList.add("pete-fly"); + stage().appendChild(node); var dur = opts.duration || 420; if (reduced) dur = 1; @@ -96,12 +99,14 @@ var midX = (a.x + b.x) / 2; var midY = (a.y + b.y) / 2 - lift; var spin = opts.spin == null ? jitter(opts.index || 0, 90) : opts.spin; + var s0 = opts.fromScale == null ? 0.85 : opts.fromScale; + var s1 = opts.toScale == null ? 1 : opts.toScale; - var anim = el.animate( + var anim = node.animate( [ - { transform: t(a.x, a.y, 0.85, 0), opacity: 1, offset: 0 }, - { transform: t(midX, midY, 1.12, spin * 0.6), opacity: 1, offset: 0.5 }, - { transform: t(b.x, b.y, 1, spin), opacity: opts.fade ? 0 : 1, offset: 1 }, + { transform: t(a.x, a.y, s0, opts.fromSpin || 0), opacity: 1, offset: 0 }, + { transform: t(midX, midY, (s0 + s1) / 2 * 1.12, spin * 0.6), opacity: 1, offset: 0.5 }, + { transform: t(b.x, b.y, s1, spin), opacity: opts.fade ? 0 : 1, offset: 1 }, ], { duration: dur, easing: "cubic-bezier(0.33, 0, 0.25, 1)", delay: reduced ? 0 : opts.delay || 0, fill: "both" } ); @@ -109,10 +114,17 @@ return anim.finished .catch(function () {}) .then(function () { - el.remove(); + node.remove(); }); } + // fly throws one chip. It is flyNode with a chip in it, which is what it always + // was — UNO wanted the same throw with a card in it, so the throw moved out. + function fly(from, to, opts) { + opts = opts || {}; + return flyNode(disc(opts.denom || 25), from, to, opts); + } + function t(x, y, scale, rot) { return "translate(" + x + "px," + y + "px) scale(" + scale + ") rotate(" + rot + "deg)"; } @@ -313,6 +325,7 @@ disc: disc, jitter: jitter, fly: fly, + flyNode: flyNode, flyMany: flyMany, spot: spot, burst: burst, diff --git a/internal/web/static/js/uno.js b/internal/web/static/js/uno.js new file mode 100644 index 0000000..941704f --- /dev/null +++ b/internal/web/static/js/uno.js @@ -0,0 +1,686 @@ +// The UNO table. +// +// Same bargain as every other table in the room: the browser holds no game. It +// sends one move, and what comes back is that move *and every bot turn it handed +// off to*, as a script of events. So a round trip here is a whole lap of the +// table, and this file's main job is to play that lap back slowly enough that +// you can see what happened to you. +// +// Two rules carried over from the tables that came before, both load-bearing: +// +// The number under the pile is a readout of the pile (PeteFX.spot owns that), and +// the chip bar does not move until the chips that justify it have landed. So a +// settling game holds the money back until the payout has physically swept home +// — a counter that pays you before the last card goes down has told you the +// ending. +// +// And the browser never learns a bot's hand. It gets a *count*. Every fan of +// backs you see up there is drawn from a number, because a number is all that +// crossed the wire. +(function () { + "use strict"; + + var root = document.querySelector("[data-uno]"); + if (!root) return; + + var FX = window.PeteFX; + + var seatsEl = root.querySelector("[data-seats]"); + var handEl = root.querySelector("[data-hand]"); + var deckEl = root.querySelector("[data-deck]"); + var deckCntEl = root.querySelector("[data-deck-count]"); + var discardEl = root.querySelector("[data-discard]"); + var colourEl = root.querySelector("[data-colour]"); + var turnEl = root.querySelector("[data-turn-label]"); + var countEl = root.querySelector("[data-count-label]"); + var verdictEl = root.querySelector("[data-verdict]"); + var paysEl = root.querySelector("[data-pays]"); + var meterEl = root.querySelector("[data-meter]"); + var tableEl = root.querySelector("[data-table-name]"); + + var wildEl = root.querySelector("[data-wild]"); + var betting = root.querySelector("[data-betting]"); + var playing = root.querySelector("[data-playing]"); + var drawBtn = root.querySelector("[data-draw]"); + var passBtn = root.querySelector("[data-pass]"); + var betAmount = root.querySelector("[data-bet-amount]"); + var startBtn = root.querySelector("[data-start]"); + var msgEl = root.querySelector("[data-table-msg]"); + var gameMsgEl = root.querySelector("[data-game-msg]"); + + var purseEl = document.querySelector("[data-chips]"); + var spotEl = root.querySelector("[data-spot]"); + var houseEl = root.querySelector("[data-house]"); + + var spot = FX.spot({ + spot: spotEl, + stack: root.querySelector("[data-stack]"), + total: root.querySelector("[data-spot-total]"), + }); + + var bet = 0; // what you're building between games + var busy = false; + var game = null; // the game as the server last described it + var tier = "table"; + var pendingWild = -1; // the wild you clicked, waiting on a colour + + var reduced = FX.reduced; + function pace(ms) { return reduced ? 0 : ms; } + function wait(ms) { return new Promise(function (r) { setTimeout(r, pace(ms)); }); } + + function say(text, tone, where) { + var el = where || msgEl; + if (!el) return; + if (!text) { el.classList.add("hidden"); return; } + el.textContent = text; + el.classList.remove("hidden"); + el.style.color = tone === "bad" ? "#cc3d4a" : ""; + } + + // ---- drawing the cards ----------------------------------------------------- + + // GLYPHS are what goes in the middle of an action card. The face the engine + // sends is a word ("skip", "+2"); this is the drawing of it. + var GLYPHS = { skip: "🚫", reverse: "⇄", "+2": "+2", wild: "★", "+4": "+4" }; + + // card builds one UNO card. The oval across the middle at an angle is the whole + // look of the thing — without it a card reads as a coloured button. + function card(c, opts) { + opts = opts || {}; + var el = document.createElement(opts.button ? "button" : "div"); + if (opts.button) el.type = "button"; + el.className = "pete-uno-card"; + el.dataset.c = c.wild ? "wild" : c.color; + + var face = document.createElement("span"); + face.className = "pete-uno-face"; + + var oval = document.createElement("span"); + oval.className = "pete-uno-oval"; + oval.textContent = GLYPHS[c.value] || c.value; + face.appendChild(oval); + + // The corners, as printed. + ["tl", "br"].forEach(function (at) { + var corner = document.createElement("span"); + corner.className = "pete-uno-corner"; + corner.dataset.at = at; + corner.textContent = GLYPHS[c.value] || c.value; + corner.setAttribute("aria-hidden", "true"); + face.appendChild(corner); + }); + + // A wild is four quadrants of colour — and once it has been played as a + // colour, that colour is the one it wears on the pile. + if (c.wild) { + var wheel = document.createElement("span"); + wheel.className = "pete-uno-wheel"; + face.appendChild(wheel); + if (c.color && c.color !== "wild") el.dataset.named = c.color; + } + + el.appendChild(face); + el.setAttribute("aria-label", label(c)); + return el; + } + + function label(c) { + var v = c.value === "+2" ? "draw two" : + c.value === "+4" ? "wild draw four" : + c.value === "wild" ? "wild" : c.value; + if (c.wild) return v + (c.color && c.color !== "wild" ? ", played as " + c.color : ""); + return c.color + " " + v; + } + + function back() { + var el = document.createElement("div"); + el.className = "pete-uno-card pete-uno-card-back"; + var b = document.createElement("span"); + b.className = "pete-uno-back"; + el.appendChild(b); + return el; + } + + // ---- the board ------------------------------------------------------------- + + // FAN is the most backs a bot's hand draws, however many it holds. Past this + // the fan is unreadable and the number beside it is doing the work anyway. + var FAN = 8; + + function renderSeats(v) { + seatsEl.innerHTML = ""; + if (!v) return; + v.seats.forEach(function (s, i) { + if (s.you) return; // you are the hand at the bottom, not a seat up here + var el = document.createElement("div"); + el.className = "pete-uno-seat"; + el.dataset.seat = String(i); + el.dataset.turn = v.turn === i && v.phase !== "done" ? "1" : "0"; + if (s.uno) el.dataset.uno = "1"; + + var fan = document.createElement("div"); + fan.className = "pete-uno-fan"; + var show = Math.min(s.cards, FAN); + for (var n = 0; n < show; n++) { + var b = back(); + b.style.setProperty("--i", n); + b.style.setProperty("--n", show); + fan.appendChild(b); + } + el.appendChild(fan); + + var name = document.createElement("p"); + name.className = "pete-uno-name"; + name.textContent = s.name; + el.appendChild(name); + + var count = document.createElement("p"); + count.className = "pete-uno-count"; + count.dataset.count = ""; + count.textContent = s.cards + (s.cards === 1 ? " card" : " cards"); + el.appendChild(count); + + seatsEl.appendChild(el); + }); + } + + function seatEl(i) { return seatsEl.querySelector('[data-seat="' + i + '"]'); } + + // Where a card flies to or from, for a seat. Yours is the hand; a bot's is its + // fan; the deck is the deck. + function seatAnchor(i) { + if (i === 0) return handEl; + var el = seatEl(i); + return el ? el.querySelector(".pete-uno-fan") : deckEl; + } + + function renderHand(v) { + handEl.innerHTML = ""; + if (!v || !v.hand) return; + var playable = {}; + (v.playable || []).forEach(function (i) { playable[i] = true; }); + var yours = v.turn === 0 && v.phase !== "done"; + + v.hand.forEach(function (c, i) { + var el = card(c, { button: true }); + el.style.setProperty("--i", i); + el.dataset.at = String(i); + var ok = yours && playable[i]; + el.dataset.on = ok ? "1" : "0"; + el.disabled = !ok || busy; + if (ok) el.addEventListener("click", function () { pick(i, c); }); + handEl.appendChild(el); + }); + } + + function renderPiles(v) { + discardEl.innerHTML = ""; + if (!v) { + deckCntEl.textContent = "0"; + colourEl.textContent = ""; + root.querySelector(".pete-uno").dataset.c = ""; + return; + } + deckCntEl.textContent = String(v.deck); + var top = card(v.top); + top.classList.add("pete-uno-top"); + discardEl.appendChild(top); + + // The colour in play, which after a wild is not the colour of the card you + // are looking at. So it is said in words, and the felt takes a tint of it — + // this is the single most missable fact on the table. + colourEl.textContent = v.color; + colourEl.dataset.c = v.color; + feltEl.dataset.c = v.color; + } + + var feltEl = root.querySelector(".pete-uno"); + + function renderRail(v) { + if (!v) { + paysEl.textContent = "—"; + meterEl.dataset.cold = "1"; + tableEl.textContent = ""; + return; + } + paysEl.textContent = (v.pays || 0).toLocaleString(); + meterEl.dataset.cold = v.phase === "done" ? "1" : "0"; + tableEl.textContent = v.tier.name + " · " + v.tier.base.toFixed(1) + "×"; + } + + function renderTurn(v) { + if (!v || v.phase === "done") { + turnEl.textContent = ""; + countEl.textContent = ""; + return; + } + var yours = v.turn === 0; + var who = yours ? "Your turn" : (v.seats[v.turn] || {}).name + " is thinking…"; + if (yours && v.phase === "drawn") who = "Play it, or keep it"; + turnEl.textContent = who; + turnEl.dataset.you = yours ? "1" : "0"; + var n = v.hand.length; + countEl.textContent = n + (n === 1 ? " card — UNO!" : " cards"); + } + + // ---- phases ---------------------------------------------------------------- + + var VERDICTS = { + won: "You went out! 🎉", + lost: "Beaten to it.", + stuck: "Nobody could move.", + }; + + function verdict(v) { + var text = VERDICTS[v.outcome] || ""; + if (v.outcome === "lost" && v.winner > 0 && v.seats[v.winner]) { + text = v.seats[v.winner].name + " went out first."; + } + 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"); + if (v.outcome === "won") FX.burst(verdictEl, { count: 34 }); + } + + function setPhase(v) { + game = v; + var live = !!v && v.phase !== "done"; + betting.classList.toggle("hidden", live); + playing.classList.toggle("hidden", !live); + hideWild(); + + var yours = live && v.turn === 0; + if (drawBtn) { + drawBtn.disabled = !yours || v.phase === "drawn" || busy; + drawBtn.classList.toggle("hidden", !!(live && v.phase === "drawn")); + } + if (passBtn) { + passBtn.classList.toggle("hidden", !(live && v.phase === "drawn")); + passBtn.disabled = !yours || busy; + } + if (deckEl) deckEl.disabled = !yours || v.phase === "drawn" || busy; + if (!v || !v.outcome) verdictEl.classList.add("hidden"); + } + + // paint puts the board up with no animation: the resume path, after a reload or + // a redeploy. Whatever the server says is on the table is on the table. + function paint(v) { + renderSeats(v); + renderPiles(v); + renderHand(v); + renderRail(v); + renderTurn(v); + spot.render(v && v.phase !== "done" ? v.bet : 0); + setPhase(v); + } + + // ---- the script ------------------------------------------------------------ + + // throwCard sends a card from one place to another across the felt. + function throwCard(node, from, to, opts) { + opts = opts || {}; + return FX.flyNode(node, from, to, { + duration: opts.duration || 380, + lift: opts.lift == null ? 0.7 : opts.lift, + spin: opts.spin == null ? FX.jitter((opts.index || 0) + 3, 14) : opts.spin, + fromScale: opts.fromScale == null ? 0.9 : opts.fromScale, + delay: opts.delay || 0, + }); + } + + // bump keeps a bot's fan honest during the script: the server's count is the + // truth, but between events the fan has to grow and shrink as cards move, or + // the flight lands on a pile that hasn't changed. + function bump(seat, left) { + if (seat === 0 || left == null) return; + var el = seatEl(seat); + if (!el) return; + var fan = el.querySelector(".pete-uno-fan"); + var count = el.querySelector("[data-count]"); + if (count) count.textContent = left + (left === 1 ? " card" : " cards"); + if (!fan) return; + var show = Math.min(left, FAN); + while (fan.children.length > show) fan.removeChild(fan.lastChild); + while (fan.children.length < show) fan.appendChild(back()); + Array.prototype.forEach.call(fan.children, function (b, i) { + b.style.setProperty("--i", i); + b.style.setProperty("--n", fan.children.length); + }); + el.dataset.uno = left === 1 ? "1" : "0"; + } + + function spotlight(seat) { + seatsEl.querySelectorAll(".pete-uno-seat").forEach(function (el) { + el.dataset.turn = parseInt(el.dataset.seat, 10) === seat ? "1" : "0"; + }); + } + + // badge floats a word off a seat: SKIPPED, UNO!, +4. The events already say + // these things; the badge is what makes them land on somebody. + function badge(seat, text, tone) { + var host = seat === 0 ? handEl : seatEl(seat); + if (!host || reduced) return Promise.resolve(); + var b = document.createElement("span"); + b.className = "pete-uno-badge"; + b.dataset.tone = tone || ""; + b.textContent = text; + host.appendChild(b); + return new Promise(function (r) { + setTimeout(function () { b.remove(); r(); }, 900); + }); + } + + // play walks the server's script. On a live game the money is already right — + // your stake left your pile when you pressed Deal, and it's on the spot — so + // the chip bar can catch up immediately. On a settling one it waits. + function play(view, money) { + var events = view.uno_events || []; + var final = view.uno; + var settles = !!final && final.phase === "done"; + var chain = Promise.resolve(); + + if (!settles) money(); + + events.forEach(function (e, n) { + chain = chain.then(function () { + switch (e.kind) { + case "deal": + // The deal isn't animated card by card: seven cards to each of four + // seats is 28 flights and a player who wants to play. The hand fans in + // on its own (CSS), which reads as being dealt without taking as long. + paint(final); + return wait(320); + + case "play": { + spotlight(e.seat); + var node = card(e.card); + var from = seatAnchor(e.seat); + // Your own card leaves the hand it was in, so the hand has to lose it + // before the flight or the card is briefly in two places. + if (e.seat === 0) { + var live = handEl.querySelector('.pete-uno-card[data-on="1"][data-at]'); + if (live) live.style.visibility = "hidden"; + } + bump(e.seat, e.left); + return throwCard(node, from, discardEl, { index: n }) + .then(function () { + discardEl.innerHTML = ""; + var top = card(e.card); + top.classList.add("pete-uno-top", "pete-uno-land"); + discardEl.appendChild(top); + if (e.color) { + colourEl.textContent = e.color; + colourEl.dataset.c = e.color; + feltEl.dataset.c = e.color; + } + return wait(e.seat === 0 ? 120 : 300); + }); + } + + case "draw": + case "forced": { + spotlight(e.seat); + var to = seatAnchor(e.seat); + var backs = []; + for (var i = 0; i < Math.min(e.n, 4); i++) { + backs.push(throwCard(back(), deckEl, to, { index: i, delay: i * 90 })); + } + var punished = e.kind === "forced"; + if (punished) badge(e.seat, "+" + e.n, "bad"); + return Promise.all(backs).then(function () { + bump(e.seat, e.left); + deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n)); + // Your own drawn card comes face up — it's yours, and the server + // sent its face for exactly this. + if (e.seat === 0 && e.card) return wait(160); + return wait(punished ? 380 : 180); + }); + } + + case "skip": + return badge(e.seat, "Skipped", "bad").then(function () { return wait(80); }); + + case "reverse": + feltEl.dataset.rev = feltEl.dataset.rev === "1" ? "0" : "1"; + return badge(e.seat, "Reverse").then(function () { return wait(60); }); + + case "uno": + return badge(e.seat, "UNO!", "uno"); + + case "reshuffle": + deckEl.classList.add("pete-uno-shuffle"); + return wait(420).then(function () { + deckEl.classList.remove("pete-uno-shuffle"); + }); + + case "pass": + spotlight(e.seat); + return wait(140); + + case "settle": + return; + } + }); + }); + + return chain.then(function () { + if (!final) { paint(null); money(); return; } + + if (!settles) { + paint(final); + return; + } + + // Over: the board settles, the money moves, and only then does the bar + // catch up. + renderSeats(final); + renderPiles(final); + renderHand(final); + renderTurn(final); + renderRail(final); + verdict(final); + playing.classList.add("hidden"); + return settleChips(final) + .then(money) + .then(function () { return standing(final.bet); }) + .then(function () { setPhase(final); }); + }); + } + + // ---- the money ------------------------------------------------------------- + + function settleChips(final) { + var payout = final.payout || 0; + if (payout <= 0) return spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true }); + + var back = payout - final.bet; + return spot + .pour(houseEl, back, { gap: 60 }) + .then(function () { return wait(back > 0 ? 380 : 200); }) + .then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); }); + } + + // standing puts the stake back on the spot for the next game, the way every + // other table in the room leaves your bet up. pour grows the pile from what it + // is told is already there, so the amount is never set first — that bug printed + // double the stake under trivia's chips for a day. + function standing(amount) { + var money = window.PeteGames.view(); + if (!amount || !money || money.chips < amount) { + bet = 0; + showBet(); + return; + } + bet = amount; + showBet(); + return spot.pour(purseEl, amount); + } + + // ---- moves ------------------------------------------------------------------ + + function pick(i, c) { + if (busy || !game || game.phase === "done" || game.turn !== 0) return; + if (c.wild) { askColour(i); return; } + move({ kind: "play", index: i }); + } + + function askColour(i) { + pendingWild = i; + wildEl.classList.remove("hidden"); + } + + function hideWild() { + pendingWild = -1; + if (wildEl) wildEl.classList.add("hidden"); + } + + // COLOURS maps the colour a player names to the number the engine calls it. + // Wild is 0 there on purpose — a move with no colour on it must not quietly + // mean red — so these start at 1 and this table is the only place that knows. + var COLOURS = { red: 1, blue: 2, yellow: 3, green: 4 }; + + root.querySelectorAll("[data-colour-pick]").forEach(function (b) { + b.addEventListener("click", function () { + if (busy || pendingWild < 0) return; + var i = pendingWild; + var c = COLOURS[b.dataset.colourPick]; + hideWild(); + move({ kind: "play", index: i, color: c }); + }); + }); + + var cancelWild = root.querySelector("[data-colour-cancel]"); + if (cancelWild) cancelWild.addEventListener("click", hideWild); + + function move(body) { + send("/api/games/uno/move", body, gameMsgEl); + } + + if (drawBtn) drawBtn.addEventListener("click", function () { drawOne(); }); + if (deckEl) deckEl.addEventListener("click", function () { drawOne(); }); + if (passBtn) { + passBtn.addEventListener("click", function () { + if (busy || !game || game.turn !== 0 || game.phase !== "drawn") return; + move({ kind: "pass" }); + }); + } + + function drawOne() { + if (busy || !game || game.phase !== "play" || game.turn !== 0) return; + move({ kind: "draw" }); + } + + // ---- talking to the table ---------------------------------------------------- + + function send(path, body, where) { + if (busy) return; + busy = true; + say("", null, where); + lock(); + return window.PeteGames.post(path, body) + .then(function (view) { + busy = false; + return play(view, function () { window.PeteGames.apply(view); }); + }) + .catch(function (err) { + busy = false; + say(err.message, "bad", where); + return window.PeteGames.refresh().then(function (v) { + if (v && v.uno) paint(v.uno); + else { paint(null); spot.render(0); } + }); + }); + } + + // lock takes the table away while a move is in flight, so a second click can't + // send a second move against a game the first one is about to change. + function lock() { + handEl.querySelectorAll(".pete-uno-card").forEach(function (b) { b.disabled = true; }); + if (drawBtn) drawBtn.disabled = true; + if (passBtn) passBtn.disabled = true; + if (deckEl) deckEl.disabled = true; + } + + // ---- betting ------------------------------------------------------------------ + + function showBet() { + betAmount.textContent = bet.toLocaleString(); + var money = window.PeteGames.view(); + if (startBtn) startBtn.disabled = bet <= 0 || !tier || !money || money.chips < bet; + } + + function pickTier(slug) { + tier = slug; + root.querySelectorAll("[data-tier]").forEach(function (b) { + b.dataset.on = b.dataset.tier === slug ? "1" : "0"; + }); + showBet(); + } + + root.querySelectorAll("[data-tier]").forEach(function (b) { + b.addEventListener("click", function () { + if (busy) return; + pickTier(b.dataset.tier); + }); + }); + + // The chip you click is the chip that flies. Scoped to buttons: the bare + // [data-chip] spans in the rack are the house's, and the house is not betting. + root.querySelectorAll("button[data-chip]").forEach(function (btn) { + 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("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 + }); + }); + }); + + var clearBtn = root.querySelector("[data-bet-clear]"); + if (clearBtn) { + clearBtn.addEventListener("click", function () { + if (busy) return; + if (spot.amount) spot.sweep(purseEl, null, { gap: 40, lift: 0.7 }); + bet = 0; + showBet(); + }); + } + + if (startBtn) { + startBtn.addEventListener("click", function () { + if (busy) return; + if (bet <= 0) { say("Put something on it first.", "bad"); return; } + // The stake sits on the spot for the whole game: it is what's riding on you + // going out first. + send("/api/games/uno/start", { bet: bet, tier: tier }); + }); + } + + pickTier(tier); + + var resumed = false; + window.PeteGames.onUpdate(function (v) { + if (!resumed) { + resumed = true; + if (v.uno) { + paint(v.uno); + if (v.uno.phase === "done") verdict(v.uno); + } else { + paint(null); + } + } + showBet(); + }); +})(); diff --git a/internal/web/templates/games.html b/internal/web/templates/games.html index 4870fd4..1607fe1 100644 --- a/internal/web/templates/games.html +++ b/internal/web/templates/games.html @@ -90,6 +90,22 @@
+ +Go out first, take the table.
++ One to three bots, and the more of them there are the more it pays: up to 3.6× for + beating a full table. Anybody else going out first takes your stake. +
+ + {{range .Soon}}Go out first, or it was somebody else's night
++ Click a card that lights up. Nothing lights up? Draw one. +
++ Normal rules: no stacking a +2 on a +2, and a reverse is a skip when it's just the two of you. +
+ +