From c62d736223eac2a68b91a44c14f84e049f77151d Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Tue, 14 Jul 2026 02:11:09 -0700 Subject: [PATCH] games: a ladder you climb against the clock --- internal/games/trivia/trivia.go | 369 ++++++++++++++++++ internal/games/trivia/trivia_test.go | 316 ++++++++++++++++ internal/opentdb/opentdb.go | 166 ++++++++ internal/storage/schema.go | 25 ++ internal/storage/trivia.go | 147 ++++++++ internal/web/games_pages.go | 17 +- internal/web/games_play.go | 15 + internal/web/games_trivia.go | 224 +++++++++++ internal/web/server.go | 2 +- internal/web/static/css/input.css | 121 ++++++ internal/web/static/css/output.css | 2 +- internal/web/static/js/trivia.js | 546 +++++++++++++++++++++++++++ internal/web/templates/games.html | 16 + internal/web/templates/trivia.html | 149 ++++++++ internal/web/trivia_bank.go | 126 +++++++ main.go | 1 + pete_games_plan.md | 54 ++- 17 files changed, 2292 insertions(+), 4 deletions(-) create mode 100644 internal/games/trivia/trivia.go create mode 100644 internal/games/trivia/trivia_test.go create mode 100644 internal/opentdb/opentdb.go create mode 100644 internal/storage/trivia.go create mode 100644 internal/web/games_trivia.go create mode 100644 internal/web/static/js/trivia.js create mode 100644 internal/web/templates/trivia.html create mode 100644 internal/web/trivia_bank.go diff --git a/internal/games/trivia/trivia.go b/internal/games/trivia/trivia.go new file mode 100644 index 0000000..817ca8d --- /dev/null +++ b/internal/games/trivia/trivia.go @@ -0,0 +1,369 @@ +// Package trivia is a pure trivia-ladder engine, played for chips. +// +// Same seam as blackjack and hangman: ApplyMove(state, move, now) (state, +// events, error), where an error means the move was illegal and nothing else. +// The one difference is that clock: trivia is the only game in the room where +// *when* you move changes what it pays, and a pure reducer cannot own a timer. +// So the time is an argument. The engine stays a value in, value out, and the +// only thing that knows what o'clock it is remains the caller. +// +// The shape is a ladder. You stake once, and then answer a run of questions: +// every right answer multiplies what the stake is worth, a wrong one loses the +// lot, and you may walk with what you've built at any point after the first. +// It is the oldest quiz-show bet there is — the tension is entirely in whether +// you take the money. +// +// The reason for the clock is less pretty: trivia answers are googlable, and a +// game that paid the same for a slow right answer as a fast one would be a game +// about typing into another tab. So the multiple a question is worth decays +// from Fast to Buzzer across the tier's time limit, and running out of time +// loses exactly as much as being wrong. The countdown in the browser is +// decoration; this is the clock that counts. +package trivia + +import ( + "errors" + "math" + "math/rand/v2" + "time" +) + +// Errors an illegal move can produce. +var ( + ErrGameOver = errors.New("trivia: the game is already over") + ErrUnknownMove = errors.New("trivia: unknown move") + ErrBadBet = errors.New("trivia: bet must be positive") + ErrUnknownTier = errors.New("trivia: no such tier") + ErrShortLadder = errors.New("trivia: not enough questions to build a ladder") + ErrNothingBanked = errors.New("trivia: answer one before you walk") +) + +// Rungs is how long the ladder is. Clearing it is a win in itself: the run ends +// and banks, because a ladder with no top is just a slot machine you can't stop +// playing, and eventually every player loses everything to one bad question. +const Rungs = 12 + +// Tier is a difficulty, chosen before the bet. It sets three things that move +// together: how hard the questions are, how long you get, and what a right +// answer is worth. Hard questions pay more and give you less time to look them +// up, which is the whole bargain. +type Tier struct { + Slug string `json:"slug"` + Name string `json:"name"` + Difficulty string `json:"difficulty"` // what OpenTDB calls it: easy | medium | hard + Fast float64 `json:"fast"` // what a right answer multiplies by, answered instantly + Buzzer float64 `json:"buzzer"` // ...and what it's worth answered on the last tick + Limit int `json:"limit"` // seconds on the clock, per question + Blurb string `json:"blurb"` +} + +// Tiers are the three tables. +var Tiers = []Tier{ + {Slug: "easy", Name: "Easy", Difficulty: "easy", Fast: 1.30, Buzzer: 1.10, Limit: 20, + Blurb: "Things you know. The clock is the only thing in your way."}, + {Slug: "medium", Name: "Medium", Difficulty: "medium", Fast: 1.55, Buzzer: 1.20, Limit: 18, + Blurb: "Things you nearly know."}, + {Slug: "hard", Name: "Hard", Difficulty: "hard", Fast: 1.90, Buzzer: 1.30, Limit: 15, + Blurb: "Things you don't. Fifteen seconds is not enough to find out."}, +} + +// 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 +} + +// Step is what a right answer multiplies the running total by, given how long +// it took. Fast at nought seconds, Buzzer at the limit, straight line between. +// +// Answering at the buzzer still pays *something* — the decay is a reason to be +// quick, not a punishment for thinking. The punishment for thinking too long is +// the timeout, and that one takes everything. +func (t Tier) Step(elapsed time.Duration) float64 { + limit := t.Clock() + switch { + case elapsed <= 0: + return t.Fast + case elapsed >= limit: + return t.Buzzer + } + speed := 1 - float64(elapsed)/float64(limit) // 1 answering instantly, 0 at the buzzer + return t.Buzzer + (t.Fast-t.Buzzer)*speed +} + +// Clock is the tier's time limit as a duration. +func (t Tier) Clock() time.Duration { return time.Duration(t.Limit) * time.Second } + +// Question is one rung. It carries its own correct index, which is exactly why +// a State never crosses the wire — the browser is sent the answers and not +// which of them is right. +type Question struct { + Category string `json:"category"` + Text string `json:"text"` + Answers []string `json:"answers"` // already shuffled: the right one is not always first + Correct int `json:"correct"` // index into Answers +} + +// Phase is where the game is. +type Phase string + +const ( + PhasePlaying Phase = "playing" + PhaseDone Phase = "done" +) + +// Outcome is how it ended. +type Outcome string + +const ( + OutcomeNone Outcome = "" + OutcomeWalked Outcome = "walked" // took the money + OutcomeCleared Outcome = "cleared" // answered all twelve + OutcomeWrong Outcome = "wrong" // picked the wrong one + OutcomeTimeout Outcome = "timeout" // ran out of clock +) + +// Won reports whether this outcome pays. +func (o Outcome) Won() bool { return o == OutcomeWalked || o == OutcomeCleared } + +// State is one game. The ladder — every question, and every right answer — is +// in here, which is why this value stays on the server. The browser gets a view +// of the current rung and nothing about the ones ahead of it. +type State struct { + Tier Tier `json:"tier"` + Ladder []Question `json:"ladder"` // the whole run, drawn up front + Rung int `json:"rung"` // how many answered right; also the index of the live question + + // AskedAt is when the current question was *put to the player*, by the + // server's clock. It is the only clock in the game. A reload does not reset + // it: you cannot stop time by refreshing. + AskedAt time.Time `json:"asked_at"` + + Multiple float64 `json:"multiple"` // 1.0 at the start; the product of every step earned + 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. +type Event struct { + Kind string `json:"kind"` // "ask" | "right" | "wrong" | "timeout" | "settle" + Choice int `json:"choice"` // what the player picked (-1 when they didn't) + Correct int `json:"correct"` // which one was right — sent only once it's decided + Step float64 `json:"step,omitempty"` // what this answer multiplied by + Multiple float64 `json:"multiple,omitempty"` // the running total, after + Text string `json:"text,omitempty"` +} + +// Move is a player action: pick an answer, or take the money. +type Move struct { + Choice int `json:"choice"` // index into the live question's answers + Walk bool `json:"walk"` +} + +// New starts a game on a ladder of questions the caller has already drawn. The +// engine does not reach for a database any more than blackjack reaches for a +// deck: the questions arrive as a value, so a game is reproducible and a test +// can pin every rung. +// +// The answers are shuffled here, with the caller's seeded rng, because a bank +// that always stores the right answer first would otherwise be a game about +// clicking first. +func New(bet int64, t Tier, rakePct float64, qs []Question, now time.Time, rng *rand.Rand) (State, []Event, error) { + if bet <= 0 { + return State{}, nil, ErrBadBet + } + if len(qs) < Rungs { + return State{}, nil, ErrShortLadder + } + ladder := make([]Question, Rungs) + for i := range ladder { + ladder[i] = shuffleAnswers(qs[i], rng) + } + s := State{ + Tier: t, Ladder: ladder, RakePct: rakePct, + Multiple: 1, + AskedAt: now, + Bet: bet, Phase: PhasePlaying, + } + return s, []Event{{Kind: "ask", Choice: -1, Correct: -1}}, nil +} + +// shuffleAnswers moves the right answer somewhere the player can't guess from +// position, and keeps track of where it went. +func shuffleAnswers(q Question, rng *rand.Rand) Question { + answers := append([]string(nil), q.Answers...) + correct := q.Answers[q.Correct] + rng.Shuffle(len(answers), func(i, j int) { answers[i], answers[j] = answers[j], answers[i] }) + out := q + out.Answers = answers + for i, a := range answers { + if a == correct { + out.Correct = i + break + } + } + return out +} + +// Live is the question the player is looking at. +func (s State) Live() Question { + if s.Rung < 0 || s.Rung >= len(s.Ladder) { + return Question{} + } + return s.Ladder[s.Rung] +} + +// Left is how much clock the live question has, at the given moment. It goes to +// the browser so its countdown starts where the server's does — but the browser +// is never asked what it says. +func (s State) Left(now time.Time) time.Duration { + d := s.Tier.Clock() - now.Sub(s.AskedAt) + if d < 0 { + return 0 + } + return d +} + +// ApplyMove is the engine. now is the server's clock, and the only one that +// counts toward the answer. +func ApplyMove(s State, m Move, now time.Time) (State, []Event, error) { + if s.Phase == PhaseDone { + return s, nil, ErrGameOver + } + s = s.clone() + + if m.Walk { + // You cannot walk off a rung you haven't climbed. If you could, seeing the + // first question and walking away would be a free look: stake, peek, walk, + // stake again, and keep reshuffling until the question is one you know. + // The first question is therefore the price of sitting down. + if s.Rung == 0 { + return s, nil, ErrNothingBanked + } + evs := []Event{} + s.settle(OutcomeWalked, &evs) + return s, evs, nil + } + + q := s.Live() + if len(q.Answers) == 0 { + return s, nil, ErrUnknownMove + } + + elapsed := now.Sub(s.AskedAt) + + // Out of time. This is a loss, and it has to be — a timeout that merely cost + // you the speed bonus would make "leave it open in another tab and go and + // look it up" the strongest way to play. + if elapsed > s.Tier.Clock() { + evs := []Event{{Kind: "timeout", Choice: -1, Correct: q.Correct}} + s.settle(OutcomeTimeout, &evs) + return s, evs, nil + } + + if m.Choice < 0 || m.Choice >= len(q.Answers) { + return s, nil, ErrUnknownMove + } + + if m.Choice != q.Correct { + evs := []Event{{Kind: "wrong", Choice: m.Choice, Correct: q.Correct}} + s.settle(OutcomeWrong, &evs) + return s, evs, nil + } + + // Right, and quick enough to be worth something. + step := s.Tier.Step(elapsed) + s.Multiple *= step + s.Rung++ + evs := []Event{{ + Kind: "right", Choice: m.Choice, Correct: q.Correct, + Step: step, Multiple: s.Multiple, + }} + + if s.Rung >= Rungs { + s.settle(OutcomeCleared, &evs) + return s, evs, nil + } + + // The next question goes up, and its clock starts now. + s.AskedAt = now + evs = append(evs, Event{Kind: "ask", Choice: -1, Correct: -1}) + return s, evs, nil +} + +// Pays is what banking *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 for the same reason hangman's does. The felt quotes this number +// while the game is still running — it is the "take the money" button's label — +// and settle() calls it rather than doing the sum a second time, so the table +// can never advertise a payout the house doesn't hand over. +func (s State) Pays() int64 { + total := int64(math.Floor(float64(s.Bet) * s.Multiple)) + if total < s.Bet { + total = s.Bet // banking never hands back less than the stake + } + profit := total - s.Bet + if profit > 0 { + rake := int64(math.Floor(float64(profit) * s.RakePct)) + if rake > 0 { + profit -= rake + } + } + return s.Bet + profit +} + +// rakeNow is the other half of what Pays works out: the house's cut of a win +// banked at this moment. Never taken from the stake, so a player who walks +// having answered nothing — which they can't — and one who loses, pay nothing. +func (s State) rakeNow() int64 { + total := int64(math.Floor(float64(s.Bet) * s.Multiple)) + if total <= s.Bet { + return 0 + } + rake := int64(math.Floor(float64(total-s.Bet) * s.RakePct)) + if rake < 0 { + return 0 + } + return rake +} + +// settle decides the payout. Same rule as every other table in the room: the +// rake comes out of winnings, never out of the stake, and a loss is never +// charged a fee. +func (s *State) settle(o Outcome, evs *[]Event) { + s.Outcome = o + s.Phase = PhaseDone + + if o.Won() { + s.Payout = s.Pays() + s.Rake = s.rakeNow() + } else { + s.Payout = 0 + } + *evs = append(*evs, Event{Kind: "settle", Choice: -1, Correct: -1, Text: string(o)}) +} + +// 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 +} + +// clone deep-copies the ladder, so a derived state shares no backing array with +// the one it came from and a game can be replayed freely. +func (s State) clone() State { + s.Ladder = append([]Question(nil), s.Ladder...) + return s +} diff --git a/internal/games/trivia/trivia_test.go b/internal/games/trivia/trivia_test.go new file mode 100644 index 0000000..9b682e2 --- /dev/null +++ b/internal/games/trivia/trivia_test.go @@ -0,0 +1,316 @@ +package trivia + +import ( + "math/rand/v2" + "testing" + "time" +) + +func rng() *rand.Rand { return rand.New(rand.NewPCG(1, 2)) } + +var epoch = time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC) + +// bank builds n questions whose right answer is always "right", so a test can +// find it after the shuffle without caring where it landed. +func bank(n int) []Question { + qs := make([]Question, n) + for i := range qs { + qs[i] = Question{ + Category: "General", + Text: "question?", + Answers: []string{"right", "wrong1", "wrong2", "wrong3"}, + Correct: 0, + } + } + return qs +} + +func tier(slug string) Tier { + t, err := TierBySlug(slug) + if err != nil { + panic(err) + } + return t +} + +func newGame(t *testing.T, bet int64, slug string) State { + t.Helper() + s, evs, err := New(bet, tier(slug), 0.05, bank(Rungs), epoch, rng()) + if err != nil { + t.Fatalf("New: %v", err) + } + if len(evs) != 1 || evs[0].Kind != "ask" { + t.Fatalf("New should open with one ask, got %+v", evs) + } + if s.Multiple != 1 { + t.Fatalf("a fresh ladder is worth the stake, got multiple %v", s.Multiple) + } + return s +} + +// answerRight plays the live question correctly, after `took` on the clock. +func answerRight(t *testing.T, s State, took time.Duration) (State, []Event) { + t.Helper() + q := s.Live() + next, evs, err := ApplyMove(s, Move{Choice: q.Correct}, s.AskedAt.Add(took)) + if err != nil { + t.Fatalf("right answer refused: %v", err) + } + return next, evs +} + +func TestNewShufflesButKeepsTheAnswer(t *testing.T) { + s := newGame(t, 100, "medium") + moved := 0 + for _, q := range s.Ladder { + if q.Answers[q.Correct] != "right" { + t.Fatalf("Correct points at %q, not the right answer", q.Answers[q.Correct]) + } + if q.Correct != 0 { + moved++ + } + } + // All twelve landing on index 0 would mean the shuffle isn't running, and the + // game would be "always click the first one". + if moved == 0 { + t.Fatal("the right answer is first in every question — the shuffle did nothing") + } +} + +func TestShortBankIsRefused(t *testing.T) { + if _, _, err := New(100, tier("easy"), 0.05, bank(Rungs-1), epoch, rng()); err != ErrShortLadder { + t.Fatalf("a ladder with a missing rung should be refused, got %v", err) + } +} + +// The one that matters most: the number the felt quotes is the number the +// player is actually paid, at every rung, exactly as in hangman. +func TestTheQuoteIsThePayout(t *testing.T) { + s := newGame(t, 200, "hard") + for rung := 1; rung < Rungs; rung++ { + s, _ = answerRight(t, s, 3*time.Second) + + quoted := s.Pays() // what the "take the money" button says it's worth + + banked, _, err := ApplyMove(s, Move{Walk: true}, s.AskedAt) + if err != nil { + t.Fatalf("rung %d: walk refused: %v", rung, err) + } + if banked.Payout != quoted { + t.Fatalf("rung %d: the felt quoted %d and the house paid %d", rung, quoted, banked.Payout) + } + if banked.Phase != PhaseDone || banked.Outcome != OutcomeWalked { + t.Fatalf("rung %d: walking should end the game, got %s/%s", rung, banked.Phase, banked.Outcome) + } + } +} + +// Walking before answering anything would be a free look at the first question: +// stake, peek, walk, restake until the question is one you happen to know. +func TestYouCannotWalkOffTheFirstRung(t *testing.T) { + s := newGame(t, 100, "easy") + if _, _, err := ApplyMove(s, Move{Walk: true}, epoch); err != ErrNothingBanked { + t.Fatalf("walking on rung 0 should be refused, got %v", err) + } + + // One right answer, and now you may. + s, _ = answerRight(t, s, time.Second) + if _, _, err := ApplyMove(s, Move{Walk: true}, s.AskedAt); err != nil { + t.Fatalf("walking after a right answer should be allowed, got %v", err) + } +} + +func TestAWrongAnswerLosesTheLot(t *testing.T) { + s := newGame(t, 300, "medium") + // Build a decent ladder first, so there is something real to lose. + for i := 0; i < 4; i++ { + s, _ = answerRight(t, s, time.Second) + } + if s.Pays() <= 300 { + t.Fatalf("four right answers should be worth more than the stake, got %d", s.Pays()) + } + + q := s.Live() + wrong := (q.Correct + 1) % len(q.Answers) + out, evs, err := ApplyMove(s, Move{Choice: wrong}, s.AskedAt.Add(time.Second)) + if err != nil { + t.Fatalf("a wrong answer is a legal move: %v", err) + } + if out.Outcome != OutcomeWrong || out.Payout != 0 { + t.Fatalf("a wrong answer should pay nothing, got %s/%d", out.Outcome, out.Payout) + } + if out.Rake != 0 { + t.Fatalf("a loss must never be charged a rake, got %d", out.Rake) + } + if out.Net() != -300 { + t.Fatalf("a wrong answer costs the stake and nothing more, got %d", out.Net()) + } + // The player is told which one it was. + if evs[0].Kind != "wrong" || evs[0].Correct != q.Correct { + t.Fatalf("a wrong answer should reveal the right one, got %+v", evs[0]) + } +} + +// The clock is the whole anti-google mechanism: running out of it has to cost +// as much as being wrong, or leaving the tab open and looking it up wins. +func TestTheClockTakesEverything(t *testing.T) { + s := newGame(t, 250, "hard") + for i := 0; i < 3; i++ { + s, _ = answerRight(t, s, time.Second) + } + banked := s.Pays() + + q := s.Live() + late := s.AskedAt.Add(s.Tier.Clock() + time.Millisecond) + out, evs, err := ApplyMove(s, Move{Choice: q.Correct}, late) // the *right* answer, too late + if err != nil { + t.Fatalf("a late answer is a legal move: %v", err) + } + if out.Outcome != OutcomeTimeout { + t.Fatalf("answering past the limit should time out, got %s", out.Outcome) + } + if out.Payout != 0 { + t.Fatalf("a timeout pays nothing — it was worth %d a moment ago, and paid %d", banked, out.Payout) + } + if evs[0].Kind != "timeout" { + t.Fatalf("expected a timeout event, got %+v", evs[0]) + } + + // And answering on the final tick still counts. + onTime := s.AskedAt.Add(s.Tier.Clock()) + if out, _, err = ApplyMove(s, Move{Choice: q.Correct}, onTime); err != nil { + t.Fatalf("an answer on the buzzer is legal: %v", err) + } + if out.Rung != s.Rung+1 { + t.Fatal("an answer on the final tick should still count") + } +} + +// Speed is the only thing separating a slow right answer from a fast one. +func TestFasterPaysMore(t *testing.T) { + base := newGame(t, 1000, "hard") + + quick, _ := answerRight(t, base, time.Second) + slow, _ := answerRight(t, base, 14*time.Second) + + if quick.Multiple <= slow.Multiple { + t.Fatalf("a quick answer should be worth more: quick %v, slow %v", quick.Multiple, slow.Multiple) + } + if quick.Pays() <= slow.Pays() { + t.Fatalf("a quick answer should pay more: quick %d, slow %d", quick.Pays(), slow.Pays()) + } + + // The ends of the scale are the tier's own numbers, and nothing is outside them. + instant, _ := answerRight(t, base, 0) + buzzer, _ := answerRight(t, base, base.Tier.Clock()) + if instant.Multiple != base.Tier.Fast { + t.Fatalf("an instant answer is worth Fast (%v), got %v", base.Tier.Fast, instant.Multiple) + } + if buzzer.Multiple != base.Tier.Buzzer { + t.Fatalf("an answer on the buzzer is worth Buzzer (%v), got %v", base.Tier.Buzzer, buzzer.Multiple) + } + if quick.Multiple > base.Tier.Fast || slow.Multiple < base.Tier.Buzzer { + t.Fatal("a step escaped the tier's range") + } +} + +// Clearing the ladder ends the run and banks it, rather than leaving the player +// on a rung that doesn't exist. +func TestClearingTheLadderBanks(t *testing.T) { + s := newGame(t, 100, "easy") + for i := 0; i < Rungs; i++ { + if s.Phase != PhasePlaying { + t.Fatalf("the game ended early, on rung %d", i) + } + s, _ = answerRight(t, s, time.Second) + } + if s.Outcome != OutcomeCleared { + t.Fatalf("twelve right answers should clear the ladder, got %s", s.Outcome) + } + if s.Rung != Rungs { + t.Fatalf("expected to be on rung %d, got %d", Rungs, s.Rung) + } + if s.Payout != s.Pays() || s.Payout <= s.Bet { + t.Fatalf("clearing should bank a win, got payout %d on a %d stake", s.Payout, s.Bet) + } + if _, _, err := ApplyMove(s, Move{Choice: 0}, s.AskedAt); err != ErrGameOver { + t.Fatalf("a cleared ladder takes no more moves, got %v", err) + } +} + +// The rake comes out of winnings, never out of the stake. +func TestRakeOnlyBitesWinnings(t *testing.T) { + s := newGame(t, 1000, "medium") + s, _ = answerRight(t, s, 0) // instant: multiple is exactly Fast, so the sum is checkable by hand + + banked, _, err := ApplyMove(s, Move{Walk: true}, s.AskedAt) + if err != nil { + t.Fatalf("walk: %v", err) + } + + total := int64(float64(1000) * s.Tier.Fast) // 1550 + profit := total - 1000 // 550 + rake := int64(float64(profit) * 0.05) // 27 + want := 1000 + profit - rake // 1523 + + if banked.Payout != want { + t.Fatalf("payout should be stake + winnings - 5%% of winnings = %d, got %d", want, banked.Payout) + } + if banked.Rake != rake { + t.Fatalf("rake should be %d, got %d", rake, banked.Rake) + } + if banked.Payout < banked.Bet { + t.Fatal("a win handed back less than the stake") + } +} + +// A move must not scribble on the state it came from — a game has to replay. +func TestApplyMoveDoesNotMutateItsInput(t *testing.T) { + s := newGame(t, 100, "easy") + before := s.Live() + + next, _, err := ApplyMove(s, Move{Choice: before.Correct}, s.AskedAt.Add(time.Second)) + if err != nil { + t.Fatalf("move: %v", err) + } + if s.Rung != 0 || s.Multiple != 1 || s.Phase != PhasePlaying { + t.Fatalf("the original state moved underneath us: rung %d multiple %v", s.Rung, s.Multiple) + } + if next.Rung != 1 { + t.Fatalf("the derived state should have climbed a rung, got %d", next.Rung) + } + // The same move replays to the same place. + again, _, err := ApplyMove(s, Move{Choice: before.Correct}, s.AskedAt.Add(time.Second)) + if err != nil { + t.Fatalf("replay: %v", err) + } + if again.Multiple != next.Multiple || again.Rung != next.Rung { + t.Fatal("the same move from the same state landed somewhere else") + } +} + +func TestLeftCountsDown(t *testing.T) { + s := newGame(t, 100, "hard") // 15s + if got := s.Left(epoch); got != 15*time.Second { + t.Fatalf("a fresh question has the whole clock, got %v", got) + } + if got := s.Left(epoch.Add(10 * time.Second)); got != 5*time.Second { + t.Fatalf("expected 5s left, got %v", got) + } + // It floors at nought rather than going negative, so a browser can render it. + if got := s.Left(epoch.Add(time.Hour)); got != 0 { + t.Fatalf("the clock should stop at zero, got %v", got) + } +} + +func TestGarbageMovesAreRefused(t *testing.T) { + s := newGame(t, 100, "easy") + for _, choice := range []int{-1, 4, 99} { + if _, _, err := ApplyMove(s, Move{Choice: choice}, s.AskedAt); err != ErrUnknownMove { + t.Fatalf("choice %d should be refused, got %v", choice, err) + } + } + if s.Phase != PhasePlaying { + t.Fatal("a refused move should leave the game alone") + } +} diff --git a/internal/opentdb/opentdb.go b/internal/opentdb/opentdb.go new file mode 100644 index 0000000..b8775d9 --- /dev/null +++ b/internal/opentdb/opentdb.go @@ -0,0 +1,166 @@ +// Package opentdb fills the casino's trivia bank from the Open Trivia Database. +// +// The questions are *prefetched* into a local table, not fetched per question, +// and that is a deliberate call rather than an optimisation. A trivia ladder +// asks a question every fifteen seconds with money on the clock: a per-question +// fetch would put somebody else's latency, rate limit and downtime inside a +// timed round the player is being scored against. Pull the bank in the +// background, and a round becomes a local read that either works or doesn't. +// +// OpenTDB allows one request every five seconds per IP and caps a batch at 50, +// so the refill is a slow, polite drip, run in the background and never in the +// path of anything a player is waiting for. +package opentdb + +import ( + "context" + "encoding/json" + "fmt" + "html" + "io" + "net/http" + "net/url" + "strings" + "time" + + "pete/internal/games/trivia" + "pete/internal/safehttp" +) + +// endpoint is the API. It is the only host this package ever talks to, and it +// goes through safehttp like every other outbound fetch in Pete. +const endpoint = "https://opentdb.com/api.php" + +// Batch is the most OpenTDB will hand over in one request. +const Batch = 50 + +// Politeness is the gap the API asks for between requests. Going faster earns a +// response_code 5 and nothing else. +const Politeness = 6 * time.Second + +// fetchTimeout bounds a single request. The refill runs in the background, so a +// slow answer costs nothing but its own goroutine — but it must still end. +const fetchTimeout = 20 * time.Second + +// maxBody caps what we will read from the API, hostile or merely broken. +const maxBody = 1 << 20 + +// apiResponse is OpenTDB's envelope. ResponseCode is the part that matters: +// zero is the only one that means "here are your questions". +type apiResponse struct { + ResponseCode int `json:"response_code"` + Results []struct { + Category string `json:"category"` + Type string `json:"type"` + Question string `json:"question"` + Correct string `json:"correct_answer"` + Incorrect []string `json:"incorrect_answers"` + } `json:"results"` +} + +// responseErr turns a non-zero code into something a log line can explain. +func responseErr(code int) error { + switch code { + case 1: + return fmt.Errorf("opentdb: no results for that query") + case 2: + return fmt.Errorf("opentdb: the query was invalid") + case 3, 4: + return fmt.Errorf("opentdb: session token expired or exhausted") + case 5: + return fmt.Errorf("opentdb: rate limited — slow down") + default: + return fmt.Errorf("opentdb: response code %d", code) + } +} + +// Client fetches questions. +type Client struct { + http *http.Client +} + +func New() *Client { + return &Client{http: safehttp.NewClient(fetchTimeout)} +} + +// Fetch pulls up to n multiple-choice questions of one difficulty. +// +// Only "multiple" questions are asked for: the ladder is four buttons, and a +// true/false question on the same felt would be a coin flip dressed up as a +// question — and a coin flip the player is being paid a difficulty multiple for. +func (c *Client) Fetch(ctx context.Context, difficulty string, n int) ([]trivia.Question, error) { + if n <= 0 || n > Batch { + n = Batch + } + q := url.Values{ + "amount": {fmt.Sprint(n)}, + "difficulty": {difficulty}, + "type": {"multiple"}, + } + raw := endpoint + "?" + q.Encode() + if err := safehttp.ValidateURL(raw); err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "pete-games/1.0 (+https://games.parodia.dev)") + + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("opentdb: http %d", resp.StatusCode) + } + + body, err := io.ReadAll(safehttp.LimitedBody(resp.Body, maxBody)) + if err != nil { + return nil, err + } + + var out apiResponse + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("opentdb: %w", err) + } + if out.ResponseCode != 0 { + return nil, responseErr(out.ResponseCode) + } + + qs := make([]trivia.Question, 0, len(out.Results)) + for _, r := range out.Results { + // The API hands back HTML entities ("Who wrote "Dune"?"), which + // would otherwise be drawn literally onto a button. + text := clean(r.Question) + correct := clean(r.Correct) + if text == "" || correct == "" || len(r.Incorrect) != 3 { + continue // a malformed question is one we simply don't take + } + + // Correct: 0 here is a convention, not a tell. The engine reshuffles every + // question against the game's own seed as it builds the ladder, so where + // the right answer sits in the bank never reaches a player. + answers := make([]string, 0, 4) + answers = append(answers, correct) + for _, w := range r.Incorrect { + answers = append(answers, clean(w)) + } + qs = append(qs, trivia.Question{ + Category: clean(r.Category), + Text: text, + Answers: answers, + Correct: 0, + }) + } + return qs, nil +} + +// clean turns an API string into something you can put on a button: entities +// decoded, whitespace tidied. +func clean(s string) string { + return strings.TrimSpace(html.UnescapeString(s)) +} diff --git a/internal/storage/schema.go b/internal/storage/schema.go index a890436..377fa24 100644 --- a/internal/storage/schema.go +++ b/internal/storage/schema.go @@ -242,6 +242,31 @@ CREATE TABLE IF NOT EXISTS game_live_hands ( updated_at INTEGER NOT NULL ); +-- The trivia bank: questions pulled from the Open Trivia Database ahead of time, +-- so that asking one is a local read. +-- +-- Prefetched rather than fetched per question because a trivia ladder asks a +-- question every fifteen seconds with money on a clock the player is scored +-- against. A live fetch would put somebody else's latency and rate limit inside +-- that clock. The refill is a slow background drip (internal/opentdb); a round +-- never waits on it. +-- +-- The question text is UNIQUE, which is the whole dedup strategy: OpenTDB hands back +-- overlapping batches and the bank would otherwise fill up with the same forty +-- questions. correct/incorrect are stored as the API gives them; the *shuffle* +-- happens in the engine, per game, against that game's seed — so where the right +-- answer sits in this table tells a player nothing. +CREATE TABLE IF NOT EXISTS trivia_questions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + difficulty TEXT NOT NULL, -- 'easy' | 'medium' | 'hard' + category TEXT NOT NULL, + question TEXT NOT NULL UNIQUE, + correct TEXT NOT NULL, + incorrect TEXT NOT NULL, -- JSON array of the three wrong answers + fetched_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_trivia_difficulty ON trivia_questions(difficulty); + CREATE UNIQUE INDEX IF NOT EXISTS idx_post_log_guid_channel ON post_log(guid, channel); CREATE INDEX IF NOT EXISTS idx_post_log_event_id ON post_log(event_id); CREATE INDEX IF NOT EXISTS idx_post_log_channel_posted ON post_log(channel, posted_at); diff --git a/internal/storage/trivia.go b/internal/storage/trivia.go new file mode 100644 index 0000000..0010b52 --- /dev/null +++ b/internal/storage/trivia.go @@ -0,0 +1,147 @@ +package storage + +import ( + "encoding/json" + "fmt" + "math/rand/v2" + "time" + + "pete/internal/games/trivia" +) + +// The trivia bank. +// +// Questions are pulled from OpenTDB in the background (internal/opentdb) and +// drawn from here when a ladder is built. Nothing in a player's round ever +// touches the network. + +// ErrBankEmpty means the bank hasn't got enough questions of that difficulty to +// build a ladder. It is a real state, not a bug: a fresh database has an empty +// bank until the refill loop has been round a few times. +var ErrBankEmpty = fmt.Errorf("trivia: the bank is short of questions") + +// AddTriviaQuestions files a fetched batch. Questions already in the bank are +// ignored rather than replaced — OpenTDB hands back overlapping batches, and the +// UNIQUE on the text is what stops the bank becoming forty questions deep. +// Returns how many were actually new, which is what the refill loop logs. +func AddTriviaQuestions(difficulty string, qs []trivia.Question) (int, error) { + if len(qs) == 0 { + return 0, nil + } + tx, err := Get().Begin() + if err != nil { + return 0, fmt.Errorf("trivia: begin: %w", err) + } + defer tx.Rollback() //nolint:errcheck // no-op once committed + + stmt, err := tx.Prepare( + `INSERT OR IGNORE INTO trivia_questions + (difficulty, category, question, correct, incorrect, fetched_at) + VALUES (?, ?, ?, ?, ?, ?)`) + if err != nil { + return 0, fmt.Errorf("trivia: prepare: %w", err) + } + defer stmt.Close() + + now := time.Now().Unix() + added := 0 + for _, q := range qs { + if len(q.Answers) < 2 || q.Correct < 0 || q.Correct >= len(q.Answers) { + continue + } + correct := q.Answers[q.Correct] + wrong := make([]string, 0, len(q.Answers)-1) + for i, a := range q.Answers { + if i != q.Correct { + wrong = append(wrong, a) + } + } + blob, err := json.Marshal(wrong) + if err != nil { + continue + } + res, err := stmt.Exec(difficulty, q.Category, q.Text, correct, string(blob), now) + if err != nil { + return added, fmt.Errorf("trivia: insert: %w", err) + } + if n, err := res.RowsAffected(); err == nil { + added += int(n) + } + } + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("trivia: commit: %w", err) + } + return added, nil +} + +// CountTrivia is how many questions of a difficulty the bank holds. The refill +// loop reads it to decide whether to bother. +func CountTrivia(difficulty string) (int, error) { + var n int + if err := Get().QueryRow( + `SELECT COUNT(*) FROM trivia_questions WHERE difficulty = ?`, difficulty, + ).Scan(&n); err != nil { + return 0, fmt.Errorf("trivia: count: %w", err) + } + return n, nil +} + +// DrawTrivia deals a ladder: n distinct questions of one difficulty, chosen with +// the game's own rng. +// +// The choice is made in Go rather than with ORDER BY RANDOM() so that the seed +// in the audit log means something: the same seed against the same bank deals +// the same ladder, which is what lets a disputed game be replayed. It reads the +// ids first and picks from them, so a bank of a few thousand questions costs one +// small scan rather than a sort of the whole table. +func DrawTrivia(difficulty string, n int, rng *rand.Rand) ([]trivia.Question, error) { + if n <= 0 { + return nil, nil + } + rows, err := Get().Query( + `SELECT id FROM trivia_questions WHERE difficulty = ? ORDER BY id`, difficulty) + if err != nil { + return nil, fmt.Errorf("trivia: draw ids: %w", err) + } + var ids []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + rows.Close() + return nil, fmt.Errorf("trivia: scan id: %w", err) + } + ids = append(ids, id) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("trivia: draw ids: %w", err) + } + if len(ids) < n { + return nil, ErrBankEmpty + } + + rng.Shuffle(len(ids), func(i, j int) { ids[i], ids[j] = ids[j], ids[i] }) + pick := ids[:n] + + out := make([]trivia.Question, 0, n) + for _, id := range pick { + var q trivia.Question + var correct, blob string + if err := Get().QueryRow( + `SELECT category, question, correct, incorrect FROM trivia_questions WHERE id = ?`, id, + ).Scan(&q.Category, &q.Text, &correct, &blob); err != nil { + return nil, fmt.Errorf("trivia: load question: %w", err) + } + var wrong []string + if err := json.Unmarshal([]byte(blob), &wrong); err != nil { + return nil, fmt.Errorf("trivia: unreadable answers: %w", err) + } + // Correct: 0 is a convention the engine immediately destroys — New() + // reshuffles every question against the game's seed. Nothing that reaches a + // player depends on the order they come out of the table in. + q.Answers = append([]string{correct}, wrong...) + q.Correct = 0 + out = append(out, q) + } + return out, nil +} diff --git a/internal/web/games_pages.go b/internal/web/games_pages.go index 6846823..b46a5db 100644 --- a/internal/web/games_pages.go +++ b/internal/web/games_pages.go @@ -7,6 +7,7 @@ import ( "pete/internal/games/blackjack" "pete/internal/games/hangman" "pete/internal/games/klondike" + "pete/internal/games/trivia" "pete/internal/storage" ) @@ -27,7 +28,6 @@ type gameTeaser struct { } var comingSoon = []gameTeaser{ - {Name: "Trivia", Emoji: "🧠", Blurb: "Faster answers score higher."}, {Name: "Hold'em", Emoji: "♠️", Blurb: "Six seats, and the house bots know how to play."}, {Name: "UNO", Emoji: "🎴", Blurb: "Normal rules, or no mercy."}, } @@ -75,6 +75,8 @@ type gamesPage struct { MaxWrong int Deals []klondike.Tier // solitaire's three deals FullDeck int + Quizzes []trivia.Tier // trivia's three difficulties + Rungs int // how long the trivia ladder is } // casinoRoutes hangs every table off the mux. @@ -88,6 +90,7 @@ func (s *Server) casinoRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /games/blackjack", s.handleBlackjack) mux.HandleFunc("GET /games/hangman", s.handleHangman) mux.HandleFunc("GET /games/solitaire", s.handleSolitaire) + mux.HandleFunc("GET /games/trivia", s.handleTrivia) mux.HandleFunc("GET /api/games/table", s.handleTable) mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn) @@ -101,6 +104,9 @@ func (s *Server) casinoRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /api/games/solitaire/start", s.handleSolitaireStart) mux.HandleFunc("POST /api/games/solitaire/move", s.handleSolitaireMove) + + mux.HandleFunc("POST /api/games/trivia/start", s.handleTriviaStart) + mux.HandleFunc("POST /api/games/trivia/answer", s.handleTriviaAnswer) } // requirePlayer sends an anonymous visitor to sign in and comes back here after. @@ -131,6 +137,8 @@ func (s *Server) gamesPage(r *http.Request) gamesPage { MaxWrong: hangman.MaxWrong, Deals: klondike.Tiers, FullDeck: klondike.FullDeck, + Quizzes: trivia.Tiers, + Rungs: trivia.Rungs, } } @@ -161,3 +169,10 @@ func (s *Server) handleSolitaire(w http.ResponseWriter, r *http.Request) { } s.render(w, "solitaire", s.gamesPage(r)) } + +func (s *Server) handleTrivia(w http.ResponseWriter, r *http.Request) { + if !s.requirePlayer(w, r) { + return + } + s.render(w, "trivia", s.gamesPage(r)) +} diff --git a/internal/web/games_play.go b/internal/web/games_play.go index 4027ef7..c929ebc 100644 --- a/internal/web/games_play.go +++ b/internal/web/games_play.go @@ -14,6 +14,7 @@ import ( "pete/internal/games/cards" "pete/internal/games/hangman" "pete/internal/games/klondike" + "pete/internal/games/trivia" "pete/internal/storage" ) @@ -189,6 +190,9 @@ type tableView struct { Solitaire *solitaireView `json:"solitaire,omitempty"` SolEvents []solEventView `json:"sol_events,omitempty"` + Trivia *triviaView `json:"trivia,omitempty"` + TrivEvents []trivia.Event `json:"triv_events,omitempty"` + Rake float64 `json:"rake_pct"` } @@ -239,6 +243,16 @@ func (s *Server) table(user string) (tableView, error) { } sv := viewSolitaire(g) v.Solitaire = &sv + case gameTrivia: + var g trivia.State + if err := json.Unmarshal(live.State, &g); err != nil { + return s.dropUnreadable(user, v, err) + } + // The clock does not stop for a reload: Left is measured from the AskedAt + // the server stamped, so a player who refreshes to buy themselves a fresh + // twenty seconds finds the countdown exactly where they left it. + tv := viewTrivia(g, time.Now()) + v.Trivia = &tv default: return s.dropUnreadable(user, v, fmt.Errorf("unknown game %q", live.Game)) } @@ -494,6 +508,7 @@ const ( gameBlackjack = "blackjack" gameHangman = "hangman" gameSolitaire = "solitaire" + gameTrivia = "trivia" ) // finished is what commit needs to know about a game it's writing back: enough diff --git a/internal/web/games_trivia.go b/internal/web/games_trivia.go new file mode 100644 index 0000000..e2ec148 --- /dev/null +++ b/internal/web/games_trivia.go @@ -0,0 +1,224 @@ +package web + +import ( + "encoding/json" + "errors" + "log/slog" + "math/rand/v2" + "net/http" + "time" + + "pete/internal/games/blackjack" + "pete/internal/games/trivia" + "pete/internal/storage" +) + +// Trivia, played for chips. +// +// The same shape as the other tables: the browser sends intents, the server +// holds the state, and the payload carries only what the player is entitled to +// see. Here that means the four answers *without* which of them is right. The +// right one is an index in the engine state, which is in game_live_hands, on +// this side of the wire — and it only ever crosses in the event that reveals it, +// once the question has been decided and it can't be used to answer. +// +// The clock is the other half. The countdown in the browser is decoration: the +// only clock that scores anything is time.Now() here, measured against the +// AskedAt the server stamped when it served the question. A player who stops +// their own countdown, or reloads to restart it, changes nothing. + +// triviaView is a game as its player may see it. +type triviaView struct { + Tier trivia.Tier `json:"tier"` + Rung int `json:"rung"` // how many they've answered + Rungs int `json:"rungs"` // how many there are + + Category string `json:"category,omitempty"` + Question string `json:"question,omitempty"` + Answers []string `json:"answers,omitempty"` // and *not* which one is right + + Limit int `json:"limit"` // the tier's seconds per question + Left float64 `json:"left"` // seconds this question has left, by the server's clock + + Multiple float64 `json:"multiple"` + Bet int64 `json:"bet"` + Stands int64 `json:"stands"` // what taking the money right now actually pays + CanWalk bool `json:"can_walk"` // false on the first question: see the engine + + Phase string `json:"phase"` + Outcome string `json:"outcome,omitempty"` + Payout int64 `json:"payout,omitempty"` + Rake int64 `json:"rake,omitempty"` + Net int64 `json:"net"` +} + +func viewTrivia(g trivia.State, now time.Time) triviaView { + v := triviaView{ + Tier: g.Tier, + Rung: g.Rung, + Rungs: trivia.Rungs, + Limit: g.Tier.Limit, + // What the player would actually collect, rake already out of it — quoting + // the pre-rake figure would have the felt advertising a payout the house + // doesn't hand over. + Stands: g.Pays(), + Multiple: g.Multiple, + Bet: g.Bet, + CanWalk: g.Rung > 0, + Phase: string(g.Phase), + Outcome: string(g.Outcome), + Payout: g.Payout, + Rake: g.Rake, + Net: g.Net(), + } + // A finished game has no live question, and must not ship the next one — the + // ladder still has rungs on it that a later game might deal. + if g.Phase == trivia.PhasePlaying { + q := g.Live() + v.Category = q.Category + v.Question = q.Text + v.Answers = q.Answers + v.Left = g.Left(now).Seconds() + } + return v +} + +// handleTriviaStart takes the bet and builds a ladder. Same order as every other +// table: the chips are staked first, in the same statement that checks they +// exist, so two starts fired at once cannot bet the same chip. +func (s *Server) handleTriviaStart(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 := trivia.TierBySlug(req.Tier) + if err != nil { + writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a difficulty"}) + return + } + + seed1, seed2 := newSeeds() + rng := rand.New(rand.NewPCG(seed1, seed2)) + + // Draw the ladder *before* taking the money. A bank too thin to deal from is + // the one failure here that isn't the player's fault, and they should not have + // to be refunded for it. + qs, err := storage.DrawTrivia(tier.Difficulty, trivia.Rungs, rng) + if errors.Is(err, storage.ErrBankEmpty) { + writeJSONStatus(w, http.StatusServiceUnavailable, map[string]string{ + "error": "the question bank is still filling up — give it a minute", + }) + return + } + if err != nil { + slog.Error("games: trivia draw", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + 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: trivia stake", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + now := time.Now() + g, evs, err := trivia.New(req.Bet, tier, blackjack.DefaultRules().RakePct, qs, now, rng) + if err != nil { + // The game never happened, so the stake never should have left. + _ = storage.Award(user, req.Bet) + slog.Error("games: trivia start", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + s.persistTrivia(w, user, g, evs, seed1, seed2, true, now) +} + +// handleTriviaAnswer plays one move: pick an answer, or take the money. +func (s *Server) handleTriviaAnswer(w http.ResponseWriter, r *http.Request) { + user, ok := s.player(w, r) + if !ok { + return + } + var move trivia.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: trivia load", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if live.Game != gameTrivia { + writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"}) + return + } + var g trivia.State + if err := json.Unmarshal(live.State, &g); err != nil { + slog.Error("games: unreadable trivia game", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + // The server's clock, and the only one that counts. Read once, so the answer + // and the view that reports it agree about what time it is. + now := time.Now() + + next, evs, err := trivia.ApplyMove(g, move, now) + if err != nil { + msg := "that move isn't legal here" + if errors.Is(err, trivia.ErrNothingBanked) { + msg = "answer one before you walk" + } + writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg}) + return + } + s.persistTrivia(w, user, next, evs, live.Seed1, live.Seed2, false, now) +} + +// persistTrivia writes the game back and answers the browser. +func (s *Server) persistTrivia(w http.ResponseWriter, user string, g trivia.State, evs []trivia.Event, seed1, seed2 uint64, fresh bool, now time.Time) { + blob, err := json.Marshal(g) + if err != nil { + slog.Error("games: marshal trivia", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + done := g.Phase == trivia.PhaseDone + v, ok := s.commit(w, user, finished{ + Game: gameTrivia, 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 { + tv := viewTrivia(g, now) + v.Trivia = &tv + } + v.TrivEvents = evs + writeJSON(w, v) +} diff --git a/internal/web/server.go b/internal/web/server.go index 04d3b5b..7145c26 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -95,7 +95,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo pages []string }{ {"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}}, - {"games_layout", []string{"_chipbar"}, []string{"games", "blackjack", "hangman", "solitaire"}}, + {"games_layout", []string{"_chipbar"}, []string{"games", "blackjack", "hangman", "solitaire", "trivia"}}, } tpls := make(map[string]*template.Template) for _, set := range sets { diff --git a/internal/web/static/css/input.css b/internal/web/static/css/input.css index aafbefd..4fa0d8b 100644 --- a/internal/web/static/css/input.css +++ b/internal/web/static/css/input.css @@ -1081,6 +1081,122 @@ html[data-phase="night"] { border-color: var(--accent); } + /* ---- trivia -------------------------------------------------------------- + The clock is the game, so the clock is the biggest thing on the felt: a bar + that drains across the top of the question, going hot as it runs out. It is + *decoration* — the server timed the answer the moment it arrived — but it + has to be honest decoration, so it's driven from the seconds the server said + were left rather than from when the browser happened to paint. */ + + .pete-clock { + position: relative; + height: 0.6rem; + border-radius: 999px; + background: rgba(0, 0, 0, 0.3); + overflow: hidden; + box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.08); + } + .pete-clock-fill { + height: 100%; + width: 100%; + border-radius: 999px; + background: var(--accent); + transform-origin: left center; + /* Driven by a transform the browser can run on its own: a width animated on + every frame from JS is a layout on every frame. */ + transform: scaleX(1); + } + /* The last few seconds. The bar stops being a progress meter and starts being + a warning. */ + .pete-clock[data-hot="1"] .pete-clock-fill { background: #cc3d4a; } + .pete-clock[data-hot="1"] { animation: pete-clock-pulse 0.7s ease-in-out infinite; } + @keyframes pete-clock-pulse { + 0%, 100% { box-shadow: inset 0 0 0 2px rgba(204, 61, 74, 0.35); } + 50% { box-shadow: inset 0 0 0 2px rgba(204, 61, 74, 0.95); } + } + + /* The four answers. Big targets, because the clock is already the hard part — + a question you lose to a mis-tap is a question about pointing. */ + .pete-answer { + position: relative; + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + border-radius: 1rem; + padding: 0.85rem 1rem; + text-align: left; + font-weight: 600; + color: #fff; + background: rgba(0, 0, 0, 0.26); + box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.1); + transition: background 0.15s ease, transform 0.1s ease, box-shadow 0.2s ease; + } + .pete-answer:hover:not(:disabled) { + background: rgba(0, 0, 0, 0.36); + box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.28); + } + .pete-answer:active:not(:disabled) { transform: translateY(1px); } + .pete-answer:disabled { cursor: default; } + + /* The letter down the side, so an answer can be picked with the keyboard and + the key you press is printed on the thing you're pressing it for. */ + .pete-answer-key { + display: grid; + place-items: center; + height: 1.75rem; + width: 1.75rem; + flex: none; + border-radius: 0.6rem; + background: rgba(255, 255, 255, 0.12); + font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif; + font-size: 0.8rem; + font-weight: 700; + color: rgba(255, 255, 255, 0.7); + } + + /* How it went. Only ever set once the server has decided — the browser has no + idea which of these is right until it's told. */ + .pete-answer[data-state="right"] { + background: rgba(46, 160, 103, 0.9); + box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.5); + } + .pete-answer[data-state="wrong"] { + background: rgba(204, 61, 74, 0.9); + box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.35); + animation: pete-answer-no 0.45s ease; + } + /* The one they *should* have picked, shown alongside the one they did. */ + .pete-answer[data-state="missed"] { + box-shadow: inset 0 0 0 2px rgba(46, 160, 103, 0.95); + } + .pete-answer[data-state="dim"] { opacity: 0.4; } + @keyframes pete-answer-no { + 0%, 100% { transform: translateX(0); } + 20% { transform: translateX(-6px); } + 45% { transform: translateX(5px); } + 70% { transform: translateX(-3px); } + } + + /* The ladder: one pip per rung, filling as they're climbed. It is the only + picture of how far there is left to fall. */ + .pete-ladder { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + } + .pete-rung { + height: 0.5rem; + width: 1.1rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.14); + transition: background 0.3s ease, transform 0.3s ease; + } + .pete-rung[data-on="1"] { + background: var(--accent); + transform: scaleY(1.35); + } + /* ---- solitaire ----------------------------------------------------------- Seven columns, four foundations, a stock and a waste, all of which have to fit across the felt on a phone as well as a desk. So the card size is one @@ -1299,6 +1415,11 @@ html[data-phase="night"] { .pete-nope, .pete-home-flash { animation: none; } .pete-card[data-held="1"] { transition: none; } + /* The clock still drains — it is information, not decoration — but it stops + pulsing at you, and a wrong answer stops shaking. */ + .pete-clock[data-hot="1"], + .pete-answer[data-state="wrong"] { animation: none; } + .pete-rung { transition: none; } } } diff --git a/internal/web/static/css/output.css b/internal/web/static/css/output.css index 1c313e0..cf4677c 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}.pete-spark{position:absolute;height:.75rem;width:.45rem;border-radius:2px;box-shadow:0 1px 3px rgba(0,0,0,.35);will-change:transform,opacity}.pete-dealer-think{animation:pete-think .9s ease-in-out infinite}@keyframes pete-think{0%,to{opacity:.6}50%{opacity:1;transform:translateY(-1px)}}.pete-gallows{overflow:visible;fill:none;stroke-linecap:round;stroke-linejoin:round}.pete-gallows-frame path{stroke:rgba(0,0,0,.35);stroke-width:6}.pete-gallows-frame path:last-child{stroke:rgba(0,0,0,.3);stroke-width:3.5}.pete-gallows-body>*{stroke:#fff;stroke-width:5;opacity:0;filter:drop-shadow(0 2px 0 rgba(0,0,0,.25))}.pete-gallows-body>[data-on="1"]{opacity:1}.pete-part-draw{stroke-dasharray:260;animation:pete-part .45s cubic-bezier(.22,1,.36,1) backwards}@keyframes pete-part{0%{stroke-dashoffset:260}to{stroke-dashoffset:0}}.pete-shake{animation:pete-shake .42s cubic-bezier(.36,.07,.19,.97)}@keyframes pete-shake{10%,90%{transform:translateX(-1.5px)}20%,80%{transform:translateX(3px)}30%,50%,70%{transform:translateX(-5px)}40%,60%{transform:translateX(5px)}}.pete-board{display:flex;flex-wrap:wrap;gap:.35rem 1.1rem;min-height:5rem;align-items:center}.pete-word{display:flex;gap:.3rem}.pete-tile{display:grid;place-items:center;height:2.9rem;width:2.2rem;border-radius:.5rem;font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:1.4rem;font-weight:700;color:#fff;background:rgba(0,0,0,.22);border-bottom:3px solid rgba(0,0,0,.28);text-transform:uppercase}.pete-tile[data-up="1"]{background:hsla(0,0%,100%,.95);color:#2b2118;border-bottom-color:rgba(0,0,0,.35)}.pete-tile[data-punct="1"]{background:none;border:0;width:auto;min-width:.7rem;color:hsla(0,0%,100%,.55)}.pete-tile-hit{animation:pete-tile-hit .34s cubic-bezier(.34,1.56,.64,1)}@keyframes pete-tile-hit{0%{transform:rotateX(90deg) scale(1.1)}to{transform:rotateX(0) scale(1)}}.pete-missed{display:grid;place-items:center;height:1.5rem;min-width:1.5rem;padding:0 .3rem;border-radius:.375rem;background:rgba(0,0,0,.25);font-size:.75rem;font-weight:700;color:hsla(0,0%,100%,.5);text-decoration:line-through;text-transform:uppercase}.pete-meter{display:flex;align-items:baseline;gap:.5rem;border-radius:999px;background:rgba(0,0,0,.28);padding:.4rem .9rem;box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.1);transition:box-shadow .3s ease}.pete-meter-label{font-size:.65rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:hsla(0,0%,100%,.45)}.pete-meter-value{font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:1.5rem;font-weight:700;font-variant-numeric:tabular-nums;color:var(--accent)}.pete-meter[data-cold="1"] .pete-meter-value{color:hsla(0,0%,100%,.55)}.pete-meter[data-hit="1"]{box-shadow:inset 0 0 0 2px rgba(204,61,74,.9);animation:pete-meter-hit .4s ease}@keyframes pete-meter-hit{0%{transform:scale(1)}35%{transform:scale(.94)}to{transform:scale(1)}}.pete-keys{display:grid;gap:.35rem}.pete-key-row{display:flex;justify-content:center;gap:.35rem}.pete-key-row[data-digits="1"]{margin-top:.25rem;opacity:.75}.pete-key-row[data-digits="1"] .pete-key{height:2rem;min-width:1.8rem;font-size:.8rem}.pete-key{height:2.75rem;min-width:2.2rem;flex:0 1 2.4rem;border-radius:.6rem;background:color-mix(in srgb,var(--ink) 6%,transparent);border:2px solid color-mix(in srgb,var(--ink) 10%,transparent);font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-weight:700;color:var(--ink);transition:transform .08s ease,background .15s ease,opacity .15s ease}.pete-key:hover:not(:disabled){background:color-mix(in srgb,var(--ink) 12%,transparent)}.pete-key:active:not(:disabled){transform:translateY(1px) scale(.96)}.pete-key:disabled{cursor:default}.pete-key[data-state=hit]{background:#4caf7d;border-color:#3d9367;color:#fff;opacity:1}.pete-key[data-state=miss]{background:color-mix(in srgb,var(--ink) 4%,transparent);color:color-mix(in srgb,var(--ink) 30%,transparent);text-decoration:line-through}.pete-key:disabled[data-state=""]{opacity:.4}.pete-tier{background:color-mix(in srgb,var(--ink) 4%,transparent);border-color:color-mix(in srgb,var(--ink) 10%,transparent)}.pete-tier:hover{background:color-mix(in srgb,var(--ink) 8%,transparent)}.pete-tier[data-on="1"]{background:color-mix(in srgb,var(--accent) 18%,transparent);border-color:var(--accent)}.pete-solitaire{--card-w:clamp(2.5rem,10.6vw,5.2rem);--card-h:calc(var(--card-w)*1.4);--fan-up:calc(var(--card-h)*0.29);--fan-down:calc(var(--card-h)*0.14)}.pete-slot{position:relative;display:grid;place-items:center;height:var(--card-h);width:var(--card-w);flex:none;border-radius:.55rem;border:2px dashed hsla(0,0%,100%,.25);background:rgba(0,0,0,.12);box-shadow:inset 0 2px 8px rgba(0,0,0,.18);transition:border-color .18s ease,background .18s ease,transform .12s ease}.pete-slot-glyph{font-size:calc(var(--card-w)*.42);line-height:1;color:hsla(0,0%,100%,.3)}.pete-slot-glyph[data-red="1"]{color:hsla(0,100%,83%,.35)}.pete-slot>.pete-card{position:absolute;inset:0;height:100%;width:100%}.pete-stock{border-style:solid;border-color:rgba(0,0,0,.15);background:repeating-linear-gradient(45deg,hsla(0,0%,100%,.1) 0 6px,transparent 6px 12px),linear-gradient(150deg,#b4553f,#8d3f2f);cursor:pointer}.pete-stock:hover{filter:brightness(1.08)}.pete-stock:active{transform:translateY(1px) scale(.98)}.pete-stock[data-empty="1"]{background:rgba(0,0,0,.12);border-style:dashed;border-color:hsla(0,0%,100%,.25)}.pete-stock[data-dead="1"]{cursor:default;opacity:.4}.pete-stock[data-dead="1"]:hover{filter:none}.pete-stock[data-dead="1"]:active{transform:none}.pete-slot-count{position:absolute;bottom:-.5rem;left:50%;transform:translateX(-50%);border-radius:999px;background:rgba(0,0,0,.5);padding:.05rem .5rem;font-size:.7rem;font-weight:700;font-variant-numeric:tabular-nums;color:#fff}.pete-slot-recycle{color:hsla(0,0%,100%,.45)}.pete-slot-recycle svg{height:1.4rem;width:1.4rem}.pete-waste{position:relative;display:flex;height:var(--card-h);min-width:var(--card-w)}.pete-waste .pete-card+.pete-card{margin-left:calc(var(--card-w)*-.72)}.pete-waste .pete-card{position:relative}.pete-tableau{display:grid;grid-template-columns:repeat(7,var(--card-w));justify-content:space-between;gap:.5rem;align-items:start;min-height:calc(var(--card-h)*2.6)}.pete-col{display:flex;flex-direction:column;align-items:center;min-height:var(--card-h)}.pete-col .pete-card{position:relative}.pete-col .pete-card[data-face=down]+.pete-card{margin-top:calc(var(--fan-down) - var(--card-h))}.pete-col .pete-card[data-face=up]+.pete-card{margin-top:calc(var(--fan-up) - var(--card-h))}.pete-card[data-live="1"]{cursor:pointer}.pete-card[data-live="1"]:hover .pete-card-front{filter:brightness(1.06)}.pete-card[data-held="1"]{z-index:5;transform:translateY(-.55rem);transition:transform .14s cubic-bezier(.34,1.56,.64,1)}.pete-card[data-held="1"] .pete-card-front{box-shadow:0 3px 0 rgba(0,0,0,.18),0 10px 22px rgba(0,0,0,.32),0 0 0 3px rgba(242,181,61,.9)}.pete-col[data-drop="1"] .pete-card:last-child .pete-card-front,.pete-col[data-drop="1"] .pete-slot,.pete-slot[data-drop="1"]{border-color:rgba(242,181,61,.9);box-shadow:0 0 0 3px rgba(242,181,61,.45)}.pete-col[data-drop="1"] .pete-slot,.pete-slot[data-drop="1"]{background:rgba(242,181,61,.12)}.pete-nope{animation:pete-shake .4s cubic-bezier(.36,.07,.19,.97)}.pete-home-flash{animation:pete-home .5s ease-out}@keyframes pete-home{0%{box-shadow:0 0 0 0 rgba(242,181,61,.9)}to{box-shadow:0 0 0 1.4rem rgba(242,181,61,0)}}.pete-rail{display:flex;flex-direction:row;flex-wrap:wrap;align-items:center;justify-content:center;gap:1rem 1.5rem}@media (min-width:1024px){.pete-rail{flex-direction:column;justify-content:flex-start;width:9.5rem;gap:1.5rem}}.pete-rack[data-at=rail]{position:static;justify-content:center}@media (prefers-reduced-motion:reduce){.pete-card,.pete-card:after,.pete-dealer-think,.pete-stack .pete-disc{animation:none}.pete-card-inner{transition:none}.pete-hand[data-won="1"] .pete-card,.pete-home-flash,.pete-meter[data-hit="1"],.pete-nope,.pete-part-draw,.pete-shake,.pete-tile-hit{animation:none}.pete-card[data-held="1"]{transition:none}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-right-6{right:-1.5rem}.-top-6{top:-1.5rem}.-z-10{z-index:-10}.-z-\[5\]{z-index:-5}.z-50{z-index:50}.order-none{order:0}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-auto{margin-left:auto;margin-right:auto}.-mt-1{margin-top:-.25rem}.mb-1{margin-bottom:.25rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-8{margin-bottom:2rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mt-0{margin-top:0}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-8{margin-top:2rem}.line-clamp-2{-webkit-line-clamp:2}.line-clamp-2,.line-clamp-3{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical}.line-clamp-3{-webkit-line-clamp:3}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.aspect-\[16\/10\]{aspect-ratio:16/10}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-20{height:5rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-52{height:13rem}.h-6{height:1.5rem}.h-9{height:2.25rem}.h-full{height:100%}.max-h-\[55vh\]{max-height:55vh}.max-h-\[60vh\]{max-height:60vh}.min-h-\[1\.75rem\]{min-height:1.75rem}.min-h-\[2\.75rem\]{min-height:2.75rem}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-2{width:.5rem}.w-2\.5{width:.625rem}.w-20{width:5rem}.w-24{width:6rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-9{width:2.25rem}.w-auto{width:auto}.w-full{width:100%}.min-w-0{min-width:0}.min-w-\[28rem\]{min-width:28rem}.min-w-\[52rem\]{min-width:52rem}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-\[7rem\]{max-width:7rem}.max-w-full{max-width:100%}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink,.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.basis-full{flex-basis:100%}.-translate-y-0{--tw-translate-y:-0px}.-translate-y-0,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-10{-moz-column-gap:2.5rem;column-gap:2.5rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-4{row-gap:1rem}.gap-y-8{row-gap:2rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.375rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-transparent{border-color:transparent}.bg-\[color\:var\(--accent\)\]{background-color:var(--accent)}.bg-\[color\:var\(--bg\)\]{background-color:var(--bg)}.bg-\[color\:var\(--bg-grad\)\]{background-color:var(--bg-grad)}.bg-\[color\:var\(--card\)\]{background-color:var(--card)}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-black\/25{background-color:rgba(0,0,0,.25)}.bg-emerald-500{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity,1))}.bg-emerald-500\/15{background-color:rgba(16,185,129,.15)}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/15{background-color:rgba(239,68,68,.15)}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/95{background-color:hsla(0,0%,100%,.95)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-0\.5{padding:.125rem}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0{padding-top:0;padding-bottom:0}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-10{padding-bottom:2.5rem}.pb-20{padding-bottom:5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pr-24{padding-right:6rem}.pt-1{padding-top:.25rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-8{padding-top:2rem}.pt-\[10vh\]{padding-top:10vh}.pt-\[12vh\]{padding-top:12vh}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.align-\[-4px\]{vertical-align:-4px}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12rem\]{font-size:12rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-\[0\.18em\]{letter-spacing:.18em}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-\[\#1c1305\]{--tw-text-opacity:1;color:rgb(28 19 5/var(--tw-text-opacity,1))}.text-\[\#20180c\]{--tw-text-opacity:1;color:rgb(32 24 12/var(--tw-text-opacity,1))}.text-\[\#2b2118\]{--tw-text-opacity:1;color:rgb(43 33 24/var(--tw-text-opacity,1))}.text-\[color\:var\(--accent\)\]{color:var(--accent)}.text-\[color\:var\(--ink\)\]{color:var(--ink)}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-emerald-700{--tw-text-opacity:1;color:rgb(4 120 87/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-white\/40{color:hsla(0,0%,100%,.4)}.text-white\/50{color:hsla(0,0%,100%,.5)}.text-white\/55{color:hsla(0,0%,100%,.55)}.text-white\/60{color:hsla(0,0%,100%,.6)}.text-white\/85{color:hsla(0,0%,100%,.85)}.text-white\/90{color:hsla(0,0%,100%,.9)}.underline{text-decoration-line:underline}.decoration-4{text-decoration-thickness:4px}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.accent-\[color\:var\(--accent\)\]{accent-color:var(--accent)}.opacity-20{opacity:.2}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.blur{--tw-blur:blur(8px)}.blur,.drop-shadow{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.drop-shadow{--tw-drop-shadow:drop-shadow(0 1px 2px rgba(0,0,0,.1)) drop-shadow(0 1px 1px rgba(0,0,0,.06))}.grayscale{--tw-grayscale:grayscale(100%)}.grayscale,.sepia{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.sepia{--tw-sepia:sepia(100%)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.backdrop-blur-sm,.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-1000{transition-duration:1s}.duration-500{transition-duration:.5s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}.font-display{font-family:Fredoka,Nunito,system-ui,sans-serif;letter-spacing:-.01em}.shadow-pete{box-shadow:0 4px 0 rgba(60,40,20,.1),0 8px 24px rgba(60,40,20,.08)}.shadow-pete-lg{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.bg-theme-gaming{background-color:#4caf7d}.bg-theme-tech{background-color:#5aa9e6}.bg-theme-politics{background-color:#e07a5f}.bg-theme-eu{background-color:#039}.bg-theme-music{background-color:#b079d6}.bg-theme-anime{background-color:#ec5e8a}.bg-theme-foss{background-color:#d97706}.bg-theme-kids{background-color:#14b8a6}.bg-theme-finance{background-color:#10b981}.bg-theme-lego{background-color:#d01012}.bg-theme-adventure{background-color:#6d4bd8}.text-theme-gaming{color:#2d8a5a}.text-theme-tech{color:#2f7fb8}.text-theme-politics{color:#b8523a}.text-theme-eu{color:#039}.text-theme-music{color:#8a4fb8}.text-theme-anime{color:#c33a6a}.text-theme-foss{color:#b8530a}.text-theme-kids{color:#0f766e}.text-theme-finance{color:#059669}.text-theme-lego{color:#b00d0e}.text-theme-adventure{color:#5836b8}.decoration-theme-gaming{text-decoration-color:#4caf7d}.decoration-theme-tech{text-decoration-color:#5aa9e6}.decoration-theme-politics{text-decoration-color:#e07a5f}.decoration-theme-eu{text-decoration-color:#039}.decoration-theme-music{text-decoration-color:#b079d6}.decoration-theme-anime{text-decoration-color:#ec5e8a}.decoration-theme-foss{text-decoration-color:#d97706}.decoration-theme-kids{text-decoration-color:#14b8a6}.decoration-theme-finance{text-decoration-color:#10b981}.decoration-theme-lego{text-decoration-color:#d01012}.decoration-theme-adventure{text-decoration-color:#6d4bd8}.border-theme-gaming{border-color:#4caf7d}.border-theme-tech{border-color:#5aa9e6}.border-theme-politics{border-color:#e07a5f}.border-theme-eu{border-color:#039}.border-theme-music{border-color:#b079d6}.border-theme-anime{border-color:#ec5e8a}.border-theme-foss{border-color:#d97706}.border-theme-kids{border-color:#14b8a6}.border-theme-finance{border-color:#10b981}.border-theme-lego{border-color:#d01012}.border-theme-adventure{border-color:#6d4bd8}.glow-theme-gaming{box-shadow:0 0 0 2px rgba(76,175,125,.35),0 0 24px 4px rgba(76,175,125,.45)}.glow-theme-gaming,.glow-theme-tech{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-tech{box-shadow:0 0 0 2px rgba(90,169,230,.35),0 0 24px 4px rgba(90,169,230,.45)}.glow-theme-politics{box-shadow:0 0 0 2px rgba(224,122,95,.35),0 0 24px 4px rgba(224,122,95,.45)}.glow-theme-eu,.glow-theme-politics{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-eu{box-shadow:0 0 0 2px rgba(0,51,153,.35),0 0 24px 4px rgba(0,51,153,.45)}.glow-theme-music{box-shadow:0 0 0 2px rgba(176,121,214,.35),0 0 24px 4px rgba(176,121,214,.45)}.glow-theme-anime,.glow-theme-music{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-anime{box-shadow:0 0 0 2px rgba(236,94,138,.35),0 0 24px 4px rgba(236,94,138,.45)}.glow-theme-foss{box-shadow:0 0 0 2px rgba(217,119,6,.35),0 0 24px 4px rgba(217,119,6,.45)}.glow-theme-foss,.glow-theme-kids{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-kids{box-shadow:0 0 0 2px rgba(20,184,166,.35),0 0 24px 4px rgba(20,184,166,.45)}.glow-theme-finance{box-shadow:0 0 0 2px rgba(16,185,129,.35),0 0 24px 4px rgba(16,185,129,.45)}.glow-theme-finance,.glow-theme-lego{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-lego{box-shadow:0 0 0 2px rgba(208,16,18,.35),0 0 24px 4px rgba(208,16,18,.45)}.glow-theme-adventure{box-shadow:0 0 0 2px rgba(109,75,216,.35),0 0 24px 4px rgba(109,75,216,.45);animation:pete-glow-pulse 2.4s ease-in-out infinite}@keyframes pete-glow-pulse{0%,to{filter:brightness(1)}50%{filter:brightness(1.08)}}.line-clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.paywall-stamp{position:absolute;inset:0;pointer-events:none;display:flex;align-items:center;justify-content:center;z-index:5;background:rgba(255,250,240,.18)}.paywall-stamp:before{content:"PAYWALLED";font-family:var(--font-display,ui-sans-serif),system-ui,sans-serif;font-weight:900;font-size:clamp(1rem,3.2vw,1.75rem);letter-spacing:.12em;color:rgba(180,30,30,.85);border:.28rem double rgba(180,30,30,.85);padding:.3rem .9rem;transform:rotate(-15deg);text-shadow:0 1px 0 hsla(0,0%,100%,.4);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.15);background:rgba(255,240,230,.55);border-radius:.25rem;filter:contrast(1.05)}:root,html[data-phase=day]{--bg:#fff7e4;--bg-grad:#ffeec2;--card:#fff;--ink:#3a2e1f;--accent:#f2a541}html[data-phase=dawn]{--bg:#ffe7d6;--bg-grad:#ffc9c9;--card:#fff4ea;--ink:#4a2e2a;--accent:#ff8a65}html[data-phase=dusk]{--bg:#ffd6a8;--bg-grad:#f7a07e;--card:#fff1de;--ink:#3d2417;--accent:#e6553a}html[data-phase=night]{--bg:#1a1f3a;--bg-grad:#2a3358;--card:#2d365a;--ink:#f1ecd8;--accent:#f9d976}html[data-room=casinopolis]{--bg:#16211c;--card:#23342b;--ink:#f6ecd8;--accent:#f2b53d;--felt-a:#2f7d5b;--felt-b:#24614a;--felt-c:#1c4d3c;--glow:242,181,61}html[data-room=casino-night]{--bg:#140f2e;--card:#241a4d;--ink:#f2ecff;--accent:#ffcc2f;--felt-a:#4a2fa8;--felt-b:#351f80;--felt-c:#241659;--glow:126,86,255}html[data-room] .cs-room{background:radial-gradient(80% 55% at 50% -8%,rgba(var(--glow),.18),transparent 62%),radial-gradient(120% 90% at 50% 120%,rgba(0,0,0,.45),transparent 55%)}html[data-room=casino-night] .cs-room:before{content:"";position:absolute;inset:0 -50% auto -50%;height:.5rem;background:repeating-linear-gradient(90deg,rgba(255,204,47,.55) 0 .5rem,transparent .5rem 2.25rem);filter:blur(1px);animation:cs-bulbs 2.4s linear infinite}@keyframes cs-bulbs{0%{transform:translateX(0)}to{transform:translateX(2.75rem)}}html[data-room] .shadow-pete{box-shadow:0 4px 0 rgba(0,0,0,.3),0 10px 26px rgba(0,0,0,.3)}html[data-room] .shadow-pete-lg{box-shadow:0 6px 0 rgba(0,0,0,.34),0 20px 40px rgba(0,0,0,.38)}html[data-room] .cs-comb svg{filter:drop-shadow(0 3px 0 rgba(0,0,0,.35))}html[data-room] .pete-felt{background:radial-gradient(120% 90% at 50% -10%,hsla(0,0%,100%,.1),transparent 60%),linear-gradient(160deg,var(--felt-a) 0,var(--felt-b) 55%,var(--felt-c) 100%)}@media (prefers-reduced-motion:reduce){html[data-room=casino-night] .cs-room:before{animation:none}}.cs-stack{display:block;width:2.75rem;height:calc(.45rem*var(--stack, 1) + .55rem);border-radius:999px;background:radial-gradient(circle at 50% 30%,hsla(0,0%,100%,.3),transparent 60%),var(--chip,#e07a5f);border:2px solid rgba(0,0,0,.25);box-shadow:0 3px 0 rgba(0,0,0,.3),inset 0 -.4rem 0 rgba(0,0,0,.16),inset 0 .35rem 0 hsla(0,0%,100%,.14)}.cs-stack[data-chip="5"]{--chip:#5aa9e6}.cs-stack[data-chip="25"]{--chip:#4caf7d}.cs-stack[data-chip="100"]{--chip:#2b2118}.cs-stack[data-chip="500"]{--chip:#b079d6}.last\:border-0:last-child{border-width:0}.empty\:hidden:empty{display:none}.hover\:-translate-y-0\.5:hover{--tw-translate-y:-0.125rem}.hover\:-translate-y-0\.5:hover,.hover\:-translate-y-1:hover{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:-translate-y-1:hover{--tw-translate-y:-0.25rem}.hover\:text-\[color\:var\(--ink\)\]:hover{color:var(--ink)}.hover\:brightness-105:hover{--tw-brightness:brightness(1.05);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.hover\:shadow-pete-lg:hover{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.focus\:border-\[color\:var\(--accent\)\]:focus{border-color:var(--accent)}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.active\:translate-y-px:active{--tw-translate-y:1px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:-rotate-6{--tw-rotate:-6deg}.group:hover .group-hover\:-rotate-6,.group:hover .group-hover\:scale-105{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:scale-105{--tw-scale-x:1.05;--tw-scale-y:1.05}.group:hover .group-hover\:underline{text-decoration-line:underline}@media (min-width:640px){.sm\:ml-auto{margin-left:auto}.sm\:block{display:block}.sm\:inline{display:inline}.sm\:flex{display:flex}.sm\:inline-flex{display:inline-flex}.sm\:h-56{height:14rem}.sm\:basis-auto{flex-basis:auto}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:gap-2{gap:.5rem}.sm\:gap-3{gap:.75rem}.sm\:gap-4{gap:1rem}.sm\:space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.sm\:p-10{padding:2.5rem}.sm\:p-6{padding:1.5rem}.sm\:p-8{padding:2rem}.sm\:pr-28{padding-right:7rem}.sm\:pt-10{padding-top:2.5rem}.sm\:pt-12{padding-top:3rem}.sm\:text-2xl{font-size:1.5rem;line-height:2rem}.sm\:text-3xl{font-size:1.875rem;line-height:2.25rem}.sm\:text-4xl{font-size:2.25rem;line-height:2.5rem}.sm\:text-5xl{font-size:3rem;line-height:1}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-\[auto\2c 1fr\]{grid-template-columns:auto 1fr}.lg\:grid-cols-\[minmax\(0\2c 1fr\)\2c auto\]{grid-template-columns:minmax(0,1fr) auto}.lg\:items-start{align-items:flex-start}.lg\:gap-8{gap:2rem}.lg\:p-8{padding:2rem}}@media (prefers-color-scheme:dark){.dark\:text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dark\:text-emerald-300{--tw-text-opacity:1;color:rgb(110 231 183/var(--tw-text-opacity,1))}.dark\:text-red-300{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.dark\:text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}body{font-family:Nunito,system-ui,sans-serif}html{scroll-behavior:smooth}body,html{overflow-x:hidden}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.pete-channel-nav{scrollbar-width:none;-ms-overflow-style:none}.pete-channel-nav::-webkit-scrollbar{display:none}.pete-reader-backdrop{position:absolute;inset:0;background:rgba(20,14,6,.55);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}html[data-phase=night] .pete-reader-backdrop{background:rgba(6,8,20,.65)}.pete-reader-shell{position:relative;height:100%;width:100%;max-width:44rem;margin:0 auto;display:flex;flex-direction:column;padding:max(env(safe-area-inset-top),.75rem) 1rem .75rem;gap:.75rem}.pete-reader-bar{display:flex;align-items:center;justify-content:space-between;gap:.75rem;color:#fff}.pete-reader-progress{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:600;font-size:.85rem;letter-spacing:.02em;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-nav{display:flex;align-items:center;gap:.4rem}.pete-reader-btn{display:inline-flex;align-items:center;justify-content:center;min-width:2.25rem;height:2.25rem;padding:0 .7rem;border-radius:9999px;background:hsla(0,0%,100%,.14);color:#fff;font-weight:700;font-size:.95rem;border:2px solid hsla(0,0%,100%,.18);cursor:pointer;transition:background .15s ease,transform .15s ease,opacity .15s ease}.pete-reader-btn:hover{background:hsla(0,0%,100%,.28);transform:translateY(-1px)}.pete-reader-btn:disabled{opacity:.35;cursor:default;transform:none}.pete-reader-btn-open{background:var(--accent);border-color:transparent;color:#1c1305;text-decoration:none}.pete-reader-btn-open:hover{background:var(--accent);filter:brightness(1.08)}.pete-reader-btn[aria-expanded=true],.pete-reader-btn[aria-pressed=true]{background:var(--accent);border-color:transparent;color:#1c1305}.pete-reader-type{position:relative;display:inline-flex}.pete-reader-typemenu[hidden]{display:none}.pete-reader-typemenu{position:absolute;top:calc(100% + .5rem);right:0;z-index:10;width:15rem;padding:.75rem;border-radius:1rem;background:var(--card);color:var(--ink);border:2px solid rgba(0,0,0,.08);box-shadow:0 10px 30px rgba(20,14,6,.28);display:flex;flex-direction:column;gap:.6rem}html[data-phase=night] .pete-reader-typemenu{border-color:hsla(0,0%,100%,.12)}.pete-reader-typerow{display:flex;align-items:center;justify-content:space-between;gap:.6rem}.pete-reader-typelabel{font-size:.8rem;font-weight:700;opacity:.7}.pete-reader-typebtns{display:inline-flex;gap:.25rem}.pete-reader-typebtns button{min-width:2rem;height:2rem;padding:0 .55rem;border-radius:.6rem;background:rgba(20,14,6,.06);color:var(--ink);font-weight:700;font-size:.82rem;border:2px solid transparent;cursor:pointer;transition:background .15s ease}html[data-phase=night] .pete-reader-typebtns button{background:hsla(0,0%,100%,.08)}.pete-reader-typebtns button:hover{background:rgba(20,14,6,.12)}.pete-reader-typebtns button.is-active{background:var(--accent);color:#1c1305;border-color:transparent}.pete-reader-voicerow[hidden]{display:none}.pete-reader-voice{font:inherit;font-size:.8rem;font-weight:600;padding:.25rem .4rem;border-radius:.55rem;border:1px solid rgba(20,14,6,.18);background:rgba(20,14,6,.06);color:inherit;max-width:11rem;cursor:pointer}html[data-phase=night] .pete-reader-voice{border-color:hsla(0,0%,100%,.14);background:hsla(0,0%,100%,.08)}.pete-reader-scroll{flex:1 1 auto;overflow-y:auto;-webkit-overflow-scrolling:touch;border-radius:1.75rem}.pete-reader-scroll:focus{outline:none}.pete-reader-article{background:var(--card);color:var(--ink);border-radius:1.75rem;border:2px solid rgba(0,0,0,.06);box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.18);padding:clamp(1.25rem,4vw,2.5rem)}html[data-phase=night] .pete-reader-article{border-color:hsla(0,0%,100%,.08)}.pete-reader-eyebrow{display:flex;align-items:center;flex-wrap:wrap;gap:.5rem;font-size:.75rem;margin-bottom:.85rem}.pete-reader-chip{display:inline-flex;align-items:center;gap:.35rem;border-radius:9999px;padding:.15rem .65rem;color:#fff;font-weight:700;text-transform:uppercase;letter-spacing:.06em}.pete-reader-source{font-weight:700;opacity:.75}.pete-reader-time{opacity:.55}.pete-reader-title{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:700;line-height:1.15;letter-spacing:-.01em;font-size:clamp(1.6rem,4.5vw,2.4rem);margin-bottom:1rem}.pete-reader-hero{width:100%;border-radius:1.1rem;margin-bottom:1.25rem;background:rgba(0,0,0,.05)}.pete-reader-scroll{--reader-fs:1.08rem}.pete-reader-body{font-size:var(--reader-fs);line-height:1.75}.pete-reader-body p{margin-bottom:1.05em}.pete-reader-body p:last-child{margin-bottom:0}.pete-reader-scroll.is-size-s{--reader-fs:0.98rem}.pete-reader-scroll.is-size-m{--reader-fs:1.08rem}.pete-reader-scroll.is-size-l{--reader-fs:1.22rem}.pete-reader-scroll.is-size-xl{--reader-fs:1.4rem}.pete-reader-scroll.is-serif .pete-reader-body{font-family:Georgia,Iowan Old Style,Times New Roman,serif}.pete-reader-scroll.is-sepia .pete-reader-article{background:#f6ecd6;color:#4a3a28;border-color:rgba(74,58,40,.16)}.pete-reader-scroll.is-sepia .pete-reader-note{border-top-color:rgba(74,58,40,.18);opacity:.8}.pete-reader-scroll.is-sepia .pete-reader-hero{background:rgba(74,58,40,.08)}.pete-reader-body p.is-speaking{background:color-mix(in srgb,var(--accent) 32%,transparent);border-radius:.4rem;box-shadow:0 0 0 .35rem color-mix(in srgb,var(--accent) 32%,transparent)}.pete-reader-note{margin-top:1.25rem;padding-top:1rem;border-top:1px solid rgba(0,0,0,.1);font-size:.85rem;opacity:.7}html[data-phase=night] .pete-reader-note{border-color:hsla(0,0%,100%,.12)}.pete-reader-note a{color:var(--accent);font-weight:700}.pete-reader-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:.75rem;padding:clamp(2rem,8vw,4rem) 1rem;color:var(--ink)}.pete-reader-empty-emoji{font-size:2.5rem}.pete-reader-toast{position:absolute;left:50%;bottom:4.5rem;transform:translate(-50%,.5rem);z-index:20;padding:.5rem .9rem;border-radius:9999px;background:rgba(20,14,6,.9);color:#fff;font-weight:700;font-size:.85rem;box-shadow:0 8px 24px rgba(0,0,0,.3);opacity:0;pointer-events:none;transition:opacity .2s ease,transform .2s ease}.pete-reader-toast.is-shown{opacity:1;transform:translate(-50%)}.pete-reader-hint{text-align:center;color:hsla(0,0%,100%,.85);font-size:.75rem;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-hint kbd{font-family:ui-monospace,monospace;background:hsla(0,0%,100%,.16);border-radius:.3rem;padding:.05rem .3rem}@media (max-width:640px){.pete-reader-hint{display:none}.pete-reader-bar{flex-wrap:wrap}.pete-reader-nav{flex-wrap:wrap;justify-content:flex-end;row-gap:.4rem}}.pete-reader-related{width:100%;margin:.85rem auto 0}.pete-reader-related-title{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:700;font-size:.95rem;color:#fff;margin:0 0 .6rem;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-related-grid{display:grid;gap:.6rem}.pete-reader-related-card{display:flex;align-items:center;gap:.75rem;background:var(--card);color:var(--ink);border-radius:1rem;border:2px solid rgba(0,0,0,.06);padding:.6rem;text-decoration:none;transition:transform .15s ease,box-shadow .15s ease}.pete-reader-related-card:hover{transform:translateY(-1px);box-shadow:0 6px 16px rgba(60,40,20,.16)}html[data-phase=night] .pete-reader-related-card{border-color:hsla(0,0%,100%,.08)}.pete-reader-related-thumb{width:4.5rem;height:3.25rem;flex-shrink:0;-o-object-fit:cover;object-fit:cover;border-radius:.6rem;background:rgba(0,0,0,.06)}.pete-reader-related-meta{min-width:0;display:flex;flex-direction:column;gap:.25rem}.pete-reader-related-eyebrow{display:flex;align-items:center;flex-wrap:wrap;gap:.4rem;font-size:.7rem}.pete-reader-related-source{font-weight:700;opacity:.7}.pete-reader-related-headline{font-weight:700;line-height:1.25;font-size:.92rem;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}[data-story-card][data-read="1"]{opacity:.5;transition:opacity .2s ease}[data-story-card][data-read="1"]:hover{opacity:1}[data-story-card][data-read="1"]:after{content:"✓";position:absolute;top:.6rem;right:.6rem;z-index:6;display:grid;place-items:center;height:1.5rem;width:1.5rem;border-radius:9999px;background:rgba(20,14,6,.72);color:#fff;font-size:.8rem;font-weight:700}.pete-felt{background:radial-gradient(120% 90% at 50% -10%,hsla(0,0%,100%,.1),transparent 60%),linear-gradient(160deg,#2f7d5b,#24614a 55%,#1c4d3c)}.pete-shoe{position:absolute;top:1.25rem;right:1.25rem;height:4.2rem;width:3rem;border-radius:.55rem;background:linear-gradient(150deg,#b4553f,#8d3f2f);border:2px solid rgba(0,0,0,.18);box-shadow:0 4px 0 rgba(0,0,0,.18),inset 0 0 0 3px hsla(0,0%,100%,.12)}.pete-shoe:after{content:"";position:absolute;inset:.45rem .4rem auto .4rem;height:.5rem;border-radius:.2rem;background:rgba(0,0,0,.22)}.pete-hand{display:flex;flex-wrap:wrap;gap:.6rem;min-height:8.6rem}.pete-card{perspective:700px;height:var(--card-h,8.4rem);width:var(--card-w,6rem);animation:pete-deal .42s cubic-bezier(.22,1,.36,1) backwards}.pete-card-inner{position:relative;height:100%;width:100%;transform-style:preserve-3d;transition:transform .45s cubic-bezier(.4,0,.2,1)}.pete-card[data-face=down] .pete-card-inner{transform:rotateY(180deg)}.pete-card-back,.pete-card-front{position:absolute;inset:0;display:grid;border-radius:.55rem;backface-visibility:hidden;-webkit-backface-visibility:hidden;box-shadow:0 3px 0 rgba(0,0,0,.18),0 6px 14px rgba(0,0,0,.22)}.pete-card-front{place-items:stretch;background:#fdfaf2;border:2px solid rgba(30,20,10,.12);color:#2b2118;font-family:Fredoka,Nunito,system-ui,sans-serif;line-height:1;overflow:hidden}.pete-card-front[data-red="1"]{color:#cc3d4a}.pete-card-back{transform:rotateY(180deg);background:repeating-linear-gradient(45deg,hsla(0,0%,100%,.1) 0 6px,transparent 6px 12px),linear-gradient(150deg,#b4553f,#8d3f2f);border:2px solid rgba(0,0,0,.15)}.pete-card-svg{width:100%;height:100%;display:block;fill:currentColor}.pete-card-idx{font-size:26px;font-weight:700;text-anchor:middle;fill:currentColor}.pete-card-panel{fill:currentColor;fill-opacity:.06;stroke:currentColor;stroke-opacity:.35;stroke-width:1.5}.pete-card-court{font-size:34px;font-weight:700;text-anchor:middle;fill:currentColor}@keyframes pete-deal{0%{opacity:0;transform:translate(var(--deal-x,14rem),var(--deal-y,-7rem)) scale(.72) rotate(9deg)}62%{opacity:1;transform:translateY(.35rem) scale(1.05) rotate(calc(var(--tilt, 0deg) - 2deg))}to{opacity:1;transform:translate(0) scale(1) rotate(var(--tilt,0deg))}}.pete-card:after{content:"";position:absolute;inset:auto 6% -.35rem 6%;height:.6rem;border-radius:999px;background:rgba(0,0,0,.35);filter:blur(5px);animation:pete-thud .42s cubic-bezier(.22,1,.36,1) backwards}@keyframes pete-thud{0%,55%{opacity:0;transform:scale(.4)}72%{opacity:.9;transform:scale(1.15)}to{opacity:.45;transform:scale(1)}}.pete-hand[data-won="1"] .pete-card{animation:pete-deal .42s cubic-bezier(.22,1,.36,1) backwards,pete-win .6s ease .1s}@keyframes pete-win{0%,to{transform:rotate(var(--tilt,0deg)) translateY(0)}40%{transform:rotate(var(--tilt,0deg)) translateY(-.6rem)}}.pete-hand[data-won="-1"] .pete-card{filter:saturate(.45) brightness(.78);transition:filter .5s ease .2s}.pete-disc{position:relative;border-radius:999px;background:radial-gradient(circle at 50% 30%,hsla(0,0%,100%,.3),transparent 62%),var(--chip,#e07a5f);border:2px solid rgba(0,0,0,.22);box-shadow:0 3px 0 rgba(0,0,0,.28),0 6px 14px rgba(0,0,0,.2)}.pete-disc:before{content:"";position:absolute;inset:11%;border-radius:999px;border:2px dashed hsla(0,0%,100%,.55)}.pete-disc[data-chip="5"]{--chip:#5aa9e6}.pete-disc[data-chip="25"]{--chip:#4caf7d}.pete-disc[data-chip="100"]{--chip:#2b2118}.pete-disc[data-chip="500"]{--chip:#b079d6}.pete-chip{transition:transform .12s ease}.pete-chip>span{position:relative}.pete-chip:active{transform:translateY(1px) scale(.94)}.pete-spot{position:relative;display:grid;place-items:center;height:7rem;width:7rem;flex:none;border-radius:999px;border:2px dashed hsla(0,0%,100%,.35);background:radial-gradient(circle at 50% 40%,hsla(0,0%,100%,.1),transparent 70%);box-shadow:inset 0 0 0 6px rgba(0,0,0,.08);transition:box-shadow .3s ease,border-color .3s ease}.pete-spot[data-live="1"]{border-color:rgba(var(--glow,242,181,61),.85);box-shadow:inset 0 0 0 6px rgba(0,0,0,.08),0 0 22px rgba(var(--glow,242,181,61),.35)}.pete-spot-label{font-size:.65rem;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:hsla(0,0%,100%,.35)}.pete-spot[data-live="1"] .pete-spot-label{opacity:0}.pete-stack{position:absolute;inset:0;pointer-events:none}.pete-stack .pete-disc{position:absolute;left:50%;top:50%;height:2.6rem;width:2.6rem;margin:-1.3rem 0 0 -1.3rem;transform:translateY(calc(-.5rem*var(--i, 0))) rotate(var(--spin,0deg));box-shadow:0 2px 0 rgba(0,0,0,.35),0 4px 8px rgba(0,0,0,.25);animation:pete-chip-land .28s cubic-bezier(.34,1.56,.64,1) backwards}@keyframes pete-chip-land{0%{transform:translateY(calc(-.5rem*var(--i, 0) - 1.1rem)) rotate(var(--spin,0deg)) scale(1.18)}}.pete-spot-total{position:absolute;bottom:-.6rem;left:50%;transform:translateX(-50%);white-space:nowrap;border-radius:999px;background:rgba(0,0,0,.45);padding:.1rem .6rem;font-size:.72rem;font-weight:700;font-variant-numeric:tabular-nums;color:#fff}.pete-fly-layer{position:fixed;inset:0;z-index:60;pointer-events:none;overflow:hidden}.pete-fly{position:absolute;height:2.6rem;width:2.6rem;margin:-1.3rem 0 0 -1.3rem;will-change:transform}.pete-rack{position:absolute;top:1.25rem;right:5.75rem;display:flex;align-items:flex-end;gap:.4rem;padding:.5rem .6rem .45rem;border-radius:.75rem;background:rgba(0,0,0,.2);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.06)}.pete-rack span{display:block;width:1.7rem;border-radius:.35rem;background:linear-gradient(90deg,rgba(0,0,0,.28),transparent 35%,transparent 65%,rgba(0,0,0,.28)),repeating-linear-gradient(180deg,hsla(0,0%,100%,.18) 0 .06rem,var(--chip,#e07a5f) .06rem .3rem,rgba(0,0,0,.35) .3rem .36rem);border:1px solid rgba(0,0,0,.35);height:calc(.36rem*var(--stack, 3))}.pete-rack span[data-chip="5"]{--chip:#5aa9e6}.pete-rack span[data-chip="25"]{--chip:#4caf7d}.pete-rack span[data-chip="100"]{--chip:#2b2118}.pete-rack span[data-chip="500"]{--chip:#b079d6}.pete-spark{position:absolute;height:.75rem;width:.45rem;border-radius:2px;box-shadow:0 1px 3px rgba(0,0,0,.35);will-change:transform,opacity}.pete-dealer-think{animation:pete-think .9s ease-in-out infinite}@keyframes pete-think{0%,to{opacity:.6}50%{opacity:1;transform:translateY(-1px)}}.pete-gallows{overflow:visible;fill:none;stroke-linecap:round;stroke-linejoin:round}.pete-gallows-frame path{stroke:rgba(0,0,0,.35);stroke-width:6}.pete-gallows-frame path:last-child{stroke:rgba(0,0,0,.3);stroke-width:3.5}.pete-gallows-body>*{stroke:#fff;stroke-width:5;opacity:0;filter:drop-shadow(0 2px 0 rgba(0,0,0,.25))}.pete-gallows-body>[data-on="1"]{opacity:1}.pete-part-draw{stroke-dasharray:260;animation:pete-part .45s cubic-bezier(.22,1,.36,1) backwards}@keyframes pete-part{0%{stroke-dashoffset:260}to{stroke-dashoffset:0}}.pete-shake{animation:pete-shake .42s cubic-bezier(.36,.07,.19,.97)}@keyframes pete-shake{10%,90%{transform:translateX(-1.5px)}20%,80%{transform:translateX(3px)}30%,50%,70%{transform:translateX(-5px)}40%,60%{transform:translateX(5px)}}.pete-board{display:flex;flex-wrap:wrap;gap:.35rem 1.1rem;min-height:5rem;align-items:center}.pete-word{display:flex;gap:.3rem}.pete-tile{display:grid;place-items:center;height:2.9rem;width:2.2rem;border-radius:.5rem;font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:1.4rem;font-weight:700;color:#fff;background:rgba(0,0,0,.22);border-bottom:3px solid rgba(0,0,0,.28);text-transform:uppercase}.pete-tile[data-up="1"]{background:hsla(0,0%,100%,.95);color:#2b2118;border-bottom-color:rgba(0,0,0,.35)}.pete-tile[data-punct="1"]{background:none;border:0;width:auto;min-width:.7rem;color:hsla(0,0%,100%,.55)}.pete-tile-hit{animation:pete-tile-hit .34s cubic-bezier(.34,1.56,.64,1)}@keyframes pete-tile-hit{0%{transform:rotateX(90deg) scale(1.1)}to{transform:rotateX(0) scale(1)}}.pete-missed{display:grid;place-items:center;height:1.5rem;min-width:1.5rem;padding:0 .3rem;border-radius:.375rem;background:rgba(0,0,0,.25);font-size:.75rem;font-weight:700;color:hsla(0,0%,100%,.5);text-decoration:line-through;text-transform:uppercase}.pete-meter{display:flex;align-items:baseline;gap:.5rem;border-radius:999px;background:rgba(0,0,0,.28);padding:.4rem .9rem;box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.1);transition:box-shadow .3s ease}.pete-meter-label{font-size:.65rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:hsla(0,0%,100%,.45)}.pete-meter-value{font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:1.5rem;font-weight:700;font-variant-numeric:tabular-nums;color:var(--accent)}.pete-meter[data-cold="1"] .pete-meter-value{color:hsla(0,0%,100%,.55)}.pete-meter[data-hit="1"]{box-shadow:inset 0 0 0 2px rgba(204,61,74,.9);animation:pete-meter-hit .4s ease}@keyframes pete-meter-hit{0%{transform:scale(1)}35%{transform:scale(.94)}to{transform:scale(1)}}.pete-keys{display:grid;gap:.35rem}.pete-key-row{display:flex;justify-content:center;gap:.35rem}.pete-key-row[data-digits="1"]{margin-top:.25rem;opacity:.75}.pete-key-row[data-digits="1"] .pete-key{height:2rem;min-width:1.8rem;font-size:.8rem}.pete-key{height:2.75rem;min-width:2.2rem;flex:0 1 2.4rem;border-radius:.6rem;background:color-mix(in srgb,var(--ink) 6%,transparent);border:2px solid color-mix(in srgb,var(--ink) 10%,transparent);font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-weight:700;color:var(--ink);transition:transform .08s ease,background .15s ease,opacity .15s ease}.pete-key:hover:not(:disabled){background:color-mix(in srgb,var(--ink) 12%,transparent)}.pete-key:active:not(:disabled){transform:translateY(1px) scale(.96)}.pete-key:disabled{cursor:default}.pete-key[data-state=hit]{background:#4caf7d;border-color:#3d9367;color:#fff;opacity:1}.pete-key[data-state=miss]{background:color-mix(in srgb,var(--ink) 4%,transparent);color:color-mix(in srgb,var(--ink) 30%,transparent);text-decoration:line-through}.pete-key:disabled[data-state=""]{opacity:.4}.pete-tier{background:color-mix(in srgb,var(--ink) 4%,transparent);border-color:color-mix(in srgb,var(--ink) 10%,transparent)}.pete-tier:hover{background:color-mix(in srgb,var(--ink) 8%,transparent)}.pete-tier[data-on="1"]{background:color-mix(in srgb,var(--accent) 18%,transparent);border-color:var(--accent)}.pete-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}.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 diff --git a/internal/web/static/js/trivia.js b/internal/web/static/js/trivia.js new file mode 100644 index 0000000..18ff286 --- /dev/null +++ b/internal/web/static/js/trivia.js @@ -0,0 +1,546 @@ +// The trivia table. +// +// Same bargain as every other table in the room: the browser holds no game. It +// sends an answer, and the server says how it went. The four buttons arrive +// without any mark on which of them is right — that index is in the engine +// state, on the server — and the reveal only comes back in the event that +// decides the question, by which point knowing it is worth nothing. +// +// The countdown here is decoration, and it is important to be clear about that. +// Nothing it does scores anything: the server timed the answer the moment it +// arrived, against the clock it started when it served the question. Stopping +// this bar, or reloading to restart it, changes nothing at all. What the bar +// owes the player is *honesty* — so it is seeded from the seconds the server +// says are left, not from when the browser got round to painting. +(function () { + "use strict"; + + var root = document.querySelector("[data-trivia]"); + if (!root) return; + + var FX = window.PeteFX; + + var questionEl = root.querySelector("[data-question]"); + var categoryEl = root.querySelector("[data-category]"); + var answersEl = root.querySelector("[data-answers]"); + var clockEl = root.querySelector("[data-clock]"); + var clockFillEl = root.querySelector("[data-clock-fill]"); + var countdownEl = root.querySelector("[data-countdown]"); + var ladderEl = root.querySelector("[data-ladder]"); + var multEl = root.querySelector("[data-multiple]"); + var meterEl = root.querySelector("[data-meter]"); + var standsEl = root.querySelector("[data-stands]"); + var standsLbl = root.querySelector("[data-stands-label]"); + var rungEl = root.querySelector("[data-rung]"); + var verdictEl = root.querySelector("[data-verdict]"); + + var betting = root.querySelector("[data-betting]"); + var playing = root.querySelector("[data-playing]"); + var walkBtn = root.querySelector("[data-walk]"); + var walkAmtEl = root.querySelector("[data-walk-amount]"); + 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]"); + + // The bet spot, and the rule that comes with it: the number under the pile is + // a readout of the pile, never the other way round. + 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 round as the server last described it + var tier = "medium"; + + 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" : ""; + } + + // ---- the clock ------------------------------------------------------------- + + var raf = null; + var clockDeadline = 0; // performance.now() ms at which this question dies + var clockLimit = 1; + var timedOut = false; + + // HOT is when the bar stops being a progress meter and starts being a warning. + var HOT = 5; + + // GRACE is how long past its own zero the browser waits before reporting the + // timeout. It cannot be negative: the browser's countdown starts when the + // response *arrives*, so it is already behind the server's by the latency of + // that response, and it therefore always reaches zero after the server has. + // The grace is only there so that "always" survives a rounding error. + var GRACE = 400; + + function stopClock() { + if (raf) cancelAnimationFrame(raf); + raf = null; + } + + function startClock(left, limit) { + stopClock(); + timedOut = false; + clockLimit = limit > 0 ? limit : 1; + clockDeadline = performance.now() + left * 1000; + tick(); + } + + function tick() { + var left = (clockDeadline - performance.now()) / 1000; + if (left < 0) left = 0; + + // A transform, not a width: the browser can run this one without laying the + // page out again on every frame. + clockFillEl.style.transform = "scaleX(" + (left / clockLimit).toFixed(4) + ")"; + countdownEl.textContent = left.toFixed(1) + "s"; + clockEl.dataset.hot = left <= HOT && left > 0 ? "1" : "0"; + + if (left <= 0) { + countdownEl.textContent = "0.0s"; + clockEl.dataset.hot = "0"; + timeUp(); + return; + } + raf = requestAnimationFrame(tick); + } + + // timeUp reports the clock running out. It is not the browser *deciding* the + // question — it is the browser telling the server the player never answered, + // and the server (which has been holding the real clock all along) agreeing. + function timeUp() { + stopClock(); + if (timedOut || busy || !game || game.phase !== "playing") return; + timedOut = true; + lockAnswers(); + setTimeout(function () { + // -1 is "no answer". The engine checks the clock before it checks the + // choice, so this resolves as the timeout it is rather than a bad move. + send("/api/games/trivia/answer", { choice: -1 }, gameMsgEl); + }, GRACE); + } + + function clearClock() { + stopClock(); + clockFillEl.style.transform = "scaleX(0)"; + countdownEl.textContent = ""; + clockEl.dataset.hot = "0"; + } + + // ---- the question ---------------------------------------------------------- + + var KEYS = ["1", "2", "3", "4"]; + + function renderQuestion(v) { + answersEl.innerHTML = ""; + if (!v || v.phase !== "playing" || !v.answers) { + questionEl.textContent = ""; + categoryEl.textContent = ""; + return; + } + categoryEl.textContent = v.category || ""; + questionEl.textContent = v.question || ""; + + v.answers.forEach(function (text, i) { + var b = document.createElement("button"); + b.type = "button"; + b.className = "pete-answer"; + b.dataset.at = String(i); + + var key = document.createElement("span"); + key.className = "pete-answer-key"; + key.textContent = KEYS[i] || ""; + key.setAttribute("aria-hidden", "true"); + + var label = document.createElement("span"); + label.textContent = text; + + b.appendChild(key); + b.appendChild(label); + b.addEventListener("click", function () { answer(i); }); + answersEl.appendChild(b); + }); + } + + function lockAnswers() { + answersEl.querySelectorAll(".pete-answer").forEach(function (b) { b.disabled = true; }); + } + + // reveal marks the board once the server has decided. Nothing in here is known + // until it comes back: the right answer arrives in the event, not in the view. + function reveal(choice, correct) { + answersEl.querySelectorAll(".pete-answer").forEach(function (b) { + var i = parseInt(b.dataset.at, 10); + b.disabled = true; + if (i === choice && i === correct) b.dataset.state = "right"; + else if (i === choice) b.dataset.state = "wrong"; + else if (i === correct) b.dataset.state = "missed"; + else b.dataset.state = "dim"; + }); + } + + // ---- the meters ------------------------------------------------------------ + + function renderLadder(v) { + ladderEl.innerHTML = ""; + var rungs = (v && v.rungs) || 12; + var done = (v && v.rung) || 0; + for (var i = 0; i < rungs; i++) { + var pip = document.createElement("span"); + pip.className = "pete-rung"; + pip.dataset.on = i < done ? "1" : "0"; + ladderEl.appendChild(pip); + } + } + + function renderMeter(v) { + if (!v) { + multEl.textContent = "—"; + standsEl.textContent = "—"; + standsLbl.textContent = "if you walk"; + meterEl.dataset.cold = "1"; + rungEl.textContent = ""; + return; + } + multEl.textContent = v.multiple.toFixed(2) + "×"; + standsEl.textContent = (v.stands || 0).toLocaleString(); + meterEl.dataset.cold = v.rung === 0 ? "1" : "0"; + + if (v.phase === "done") { + standsLbl.textContent = v.net > 0 ? "banked" : "gone"; + rungEl.textContent = ""; + return; + } + standsLbl.textContent = v.can_walk ? "if you walk" : "answer one to unlock"; + rungEl.textContent = "Question " + (v.rung + 1) + " of " + v.rungs; + } + + // knock rolls the multiple up to its new value rather than swapping it, so a + // right answer reads as the total *growing* — which is the thing you're + // deciding whether to risk. + function climb(v) { + var from = parseFloat(multEl.textContent) || 1; + var to = v.multiple; + if (reduced) { renderMeter(v); return; } + var t0 = performance.now(); + meterEl.dataset.hit = "0"; + + (function step(now) { + var p = Math.min(1, (now - t0) / 420); + var eased = 1 - Math.pow(1 - p, 3); + multEl.textContent = (from + (to - from) * eased).toFixed(2) + "×"; + if (p < 1) requestAnimationFrame(step); + else renderMeter(v); + })(t0); + } + + // ---- the money ------------------------------------------------------------- + + function settleChips(final) { + var payout = final.payout || 0; + var back = payout - final.bet; + + if (payout <= 0) { + var chain = spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true }); + return chain; + } + 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 ladder, the way every + // other table in the room leaves your bet up. + function standing(amount) { + var money = window.PeteGames.view(); + if (!amount || !money || money.chips < amount) { + bet = 0; + showBet(); + return; + } + bet = amount; + showBet(); + spot.amount = amount; + return spot.pour(purseEl, amount); + } + + // ---- phases ---------------------------------------------------------------- + + var VERDICTS = { + walked: "Banked it.", + cleared: "Cleared the board! 🎉", + wrong: "Wrong.", + timeout: "Out of time.", + }; + + function verdict(v) { + var text = VERDICTS[v.outcome] || ""; + if (!text) { verdictEl.classList.add("hidden"); return; } + if (v.net > 0) text += " +" + v.net.toLocaleString(); + else if (v.net < 0) text += " " + v.net.toLocaleString(); + verdictEl.textContent = text; + verdictEl.classList.remove("hidden"); + + // Confetti only for clearing all twelve — the one thing in here worth it. + if (v.outcome === "cleared") FX.burst(verdictEl, { count: 34 }); + } + + function setPhase(v) { + game = v; + var live = !!v && v.phase === "playing"; + betting.classList.toggle("hidden", live); + playing.classList.toggle("hidden", !live); + if (walkBtn) { + walkBtn.disabled = !live || !v.can_walk; + walkAmtEl.textContent = (v && v.can_walk ? v.stands : 0).toLocaleString(); + } + if (!v || !v.outcome) verdictEl.classList.add("hidden"); + } + + // paint puts a round up with no animation: the resume path, after a reload or a + // redeploy. The clock picks up exactly where the server says it is — which is + // the whole point of it being the server's clock. + function paint(v) { + if (!v) { + clearClock(); + renderQuestion(null); + renderLadder(null); + renderMeter(null); + spot.render(0); + setPhase(null); + return; + } + renderQuestion(v); + renderLadder(v); + renderMeter(v); + spot.render(v.phase === "done" ? 0 : v.bet); + setPhase(v); + + if (v.phase === "playing") startClock(v.left, v.limit); + else clearClock(); + } + + // ---- the script ------------------------------------------------------------ + + // play walks the server's events. Same rule as the other tables: on a live + // round the money is already right (your stake left your pile when you pressed + // Play, and it's on the spot), but on a settling one the chip bar is held back + // until the chips have physically come home. A counter that pays you before + // the reveal is a counter that has told you the ending. + function play(view, money) { + var events = view.triv_events || []; + var final = view.trivia; + var settles = !!final && final.phase === "done"; + var chain = Promise.resolve(); + + if (!settles) money(); + + stopClock(); + + events.forEach(function (e) { + chain = chain.then(function () { + switch (e.kind) { + case "ask": + // A fresh question. Everything about the last one goes. + verdictEl.classList.add("hidden"); + renderQuestion(final); + renderLadder(final); + if (final && final.phase === "playing") startClock(final.left, final.limit); + return; + + case "right": + reveal(e.choice, e.correct); + if (final) { + // The rung lighting and the multiple climbing are one event, + // because they are one event: this is what the answer was worth. + climb({ multiple: e.multiple, rung: final.rung, rungs: final.rungs, + stands: final.stands, can_walk: true, phase: "playing" }); + renderLadder(final); + } + return wait(900); + + case "wrong": + reveal(e.choice, e.correct); + return wait(1100); + + case "timeout": + reveal(-1, e.correct); + return wait(1100); + + case "settle": + return; + } + }); + }); + + return chain.then(function () { + if (!final) { paint(null); money(); return; } + + if (!settles) { + renderMeter(final); + setPhase(final); + return; + } + + // Over: the clock stops, the money moves, and only then does the bar catch up. + clearClock(); + playing.classList.add("hidden"); + renderMeter(final); + renderLadder(final); + verdict(final); + return settleChips(final) + .then(money) + .then(function () { return standing(final.bet); }) + .then(function () { setPhase(final); }); + }); + } + + // ---- talking to the table --------------------------------------------------- + + function send(path, body, where) { + if (busy) return; + busy = true; + say("", null, where); + return window.PeteGames.post(path, body) + .then(function (view) { + return play(view, function () { window.PeteGames.apply(view); }); + }) + .catch(function (err) { + say(err.message, "bad", where); + return window.PeteGames.refresh().then(function (v) { + if (v && v.trivia) paint(v.trivia); + else { paint(null); spot.render(0); } + }); + }) + .then(function () { busy = false; }); + } + + function answer(i) { + if (busy || timedOut || !game || game.phase !== "playing") return; + stopClock(); + lockAnswers(); + send("/api/games/trivia/answer", { choice: i }, gameMsgEl); + } + + if (walkBtn) { + walkBtn.addEventListener("click", function () { + if (busy || !game || game.phase !== "playing" || !game.can_walk) return; + stopClock(); + lockAnswers(); + send("/api/games/trivia/answer", { walk: true }, gameMsgEl); + }); + } + + // 1–4 answers the question. The key is printed on the button it answers. + document.addEventListener("keydown", function (e) { + if (e.metaKey || e.ctrlKey || e.altKey) return; + if (/^(input|textarea|select)$/i.test(e.target.tagName || "")) return; + if (!game || game.phase !== "playing" || busy) return; + var i = KEYS.indexOf(e.key); + if (i === -1) return; + var btn = answersEl.querySelector('.pete-answer[data-at="' + i + '"]'); + if (!btn || btn.disabled) return; + e.preventDefault(); + answer(i); + }); + + // ---- 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 corner are the house's rack, and it 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 (!tier) { say("Pick a difficulty first.", "bad"); return; } + if (bet <= 0) { say("Put something on it first.", "bad"); return; } + // The stake stays on the spot for the whole ladder: it is what's at risk, + // and it is riding on every question until you take it back or lose it. + send("/api/games/trivia/start", { bet: bet, tier: tier }); + }); + } + + pickTier(tier); + + var resumed = false; + window.PeteGames.onUpdate(function (v) { + if (!resumed) { + resumed = true; + if (v.trivia) { + paint(v.trivia); + if (v.trivia.phase === "done") verdict(v.trivia); + } else { + paint(null); + } + } + showBet(); + }); +})(); diff --git a/internal/web/templates/games.html b/internal/web/templates/games.html index 1c3de16..4870fd4 100644 --- a/internal/web/templates/games.html +++ b/internal/web/templates/games.html @@ -74,6 +74,22 @@

+ +
+ 🧠 +
+

Trivia

+

Climb the ladder, or take the money.

+
+ Open +
+

+ {{.Rungs}} questions, and every right answer multiplies what you're holding. A wrong + one loses the lot. Answer fast: the multiple decays as the clock runs. +

+
+ {{range .Soon}}
diff --git a/internal/web/templates/trivia.html b/internal/web/templates/trivia.html new file mode 100644 index 0000000..b657da7 --- /dev/null +++ b/internal/web/templates/trivia.html @@ -0,0 +1,149 @@ +{{define "title"}}Trivia · {{.Room.Name}}{{end}} + +{{define "main"}} +
+ +
+ +

{{.Rungs}} questions · answer fast, or don't bother

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

+ + if you walk +

+ +
+ + +
+ +
+
+
+ +
+

+

+
+ +

+ +
+ +
+ +
+
+ + +
+
+ Bet +
+ +
+

+
+
+ + + + + +
+ +
How hard?
+
+ {{range .Quizzes}} + + {{end}} +
+ +
+
+
Your bet
+
0
+
+ +
+ {{range .Denominations}} + + {{end}} + +
+ + +
+

+ The first question is the price of sitting down: you can only walk once you've answered one. +

+ +
+ +
+{{end}} + +{{define "scripts"}} + + + +{{end}} diff --git a/internal/web/trivia_bank.go b/internal/web/trivia_bank.go new file mode 100644 index 0000000..f4a10fb --- /dev/null +++ b/internal/web/trivia_bank.go @@ -0,0 +1,126 @@ +package web + +import ( + "context" + "log/slog" + "time" + + "pete/internal/games/trivia" + "pete/internal/opentdb" + "pete/internal/storage" +) + +// Keeping the trivia bank stocked. +// +// The bank is not consumed by play — a question drawn is still there afterwards +// — so this loop is about *variety*, not supply. It fills each difficulty up to +// a target and then has nothing to do, which is why a pass that finds the bank +// full costs three COUNT queries and no network at all. + +// bankTarget is how many questions of each difficulty we want to hold. Twelve +// rungs drawn from four hundred is enough that a regular player doesn't start +// recognising them, and it's a size OpenTDB's pool can actually fill. +const bankTarget = 400 + +// bankMaxFetches bounds one pass. At OpenTDB's politeness interval this is a +// couple of minutes of drip, after which the loop goes back to sleep rather than +// hammering a free API for an hour to top up the last few questions. +const bankMaxFetches = 12 + +// bankInterval is how often we go back and look. The bank is a slow-moving +// thing: it only grows, and it only needs to grow once. +const bankInterval = 12 * time.Hour + +// StartTriviaBank launches the refill loop if the casino is on. Safe to call +// unconditionally; a no-op when games are off. +func (s *Server) StartTriviaBank(ctx context.Context) { + if !s.gamesReady() { + return + } + go s.runTriviaBank(ctx) +} + +func (s *Server) runTriviaBank(ctx context.Context) { + slog.Info("games: trivia bank refill started", "target", bankTarget, "interval", bankInterval) + + s.refillTriviaBank(ctx) + + ticker := time.NewTicker(bankInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.refillTriviaBank(ctx) + } + } +} + +// refillTriviaBank tops each difficulty up toward the target, politely. +// +// Every failure here is survivable and none of them stop the loop: OpenTDB is a +// free API that is sometimes down, and a thin bank costs a player nothing worse +// than a "give it a minute" when they try to start a ladder. +func (s *Server) refillTriviaBank(ctx context.Context) { + client := opentdb.New() + fetches := 0 + + for _, t := range trivia.Tiers { + for fetches < bankMaxFetches { + have, err := storage.CountTrivia(t.Difficulty) + if err != nil { + slog.Error("games: trivia bank count", "difficulty", t.Difficulty, "err", err) + break + } + if have >= bankTarget { + break + } + + qs, err := client.Fetch(ctx, t.Difficulty, opentdb.Batch) + fetches++ + if err != nil { + if ctx.Err() != nil { + return + } + slog.Warn("games: trivia bank fetch", "difficulty", t.Difficulty, "err", err) + // Whatever went wrong, waiting is the only sensible response: the + // likeliest cause is the rate limit, and retrying at once earns another. + if !sleepCtx(ctx, opentdb.Politeness) { + return + } + continue + } + + added, err := storage.AddTriviaQuestions(t.Difficulty, qs) + if err != nil { + slog.Error("games: trivia bank store", "difficulty", t.Difficulty, "err", err) + break + } + slog.Info("games: trivia bank filled", + "difficulty", t.Difficulty, "fetched", len(qs), "new", added, "have", have+added) + + // The API hands back random batches, so once the bank is deep the + // overlap gets heavy and a batch adds almost nothing new. When it adds + // nothing at all, this difficulty has given us what it has: stop asking. + if added == 0 { + break + } + if !sleepCtx(ctx, opentdb.Politeness) { + return + } + } + } +} + +// sleepCtx waits, unless we're being shut down. Reports false if we are. +func sleepCtx(ctx context.Context, d time.Duration) bool { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return false + case <-t.C: + return true + } +} diff --git a/main.go b/main.go index 5f39d01..7c970a4 100644 --- a/main.go +++ b/main.go @@ -299,6 +299,7 @@ func main() { go ws.Start(ctx) ws.StartPushSender(ctx) ws.StartAdventureDigest(ctx) + ws.StartTriviaBank(ctx) } } diff --git a/pete_games_plan.md b/pete_games_plan.md index e6e850f..eb57f54 100644 --- a/pete_games_plan.md +++ b/pete_games_plan.md @@ -244,9 +244,61 @@ A multi-session build. This section is the handover; read it before anything els hits the rack, nothing happens, and it looks like the bet is broken. Blackjack's action buttons are also `[data-move="stand"]`, not `[data-stand]`. +- **Trivia, and it plays for chips.** *(2026-07-14. Built, tested, and — say it + plainly — **NOT YET DRIVEN IN A BROWSER**. Every Go test passes and `go vet` is + clean, which per this plan's own hard-won rule means nothing at all about the + table. Do that first next session; see the bottom of this entry.)* + - **A ladder.** Stake once, then answer a run of twelve. Every right answer + multiplies what you're holding, a wrong one loses the lot, and you may walk + with what you've built. Clearing all twelve ends the run and banks it — a + ladder with no top is a slot machine you can't stop playing, and eventually + every player loses everything to one bad question. + - **The clock is the game, and it is the anti-google mechanism.** Trivia answers + are lookupable, so a right answer is worth what it's worth *when you give it*: + the multiple decays from Fast to Buzzer across the tier's limit (easy 1.30→1.10 + over 20s, medium 1.55→1.20 over 18s, hard 1.90→1.30 over 15s), and running out + of time loses exactly as much as being wrong. A timeout that merely cost you the + speed bonus would make "look it up in the other tab" the strongest way to play. + The countdown in the browser is decoration; the clock that scores is + `time.Now()` against the `AskedAt` the server stamped. A reload does not restart + it. + - **A pure reducer still, but the time is an argument** — `ApplyMove(state, move, + now)`. A reducer cannot own a timer, so it doesn't: the only thing that knows + what o'clock it is remains the caller, and the engine stays value-in, value-out. + - **You cannot walk off the first rung** (`ErrNothingBanked`). If you could, seeing + question one and walking would be a free look: stake, peek, walk, restake, and + reshuffle until the question is one you happen to know. The first question is the + price of sitting down. + - **The browser never learns which answer is right.** The four answers cross the + wire without the index; that index is in the engine state, on the server. It + comes back only in the event that *decides* the question, by which point knowing + it is worth nothing. The ladder's remaining questions are never sent at all. + - `internal/games/trivia` — engine, 11 tests. The one that matters most is the + same one hangman needed: the number the felt quotes (`Pays()`) is asserted equal + to the number `settle()` lands on, at every rung. + - **The bank is prefetched, not fetched per question** (`internal/opentdb`, + `storage.DrawTrivia`, table `trivia_questions`). A ladder asks a question every + fifteen seconds with money on a clock the player is scored against; a live fetch + would put OpenTDB's latency and rate limit *inside* that clock. The refill is a + slow background drip (`StartTriviaBank`, 400 per difficulty, one request per six + seconds, stops early when a batch adds nothing new), and a round never waits on + it. Answers are shuffled per-game against the game's own seed, so where the right + answer sits in the table tells a player nothing. + - **Left to do, and it is the whole of the risk:** `PETE_DEV_CASINO=:7788 go test + ./internal/web -run TestDevCasino -timeout 0` and *play it*. Watch especially: + the bank is empty on a fresh dev database, so the first start will 503 with "the + question bank is still filling up" until the refill loop has run — the dev rig + does not start that loop, so it likely needs seeding by hand or a call to + `refillTriviaBank`. Then: does the clock bar drain honestly, does the auto-submit + at zero land as a timeout rather than a "that move isn't legal" (the GRACE window + in trivia.js is what defends this), does the reveal mark the right answer, and + does the money settle onto the shared spot the way the other three tables do. + ### Next, in order -1. Phase 2's other half: **trivia**. Decided but not built: the question bank is +1. **Drive trivia in a browser** (see above) — it has never been played, and that + means nothing about it is known. +2. Phase 2's other half is now built but unproven: the question bank is **prefetched from OpenTDB into a local table** (a per-question fetch in a web game loop is a latency and rate-limit problem gogobee never had), through `internal/safehttp`. It stakes chips too. The shape that fits the room is a