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 @@

+ +
+ 🎴 +
+

UNO

+

Go out first, take the table.

+
+ Open +
+

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

Go out first, or it was somebody else's night

+
+ + {{template "_chipbar" .}} + + +
+
+ +
+ + +
+ + +
+ + +
+
+

+
+
+ + +
+
+

+

+
+
+
+ +
+ +
+
+ + + +
+ + + +
+ + + + + +
+ +
Who are you playing?
+
+ {{range .Tables}} + + {{end}} +
+ +
+
+
Your bet
+
0
+
+ +
+ {{range .Denominations}} + + {{end}} + +
+ + +
+

+ Normal rules: no stacking a +2 on a +2, and a reverse is a skip when it's just the two of you. +

+ +
+ +
+{{end}} + +{{define "scripts"}} + + + +{{end}} diff --git a/pete_games_plan.md b/pete_games_plan.md index 95ba845..d445047 100644 --- a/pete_games_plan.md +++ b/pete_games_plan.md @@ -322,11 +322,65 @@ A multi-session build. This section is the handover; read it before anything els live games on the felt: no overlap with text, no overlap with the shoe, no horizontal overflow, desktop geometry unchanged. +- **UNO, and it plays for chips.** *(2026-07-14. Built and tested, **not yet driven + in a browser** — see "Next" below, because in this room that means it is not + finished.)* + - **You beat the table, or you don't.** The user's call between three money + models: stake once, go out first and take the tier's multiple; anybody else + going out first takes the stake. **The table size is the tier**, which is the + one dial UNO actually has: Duel (1 bot, 2.2×), Table (2 bots, 2.9×), Full + House (3 bots, 3.6×). Rake on winnings only, as everywhere. + - **The multiples are measured, not guessed.** A player who just plays the first + legal card they hold goes out first 43% / 32% / 27% of the time against the + bots, so the tiers are priced to make that lose about 8% a game — which leaves + good play (holding the wilds back, dumping the colour you're long in) worth + roughly the house's edge. The measurement is a throwaway test, not in the tree; + re-run it if the bots or the tiers change, because the two are a pair. + - **The bots move inside `ApplyMove`, and that is what keeps solo UNO off a + socket.** One request plays your move *and every bot turn it hands off to*, + and returns the lot as a script of events the felt plays back in order. §7 said + solo first, no sockets; this is what that costs. + - **The RNG is in the state, not an argument.** The bots choose and a spent deck + reshuffles, so the engine needs randomness *mid-game* — but there is no rng + alive across requests to hand it. 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 the seed and the step count. Value in, value out, and a game + still replays exactly as it fell. + - **The zero value of `Color` is Wild, deliberately.** It was Red for an hour, and + a wild played with the `color` field simply missing from the JSON went down as + a red one. The zero has to be "no colour named", so the omission is refused + instead of quietly meaning something. This is the kind of bug a rules test + finds and a browser never would. + - **The browser never sees a bot's card.** Not the deck, not a hand, not even the + face of a card a bot drew — that last one is most of the deck, and sending it + would turn counting cards into reading the network tab. Seats cross the wire as + a name and a *count*. There are two walls: the engine only attaches a face to + an event the seat may see it in, and `viewUnoEvents` drops it again anyway. + - `internal/games/uno` — engine, 22 tests. The census one is the load-bearing one: + 108 cards, each in exactly one place, asserted after every move of 100 games + played out end to end. It is what would catch a reshuffle that leaks cards (the + wilds go back into the deck as *wilds*, not as the colour they were played as) + or a turn the bots never hand back. + - `PeteFX.flyNode` — the throw, with the chip taken out of it. `fly()` is now that + with a chip in it, because UNO wanted the same arc with a card in it. Extracted + rather than copied, same as `casino-cards.js` and `PeteFX.spot()` before it. + - The felt has no corner free for the house rack (bots along the top, piles in the + middle, your hand at the bottom), so it takes solitaire's **rail** instead: + `data-at="rail"`, off the felt, no collision to check for. + ### Next, in order -1. Phase 3 (UNO), Phase 4 (hold'em) as below. -2. Trivia is played but not deployed. Hangman, solitaire and trivia are all still - sitting on main un-deployed — the server runs `StartTriviaBank`, so its bank +1. **Drive UNO in a browser.** It is built, the engine is tested and the handlers are + tested, but nothing on this table has been *looked at* — and every game before it + found bugs in that step that no Go test could see (a white-on-white pill, a rack on + top of a multiplier, a spot printing double the stake). Specifically worth watching: + the bots' turns play back as a readable script rather than a blur; the wild colour + picker; a hand of ten cards at 390px with no sideways overflow; and a reload + mid-game bringing the board back. `PETE_DEV_CASINO=:7788 go test ./internal/web + -run TestDevCasino -timeout 0`, then Playwright. +2. Phase 4 (hold'em) as below. +3. Trivia is played but not deployed. Hangman, solitaire, trivia and now UNO are all + still sitting on main un-deployed — the server runs `StartTriviaBank`, so its bank fills itself once the binary is out there, but the first player to try a ladder in the first minute after a deploy gets the 503.