diff --git a/config.example.toml b/config.example.toml index 67b3311..d9f490d 100644 --- a/config.example.toml +++ b/config.example.toml @@ -102,6 +102,16 @@ label = "Amy (US, female)" id = "en_US-ryan-high" label = "Ryan (US, male, HQ)" +# The casino (games.parodia.dev). Signed-in only — there is real money in it — so +# it needs [web.auth] above, and web.auth.cookie_domain if the games host is a +# different subdomain from the news one. Chips are 1:1 with gogobee euros and +# cross the border through the escrow endpoints, which gogobee polls; matrix_server +# is how a player is named to that ledger (@:). +[web.games] +enabled = false +host = "games.parodia.dev" +matrix_server = "parodia.dev" + # Every enabled source MUST set direct_route to a key from [matrix.channels] above. # There is no automatic classification — Pete posts each story to its configured channel. # Optional: language = "en" drops feed items whose per-item tag is diff --git a/internal/config/config.go b/internal/config/config.go index f9df620..af4fae8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -53,13 +53,14 @@ const defaultDigestHour = 17 // WebConfig controls the read-only HTTP interface (news.parodia.dev style). type WebConfig struct { - Enabled bool `toml:"enabled"` - ListenAddr string `toml:"listen_addr"` // e.g. ":8080" or "127.0.0.1:8080" - SiteTitle string `toml:"site_title"` // display name in the header - BaseURL string `toml:"base_url"` // public URL (used in metadata only) - Auth AuthConfig `toml:"auth"` // optional OIDC sign-in (Authentik) - Push PushConfig `toml:"push"` // optional Web Push digests (VAPID) - TTS TTSConfig `toml:"tts"` // optional server-side neural read-aloud (Piper) + Enabled bool `toml:"enabled"` + ListenAddr string `toml:"listen_addr"` // e.g. ":8080" or "127.0.0.1:8080" + SiteTitle string `toml:"site_title"` // display name in the header + BaseURL string `toml:"base_url"` // public URL (used in metadata only) + Auth AuthConfig `toml:"auth"` // optional OIDC sign-in (Authentik) + Push PushConfig `toml:"push"` // optional Web Push digests (VAPID) + TTS TTSConfig `toml:"tts"` // optional server-side neural read-aloud (Piper) + Games GamesConfig `toml:"games"` // optional casino (games.parodia.dev) // AdminSubs is the allowlist of OIDC subjects allowed to view the // owner-facing source-health dashboard at /status. Empty means the page is // inaccessible to everyone (returns 404). Requires auth to be enabled. @@ -108,6 +109,23 @@ type VoiceConfig struct { Label string `toml:"label"` // menu label, e.g. "Ryan — US, male" } +// GamesConfig wires the casino. It is signed-in only — there is money in it — +// so it does nothing without web.auth, and it needs the Matrix server name +// because that is how a player's identity reaches gogobee's euro ledger: an +// Authentik username is the Matrix localpart, and the Matrix id is the account. +type GamesConfig struct { + Enabled bool `toml:"enabled"` + // Host is the public hostname the casino answers on, e.g. + // "games.parodia.dev". Requests arriving on it are served the casino at "/"; + // everywhere else the same pages live under /games. Empty means no host + // branching, which is the normal state of affairs in local development. + Host string `toml:"host"` + // MatrixServer is the server name half of a player's Matrix id + // ("parodia.dev" -> @reala:parodia.dev). Without it, no player can be + // identified in the economy and the casino stays shut. + MatrixServer string `toml:"matrix_server"` +} + // AuthConfig wires Pete's web UI to an OIDC provider (our Authentik instance). // When enabled, signed-in users get their preferences stored server-side keyed // by the OIDC subject; anonymous visitors keep using browser localStorage. diff --git a/internal/storage/games.go b/internal/storage/games.go index 9b93580..b3a1621 100644 --- a/internal/storage/games.go +++ b/internal/storage/games.go @@ -503,6 +503,91 @@ func Touch(user string) { nowUnix(), nowUnix(), user) } +// ---- the hand in progress ------------------------------------------------- + +var ( + // ErrNoLiveHand means the player isn't in a hand right now. + ErrNoLiveHand = errors.New("games: no hand in progress") + // ErrHandInProgress means they already are, and may not be dealt another. + ErrHandInProgress = errors.New("games: already in a hand") +) + +// LiveHand is a hand a player is in the middle of. State is the engine's own +// State, serialized whole — the shoe is in there, which is exactly why this row +// never leaves the server. +type LiveHand struct { + Game string + State []byte + Seed1 uint64 + Seed2 uint64 +} + +// StartLiveHand seats a *new* hand, and refuses if the player is already in one. +// The plain INSERT is the point: it is the primary key, not a prior read, that +// decides. Two Deal clicks racing each other would otherwise both see an empty +// felt, both take a stake, and the second would overwrite the first — taking the +// player's chips for a hand that no longer exists anywhere. +func StartLiveHand(user string, h LiveHand) error { + res, err := Get().Exec( + `INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(matrix_user) DO NOTHING`, + user, h.Game, string(h.State), int64(h.Seed1), int64(h.Seed2), nowUnix(), + ) + if err != nil { + return fmt.Errorf("games: start live hand: %w", err) + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrHandInProgress + } + return nil +} + +// SaveLiveHand stores the hand a player is in, replacing any earlier one. The +// player's stake has already left their stack by the time this is called, so +// the write is what makes the hand recoverable if Pete restarts mid-deal. +func SaveLiveHand(user string, h LiveHand) error { + now := nowUnix() + if _, err := Get().Exec( + `INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(matrix_user) DO UPDATE SET + game = excluded.game, state = excluded.state, + seed1 = excluded.seed1, seed2 = excluded.seed2, updated_at = excluded.updated_at`, + user, h.Game, string(h.State), int64(h.Seed1), int64(h.Seed2), now, + ); err != nil { + return fmt.Errorf("games: save live hand: %w", err) + } + return nil +} + +// LoadLiveHand returns the hand a player is in, or ErrNoLiveHand. +func LoadLiveHand(user string) (LiveHand, error) { + var h LiveHand + var state string + var s1, s2 int64 + err := Get().QueryRow( + `SELECT game, state, seed1, seed2 FROM game_live_hands WHERE matrix_user = ?`, user, + ).Scan(&h.Game, &state, &s1, &s2) + if errors.Is(err, sql.ErrNoRows) { + return LiveHand{}, ErrNoLiveHand + } + if err != nil { + return LiveHand{}, fmt.Errorf("games: load live hand: %w", err) + } + h.State, h.Seed1, h.Seed2 = []byte(state), uint64(s1), uint64(s2) + return h, nil +} + +// ClearLiveHand ends a hand. Called when it settles — the audit log in +// game_hands is what survives it. +func ClearLiveHand(user string) error { + if _, err := Get().Exec(`DELETE FROM game_live_hands WHERE matrix_user = ?`, user); err != nil { + return fmt.Errorf("games: clear live hand: %w", err) + } + return nil +} + // HouseTake is the total rake collected since a given time — the number that // answers "is this economy inflating". func HouseTake(since int64) (int64, error) { diff --git a/internal/storage/schema.go b/internal/storage/schema.go index ee51bc7..a890436 100644 --- a/internal/storage/schema.go +++ b/internal/storage/schema.go @@ -225,6 +225,23 @@ CREATE TABLE IF NOT EXISTS game_hands ( CREATE INDEX IF NOT EXISTS idx_game_hands_user ON game_hands(matrix_user, played_at DESC); CREATE INDEX IF NOT EXISTS idx_game_hands_played ON game_hands(played_at); +-- The hand a player is in the middle of. One per player: you cannot be dealt a +-- second hand while chips are riding on the first. +-- +-- The state column is the engine's State, serialized whole — shoe included. It +-- lives here rather than in memory because Pete redeploys often, and a player +-- whose stake has already been taken must find their cards where they left them +-- rather than a table that has forgotten them. It is also why the deck never +-- goes to the browser: the authoritative shoe is this row, on the server. +CREATE TABLE IF NOT EXISTS game_live_hands ( + matrix_user TEXT PRIMARY KEY, + game TEXT NOT NULL, -- 'blackjack' + state TEXT NOT NULL, -- JSON: the engine's State + seed1 INTEGER NOT NULL, -- carried to the audit log when it settles + seed2 INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + CREATE UNIQUE INDEX IF NOT EXISTS idx_post_log_guid_channel ON post_log(guid, channel); CREATE INDEX IF NOT EXISTS idx_post_log_event_id ON post_log(event_id); CREATE INDEX IF NOT EXISTS idx_post_log_channel_posted ON post_log(channel, posted_at); diff --git a/internal/web/games_pages.go b/internal/web/games_pages.go new file mode 100644 index 0000000..5547825 --- /dev/null +++ b/internal/web/games_pages.go @@ -0,0 +1,84 @@ +package web + +import ( + "net/http" + + "pete/internal/games/blackjack" + "pete/internal/storage" +) + +// The casino's two pages. Both require a signed-in visitor — there is money in +// here, and a player has to be somebody gogobee's ledger can name. +// +// Neither page renders any game state server-side. The felt is drawn by the +// browser from /api/games/table, because a hand is a thing that *happens*: cards +// are dealt one at a time and the table plays that back. A server-rendered hand +// would arrive fully formed, which is the one thing a card table must never do. + +// gameTeaser is a table that isn't open yet. They're on the lobby because an +// empty casino with one game reads as broken, and this reads as early. +type gameTeaser struct { + Name string + Emoji string + Blurb string +} + +var comingSoon = []gameTeaser{ + {Name: "Hold'em", Emoji: "♠️", Blurb: "Six seats, and Pete's 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 Pete finishes drawing."}, +} + +// betDenominations are the chips you build a bet out of. +var betDenominations = []int64{5, 25, 100, 500} + +type gamesPage struct { + pageData + Cap int64 + RakePct int + Soon []gameTeaser + Denominations []int64 +} + +// requirePlayer sends an anonymous visitor to sign in and comes back here after. +// Anyone who is signed in but carries a session from before the casino existed +// has no username in it, so they get sent through sign-in too — which mints one. +func (s *Server) requirePlayer(w http.ResponseWriter, r *http.Request) bool { + if !s.gamesReady() { + http.NotFound(w, r) + return false + } + u := s.auth.userFromRequest(r) + if u != nil && u.MatrixUser(s.cfg.Games.MatrixServer) != "" { + return true + } + http.Redirect(w, r, "/auth/login?next="+r.URL.Path, http.StatusFound) + return false +} + +func (s *Server) gamesPage(r *http.Request) gamesPage { + base := s.base(r) + base.NoIndex = true // the casino is for players, not for search engines + return gamesPage{ + pageData: base, + Cap: storage.MaxChipsOnTable, + RakePct: int(blackjack.DefaultRules().RakePct * 100), + Soon: comingSoon, + Denominations: betDenominations, + } +} + +func (s *Server) handleLobby(w http.ResponseWriter, r *http.Request) { + if !s.requirePlayer(w, r) { + return + } + s.render(w, "games", s.gamesPage(r)) +} + +func (s *Server) handleBlackjack(w http.ResponseWriter, r *http.Request) { + if !s.requirePlayer(w, r) { + return + } + s.render(w, "blackjack", s.gamesPage(r)) +} diff --git a/internal/web/games_play.go b/internal/web/games_play.go new file mode 100644 index 0000000..7757252 --- /dev/null +++ b/internal/web/games_play.go @@ -0,0 +1,530 @@ +package web + +import ( + "encoding/json" + "errors" + "io" + "log/slog" + "math/rand/v2" + "net/http" + "time" + + "pete/internal/games/blackjack" + "pete/internal/games/cards" + "pete/internal/storage" +) + +// The table, as a browser sees it. +// +// Everything here is server-authoritative. The browser sends intents — deal me +// in, hit, stand — and gets back a *view*: the cards it is entitled to see and +// nothing else. The shoe stays in game_live_hands, on this side of the wire. +// That is not belt-and-braces, it is the whole reason the engines are Go: a game +// with money on it cannot trust a client-reported result, and a client that +// holds the deck can read the next card. +// +// The stake leaves the player's stack *before* the hand is dealt, and comes back +// only through the engine's own payout. So a hand that crashes halfway costs the +// player their bet and nothing more, and a hand that Pete restarts through is +// still sitting there when they come back. + +// gamesReady reports whether the casino can actually run: it needs sign-in (a +// player has to be someone) and a Matrix server name (that someone has to exist +// in gogobee's ledger). +func (s *Server) gamesReady() bool { + return s.cfg.Games.Enabled && s.auth != nil && s.cfg.Games.MatrixServer != "" +} + +// player resolves the signed-in visitor to their Matrix id, or writes the +// failure. An empty id means a session from before games existed, which carries +// no username: sending them back through sign-in mints one. +func (s *Server) player(w http.ResponseWriter, r *http.Request) (string, bool) { + if !s.gamesReady() { + http.NotFound(w, r) + return "", false + } + u := s.auth.userFromRequest(r) + if u == nil { + writeJSONStatus(w, http.StatusUnauthorized, map[string]string{"error": "sign in to play"}) + return "", false + } + mx := u.MatrixUser(s.cfg.Games.MatrixServer) + if mx == "" { + writeJSONStatus(w, http.StatusForbidden, map[string]string{ + "error": "your session predates the casino — sign out and back in", + }) + return "", false + } + return mx, true +} + +// ---- what the browser is allowed to see ----------------------------------- + +// cardView is one card, pre-rendered. The browser draws faces, not logic: it +// gets the glyph and the colour rather than a rank it has to map itself. +type cardView struct { + Label string `json:"label"` // "A♠" + Rank string `json:"rank"` // "A" + Suit string `json:"suit"` // "♠" + Red bool `json:"red"` +} + +func viewCard(c cards.Card) cardView { + label := c.String() + // String() renders rank then a three-byte suit glyph, except for a card that + // isn't one ("??"), which has no glyph to split off. + if len(label) <= len("♠") { + return cardView{Label: label, Rank: label} + } + cut := len(label) - len("♠") + return cardView{Label: label, Rank: label[:cut], Suit: label[cut:], Red: c.Red()} +} + +// handView is a blackjack hand as its player may see it. While they are still +// acting, the dealer's hole card is *absent* — not sent and flagged hidden, but +// genuinely not in the payload. A field the browser is told to ignore is a field +// somebody reads in devtools. +type handView struct { + Phase string `json:"phase"` + Bet int64 `json:"bet"` + Player []cardView `json:"player"` + Dealer []cardView `json:"dealer"` + Hole bool `json:"hole"` // true: the dealer has a face-down card + Total int `json:"total"` + Soft bool `json:"soft"` + DTotal int `json:"dealer_total"` // what the *shown* dealer cards add to + Outcome string `json:"outcome,omitempty"` + Payout int64 `json:"payout,omitempty"` + Rake int64 `json:"rake,omitempty"` + Net int64 `json:"net"` + Double bool `json:"can_double"` +} + +func viewHand(st blackjack.State) handView { + v := handView{ + Phase: string(st.Phase), + Bet: st.Bet, + Outcome: string(st.Outcome), + Payout: st.Payout, + Rake: st.Rake, + Net: st.Net(), + Double: st.CanDouble(), + } + for _, c := range st.Player { + v.Player = append(v.Player, viewCard(c)) + } + v.Total, v.Soft = blackjack.HandValue(st.Player) + + dealer := st.Dealer + if st.Phase == blackjack.PhasePlayer && len(dealer) > 1 { + dealer = dealer[:1] // the hole card is the dealer's business until it isn't + v.Hole = true + } + for _, c := range dealer { + v.Dealer = append(v.Dealer, viewCard(c)) + } + v.DTotal, _ = blackjack.HandValue(dealer) + return v +} + +// eventView is the dealing script. The engine emits one event per card off the +// shoe, in order, and the table animates them one at a time — which is why the +// events go over the wire at all rather than the browser diffing two states. +type eventView struct { + Kind string `json:"kind"` + Card *cardView `json:"card,omitempty"` + Text string `json:"text,omitempty"` +} + +// viewEvents renders the engine's events for the browser, dropping the cards it +// is not yet allowed to see: the dealer's second card is dealt face-down, and +// only the "reveal" event turns it over. +func viewEvents(evs []blackjack.Event, phase blackjack.Phase) []eventView { + out := make([]eventView, 0, len(evs)) + dealerCards := 0 + for _, e := range evs { + v := eventView{Kind: e.Kind, Text: e.Text} + if e.Card != nil { + c := viewCard(*e.Card) + v.Card = &c + } + if e.Kind == "dealer_card" { + dealerCards++ + // The hole card, while the hand is still the player's to play: send + // the event so the table deals a face-down card, but not the face. + if dealerCards == 2 && phase == blackjack.PhasePlayer { + v.Card = nil + v.Kind = "dealer_hole" + } + } + out = append(out, v) + } + return out +} + +// tableView is the whole page state: the money, and the hand if there is one. +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"` +} + +// table reads the player's money and any hand in progress. +func (s *Server) table(user string) (tableView, error) { + st, err := storage.Chips(user) + if err != nil { + return tableView{}, err + } + v := tableView{ + Chips: st.Chips, + Pending: st.Pending, + Euros: st.EuroBalance, + Cap: storage.MaxChipsOnTable, + Rake: blackjack.DefaultRules().RakePct, + } + live, err := storage.LoadLiveHand(user) + if errors.Is(err, storage.ErrNoLiveHand) { + return v, nil + } + 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 + } + hv := viewHand(hand) + v.Hand = &hv + return v, nil +} + +// ---- handlers ------------------------------------------------------------- + +// handleTable is the page's poll: chips, euros, and whatever hand is on the felt. +func (s *Server) handleTable(w http.ResponseWriter, r *http.Request) { + user, ok := s.player(w, r) + if !ok { + return + } + v, err := s.table(user) + if err != nil { + slog.Error("games: table", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + writeJSON(w, v) +} + +// amountBody is {amount: n} — chips, which are euros. +type amountBody struct { + Amount int64 `json:"amount"` +} + +func decodeJSON(r *http.Request, v any) error { + return json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(v) +} + +// handleBuyIn opens a buy-in. It creates no chips: it writes an escrow row, and +// gogobee decides — up to three seconds later — whether the player could afford +// it. The browser watches `pending` fall to zero to know how it went. +func (s *Server) handleBuyIn(w http.ResponseWriter, r *http.Request) { + user, ok := s.player(w, r) + if !ok { + return + } + var req amountBody + if err := decodeJSON(r, &req); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + + e, err := storage.RequestBuyIn(user, req.Amount) + switch { + case errors.Is(err, storage.ErrBadAmount): + writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "buy in for something more than nothing"}) + return + case errors.Is(err, storage.ErrOverTableCap): + writeJSONStatus(w, http.StatusBadRequest, map[string]string{ + "error": "that would put more than the table cap in front of you", + }) + return + case err != nil: + slog.Error("games: buy-in", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + storage.Touch(user) + slog.Info("games: buy-in requested", "user", user, "amount", e.Amount, "guid", e.GUID) + v, err := s.table(user) + if err != nil { + slog.Error("games: table after buy-in", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + writeJSON(w, v) +} + +// handleCashOut sends chips back across the border. The chips are destroyed +// here and now — see storage.RequestCashOut for why that has to happen before +// gogobee has said anything — so the response already shows an empty stack. An +// amount of zero or less means "all of it", which is what the button does. +func (s *Server) handleCashOut(w http.ResponseWriter, r *http.Request) { + user, ok := s.player(w, r) + if !ok { + return + } + var req amountBody + if err := decodeJSON(r, &req); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + + // You cannot walk away from a hand you have chips riding on. The stake is + // already off the stack, so this isn't about the money — it's that a hand + // left half-played would settle into a session that no longer exists. + if _, err := storage.LoadLiveHand(user); err == nil { + writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the hand first"}) + return + } else if !errors.Is(err, storage.ErrNoLiveHand) { + slog.Error("games: cash-out live-hand check", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + amount := req.Amount + if amount <= 0 { + st, err := storage.Chips(user) + if err != nil { + slog.Error("games: cash-out", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + amount = st.Chips + } + + e, err := storage.RequestCashOut(user, amount) + switch { + case errors.Is(err, storage.ErrBadAmount), errors.Is(err, storage.ErrInsufficientChips): + writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you don't have those chips"}) + return + case err != nil: + slog.Error("games: cash-out", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + slog.Info("games: cash-out requested", "user", user, "amount", e.Amount, "guid", e.GUID) + v, err := s.table(user) + if err != nil { + slog.Error("games: table after cash-out", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + writeJSON(w, v) +} + +// ---- blackjack ------------------------------------------------------------ + +// handleDeal takes the bet and deals. The order matters: chips are staked first, +// in the same statement that checks they exist, so two deals fired at once +// cannot bet the same chip. Only then is a hand dealt. +func (s *Server) handleDeal(w http.ResponseWriter, r *http.Request) { + user, ok := s.player(w, r) + if !ok { + return + } + var req struct { + Bet int64 `json:"bet"` + } + if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 { + writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"}) + 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: stake", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + seed1, seed2 := newSeeds() + rng := rand.New(rand.NewPCG(seed1, seed2)) + st, evs, err := blackjack.New(req.Bet, blackjack.DefaultRules(), rng) + if err != nil { + // The hand never happened, so the stake never should have left. Give it back. + _ = storage.Award(user, req.Bet) + slog.Error("games: deal", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + s.persist(w, user, st, evs, seed1, seed2, true) +} + +// handleMove plays one move of the hand in progress. +func (s *Server) handleMove(w http.ResponseWriter, r *http.Request) { + user, ok := s.player(w, r) + if !ok { + return + } + var req struct { + Move string `json:"move"` + } + if err := decodeJSON(r, &req); 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 hand in progress"}) + return + } + if err != nil { + slog.Error("games: load hand", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + var st blackjack.State + if err := json.Unmarshal(live.State, &st); err != nil { + slog.Error("games: unreadable live hand", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + move := blackjack.Move(req.Move) + + // A double doubles the stake, so the extra chips have to be taken before the + // move is applied — and if they aren't there, the move simply isn't legal. + // Take them first: if the engine then refuses the move, they go straight back. + doubled := false + if move == blackjack.Double { + if !st.CanDouble() { + writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you can only double on the first two cards"}) + return + } + if err := storage.Stake(user, st.Bet); err != nil { + if errors.Is(err, storage.ErrInsufficientChips) { + writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips to double"}) + return + } + slog.Error("games: stake double", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + doubled = true + } + + next, evs, err := blackjack.ApplyMove(st, move) + if err != nil { + if doubled { + _ = storage.Award(user, st.Bet) // the move didn't happen; neither did the raise + } + writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "that move isn't legal here"}) + return + } + 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 + } + + // 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} + save := storage.SaveLiveHand + if fresh { + save = storage.StartLiveHand + } + if err := save(user, hand); 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 + } + slog.Error("games: save hand", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + if st.Phase == blackjack.PhaseDone { + // 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 — + // 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) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + 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, + }); err != nil { + slog.Error("games: record hand", "user", user, "err", err) // audit only; don't fail the player's hand + } + if err := storage.ClearLiveHand(user); err != nil { + slog.Error("games: clear hand", "user", user, "err", err) + } + } + + storage.Touch(user) + + v, err := s.table(user) + if err != nil { + slog.Error("games: table", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + 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 { + hv := viewHand(st) + v.Hand = &hv + } + v.Events = viewEvents(evs, st.Phase) + writeJSON(w, v) +} + +// newSeeds mints the shoe's seed. It goes in the audit log, so a hand somebody +// disputes can be dealt again exactly as it fell. +func newSeeds() (uint64, uint64) { + return rand.Uint64(), uint64(time.Now().UnixNano()) +} + +func writeJSONStatus(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + if err := json.NewEncoder(w).Encode(v); err != nil { + slog.Error("games: write response", "err", err) + } +} diff --git a/internal/web/games_play_test.go b/internal/web/games_play_test.go new file mode 100644 index 0000000..4b8418e --- /dev/null +++ b/internal/web/games_play_test.go @@ -0,0 +1,275 @@ +package web + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "pete/internal/config" + "pete/internal/storage" +) + +const testPlayer = "@reala:parodia.dev" + +// newCasino is a server with the tables open and one signed-in player. The +// Authenticator is built by hand rather than through OIDC discovery: sign-in is +// a network call, and none of what's under test is about the handshake. +func newCasino(t *testing.T) *Server { + t.Helper() + s, _ := newAdvServer(t, "tok") + s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")} + s.cfg.Games = config.GamesConfig{Enabled: true, MatrixServer: "parodia.dev"} + return s +} + +// as returns a request carrying the signed session of the given username. An +// empty username is the pre-casino session: signed in, but nobody the economy +// can name. +func as(t *testing.T, s *Server, username, method, path string, body any) *http.Request { + t.Helper() + var buf bytes.Buffer + if body != nil { + if err := json.NewEncoder(&buf).Encode(body); err != nil { + t.Fatal(err) + } + } + r := httptest.NewRequest(method, path, &buf) + payload, _ := json.Marshal(SessionUser{ + Sub: "sub-1", Username: username, Exp: time.Now().Add(time.Hour).Unix(), + }) + r.AddCookie(&http.Cookie{Name: sessionCookie, Value: s.auth.sign(payload)}) + return r +} + +// call runs one request against a handler and decodes the table view. +func call(t *testing.T, s *Server, h http.HandlerFunc, r *http.Request) (tableView, int) { + t.Helper() + w := httptest.NewRecorder() + h(w, r) + var v tableView + if w.Code == 200 { + if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil { + t.Fatalf("decode table: %v (body %q)", err, w.Body.String()) + } + } + return v, w.Code +} + +// fund puts chips in front of the player the way the border really does it. +func fund(t *testing.T, chips int64) { + t.Helper() + e, err := storage.RequestBuyIn(testPlayer, chips) + if err != nil { + t.Fatal(err) + } + if _, err := storage.ClaimEscrow(e.GUID); err != nil { + t.Fatal(err) + } + if _, err := storage.SettleEscrow(e.GUID, true, "", 0); err != nil { + t.Fatal(err) + } +} + +func chipsNow(t *testing.T) int64 { + t.Helper() + st, err := storage.Chips(testPlayer) + if err != nil { + t.Fatal(err) + } + return st.Chips +} + +// TestDealTakesTheStakeAndHidesTheHoleCard is the one thing the table cannot get +// wrong: the bet leaves the stack, and the dealer's second card does not leave +// the server. +func TestDealTakesTheStakeAndHidesTheHoleCard(t *testing.T) { + s := newCasino(t) + fund(t, 1000) + + v, code := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/api/games/blackjack/deal", map[string]int64{"bet": 100})) + if code != 200 { + t.Fatalf("deal = %d, want 200", code) + } + if v.Chips != 900 { + t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips) + } + if v.Hand == nil { + t.Fatal("deal returned no hand") + } + if len(v.Hand.Player) != 2 { + t.Fatalf("player was dealt %d cards, want 2", len(v.Hand.Player)) + } + + // A natural settles on the spot and legitimately shows both dealer cards. + if v.Hand.Phase == "done" { + return + } + if !v.Hand.Hole || len(v.Hand.Dealer) != 1 { + t.Fatalf("dealer shows %d cards (hole=%v), want 1 with the hole card held back", + len(v.Hand.Dealer), v.Hand.Hole) + } + // And it isn't smuggled out in the dealing script either. + for _, e := range v.Events { + if e.Kind == "dealer_hole" && e.Card != nil { + t.Fatal("the hole card's face went to the browser in the events") + } + } +} + +// TestHandSettlesIntoTheChipStack plays a hand to the end and checks the chips +// moved by exactly what the engine said they did — and that the hand left the +// felt and landed in the audit log. +func TestHandSettlesIntoTheChipStack(t *testing.T) { + s := newCasino(t) + fund(t, 1000) + + v, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100})) + for v.Hand != nil && v.Hand.Phase == "player" { + v, _ = call(t, s, s.handleMove, as(t, s, "reala", "POST", "/move", map[string]string{"move": "stand"})) + } + if v.Hand == nil || v.Hand.Phase != "done" { + t.Fatalf("hand didn't finish: %+v", v.Hand) + } + + // 1000, minus the stake, plus whatever came back. + want := int64(1000) - 100 + v.Hand.Payout + if got := chipsNow(t); got != want { + t.Fatalf("chips = %d, want %d (payout %d, outcome %q)", got, want, v.Hand.Payout, v.Hand.Outcome) + } + if _, err := storage.LoadLiveHand(testPlayer); err == nil { + t.Fatal("a settled hand is still sitting on the felt") + } + if v.Hand.Outcome == "" { + t.Fatal("a finished hand with no outcome") + } + + // The rake only ever comes out of winnings. + if v.Hand.Outcome == "push" && v.Hand.Payout != 100 { + t.Fatalf("a push paid %d, want the 100 back untouched", v.Hand.Payout) + } + if v.Hand.Net < 0 && v.Hand.Rake != 0 { + t.Fatalf("a losing hand was raked %d", v.Hand.Rake) + } +} + +// TestOneHandAtATime is the double-click: a second Deal must not overwrite the +// hand the first one is paying for, and must not keep the chips it staked. +func TestOneHandAtATime(t *testing.T) { + s := newCasino(t) + fund(t, 1000) + + first, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100})) + if first.Hand != nil && first.Hand.Phase == "done" { + t.Skip("dealt a natural; there is no live hand to protect") + } + before := chipsNow(t) + + w := httptest.NewRecorder() + s.handleDeal(w, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100})) + if w.Code != http.StatusConflict { + t.Fatalf("second deal = %d, want 409", w.Code) + } + if got := chipsNow(t); got != before { + t.Fatalf("the refused deal cost the player %d chips", before-got) + } + // The original hand is untouched. + live, err := storage.LoadLiveHand(testPlayer) + if err != nil { + t.Fatalf("the live hand went missing: %v", err) + } + var st struct { + Player []struct{} `json:"player"` + } + if err := json.Unmarshal(live.State, &st); err != nil { + t.Fatal(err) + } + if len(st.Player) != len(first.Hand.Player) { + t.Fatal("the refused deal replaced the hand in progress") + } +} + +// TestCannotCashOutMidHand — the stake is on the table, so the session it would +// settle into cannot be closed underneath it. +func TestCannotCashOutMidHand(t *testing.T) { + s := newCasino(t) + fund(t, 1000) + + v, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100})) + if v.Hand != nil && v.Hand.Phase == "done" { + t.Skip("dealt a natural; nothing is in progress") + } + + w := httptest.NewRecorder() + s.handleCashOut(w, as(t, s, "reala", "POST", "/cashout", map[string]int64{"amount": 0})) + if w.Code != http.StatusConflict { + t.Fatalf("cash-out mid-hand = %d, want 409", w.Code) + } + if got := chipsNow(t); got != 900 { + t.Fatalf("the refused cash-out moved chips: %d, want 900", got) + } +} + +// TestDoubleWithoutTheChipsChangesNothing: a double the player can't cover is +// refused, and refusing it must not quietly pocket the raise. +func TestDoubleWithoutTheChipsChangesNothing(t *testing.T) { + s := newCasino(t) + fund(t, 100) + + v, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100})) + if v.Hand == nil || v.Hand.Phase != "player" { + t.Skip("no live hand to double on") + } + if chipsNow(t) != 0 { + t.Fatal("test wants a player with nothing left to raise with") + } + + w := httptest.NewRecorder() + s.handleMove(w, as(t, s, "reala", "POST", "/move", map[string]string{"move": "double"})) + if w.Code != http.StatusBadRequest { + t.Fatalf("broke double = %d, want 400", w.Code) + } + if got := chipsNow(t); got != 0 { + t.Fatalf("chips = %d after a refused double, want 0", got) + } + // And the hand is still there, still doubleable once they can afford it. + after, code := call(t, s, s.handleTable, as(t, s, "reala", "GET", "/table", nil)) + if code != 200 || after.Hand == nil || !after.Hand.Double { + t.Fatalf("the hand should be intact and still doubleable: %d %+v", code, after.Hand) + } +} + +// TestTableNeedsAPlayerTheEconomyCanName. Anonymous is a 401. A session minted +// before the casino existed carries no username, so it can't be mapped to a +// Matrix id — that's a 403, and the fix is to sign in again. +func TestTableNeedsAPlayerTheEconomyCanName(t *testing.T) { + s := newCasino(t) + + w := httptest.NewRecorder() + s.handleTable(w, httptest.NewRequest("GET", "/api/games/table", nil)) + if w.Code != http.StatusUnauthorized { + t.Fatalf("anonymous = %d, want 401", w.Code) + } + + w = httptest.NewRecorder() + s.handleTable(w, as(t, s, "", "GET", "/api/games/table", nil)) + if w.Code != http.StatusForbidden { + t.Fatalf("session with no username = %d, want 403", w.Code) + } +} + +// TestCasinoIsShutWithoutAServerName. No Matrix server name means no player can +// be named to gogobee, so the tables 404 rather than dealing hands whose money +// has nowhere to go. +func TestCasinoIsShutWithoutAServerName(t *testing.T) { + s := newCasino(t) + s.cfg.Games.MatrixServer = "" + + w := httptest.NewRecorder() + s.handleTable(w, as(t, s, "reala", "GET", "/api/games/table", nil)) + if w.Code != http.StatusNotFound { + t.Fatalf("table with no server name = %d, want 404", w.Code) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index fc7fcc4..fcfce64 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -9,6 +9,7 @@ import ( "io/fs" "log/slog" "net/http" + "strings" "sync" "time" @@ -81,12 +82,13 @@ type Server struct { // gets its own template set sharing layout.html + _card.html, which avoids // `main` define collisions between pages. func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled bool, adv config.AdventureConfig, advPost PriorityPoster) (*Server, error) { - pages := []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"} + pages := []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story", "games", "blackjack"} tpls := make(map[string]*template.Template, len(pages)) for _, p := range pages { t, err := template.New("").Funcs(funcs).ParseFS(templateFS, "templates/layout.html", "templates/_card.html", + "templates/_chipbar.html", "templates/"+p+".html", ) if err != nil { @@ -210,6 +212,19 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo mux.HandleFunc("POST /api/games/escrow/claim", s.handleEscrowClaim) mux.HandleFunc("POST /api/games/escrow/settled", s.handleEscrowSettled) + // The casino. Signed-in only — there is money in it — so these hang off the + // 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) + } + if s.auth != nil { mux.HandleFunc("GET /auth/login", s.auth.handleLogin) mux.HandleFunc("GET /auth/callback", s.auth.handleCallback) @@ -232,12 +247,56 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo s.srv = &http.Server{ Addr: cfg.ListenAddr, - Handler: mux, + Handler: s.hostRouter(mux), ReadHeaderTimeout: 5 * time.Second, } return s, nil } +// hostRouter puts the casino at the root of its own hostname without giving it +// its own mux. games.parodia.dev and news.parodia.dev are the same process on +// the same port — Caddy sends both here — so a request that arrives on the games +// host has /games prefixed onto its path, and "/" lands on the lobby. +// +// Shared plumbing (the API, sign-in, static files, health) is left alone: it is +// the same plumbing whichever door you came in by, and the login round-trip in +// particular has to keep working on both hosts. +func (s *Server) hostRouter(mux http.Handler) http.Handler { + host := strings.ToLower(strings.TrimSpace(s.cfg.Games.Host)) + if host == "" || !s.gamesReady() { + return mux + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.EqualFold(hostOnly(r.Host), host) || isSharedPath(r.URL.Path) { + mux.ServeHTTP(w, r) + return + } + // Rewrite a copy: the request's URL is shared with anything that logged it. + r2 := r.Clone(r.Context()) + r2.URL.Path = "/games" + strings.TrimSuffix(r.URL.Path, "/") + mux.ServeHTTP(w, r2) + }) +} + +// isSharedPath marks the paths that mean the same thing on every host and must +// not be shuffled under /games. +func isSharedPath(p string) bool { + for _, prefix := range []string{"/api/", "/auth/", "/static/", "/img/", "/games"} { + if strings.HasPrefix(p, prefix) { + return true + } + } + return p == "/healthz" || p == "/sw.js" || p == "/manifest.webmanifest" +} + +// hostOnly strips the port from a Host header. +func hostOnly(host string) string { + if i := strings.IndexByte(host, ':'); i >= 0 { + return host[:i] + } + return host +} + // Start runs the HTTP server and blocks until ctx is canceled. func (s *Server) Start(ctx context.Context) { go func() { diff --git a/internal/web/static/css/input.css b/internal/web/static/css/input.css index ccc1287..55eb22b 100644 --- a/internal/web/static/css/input.css +++ b/internal/web/static/css/input.css @@ -527,4 +527,132 @@ html[data-phase="night"] { font-size: 0.8rem; font-weight: 700; } + + /* ---- the casino --------------------------------------------------------- + Cards are dealt, not swapped in: each one starts at the shoe in the corner, + flies to its place, and turns over. The whole point of the table is that you + watch it happen, so this is written as one animation per card with a delay, + and the JS just says which cards exist and in what order. */ + + .pete-felt { + background: + radial-gradient(120% 90% at 50% -10%, rgba(255,255,255,0.10), transparent 60%), + linear-gradient(160deg, #2f7d5b 0%, #24614a 55%, #1c4d3c 100%); + } + + /* The shoe: where every card comes from. */ + .pete-shoe { + position: absolute; + top: 1.25rem; + right: 1.25rem; + height: 4.2rem; + width: 3rem; + border-radius: 0.55rem; + background: linear-gradient(150deg, #b4553f, #8d3f2f); + border: 2px solid rgba(0,0,0,0.18); + box-shadow: 0 4px 0 rgba(0,0,0,0.18), inset 0 0 0 3px rgba(255,255,255,0.12); + } + .pete-shoe::after { + content: ""; + position: absolute; + inset: 0.45rem 0.4rem auto 0.4rem; + height: 0.5rem; + border-radius: 0.2rem; + background: rgba(0,0,0,0.22); + } + + .pete-hand { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + min-height: 6.6rem; + } + + /* One card. The wrapper does the flight, the inner face does the flip, so the + two never fight over the same transform. */ + .pete-card { + perspective: 700px; + height: 6.4rem; + width: 4.5rem; + animation: pete-deal 0.42s cubic-bezier(0.22, 1, 0.36, 1) backwards; + } + .pete-card-inner { + position: relative; + height: 100%; + width: 100%; + transform-style: preserve-3d; + transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1); + } + /* Face-down is the resting state of a card that hasn't been turned over: the + hole card sits like this until the dealer plays. */ + .pete-card[data-face="down"] .pete-card-inner { transform: rotateY(180deg); } + + .pete-card-front, + .pete-card-back { + position: absolute; + inset: 0; + display: grid; + border-radius: 0.55rem; + backface-visibility: hidden; + -webkit-backface-visibility: hidden; + box-shadow: 0 3px 0 rgba(0,0,0,0.18), 0 6px 14px rgba(0,0,0,0.22); + } + .pete-card-front { + place-items: center; + background: #fdfaf2; + border: 2px solid rgba(30,20,10,0.12); + color: #2b2118; + font-family: "Fredoka", "Nunito", system-ui, sans-serif; + line-height: 1; + } + .pete-card-front[data-red="1"] { color: #cc3d4a; } + .pete-card-back { + transform: rotateY(180deg); + background: + repeating-linear-gradient(45deg, rgba(255,255,255,0.10) 0 6px, transparent 6px 12px), + linear-gradient(150deg, #b4553f, #8d3f2f); + border: 2px solid rgba(0,0,0,0.15); + } + + .pete-card-rank { font-size: 1.5rem; font-weight: 700; } + .pete-card-suit { font-size: 1.15rem; margin-top: 0.1rem; } + + /* The flight itself: out of the shoe (up and to the right), scaled down and + spinning slightly, into place. */ + @keyframes pete-deal { + from { + opacity: 0; + transform: translate(var(--deal-x, 14rem), var(--deal-y, -7rem)) scale(0.72) rotate(9deg); + } + to { + opacity: 1; + transform: translate(0, 0) scale(1) rotate(0); + } + } + + /* A settled hand: the winning side gets a little lift, so a win reads at a + glance rather than only in the text. */ + .pete-hand[data-won="1"] .pete-card { animation: pete-deal 0.42s cubic-bezier(0.22,1,0.36,1) backwards, pete-win 0.6s ease 0.1s; } + @keyframes pete-win { + 0%, 100% { transform: translateY(0); } + 40% { transform: translateY(-0.6rem); } + } + + /* Chips: the denominations you bet with. */ + .pete-chip { + background: radial-gradient(circle at 50% 35%, rgba(255,255,255,0.28), transparent 60%), var(--chip, #e07a5f); + border: 3px dashed rgba(255,255,255,0.55); + box-shadow: 0 3px 0 rgba(60,40,20,0.22), 0 6px 14px rgba(60,40,20,0.18); + } + .pete-chip[data-chip="5"] { --chip: #5aa9e6; } + .pete-chip[data-chip="25"] { --chip: #4caf7d; } + .pete-chip[data-chip="100"] { --chip: #2b2118; } + .pete-chip[data-chip="500"] { --chip: #b079d6; } + .pete-chip[aria-pressed="true"] { outline: 3px solid var(--accent); outline-offset: 2px; } + + @media (prefers-reduced-motion: reduce) { + .pete-card { animation: none; } + .pete-card-inner { transition: none; } + .pete-hand[data-won="1"] .pete-card { animation: none; } + } } diff --git a/internal/web/static/css/output.css b/internal/web/static/css/output.css index b7f098a..c24ef9b 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}.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}.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-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-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-screen{min-height:100vh}.w-12{width:3rem}.w-2{width:.5rem}.w-2\.5{width:.625rem}.w-20{width:5rem}.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-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))}.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}.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-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-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-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}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-0\.5{padding:.125rem}.p-1{padding:.25rem}.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}.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-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\.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-\[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\/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}.blur{--tw-blur:blur(8px)}.blur,.grayscale{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)}.grayscale{--tw-grayscale:grayscale(100%)}.sepia{--tw-sepia:sepia(100%)}.filter,.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)}.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)}.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}.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)}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.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-8{padding:2rem}.sm\:pt-12{padding-top:3rem}.sm\:text-2xl{font-size:1.5rem;line-height:2rem}.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:6.6rem}.pete-card{perspective:700px;height:6.4rem;width:4.5rem;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:center;background:#fdfaf2;border:2px solid rgba(30,20,10,.12);color:#2b2118;font-family:Fredoka,Nunito,system-ui,sans-serif;line-height:1}.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-rank{font-size:1.5rem;font-weight:700}.pete-card-suit{font-size:1.15rem;margin-top:.1rem}@keyframes pete-deal{0%{opacity:0;transform:translate(var(--deal-x,14rem),var(--deal-y,-7rem)) scale(.72) rotate(9deg)}to{opacity:1;transform:translate(0) scale(1) rotate(0)}}.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:translateY(0)}40%{transform:translateY(-.6rem)}}.pete-chip{background:radial-gradient(circle at 50% 35%,hsla(0,0%,100%,.28),transparent 60%),var(--chip,#e07a5f);border:3px dashed hsla(0,0%,100%,.55);box-shadow:0 3px 0 rgba(60,40,20,.22),0 6px 14px rgba(60,40,20,.18)}.pete-chip[data-chip="5"]{--chip:#5aa9e6}.pete-chip[data-chip="25"]{--chip:#4caf7d}.pete-chip[data-chip="100"]{--chip:#2b2118}.pete-chip[data-chip="500"]{--chip:#b079d6}.pete-chip[aria-pressed=true]{outline:3px solid var(--accent);outline-offset:2px}@media (prefers-reduced-motion:reduce){.pete-card{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}.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-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-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-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-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\.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-\[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}.blur{--tw-blur:blur(8px)}.blur,.grayscale{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)}.grayscale{--tw-grayscale:grayscale(100%)}.sepia{--tw-sepia:sepia(100%)}.filter,.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)}.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)}.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}.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-0:active{--tw-translate-y:0px}.active\:translate-y-0:active,.active\:translate-y-px:active{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))}.active\:translate-y-px:active{--tw-translate-y:1px}.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-12{padding-top:3rem}.sm\:text-2xl{font-size:1.5rem;line-height:2rem}.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 diff --git a/internal/web/static/js/blackjack.js b/internal/web/static/js/blackjack.js new file mode 100644 index 0000000..d4933ff --- /dev/null +++ b/internal/web/static/js/blackjack.js @@ -0,0 +1,302 @@ +// The blackjack table. +// +// The browser holds no game. It sends intents — deal, hit, stand, double — and +// the server answers with the cards you're allowed to see plus the *script* of +// how they got there: one event per card off the shoe, in the order the shoe +// gave them up. This file's job is to play that script back at a human speed +// rather than snapping the finished hand into place. +// +// Which is also why the hole card works the way it does: the server sends a +// "dealer_hole" event with no card attached, because while you are still acting +// it hasn't told anyone what that card is. It arrives with the reveal. +(function () { + "use strict"; + + var root = document.querySelector("[data-blackjack]"); + if (!root) return; + + var dealerEl = root.querySelector("[data-dealer]"); + var playerEl = root.querySelector("[data-player]"); + var dTotalEl = root.querySelector("[data-dealer-total]"); + var pTotalEl = root.querySelector("[data-player-total]"); + var betChip = root.querySelector("[data-bet]"); + var verdictEl = root.querySelector("[data-verdict]"); + var betting = root.querySelector("[data-betting]"); + var actions = root.querySelector("[data-actions]"); + var betAmount = root.querySelector("[data-bet-amount]"); + var dealBtn = root.querySelector("[data-deal]"); + var msgEl = root.querySelector("[data-table-msg]"); + + var bet = 25; + var busy = false; // a request is in flight, or cards are still landing + var hand = null; // the hand as the server last described it + + var DEAL_MS = 380; // one card's flight, and the gap before the next + var FLIP_MS = 450; + + var reduced = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + function pace(ms) { return reduced ? 0 : ms; } + function wait(ms) { return new Promise(function (r) { setTimeout(r, pace(ms)); }); } + + function say(text, tone) { + if (!text) { msgEl.classList.add("hidden"); return; } + msgEl.textContent = text; + msgEl.classList.remove("hidden"); + msgEl.style.color = tone === "bad" ? "#cc3d4a" : ""; + } + + // ---- drawing -------------------------------------------------------------- + + // cardEl builds one card. face === null means face-down: the card is dealt, + // but this browser has not been told what it is. + function cardEl(face) { + var wrap = document.createElement("div"); + wrap.className = "pete-card"; + wrap.dataset.face = face ? "up" : "down"; + // Every card flies out of the shoe, which sits in the top-right of the felt. + // The offset is per-card, so a card landing further left flies further. + wrap.style.setProperty("--deal-x", "14rem"); + wrap.style.setProperty("--deal-y", "-6rem"); + + var inner = document.createElement("div"); + inner.className = "pete-card-inner"; + + var front = document.createElement("div"); + front.className = "pete-card-front"; + var back = document.createElement("div"); + back.className = "pete-card-back"; + + inner.appendChild(front); + inner.appendChild(back); + wrap.appendChild(inner); + if (face) paintFace(front, face); + return wrap; + } + + function paintFace(front, face) { + front.dataset.red = face.red ? "1" : "0"; + front.innerHTML = ""; + var rank = document.createElement("span"); + rank.className = "pete-card-rank"; + rank.textContent = face.rank; + var suit = document.createElement("span"); + suit.className = "pete-card-suit"; + suit.textContent = face.suit; + front.appendChild(rank); + front.appendChild(suit); + front.setAttribute("aria-label", face.label); + } + + // turnOver flips a face-down card up, now that we've been told what it is. + function turnOver(wrap, face) { + if (!wrap) return; + paintFace(wrap.querySelector(".pete-card-front"), face); + wrap.dataset.face = "up"; + } + + function totals(v) { + if (v.total) { + pTotalEl.textContent = v.total + (v.soft ? " (soft)" : ""); + pTotalEl.classList.remove("hidden"); + } else { + pTotalEl.classList.add("hidden"); + } + // While the hole card is down, the dealer's total is only what's showing — + // so say so, rather than printing a number that quietly means something else. + if (v.dealer && v.dealer.length) { + dTotalEl.textContent = v.hole ? v.dealer_total + " showing" : String(v.dealer_total); + dTotalEl.classList.remove("hidden"); + } else { + dTotalEl.classList.add("hidden"); + } + } + + // paint puts a hand on the felt with no animation. This is the resume path: + // you reloaded, or Pete restarted, and your cards are simply there. + function paint(v) { + dealerEl.innerHTML = ""; + playerEl.innerHTML = ""; + if (!v) { setPhase(null); return; } + + v.player.forEach(function (c) { playerEl.appendChild(cardEl(c)); }); + v.dealer.forEach(function (c) { dealerEl.appendChild(cardEl(c)); }); + if (v.hole) dealerEl.appendChild(cardEl(null)); + totals(v); + setPhase(v); + } + + var VERDICTS = { + blackjack: "Blackjack! 🎉", + win: "You win!", + dealer_bust: "Dealer busts. You win!", + lose: "Dealer takes it.", + bust: "Bust.", + push: "Push — your bet comes back.", + }; + + function verdict(v) { + var text = VERDICTS[v.outcome] || ""; + if (!text) { verdictEl.classList.add("hidden"); return; } + if (v.net > 0) text += " +" + v.net.toLocaleString(); + else if (v.net < 0) text += " " + v.net.toLocaleString(); + verdictEl.textContent = text; + verdictEl.classList.remove("hidden"); + playerEl.dataset.won = v.net > 0 ? "1" : "0"; + } + + // setPhase swaps the controls: bet between hands, act during one. + function setPhase(v) { + hand = v; + var live = !!v && v.phase === "player"; + betting.classList.toggle("hidden", live); + actions.classList.toggle("hidden", !live); + + if (v && v.bet) { + betChip.textContent = "Bet " + v.bet.toLocaleString(); + betChip.classList.remove("hidden"); + } else { + betChip.classList.add("hidden"); + } + if (live) { + var dbl = actions.querySelector('[data-move="double"]'); + if (dbl) dbl.disabled = !v.can_double; + } + if (!v || v.phase !== "player") verdictEl.classList.toggle("hidden", !(v && v.outcome)); + } + + // ---- the script ----------------------------------------------------------- + + // play walks the server's events, one card at a time. It is deliberately the + // only thing that renders during a hand: the final state is applied at the end, + // so what you watch and what the server says can't disagree halfway through. + function play(view) { + var events = view.events || []; + var final = view.hand; + var hole = null; // the face-down card element, once one has been dealt + var chain = Promise.resolve(); + + events.forEach(function (e) { + chain = chain.then(function () { + switch (e.kind) { + case "deal": + dealerEl.innerHTML = ""; + playerEl.innerHTML = ""; + playerEl.dataset.won = "0"; + verdictEl.classList.add("hidden"); + return; + + case "player_card": + playerEl.appendChild(cardEl(e.card)); + return wait(DEAL_MS); + + case "dealer_card": + dealerEl.appendChild(cardEl(e.card)); + return wait(DEAL_MS); + + case "dealer_hole": + hole = cardEl(null); + dealerEl.appendChild(hole); + return wait(DEAL_MS); + + case "reveal": + // The hole card turns over. Its face is in the final hand — this is + // the first moment the server has been willing to say what it was. + if (!hole) hole = dealerEl.querySelector('.pete-card[data-face="down"]'); + if (hole && final && final.dealer && final.dealer[1]) { + turnOver(hole, final.dealer[1]); + } + return wait(FLIP_MS); + + case "settle": + return; + } + }); + }); + + return chain.then(function () { + if (final) { + totals(final); + setPhase(final); + if (final.phase === "done") verdict(final); + } else { + paint(null); + } + }); + } + + // ---- talking to the table ------------------------------------------------- + + function send(path, body) { + if (busy) return; + busy = true; + say(""); + return window.PeteGames.post(path, body) + .then(function (view) { + window.PeteGames.apply(view); + return play(view); + }) + .catch(function (err) { + say(err.message, "bad"); + // Whatever we thought was on the felt, the server is the authority on it. + return window.PeteGames.refresh(); + }) + .then(function () { busy = false; }); + } + + // ---- betting -------------------------------------------------------------- + + function showBet() { + betAmount.textContent = bet.toLocaleString(); + var money = window.PeteGames.view(); + if (dealBtn) dealBtn.disabled = bet <= 0 || !money || money.chips < bet; + } + + root.querySelectorAll("[data-chip]").forEach(function (btn) { + btn.addEventListener("click", function () { + bet += parseInt(btn.dataset.chip, 10); + showBet(); + }); + }); + + var clearBtn = root.querySelector("[data-bet-clear]"); + if (clearBtn) { + clearBtn.addEventListener("click", function () { bet = 0; showBet(); }); + } + + if (dealBtn) { + dealBtn.addEventListener("click", function () { + if (bet <= 0) { say("Put something on it first.", "bad"); return; } + send("/api/games/blackjack/deal", { bet: bet }); + }); + } + + root.querySelectorAll("[data-move]").forEach(function (btn) { + btn.addEventListener("click", function () { + send("/api/games/blackjack/move", { move: btn.dataset.move }); + }); + }); + + document.addEventListener("keydown", function (e) { + if (e.metaKey || e.ctrlKey || e.altKey) return; + if (/^(input|textarea|select)$/i.test((e.target.tagName || ""))) return; + if (!hand || hand.phase !== "player" || busy) return; + + var move = { h: "hit", s: "stand", d: "double" }[e.key.toLowerCase()]; + if (!move) return; + if (move === "double" && !hand.can_double) return; + e.preventDefault(); + send("/api/games/blackjack/move", { move: move }); + }); + + // The money bar owns the first fetch; the table picks up whatever it found, + // including a hand left sitting on the felt by a reload or a redeploy. + var resumed = false; + window.PeteGames.onUpdate(function (v) { + if (!resumed) { + resumed = true; + if (v.hand) paint(v.hand); + if (v.hand && v.hand.phase === "done") verdict(v.hand); + } + showBet(); + }); +})(); diff --git a/internal/web/static/js/games.js b/internal/web/static/js/games.js new file mode 100644 index 0000000..581a094 --- /dev/null +++ b/internal/web/static/js/games.js @@ -0,0 +1,160 @@ +// The money bar: chips, wallet, buying in, cashing out. +// +// Buying chips is not instant and cannot be. gogobee owns the euros and has no +// inbound API, so it polls Pete for the crossing, moves the money on its side, +// and pushes the verdict back — a few seconds, end to end. So the button does +// not lie about it: the chips show as "buying" until they are real, and this +// file polls until they are. Nothing spendable appears until gogobee has said +// it took the euros. +// +// Exposed as window.PeteGames so the table (blackjack.js) shares one view of +// the money rather than keeping a second copy that drifts from this one. +(function () { + "use strict"; + + var bar = document.querySelector("[data-chipbar]"); + if (!bar) return; + + var chipsEl = bar.querySelector("[data-chips]"); + var pendingEl = bar.querySelector("[data-pending]"); + var eurosEl = bar.querySelector("[data-euros]"); + var amountEl = bar.querySelector("[data-buyin-amount]"); + var buyBtn = bar.querySelector("[data-buyin]"); + var cashBtn = bar.querySelector("[data-cashout]"); + var msgEl = bar.querySelector("[data-chipbar-msg]"); + + var listeners = []; + var view = null; + var pollTimer = null; + var pollUntil = 0; + + function money(n) { + return (n || 0).toLocaleString(); + } + + function say(text, tone) { + if (!msgEl) return; + if (!text) { msgEl.classList.add("hidden"); return; } + msgEl.textContent = text; + msgEl.classList.remove("hidden"); + msgEl.style.color = tone === "bad" ? "#cc3d4a" : ""; + } + + function paint(v) { + view = v; + if (chipsEl) chipsEl.textContent = money(v.chips); + if (eurosEl) eurosEl.textContent = (v.euros || 0).toFixed(2); + + if (pendingEl) { + if (v.pending > 0) { + pendingEl.textContent = "+" + money(v.pending) + " buying…"; + pendingEl.classList.remove("hidden"); + } else { + pendingEl.classList.add("hidden"); + } + } + + // You cannot cash out mid-hand: the stake is already on the table. + if (cashBtn) cashBtn.disabled = v.chips <= 0 || !!v.hand; + if (buyBtn) buyBtn.disabled = v.chips + v.pending >= v.cap; + + listeners.forEach(function (fn) { fn(v); }); + } + + // pollPending keeps asking while a buy-in is in flight, and stops the moment + // it lands — or is refused, which looks the same from here (pending drops to + // zero) and is told apart by whether the chips arrived. + function pollPending() { + clearTimeout(pollTimer); + if (Date.now() > pollUntil) { + say("gogobee hasn't answered yet. Your euros are safe — give it a moment and reload."); + return; + } + pollTimer = setTimeout(function () { + get().then(function (v) { + if (!v) return; + if (v.pending > 0) { pollPending(); return; } + if (v.chips > (pollPending.was || 0)) { + say("Chips are yours. Good luck!"); + } else { + say("gogobee wouldn't cover that — not enough euros in your wallet.", "bad"); + } + }); + }, 1200); + } + + function request(path, body) { + return fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body || {}), + }).then(function (res) { + return res.json().catch(function () { return {}; }).then(function (data) { + if (!res.ok) throw new Error(data.error || "that didn't work"); + return data; + }); + }); + } + + function get() { + return fetch("/api/games/table", { headers: { "Accept": "application/json" } }) + .then(function (res) { return res.ok ? res.json() : null; }) + .then(function (v) { if (v) paint(v); return v; }) + .catch(function () { return null; }); + } + + if (buyBtn) { + buyBtn.addEventListener("click", function () { + var amount = parseInt(amountEl && amountEl.value, 10); + if (!(amount > 0)) { say("How many chips?", "bad"); return; } + + buyBtn.disabled = true; + say("Asking gogobee for " + money(amount) + " euros…"); + pollPending.was = view ? view.chips : 0; + + request("/api/games/buyin", { amount: amount }) + .then(function (v) { + paint(v); + pollUntil = Date.now() + 60000; + pollPending(); + }) + .catch(function (err) { + say(err.message, "bad"); + buyBtn.disabled = false; + }); + }); + } + + if (cashBtn) { + cashBtn.addEventListener("click", function () { + cashBtn.disabled = true; + say("Cashing you out…"); + request("/api/games/cashout", { amount: 0 }) + .then(function (v) { + paint(v); + say("Chips are on their way back to euros. They'll show in your wallet shortly."); + // The euro balance Pete shows is whatever gogobee last told it, so it + // only moves once the credit has actually gone through over there. + setTimeout(get, 4000); + }) + .catch(function (err) { + say(err.message, "bad"); + cashBtn.disabled = false; + }); + }); + } + + window.PeteGames = { + // onUpdate registers a listener called on every fresh view of the money. + onUpdate: function (fn) { listeners.push(fn); if (view) fn(view); }, + // apply pushes a view the table already fetched (a deal answers with one), + // so playing a hand doesn't need a second round-trip to refresh the chips. + apply: paint, + refresh: get, + post: request, + say: say, + view: function () { return view; }, + }; + + get(); +})(); diff --git a/internal/web/templates/_chipbar.html b/internal/web/templates/_chipbar.html new file mode 100644 index 0000000..54774ba --- /dev/null +++ b/internal/web/templates/_chipbar.html @@ -0,0 +1,45 @@ +{{define "_chipbar"}} +
+
+ +
+
Chips in front of you
+
+ + +
+
+ +
+
Your wallet
+
+ + €, give or take +
+
+ +
+
+ + + +
+ +
+
+ + +
+{{end}} diff --git a/internal/web/templates/blackjack.html b/internal/web/templates/blackjack.html new file mode 100644 index 0000000..729600e --- /dev/null +++ b/internal/web/templates/blackjack.html @@ -0,0 +1,113 @@ +{{define "title"}}Blackjack · Pete's Casino{{end}} + +{{define "main"}} +
+ +
+ +

Six decks · pays 3:2 · dealer hits soft 17

+
+ + {{template "_chipbar" .}} + + +
+ + + + +
+ + +
+
+ Dealer + +
+
+
+ + +
+ +
+ + +
+
+ You + + +
+
+
+ +
+
+ + +
+
+
+
Your bet
+
25
+
+ +
+ {{range .Denominations}} + + {{end}} + +
+ + +
+ +
+ + + + +
+{{end}} + +{{define "scripts"}} + + +{{end}} diff --git a/internal/web/templates/games.html b/internal/web/templates/games.html new file mode 100644 index 0000000..6f79e68 --- /dev/null +++ b/internal/web/templates/games.html @@ -0,0 +1,72 @@ +{{define "title"}}Pete's Casino · {{.SiteTitle}}{{end}} + +{{define "main"}} +
+ +
+
+
+

Pete's Casino 🎲

+

+ Real euros, from the same wallet as everything else. Chips are one euro each, + and whatever you don't spend goes straight back when you cash out. +

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

The tables

+ +
+ +
+

House rules

+
    +
  • · A chip is a euro. Nothing is worth more here than it is out there.
  • +
  • · You can have {{.Cap}} chips in front of you at most. That's the limit for one sitting.
  • +
  • · The house takes {{.RakePct}}% of your winnings. Never your stake: a push hands your bet straight back.
  • +
  • · Walk away for half an hour and Pete cashes you out on his own, so your euros never sit in limbo.
  • +
  • · Every hand is logged with the seed it was dealt from, so any hand can be dealt again exactly as it fell.
  • +
+
+ +
+{{end}} + +{{define "scripts"}}{{end}} diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 5f8778f..b360b98 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -322,5 +322,6 @@ + {{block "scripts" .}}{{end}} {{end}} diff --git a/pete_games_plan.md b/pete_games_plan.md index ad745e5..1058f6c 100644 --- a/pete_games_plan.md +++ b/pete_games_plan.md @@ -8,7 +8,7 @@ This plan reuses that seam wholesale and does not invent a second one. --- -## 0. Progress — last updated 2026-07-13 +## 0. Progress — last updated 2026-07-14 A multi-session build. This section is the handover; read it before anything else. @@ -58,17 +58,49 @@ A multi-session build. This section is the handover; read it before anything els gogobee crash replays as a no-op: 13 tests across both repos, including a fake Pete that offers the same row three times and a player who is charged once. +- **Identity.** `preferred_username` now rides in the signed session, and + `SessionUser.MatrixUser(server)` maps it to `@user:parodia.dev`. The session cookie + takes an opt-in `web.auth.cookie_domain`, so a sign-in on news is a sign-in on games; + the OAuth round-trip cookie deliberately stays host-only, and the redirect_uri is + derived per-request so a login that starts on games comes back to games. A Host we + don't own is never echoed into a redirect. *(pete `cb84e1d`)* +- **Blackjack, playable end to end.** `game_live_hands` (the hand in progress, + engine state and all, so a redeploy mid-hand is survivable), the session-authed play + surface (`internal/web/games_play.go`), the lobby and table pages, and the dealing + animation. Driven in a real browser: chips staked before the deal, hole card withheld + from the payload until the reveal, payout settled back into the stack. + ### Next, in order -1. **Identity.** `PreferredUsername` into the OIDC claims struct and the signed cookie; - cookie `Domain: ".parodia.dev"` so a news session travels to games. Add the games - redirect URI to the `pete` app in Authentik. -2. **Frontend.** Host branching in the mux, lobby + blackjack table, animated dealing. - Nothing yet *opens* an escrow row from a browser — `RequestBuyIn`/`RequestCashOut` - have no HTTP surface, on purpose: they need a signed-in Matrix identity, which is - step 1. +1. **Make the table lively.** The mechanics are all there and the dealing animates, but + the presentation is thin: card faces are a rank and a small suit (no pips, no corner + indices), chips never physically move, and nothing celebrates. This is a *stated + requirement*, not polish — see the decisions above. Ideas already sketched: chips that + fly to a bet spot and back on a win, cards landing with weight, a dealer beat before + drawing out, a burst on a natural. +2. **Deploy.** Add the `games.parodia.dev` redirect URI to the `pete` app in Authentik, + point Caddy at the same port, set `[web.games]` + `web.auth.cookie_domain` on the + server. Nothing else is host-specific. 3. Then Phase 2 (trivia, hangman), 3 (UNO), 4 (hold'em) as below. +### How the browser half fits together + +- `GET /games` (lobby), `GET /games/blackjack` (table) — signed-in only. On the games + host, the mux prefixes `/games` onto the path, so the lobby is that host's `/`. Shared + paths (`/api/`, `/auth/`, `/static/`) mean the same thing on every host and are left + alone. +- `GET /api/games/table`, `POST /api/games/{buyin,cashout}`, + `POST /api/games/blackjack/{deal,move}` — session-authed, JSON, all returning the same + `tableView` so the money and the felt can never disagree. +- **The browser never sees the shoe.** The dealer's hole card is *absent* from the + payload — not flagged hidden — until the reveal, and the deck lives only in + `game_live_hands`. The response carries the engine's events (one per card off the + shoe), which is what the table plays back as an animation. +- Money order-of-operations: stake leaves the stack *before* the hand is dealt, in the + same statement that checks it's there; the hand is *seated* (a plain INSERT on the + primary key) before it can settle, which is what makes a double-clicked Deal a 409 with + the stake refunded rather than a silently overwritten hand. + ### Notes for whoever picks this up - SQLite runs at `MaxOpenConns(1)` in *both* repos. Any `db.Get().Exec` inside an