diff --git a/internal/games/hangman/hangman.go b/internal/games/hangman/hangman.go new file mode 100644 index 0000000..a00ffee --- /dev/null +++ b/internal/games/hangman/hangman.go @@ -0,0 +1,395 @@ +// Package hangman is a pure hangman engine, played for chips. +// +// Same seam as blackjack: ApplyMove(state, move) (state, events, error), where +// an error means the move was illegal and nothing else. No HTTP, no timers, no +// player names. The state is a plain value, so a game survives a redeploy and +// replays from its seed. +// +// The casino version differs from the one gogobee plays in Matrix in one way +// that matters: there is money on it. That makes the gallows a payout meter as +// well as a death clock — every wrong guess takes a tenth off what a win is +// worth. You can still win from five wrong, you just won't win much. +package hangman + +import ( + "errors" + "math" + "math/rand/v2" + "strings" + "unicode" +) + +// Errors an illegal move can produce. +var ( + ErrGameOver = errors.New("hangman: the game is already over") + ErrNotALetter = errors.New("hangman: that is not a letter") + ErrAlreadyTried = errors.New("hangman: that letter has been tried") + ErrUnknownMove = errors.New("hangman: unknown move") + ErrBadBet = errors.New("hangman: bet must be positive") + ErrEmptySolution = errors.New("hangman: guess something") + ErrUnknownTier = errors.New("hangman: no such tier") +) + +// MaxWrong is how many wrong guesses the gallows holds: head, body, two arms, +// two legs. It is not a tier setting — the drawing has six parts, so the game +// has six lives, and the tiers vary what a win pays instead. +const MaxWrong = 6 + +// Decay is what one wrong guess costs, as a fraction of the tier's base +// multiple. Six wrong is death, so the worst a living player can be is 50% off. +const Decay = 0.10 + +// Tier is a difficulty, chosen before the bet. Short phrases pay the most: +// there is less of them to read, and fewer distinct letters to hit by accident. +// A long phrase mostly reveals itself. +type Tier struct { + Slug string `json:"slug"` + Name string `json:"name"` + Min int `json:"min"` // phrase length, in characters + Max int `json:"max"` // inclusive + Base float64 `json:"base"` // what a win pays, before any wrong guesses + Blurb string `json:"blurb"` +} + +// Tiers are the three tables. The bank has 74 short phrases, 67 medium and 64 +// long, so none of them runs thin. +var Tiers = []Tier{ + {Slug: "short", Name: "Short", Min: 8, Max: 20, Base: 2.6, + Blurb: "A handful of letters and nothing to go on."}, + {Slug: "medium", Name: "Medium", Min: 21, Max: 40, Base: 2.0, + Blurb: "Long enough to read once it starts falling open."}, + {Slug: "long", Name: "Long", Min: 41, Max: 9999, Base: 1.6, + Blurb: "It gives itself away. It also pays the least."}, +} + +// TierBySlug finds a tier by the name the browser sent. +func TierBySlug(slug string) (Tier, error) { + for _, t := range Tiers { + if t.Slug == slug { + return t, nil + } + } + return Tier{}, ErrUnknownTier +} + +// Phase is where the game is. +type Phase string + +const ( + PhasePlaying Phase = "playing" + PhaseDone Phase = "done" +) + +// Outcome is how it ended. +type Outcome string + +const ( + OutcomeNone Outcome = "" + OutcomeSolved Outcome = "solved" // guessed the phrase outright + OutcomeFilled Outcome = "filled" // revealed the last letter + OutcomeHung Outcome = "hung" // six wrong +) + +// Won reports whether this outcome pays. +func (o Outcome) Won() bool { return o == OutcomeSolved || o == OutcomeFilled } + +// State is one game. The phrase is in here, which is exactly why this value +// never leaves the server — the browser gets a Masked() view of it instead. +type State struct { + Tier Tier `json:"tier"` + Phrase string `json:"phrase"` + Runes []rune `json:"runes"` // the phrase, indexable safely + Shown []bool `json:"shown"` // one per rune: is it face up + Tried []rune `json:"tried"` // every letter guessed, in order, right or wrong + Wrong []rune `json:"wrong"` // just the ones that missed + 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: a letter turning over, a limb being +// drawn. The engine emits them rather than drawing anything itself. +type Event struct { + Kind string `json:"kind"` // "start" | "hit" | "miss" | "solve" | "settle" + Letter string `json:"letter,omitempty"` // the letter guessed + At []int `json:"at,omitempty"` // which rune positions it turned over + Text string `json:"text,omitempty"` +} + +// Move is a player action: a single letter, or the whole phrase. +type Move struct { + Letter string `json:"letter"` + Solve string `json:"solve"` +} + +// New starts a game on a phrase drawn from the tier's shelf of the bank. +func New(bet int64, t Tier, rakePct float64, rng *rand.Rand) (State, []Event, error) { + if bet <= 0 { + return State{}, nil, ErrBadBet + } + phrase, err := drawPhrase(t, rng) + if err != nil { + return State{}, nil, err + } + return start(bet, t, phrase, rakePct) +} + +// start builds the opening state for a known phrase. Split out from New so a +// test can pin the phrase instead of the seed. +func start(bet int64, t Tier, phrase string, rakePct float64) (State, []Event, error) { + if bet <= 0 { + return State{}, nil, ErrBadBet + } + rs := []rune(phrase) + s := State{ + Tier: t, Phrase: phrase, Runes: rs, + Shown: make([]bool, len(rs)), + RakePct: rakePct, + Bet: bet, Phase: PhasePlaying, + } + // Spaces and punctuation are never guessed — they start face up, because a + // row of blanks with the word breaks hidden is a puzzle about typography. + for i, r := range rs { + if !isGuessable(r) { + s.Shown[i] = true + } + } + return s, []Event{{Kind: "start"}}, nil +} + +// isGuessable reports whether a rune is one you'd guess: a letter or a digit. +// Everything else is scaffolding and is shown from the start. +func isGuessable(r rune) bool { + return unicode.IsLetter(r) || unicode.IsDigit(r) +} + +// ApplyMove is the engine. A legal move in, the new state and what happened out. +// An error means the move was illegal and the caller's state is untouched. +func ApplyMove(s State, m Move) (State, []Event, error) { + if s.Phase == PhaseDone { + return s, nil, ErrGameOver + } + s = s.clone() + + switch { + case m.Solve != "": + return applySolve(s, m.Solve) + case m.Letter != "": + return applyLetter(s, m.Letter) + default: + return s, nil, ErrUnknownMove + } +} + +// applyLetter guesses one letter. +func applyLetter(s State, letter string) (State, []Event, error) { + rs := []rune(strings.ToLower(strings.TrimSpace(letter))) + if len(rs) != 1 || !isGuessable(rs[0]) { + return s, nil, ErrNotALetter + } + g := rs[0] + if containsRune(s.Tried, g) { + // Not a miss — a no-op. Charging a life for a letter the board already + // shows you tried is punishing a mis-click, not a bad guess. + return s, nil, ErrAlreadyTried + } + s.Tried = append(s.Tried, g) + + var at []int + for i, r := range s.Runes { + if !s.Shown[i] && foldEq(r, g) { + s.Shown[i] = true + at = append(at, i) + } + } + + evs := []Event{} + if len(at) > 0 { + evs = append(evs, Event{Kind: "hit", Letter: string(g), At: at}) + if s.filled() { + s.settle(OutcomeFilled, &evs) + } + return s, evs, nil + } + + s.Wrong = append(s.Wrong, g) + evs = append(evs, Event{Kind: "miss", Letter: string(g)}) + if len(s.Wrong) >= MaxWrong { + s.settle(OutcomeHung, &evs) + } + return s, evs, nil +} + +// applySolve guesses the whole phrase. Right, and it's over at the multiple you +// still hold. Wrong, and it costs a life like any other bad guess — otherwise +// solving would be a free roll you could spam until it landed. +func applySolve(s State, attempt string) (State, []Event, error) { + if strings.TrimSpace(attempt) == "" { + return s, nil, ErrEmptySolution + } + evs := []Event{{Kind: "solve", Text: attempt}} + + if normalize(attempt) == normalize(s.Phrase) { + for i := range s.Shown { + s.Shown[i] = true + } + s.settle(OutcomeSolved, &evs) + return s, evs, nil + } + + // A wrong solve is a miss with no letter attached to it. + s.Wrong = append(s.Wrong, '·') + evs = append(evs, Event{Kind: "miss", Text: attempt}) + if len(s.Wrong) >= MaxWrong { + s.settle(OutcomeHung, &evs) + } + return s, evs, nil +} + +// Multiple is what a win is worth right now: the tier's base, less a tenth of +// it for every wrong guess so far. Floored at 1, so a win never hands back less +// than the stake — a player who fought through five wrong guesses and got there +// has not earned a loss. +func (s State) Multiple() float64 { + m := s.Tier.Base * (1 - Decay*float64(len(s.Wrong))) + if m < 1 { + return 1 + } + return m +} + +// Pays is what a win *right now* would actually put back on the player's stack: +// the stake, plus the winnings, less the house's cut of the winnings. +// +// It exists because the felt shows this number while the game is still running, +// and settle() is the only other thing that computes it. If the two ever +// disagreed the table would be quoting a payout it doesn't honour — so settle +// calls this rather than doing the sum a second time. +func (s State) Pays() int64 { + total := int64(math.Floor(float64(s.Bet) * s.Multiple())) + if total < s.Bet { + total = s.Bet // a win 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 { + rake = 0 + } + profit -= rake + } + return s.Bet + profit +} + +// Rake taken on a win right now — the other half of what Pays works out. +func (s State) rakeNow() int64 { + total := int64(math.Floor(float64(s.Bet) * s.Multiple())) + if total < s.Bet { + return 0 + } + profit := total - s.Bet + if profit <= 0 { + return 0 + } + rake := int64(math.Floor(float64(profit) * s.RakePct)) + if rake < 0 { + return 0 + } + return rake +} + +// Lives is how many wrong guesses are left. +func (s State) Lives() int { return MaxWrong - len(s.Wrong) } + +// filled reports whether every guessable rune is face up. +func (s State) filled() bool { + for _, up := range s.Shown { + if !up { + return false + } + } + return true +} + +// settle decides the payout. Same rule as blackjack: the rake comes out of +// winnings, never out of the stake. 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 + } + + // The phrase goes face up when it's over, win or lose. Losing without ever + // being told the answer is the one thing hangman must never do. + for i := range s.Shown { + s.Shown[i] = true + } + *evs = append(*evs, Event{Kind: "settle", 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 +} + +// Masked is the phrase as the player may see it: revealed runes as themselves, +// hidden ones as an underscore. This — not Phrase — is what crosses the wire. +func (s State) Masked() string { + var b strings.Builder + for i, r := range s.Runes { + if s.Shown[i] { + b.WriteRune(r) + } else { + b.WriteRune('_') + } + } + return b.String() +} + +// clone deep-copies the slices, so a derived state shares no backing array with +// the one it came from and a state can be replayed freely. +func (s State) clone() State { + s.Runes = append([]rune(nil), s.Runes...) + s.Shown = append([]bool(nil), s.Shown...) + s.Tried = append([]rune(nil), s.Tried...) + s.Wrong = append([]rune(nil), s.Wrong...) + return s +} + +// normalize flattens a phrase for comparison: case, spacing and punctuation all +// stop mattering. "How are you gentlemen!!" is solved by "how are you gentlemen". +func normalize(s string) string { + var b strings.Builder + for _, r := range strings.ToLower(s) { + if isGuessable(r) { + b.WriteRune(r) + } + } + return b.String() +} + +// foldEq compares two runes the way a guess should: case-insensitively. +func foldEq(a, b rune) bool { + return unicode.ToLower(a) == unicode.ToLower(b) +} + +func containsRune(rs []rune, r rune) bool { + for _, x := range rs { + if foldEq(x, r) { + return true + } + } + return false +} diff --git a/internal/games/hangman/hangman_test.go b/internal/games/hangman/hangman_test.go new file mode 100644 index 0000000..87b35fb --- /dev/null +++ b/internal/games/hangman/hangman_test.go @@ -0,0 +1,369 @@ +package hangman + +import ( + "encoding/json" + "math/rand/v2" + "strings" + "testing" +) + +// tierShort is the tier most tests play on: base 2.6. +func tierShort(t *testing.T) Tier { + t.Helper() + tr, err := TierBySlug("short") + if err != nil { + t.Fatal(err) + } + return tr +} + +// play runs a sequence of single-letter guesses against a pinned phrase. +func play(t *testing.T, phrase string, bet int64, rake float64, guesses ...string) State { + t.Helper() + s, _, err := start(bet, tierShort(t), phrase, rake) + if err != nil { + t.Fatal(err) + } + for _, g := range guesses { + next, _, err := ApplyMove(s, Move{Letter: g}) + if err != nil { + t.Fatalf("guess %q: %v", g, err) + } + s = next + } + return s +} + +func TestStartShowsScaffoldingOnly(t *testing.T) { + s, evs, err := start(100, tierShort(t), "Insert Coin", 0.05) + if err != nil { + t.Fatal(err) + } + if got, want := s.Masked(), "______ ____"; got != want { + t.Errorf("masked = %q, want %q — the space is scaffolding and shows, the letters don't", got, want) + } + if len(evs) != 1 || evs[0].Kind != "start" { + t.Errorf("events = %+v, want one start", evs) + } + if s.Lives() != MaxWrong { + t.Errorf("lives = %d, want %d", s.Lives(), MaxWrong) + } +} + +func TestPunctuationAndDigitsAreScaffoldingOrNot(t *testing.T) { + // Punctuation shows from the start; a digit is guessable like a letter. + s, _, err := start(100, tierShort(t), "Level 9!", 0.05) + if err != nil { + t.Fatal(err) + } + if got, want := s.Masked(), "_____ _!"; got != want { + t.Fatalf("masked = %q, want %q", got, want) + } + next, _, err := ApplyMove(s, Move{Letter: "9"}) + if err != nil { + t.Fatalf("guessing a digit: %v", err) + } + if got, want := next.Masked(), "_____ 9!"; got != want { + t.Errorf("masked = %q, want %q — a digit is a guess", got, want) + } +} + +func TestHitRevealsEveryOccurrence(t *testing.T) { + // "Blue Shell" has an l at 1, 8 and 9. One guess turns over all three. + s := play(t, "Blue Shell", 100, 0.05, "l") + if got, want := s.Masked(), "_l__ ___ll"; got != want { + t.Errorf("masked = %q, want %q — every l turns over, not just the first", got, want) + } + if s.Lives() != MaxWrong { + t.Errorf("a hit cost a life: lives = %d", s.Lives()) + } +} + +func TestMissCostsALifeAndTheMultiple(t *testing.T) { + tr := tierShort(t) + s := play(t, "Insert Coin", 100, 0.05, "z") + if s.Lives() != MaxWrong-1 { + t.Errorf("lives = %d, want %d", s.Lives(), MaxWrong-1) + } + want := tr.Base * 0.9 + if got := s.Multiple(); got != want { + t.Errorf("multiple = %v, want %v — one wrong guess is a tenth of the base", got, want) + } +} + +func TestRepeatedLetterIsRefusedNotPunished(t *testing.T) { + s := play(t, "Insert Coin", 100, 0.05, "z") + _, _, err := ApplyMove(s, Move{Letter: "z"}) + if err != ErrAlreadyTried { + t.Fatalf("err = %v, want ErrAlreadyTried", err) + } + // And the state the caller holds is untouched: a mis-click is not a life. + if s.Lives() != MaxWrong-1 { + t.Errorf("the refused move moved the state: lives = %d", s.Lives()) + } +} + +func TestSixWrongHangsYouAndPaysNothing(t *testing.T) { + s := play(t, "Insert Coin", 100, 0.05, "z", "x", "q", "y", "w", "k") + if s.Phase != PhaseDone || s.Outcome != OutcomeHung { + t.Fatalf("phase/outcome = %s/%s, want done/hung", s.Phase, s.Outcome) + } + if s.Payout != 0 { + t.Errorf("payout = %d, want 0", s.Payout) + } + if s.Net() != -100 { + t.Errorf("net = %d, want -100", s.Net()) + } + if strings.Contains(s.Masked(), "_") { + t.Error("a lost game must still show the phrase — being hung without being told the answer is the one thing hangman can't do") + } +} + +func TestFillingTheLastLetterWinsCleanAtFullMultiple(t *testing.T) { + // "Blue Shell" — every distinct letter, no misses. + s := play(t, "Blue Shell", 100, 0, "b", "l", "u", "e", "s", "h") + if s.Outcome != OutcomeFilled { + t.Fatalf("outcome = %s, want filled", s.Outcome) + } + // Base 2.6, no wrong guesses, no rake: 100 -> 260. + if s.Payout != 260 { + t.Errorf("payout = %d, want 260", s.Payout) + } + if s.Net() != 160 { + t.Errorf("net = %d, want 160", s.Net()) + } +} + +func TestSolveOutrightWinsAtTheMultipleYouStillHold(t *testing.T) { + s, _, err := start(100, tierShort(t), "Insert Coin", 0) + if err != nil { + t.Fatal(err) + } + // Two wrong first: 2.6 -> 2.08. + for _, g := range []string{"z", "x"} { + s, _, err = ApplyMove(s, Move{Letter: g}) + if err != nil { + t.Fatal(err) + } + } + s, _, err = ApplyMove(s, Move{Solve: "insert coin"}) + if err != nil { + t.Fatal(err) + } + if s.Outcome != OutcomeSolved { + t.Fatalf("outcome = %s, want solved", s.Outcome) + } + if s.Payout != 208 { + t.Errorf("payout = %d, want 208 — solving pays the multiple you still hold, not the base", s.Payout) + } +} + +func TestSolveIgnoresCaseAndPunctuation(t *testing.T) { + s, _, err := start(100, tierShort(t), "How are you gentlemen!!", 0) + if err != nil { + t.Fatal(err) + } + s, _, err = ApplyMove(s, Move{Solve: "HOW ARE YOU GENTLEMEN"}) + if err != nil { + t.Fatal(err) + } + if s.Outcome != OutcomeSolved { + t.Errorf("outcome = %s — a solve shouldn't turn on shouting or the exclamation marks", s.Outcome) + } +} + +func TestWrongSolveCostsALife(t *testing.T) { + s, _, err := start(100, tierShort(t), "Insert Coin", 0.05) + if err != nil { + t.Fatal(err) + } + s, _, err = ApplyMove(s, Move{Solve: "insert quarter"}) + if err != nil { + t.Fatal(err) + } + if s.Lives() != MaxWrong-1 { + t.Errorf("lives = %d, want %d — a free solve is a solve you spam until it lands", s.Lives(), MaxWrong-1) + } + if s.Phase != PhasePlaying { + t.Errorf("phase = %s, want playing", s.Phase) + } +} + +func TestAWinNeverReturnsLessThanTheStake(t *testing.T) { + // Long pays 1.6, and five wrong guesses would take it to 0.8 — under water. + long, err := TierBySlug("long") + if err != nil { + t.Fatal(err) + } + s, _, err := start(100, long, "the quick brown fox jumps over the lazy dog and keeps going", 0.05) + if err != nil { + t.Fatal(err) + } + // Five clean misses. Every letter in the pangram is in the pangram, so the + // only things guaranteed to miss it are digits. + for _, g := range []string{"1", "2", "3", "4", "5"} { + s, _, err = ApplyMove(s, Move{Letter: g}) + if err != nil { + t.Fatal(err) + } + } + if got := s.Multiple(); got != 1 { + t.Fatalf("multiple = %v, want 1 — the floor", got) + } + s, _, err = ApplyMove(s, Move{Solve: "the quick brown fox jumps over the lazy dog and keeps going"}) + if err != nil { + t.Fatal(err) + } + if s.Payout != 100 { + t.Errorf("payout = %d, want 100 — a win hands back the stake at worst, never less", s.Payout) + } + if s.Rake != 0 { + t.Errorf("rake = %d, want 0 — there was no profit to take a cut of", s.Rake) + } +} + +func TestRakeComesOutOfWinningsNeverTheStake(t *testing.T) { + s := play(t, "Blue Shell", 100, 0.05, "b", "l", "u", "e", "s", "h") + // Total 260, profit 160, rake 5% = 8, so 252 comes back. + if s.Rake != 8 { + t.Errorf("rake = %d, want 8 (5%% of the 160 profit)", s.Rake) + } + if s.Payout != 252 { + t.Errorf("payout = %d, want 252", s.Payout) + } + if s.Payout < s.Bet { + t.Error("the rake ate into the stake") + } +} + +func TestWhatTheFeltQuotesIsWhatTheHousePays(t *testing.T) { + // The table shows Pays() while the game is still running. If that number and + // the one settle() lands on ever came apart, the felt would be advertising a + // payout the house doesn't honour. Walk a game and check they agree at every + // step — including after a miss, which is where they'd drift. + for _, wrong := range []int{0, 1, 2, 3} { + s, _, err := start(200, tierShort(t), "Blue Shell", 0.05) + if err != nil { + t.Fatal(err) + } + misses := []string{"z", "x", "q", "y"} + for i := 0; i < wrong; i++ { + s, _, err = ApplyMove(s, Move{Letter: misses[i]}) + if err != nil { + t.Fatal(err) + } + } + quoted := s.Pays() // what the felt is telling the player right now + + s, _, err = ApplyMove(s, Move{Solve: "blue shell"}) + if err != nil { + t.Fatal(err) + } + if s.Payout != quoted { + t.Errorf("%d wrong: felt quoted %d, house paid %d", wrong, quoted, s.Payout) + } + if s.Payout != s.Bet+s.Net() { + t.Errorf("%d wrong: payout %d doesn't square with net %d on a %d bet", wrong, s.Payout, s.Net(), s.Bet) + } + } +} + +func TestMoveOnAFinishedGameIsRefused(t *testing.T) { + s := play(t, "Blue Shell", 100, 0.05, "b", "l", "u", "e", "s", "h") + if _, _, err := ApplyMove(s, Move{Letter: "z"}); err != ErrGameOver { + t.Errorf("err = %v, want ErrGameOver", err) + } +} + +func TestGarbageGuessesAreRefused(t *testing.T) { + s, _, err := start(100, tierShort(t), "Insert Coin", 0.05) + if err != nil { + t.Fatal(err) + } + for _, m := range []Move{{Letter: "ab"}, {Letter: "!"}, {Letter: " "}, {}} { + if _, _, err := ApplyMove(s, m); err == nil { + t.Errorf("move %+v was accepted", m) + } + } +} + +func TestApplyMoveDoesNotTouchTheCallersState(t *testing.T) { + s, _, err := start(100, tierShort(t), "Insert Coin", 0.05) + if err != nil { + t.Fatal(err) + } + before := s.Masked() + if _, _, err := ApplyMove(s, Move{Letter: "i"}); err != nil { + t.Fatal(err) + } + if s.Masked() != before { + t.Errorf("applying a move mutated the state it was given: %q -> %q", before, s.Masked()) + } + if len(s.Tried) != 0 { + t.Errorf("tried = %v, want empty — the caller's state was written through", s.Tried) + } +} + +func TestStateSurvivesASerializationRoundTrip(t *testing.T) { + // A redeploy mid-game is a JSON round-trip. It has to come back playable. + s := play(t, "Insert Coin", 100, 0.05, "i", "z") + blob, err := json.Marshal(s) + if err != nil { + t.Fatal(err) + } + var back State + if err := json.Unmarshal(blob, &back); err != nil { + t.Fatal(err) + } + if back.Masked() != s.Masked() || back.Lives() != s.Lives() || back.Multiple() != s.Multiple() { + t.Fatalf("round trip changed the game: %+v", back) + } + next, _, err := ApplyMove(back, Move{Solve: "insert coin"}) + if err != nil { + t.Fatal(err) + } + if next.Outcome != OutcomeSolved { + t.Errorf("a game restored from JSON couldn't be finished: %s", next.Outcome) + } +} + +func TestNewIsReproducibleFromItsSeed(t *testing.T) { + // The seed is in the audit log so a disputed game can be replayed. That is + // only true if the phrase comes back the same. + one, _, err := New(100, tierShort(t), 0.05, rand.New(rand.NewPCG(7, 9))) + if err != nil { + t.Fatal(err) + } + two, _, err := New(100, tierShort(t), 0.05, rand.New(rand.NewPCG(7, 9))) + if err != nil { + t.Fatal(err) + } + if one.Phrase != two.Phrase { + t.Errorf("same seed dealt different phrases: %q vs %q", one.Phrase, two.Phrase) + } +} + +func TestNewDrawsFromTheRightShelf(t *testing.T) { + rng := rand.New(rand.NewPCG(1, 2)) + for _, tr := range Tiers { + for i := 0; i < 50; i++ { + s, _, err := New(100, tr, 0.05, rng) + if err != nil { + t.Fatalf("%s: %v", tr.Slug, err) + } + if n := len([]rune(s.Phrase)); n < tr.Min || n > tr.Max { + t.Fatalf("%s drew %q (%d chars), outside %d-%d", tr.Slug, s.Phrase, n, tr.Min, tr.Max) + } + } + } +} + +func TestEveryTierHasAShelfWorthPlaying(t *testing.T) { + // If someone edits phrases.txt and empties a tier, the game 500s at the + // table rather than here. Catch it here. + for _, tr := range Tiers { + if n := Shelf(tr.Slug); n < 20 { + t.Errorf("tier %s has %d phrases — too few to not repeat", tr.Slug, n) + } + } +} diff --git a/internal/games/hangman/phrases.go b/internal/games/hangman/phrases.go new file mode 100644 index 0000000..c63a117 --- /dev/null +++ b/internal/games/hangman/phrases.go @@ -0,0 +1,68 @@ +package hangman + +import ( + "bufio" + _ "embed" + "errors" + "math/rand/v2" + "strings" + "sync" +) + +// The bank. gogobee kept its phrases in a file it read at boot out of a path in +// an env var, which meant the game was one missing file away from not existing. +// Embedding it means the casino cannot start without its phrases, which is the +// correct relationship between a game and the thing it is about. +// +//go:embed phrases.txt +var phrasesTxt string + +// ErrNoPhrases means the bank has nothing at this length. It can only happen if +// someone edits phrases.txt down past a tier, and it is a programming error +// rather than anything a player did — but it's an error, not a panic, because a +// casino that won't boot is worse than one game being shut. +var ErrNoPhrases = errors.New("hangman: no phrases in that tier") + +var ( + shelvesOnce sync.Once + shelves map[string][]string // tier slug -> the phrases that fit it +) + +// load sorts the bank onto one shelf per tier, once. Comments and blank lines +// are dropped, and so is anything too short to be a game — the tiers' own Min +// is the floor. +func load() { + shelves = make(map[string][]string, len(Tiers)) + sc := bufio.NewScanner(strings.NewReader(phrasesTxt)) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + n := len([]rune(line)) + for _, t := range Tiers { + if n >= t.Min && n <= t.Max { + shelves[t.Slug] = append(shelves[t.Slug], line) + break + } + } + } +} + +// Shelf is how many phrases a tier has. Exists so a test can assert the bank +// hasn't been edited out from under a tier. +func Shelf(slug string) int { + shelvesOnce.Do(load) + return len(shelves[slug]) +} + +// drawPhrase picks one phrase from a tier's shelf. The rng is threaded, never +// the package global, so a game replays exactly from the seed in its audit row. +func drawPhrase(t Tier, rng *rand.Rand) (string, error) { + shelvesOnce.Do(load) + shelf := shelves[t.Slug] + if len(shelf) == 0 { + return "", ErrNoPhrases + } + return shelf[rng.IntN(len(shelf))], nil +} diff --git a/internal/games/hangman/phrases.txt b/internal/games/hangman/phrases.txt new file mode 100644 index 0000000..933ce14 --- /dev/null +++ b/internal/games/hangman/phrases.txt @@ -0,0 +1,237 @@ +# GogoBee Hangman Seed Phrases -- Video Game Edition +# +# Tiers are assigned automatically by character count at load time. +# Section headers below are for human readability only -- the bot ignores them. +# +# Easy: 8-20 characters +# Medium: 21-40 characters +# Hard: 41-80 characters +# +# Add community phrases via: !hangman submit [phrase] +# All submissions require LLM approval before entering the pool. +# This file can be edited directly. Bot reloads on restart. + +# --------------------------------------------------------------------------- +# EASY (8-20 characters) +# --------------------------------------------------------------------------- + +Finish Him! +Game Over +Insert Coin +Hadouken! +Fatality! +Big Boss +Konami Code +Warp Zone +Blue Shell +God Mode +BFG 9000 +Cacodemon +Quad Damage +Samus Aran +Morph Ball +Mother Brain +Dracula! +Simon Belmont +Ecclesia +Outer Heaven +Spread Gun +Power Pellet +Checkpoint +Rocket Jump +Mushroom Kingdom +Princess Peach +Bowser's Castle +Fire Flower +Varia Suit +Space Jump +Vampire Killer +Holy Water +Trevor Belmont +Soma Cruz +Julius Belmont +Waluigi! +Richter! +Phantoon +Speed Run +High Score +Continue? +Press Start +Ryu Hayabusa +Plasma Gun +What is a man? +Serious Sam +Shoryuken! +Duck Hunt +The cake is a lie +War never changes +Would you kindly? +Do a barrel roll! +Praise the Sun! +A winner is you +You're pretty good +La-Li-Lu-Le-Lo +I am error +Leeroy Jenkins! +For the Horde! +For the Alliance! +Frostmourne hungers +Falcon Punch! +Rip and tear! +Vic Viper +Salamander! +Parodius Da! +We'll bang, okay? +What you say!! +You spoony bard! +One-Winged Angel +Morning Star +Glyph Union +TwinBee, scramble! + +# --------------------------------------------------------------------------- +# MEDIUM (21-40 characters) +# --------------------------------------------------------------------------- + +Stay a while and listen +It's dangerous to go alone! +Kept you waiting, huh? +A man chooses, a slave obeys +Metal Gear?! Metal Gear!! +We're not tools of the government +The winds of destruction +Who are the Patriots? +This is good, isn't it? +You have died of dysentery +The Triforce of Courage +Ganon has broken the seal! +May the wind guide you home +Dodongo dislikes smoke +The right man in the wrong place +Nothing is true, everything is permitted +I used to be an adventurer like you +You can't hide from the Grim Reaper +All your base are belong to us! +Somebody set up us the bomb! +For great justice, take off every Zig! +How are you gentlemen!! +Kain has betrayed us! +Garland will knock you all down! +You are not prepared! +I am Uther the Lightbringer! +Order of Ecclesia calls +The Dark Lord rises again! +Shanoa, bearer of glyphs +In this world, it's kill or be killed +Despite everything, it's still you +The Underground is your home now +Papyrus demands a battle! +I'm going to make spaghetti! +Toriel will protect you +Estus Flask replenished +The age of fire fades +Prepare to die, undead one +Can't let you do that, Star Fox! +Andross' empire spans the Lylat system! +Captain Falcon, show me your moves! +OBJECTION! That testimony is a lie! +Hold it! I have new evidence! +Phoenix Wright, attorney at law! +Does this unit have a soul? +Shepard, the Reapers are coming! +Tali'Zorah vas Normandy! +Gruntilda shall not be defeated! +K. Rool has stolen the banana hoard! +Kirby, hero of Dream Land! +Meta Knight awaits your challenge! +Congraturation! This story is happy end. +Cecil has become a Paladin +Cloud Strife, SOLDIER First Class +Time compression is inevitable +You require more vespene gas +Nuclear launch detected +You must construct additional pylons +I'm Commander Shepard! +War... War has changed. +Rip and tear until it is done! +Dawn of Sorrow awaits +Do you feel like a hero yet? +You're a monster. You know that, Walker? +Halo... it's not a natural formation. +Whip it good, Belmont! + +# --------------------------------------------------------------------------- +# HARD (41-80 characters) +# --------------------------------------------------------------------------- + +What is a man? A miserable pile of secrets! +Die monster! You don't belong in this world! +My name is Dracula, and I bid you welcome. +You have no chance to survive make your time! +You've met with a terrible fate, haven't you? +Fear the old blood... and welcome, good hunter. +Welcome to the Liandri Grand Tournament! +I am the very model of a scientist Salarian! +Metroid Prime has escaped into the impact crater! +I want to be the very best, like no one ever was +The world needs only one Big Boss... and one Snake. +Snake, do you think love can bloom on a battlefield? +Thank you Mario, but our princess is in another castle! +You must gather your party before venturing forth +Snake, we're not tools of the government, or anyone else +Did I ever tell you what the definition of insanity is? +They're everywhere! The demons... they won't stop coming! +Dracula! Your time has come! The Vampire Killer strikes! +Link... I'm Navi, your fairy companion! Listen! +Hero of Time, your destiny awaits in the Sacred Realm +Master Chief, finish the fight. Earth is counting on you. +Outer Heaven... a place where warriors can find purpose +We passed the point of no return a long time ago, Snake +Liquid! I was the one who was meant to be the successor! +Revolver Ocelot is a triple agent working for the Patriots +Phazon corruption detected! Seek immediate medical attention! +Dark Samus has absorbed the Phazon and grown more powerful! +I'm the Doom Slayer, and I'm here to kill every last one of you! +Praise the Chosen Undead, for they shall link the fire! +Ganon is the evil king who stole the Triforce of Power! +The legendary soldier who defied his genes... Big Boss +Killing spree! Monster kill! Godlike! Unstoppable! +Humanity restored! The bonfire blazes with newfound strength! +You are the last line of defense against an infinite demonic army +Liquid Snake... your dominant genes... give you the edge in battle! +All we did was give meaning to the nuclear age by using nukes! +I'm a soldier who's been betrayed, abandoned... I fight alone now +Samus Aran, the last of the Chozo warriors, descends into the unknown +You are the Chosen Undead, fated to succeed where so many have failed +The price of living in the past is a slow death in the present +A sword wields no strength unless the hands that hold it have courage +The flow of time is always cruel, its speed seems different for each person +Hey! Listen! There's something important you need to know! +We are born of the blood, made men by the blood, undone by the blood +War has changed. It's no longer about nations, ideologies, or ethnicity +Mental has sent his armies, and I am all that stands between them and Earth! +I'm no hero. Never was. Never will be. I'm just an old killer. +What is a man? A miserable pile of secrets! But enough talk... have at you! +I used to be an adventurer like you, then I took an arrow in the knee +I'm not the only one who's responsible. The humans are just as guilty! +The Skaarj have invaded, and you must fight your way to freedom + +# --------------------------------------------------------------------------- +# EXTREME (81+ characters) -- Full quotes. No mercy. +# --------------------------------------------------------------------------- + +For you, the day Bison graced your village was the most important day of your life. But for me... it was Tuesday. +What is a man? A miserable pile of secrets! But enough talk... have at you! +Die monster! You don't belong in this world! It was not by my hand that I am once again given flesh! +You can't keep a good man down, and that goes double for a soldier who's been fighting his whole life. +They say the definition of insanity is doing the same thing over and over and expecting different results... did I ever tell you that? +I've been waiting for this... a professional killer. I'm so excited I may be sick! +Kept you waiting, huh? Don't worry though. I'll take you somewhere warm. Back to the battlefield. +It's easy to forget what a sin is in the middle of a battlefield. Especially when you're killing to stay alive. +We are not tools of the government or anyone else. Fighting was the only thing, the only thing I was good at. But at least I always fought for what I believed in. +In the 21st century, the battlefield will once again be a symbol of the glory of nations. I am a weapon. A human weapon. +A man's dreams can be his greatest asset... or his most dangerous enemy. The question is: what are you willing to sacrifice to see them through? +I need scissors! 61! +Outer Heaven, a place where soldiers need not justify their actions -- where warriors can be free of political manipulation. +Nothing happened to me. I happened. I'm the one who knocks, the one who causes all the trouble... that is my purpose. +Sun Tzu said that. I think he meant it as a metaphor, but he also said never leave home without a healthy supply of rations, so what does he know? diff --git a/internal/web/devcasino_test.go b/internal/web/devcasino_test.go index 9cfe638..61b3ffa 100644 --- a/internal/web/devcasino_test.go +++ b/internal/web/devcasino_test.go @@ -44,13 +44,7 @@ func TestDevCasino(t *testing.T) { mux := http.NewServeMux() mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub)))) - mux.HandleFunc("GET /games", s.handleLobby) - mux.HandleFunc("GET /games/blackjack", s.handleBlackjack) - mux.HandleFunc("GET /api/games/table", s.handleTable) - mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn) - mux.HandleFunc("POST /api/games/cashout", s.handleCashOut) - mux.HandleFunc("POST /api/games/blackjack/deal", s.handleDeal) - mux.HandleFunc("POST /api/games/blackjack/move", s.handleMove) + s.casinoRoutes(mux) ln, err := net.Listen("tcp", addr) if err != nil { @@ -63,7 +57,7 @@ func TestDevCasino(t *testing.T) { t.Fatal(err) } } - fmt.Printf("\nCASINO http://localhost%s/games/blackjack\nCOOKIE %s=%s\n\n", addr, sessionCookie, cookie) + fmt.Printf("\nCASINO http://localhost%s/games\nCOOKIE %s=%s\n\n", addr, sessionCookie, cookie) srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second} t.Cleanup(func() { _ = srv.Close() }) diff --git a/internal/web/games_hangman.go b/internal/web/games_hangman.go new file mode 100644 index 0000000..4d8e8f3 --- /dev/null +++ b/internal/web/games_hangman.go @@ -0,0 +1,217 @@ +package web + +import ( + "encoding/json" + "errors" + "log/slog" + "math/rand/v2" + "net/http" + + "pete/internal/games/blackjack" + "pete/internal/games/hangman" + "pete/internal/storage" +) + +// Hangman, played for chips. +// +// The same shape as the blackjack table: 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 *masked* phrase. The unmasked one is in the engine +// state, which is in game_live_hands, which is on this side of the wire — a +// phrase sent down and flagged hidden is a phrase read out of devtools, and the +// game would be a formality. + +// cellView is one position in the phrase, as the browser draws it. +// +// Ch is empty while the letter is hidden — not the letter with a flag beside +// it. Slot says whether this is a position you'd guess at all: a space or an +// exclamation mark is scaffolding, shows from the start, and gets no tile. +type cellView struct { + Ch string `json:"ch"` + Slot bool `json:"slot"` +} + +// hangmanView is a game as its player may see it. +type hangmanView struct { + Tier hangman.Tier `json:"tier"` + Cells []cellView `json:"cells"` + Tried []string `json:"tried"` // every letter guessed, right or wrong + Wrong []string `json:"wrong"` // just the misses — the gallows counts these + Lives int `json:"lives"` + MaxWrong int `json:"max_wrong"` + Multiple float64 `json:"multiple"` // what a win is worth right now + Bet int64 `json:"bet"` + Stands int64 `json:"stands"` // what the player would actually be paid if they won now + + Phase string `json:"phase"` + Outcome string `json:"outcome,omitempty"` + Phrase string `json:"phrase,omitempty"` // only once it's over + Payout int64 `json:"payout,omitempty"` + Rake int64 `json:"rake,omitempty"` + Net int64 `json:"net"` +} + +func viewHangman(g hangman.State) hangmanView { + v := hangmanView{ + Tier: g.Tier, + Lives: g.Lives(), + MaxWrong: hangman.MaxWrong, + Multiple: g.Multiple(), + Bet: g.Bet, + // What the player would actually collect, rake already taken out. Quoting + // the pre-rake figure here would have the felt advertising a payout the + // house doesn't hand over. + Stands: g.Pays(), + Phase: string(g.Phase), + Outcome: string(g.Outcome), + Payout: g.Payout, + Rake: g.Rake, + Net: g.Net(), + } + for i, r := range g.Runes { + c := cellView{Slot: isSlot(r)} + if i < len(g.Shown) && g.Shown[i] { + c.Ch = string(r) + } + v.Cells = append(v.Cells, c) + } + for _, r := range g.Tried { + v.Tried = append(v.Tried, string(r)) + } + for _, r := range g.Wrong { + v.Wrong = append(v.Wrong, string(r)) + } + // The phrase goes over the wire exactly once: when the game is over and it no + // longer decides anything. + if g.Phase == hangman.PhaseDone { + v.Phrase = g.Phrase + } + return v +} + +// isSlot mirrors the engine's isGuessable — a rune you'd guess gets a tile to +// guess it into. Kept here rather than exported from the engine because it is a +// question about drawing, and the engine doesn't draw. +func isSlot(r rune) bool { + return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') +} + +// handleHangmanStart takes the bet and draws a phrase. Same order as a deal: +// 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) handleHangmanStart(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 := hangman.TierBySlug(req.Tier) + if err != nil { + writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a length"}) + 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: hangman stake", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + seed1, seed2 := newSeeds() + rng := rand.New(rand.NewPCG(seed1, seed2)) + g, evs, err := hangman.New(req.Bet, tier, blackjack.DefaultRules().RakePct, rng) + if err != nil { + // The game never happened, so the stake never should have left. + _ = storage.Award(user, req.Bet) + slog.Error("games: hangman start", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + s.persistHangman(w, user, g, evs, seed1, seed2, true) +} + +// handleHangmanGuess plays one guess: a letter, or the whole phrase. +func (s *Server) handleHangmanGuess(w http.ResponseWriter, r *http.Request) { + user, ok := s.player(w, r) + if !ok { + return + } + var move hangman.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: hangman load", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if live.Game != gameHangman { + writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the hand you're in first"}) + return + } + var g hangman.State + if err := json.Unmarshal(live.State, &g); err != nil { + slog.Error("games: unreadable hangman game", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + next, evs, err := hangman.ApplyMove(g, move) + if err != nil { + // A letter already tried is the one illegal move a player makes by + // accident rather than by trying it on, so it gets its own answer. + msg := "that guess isn't legal here" + if errors.Is(err, hangman.ErrAlreadyTried) { + msg = "you've already tried that one" + } + writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg}) + return + } + s.persistHangman(w, user, next, evs, live.Seed1, live.Seed2, false) +} + +// persistHangman writes the game back and answers the browser. +func (s *Server) persistHangman(w http.ResponseWriter, user string, g hangman.State, evs []hangman.Event, seed1, seed2 uint64, fresh bool) { + blob, err := json.Marshal(g) + if err != nil { + slog.Error("games: marshal hangman", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + done := g.Phase == hangman.PhaseDone + v, ok := s.commit(w, user, finished{ + Game: gameHangman, 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 reveal the phrase onto. + if done { + hv := viewHangman(g) + v.Hangman = &hv + } + v.HangEvents = evs + writeJSON(w, v) +} diff --git a/internal/web/games_hangman_test.go b/internal/web/games_hangman_test.go new file mode 100644 index 0000000..dbc297b --- /dev/null +++ b/internal/web/games_hangman_test.go @@ -0,0 +1,188 @@ +package web + +import ( + "strings" + "testing" + + "pete/internal/storage" +) + +// The one thing this table cannot get wrong: the stake leaves the stack, and the +// phrase does not leave the server. +func TestHangmanStartTakesTheStakeAndKeepsThePhrase(t *testing.T) { + s := newCasino(t) + fund(t, 1000) + + v, code := call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start", + map[string]any{"bet": 100, "tier": "short"})) + if code != 200 { + t.Fatalf("start = %d, want 200", code) + } + if v.Chips != 900 { + t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips) + } + if v.Hangman == nil { + t.Fatal("start returned no game") + } + if v.Game != gameHangman { + t.Errorf("game = %q, want hangman", v.Game) + } + if v.Hangman.Phrase != "" { + t.Fatalf("the phrase was sent to the browser before it was won: %q", v.Hangman.Phrase) + } + // Nothing is revealed at the start except the scaffolding, and a space is not + // a letter you have to earn. + for _, c := range v.Hangman.Cells { + if c.Slot && c.Ch != "" { + t.Fatalf("a letter was face up before it was guessed: %+v", c) + } + } + if v.Hangman.Lives != 6 { + t.Errorf("lives = %d, want 6", v.Hangman.Lives) + } +} + +// A win pays what the felt said it would, and the rake comes out of the winnings. +func TestHangmanWinPaysWhatTheFeltQuoted(t *testing.T) { + s := newCasino(t) + fund(t, 1000) + + v, _ := call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start", + map[string]any{"bet": 100, "tier": "short"})) + quoted := v.Hangman.Stands + + // The server holds the phrase, so read it out of the live row — which is the + // only place it exists — and solve it. + phrase := livePhrase(t) + v, code := call(t, s, s.handleHangmanGuess, as(t, s, "reala", "POST", "/api/games/hangman/guess", + map[string]string{"solve": phrase})) + if code != 200 { + t.Fatalf("solve = %d, want 200", code) + } + if v.Hangman.Outcome != "solved" { + t.Fatalf("outcome = %q, want solved", v.Hangman.Outcome) + } + // No wrong guesses, so the full 2.6×: 260 gross, 160 profit, 8 rake, 252 back. + if v.Hangman.Payout != quoted { + t.Errorf("felt quoted %d, house paid %d", quoted, v.Hangman.Payout) + } + if v.Hangman.Payout != 252 || v.Hangman.Rake != 8 { + t.Errorf("payout/rake = %d/%d, want 252/8", v.Hangman.Payout, v.Hangman.Rake) + } + if got := chipsNow(t); got != 900+252 { + t.Errorf("chips = %d, want %d", got, 900+252) + } + // And the phrase is finally allowed out, now that it decides nothing. + if v.Hangman.Phrase == "" { + t.Error("a finished game never told the player what the phrase was") + } + // The game is off the felt. + if _, err := storage.LoadLiveHand(testPlayer); err == nil { + t.Error("a settled game is still sitting in game_live_hands") + } +} + +// Six wrong guesses take the stake and nothing more. +func TestHangmanHangingCostsExactlyTheStake(t *testing.T) { + s := newCasino(t) + fund(t, 1000) + + call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start", + map[string]any{"bet": 100, "tier": "short"})) + + // Six solves that are certainly wrong — a wrong solve costs a life, same as a + // wrong letter, and this needs no knowledge of the phrase. + var v tableView + for i := 0; i < 6; i++ { + v, _ = call(t, s, s.handleHangmanGuess, as(t, s, "reala", "POST", "/api/games/hangman/guess", + map[string]string{"solve": "definitely not the phrase at all"})) + } + if v.Hangman == nil || v.Hangman.Outcome != "hung" { + t.Fatalf("outcome = %+v, want hung", v.Hangman) + } + if v.Hangman.Payout != 0 { + t.Errorf("payout = %d, want 0", v.Hangman.Payout) + } + if got := chipsNow(t); got != 900 { + t.Errorf("chips = %d, want 900 — a loss costs the stake and no more", got) + } + if v.Hangman.Phrase == "" { + t.Error("hung without being told the answer") + } +} + +// One game at a time, across games: you cannot walk from a hangman into a hand of +// blackjack with chips still riding on a phrase. +func TestHangmanHoldsTheSeatAgainstBlackjack(t *testing.T) { + s := newCasino(t) + fund(t, 1000) + + call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start", + map[string]any{"bet": 100, "tier": "short"})) + + _, code := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/api/games/blackjack/deal", + map[string]int64{"bet": 100})) + if code != 409 { + t.Fatalf("dealt blackjack on top of a live hangman: %d, want 409", code) + } + // And the stake that was refused came back: 1000 - 100 (the hangman) and not a + // chip more. + if got := chipsNow(t); got != 900 { + t.Errorf("chips = %d, want 900 — the refused deal kept the stake", got) + } + if _, err := storage.LoadLiveHand(testPlayer); err != nil { + t.Errorf("the hangman was evicted by the deal it refused: %v", err) + } +} + +// Cashing out mid-phrase is refused, for the same reason as mid-hand. +func TestCannotCashOutMidPhrase(t *testing.T) { + s := newCasino(t) + fund(t, 1000) + + call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start", + map[string]any{"bet": 100, "tier": "short"})) + + _, code := call(t, s, s.handleCashOut, as(t, s, "reala", "POST", "/api/games/cashout", + map[string]int64{"amount": 0})) + if code != 409 { + t.Fatalf("cash-out mid-phrase = %d, want 409", code) + } +} + +// A tier the browser made up is refused, and costs nothing. +func TestHangmanRefusesAnInventedTier(t *testing.T) { + s := newCasino(t) + fund(t, 1000) + + _, code := call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start", + map[string]any{"bet": 100, "tier": "impossible"})) + if code != 400 { + t.Fatalf("start on a made-up tier = %d, want 400", code) + } + if got := chipsNow(t); got != 1000 { + t.Errorf("chips = %d, want 1000 — a refused game must not take a stake", got) + } +} + +// livePhrase digs the phrase out of the live row. Only a test may do this: it is +// reaching past the wire on purpose, to prove the wire doesn't carry it. +func livePhrase(t *testing.T) string { + t.Helper() + live, err := storage.LoadLiveHand(testPlayer) + if err != nil { + t.Fatal(err) + } + blob := string(live.State) + const key = `"phrase":"` + i := strings.Index(blob, key) + if i < 0 { + t.Fatalf("no phrase in the live row: %s", blob) + } + rest := blob[i+len(key):] + j := strings.Index(rest, `"`) + if j < 0 { + t.Fatal("unterminated phrase in the live row") + } + return rest[:j] +} diff --git a/internal/web/games_pages.go b/internal/web/games_pages.go index 1288692..88f335a 100644 --- a/internal/web/games_pages.go +++ b/internal/web/games_pages.go @@ -5,6 +5,7 @@ import ( "time" "pete/internal/games/blackjack" + "pete/internal/games/hangman" "pete/internal/storage" ) @@ -28,7 +29,6 @@ var comingSoon = []gameTeaser{ {Name: "Hold'em", Emoji: "♠️", Blurb: "Six seats, and the house bots know how to play."}, {Name: "UNO", Emoji: "🎴", Blurb: "Normal rules, or no mercy."}, {Name: "Trivia", Emoji: "🧠", Blurb: "Faster answers score higher."}, - {Name: "Hangman", Emoji: "🪢", Blurb: "Guess the phrase before the gallows finish."}, } // betDenominations are the chips you build a bet out of. @@ -70,6 +70,30 @@ type gamesPage struct { RakePct int Soon []gameTeaser Denominations []int64 + Tiers []hangman.Tier // hangman's three lengths, and what each pays + MaxWrong int +} + +// casinoRoutes hangs every table off the mux. +// +// It exists so there is exactly one list of them. The dev rig (devcasino_test.go) +// has to wire its own mux — New() decides whether the casino exists before the +// rig has signed anybody in — and a second copy of this list is a list that +// silently stops including the newest game. +func (s *Server) casinoRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /games", s.handleLobby) + mux.HandleFunc("GET /games/blackjack", s.handleBlackjack) + mux.HandleFunc("GET /games/hangman", s.handleHangman) + + mux.HandleFunc("GET /api/games/table", s.handleTable) + mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn) + mux.HandleFunc("POST /api/games/cashout", s.handleCashOut) + + mux.HandleFunc("POST /api/games/blackjack/deal", s.handleDeal) + mux.HandleFunc("POST /api/games/blackjack/move", s.handleMove) + + mux.HandleFunc("POST /api/games/hangman/start", s.handleHangmanStart) + mux.HandleFunc("POST /api/games/hangman/guess", s.handleHangmanGuess) } // requirePlayer sends an anonymous visitor to sign in and comes back here after. @@ -96,6 +120,8 @@ func (s *Server) gamesPage(r *http.Request) gamesPage { RakePct: int(blackjack.DefaultRules().RakePct * 100), Soon: comingSoon, Denominations: betDenominations, + Tiers: hangman.Tiers, + MaxWrong: hangman.MaxWrong, } } @@ -112,3 +138,10 @@ func (s *Server) handleBlackjack(w http.ResponseWriter, r *http.Request) { } s.render(w, "blackjack", s.gamesPage(r)) } + +func (s *Server) handleHangman(w http.ResponseWriter, r *http.Request) { + if !s.requirePlayer(w, r) { + return + } + s.render(w, "hangman", s.gamesPage(r)) +} diff --git a/internal/web/games_play.go b/internal/web/games_play.go index 7757252..d3961ab 100644 --- a/internal/web/games_play.go +++ b/internal/web/games_play.go @@ -3,6 +3,7 @@ package web import ( "encoding/json" "errors" + "fmt" "io" "log/slog" "math/rand/v2" @@ -11,6 +12,7 @@ import ( "pete/internal/games/blackjack" "pete/internal/games/cards" + "pete/internal/games/hangman" "pete/internal/storage" ) @@ -162,18 +164,31 @@ func viewEvents(evs []blackjack.Event, phase blackjack.Phase) []eventView { return out } -// tableView is the whole page state: the money, and the hand if there is one. +// tableView is the whole page state: the money, and whatever game is in progress. +// +// A player is in at most one game at a time — game_live_hands is keyed on the +// player, so the primary key enforces it — and Game says which. Each game gets +// its own field rather than a shared blob, because a hangman phrase and a +// blackjack shoe have nothing in common and pretending otherwise would mean a +// browser that has to guess what it's holding. type tableView struct { - Chips int64 `json:"chips"` - Pending int64 `json:"pending"` // buy-ins gogobee hasn't answered yet - Euros float64 `json:"euros"` // advisory, and up to a couple of minutes stale - Cap int64 `json:"cap"` - Hand *handView `json:"hand,omitempty"` - Events []eventView `json:"events,omitempty"` // only on a move, for the animation - Rake float64 `json:"rake_pct"` + Chips int64 `json:"chips"` + Pending int64 `json:"pending"` // buy-ins gogobee hasn't answered yet + Euros float64 `json:"euros"` // advisory, and up to a couple of minutes stale + Cap int64 `json:"cap"` + + Game string `json:"game,omitempty"` // "blackjack" | "hangman", if one is live + + Hand *handView `json:"hand,omitempty"` // blackjack + Events []eventView `json:"events,omitempty"` // blackjack, only on a move + + Hangman *hangmanView `json:"hangman,omitempty"` + HangEvents []hangman.Event `json:"hang_events,omitempty"` + + Rake float64 `json:"rake_pct"` } -// table reads the player's money and any hand in progress. +// table reads the player's money and any game in progress. func (s *Server) table(user string) (tableView, error) { st, err := storage.Chips(user) if err != nil { @@ -193,16 +208,40 @@ func (s *Server) table(user string) (tableView, error) { if err != nil { return tableView{}, err } - var hand blackjack.State - if err := json.Unmarshal(live.State, &hand); err != nil { - // A hand we can't read is a hand nobody can play. Rather than wedge the - // player out of the casino forever, drop it and tell them. - slog.Error("games: unreadable live hand, discarding", "user", user, "err", err) - _ = storage.ClearLiveHand(user) - return v, nil + + // Dispatch on the game the row says it is. Unmarshalling a hangman state into + // a blackjack one would not fail — JSON is happy to fill nothing in — it would + // just quietly produce an empty hand, which is the worst of both. + v.Game = live.Game + switch live.Game { + case gameBlackjack: + var hand blackjack.State + if err := json.Unmarshal(live.State, &hand); err != nil { + return s.dropUnreadable(user, v, err) + } + hv := viewHand(hand) + v.Hand = &hv + case gameHangman: + var g hangman.State + if err := json.Unmarshal(live.State, &g); err != nil { + return s.dropUnreadable(user, v, err) + } + hv := viewHangman(g) + v.Hangman = &hv + default: + return s.dropUnreadable(user, v, fmt.Errorf("unknown game %q", live.Game)) } - hv := viewHand(hand) - v.Hand = &hv + return v, nil +} + +// dropUnreadable throws away a live game nobody can play. Rather than wedge the +// player out of the casino forever, it goes, and their stake with it — which is +// why it is logged loudly. The alternative is a player who can never be dealt +// another hand because an old one won't parse. +func (s *Server) dropUnreadable(user string, v tableView, err error) (tableView, error) { + slog.Error("games: unreadable live game, discarding", "user", user, "err", err) + _ = storage.ClearLiveHand(user) + v.Game = "" return v, nil } @@ -438,62 +477,76 @@ func (s *Server) handleMove(w http.ResponseWriter, r *http.Request) { s.persist(w, user, next, evs, live.Seed1, live.Seed2, false) } -// persist writes the hand back and answers the browser. A finished hand pays -// out, goes in the audit log, and leaves the felt; an unfinished one is saved -// as it stands, so a redeploy mid-hand is survivable. -// -// fresh marks a hand that has just been dealt, which is the one case where the -// write may be refused: the primary key, not an earlier read, is what enforces -// one hand at a time. A Deal that loses that race gets its stake back. -func (s *Server) persist(w http.ResponseWriter, user string, st blackjack.State, evs []blackjack.Event, seed1, seed2 uint64, fresh bool) { - blob, err := json.Marshal(st) - if err != nil { - slog.Error("games: marshal hand", "user", user, "err", err) - http.Error(w, "internal error", http.StatusInternalServerError) - return - } +// The games a live row can be. They're the storage key, so they're constants: +// a typo here is a game nobody can ever load again. +const ( + gameBlackjack = "blackjack" + gameHangman = "hangman" +) - // Seat the hand before doing anything else with it — even one that is already - // over, because a natural settles the instant it's dealt. The insert is what - // enforces one hand at a time, and it has to happen for *every* new hand: a - // natural dealt on top of a hand already in progress would otherwise settle, - // clear the felt, and take the other hand's stake down with it. - hand := storage.LiveHand{Game: "blackjack", State: blob, Seed1: seed1, Seed2: seed2} +// finished is what commit needs to know about a game it's writing back: enough +// to settle it, and nothing about how it's played. Both engines produce one. +type finished struct { + Game string + Blob []byte // the engine's whole state, shoe or phrase and all + Bet int64 + Payout int64 + Rake int64 + Outcome string + Done bool + Seed1 uint64 + Seed2 uint64 + Fresh bool // a game just started, which is the one write that may be refused +} + +// commit writes a game back and settles it if it's over. It is the money path, +// and both games go through it so that neither has to re-derive an ordering +// that took a while to get right. +// +// It returns the table as it now stands. ok is false when it has already +// written an error response and the caller must simply return. +func (s *Server) commit(w http.ResponseWriter, user string, f finished) (tableView, bool) { + // Seat the game before doing anything else with it — even one that is already + // over, because a blackjack natural settles the instant it's dealt. The insert + // is what enforces one game at a time, and it has to happen for *every* new + // one: a natural dealt on top of a game already in progress would otherwise + // settle, clear the felt, and take the other game's stake down with it. + live := storage.LiveHand{Game: f.Game, State: f.Blob, Seed1: f.Seed1, Seed2: f.Seed2} save := storage.SaveLiveHand - if fresh { + if f.Fresh { save = storage.StartLiveHand } - if err := save(user, hand); err != nil { + if err := save(user, live); err != nil { if errors.Is(err, storage.ErrHandInProgress) { - // Somebody was already sitting here. This hand was never seated, so the - // chips it staked go back: the player is in one hand, not two. - _ = storage.Award(user, st.Bet) - writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're already in a hand"}) - return + // Somebody was already sitting here. This game was never seated, so the + // chips it staked go back: the player is in one game, not two. + _ = storage.Award(user, f.Bet) + writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're already in a game"}) + return tableView{}, false } - slog.Error("games: save hand", "user", user, "err", err) + slog.Error("games: save game", "user", user, "game", f.Game, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) - return + return tableView{}, false } - if st.Phase == blackjack.PhaseDone { + if f.Done { // Pay first, then clear. If Pete dies between the two, the player has been - // paid and the worst case is a settled hand still showing on the felt — + // paid and the worst case is a settled game still showing on the felt — // which reads as done and can be cleared. The other order loses them a win. - if err := storage.Award(user, st.Payout); err != nil { - slog.Error("games: award", "user", user, "payout", st.Payout, "err", err) + if err := storage.Award(user, f.Payout); err != nil { + slog.Error("games: award", "user", user, "payout", f.Payout, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) - return + return tableView{}, false } if err := storage.RecordHand(storage.Hand{ - MatrixUser: user, Game: "blackjack", - Bet: st.Bet, Payout: st.Payout, Rake: st.Rake, - Outcome: string(st.Outcome), Seed1: seed1, Seed2: seed2, + MatrixUser: user, Game: f.Game, + Bet: f.Bet, Payout: f.Payout, Rake: f.Rake, + Outcome: f.Outcome, Seed1: f.Seed1, Seed2: f.Seed2, }); err != nil { - slog.Error("games: record hand", "user", user, "err", err) // audit only; don't fail the player's hand + slog.Error("games: record hand", "user", user, "err", err) // audit only; don't fail the player's game } if err := storage.ClearLiveHand(user); err != nil { - slog.Error("games: clear hand", "user", user, "err", err) + slog.Error("games: clear game", "user", user, "err", err) } } @@ -503,11 +556,32 @@ func (s *Server) persist(w http.ResponseWriter, user string, st blackjack.State, if err != nil { slog.Error("games: table", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) + return tableView{}, false + } + return v, true +} + +// persist writes a blackjack hand back and answers the browser. +func (s *Server) persist(w http.ResponseWriter, user string, st blackjack.State, evs []blackjack.Event, seed1, seed2 uint64, fresh bool) { + blob, err := json.Marshal(st) + if err != nil { + slog.Error("games: marshal hand", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + done := st.Phase == blackjack.PhaseDone + v, ok := s.commit(w, user, finished{ + Game: gameBlackjack, Blob: blob, + Bet: st.Bet, Payout: st.Payout, Rake: st.Rake, + Outcome: string(st.Outcome), Done: done, + Seed1: seed1, Seed2: seed2, Fresh: fresh, + }) + if !ok { return } // A settled hand is gone from storage, so the table view has no hand to show — // but the browser still needs the final cards to animate the reveal onto. - if st.Phase == blackjack.PhaseDone { + if done { hv := viewHand(st) v.Hand = &hv } diff --git a/internal/web/roster_test.go b/internal/web/roster_test.go index 6386dda..f9744b7 100644 --- a/internal/web/roster_test.go +++ b/internal/web/roster_test.go @@ -181,7 +181,7 @@ func TestAdventurePageRendersBoard(t *testing.T) { } body := w.Body.String() for _, want := range []string{ - "Out there right now", // the board's headline + "Out there right now", // the board's headline "Josie", "holymachina", "day 3", // live zone, shown while she's still in it "Quack", "in town", "quiet for 2 days", "/api/roster", // the client re-poll, so an open tab stays true diff --git a/internal/web/server.go b/internal/web/server.go index 48dac26..4330b2b 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"}}, + {"games_layout", []string{"_chipbar"}, []string{"games", "blackjack", "hangman"}}, } tpls := make(map[string]*template.Template) for _, set := range sets { @@ -232,13 +232,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo // auth block, and gamesReady() also insists on a Matrix server name: without // one, no player can be named to gogobee's ledger and the tables stay shut. if s.gamesReady() { - mux.HandleFunc("GET /games", s.handleLobby) - mux.HandleFunc("GET /games/blackjack", s.handleBlackjack) - mux.HandleFunc("GET /api/games/table", s.handleTable) - mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn) - mux.HandleFunc("POST /api/games/cashout", s.handleCashOut) - mux.HandleFunc("POST /api/games/blackjack/deal", s.handleDeal) - mux.HandleFunc("POST /api/games/blackjack/move", s.handleMove) + s.casinoRoutes(mux) } if s.auth != nil { diff --git a/internal/web/static/css/input.css b/internal/web/static/css/input.css index 27dff44..aef6d05 100644 --- a/internal/web/static/css/input.css +++ b/internal/web/static/css/input.css @@ -877,6 +877,207 @@ html[data-phase="night"] { 50% { opacity: 1; transform: translateY(-1px); } } + /* ---- hangman ------------------------------------------------------------- + The gallows is the meter: it counts down your lives and your winnings at the + same time. So a miss has to *land* — the limb draws itself in along its own + length, the whole thing flinches, and the multiple falls. Three parts of one + event, and the game is about watching it happen to you. */ + + .pete-gallows { + overflow: visible; + fill: none; + stroke-linecap: round; + stroke-linejoin: round; + } + .pete-gallows-frame path { + stroke: rgba(0, 0, 0, 0.35); + stroke-width: 6; + } + /* The rope. Same post, thinner, so it reads as something that would hold. */ + .pete-gallows-frame path:last-child { + stroke: rgba(0, 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, 0.25)); + } + .pete-gallows-body > [data-on="1"] { opacity: 1; } + + /* Drawn, not faded in: the stroke runs on along its own path. 260 is longer + than any limb here, which is all a dash offset needs to be. */ + .pete-part-draw { + stroke-dasharray: 260; + animation: pete-part 0.45s cubic-bezier(0.22, 1, 0.36, 1) backwards; + } + @keyframes pete-part { + from { stroke-dashoffset: 260; } + to { stroke-dashoffset: 0; } + } + + .pete-shake { animation: pete-shake 0.42s cubic-bezier(0.36, 0.07, 0.19, 0.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); } + } + + /* The phrase. Tiles wrap between words, never inside one. */ + .pete-board { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 1.1rem; + min-height: 5rem; + align-items: center; + } + .pete-word { + display: flex; + gap: 0.3rem; + } + + .pete-tile { + display: grid; + place-items: center; + height: 2.9rem; + width: 2.2rem; + border-radius: 0.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, 0.22); + border-bottom: 3px solid rgba(0, 0, 0, 0.28); + text-transform: uppercase; + } + /* A letter you've earned sits proud of the ones you haven't. */ + .pete-tile[data-up="1"] { + background: rgba(255, 255, 255, 0.95); + color: #2b2118; + border-bottom-color: rgba(0, 0, 0, 0.35); + } + /* Punctuation is scaffolding: it was never yours to guess, so it gets no tile + to guess it into. */ + .pete-tile[data-punct="1"] { + background: none; + border: 0; + width: auto; + min-width: 0.7rem; + color: rgba(255, 255, 255, 0.55); + } + .pete-tile-hit { animation: pete-tile-hit 0.34s cubic-bezier(0.34, 1.56, 0.64, 1); } + @keyframes pete-tile-hit { + 0% { transform: rotateX(90deg) scale(1.1); } + 100% { transform: rotateX(0) scale(1); } + } + + .pete-missed { + display: grid; + place-items: center; + height: 1.5rem; + min-width: 1.5rem; + padding: 0 0.3rem; + border-radius: 0.375rem; + background: rgba(0, 0, 0, 0.25); + font-size: 0.75rem; + font-weight: 700; + color: rgba(255, 255, 255, 0.5); + text-decoration: line-through; + text-transform: uppercase; + } + + /* What a win is worth, right now. */ + .pete-meter { + display: flex; + align-items: baseline; + gap: 0.5rem; + border-radius: 999px; + background: rgba(0, 0, 0, 0.28); + padding: 0.4rem 0.9rem; + box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.1); + transition: box-shadow 0.3s ease; + } + .pete-meter-label { + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: rgba(255, 255, 255, 0.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); + } + /* Down at the floor: a win now hands back the stake and nothing else. */ + .pete-meter[data-cold="1"] .pete-meter-value { color: rgba(255, 255, 255, 0.55); } + .pete-meter[data-hit="1"] { + box-shadow: inset 0 0 0 2px rgba(204, 61, 74, 0.9); + animation: pete-meter-hit 0.4s ease; + } + @keyframes pete-meter-hit { + 0% { transform: scale(1); } + 35% { transform: scale(0.94); } + 100% { transform: scale(1); } + } + + /* The keyboard. */ + .pete-keys { display: grid; gap: 0.35rem; } + .pete-key-row { + display: flex; + justify-content: center; + gap: 0.35rem; + } + .pete-key-row[data-digits="1"] { margin-top: 0.25rem; opacity: 0.75; } + .pete-key-row[data-digits="1"] .pete-key { + height: 2rem; + min-width: 1.8rem; + font-size: 0.8rem; + } + .pete-key { + height: 2.75rem; + min-width: 2.2rem; + flex: 0 1 2.4rem; + border-radius: 0.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 0.08s ease, background 0.15s ease, opacity 0.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(0.96); } + .pete-key:disabled { cursor: default; } + /* A key that's been spent looks spent — otherwise you spend it twice. */ + .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: 0.4; } + + /* Picking a length. The one you're on is lit. */ + .pete-tier { + background: color-mix(in srgb, var(--ink) 4%, transparent); + border-color: color-mix(in srgb, var(--ink) 10%, transparent); + } + .pete-tier:hover { background: color-mix(in srgb, var(--ink) 8%, transparent); } + .pete-tier[data-on="1"] { + background: color-mix(in srgb, var(--accent) 18%, transparent); + border-color: var(--accent); + } + @media (prefers-reduced-motion: reduce) { .pete-card, .pete-card::after, @@ -884,6 +1085,10 @@ html[data-phase="night"] { .pete-dealer-think { animation: none; } .pete-card-inner { transition: none; } .pete-hand[data-won="1"] .pete-card { animation: none; } + .pete-part-draw, + .pete-shake, + .pete-tile-hit, + .pete-meter[data-hit="1"] { animation: none; } } } diff --git a/internal/web/static/css/output.css b/internal/web/static/css/output.css index 6f98a20..87e522e 100644 --- a/internal/web/static/css/output.css +++ b/internal/web/static/css/output.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}body{font-family:Nunito,system-ui,sans-serif}html{scroll-behavior:smooth}body,html{overflow-x:hidden}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.pete-channel-nav{scrollbar-width:none;-ms-overflow-style:none}.pete-channel-nav::-webkit-scrollbar{display:none}.pete-reader-backdrop{position:absolute;inset:0;background:rgba(20,14,6,.55);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}html[data-phase=night] .pete-reader-backdrop{background:rgba(6,8,20,.65)}.pete-reader-shell{position:relative;height:100%;width:100%;max-width:44rem;margin:0 auto;display:flex;flex-direction:column;padding:max(env(safe-area-inset-top),.75rem) 1rem .75rem;gap:.75rem}.pete-reader-bar{display:flex;align-items:center;justify-content:space-between;gap:.75rem;color:#fff}.pete-reader-progress{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:600;font-size:.85rem;letter-spacing:.02em;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-nav{display:flex;align-items:center;gap:.4rem}.pete-reader-btn{display:inline-flex;align-items:center;justify-content:center;min-width:2.25rem;height:2.25rem;padding:0 .7rem;border-radius:9999px;background:hsla(0,0%,100%,.14);color:#fff;font-weight:700;font-size:.95rem;border:2px solid hsla(0,0%,100%,.18);cursor:pointer;transition:background .15s ease,transform .15s ease,opacity .15s ease}.pete-reader-btn:hover{background:hsla(0,0%,100%,.28);transform:translateY(-1px)}.pete-reader-btn:disabled{opacity:.35;cursor:default;transform:none}.pete-reader-btn-open{background:var(--accent);border-color:transparent;color:#1c1305;text-decoration:none}.pete-reader-btn-open:hover{background:var(--accent);filter:brightness(1.08)}.pete-reader-btn[aria-expanded=true],.pete-reader-btn[aria-pressed=true]{background:var(--accent);border-color:transparent;color:#1c1305}.pete-reader-type{position:relative;display:inline-flex}.pete-reader-typemenu[hidden]{display:none}.pete-reader-typemenu{position:absolute;top:calc(100% + .5rem);right:0;z-index:10;width:15rem;padding:.75rem;border-radius:1rem;background:var(--card);color:var(--ink);border:2px solid rgba(0,0,0,.08);box-shadow:0 10px 30px rgba(20,14,6,.28);display:flex;flex-direction:column;gap:.6rem}html[data-phase=night] .pete-reader-typemenu{border-color:hsla(0,0%,100%,.12)}.pete-reader-typerow{display:flex;align-items:center;justify-content:space-between;gap:.6rem}.pete-reader-typelabel{font-size:.8rem;font-weight:700;opacity:.7}.pete-reader-typebtns{display:inline-flex;gap:.25rem}.pete-reader-typebtns button{min-width:2rem;height:2rem;padding:0 .55rem;border-radius:.6rem;background:rgba(20,14,6,.06);color:var(--ink);font-weight:700;font-size:.82rem;border:2px solid transparent;cursor:pointer;transition:background .15s ease}html[data-phase=night] .pete-reader-typebtns button{background:hsla(0,0%,100%,.08)}.pete-reader-typebtns button:hover{background:rgba(20,14,6,.12)}.pete-reader-typebtns button.is-active{background:var(--accent);color:#1c1305;border-color:transparent}.pete-reader-voicerow[hidden]{display:none}.pete-reader-voice{font:inherit;font-size:.8rem;font-weight:600;padding:.25rem .4rem;border-radius:.55rem;border:1px solid rgba(20,14,6,.18);background:rgba(20,14,6,.06);color:inherit;max-width:11rem;cursor:pointer}html[data-phase=night] .pete-reader-voice{border-color:hsla(0,0%,100%,.14);background:hsla(0,0%,100%,.08)}.pete-reader-scroll{flex:1 1 auto;overflow-y:auto;-webkit-overflow-scrolling:touch;border-radius:1.75rem}.pete-reader-scroll:focus{outline:none}.pete-reader-article{background:var(--card);color:var(--ink);border-radius:1.75rem;border:2px solid rgba(0,0,0,.06);box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.18);padding:clamp(1.25rem,4vw,2.5rem)}html[data-phase=night] .pete-reader-article{border-color:hsla(0,0%,100%,.08)}.pete-reader-eyebrow{display:flex;align-items:center;flex-wrap:wrap;gap:.5rem;font-size:.75rem;margin-bottom:.85rem}.pete-reader-chip{display:inline-flex;align-items:center;gap:.35rem;border-radius:9999px;padding:.15rem .65rem;color:#fff;font-weight:700;text-transform:uppercase;letter-spacing:.06em}.pete-reader-source{font-weight:700;opacity:.75}.pete-reader-time{opacity:.55}.pete-reader-title{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:700;line-height:1.15;letter-spacing:-.01em;font-size:clamp(1.6rem,4.5vw,2.4rem);margin-bottom:1rem}.pete-reader-hero{width:100%;border-radius:1.1rem;margin-bottom:1.25rem;background:rgba(0,0,0,.05)}.pete-reader-scroll{--reader-fs:1.08rem}.pete-reader-body{font-size:var(--reader-fs);line-height:1.75}.pete-reader-body p{margin-bottom:1.05em}.pete-reader-body p:last-child{margin-bottom:0}.pete-reader-scroll.is-size-s{--reader-fs:0.98rem}.pete-reader-scroll.is-size-m{--reader-fs:1.08rem}.pete-reader-scroll.is-size-l{--reader-fs:1.22rem}.pete-reader-scroll.is-size-xl{--reader-fs:1.4rem}.pete-reader-scroll.is-serif .pete-reader-body{font-family:Georgia,Iowan Old Style,Times New Roman,serif}.pete-reader-scroll.is-sepia .pete-reader-article{background:#f6ecd6;color:#4a3a28;border-color:rgba(74,58,40,.16)}.pete-reader-scroll.is-sepia .pete-reader-note{border-top-color:rgba(74,58,40,.18);opacity:.8}.pete-reader-scroll.is-sepia .pete-reader-hero{background:rgba(74,58,40,.08)}.pete-reader-body p.is-speaking{background:color-mix(in srgb,var(--accent) 32%,transparent);border-radius:.4rem;box-shadow:0 0 0 .35rem color-mix(in srgb,var(--accent) 32%,transparent)}.pete-reader-note{margin-top:1.25rem;padding-top:1rem;border-top:1px solid rgba(0,0,0,.1);font-size:.85rem;opacity:.7}html[data-phase=night] .pete-reader-note{border-color:hsla(0,0%,100%,.12)}.pete-reader-note a{color:var(--accent);font-weight:700}.pete-reader-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:.75rem;padding:clamp(2rem,8vw,4rem) 1rem;color:var(--ink)}.pete-reader-empty-emoji{font-size:2.5rem}.pete-reader-toast{position:absolute;left:50%;bottom:4.5rem;transform:translate(-50%,.5rem);z-index:20;padding:.5rem .9rem;border-radius:9999px;background:rgba(20,14,6,.9);color:#fff;font-weight:700;font-size:.85rem;box-shadow:0 8px 24px rgba(0,0,0,.3);opacity:0;pointer-events:none;transition:opacity .2s ease,transform .2s ease}.pete-reader-toast.is-shown{opacity:1;transform:translate(-50%)}.pete-reader-hint{text-align:center;color:hsla(0,0%,100%,.85);font-size:.75rem;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-hint kbd{font-family:ui-monospace,monospace;background:hsla(0,0%,100%,.16);border-radius:.3rem;padding:.05rem .3rem}@media (max-width:640px){.pete-reader-hint{display:none}.pete-reader-bar{flex-wrap:wrap}.pete-reader-nav{flex-wrap:wrap;justify-content:flex-end;row-gap:.4rem}}.pete-reader-related{width:100%;margin:.85rem auto 0}.pete-reader-related-title{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:700;font-size:.95rem;color:#fff;margin:0 0 .6rem;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-related-grid{display:grid;gap:.6rem}.pete-reader-related-card{display:flex;align-items:center;gap:.75rem;background:var(--card);color:var(--ink);border-radius:1rem;border:2px solid rgba(0,0,0,.06);padding:.6rem;text-decoration:none;transition:transform .15s ease,box-shadow .15s ease}.pete-reader-related-card:hover{transform:translateY(-1px);box-shadow:0 6px 16px rgba(60,40,20,.16)}html[data-phase=night] .pete-reader-related-card{border-color:hsla(0,0%,100%,.08)}.pete-reader-related-thumb{width:4.5rem;height:3.25rem;flex-shrink:0;-o-object-fit:cover;object-fit:cover;border-radius:.6rem;background:rgba(0,0,0,.06)}.pete-reader-related-meta{min-width:0;display:flex;flex-direction:column;gap:.25rem}.pete-reader-related-eyebrow{display:flex;align-items:center;flex-wrap:wrap;gap:.4rem;font-size:.7rem}.pete-reader-related-source{font-weight:700;opacity:.7}.pete-reader-related-headline{font-weight:700;line-height:1.25;font-size:.92rem;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}[data-story-card][data-read="1"]{opacity:.5;transition:opacity .2s ease}[data-story-card][data-read="1"]:hover{opacity:1}[data-story-card][data-read="1"]:after{content:"✓";position:absolute;top:.6rem;right:.6rem;z-index:6;display:grid;place-items:center;height:1.5rem;width:1.5rem;border-radius:9999px;background:rgba(20,14,6,.72);color:#fff;font-size:.8rem;font-weight:700}.pete-felt{background:radial-gradient(120% 90% at 50% -10%,hsla(0,0%,100%,.1),transparent 60%),linear-gradient(160deg,#2f7d5b,#24614a 55%,#1c4d3c)}.pete-shoe{position:absolute;top:1.25rem;right:1.25rem;height:4.2rem;width:3rem;border-radius:.55rem;background:linear-gradient(150deg,#b4553f,#8d3f2f);border:2px solid rgba(0,0,0,.18);box-shadow:0 4px 0 rgba(0,0,0,.18),inset 0 0 0 3px hsla(0,0%,100%,.12)}.pete-shoe:after{content:"";position:absolute;inset:.45rem .4rem auto .4rem;height:.5rem;border-radius:.2rem;background:rgba(0,0,0,.22)}.pete-hand{display:flex;flex-wrap:wrap;gap:.6rem;min-height:8.6rem}.pete-card{perspective:700px;height:8.4rem;width:6rem;animation:pete-deal .42s cubic-bezier(.22,1,.36,1) backwards}.pete-card-inner{position:relative;height:100%;width:100%;transform-style:preserve-3d;transition:transform .45s cubic-bezier(.4,0,.2,1)}.pete-card[data-face=down] .pete-card-inner{transform:rotateY(180deg)}.pete-card-back,.pete-card-front{position:absolute;inset:0;display:grid;border-radius:.55rem;backface-visibility:hidden;-webkit-backface-visibility:hidden;box-shadow:0 3px 0 rgba(0,0,0,.18),0 6px 14px rgba(0,0,0,.22)}.pete-card-front{place-items:stretch;background:#fdfaf2;border:2px solid rgba(30,20,10,.12);color:#2b2118;font-family:Fredoka,Nunito,system-ui,sans-serif;line-height:1;overflow:hidden}.pete-card-front[data-red="1"]{color:#cc3d4a}.pete-card-back{transform:rotateY(180deg);background:repeating-linear-gradient(45deg,hsla(0,0%,100%,.1) 0 6px,transparent 6px 12px),linear-gradient(150deg,#b4553f,#8d3f2f);border:2px solid rgba(0,0,0,.15)}.pete-card-svg{width:100%;height:100%;display:block;fill:currentColor}.pete-card-idx{font-size:26px;font-weight:700;text-anchor:middle;fill:currentColor}.pete-card-panel{fill:currentColor;fill-opacity:.06;stroke:currentColor;stroke-opacity:.35;stroke-width:1.5}.pete-card-court{font-size:34px;font-weight:700;text-anchor:middle;fill:currentColor}@keyframes pete-deal{0%{opacity:0;transform:translate(var(--deal-x,14rem),var(--deal-y,-7rem)) scale(.72) rotate(9deg)}62%{opacity:1;transform:translateY(.35rem) scale(1.05) rotate(calc(var(--tilt, 0deg) - 2deg))}to{opacity:1;transform:translate(0) scale(1) rotate(var(--tilt,0deg))}}.pete-card:after{content:"";position:absolute;inset:auto 6% -.35rem 6%;height:.6rem;border-radius:999px;background:rgba(0,0,0,.35);filter:blur(5px);animation:pete-thud .42s cubic-bezier(.22,1,.36,1) backwards}@keyframes pete-thud{0%,55%{opacity:0;transform:scale(.4)}72%{opacity:.9;transform:scale(1.15)}to{opacity:.45;transform:scale(1)}}.pete-hand[data-won="1"] .pete-card{animation:pete-deal .42s cubic-bezier(.22,1,.36,1) backwards,pete-win .6s ease .1s}@keyframes pete-win{0%,to{transform:rotate(var(--tilt,0deg)) translateY(0)}40%{transform:rotate(var(--tilt,0deg)) translateY(-.6rem)}}.pete-hand[data-won="-1"] .pete-card{filter:saturate(.45) brightness(.78);transition:filter .5s ease .2s}.pete-disc{position:relative;border-radius:999px;background:radial-gradient(circle at 50% 30%,hsla(0,0%,100%,.3),transparent 62%),var(--chip,#e07a5f);border:2px solid rgba(0,0,0,.22);box-shadow:0 3px 0 rgba(0,0,0,.28),0 6px 14px rgba(0,0,0,.2)}.pete-disc:before{content:"";position:absolute;inset:11%;border-radius:999px;border:2px dashed hsla(0,0%,100%,.55)}.pete-disc[data-chip="5"]{--chip:#5aa9e6}.pete-disc[data-chip="25"]{--chip:#4caf7d}.pete-disc[data-chip="100"]{--chip:#2b2118}.pete-disc[data-chip="500"]{--chip:#b079d6}.pete-chip{transition:transform .12s ease}.pete-chip>span{position:relative}.pete-chip:active{transform:translateY(1px) scale(.94)}.pete-spot{position:relative;display:grid;place-items:center;height:7rem;width:7rem;flex:none;border-radius:999px;border:2px dashed hsla(0,0%,100%,.35);background:radial-gradient(circle at 50% 40%,hsla(0,0%,100%,.1),transparent 70%);box-shadow:inset 0 0 0 6px rgba(0,0,0,.08);transition:box-shadow .3s ease,border-color .3s ease}.pete-spot[data-live="1"]{border-color:rgba(var(--glow,242,181,61),.85);box-shadow:inset 0 0 0 6px rgba(0,0,0,.08),0 0 22px rgba(var(--glow,242,181,61),.35)}.pete-spot-label{font-size:.65rem;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:hsla(0,0%,100%,.35)}.pete-spot[data-live="1"] .pete-spot-label{opacity:0}.pete-stack{position:absolute;inset:0;pointer-events:none}.pete-stack .pete-disc{position:absolute;left:50%;top:50%;height:2.6rem;width:2.6rem;margin:-1.3rem 0 0 -1.3rem;transform:translateY(calc(-.5rem*var(--i, 0))) rotate(var(--spin,0deg));box-shadow:0 2px 0 rgba(0,0,0,.35),0 4px 8px rgba(0,0,0,.25);animation:pete-chip-land .28s cubic-bezier(.34,1.56,.64,1) backwards}@keyframes pete-chip-land{0%{transform:translateY(calc(-.5rem*var(--i, 0) - 1.1rem)) rotate(var(--spin,0deg)) scale(1.18)}}.pete-spot-total{position:absolute;bottom:-.6rem;left:50%;transform:translateX(-50%);white-space:nowrap;border-radius:999px;background:rgba(0,0,0,.45);padding:.1rem .6rem;font-size:.72rem;font-weight:700;font-variant-numeric:tabular-nums;color:#fff}.pete-fly-layer{position:fixed;inset:0;z-index:60;pointer-events:none;overflow:hidden}.pete-fly{position:absolute;height:2.6rem;width:2.6rem;margin:-1.3rem 0 0 -1.3rem;will-change:transform}.pete-rack{position:absolute;top:1.25rem;right:5.75rem;display:flex;align-items:flex-end;gap:.4rem;padding:.5rem .6rem .45rem;border-radius:.75rem;background:rgba(0,0,0,.2);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.06)}.pete-rack span{display:block;width:1.7rem;border-radius:.35rem;background:linear-gradient(90deg,rgba(0,0,0,.28),transparent 35%,transparent 65%,rgba(0,0,0,.28)),repeating-linear-gradient(180deg,hsla(0,0%,100%,.18) 0 .06rem,var(--chip,#e07a5f) .06rem .3rem,rgba(0,0,0,.35) .3rem .36rem);border:1px solid rgba(0,0,0,.35);height:calc(.36rem*var(--stack, 3))}.pete-rack span[data-chip="5"]{--chip:#5aa9e6}.pete-rack span[data-chip="25"]{--chip:#4caf7d}.pete-rack span[data-chip="100"]{--chip:#2b2118}.pete-rack span[data-chip="500"]{--chip:#b079d6}.pete-spark{position:absolute;height:.75rem;width:.45rem;border-radius:2px;box-shadow:0 1px 3px rgba(0,0,0,.35);will-change:transform,opacity}.pete-dealer-think{animation:pete-think .9s ease-in-out infinite}@keyframes pete-think{0%,to{opacity:.6}50%{opacity:1;transform:translateY(-1px)}}@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{animation:none}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-right-6{right:-1.5rem}.-top-6{top:-1.5rem}.-z-10{z-index:-10}.-z-\[5\]{z-index:-5}.z-50{z-index:50}.order-none{order:0}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-auto{margin-left:auto;margin-right:auto}.-mt-1{margin-top:-.25rem}.mb-1{margin-bottom:.25rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-8{margin-bottom:2rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-8{margin-top:2rem}.line-clamp-2{-webkit-line-clamp:2}.line-clamp-2,.line-clamp-3{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical}.line-clamp-3{-webkit-line-clamp:3}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.aspect-\[16\/10\]{aspect-ratio:16/10}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-20{height:5rem}.h-4{height:1rem}.h-5{height:1.25rem}.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-\[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-full{width:100%}.min-w-0{min-width:0}.min-w-\[28rem\]{min-width:28rem}.min-w-\[52rem\]{min-width:52rem}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-\[7rem\]{max-width:7rem}.max-w-full{max-width:100%}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.basis-full{flex-basis:100%}.-translate-y-0{--tw-translate-y:-0px}.-translate-y-0,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-4{row-gap:1rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.375rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.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-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-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}.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\(--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\/60{color:hsla(0,0%,100%,.6)}.text-white\/85{color:hsla(0,0%,100%,.85)}.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\:basis-auto{flex-basis:auto}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:gap-2{gap:.5rem}.sm\:gap-3{gap:.75rem}.sm\:gap-4{gap:1rem}.sm\:p-10{padding:2.5rem}.sm\:p-6{padding:1.5rem}.sm\:p-8{padding:2rem}.sm\: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))}}@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:8.4rem;width:6rem;animation:pete-deal .42s cubic-bezier(.22,1,.36,1) backwards}.pete-card-inner{position:relative;height:100%;width:100%;transform-style:preserve-3d;transition:transform .45s cubic-bezier(.4,0,.2,1)}.pete-card[data-face=down] .pete-card-inner{transform:rotateY(180deg)}.pete-card-back,.pete-card-front{position:absolute;inset:0;display:grid;border-radius:.55rem;backface-visibility:hidden;-webkit-backface-visibility:hidden;box-shadow:0 3px 0 rgba(0,0,0,.18),0 6px 14px rgba(0,0,0,.22)}.pete-card-front{place-items:stretch;background:#fdfaf2;border:2px solid rgba(30,20,10,.12);color:#2b2118;font-family:Fredoka,Nunito,system-ui,sans-serif;line-height:1;overflow:hidden}.pete-card-front[data-red="1"]{color:#cc3d4a}.pete-card-back{transform:rotateY(180deg);background:repeating-linear-gradient(45deg,hsla(0,0%,100%,.1) 0 6px,transparent 6px 12px),linear-gradient(150deg,#b4553f,#8d3f2f);border:2px solid rgba(0,0,0,.15)}.pete-card-svg{width:100%;height:100%;display:block;fill:currentColor}.pete-card-idx{font-size:26px;font-weight:700;text-anchor:middle;fill:currentColor}.pete-card-panel{fill:currentColor;fill-opacity:.06;stroke:currentColor;stroke-opacity:.35;stroke-width:1.5}.pete-card-court{font-size:34px;font-weight:700;text-anchor:middle;fill:currentColor}@keyframes pete-deal{0%{opacity:0;transform:translate(var(--deal-x,14rem),var(--deal-y,-7rem)) scale(.72) rotate(9deg)}62%{opacity:1;transform:translateY(.35rem) scale(1.05) rotate(calc(var(--tilt, 0deg) - 2deg))}to{opacity:1;transform:translate(0) scale(1) rotate(var(--tilt,0deg))}}.pete-card:after{content:"";position:absolute;inset:auto 6% -.35rem 6%;height:.6rem;border-radius:999px;background:rgba(0,0,0,.35);filter:blur(5px);animation:pete-thud .42s cubic-bezier(.22,1,.36,1) backwards}@keyframes pete-thud{0%,55%{opacity:0;transform:scale(.4)}72%{opacity:.9;transform:scale(1.15)}to{opacity:.45;transform:scale(1)}}.pete-hand[data-won="1"] .pete-card{animation:pete-deal .42s cubic-bezier(.22,1,.36,1) backwards,pete-win .6s ease .1s}@keyframes pete-win{0%,to{transform:rotate(var(--tilt,0deg)) translateY(0)}40%{transform:rotate(var(--tilt,0deg)) translateY(-.6rem)}}.pete-hand[data-won="-1"] .pete-card{filter:saturate(.45) brightness(.78);transition:filter .5s ease .2s}.pete-disc{position:relative;border-radius:999px;background:radial-gradient(circle at 50% 30%,hsla(0,0%,100%,.3),transparent 62%),var(--chip,#e07a5f);border:2px solid rgba(0,0,0,.22);box-shadow:0 3px 0 rgba(0,0,0,.28),0 6px 14px rgba(0,0,0,.2)}.pete-disc:before{content:"";position:absolute;inset:11%;border-radius:999px;border:2px dashed hsla(0,0%,100%,.55)}.pete-disc[data-chip="5"]{--chip:#5aa9e6}.pete-disc[data-chip="25"]{--chip:#4caf7d}.pete-disc[data-chip="100"]{--chip:#2b2118}.pete-disc[data-chip="500"]{--chip:#b079d6}.pete-chip{transition:transform .12s ease}.pete-chip>span{position:relative}.pete-chip:active{transform:translateY(1px) scale(.94)}.pete-spot{position:relative;display:grid;place-items:center;height:7rem;width:7rem;flex:none;border-radius:999px;border:2px dashed hsla(0,0%,100%,.35);background:radial-gradient(circle at 50% 40%,hsla(0,0%,100%,.1),transparent 70%);box-shadow:inset 0 0 0 6px rgba(0,0,0,.08);transition:box-shadow .3s ease,border-color .3s ease}.pete-spot[data-live="1"]{border-color:rgba(var(--glow,242,181,61),.85);box-shadow:inset 0 0 0 6px rgba(0,0,0,.08),0 0 22px rgba(var(--glow,242,181,61),.35)}.pete-spot-label{font-size:.65rem;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:hsla(0,0%,100%,.35)}.pete-spot[data-live="1"] .pete-spot-label{opacity:0}.pete-stack{position:absolute;inset:0;pointer-events:none}.pete-stack .pete-disc{position:absolute;left:50%;top:50%;height:2.6rem;width:2.6rem;margin:-1.3rem 0 0 -1.3rem;transform:translateY(calc(-.5rem*var(--i, 0))) rotate(var(--spin,0deg));box-shadow:0 2px 0 rgba(0,0,0,.35),0 4px 8px rgba(0,0,0,.25);animation:pete-chip-land .28s cubic-bezier(.34,1.56,.64,1) backwards}@keyframes pete-chip-land{0%{transform:translateY(calc(-.5rem*var(--i, 0) - 1.1rem)) rotate(var(--spin,0deg)) scale(1.18)}}.pete-spot-total{position:absolute;bottom:-.6rem;left:50%;transform:translateX(-50%);white-space:nowrap;border-radius:999px;background:rgba(0,0,0,.45);padding:.1rem .6rem;font-size:.72rem;font-weight:700;font-variant-numeric:tabular-nums;color:#fff}.pete-fly-layer{position:fixed;inset:0;z-index:60;pointer-events:none;overflow:hidden}.pete-fly{position:absolute;height:2.6rem;width:2.6rem;margin:-1.3rem 0 0 -1.3rem;will-change:transform}.pete-rack{position:absolute;top:1.25rem;right:5.75rem;display:flex;align-items:flex-end;gap:.4rem;padding:.5rem .6rem .45rem;border-radius:.75rem;background:rgba(0,0,0,.2);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.06)}.pete-rack span{display:block;width:1.7rem;border-radius:.35rem;background:linear-gradient(90deg,rgba(0,0,0,.28),transparent 35%,transparent 65%,rgba(0,0,0,.28)),repeating-linear-gradient(180deg,hsla(0,0%,100%,.18) 0 .06rem,var(--chip,#e07a5f) .06rem .3rem,rgba(0,0,0,.35) .3rem .36rem);border:1px solid rgba(0,0,0,.35);height:calc(.36rem*var(--stack, 3))}.pete-rack span[data-chip="5"]{--chip:#5aa9e6}.pete-rack span[data-chip="25"]{--chip:#4caf7d}.pete-rack span[data-chip="100"]{--chip:#2b2118}.pete-rack span[data-chip="500"]{--chip:#b079d6}.pete-spark{position:absolute;height:.75rem;width:.45rem;border-radius:2px;box-shadow:0 1px 3px rgba(0,0,0,.35);will-change:transform,opacity}.pete-dealer-think{animation:pete-think .9s ease-in-out infinite}@keyframes pete-think{0%,to{opacity:.6}50%{opacity:1;transform:translateY(-1px)}}.pete-gallows{overflow:visible;fill:none;stroke-linecap:round;stroke-linejoin:round}.pete-gallows-frame path{stroke:rgba(0,0,0,.35);stroke-width:6}.pete-gallows-frame path:last-child{stroke:rgba(0,0,0,.3);stroke-width:3.5}.pete-gallows-body>*{stroke:#fff;stroke-width:5;opacity:0;filter:drop-shadow(0 2px 0 rgba(0,0,0,.25))}.pete-gallows-body>[data-on="1"]{opacity:1}.pete-part-draw{stroke-dasharray:260;animation:pete-part .45s cubic-bezier(.22,1,.36,1) backwards}@keyframes pete-part{0%{stroke-dashoffset:260}to{stroke-dashoffset:0}}.pete-shake{animation:pete-shake .42s cubic-bezier(.36,.07,.19,.97)}@keyframes pete-shake{10%,90%{transform:translateX(-1.5px)}20%,80%{transform:translateX(3px)}30%,50%,70%{transform:translateX(-5px)}40%,60%{transform:translateX(5px)}}.pete-board{display:flex;flex-wrap:wrap;gap:.35rem 1.1rem;min-height:5rem;align-items:center}.pete-word{display:flex;gap:.3rem}.pete-tile{display:grid;place-items:center;height:2.9rem;width:2.2rem;border-radius:.5rem;font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:1.4rem;font-weight:700;color:#fff;background:rgba(0,0,0,.22);border-bottom:3px solid rgba(0,0,0,.28);text-transform:uppercase}.pete-tile[data-up="1"]{background:hsla(0,0%,100%,.95);color:#2b2118;border-bottom-color:rgba(0,0,0,.35)}.pete-tile[data-punct="1"]{background:none;border:0;width:auto;min-width:.7rem;color:hsla(0,0%,100%,.55)}.pete-tile-hit{animation:pete-tile-hit .34s cubic-bezier(.34,1.56,.64,1)}@keyframes pete-tile-hit{0%{transform:rotateX(90deg) scale(1.1)}to{transform:rotateX(0) scale(1)}}.pete-missed{display:grid;place-items:center;height:1.5rem;min-width:1.5rem;padding:0 .3rem;border-radius:.375rem;background:rgba(0,0,0,.25);font-size:.75rem;font-weight:700;color:hsla(0,0%,100%,.5);text-decoration:line-through;text-transform:uppercase}.pete-meter{display:flex;align-items:baseline;gap:.5rem;border-radius:999px;background:rgba(0,0,0,.28);padding:.4rem .9rem;box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.1);transition:box-shadow .3s ease}.pete-meter-label{font-size:.65rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:hsla(0,0%,100%,.45)}.pete-meter-value{font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:1.5rem;font-weight:700;font-variant-numeric:tabular-nums;color:var(--accent)}.pete-meter[data-cold="1"] .pete-meter-value{color:hsla(0,0%,100%,.55)}.pete-meter[data-hit="1"]{box-shadow:inset 0 0 0 2px rgba(204,61,74,.9);animation:pete-meter-hit .4s ease}@keyframes pete-meter-hit{0%{transform:scale(1)}35%{transform:scale(.94)}to{transform:scale(1)}}.pete-keys{display:grid;gap:.35rem}.pete-key-row{display:flex;justify-content:center;gap:.35rem}.pete-key-row[data-digits="1"]{margin-top:.25rem;opacity:.75}.pete-key-row[data-digits="1"] .pete-key{height:2rem;min-width:1.8rem;font-size:.8rem}.pete-key{height:2.75rem;min-width:2.2rem;flex:0 1 2.4rem;border-radius:.6rem;background:color-mix(in srgb,var(--ink) 6%,transparent);border:2px solid color-mix(in srgb,var(--ink) 10%,transparent);font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-weight:700;color:var(--ink);transition:transform .08s ease,background .15s ease,opacity .15s ease}.pete-key:hover:not(:disabled){background:color-mix(in srgb,var(--ink) 12%,transparent)}.pete-key:active:not(:disabled){transform:translateY(1px) scale(.96)}.pete-key:disabled{cursor:default}.pete-key[data-state=hit]{background:#4caf7d;border-color:#3d9367;color:#fff;opacity:1}.pete-key[data-state=miss]{background:color-mix(in srgb,var(--ink) 4%,transparent);color:color-mix(in srgb,var(--ink) 30%,transparent);text-decoration:line-through}.pete-key:disabled[data-state=""]{opacity:.4}.pete-tier{background:color-mix(in srgb,var(--ink) 4%,transparent);border-color:color-mix(in srgb,var(--ink) 10%,transparent)}.pete-tier:hover{background:color-mix(in srgb,var(--ink) 8%,transparent)}.pete-tier[data-on="1"]{background:color-mix(in srgb,var(--accent) 18%,transparent);border-color:var(--accent)}@media (prefers-reduced-motion:reduce){.pete-card,.pete-card:after,.pete-dealer-think,.pete-stack .pete-disc{animation:none}.pete-card-inner{transition:none}.pete-hand[data-won="1"] .pete-card,.pete-meter[data-hit="1"],.pete-part-draw,.pete-shake,.pete-tile-hit{animation:none}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-right-6{right:-1.5rem}.-top-6{top:-1.5rem}.-z-10{z-index:-10}.-z-\[5\]{z-index:-5}.z-50{z-index:50}.order-none{order:0}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-auto{margin-left:auto;margin-right:auto}.-mt-1{margin-top:-.25rem}.mb-1{margin-bottom:.25rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-8{margin-bottom:2rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mt-0{margin-top:0}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-8{margin-top:2rem}.line-clamp-2{-webkit-line-clamp:2}.line-clamp-2,.line-clamp-3{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical}.line-clamp-3{-webkit-line-clamp:3}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.aspect-\[16\/10\]{aspect-ratio:16/10}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-20{height:5rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-52{height:13rem}.h-6{height:1.5rem}.h-9{height:2.25rem}.h-full{height:100%}.max-h-\[55vh\]{max-height:55vh}.max-h-\[60vh\]{max-height:60vh}.min-h-\[1\.75rem\]{min-height:1.75rem}.min-h-\[2\.75rem\]{min-height:2.75rem}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-2{width:.5rem}.w-2\.5{width:.625rem}.w-20{width:5rem}.w-24{width:6rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-9{width:2.25rem}.w-auto{width:auto}.w-full{width:100%}.min-w-0{min-width:0}.min-w-\[28rem\]{min-width:28rem}.min-w-\[52rem\]{min-width:52rem}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-\[7rem\]{max-width:7rem}.max-w-full{max-width:100%}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.basis-full{flex-basis:100%}.-translate-y-0{--tw-translate-y:-0px}.-translate-y-0,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-10{-moz-column-gap:2.5rem;column-gap:2.5rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-4{row-gap:1rem}.gap-y-8{row-gap:2rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.375rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-transparent{border-color:transparent}.bg-\[color\:var\(--accent\)\]{background-color:var(--accent)}.bg-\[color\:var\(--bg\)\]{background-color:var(--bg)}.bg-\[color\:var\(--bg-grad\)\]{background-color:var(--bg-grad)}.bg-\[color\:var\(--card\)\]{background-color:var(--card)}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-black\/25{background-color:rgba(0,0,0,.25)}.bg-emerald-500{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity,1))}.bg-emerald-500\/15{background-color:rgba(16,185,129,.15)}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/15{background-color:rgba(239,68,68,.15)}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/95{background-color:hsla(0,0%,100%,.95)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-0\.5{padding:.125rem}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0{padding-top:0;padding-bottom:0}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-10{padding-bottom:2.5rem}.pb-20{padding-bottom:5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pr-24{padding-right:6rem}.pt-1{padding-top:.25rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-8{padding-top:2rem}.pt-\[10vh\]{padding-top:10vh}.pt-\[12vh\]{padding-top:12vh}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.align-\[-4px\]{vertical-align:-4px}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12rem\]{font-size:12rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-\[0\.18em\]{letter-spacing:.18em}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-\[\#1c1305\]{--tw-text-opacity:1;color:rgb(28 19 5/var(--tw-text-opacity,1))}.text-\[\#20180c\]{--tw-text-opacity:1;color:rgb(32 24 12/var(--tw-text-opacity,1))}.text-\[\#2b2118\]{--tw-text-opacity:1;color:rgb(43 33 24/var(--tw-text-opacity,1))}.text-\[color\:var\(--accent\)\]{color:var(--accent)}.text-\[color\:var\(--ink\)\]{color:var(--ink)}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-emerald-700{--tw-text-opacity:1;color:rgb(4 120 87/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-white\/40{color:hsla(0,0%,100%,.4)}.text-white\/50{color:hsla(0,0%,100%,.5)}.text-white\/60{color:hsla(0,0%,100%,.6)}.text-white\/85{color:hsla(0,0%,100%,.85)}.text-white\/90{color:hsla(0,0%,100%,.9)}.underline{text-decoration-line:underline}.decoration-4{text-decoration-thickness:4px}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.accent-\[color\:var\(--accent\)\]{accent-color:var(--accent)}.opacity-20{opacity:.2}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.blur{--tw-blur:blur(8px)}.blur,.drop-shadow{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.drop-shadow{--tw-drop-shadow:drop-shadow(0 1px 2px rgba(0,0,0,.1)) drop-shadow(0 1px 1px rgba(0,0,0,.06))}.grayscale{--tw-grayscale:grayscale(100%)}.grayscale,.sepia{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.sepia{--tw-sepia:sepia(100%)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.backdrop-blur-sm,.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-1000{transition-duration:1s}.duration-500{transition-duration:.5s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}.font-display{font-family:Fredoka,Nunito,system-ui,sans-serif;letter-spacing:-.01em}.shadow-pete{box-shadow:0 4px 0 rgba(60,40,20,.1),0 8px 24px rgba(60,40,20,.08)}.shadow-pete-lg{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.bg-theme-gaming{background-color:#4caf7d}.bg-theme-tech{background-color:#5aa9e6}.bg-theme-politics{background-color:#e07a5f}.bg-theme-eu{background-color:#039}.bg-theme-music{background-color:#b079d6}.bg-theme-anime{background-color:#ec5e8a}.bg-theme-foss{background-color:#d97706}.bg-theme-kids{background-color:#14b8a6}.bg-theme-finance{background-color:#10b981}.bg-theme-lego{background-color:#d01012}.bg-theme-adventure{background-color:#6d4bd8}.text-theme-gaming{color:#2d8a5a}.text-theme-tech{color:#2f7fb8}.text-theme-politics{color:#b8523a}.text-theme-eu{color:#039}.text-theme-music{color:#8a4fb8}.text-theme-anime{color:#c33a6a}.text-theme-foss{color:#b8530a}.text-theme-kids{color:#0f766e}.text-theme-finance{color:#059669}.text-theme-lego{color:#b00d0e}.text-theme-adventure{color:#5836b8}.decoration-theme-gaming{text-decoration-color:#4caf7d}.decoration-theme-tech{text-decoration-color:#5aa9e6}.decoration-theme-politics{text-decoration-color:#e07a5f}.decoration-theme-eu{text-decoration-color:#039}.decoration-theme-music{text-decoration-color:#b079d6}.decoration-theme-anime{text-decoration-color:#ec5e8a}.decoration-theme-foss{text-decoration-color:#d97706}.decoration-theme-kids{text-decoration-color:#14b8a6}.decoration-theme-finance{text-decoration-color:#10b981}.decoration-theme-lego{text-decoration-color:#d01012}.decoration-theme-adventure{text-decoration-color:#6d4bd8}.border-theme-gaming{border-color:#4caf7d}.border-theme-tech{border-color:#5aa9e6}.border-theme-politics{border-color:#e07a5f}.border-theme-eu{border-color:#039}.border-theme-music{border-color:#b079d6}.border-theme-anime{border-color:#ec5e8a}.border-theme-foss{border-color:#d97706}.border-theme-kids{border-color:#14b8a6}.border-theme-finance{border-color:#10b981}.border-theme-lego{border-color:#d01012}.border-theme-adventure{border-color:#6d4bd8}.glow-theme-gaming{box-shadow:0 0 0 2px rgba(76,175,125,.35),0 0 24px 4px rgba(76,175,125,.45)}.glow-theme-gaming,.glow-theme-tech{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-tech{box-shadow:0 0 0 2px rgba(90,169,230,.35),0 0 24px 4px rgba(90,169,230,.45)}.glow-theme-politics{box-shadow:0 0 0 2px rgba(224,122,95,.35),0 0 24px 4px rgba(224,122,95,.45)}.glow-theme-eu,.glow-theme-politics{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-eu{box-shadow:0 0 0 2px rgba(0,51,153,.35),0 0 24px 4px rgba(0,51,153,.45)}.glow-theme-music{box-shadow:0 0 0 2px rgba(176,121,214,.35),0 0 24px 4px rgba(176,121,214,.45)}.glow-theme-anime,.glow-theme-music{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-anime{box-shadow:0 0 0 2px rgba(236,94,138,.35),0 0 24px 4px rgba(236,94,138,.45)}.glow-theme-foss{box-shadow:0 0 0 2px rgba(217,119,6,.35),0 0 24px 4px rgba(217,119,6,.45)}.glow-theme-foss,.glow-theme-kids{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-kids{box-shadow:0 0 0 2px rgba(20,184,166,.35),0 0 24px 4px rgba(20,184,166,.45)}.glow-theme-finance{box-shadow:0 0 0 2px rgba(16,185,129,.35),0 0 24px 4px rgba(16,185,129,.45)}.glow-theme-finance,.glow-theme-lego{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-lego{box-shadow:0 0 0 2px rgba(208,16,18,.35),0 0 24px 4px rgba(208,16,18,.45)}.glow-theme-adventure{box-shadow:0 0 0 2px rgba(109,75,216,.35),0 0 24px 4px rgba(109,75,216,.45);animation:pete-glow-pulse 2.4s ease-in-out infinite}@keyframes pete-glow-pulse{0%,to{filter:brightness(1)}50%{filter:brightness(1.08)}}.line-clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.paywall-stamp{position:absolute;inset:0;pointer-events:none;display:flex;align-items:center;justify-content:center;z-index:5;background:rgba(255,250,240,.18)}.paywall-stamp:before{content:"PAYWALLED";font-family:var(--font-display,ui-sans-serif),system-ui,sans-serif;font-weight:900;font-size:clamp(1rem,3.2vw,1.75rem);letter-spacing:.12em;color:rgba(180,30,30,.85);border:.28rem double rgba(180,30,30,.85);padding:.3rem .9rem;transform:rotate(-15deg);text-shadow:0 1px 0 hsla(0,0%,100%,.4);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.15);background:rgba(255,240,230,.55);border-radius:.25rem;filter:contrast(1.05)}:root,html[data-phase=day]{--bg:#fff7e4;--bg-grad:#ffeec2;--card:#fff;--ink:#3a2e1f;--accent:#f2a541}html[data-phase=dawn]{--bg:#ffe7d6;--bg-grad:#ffc9c9;--card:#fff4ea;--ink:#4a2e2a;--accent:#ff8a65}html[data-phase=dusk]{--bg:#ffd6a8;--bg-grad:#f7a07e;--card:#fff1de;--ink:#3d2417;--accent:#e6553a}html[data-phase=night]{--bg:#1a1f3a;--bg-grad:#2a3358;--card:#2d365a;--ink:#f1ecd8;--accent:#f9d976}html[data-room=casinopolis]{--bg:#16211c;--card:#23342b;--ink:#f6ecd8;--accent:#f2b53d;--felt-a:#2f7d5b;--felt-b:#24614a;--felt-c:#1c4d3c;--glow:242,181,61}html[data-room=casino-night]{--bg:#140f2e;--card:#241a4d;--ink:#f2ecff;--accent:#ffcc2f;--felt-a:#4a2fa8;--felt-b:#351f80;--felt-c:#241659;--glow:126,86,255}html[data-room] .cs-room{background:radial-gradient(80% 55% at 50% -8%,rgba(var(--glow),.18),transparent 62%),radial-gradient(120% 90% at 50% 120%,rgba(0,0,0,.45),transparent 55%)}html[data-room=casino-night] .cs-room:before{content:"";position:absolute;inset:0 -50% auto -50%;height:.5rem;background:repeating-linear-gradient(90deg,rgba(255,204,47,.55) 0 .5rem,transparent .5rem 2.25rem);filter:blur(1px);animation:cs-bulbs 2.4s linear infinite}@keyframes cs-bulbs{0%{transform:translateX(0)}to{transform:translateX(2.75rem)}}html[data-room] .shadow-pete{box-shadow:0 4px 0 rgba(0,0,0,.3),0 10px 26px rgba(0,0,0,.3)}html[data-room] .shadow-pete-lg{box-shadow:0 6px 0 rgba(0,0,0,.34),0 20px 40px rgba(0,0,0,.38)}html[data-room] .cs-comb svg{filter:drop-shadow(0 3px 0 rgba(0,0,0,.35))}html[data-room] .pete-felt{background:radial-gradient(120% 90% at 50% -10%,hsla(0,0%,100%,.1),transparent 60%),linear-gradient(160deg,var(--felt-a) 0,var(--felt-b) 55%,var(--felt-c) 100%)}@media (prefers-reduced-motion:reduce){html[data-room=casino-night] .cs-room:before{animation:none}}.cs-stack{display:block;width:2.75rem;height:calc(.45rem*var(--stack, 1) + .55rem);border-radius:999px;background:radial-gradient(circle at 50% 30%,hsla(0,0%,100%,.3),transparent 60%),var(--chip,#e07a5f);border:2px solid rgba(0,0,0,.25);box-shadow:0 3px 0 rgba(0,0,0,.3),inset 0 -.4rem 0 rgba(0,0,0,.16),inset 0 .35rem 0 hsla(0,0%,100%,.14)}.cs-stack[data-chip="5"]{--chip:#5aa9e6}.cs-stack[data-chip="25"]{--chip:#4caf7d}.cs-stack[data-chip="100"]{--chip:#2b2118}.cs-stack[data-chip="500"]{--chip:#b079d6}.last\:border-0:last-child{border-width:0}.empty\:hidden:empty{display:none}.hover\:-translate-y-0\.5:hover{--tw-translate-y:-0.125rem}.hover\:-translate-y-0\.5:hover,.hover\:-translate-y-1:hover{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:-translate-y-1:hover{--tw-translate-y:-0.25rem}.hover\:text-\[color\:var\(--ink\)\]:hover{color:var(--ink)}.hover\:brightness-105:hover{--tw-brightness:brightness(1.05);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.hover\:shadow-pete-lg:hover{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.focus\:border-\[color\:var\(--accent\)\]:focus{border-color:var(--accent)}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.active\:translate-y-px:active{--tw-translate-y:1px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:-rotate-6{--tw-rotate:-6deg}.group:hover .group-hover\:-rotate-6,.group:hover .group-hover\:scale-105{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:scale-105{--tw-scale-x:1.05;--tw-scale-y:1.05}.group:hover .group-hover\:underline{text-decoration-line:underline}@media (min-width:640px){.sm\:ml-auto{margin-left:auto}.sm\:block{display:block}.sm\:inline{display:inline}.sm\:flex{display:flex}.sm\:inline-flex{display:inline-flex}.sm\:h-56{height:14rem}.sm\:basis-auto{flex-basis:auto}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:gap-2{gap:.5rem}.sm\:gap-3{gap:.75rem}.sm\:gap-4{gap:1rem}.sm\:p-10{padding:2.5rem}.sm\:p-6{padding:1.5rem}.sm\:p-8{padding:2rem}.sm\:pr-28{padding-right:7rem}.sm\:pt-10{padding-top:2.5rem}.sm\:pt-12{padding-top:3rem}.sm\:text-2xl{font-size:1.5rem;line-height:2rem}.sm\:text-3xl{font-size:1.875rem;line-height:2.25rem}.sm\:text-4xl{font-size:2.25rem;line-height:2.5rem}.sm\:text-5xl{font-size:3rem;line-height:1}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-\[auto\2c 1fr\]{grid-template-columns:auto 1fr}.lg\:items-start{align-items:flex-start}}@media (prefers-color-scheme:dark){.dark\:text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dark\:text-emerald-300{--tw-text-opacity:1;color:rgb(110 231 183/var(--tw-text-opacity,1))}.dark\:text-red-300{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.dark\:text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}} \ No newline at end of file diff --git a/internal/web/static/js/games.js b/internal/web/static/js/games.js index d66e0b5..7aefbb0 100644 --- a/internal/web/static/js/games.js +++ b/internal/web/static/js/games.js @@ -62,8 +62,10 @@ } } - // You cannot cash out mid-hand: the stake is already on the table. - if (cashBtn) cashBtn.disabled = v.chips <= 0 || !!v.hand; + // You cannot cash out mid-game: the stake is already on the table. `game` is + // set by whichever game you're in, so this holds for any of them — checking + // for a blackjack hand specifically would let you walk out on a hangman. + if (cashBtn) cashBtn.disabled = v.chips <= 0 || !!v.game; if (buyBtn) buyBtn.disabled = v.chips + v.pending >= v.cap; listeners.forEach(function (fn) { fn(v); }); diff --git a/internal/web/static/js/hangman.js b/internal/web/static/js/hangman.js new file mode 100644 index 0000000..1507508 --- /dev/null +++ b/internal/web/static/js/hangman.js @@ -0,0 +1,583 @@ +// The hangman table. +// +// Same bargain as the blackjack table: the browser holds no game. It sends a +// letter, and the server answers with the board you're allowed to see — the +// phrase with the letters you haven't earned still blank — plus the script of +// what just happened. The phrase itself only ever arrives once the game is over +// and it no longer decides anything. +// +// The gallows is the meter. It counts your lives down and your winnings down at +// the same time, which is why a miss draws a limb *and* knocks the multiple back +// in the same beat: they are the same event, and showing them as one thing is +// the whole reason to bet on this rather than play it on paper. +(function () { + "use strict"; + + var root = document.querySelector("[data-hangman]"); + if (!root) return; + + var FX = window.PeteFX; + + var boardEl = root.querySelector("[data-board]"); + var gallowsEl = root.querySelector("[data-gallows]"); + var wrongEl = root.querySelector("[data-wrong]"); + var wrongLbl = root.querySelector("[data-wrong-label]"); + 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 livesEl = root.querySelector("[data-lives]"); + var verdictEl = root.querySelector("[data-verdict]"); + + var betting = root.querySelector("[data-betting]"); + var guessing = root.querySelector("[data-guessing]"); + var keysEl = root.querySelector("[data-keyboard]"); + var betAmount = root.querySelector("[data-bet-amount]"); + var startBtn = root.querySelector("[data-start]"); + var solveIn = root.querySelector("[data-solve-input]"); + var solveBtn = root.querySelector("[data-solve]"); + var msgEl = root.querySelector("[data-table-msg]"); + var gameMsgEl = root.querySelector("[data-game-msg]"); + + // The three places a chip can be, exactly as at the other table. + var purseEl = document.querySelector("[data-chips]"); + var spotEl = root.querySelector("[data-spot]"); + var stackEl = root.querySelector("[data-stack]"); + var spotTotalEl = root.querySelector("[data-spot-total]"); + var houseEl = root.querySelector("[data-house]"); + + var bet = 0; // what you're building between games + var staked = 0; // what is actually on the spot + var busy = false; + var game = null; // the board as the server last described it + var tier = "medium"; + + var FLIP_MS = 320; + var MISS_MS = 520; + + 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 board ------------------------------------------------------------- + + // renderBoard lays the phrase out as tiles. A tile is a letter you have to earn; + // a space or a piece of punctuation is scaffolding and gets no tile, because a + // row of blanks with the word breaks hidden is a puzzle about typography. + // + // Words are kept whole: the board wraps between words, never inside one. + function renderBoard(cells) { + boardEl.innerHTML = ""; + if (!cells) return; + + var word = document.createElement("div"); + word.className = "pete-word"; + + cells.forEach(function (c, i) { + if (!c.slot && (c.ch === " " || c.ch === "")) { + // A space: end the word and start the next one. + if (word.childNodes.length) boardEl.appendChild(word); + word = document.createElement("div"); + word.className = "pete-word"; + return; + } + var t = document.createElement("span"); + t.className = "pete-tile"; + t.dataset.at = String(i); + if (!c.slot) { + t.dataset.punct = "1"; // an exclamation mark is not a blank to fill + t.textContent = c.ch; + } else { + t.dataset.up = c.ch ? "1" : "0"; + t.textContent = c.ch || ""; + } + word.appendChild(t); + }); + if (word.childNodes.length) boardEl.appendChild(word); + } + + // turnUp flips the tiles a hit just earned, one after the other so a letter that + // appears three times reads as three finds rather than one repaint. + function turnUp(at, ch) { + if (!at || !at.length) return Promise.resolve(); + at.forEach(function (i, n) { + var t = boardEl.querySelector('.pete-tile[data-at="' + i + '"]'); + if (!t) return; + setTimeout(function () { + // Left as it comes: the tile is uppercased in CSS, and doing it here too + // would mean the resume path (which paints the phrase's own casing) and + // this one put different text in the same tile. + t.textContent = ch; + t.dataset.up = "1"; + t.classList.add("pete-tile-hit"); + }, pace(n * 90)); + }); + return wait(FLIP_MS + (at.length - 1) * 90); + } + + // ---- the gallows ----------------------------------------------------------- + + // drawGallows shows the first n parts. Each one draws itself in along its own + // length rather than fading up — the difference between a limb being *drawn* and + // a limb appearing is the whole character of the game. + function drawGallows(n, animateLast) { + var parts = gallowsEl.querySelectorAll("[data-part]"); + parts.forEach(function (p, i) { + var on = i < n; + var was = p.dataset.on === "1"; + p.dataset.on = on ? "1" : "0"; + if (on && !was && animateLast && i === n - 1 && !reduced) { + // Restart the draw-in animation on the part that was just earned. + p.classList.remove("pete-part-draw"); + void p.getBoundingClientRect(); + p.classList.add("pete-part-draw"); + } else if (on && !animateLast) { + p.classList.remove("pete-part-draw"); + } + }); + } + + function shake() { + if (reduced) return; + gallowsEl.classList.remove("pete-shake"); + void gallowsEl.getBoundingClientRect(); + gallowsEl.classList.add("pete-shake"); + } + + // ---- the meter ------------------------------------------------------------- + + function renderMeter(v) { + if (!v) { + multEl.textContent = "—"; + standsEl.textContent = "—"; + standsLbl.textContent = "if you get it"; + livesEl.textContent = ""; + meterEl.dataset.cold = "0"; + return; + } + multEl.textContent = v.multiple.toFixed(2) + "×"; + standsEl.textContent = (v.stands || 0).toLocaleString(); + standsLbl.textContent = v.phase === "done" ? "was on it" : "if you get it"; + livesEl.textContent = v.lives + (v.lives === 1 ? " life left" : " lives left"); + // The meter goes cold once the multiple is down at its floor: from here a win + // hands back the stake and nothing more. + meterEl.dataset.cold = v.multiple <= 1.001 ? "1" : "0"; + } + + // knock ticks the multiple down to its new value, so the number falls rather + // than simply being different. + function knock(v) { + if (reduced) { renderMeter(v); return; } + var from = parseFloat(multEl.textContent) || v.multiple; + var to = v.multiple; + var t0 = performance.now(); + meterEl.dataset.hit = "1"; + setTimeout(function () { meterEl.dataset.hit = "0"; }, 400); + + (function step(now) { + var p = Math.min(1, (now - t0) / 380); + 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 keyboard ---------------------------------------------------------- + + var ROWS = ["qwertyuiop", "asdfghjkl", "zxcvbnm", "0123456789"]; + + function buildKeys() { + keysEl.innerHTML = ""; + ROWS.forEach(function (row, r) { + var line = document.createElement("div"); + line.className = "pete-key-row"; + if (r === 3) line.dataset.digits = "1"; + row.split("").forEach(function (ch) { + var b = document.createElement("button"); + b.type = "button"; + b.className = "pete-key"; + b.dataset.key = ch; + b.textContent = ch.toUpperCase(); + b.addEventListener("click", function () { guessLetter(ch); }); + line.appendChild(b); + }); + keysEl.appendChild(line); + }); + } + + // paintKeys marks every letter that's been tried, and how it went. A key that + // has been spent should look spent — otherwise you spend it again. + function paintKeys(v) { + var tried = (v && v.tried) || []; + var wrong = (v && v.wrong) || []; + keysEl.querySelectorAll(".pete-key").forEach(function (b) { + var ch = b.dataset.key; + var used = tried.indexOf(ch) !== -1; + b.disabled = used || !v || v.phase !== "playing"; + b.dataset.state = !used ? "" : wrong.indexOf(ch) !== -1 ? "miss" : "hit"; + }); + } + + function renderWrong(v) { + wrongEl.innerHTML = ""; + var wrong = (v && v.wrong) || []; + // A wrong *solve* is recorded as a miss with no letter on it — it cost a life + // and it's on the gallows, but there's no key to grey out for it. + var letters = wrong.filter(function (c) { return c !== "·"; }); + wrongLbl.classList.toggle("hidden", letters.length === 0); + letters.forEach(function (ch) { + var s = document.createElement("span"); + s.className = "pete-missed"; + s.textContent = ch.toUpperCase(); + wrongEl.appendChild(s); + }); + } + + // ---- the money on the felt ------------------------------------------------- + // Lifted wholesale from the blackjack table, and deliberately identical: a chip + // has to behave the same way in both rooms or it isn't a chip, it's a widget. + + function renderStack(amount) { + staked = amount || 0; + stackEl.innerHTML = ""; + spotEl.dataset.live = staked > 0 ? "1" : "0"; + if (!staked) { spotTotalEl.classList.add("hidden"); return; } + + FX.chipsFor(staked).forEach(function (d, i) { + var c = FX.disc(d); + c.style.setProperty("--i", i); + c.style.setProperty("--spin", FX.jitter(i, 12).toFixed(1) + "deg"); + c.style.animationDelay = pace(i * 40) + "ms"; + stackEl.appendChild(c); + }); + spotTotalEl.textContent = staked.toLocaleString(); + spotTotalEl.classList.remove("hidden"); + } + + function pour(from, to, amount, opts) { + if (amount <= 0) return Promise.resolve(); + var base = staked; + var chips = FX.chipsFor(amount, 8); + var run = 0; + return FX.flyMany(from, to, chips, Object.assign({ + onLand: function (d, i) { + run += d; + renderStack(base + (i === chips.length - 1 ? amount : run)); + }, + }, opts || {})); + } + + function stake(amount, from) { + return pour(from || purseEl, spotEl, amount); + } + + function settleChips(final) { + var payout = final.payout || 0; + var back = payout - final.bet; + + if (payout <= 0) { + var lost = FX.chipsFor(final.bet, 8); + var chain = FX.flyMany(spotEl, houseEl, lost, { gap: 45, lift: 0.6, fade: true }); + renderStack(0); + return chain; + } + var pay = pour(houseEl, spotEl, back, { gap: 60 }); + return pay + .then(function () { return wait(back > 0 ? 380 : 200); }) + .then(function () { + var home = FX.flyMany(spotEl, purseEl, FX.chipsFor(payout, 8), { gap: 40, lift: 0.8 }); + renderStack(0); + return home; + }); + } + + // ---- phases ---------------------------------------------------------------- + + var VERDICTS = { + solved: "Got it! 🎉", + filled: "That's the lot!", + hung: "Hung.", + }; + + function verdict(v) { + var text = VERDICTS[v.outcome] || ""; + if (!text) { verdictEl.classList.add("hidden"); return; } + if (v.outcome === "hung" && v.phrase) text = "Hung. It was “" + v.phrase + "”."; + 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 for a phrase guessed outright — the one call you make on your own + // rather than by grinding the alphabet. + if (v.outcome === "solved" && v.net > 0) FX.burst(verdictEl, { count: 30 }); + } + + function setPhase(v) { + game = v; + var live = !!v && v.phase === "playing"; + betting.classList.toggle("hidden", live); + guessing.classList.toggle("hidden", !live); + paintKeys(v); + if (!live && solveIn) solveIn.value = ""; + if (!v || !v.outcome) verdictEl.classList.add("hidden"); + } + + // paint puts a board up with no animation: the resume path, after a reload or a + // redeploy. Your stake is still on the spot because the server still has it. + function paint(v) { + if (!v) { + renderBoard(null); + drawGallows(0, false); + renderWrong(null); + renderMeter(null); + renderStack(0); + setPhase(null); + return; + } + renderBoard(v.cells); + drawGallows((v.wrong || []).length, false); + renderWrong(v); + renderMeter(v); + renderStack(v.phase === "done" ? 0 : v.bet); + setPhase(v); + } + + // ---- the script ------------------------------------------------------------ + + // play walks the server's events. Same rule as the other table: on a live game + // 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. + function play(view, money) { + var events = view.hang_events || []; + var final = view.hangman; + var settles = !!final && final.phase === "done"; + var chain = Promise.resolve(); + + if (!settles) money(); + + if (final && final.bet > staked) { + var extra = final.bet - staked; + chain = chain.then(function () { return stake(extra); }); + } + + events.forEach(function (e) { + chain = chain.then(function () { + switch (e.kind) { + case "start": + verdictEl.classList.add("hidden"); + renderBoard(final && final.cells); + drawGallows(0, false); + renderWrong(null); + renderMeter(final); + return; + + case "hit": + return turnUp(e.at, e.letter); + + case "miss": + // The limb, the shake and the multiple falling are one event, because + // they are one event: this is what a wrong guess costs, all of it. + drawGallows(countMisses(events, e), true); + shake(); + if (final) knock(final); + return wait(MISS_MS); + + case "solve": + return wait(220); + + case "settle": + return; + } + }); + }); + + return chain.then(function () { + if (!final) { paint(null); money(); return; } + + renderWrong(final); + if (!settles) { + renderMeter(final); + setPhase(final); + return; + } + + // Over: the board goes fully face up (the server has finally sent the + // phrase), then the money moves, and only then does the bar catch up. + guessing.classList.add("hidden"); + renderBoard(final.cells); + renderMeter(final); + verdict(final); + return settleChips(final) + .then(money) + .then(function () { return standing(final.bet); }) + .then(function () { setPhase(final); }); + }); + } + + // countMisses works out how many limbs should be on the gallows by the time + // this miss has been played — the misses already on the board when the request + // went out, plus every miss in this batch up to and including this one. + function countMisses(events, upTo) { + var before = game ? (game.wrong || []).length : 0; + var n = 0; + for (var i = 0; i < events.length; i++) { + if (events[i].kind === "miss") n++; + if (events[i] === upTo) break; + } + return before + n; + } + + // standing puts the stake back on the spot for the next phrase, the way the + // blackjack table 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(); + return stake(amount); + } + + // ---- 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.hangman) renderStack(0); + }); + }) + .then(function () { busy = false; }); + } + + function guessLetter(ch) { + if (busy || !game || game.phase !== "playing") return; + if ((game.tried || []).indexOf(ch) !== -1) return; + send("/api/games/hangman/guess", { letter: ch }, gameMsgEl); + } + + // ---- betting ---------------------------------------------------------------- + + function showBet() { + betAmount.textContent = bet.toLocaleString(); + var money = window.PeteGames.view(); + if (startBtn) startBtn.disabled = bet <= 0 || !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"; + }); + } + + root.querySelectorAll("[data-tier]").forEach(function (b) { + b.addEventListener("click", function () { + if (busy) return; + pickTier(b.dataset.tier); + }); + }); + + root.querySelectorAll("[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; + staked = bet; + FX.fly(btn, spotEl, { denom: d }).then(function () { + if (bet >= target) renderStack(target); + }); + }); + }); + + var clearBtn = root.querySelector("[data-bet-clear]"); + if (clearBtn) { + clearBtn.addEventListener("click", function () { + if (busy || !staked) { bet = 0; showBet(); return; } + FX.flyMany(spotEl, purseEl, FX.chipsFor(staked, 8), { gap: 40, lift: 0.7 }); + bet = 0; + renderStack(0); + showBet(); + }); + } + + if (startBtn) { + startBtn.addEventListener("click", function () { + if (bet <= 0) { say("Put something on it first.", "bad"); return; } + send("/api/games/hangman/start", { bet: bet, tier: tier }); + }); + } + + function solve() { + if (busy || !game || game.phase !== "playing") return; + var attempt = (solveIn.value || "").trim(); + if (!attempt) { say("Say what it is, then.", "bad", gameMsgEl); return; } + send("/api/games/hangman/guess", { solve: attempt }, gameMsgEl); + } + + if (solveBtn) solveBtn.addEventListener("click", solve); + if (solveIn) { + solveIn.addEventListener("keydown", function (e) { + if (e.key === "Enter") { e.preventDefault(); solve(); } + }); + } + + // Type a letter to guess it — but not while you're typing a solution into the + // box, which is the whole reason this checks what has focus. + 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 ch = (e.key || "").toLowerCase(); + if (!/^[a-z0-9]$/.test(ch)) return; + e.preventDefault(); + guessLetter(ch); + }); + + buildKeys(); + pickTier(tier); + + var resumed = false; + window.PeteGames.onUpdate(function (v) { + if (!resumed) { + resumed = true; + if (v.hangman) { + paint(v.hangman); + if (v.hangman.phase === "done") verdict(v.hangman); + } else { + paint(null); + } + } + showBet(); + }); +})(); diff --git a/internal/web/templates/games.html b/internal/web/templates/games.html index f418bce..d30a923 100644 --- a/internal/web/templates/games.html +++ b/internal/web/templates/games.html @@ -42,6 +42,22 @@

+ +
+ 🪢 +
+

Hangman

+

Guess the phrase, keep the multiple.

+
+ Open +
+

+ Short phrases pay up to 2.6×. You get {{.MaxWrong}} lives, and every wrong guess + takes a tenth off what a win is worth. +

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

{{.MaxWrong}} lives · every wrong guess costs you a tenth of the win

+
+ + {{template "_chipbar" .}} + + +
+ + + + +
+ +
+ + The gallows + + + + + + + + + + + + + + + + + +
+
+ Bet +
+ +
+

+
+
+ +
+ + +
+
+ Pays + +
+

+ + if you get it +

+
+ + +
+ + +
+ +
+
+ + +
+ +
+ +
+
+
+ + + + + +
+ +
How long a phrase?
+
+ {{range .Tiers}} + + {{end}} +
+ +
+
+
Your bet
+
0
+
+ +
+ {{range .Denominations}} + + {{end}} + +
+ + +
+ +
+ +
+{{end}} + +{{define "scripts"}} + + + +{{end}} diff --git a/internal/web/tts.go b/internal/web/tts.go index 998f227..6e8f447 100644 --- a/internal/web/tts.go +++ b/internal/web/tts.go @@ -19,7 +19,7 @@ import ( ) const ( - ttsMaxTextLen = 6000 // per-request cap; a single paragraph is ~1-2k chars + ttsMaxTextLen = 6000 // per-request cap; a single paragraph is ~1-2k chars ttsTimeout = 60 * time.Second ttsMaxConcurrent = 3 // cap simultaneous piper processes so a burst can't pin the box ) diff --git a/pete_games_plan.md b/pete_games_plan.md index 3e9f1dc..a11efc7 100644 --- a/pete_games_plan.md +++ b/pete_games_plan.md @@ -147,9 +147,55 @@ A multi-session build. This section is the handover; read it before anything els that adventure news already set. Restarted, it logs `pete games: escrow loop started interval=3s`. +- **Hangman, and it plays for chips.** *(2026-07-14. This revises §7's "Phase 2 — + no escrow": the decision was that a free game in a casino reads as a demo, so + hangman stakes chips like everything else and reuses the money path whole.)* + - **The gallows is the payout meter.** You pick a tier, stake, and get six + lives. Every wrong guess draws a limb *and* takes a tenth off the base + multiple — one event, shown as one event. Short phrases pay 2.6×, medium 2.0×, + long 1.6× (short is hardest: fewer letters, less to go on). Floored at 1×, so + a win never hands back less than the stake, and the rake still comes out of + winnings only. + - `internal/games/hangman` — the same pure reducer as blackjack, phrases + embedded (`phrases.txt`, 205 of them, video-game flavoured, lifted from + gogobee). `State.Pays()` is the number the felt quotes *and* the number + settle() lands on: they were briefly two sums and the table advertised a + pre-rake payout it didn't honour. One function now, and a test that walks a + game asserting the quote equals the payout at every step. + - **The browser never sees the phrase.** Cells carry the letter or an empty + string — not the letter with a hidden flag — and the phrase itself is only + added to the payload once the game is over and it decides nothing. + - Two things the storage layer already gave us for free, and one it didn't: + `game_live_hands` is keyed on the *player*, so "one game at a time" holds + across games with no new code (a live hangman 409s a blackjack deal). But + `table()` used to unmarshal any live row as a blackjack hand — which does not + *fail* on a hangman row, it just silently yields an empty hand. It now + dispatches on `live.Game`. + - `commit()` in games_play.go is the shared settle path (seat → pay → audit → + clear → touch). Both games go through it so neither re-derives an ordering + that took a while to get right. `casinoRoutes()` is likewise the single route + list, because devcasino_test.go has to wire its own mux and a second copy is + a copy that stops including the newest game. + - Driven in a real browser, win and loss: a 200 stake at 2.34× paid 455 and the + bar landed on it; six wrong took the stake and nothing more; a reload + mid-phrase brought back the board, the limbs, the multiple, the spent keys and + the stake on the spot. Two layout bugs only the browser could show: the lives + counter ran under the house rack, and the board wrapped a word early because + the rack's clearance padding was on the whole column instead of the one row + level with it. + ### Next, in order -1. Phase 2 (trivia, hangman), 3 (UNO), 4 (hold'em) as below. +1. Phase 2's other half: **trivia**. Decided but not built: the question bank is + **prefetched from OpenTDB into a local table** (a per-question fetch in a web + game loop is a latency and rate-limit problem gogobee never had), through + `internal/safehttp`. It stakes chips too. The shape that fits the room is a + **ladder**: stake once, answer a run of questions, each right answer compounds + the multiple and a wrong one loses the lot, with the option to walk. Note the + real risk — trivia answers are googlable, so a tight per-question clock is the + only thing making a slow-but-correct answer worth less than a fast one. Score + the clock server-side; the browser's countdown is decoration. +2. Phase 3 (UNO), Phase 4 (hold'em) as below. Still open on the table itself, none of it blocking: **split** isn't implemented (the engine has no move for it), the felt is roomy at desktop widths with only one seat on