diff --git a/internal/games/klondike/klondike.go b/internal/games/klondike/klondike.go
new file mode 100644
index 0000000..ccccbf4
--- /dev/null
+++ b/internal/games/klondike/klondike.go
@@ -0,0 +1,684 @@
+// Package klondike is a pure Klondike solitaire engine, played for chips.
+//
+// Same seam as blackjack and hangman: ApplyMove(state, move) (state, events,
+// error), where an error means the move was illegal and nothing else. The state
+// is a plain value, so a game survives a redeploy and replays from its seed.
+//
+// The casino version is Vegas scoring, which is the only way solitaire has ever
+// been a gambling game and the only shape that makes sense with money on it.
+// You do not win or lose the deal. You *buy the deck* for your stake, and every
+// card you get home to a foundation pays a slice of it back. Fifty-two cards
+// home pays the tier's full multiple; nothing home pays nothing. You can stop
+// whenever you like and keep what you have banked, which is what makes a game
+// that has gone dead a decision rather than a wall.
+//
+// There is no undo. The stake is spent the moment the deck is bought, so an undo
+// would be a way to walk a losing board backwards until it wins.
+package klondike
+
+import (
+ "errors"
+ "math"
+ "math/rand/v2"
+ "strconv"
+ "strings"
+
+ "pete/internal/games/cards"
+)
+
+// Errors an illegal move can produce.
+var (
+ ErrGameOver = errors.New("klondike: the game is already over")
+ ErrUnknownMove = errors.New("klondike: unknown move")
+ ErrBadBet = errors.New("klondike: bet must be positive")
+ ErrUnknownTier = errors.New("klondike: no such tier")
+ ErrBadPile = errors.New("klondike: no such pile")
+ ErrEmptyPile = errors.New("klondike: there is nothing there to move")
+ ErrNotASequence = errors.New("klondike: those cards aren't a run you can lift")
+ ErrWontGo = errors.New("klondike: that card doesn't go there")
+ ErrNoDraw = errors.New("klondike: there is nothing left to turn over")
+ ErrNoPasses = errors.New("klondike: you've used your passes through the stock")
+ ErrNothingHome = errors.New("klondike: nothing can go home right now")
+)
+
+// Piles is the number of tableau columns. Foundations is one per suit.
+const (
+ Piles = 7
+ Foundations = 4
+ FullDeck = 52
+)
+
+// Tier is a difficulty, chosen with the bet. The two dials are how many cards
+// the stock turns over at a time and how many times you may go through it —
+// which between them are the whole difficulty of Klondike. Turning three at a
+// time hides two of every three cards behind a card you may never reach; a
+// single pass means the ones you leave behind are gone for good.
+//
+// The multiple pays for that. Cutthroat is the cruellest deal in the room and
+// pays 3.4×, which means you are ahead from sixteen cards home even though most
+// of those boards never clear.
+type Tier struct {
+ Slug string `json:"slug"`
+ Name string `json:"name"`
+ Draw int `json:"draw"` // cards turned over per pull on the stock
+ Passes int `json:"passes"` // times through the stock; 0 means unlimited
+ Base float64 `json:"base"` // what a full 52 cards home pays, as a multiple of the stake
+ Blurb string `json:"blurb"`
+}
+
+// BreakEven is how many cards have to reach the foundations before the player is
+// square with the house. It's the number the felt actually quotes, because
+// "1.4×" tells a player nothing about a game where the multiple is paid per card.
+func (t Tier) BreakEven() int {
+ if t.Base <= 0 {
+ return FullDeck
+ }
+ n := int(math.Ceil(float64(FullDeck) / t.Base))
+ if n > FullDeck {
+ return FullDeck
+ }
+ return n
+}
+
+// Tiers are the three deals.
+var Tiers = []Tier{
+ {Slug: "patient", Name: "Patient", Draw: 1, Passes: 0, Base: 1.4,
+ Blurb: "One card at a time, through the stock as often as you like."},
+ {Slug: "vegas", Name: "Vegas", Draw: 3, Passes: 3, Base: 2.2,
+ Blurb: "Three at a time, three times round. The house game."},
+ {Slug: "cutthroat", Name: "Cutthroat", Draw: 3, Passes: 1, Base: 3.4,
+ Blurb: "Three at a time, one pass. What you leave behind is gone."},
+}
+
+// TierBySlug finds a tier by the name the browser sent.
+func TierBySlug(slug string) (Tier, error) {
+ for _, t := range Tiers {
+ if t.Slug == slug {
+ return t, nil
+ }
+ }
+ return Tier{}, ErrUnknownTier
+}
+
+// Phase is where the game is.
+type Phase string
+
+const (
+ PhasePlaying Phase = "playing"
+ PhaseDone Phase = "done"
+)
+
+// Outcome is how it ended. Note there is no "lost": a board that goes dead is
+// cashed, for whatever it made. Solitaire's failure mode is a board you can't
+// improve, and the honest thing to do with one is pay out what's on it.
+type Outcome string
+
+const (
+ OutcomeNone Outcome = ""
+ OutcomeCleared Outcome = "cleared" // all 52 home
+ OutcomeCashed Outcome = "cashed" // the player stopped and took the board
+)
+
+// Pile is one tableau column: a face-down stack with a face-up run on top of it.
+// Down is the part the browser never sees.
+type Pile struct {
+ Down []cards.Card `json:"down"`
+ Up []cards.Card `json:"up"`
+}
+
+// State is one game. The stock and every Down card are in here, which is exactly
+// why this value never leaves the server.
+type State struct {
+ Tier Tier `json:"tier"`
+ Stock cards.Deck `json:"stock"`
+ Waste []cards.Card `json:"waste"`
+ Table [Piles]Pile `json:"table"`
+ Found [Foundations][]cards.Card `json:"found"` // indexed by suit
+ Recycles int `json:"recycles"` // times the waste has gone back under
+ Moves int `json:"moves"`
+ RakePct float64 `json:"rake_pct"`
+
+ Bet int64 `json:"bet"`
+ Phase Phase `json:"phase"`
+ Outcome Outcome `json:"outcome"`
+ Payout int64 `json:"payout"`
+ Rake int64 `json:"rake"`
+}
+
+// Event is something the table animates. The engine emits them rather than
+// leaving the browser to diff two boards and guess what moved — a card that
+// slides from a column to a foundation and a card that was simply redrawn there
+// are the same diff and very different things to watch.
+//
+// Home and Pays ride on every event, so the meter on the felt is always quoting
+// a number the engine worked out. The browser never does this arithmetic: it did
+// once, and the felt advertised a payout the house didn't honour.
+type Event struct {
+ Kind string `json:"kind"` // "deal" | "draw" | "recycle" | "move" | "home" | "flip" | "settle"
+ Cards []cards.Card `json:"cards,omitempty"`
+ From string `json:"from,omitempty"`
+ To string `json:"to,omitempty"`
+ Text string `json:"text,omitempty"`
+ Home int `json:"home"`
+ Pays int64 `json:"pays"`
+}
+
+// Move is a player action.
+//
+// Home is its own kind rather than a Move To a foundation the player picked,
+// because there is only ever one foundation a card can go to and asking the
+// player to name it would be a quiz about suit ordering. The browser sends
+// "this card, home"; the engine finds the pile.
+type Move struct {
+ Kind string `json:"kind"` // "draw" | "move" | "home" | "auto" | "concede"
+ From string `json:"from"` // "waste" | "t0".."t6" | "f0".."f3"
+ To string `json:"to"` // "t0".."t6" | "f0".."f3"
+ Count int `json:"count"` // how many cards off the end of a tableau run; 0 means 1
+}
+
+// New deals a game.
+func New(bet int64, t Tier, rakePct float64, rng *rand.Rand) (State, []Event, error) {
+ if bet <= 0 {
+ return State{}, nil, ErrBadBet
+ }
+ if t.Draw < 1 {
+ return State{}, nil, ErrUnknownTier
+ }
+ d := cards.NewDeck(1)
+ d.Shuffle(rng)
+ return deal(bet, t, d, rakePct)
+}
+
+// deal lays the board out. Split out from New so a test can pin the deck
+// instead of the seed.
+func deal(bet int64, t Tier, d cards.Deck, rakePct float64) (State, []Event, error) {
+ if bet <= 0 {
+ return State{}, nil, ErrBadBet
+ }
+ if len(d) != FullDeck {
+ return State{}, nil, errors.New("klondike: a solitaire deck is 52 cards")
+ }
+ s := State{Tier: t, Bet: bet, RakePct: rakePct, Phase: PhasePlaying}
+
+ // The classic lay-out: column i gets i+1 cards, the last of them face up.
+ for i := 0; i < Piles; i++ {
+ for j := 0; j <= i; j++ {
+ c, _ := d.Draw()
+ if j == i {
+ s.Table[i].Up = append(s.Table[i].Up, c)
+ } else {
+ s.Table[i].Down = append(s.Table[i].Down, c)
+ }
+ }
+ }
+ s.Stock = d
+ return s, []Event{s.event("deal", nil, "", "")}, nil
+}
+
+// ApplyMove is the engine. A legal move in, the new board and what happened out.
+// An error means the move was illegal and the caller's state is untouched.
+func ApplyMove(s State, m Move) (State, []Event, error) {
+ if s.Phase == PhaseDone {
+ return s, nil, ErrGameOver
+ }
+ // The move is played against a copy, and an illegal one hands the original
+ // back untouched. Nothing below mutates before it has decided the move is
+ // legal — but "nothing below mutates early" is an invariant seven functions
+ // have to keep, and this is one line that doesn't need them to.
+ orig := s
+ s = s.clone()
+
+ var evs []Event
+ var err error
+ switch m.Kind {
+ case "draw":
+ evs, err = s.draw()
+ case "move":
+ evs, err = s.move(m.From, m.To, m.Count)
+ case "home":
+ evs, err = s.home(m.From)
+ case "auto":
+ evs, err = s.auto()
+ case "concede":
+ s.settle(OutcomeCashed, &evs)
+ return s, evs, nil
+ default:
+ return orig, nil, ErrUnknownMove
+ }
+ if err != nil {
+ return orig, nil, err
+ }
+ s.Moves++
+
+ // A cleared board settles itself. Nothing else does: a board with no move left
+ // on it is not something the engine gets to decide, because "no move left" in
+ // Klondike depends on cards nobody has turned over yet.
+ if s.cleared() {
+ s.settle(OutcomeCleared, &evs)
+ }
+ return s, evs, nil
+}
+
+// ---- the moves -------------------------------------------------------------
+
+// draw turns cards off the stock, or puts the waste back under it if the stock
+// is spent and the tier still owes a pass.
+func (s *State) draw() ([]Event, error) {
+ if len(s.Stock) == 0 {
+ if len(s.Waste) == 0 {
+ return nil, ErrNoDraw
+ }
+ // Passes is how many times you may go *through* the stock, so the number of
+ // times you may turn it back over is one less than that. Zero means unlimited.
+ if s.Tier.Passes > 0 && s.Recycles >= s.Tier.Passes-1 {
+ return nil, ErrNoPasses
+ }
+ // The waste is turned over as a block, not reshuffled — so the card that
+ // comes out first on the next pass is the one that came out first on this
+ // one. Which means no reversal: the waste's *bottom* card is the one your
+ // hand lands on when you flip the pile, and the bottom card is the one that
+ // was drawn first. Reversing here would deal a different game on every pass
+ // and quietly break the seed in the audit log.
+ s.Stock = cards.Deck(s.Waste)
+ s.Waste = nil
+ s.Recycles++
+ return []Event{s.event("recycle", nil, "waste", "stock")}, nil
+ }
+
+ n := s.Tier.Draw
+ if n > len(s.Stock) {
+ n = len(s.Stock)
+ }
+ drawn := make([]cards.Card, 0, n)
+ for i := 0; i < n; i++ {
+ c, _ := s.Stock.Draw()
+ drawn = append(drawn, c)
+ s.Waste = append(s.Waste, c)
+ }
+ return []Event{s.event("draw", drawn, "stock", "waste")}, nil
+}
+
+// move takes cards from one pile and puts them on another.
+func (s *State) move(from, to string, count int) ([]Event, error) {
+ if count < 1 {
+ count = 1
+ }
+ lifted, err := s.peek(from, count)
+ if err != nil {
+ return nil, err
+ }
+ if !s.accepts(to, lifted) {
+ return nil, ErrWontGo
+ }
+ if err := s.take(from, count); err != nil {
+ return nil, err
+ }
+ s.put(to, lifted)
+
+ kind := "move"
+ if isFoundation(to) {
+ kind = "home"
+ }
+ evs := []Event{s.event(kind, lifted, from, to)}
+ return s.withFlip(from, evs), nil
+}
+
+// home sends the top card of a pile to the foundation that will take it. There
+// is only ever one, so the player doesn't have to say which.
+func (s *State) home(from string) ([]Event, error) {
+ top, err := s.peek(from, 1)
+ if err != nil {
+ return nil, err
+ }
+ to := "f" + strconv.Itoa(int(top[0].Suit))
+ if !s.accepts(to, top) {
+ return nil, ErrWontGo
+ }
+ return s.move(from, to, 1)
+}
+
+// auto sends everything that can go home, home, and keeps doing it until nothing
+// else can. It is the finish button, and it is also the shortcut for the tail of
+// a board that is already decided.
+//
+// It can cost you: a two you needed on the tableau is a two that has gone home.
+// That is the player's call to make by pressing it, and it is the same call the
+// button makes in every other solitaire ever written.
+func (s *State) auto() ([]Event, error) {
+ var evs []Event
+ for {
+ moved := false
+ for _, from := range sources() {
+ top, err := s.peek(from, 1)
+ if err != nil {
+ continue
+ }
+ to := "f" + strconv.Itoa(int(top[0].Suit))
+ if !s.accepts(to, top) {
+ continue
+ }
+ one, err := s.move(from, to, 1)
+ if err != nil {
+ continue
+ }
+ evs = append(evs, one...)
+ moved = true
+ }
+ if !moved {
+ break
+ }
+ }
+ if len(evs) == 0 {
+ return nil, ErrNothingHome
+ }
+ return evs, nil
+}
+
+// sources are the piles auto() will lift a card off, in the order it tries them.
+func sources() []string {
+ out := make([]string, 0, Piles+1)
+ out = append(out, "waste")
+ for i := 0; i < Piles; i++ {
+ out = append(out, "t"+strconv.Itoa(i))
+ }
+ return out
+}
+
+// withFlip turns up the card a tableau column was hiding, if taking from it left
+// its face-down stack exposed. This is the only thing in the game that reveals a
+// card the player hadn't earned yet, so it is the only place it can happen.
+func (s *State) withFlip(from string, evs []Event) []Event {
+ i, ok := tableauIndex(from)
+ if !ok {
+ return evs
+ }
+ p := &s.Table[i]
+ if len(p.Up) > 0 || len(p.Down) == 0 {
+ return evs
+ }
+ c := p.Down[len(p.Down)-1]
+ p.Down = p.Down[:len(p.Down)-1]
+ p.Up = append(p.Up, c)
+ return append(evs, s.event("flip", []cards.Card{c}, from, from))
+}
+
+// ---- piles -----------------------------------------------------------------
+
+// peek returns the top `count` cards of a pile without taking them, and refuses
+// a run that isn't one you could lift: a tableau run has to descend in rank and
+// alternate colour all the way down, exactly as it does on the felt.
+func (s *State) peek(name string, count int) ([]cards.Card, error) {
+ switch {
+ case name == "waste":
+ if count != 1 {
+ return nil, ErrNotASequence // the waste is a pile, not a run: one card, the top one
+ }
+ if len(s.Waste) == 0 {
+ return nil, ErrEmptyPile
+ }
+ return []cards.Card{s.Waste[len(s.Waste)-1]}, nil
+
+ case isFoundation(name):
+ i, ok := foundationIndex(name)
+ if !ok {
+ return nil, ErrBadPile
+ }
+ if count != 1 {
+ return nil, ErrNotASequence
+ }
+ f := s.Found[i]
+ if len(f) == 0 {
+ return nil, ErrEmptyPile
+ }
+ return []cards.Card{f[len(f)-1]}, nil
+
+ default:
+ i, ok := tableauIndex(name)
+ if !ok {
+ return nil, ErrBadPile
+ }
+ up := s.Table[i].Up
+ if len(up) == 0 {
+ return nil, ErrEmptyPile
+ }
+ if count > len(up) {
+ return nil, ErrNotASequence
+ }
+ run := up[len(up)-count:]
+ if !isRun(run) {
+ return nil, ErrNotASequence
+ }
+ return append([]cards.Card(nil), run...), nil
+ }
+}
+
+// take removes the top `count` cards. peek has already vetted them.
+func (s *State) take(name string, count int) error {
+ switch {
+ case name == "waste":
+ s.Waste = s.Waste[:len(s.Waste)-count]
+ return nil
+ case isFoundation(name):
+ i, _ := foundationIndex(name)
+ s.Found[i] = s.Found[i][:len(s.Found[i])-count]
+ return nil
+ default:
+ i, ok := tableauIndex(name)
+ if !ok {
+ return ErrBadPile
+ }
+ s.Table[i].Up = s.Table[i].Up[:len(s.Table[i].Up)-count]
+ return nil
+ }
+}
+
+// put drops cards onto a pile. accepts has already vetted them.
+func (s *State) put(name string, cs []cards.Card) {
+ if isFoundation(name) {
+ i, _ := foundationIndex(name)
+ s.Found[i] = append(s.Found[i], cs...)
+ return
+ }
+ i, _ := tableauIndex(name)
+ s.Table[i].Up = append(s.Table[i].Up, cs...)
+}
+
+// accepts is the rule the whole game is made of: what may be put where.
+//
+// A foundation takes its own suit in order from the ace, one card at a time. A
+// tableau column takes a run that descends by one and alternates colour from its
+// top card, and an empty column takes a King and nothing else.
+func (s *State) accepts(name string, cs []cards.Card) bool {
+ if len(cs) == 0 {
+ return false
+ }
+ if isFoundation(name) {
+ i, ok := foundationIndex(name)
+ if !ok || len(cs) != 1 {
+ return false
+ }
+ c := cs[0]
+ return int(c.Suit) == i && int(c.Rank) == len(s.Found[i])+1
+ }
+
+ i, ok := tableauIndex(name)
+ if !ok {
+ return false
+ }
+ if !isRun(cs) {
+ return false
+ }
+ up := s.Table[i].Up
+ if len(up) == 0 {
+ // An empty column is the most valuable thing on the board, so it costs a
+ // King to take one. A column with cards still face-down under it is not
+ // empty, and Up being empty there can't happen: withFlip turns one over.
+ return cs[0].Rank == cards.King && len(s.Table[i].Down) == 0
+ }
+ top := up[len(up)-1]
+ return int(cs[0].Rank) == int(top.Rank)-1 && cs[0].Red() != top.Red()
+}
+
+// isRun reports whether these cards, in this order, are a tableau sequence:
+// descending by one, alternating colour.
+func isRun(cs []cards.Card) bool {
+ for i := 1; i < len(cs); i++ {
+ if int(cs[i].Rank) != int(cs[i-1].Rank)-1 || cs[i].Red() == cs[i-1].Red() {
+ return false
+ }
+ }
+ return true
+}
+
+func isFoundation(name string) bool { return strings.HasPrefix(name, "f") }
+
+func tableauIndex(name string) (int, bool) { return pileIndex(name, "t", Piles) }
+
+func foundationIndex(name string) (int, bool) { return pileIndex(name, "f", Foundations) }
+
+func pileIndex(name, prefix string, n int) (int, bool) {
+ if !strings.HasPrefix(name, prefix) {
+ return 0, false
+ }
+ i, err := strconv.Atoi(name[len(prefix):])
+ if err != nil || i < 0 || i >= n {
+ return 0, false
+ }
+ return i, true
+}
+
+// ---- the money -------------------------------------------------------------
+
+// Home is how many cards have reached the foundations. It is the only number in
+// this game that the payout depends on.
+func (s State) Home() int {
+ n := 0
+ for _, f := range s.Found {
+ n += len(f)
+ }
+ return n
+}
+
+// PerCard is what one card home is worth, before the rake. The felt quotes this
+// because "2.2×" tells a player nothing about a game where the multiple is paid
+// out a fifty-second at a time.
+func (s State) PerCard() float64 {
+ return float64(s.Bet) * s.Tier.Base / float64(FullDeck)
+}
+
+// Earned is the gross: what the cards home have bought back, before the house
+// takes anything. Computed from the total rather than card by card, so 52 cards
+// home pays the tier's multiple exactly instead of the multiple less 52 roundings.
+func (s State) Earned() int64 {
+ return int64(math.Floor(float64(s.Bet) * s.Tier.Base * float64(s.Home()) / float64(FullDeck)))
+}
+
+// Pays is what stopping *right now* would actually put back on the player's
+// stack: the gross, less the house's cut of anything above the stake.
+//
+// The felt shows this number while the game is still running and settle() lands
+// on it, and they are the same function for the reason hangman's are: the moment
+// they are two sums, the table is quoting a payout it doesn't honour.
+//
+// Unlike the other games it can be less than the stake, and can be zero. That is
+// the game — you bought the deck, and a deck that gives you nothing owes you
+// nothing.
+func (s State) Pays() int64 {
+ total := s.Earned()
+ profit := total - s.Bet
+ if profit > 0 {
+ rake := int64(math.Floor(float64(profit) * s.RakePct))
+ if rake > 0 {
+ total -= rake
+ }
+ }
+ return total
+}
+
+// rakeNow is the house's cut if the board were cashed right now — the other half
+// of what Pays works out.
+func (s State) rakeNow() int64 {
+ profit := s.Earned() - s.Bet
+ if profit <= 0 {
+ return 0
+ }
+ rake := int64(math.Floor(float64(profit) * s.RakePct))
+ if rake < 0 {
+ return 0
+ }
+ return rake
+}
+
+// Net is what the game did to the player's stack.
+func (s State) Net() int64 {
+ if s.Phase != PhaseDone {
+ return 0
+ }
+ return s.Payout - s.Bet
+}
+
+// cleared reports whether every card is home.
+func (s State) cleared() bool { return s.Home() == FullDeck }
+
+// CanAuto reports whether anything can go home at all — which is what greys the
+// finish button out rather than letting it be pressed at a board that has nothing
+// for it.
+func (s State) CanAuto() bool {
+ for _, from := range sources() {
+ top, err := (&s).peek(from, 1)
+ if err != nil {
+ continue
+ }
+ if (&s).accepts("f"+strconv.Itoa(int(top[0].Suit)), top) {
+ return true
+ }
+ }
+ return false
+}
+
+// PassesLeft is how many more times the player may go through the stock,
+// counting the one they are in. -1 means unlimited.
+func (s State) PassesLeft() int {
+ if s.Tier.Passes <= 0 {
+ return -1
+ }
+ left := s.Tier.Passes - s.Recycles
+ if left < 0 {
+ return 0
+ }
+ return left
+}
+
+// settle closes the game at whatever is on the board. Same rule as everywhere
+// else in the room: the rake comes out of winnings, never out of the stake.
+func (s *State) settle(o Outcome, evs *[]Event) {
+ s.Outcome = o
+ s.Phase = PhaseDone
+ s.Payout = s.Pays()
+ s.Rake = s.rakeNow()
+ *evs = append(*evs, s.event("settle", nil, "", string(o)))
+}
+
+// event stamps an event with the two numbers the felt's meter reads off it, so
+// the browser never has to work out what the board is worth.
+func (s State) event(kind string, cs []cards.Card, from, to string) Event {
+ return Event{
+ Kind: kind, Cards: cs, From: from, To: to,
+ Home: s.Home(), Pays: s.Pays(),
+ }
+}
+
+// clone deep-copies everything with a backing array, so a derived state shares
+// none of it with the one it came from and a board can be replayed freely.
+func (s State) clone() State {
+ s.Stock = append(cards.Deck(nil), s.Stock...)
+ s.Waste = append([]cards.Card(nil), s.Waste...)
+ for i := range s.Table {
+ s.Table[i].Down = append([]cards.Card(nil), s.Table[i].Down...)
+ s.Table[i].Up = append([]cards.Card(nil), s.Table[i].Up...)
+ }
+ for i := range s.Found {
+ s.Found[i] = append([]cards.Card(nil), s.Found[i]...)
+ }
+ return s
+}
diff --git a/internal/games/klondike/klondike_test.go b/internal/games/klondike/klondike_test.go
new file mode 100644
index 0000000..76d8b10
--- /dev/null
+++ b/internal/games/klondike/klondike_test.go
@@ -0,0 +1,730 @@
+package klondike
+
+import (
+ "encoding/json"
+ "math/rand/v2"
+ "strconv"
+ "testing"
+
+ "pete/internal/games/cards"
+)
+
+const rake = 0.05
+
+func vegas() Tier { t, _ := TierBySlug("vegas"); return t }
+func patient() Tier { t, _ := TierBySlug("patient"); return t }
+func cut() Tier { t, _ := TierBySlug("cutthroat"); return t }
+
+func card(r cards.Rank, s cards.Suit) cards.Card { return cards.Card{Rank: r, Suit: s} }
+
+// ordered builds the 52 cards in a fixed order — the deck deal() would get if
+// the shuffle were the identity. Tests that care about the board build their own.
+func ordered() cards.Deck { return cards.NewDeck(1) }
+
+func mustDeal(t *testing.T, bet int64, tier Tier, d cards.Deck) State {
+ t.Helper()
+ s, evs, err := deal(bet, tier, d, rake)
+ if err != nil {
+ t.Fatalf("deal: %v", err)
+ }
+ if len(evs) != 1 || evs[0].Kind != "deal" {
+ t.Fatalf("deal events = %+v, want one deal", evs)
+ }
+ return s
+}
+
+func apply(t *testing.T, s State, m Move) (State, []Event) {
+ t.Helper()
+ next, evs, err := ApplyMove(s, m)
+ if err != nil {
+ t.Fatalf("ApplyMove(%+v): %v", m, err)
+ }
+ return next, evs
+}
+
+func refuses(t *testing.T, s State, m Move, want error) {
+ t.Helper()
+ next, evs, err := ApplyMove(s, m)
+ if err == nil {
+ t.Fatalf("ApplyMove(%+v) was allowed, want %v", m, want)
+ }
+ if want != nil && err != want {
+ t.Fatalf("ApplyMove(%+v) = %v, want %v", m, err, want)
+ }
+ if evs != nil {
+ t.Errorf("an illegal move emitted events: %+v", evs)
+ }
+ // The board an illegal move hands back must be the one it was given. This is
+ // the whole contract of the reducer, and it's cheap to check by value.
+ if !sameBoard(next, s) {
+ t.Errorf("an illegal move changed the board")
+ }
+}
+
+func sameBoard(a, b State) bool {
+ x, _ := json.Marshal(a)
+ y, _ := json.Marshal(b)
+ return string(x) == string(y)
+}
+
+// ---- the deal --------------------------------------------------------------
+
+func TestDealLaysOutTheBoard(t *testing.T) {
+ s := mustDeal(t, 520, vegas(), ordered())
+
+ seen := 0
+ for i := 0; i < Piles; i++ {
+ p := s.Table[i]
+ if len(p.Up) != 1 {
+ t.Errorf("column %d has %d face up, want 1", i, len(p.Up))
+ }
+ if len(p.Down) != i {
+ t.Errorf("column %d has %d face down, want %d", i, len(p.Down), i)
+ }
+ seen += len(p.Up) + len(p.Down)
+ }
+ if seen != 28 {
+ t.Errorf("tableau holds %d cards, want 28", seen)
+ }
+ if len(s.Stock) != 24 {
+ t.Errorf("stock is %d, want 24", len(s.Stock))
+ }
+ if s.Home() != 0 || s.Pays() != 0 {
+ t.Errorf("a fresh board is worth %d from %d home, want nothing", s.Pays(), s.Home())
+ }
+}
+
+func TestDealRefusesABadStake(t *testing.T) {
+ if _, _, err := deal(0, vegas(), ordered(), rake); err != ErrBadBet {
+ t.Fatalf("deal(0) = %v, want ErrBadBet", err)
+ }
+ if _, _, err := New(-5, vegas(), rake, cards.NewRNG(1, 2)); err != ErrBadBet {
+ t.Fatalf("New(-5) = %v, want ErrBadBet", err)
+ }
+}
+
+// ---- the stock -------------------------------------------------------------
+
+func TestDrawTurnsTheTiersCount(t *testing.T) {
+ for _, tier := range []Tier{patient(), vegas()} {
+ s := mustDeal(t, 100, tier, ordered())
+ next, evs := apply(t, s, Move{Kind: "draw"})
+ if len(next.Waste) != tier.Draw {
+ t.Errorf("%s: waste is %d after one draw, want %d", tier.Slug, len(next.Waste), tier.Draw)
+ }
+ if len(next.Stock) != 24-tier.Draw {
+ t.Errorf("%s: stock is %d, want %d", tier.Slug, len(next.Stock), 24-tier.Draw)
+ }
+ if len(evs) != 1 || evs[0].Kind != "draw" || len(evs[0].Cards) != tier.Draw {
+ t.Errorf("%s: draw events = %+v", tier.Slug, evs)
+ }
+ }
+}
+
+// The last pull off a short stock turns over what's left rather than refusing.
+func TestDrawTakesWhatIsLeft(t *testing.T) {
+ s := mustDeal(t, 100, vegas(), ordered()) // 24 in the stock, drawing 3
+ for i := 0; i < 7; i++ {
+ s, _ = apply(t, s, Move{Kind: "draw"}) // 21 drawn, 3 left
+ }
+ s, _ = apply(t, s, Move{Kind: "draw"})
+ if len(s.Stock) != 0 || len(s.Waste) != 24 {
+ t.Fatalf("stock %d waste %d, want 0 and 24", len(s.Stock), len(s.Waste))
+ }
+ refuses(t, drained(t, s), Move{Kind: "draw"}, ErrNoDraw)
+}
+
+// drained empties the waste too, so there is genuinely nothing to turn over.
+func drained(t *testing.T, s State) State {
+ t.Helper()
+ s = s.clone()
+ s.Waste = nil
+ s.Stock = nil
+ return s
+}
+
+// The waste goes back under the stock in the order it came out — a recycle is a
+// pile being turned over, not reshuffled. If this ever reshuffled, the seed in
+// the audit log would stop replaying the game.
+func TestRecycleTurnsTheWasteOverInOrder(t *testing.T) {
+ s := mustDeal(t, 100, patient(), ordered())
+ want := append(cards.Deck(nil), s.Stock...)
+
+ for i := 0; i < 24; i++ {
+ s, _ = apply(t, s, Move{Kind: "draw"})
+ }
+ next, evs := apply(t, s, Move{Kind: "draw"})
+ if len(evs) != 1 || evs[0].Kind != "recycle" {
+ t.Fatalf("events = %+v, want a recycle", evs)
+ }
+ if len(next.Waste) != 0 {
+ t.Errorf("waste is %d after a recycle, want empty", len(next.Waste))
+ }
+ for i := range want {
+ if next.Stock[i] != want[i] {
+ t.Fatalf("stock[%d] = %v after recycle, want %v — the pile was reshuffled",
+ i, next.Stock[i], want[i])
+ }
+ }
+ if next.Recycles != 1 {
+ t.Errorf("recycles = %d, want 1", next.Recycles)
+ }
+}
+
+// Passes is how many times you may go *through* the stock, so it is one more
+// than the number of times you may turn it back over.
+func TestPassesRunOut(t *testing.T) {
+ tests := []struct {
+ tier Tier
+ recycles int // how many turn-overs the tier should allow
+ }{
+ {cut(), 0}, // one pass: you never get to turn it back over
+ {vegas(), 2}, // three passes: two turn-overs
+ {patient(), -1}, // unlimited
+ }
+ for _, tc := range tests {
+ s := mustDeal(t, 100, tc.tier, ordered())
+ if got := s.PassesLeft(); tc.recycles < 0 && got != -1 {
+ t.Errorf("%s: PassesLeft = %d, want -1 (unlimited)", tc.tier.Slug, got)
+ }
+ allowed := 0
+ for i := 0; i < 5; i++ {
+ // Empty the stock, then try to turn it over.
+ for len(s.Stock) > 0 {
+ s, _ = apply(t, s, Move{Kind: "draw"})
+ }
+ next, _, err := ApplyMove(s, Move{Kind: "draw"})
+ if err == ErrNoPasses {
+ break
+ }
+ if err != nil {
+ t.Fatalf("%s: %v", tc.tier.Slug, err)
+ }
+ s = next
+ allowed++
+ }
+ if tc.recycles < 0 {
+ if allowed != 5 {
+ t.Errorf("%s: only %d recycles allowed, want unlimited", tc.tier.Slug, allowed)
+ }
+ continue
+ }
+ if allowed != tc.recycles {
+ t.Errorf("%s: %d recycles allowed, want %d", tc.tier.Slug, allowed, tc.recycles)
+ }
+ if s.PassesLeft() != 1 {
+ t.Errorf("%s: PassesLeft = %d on the last pass, want 1", tc.tier.Slug, s.PassesLeft())
+ }
+ }
+}
+
+// ---- the rules -------------------------------------------------------------
+
+// board builds a State directly, so a rule can be tested against the position
+// that exercises it rather than against whatever a shuffle happened to deal.
+func board(tier Tier, bet int64) State {
+ return State{Tier: tier, Bet: bet, RakePct: rake, Phase: PhasePlaying}
+}
+
+func TestTableauTakesDescendingAlternatingColour(t *testing.T) {
+ s := board(vegas(), 520)
+ s.Table[0].Up = []cards.Card{card(8, cards.Spades)} // black 8
+ s.Table[1].Up = []cards.Card{card(7, cards.Hearts)} // red 7 — goes on the 8
+ s.Table[2].Up = []cards.Card{card(7, cards.Clubs)} // black 7 — does not
+ s.Table[3].Up = []cards.Card{card(6, cards.Hearts)} // red 6 — wrong rank for the 8
+
+ next, evs := apply(t, s, Move{Kind: "move", From: "t1", To: "t0"})
+ if len(next.Table[0].Up) != 2 || next.Table[0].Up[1] != card(7, cards.Hearts) {
+ t.Fatalf("the red seven didn't land on the black eight: %+v", next.Table[0].Up)
+ }
+ if len(next.Table[1].Up) != 0 {
+ t.Errorf("the seven is still in its old column")
+ }
+ if len(evs) != 1 || evs[0].Kind != "move" {
+ t.Errorf("events = %+v, want one move", evs)
+ }
+
+ refuses(t, s, Move{Kind: "move", From: "t2", To: "t0"}, ErrWontGo) // same colour
+ refuses(t, s, Move{Kind: "move", From: "t3", To: "t0"}, ErrWontGo) // two below
+}
+
+func TestOnlyAKingTakesAnEmptyColumn(t *testing.T) {
+ s := board(vegas(), 520)
+ // t0 is empty and has nothing under it.
+ s.Table[1].Up = []cards.Card{card(cards.King, cards.Hearts)}
+ s.Table[2].Up = []cards.Card{card(cards.Queen, cards.Spades)}
+
+ refuses(t, s, Move{Kind: "move", From: "t2", To: "t0"}, ErrWontGo)
+
+ next, _ := apply(t, s, Move{Kind: "move", From: "t1", To: "t0"})
+ if len(next.Table[0].Up) != 1 || next.Table[0].Up[0].Rank != cards.King {
+ t.Fatalf("the king didn't take the empty column: %+v", next.Table[0].Up)
+ }
+}
+
+// A run comes off the tableau as a block, and only if it is a run.
+func TestLiftingARun(t *testing.T) {
+ s := board(vegas(), 520)
+ s.Table[0].Up = []cards.Card{
+ card(9, cards.Hearts), // red
+ card(8, cards.Spades), // black
+ card(7, cards.Diamonds), // red
+ }
+ s.Table[1].Up = []cards.Card{card(10, cards.Clubs)} // black 10 takes the red 9
+
+ next, _ := apply(t, s, Move{Kind: "move", From: "t0", To: "t1", Count: 3})
+ if len(next.Table[1].Up) != 4 || len(next.Table[0].Up) != 0 {
+ t.Fatalf("the run didn't move as a block: t0=%v t1=%v", next.Table[0].Up, next.Table[1].Up)
+ }
+
+ // Not a run: same colour in the middle of it.
+ bad := board(vegas(), 520)
+ bad.Table[0].Up = []cards.Card{
+ card(9, cards.Hearts),
+ card(8, cards.Diamonds), // red on red
+ }
+ bad.Table[1].Up = []cards.Card{card(10, cards.Clubs)}
+ refuses(t, bad, Move{Kind: "move", From: "t0", To: "t1", Count: 2}, ErrNotASequence)
+
+ // And you can't lift more cards than the column has.
+ refuses(t, bad, Move{Kind: "move", From: "t0", To: "t1", Count: 9}, ErrNotASequence)
+}
+
+// Taking the last face-up card off a column turns the next one over. This is the
+// only thing in the game that reveals a card, which is the point of the test.
+func TestTakingTheLastCardFlipsTheNextOne(t *testing.T) {
+ s := board(vegas(), 520)
+ hidden := card(cards.Queen, cards.Clubs)
+ s.Table[0].Down = []cards.Card{card(2, cards.Spades), hidden}
+ s.Table[0].Up = []cards.Card{card(7, cards.Hearts)}
+ s.Table[1].Up = []cards.Card{card(8, cards.Spades)}
+
+ next, evs := apply(t, s, Move{Kind: "move", From: "t0", To: "t1"})
+ if len(next.Table[0].Up) != 1 || next.Table[0].Up[0] != hidden {
+ t.Fatalf("the hidden card didn't turn over: %+v", next.Table[0].Up)
+ }
+ if len(next.Table[0].Down) != 1 {
+ t.Errorf("face-down stack is %d, want 1", len(next.Table[0].Down))
+ }
+ if len(evs) != 2 || evs[1].Kind != "flip" || evs[1].Cards[0] != hidden {
+ t.Fatalf("events = %+v, want a move then a flip carrying the card", evs)
+ }
+}
+
+func TestFoundationsBuildUpBySuitFromTheAce(t *testing.T) {
+ s := board(vegas(), 520)
+ s.Table[0].Up = []cards.Card{card(cards.Ace, cards.Hearts)}
+ s.Table[1].Up = []cards.Card{card(2, cards.Hearts)}
+ s.Table[2].Up = []cards.Card{card(2, cards.Spades)}
+ s.Table[3].Up = []cards.Card{card(3, cards.Hearts)}
+
+ // A two can't start a foundation.
+ refuses(t, s, Move{Kind: "home", From: "t1"}, ErrWontGo)
+
+ s, evs := apply(t, s, Move{Kind: "home", From: "t0"})
+ if len(s.Found[cards.Hearts]) != 1 {
+ t.Fatalf("the ace didn't go home: %+v", s.Found)
+ }
+ if evs[0].Kind != "home" || evs[0].To != "f"+strconv.Itoa(int(cards.Hearts)) {
+ t.Fatalf("event = %+v, want a home to the hearts pile", evs[0])
+ }
+ if evs[0].Home != 1 {
+ t.Errorf("event carries Home=%d, want 1", evs[0].Home)
+ }
+
+ // The three can't jump the two, and the two of spades can't go on hearts.
+ refuses(t, s, Move{Kind: "home", From: "t3"}, ErrWontGo)
+ refuses(t, s, Move{Kind: "move", From: "t2", To: "f" + strconv.Itoa(int(cards.Hearts))}, ErrWontGo)
+
+ s, _ = apply(t, s, Move{Kind: "home", From: "t1"})
+ if s.Home() != 2 {
+ t.Errorf("Home = %d, want 2", s.Home())
+ }
+}
+
+// A card can come back off a foundation — a real rule, and one that matters when
+// you need a low card to move a column. The payout follows it back down, because
+// the payout reads the board rather than counting events.
+func TestACardComesBackOffAFoundation(t *testing.T) {
+ s := board(vegas(), 5200)
+ s.Found[cards.Hearts] = []cards.Card{card(cards.Ace, cards.Hearts), card(2, cards.Hearts)}
+ s.Table[0].Up = []cards.Card{card(3, cards.Spades)}
+
+ before := s.Pays()
+ next, _ := apply(t, s, Move{Kind: "move", From: "f" + strconv.Itoa(int(cards.Hearts)), To: "t0"})
+ if len(next.Found[cards.Hearts]) != 1 || len(next.Table[0].Up) != 2 {
+ t.Fatalf("the two didn't come back down: found=%v t0=%v", next.Found[cards.Hearts], next.Table[0].Up)
+ }
+ if next.Home() != 1 {
+ t.Errorf("Home = %d after taking a card back, want 1", next.Home())
+ }
+ if next.Pays() >= before {
+ t.Errorf("Pays = %d after taking a card back, want less than %d", next.Pays(), before)
+ }
+}
+
+func TestWasteGivesUpItsTopCardOnly(t *testing.T) {
+ s := board(vegas(), 520)
+ s.Waste = []cards.Card{card(5, cards.Spades), card(7, cards.Hearts)}
+ s.Table[0].Up = []cards.Card{card(8, cards.Spades)}
+
+ // The 5 is under the 7 and is not available, however much you'd like it.
+ refuses(t, s, Move{Kind: "move", From: "waste", To: "t0", Count: 2}, ErrNotASequence)
+
+ next, _ := apply(t, s, Move{Kind: "move", From: "waste", To: "t0"})
+ if len(next.Waste) != 1 || next.Waste[0] != card(5, cards.Spades) {
+ t.Fatalf("the wrong card left the waste: %+v", next.Waste)
+ }
+}
+
+func TestEmptyPilesAndNonsensePiles(t *testing.T) {
+ s := board(vegas(), 520)
+ s.Table[0].Up = []cards.Card{card(8, cards.Spades)}
+
+ refuses(t, s, Move{Kind: "move", From: "waste", To: "t0"}, ErrEmptyPile)
+ refuses(t, s, Move{Kind: "move", From: "t3", To: "t0"}, ErrEmptyPile)
+ refuses(t, s, Move{Kind: "move", From: "t9", To: "t0"}, ErrBadPile)
+ refuses(t, s, Move{Kind: "move", From: "t0", To: "t9"}, ErrWontGo)
+ refuses(t, s, Move{Kind: "move", From: "banana", To: "t0"}, ErrBadPile)
+ refuses(t, s, Move{Kind: "sing"}, ErrUnknownMove)
+}
+
+// ---- auto ------------------------------------------------------------------
+
+func TestAutoSendsEverythingItCanHome(t *testing.T) {
+ s := board(vegas(), 5200)
+ // Two aces and the hearts two, sitting on top of three columns.
+ s.Table[0].Up = []cards.Card{card(cards.Ace, cards.Hearts)}
+ s.Table[1].Up = []cards.Card{card(2, cards.Hearts)}
+ s.Table[2].Up = []cards.Card{card(cards.Ace, cards.Spades)}
+ s.Table[3].Up = []cards.Card{card(9, cards.Clubs)} // goes nowhere
+
+ next, evs := apply(t, s, Move{Kind: "auto"})
+ if next.Home() != 3 {
+ t.Fatalf("Home = %d after auto, want 3 (two aces and the two)", next.Home())
+ }
+ if len(next.Table[3].Up) != 1 {
+ t.Errorf("the nine went somewhere it couldn't go")
+ }
+ homes := 0
+ for _, e := range evs {
+ if e.Kind == "home" {
+ homes++
+ }
+ }
+ if homes != 3 {
+ t.Errorf("auto emitted %d home events, want 3 — the table has to animate each one", homes)
+ }
+
+ // Nothing left to do: the button says so rather than doing nothing quietly.
+ if next.CanAuto() {
+ t.Errorf("CanAuto is true with only a nine on the board")
+ }
+ refuses(t, next, Move{Kind: "auto"}, ErrNothingHome)
+}
+
+// ---- the money -------------------------------------------------------------
+
+// The number the felt quotes while you play and the number settle() lands on are
+// the same function. Hangman had these as two sums once and the table advertised
+// a payout the house didn't honour; this asserts they can't drift here.
+func TestTheQuoteIsThePayout(t *testing.T) {
+ s := board(vegas(), 1000)
+ for home := 0; home <= FullDeck; home++ {
+ s.Found = [Foundations][]cards.Card{}
+ left := home
+ for suit := 0; suit < Foundations && left > 0; suit++ {
+ n := left
+ if n > 13 {
+ n = 13
+ }
+ for r := 1; r <= n; r++ {
+ s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
+ }
+ left -= n
+ }
+ if s.Home() != home {
+ t.Fatalf("built a board with %d home, wanted %d", s.Home(), home)
+ }
+
+ quoted := s.Pays()
+ var evs []Event
+ done := s.clone()
+ done.settle(OutcomeCashed, &evs)
+ if done.Payout != quoted {
+ t.Fatalf("%d home: the felt quoted %d and settle paid %d", home, quoted, done.Payout)
+ }
+ if done.Payout+done.Rake != done.Earned() {
+ t.Fatalf("%d home: payout %d + rake %d != earned %d",
+ home, done.Payout, done.Rake, done.Earned())
+ }
+ }
+}
+
+func TestAFullBoardPaysTheTiersMultiple(t *testing.T) {
+ for _, tier := range Tiers {
+ s := board(tier, 1000)
+ for suit := 0; suit < Foundations; suit++ {
+ for r := 1; r <= 13; r++ {
+ s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
+ }
+ }
+ // Gross is the multiple exactly — computed from the total, not summed 52
+ // times, so it doesn't bleed a rounding per card.
+ want := int64(float64(s.Bet) * tier.Base)
+ if s.Earned() != want {
+ t.Errorf("%s: a cleared board earns %d, want %d", tier.Slug, s.Earned(), want)
+ }
+ // And the rake comes out of the winnings, never the stake.
+ profit := want - s.Bet
+ if s.Pays() != want-int64(float64(profit)*rake) {
+ t.Errorf("%s: pays %d, want %d less %v%% of the profit", tier.Slug, s.Pays(), want, rake*100)
+ }
+ }
+}
+
+// An empty board owes nothing, and is not charged a fee for owing nothing.
+func TestNothingHomePaysNothing(t *testing.T) {
+ s := board(cut(), 500)
+ if s.Pays() != 0 || s.rakeNow() != 0 {
+ t.Fatalf("an empty board pays %d and rakes %d, want nothing either way", s.Pays(), s.rakeNow())
+ }
+ var evs []Event
+ s.settle(OutcomeCashed, &evs)
+ if s.Payout != 0 || s.Net() != -500 {
+ t.Errorf("payout %d net %d, want 0 and -500", s.Payout, s.Net())
+ }
+}
+
+// Below break-even the player is down but is not raked: there is no profit to
+// take a cut of.
+func TestNoRakeBelowTheStake(t *testing.T) {
+ tier := vegas()
+ s := board(tier, 5200)
+ for i := 0; i < tier.BreakEven()-1; i++ {
+ suit, r := i/13, i%13+1
+ s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
+ }
+ if s.Earned() > s.Bet {
+ t.Fatalf("break-even is meant to be the first card that gets you square, but %d earns %d on a %d stake",
+ s.Home(), s.Earned(), s.Bet)
+ }
+ if s.rakeNow() != 0 {
+ t.Errorf("raked %d off a losing board", s.rakeNow())
+ }
+ if s.Pays() != s.Earned() {
+ t.Errorf("pays %d, want the full %d — nothing to rake", s.Pays(), s.Earned())
+ }
+}
+
+func TestBreakEvenIsTheCardThatGetsYouSquare(t *testing.T) {
+ for _, tier := range Tiers {
+ s := board(tier, 5200)
+ for i := 0; i < tier.BreakEven(); i++ {
+ suit, r := i/13, i%13+1
+ s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
+ }
+ if s.Earned() < s.Bet {
+ t.Errorf("%s: %d cards home earns %d on a %d stake — break-even is quoted too low",
+ tier.Slug, s.Home(), s.Earned(), s.Bet)
+ }
+ }
+}
+
+// ---- settling --------------------------------------------------------------
+
+func TestConcedeCashesTheBoard(t *testing.T) {
+ s := board(vegas(), 5200)
+ s.Found[cards.Hearts] = []cards.Card{card(cards.Ace, cards.Hearts), card(2, cards.Hearts)}
+ want := s.Pays()
+
+ next, evs := apply(t, s, Move{Kind: "concede"})
+ if next.Phase != PhaseDone || next.Outcome != OutcomeCashed {
+ t.Fatalf("phase %q outcome %q, want done/cashed", next.Phase, next.Outcome)
+ }
+ if next.Payout != want {
+ t.Errorf("cashed for %d, want the %d the board was quoting", next.Payout, want)
+ }
+ if evs[len(evs)-1].Kind != "settle" {
+ t.Errorf("no settle event: %+v", evs)
+ }
+ refuses(t, next, Move{Kind: "draw"}, ErrGameOver)
+}
+
+// The last card home ends the game on its own — the player doesn't have to tell
+// the table they've won.
+func TestTheLastCardHomeClearsTheBoard(t *testing.T) {
+ s := board(vegas(), 1000)
+ for suit := 0; suit < Foundations; suit++ {
+ top := 13
+ if suit == int(cards.Clubs) {
+ top = 12 // the king of clubs is the one card still out
+ }
+ for r := 1; r <= top; r++ {
+ s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
+ }
+ }
+ s.Table[0].Up = []cards.Card{card(cards.King, cards.Clubs)}
+
+ next, evs := apply(t, s, Move{Kind: "home", From: "t0"})
+ if next.Phase != PhaseDone || next.Outcome != OutcomeCleared {
+ t.Fatalf("phase %q outcome %q, want done/cleared", next.Phase, next.Outcome)
+ }
+ if next.Payout != int64(float64(1000)*vegas().Base)-int64(float64(int64(float64(1000)*vegas().Base)-1000)*rake) {
+ t.Errorf("a cleared board paid %d", next.Payout)
+ }
+ if evs[len(evs)-1].Kind != "settle" {
+ t.Errorf("the winning card didn't settle the game: %+v", evs)
+ }
+}
+
+// ---- the shape of the thing ------------------------------------------------
+
+// A game survives a redeploy: the whole state, shoe and face-down cards and all,
+// goes through JSON and comes back the same board.
+func TestAGameSurvivesJSON(t *testing.T) {
+ s, _, err := New(500, cut(), rake, cards.NewRNG(7, 11))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for i := 0; i < 6; i++ {
+ s, _, _ = ApplyMove(s, Move{Kind: "draw"})
+ }
+ blob, err := json.Marshal(s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var back State
+ if err := json.Unmarshal(blob, &back); err != nil {
+ t.Fatal(err)
+ }
+ if !sameBoard(s, back) {
+ t.Fatal("the board didn't come back the same")
+ }
+}
+
+// The same seed deals the same board. This is what lets a disputed game be dealt
+// again exactly as it fell, and it is why the RNG is threaded rather than global.
+func TestASeedDealsTheSameBoard(t *testing.T) {
+ a, _, err := New(100, vegas(), rake, cards.NewRNG(42, 99))
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, _, err := New(100, vegas(), rake, cards.NewRNG(42, 99))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !sameBoard(a, b) {
+ t.Fatal("the same seed dealt two different boards")
+ }
+
+ c, _, _ := New(100, vegas(), rake, cards.NewRNG(43, 99))
+ if sameBoard(a, c) {
+ t.Fatal("two seeds dealt the same board")
+ }
+}
+
+// Every card is on the board exactly once, whatever you do to it. A move that
+// duplicated a card would be a move that printed money.
+func TestNoCardIsEverLostOrDuplicated(t *testing.T) {
+ rng := rand.New(rand.NewPCG(3, 5))
+ s, _, err := New(1000, patient(), rake, rng)
+ if err != nil {
+ t.Fatal(err)
+ }
+ countDeck(t, s, "the deal")
+
+ // Play a long random game: whatever the fuzzer stumbles into, the deck holds.
+ for i := 0; i < 4000 && s.Phase == PhasePlaying; i++ {
+ m := randomMove(rng)
+ next, _, err := ApplyMove(s, m)
+ if err != nil {
+ continue // an illegal move is a fine thing for a fuzzer to find
+ }
+ s = next
+ countDeck(t, s, "after "+m.Kind)
+ }
+}
+
+func randomMove(rng *rand.Rand) Move {
+ pile := func() string {
+ switch rng.IntN(3) {
+ case 0:
+ return "waste"
+ case 1:
+ return "t" + strconv.Itoa(rng.IntN(Piles))
+ default:
+ return "f" + strconv.Itoa(rng.IntN(Foundations))
+ }
+ }
+ switch rng.IntN(10) {
+ case 0, 1, 2, 3:
+ return Move{Kind: "draw"}
+ case 4:
+ return Move{Kind: "home", From: pile()}
+ case 5:
+ return Move{Kind: "auto"}
+ default:
+ return Move{Kind: "move", From: pile(), To: pile(), Count: 1 + rng.IntN(4)}
+ }
+}
+
+func countDeck(t *testing.T, s State, when string) {
+ t.Helper()
+ seen := map[cards.Card]int{}
+ add := func(cs []cards.Card) {
+ for _, c := range cs {
+ seen[c]++
+ }
+ }
+ add(s.Stock)
+ add(s.Waste)
+ for _, p := range s.Table {
+ add(p.Down)
+ add(p.Up)
+ }
+ for _, f := range s.Found {
+ add(f)
+ }
+ if len(seen) != FullDeck {
+ t.Fatalf("%s: %d distinct cards on the board, want 52", when, len(seen))
+ }
+ for c, n := range seen {
+ if n != 1 {
+ t.Fatalf("%s: %v appears %d times", when, c, n)
+ }
+ }
+}
+
+// The face-up run in every tableau column is always a legal run, and a column
+// with cards face-up never has an unturned card left under it. Both are things
+// the *rules* keep true, so a fuzzer that breaks them has found a real bug.
+func TestTheBoardStaysWellFormed(t *testing.T) {
+ rng := rand.New(rand.NewPCG(11, 13))
+ s, _, err := New(1000, vegas(), rake, rng)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for i := 0; i < 4000 && s.Phase == PhasePlaying; i++ {
+ next, _, err := ApplyMove(s, randomMove(rng))
+ if err != nil {
+ continue
+ }
+ s = next
+ for j, p := range s.Table {
+ if !isRun(p.Up) {
+ t.Fatalf("column %d holds a run that isn't one: %v", j, p.Up)
+ }
+ if len(p.Up) == 0 && len(p.Down) > 0 {
+ t.Fatalf("column %d has %d cards face down and nothing turned over", j, len(p.Down))
+ }
+ }
+ for suit, f := range s.Found {
+ for r, c := range f {
+ if int(c.Suit) != suit || int(c.Rank) != r+1 {
+ t.Fatalf("foundation %d holds %v at position %d", suit, c, r)
+ }
+ }
+ }
+ }
+}
diff --git a/internal/web/games_pages.go b/internal/web/games_pages.go
index 88f335a..6846823 100644
--- a/internal/web/games_pages.go
+++ b/internal/web/games_pages.go
@@ -6,6 +6,7 @@ import (
"pete/internal/games/blackjack"
"pete/internal/games/hangman"
+ "pete/internal/games/klondike"
"pete/internal/storage"
)
@@ -26,9 +27,9 @@ type gameTeaser struct {
}
var comingSoon = []gameTeaser{
+ {Name: "Trivia", Emoji: "🧠", Blurb: "Faster answers score higher."},
{Name: "Hold'em", Emoji: "♠️", Blurb: "Six seats, and the house bots know how to play."},
{Name: "UNO", Emoji: "🎴", Blurb: "Normal rules, or no mercy."},
- {Name: "Trivia", Emoji: "🧠", Blurb: "Faster answers score higher."},
}
// betDenominations are the chips you build a bet out of.
@@ -72,6 +73,8 @@ type gamesPage struct {
Denominations []int64
Tiers []hangman.Tier // hangman's three lengths, and what each pays
MaxWrong int
+ Deals []klondike.Tier // solitaire's three deals
+ FullDeck int
}
// casinoRoutes hangs every table off the mux.
@@ -84,6 +87,7 @@ func (s *Server) casinoRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /games", s.handleLobby)
mux.HandleFunc("GET /games/blackjack", s.handleBlackjack)
mux.HandleFunc("GET /games/hangman", s.handleHangman)
+ mux.HandleFunc("GET /games/solitaire", s.handleSolitaire)
mux.HandleFunc("GET /api/games/table", s.handleTable)
mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn)
@@ -94,6 +98,9 @@ func (s *Server) casinoRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/games/hangman/start", s.handleHangmanStart)
mux.HandleFunc("POST /api/games/hangman/guess", s.handleHangmanGuess)
+
+ mux.HandleFunc("POST /api/games/solitaire/start", s.handleSolitaireStart)
+ mux.HandleFunc("POST /api/games/solitaire/move", s.handleSolitaireMove)
}
// requirePlayer sends an anonymous visitor to sign in and comes back here after.
@@ -122,6 +129,8 @@ func (s *Server) gamesPage(r *http.Request) gamesPage {
Denominations: betDenominations,
Tiers: hangman.Tiers,
MaxWrong: hangman.MaxWrong,
+ Deals: klondike.Tiers,
+ FullDeck: klondike.FullDeck,
}
}
@@ -145,3 +154,10 @@ func (s *Server) handleHangman(w http.ResponseWriter, r *http.Request) {
}
s.render(w, "hangman", s.gamesPage(r))
}
+
+func (s *Server) handleSolitaire(w http.ResponseWriter, r *http.Request) {
+ if !s.requirePlayer(w, r) {
+ return
+ }
+ s.render(w, "solitaire", s.gamesPage(r))
+}
diff --git a/internal/web/games_play.go b/internal/web/games_play.go
index d3961ab..4027ef7 100644
--- a/internal/web/games_play.go
+++ b/internal/web/games_play.go
@@ -13,6 +13,7 @@ import (
"pete/internal/games/blackjack"
"pete/internal/games/cards"
"pete/internal/games/hangman"
+ "pete/internal/games/klondike"
"pete/internal/storage"
)
@@ -177,7 +178,7 @@ type tableView struct {
Euros float64 `json:"euros"` // advisory, and up to a couple of minutes stale
Cap int64 `json:"cap"`
- Game string `json:"game,omitempty"` // "blackjack" | "hangman", if one is live
+ Game string `json:"game,omitempty"` // "blackjack" | "hangman" | "solitaire", if one is live
Hand *handView `json:"hand,omitempty"` // blackjack
Events []eventView `json:"events,omitempty"` // blackjack, only on a move
@@ -185,6 +186,9 @@ type tableView struct {
Hangman *hangmanView `json:"hangman,omitempty"`
HangEvents []hangman.Event `json:"hang_events,omitempty"`
+ Solitaire *solitaireView `json:"solitaire,omitempty"`
+ SolEvents []solEventView `json:"sol_events,omitempty"`
+
Rake float64 `json:"rake_pct"`
}
@@ -228,6 +232,13 @@ func (s *Server) table(user string) (tableView, error) {
}
hv := viewHangman(g)
v.Hangman = &hv
+ case gameSolitaire:
+ var g klondike.State
+ if err := json.Unmarshal(live.State, &g); err != nil {
+ return s.dropUnreadable(user, v, err)
+ }
+ sv := viewSolitaire(g)
+ v.Solitaire = &sv
default:
return s.dropUnreadable(user, v, fmt.Errorf("unknown game %q", live.Game))
}
@@ -482,6 +493,7 @@ func (s *Server) handleMove(w http.ResponseWriter, r *http.Request) {
const (
gameBlackjack = "blackjack"
gameHangman = "hangman"
+ gameSolitaire = "solitaire"
)
// finished is what commit needs to know about a game it's writing back: enough
diff --git a/internal/web/games_solitaire.go b/internal/web/games_solitaire.go
new file mode 100644
index 0000000..445d40f
--- /dev/null
+++ b/internal/web/games_solitaire.go
@@ -0,0 +1,286 @@
+package web
+
+import (
+ "encoding/json"
+ "errors"
+ "log/slog"
+ "math/rand/v2"
+ "net/http"
+
+ "pete/internal/games/blackjack"
+ "pete/internal/games/cards"
+ "pete/internal/games/klondike"
+ "pete/internal/storage"
+)
+
+// Solitaire, played for chips. Vegas scoring: you buy the deck, and every card
+// you get home pays a slice of it back.
+//
+// The withheld information here is bigger than blackjack's single hole card —
+// it's the stock and every face-down card in the tableau, which between them are
+// most of the deck. So the view sends *counts* for both: a column says how many
+// cards are face-down under it, never which. A browser that held the stock would
+// be a browser that knows whether the next pull is worth taking, and this game is
+// nothing but that decision, repeated.
+//
+// The events, on the other hand, need no filtering at all, and that's worth
+// saying out loud because blackjack's do. Every card a klondike event carries is
+// a card the move itself just turned face up: the draw puts cards in the waste,
+// the flip turns a column's top card over, a move carries cards that were already
+// face up. There is no event here that mentions a card the player isn't now
+// looking at.
+
+// solPileView is one tableau column: how deep the face-down stack is, and the
+// run sitting face up on top of it.
+type solPileView struct {
+ Down int `json:"down"`
+ Up []cardView `json:"up"`
+}
+
+// solFoundView is one foundation. Only the top card matters — it's the only one
+// that can be played back off — so it's the only one sent, with a count for the
+// height of the pile.
+type solFoundView struct {
+ Suit string `json:"suit"` // the glyph, so the empty pile can show what it wants
+ Red bool `json:"red"`
+ N int `json:"n"`
+ Top *cardView `json:"top,omitempty"`
+}
+
+// solitaireView is a board as its player may see it.
+type solitaireView struct {
+ Tier klondike.Tier `json:"tier"`
+
+ Stock int `json:"stock"` // how many cards are left in it, not which
+ Waste []cardView `json:"waste"` // the top few, in the order they were turned
+ WasteN int `json:"waste_n"`
+ Table []solPileView `json:"table"`
+ Found []solFoundView `json:"found"`
+
+ Passes int `json:"passes"` // through the stock, counting this one; -1 unlimited
+ Moves int `json:"moves"`
+ CanAuto bool `json:"can_auto"`
+
+ Home int `json:"home"` // cards on the foundations
+ PerCard float64 `json:"per_card"` // what one more is worth
+ BreakEven int `json:"break_even"` // how many gets you square with the house
+ Bet int64 `json:"bet"`
+ Stands int64 `json:"stands"` // what cashing out right now actually pays
+
+ Phase string `json:"phase"`
+ Outcome string `json:"outcome,omitempty"`
+ Payout int64 `json:"payout,omitempty"`
+ Rake int64 `json:"rake,omitempty"`
+ Net int64 `json:"net"`
+}
+
+// wasteShown is how much of the waste the felt fans out. Three, because that is
+// what a three-card draw puts down and the rest of the pile is just a pile.
+const wasteShown = 3
+
+func viewSolitaire(g klondike.State) solitaireView {
+ v := solitaireView{
+ Tier: g.Tier,
+ Stock: len(g.Stock),
+ WasteN: len(g.Waste),
+ Passes: g.PassesLeft(),
+ Moves: g.Moves,
+ CanAuto: g.CanAuto(),
+ Home: g.Home(),
+ PerCard: g.PerCard(),
+ BreakEven: g.Tier.BreakEven(),
+ Bet: g.Bet,
+ // What cashing out right now would actually land on the stack, rake already
+ // out of it. The pre-rake figure would have the felt advertising a number
+ // the house doesn't hand over.
+ Stands: g.Pays(),
+ Phase: string(g.Phase),
+ Outcome: string(g.Outcome),
+ Payout: g.Payout,
+ Rake: g.Rake,
+ Net: g.Net(),
+ }
+
+ from := len(g.Waste) - wasteShown
+ if from < 0 {
+ from = 0
+ }
+ for _, c := range g.Waste[from:] {
+ v.Waste = append(v.Waste, viewCard(c))
+ }
+
+ v.Table = make([]solPileView, klondike.Piles)
+ for i, p := range g.Table {
+ v.Table[i] = solPileView{Down: len(p.Down)}
+ for _, c := range p.Up {
+ v.Table[i].Up = append(v.Table[i].Up, viewCard(c))
+ }
+ }
+
+ v.Found = make([]solFoundView, klondike.Foundations)
+ for i, f := range g.Found {
+ suit := cards.Suit(i)
+ fv := solFoundView{Suit: suit.String(), Red: suit == cards.Hearts || suit == cards.Diamonds, N: len(f)}
+ if len(f) > 0 {
+ top := viewCard(f[len(f)-1])
+ fv.Top = &top
+ }
+ v.Found[i] = fv
+ }
+ return v
+}
+
+// solEventView is one thing the table animates. See the note at the top: unlike
+// blackjack's, these need nothing stripped out of them.
+type solEventView struct {
+ Kind string `json:"kind"`
+ Cards []cardView `json:"cards,omitempty"`
+ From string `json:"from,omitempty"`
+ To string `json:"to,omitempty"`
+ Home int `json:"home"`
+ Pays int64 `json:"pays"`
+}
+
+func viewSolEvents(evs []klondike.Event) []solEventView {
+ out := make([]solEventView, 0, len(evs))
+ for _, e := range evs {
+ v := solEventView{Kind: e.Kind, From: e.From, To: e.To, Home: e.Home, Pays: e.Pays}
+ for _, c := range e.Cards {
+ v.Cards = append(v.Cards, viewCard(c))
+ }
+ out = append(out, v)
+ }
+ return out
+}
+
+// handleSolitaireStart takes the stake and deals the board. Same order as a
+// blackjack deal: the chips are staked first, in the same statement that checks
+// they exist, so two starts fired at once cannot buy the same deck twice.
+func (s *Server) handleSolitaireStart(w http.ResponseWriter, r *http.Request) {
+ user, ok := s.player(w, r)
+ if !ok {
+ return
+ }
+ var req struct {
+ Bet int64 `json:"bet"`
+ Tier string `json:"tier"`
+ }
+ if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 {
+ writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"})
+ return
+ }
+ tier, err := klondike.TierBySlug(req.Tier)
+ if err != nil {
+ writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a deal"})
+ 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 deck"})
+ return
+ }
+ slog.Error("games: solitaire stake", "user", user, "err", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ seed1, seed2 := newSeeds()
+ rng := rand.New(rand.NewPCG(seed1, seed2))
+ g, evs, err := klondike.New(req.Bet, tier, blackjack.DefaultRules().RakePct, rng)
+ if err != nil {
+ // The board never happened, so the stake never should have left.
+ _ = storage.Award(user, req.Bet)
+ slog.Error("games: solitaire deal", "user", user, "err", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ s.persistSolitaire(w, user, g, evs, seed1, seed2, true)
+}
+
+// solitaireErrors are the illegal moves a player makes by playing, rather than
+// by tampering. Each gets said back to them in words, because "that move isn't
+// legal" over a board with 60 legal-looking targets on it is not an answer.
+var solitaireErrors = map[error]string{
+ klondike.ErrWontGo: "that card doesn't go there",
+ klondike.ErrNotASequence: "you can only lift a run that goes down in rank and alternates colour",
+ klondike.ErrEmptyPile: "there's nothing there",
+ klondike.ErrNoDraw: "the stock is empty",
+ klondike.ErrNoPasses: "that was your last pass through the stock",
+ klondike.ErrNothingHome: "nothing can go home right now",
+ klondike.ErrGameOver: "that board is finished",
+}
+
+// handleSolitaireMove plays one move: a draw, a card moved, a card sent home, an
+// auto-finish, or cashing the board in.
+func (s *Server) handleSolitaireMove(w http.ResponseWriter, r *http.Request) {
+ user, ok := s.player(w, r)
+ if !ok {
+ return
+ }
+ var move klondike.Move
+ if err := decodeJSON(r, &move); err != nil {
+ http.Error(w, "bad json", http.StatusBadRequest)
+ return
+ }
+
+ live, err := storage.LoadLiveHand(user)
+ if errors.Is(err, storage.ErrNoLiveHand) {
+ writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "no game in progress"})
+ return
+ }
+ if err != nil {
+ slog.Error("games: solitaire load", "user", user, "err", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ if live.Game != gameSolitaire {
+ writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the hand you're in first"})
+ return
+ }
+ var g klondike.State
+ if err := json.Unmarshal(live.State, &g); err != nil {
+ slog.Error("games: unreadable solitaire board", "user", user, "err", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ next, evs, err := klondike.ApplyMove(g, move)
+ if err != nil {
+ msg, known := solitaireErrors[err]
+ if !known {
+ msg = "that move isn't legal here"
+ }
+ writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
+ return
+ }
+ s.persistSolitaire(w, user, next, evs, live.Seed1, live.Seed2, false)
+}
+
+// persistSolitaire writes the board back and answers the browser.
+func (s *Server) persistSolitaire(w http.ResponseWriter, user string, g klondike.State, evs []klondike.Event, seed1, seed2 uint64, fresh bool) {
+ blob, err := json.Marshal(g)
+ if err != nil {
+ slog.Error("games: marshal solitaire", "user", user, "err", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ done := g.Phase == klondike.PhaseDone
+ v, ok := s.commit(w, user, finished{
+ Game: gameSolitaire, Blob: blob,
+ Bet: g.Bet, Payout: g.Payout, Rake: g.Rake,
+ Outcome: string(g.Outcome), Done: done,
+ Seed1: seed1, Seed2: seed2, Fresh: fresh,
+ })
+ if !ok {
+ return
+ }
+ // A finished board is gone from storage, so the table has none to show — but
+ // the browser still needs the final one to animate the last cards onto.
+ if done {
+ sv := viewSolitaire(g)
+ v.Solitaire = &sv
+ }
+ v.SolEvents = viewSolEvents(evs)
+ writeJSON(w, v)
+}
diff --git a/internal/web/server.go b/internal/web/server.go
index 4330b2b..04d3b5b 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -95,7 +95,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
pages []string
}{
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}},
- {"games_layout", []string{"_chipbar"}, []string{"games", "blackjack", "hangman"}},
+ {"games_layout", []string{"_chipbar"}, []string{"games", "blackjack", "hangman", "solitaire"}},
}
tpls := make(map[string]*template.Template)
for _, set := range sets {
diff --git a/internal/web/static/css/input.css b/internal/web/static/css/input.css
index aef6d05..aafbefd 100644
--- a/internal/web/static/css/input.css
+++ b/internal/web/static/css/input.css
@@ -570,10 +570,13 @@ html[data-phase="night"] {
/* One card. The wrapper does the flight, the inner face does the flip, so the
two never fight over the same transform. */
+ /* The size is a variable because solitaire has seven columns to fit and
+ blackjack has two hands. Everything below is in terms of it, so a table can
+ shrink its cards without a second set of rules. */
.pete-card {
perspective: 700px;
- height: 8.4rem;
- width: 6rem;
+ height: var(--card-h, 8.4rem);
+ width: var(--card-w, 6rem);
animation: pete-deal 0.42s cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
.pete-card-inner {
@@ -1078,6 +1081,210 @@ html[data-phase="night"] {
border-color: var(--accent);
}
+ /* ---- solitaire -----------------------------------------------------------
+ Seven columns, four foundations, a stock and a waste, all of which have to
+ fit across the felt on a phone as well as a desk. So the card size is one
+ variable derived from the width available, and every gap and fan below is
+ derived from *that* — the board scales as one thing rather than as nine
+ things that each stop fitting at a different width.
+
+ The cards themselves don't move by CSS here. A move in solitaire can take a
+ card from anywhere to anywhere, so the table re-renders the board and then
+ animates each card from where it just was (see the FLIP note in
+ solitaire.js). CSS's job is only to say where things sit. */
+
+ .pete-solitaire {
+ --card-w: clamp(2.5rem, 10.6vw, 5.2rem);
+ --card-h: calc(var(--card-w) * 1.4);
+ --fan-up: calc(var(--card-h) * 0.29); /* how much of a face-up card shows under the next */
+ --fan-down: calc(var(--card-h) * 0.14); /* a face-down one shows less: there's nothing to read */
+ }
+
+ /* An empty place a card could be: the stock when it's spent, a foundation
+ waiting for its ace, a column waiting for a king. Same footprint as a card,
+ so nothing on the board reflows when one empties. */
+ .pete-slot {
+ position: relative;
+ display: grid;
+ place-items: center;
+ height: var(--card-h);
+ width: var(--card-w);
+ flex: none;
+ border-radius: 0.55rem;
+ border: 2px dashed rgba(255, 255, 255, 0.25);
+ background: rgba(0, 0, 0, 0.12);
+ box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.18);
+ transition: border-color 0.18s ease, background 0.18s ease, transform 0.12s ease;
+ }
+ .pete-slot-glyph {
+ font-size: calc(var(--card-w) * 0.42);
+ line-height: 1;
+ color: rgba(255, 255, 255, 0.3);
+ }
+ .pete-slot-glyph[data-red="1"] { color: rgba(255, 170, 170, 0.35); }
+
+ /* A card sitting *in* a slot — the top of a foundation. The slot keeps its
+ footprint whether or not there's a card on it, so an ace going home doesn't
+ reflow the row, and a drop target has somewhere to be while it's empty. */
+ .pete-slot > .pete-card {
+ position: absolute;
+ inset: 0;
+ height: 100%;
+ width: 100%;
+ }
+
+ /* The stock is a button, and it looks like the back of a card because that is
+ what it is: a pile you can turn over. */
+ .pete-stock {
+ border-style: solid;
+ border-color: rgba(0, 0, 0, 0.15);
+ background:
+ repeating-linear-gradient(45deg, rgba(255,255,255,0.10) 0 6px, transparent 6px 12px),
+ linear-gradient(150deg, #b4553f, #8d3f2f);
+ cursor: pointer;
+ }
+ .pete-stock:hover { filter: brightness(1.08); }
+ .pete-stock:active { transform: translateY(1px) scale(0.98); }
+ /* Spent, but there's a pass left: it stops being a pile of cards and starts
+ being the gesture of turning the waste back over. */
+ .pete-stock[data-empty="1"] {
+ background: rgba(0, 0, 0, 0.12);
+ border-style: dashed;
+ border-color: rgba(255, 255, 255, 0.25);
+ }
+ /* Spent, and no passes left. Not a button any more — and it says so by looking
+ like the dead thing it is rather than by silently refusing clicks. */
+ .pete-stock[data-dead="1"] {
+ cursor: default;
+ opacity: 0.4;
+ }
+ .pete-stock[data-dead="1"]:hover { filter: none; }
+ .pete-stock[data-dead="1"]:active { transform: none; }
+
+ .pete-slot-count {
+ position: absolute;
+ bottom: -0.5rem;
+ left: 50%;
+ transform: translateX(-50%);
+ border-radius: 999px;
+ background: rgba(0, 0, 0, 0.5);
+ padding: 0.05rem 0.5rem;
+ font-size: 0.7rem;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+ color: #fff;
+ }
+ .pete-slot-recycle { color: rgba(255, 255, 255, 0.45); }
+ .pete-slot-recycle svg { height: 1.4rem; width: 1.4rem; }
+
+ /* The waste. Three cards fanned sideways, the top one on the right — you can
+ see what's under it, and only the top one is yours to take. */
+ .pete-waste {
+ position: relative;
+ display: flex;
+ height: var(--card-h);
+ min-width: var(--card-w);
+ }
+ .pete-waste .pete-card + .pete-card {
+ margin-left: calc(var(--card-w) * -0.72);
+ }
+ .pete-waste .pete-card { position: relative; }
+
+ /* The seven columns. They share the width evenly and never scroll: a board you
+ have to scroll sideways is a board you can't plan on. */
+ .pete-tableau {
+ display: grid;
+ grid-template-columns: repeat(7, var(--card-w));
+ justify-content: space-between;
+ gap: 0.5rem;
+ align-items: start;
+ min-height: calc(var(--card-h) * 2.6);
+ }
+ .pete-col {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ min-height: var(--card-h);
+ }
+ /* Cards overlap up the column, and how much of one shows depends on whether
+ there's anything to see. The rule keys off the card *above*, which is why
+ it's an adjacent-sibling selector and not something the JS has to compute. */
+ .pete-col .pete-card { position: relative; }
+ .pete-col .pete-card[data-face="down"] + .pete-card {
+ margin-top: calc(var(--fan-down) - var(--card-h));
+ }
+ .pete-col .pete-card[data-face="up"] + .pete-card {
+ margin-top: calc(var(--fan-up) - var(--card-h));
+ }
+
+ /* A card you can pick up says so. */
+ .pete-card[data-live="1"] { cursor: pointer; }
+ .pete-card[data-live="1"]:hover .pete-card-front {
+ filter: brightness(1.06);
+ }
+ /* The card (or run) in your hand: lifted off the felt, and glowing, so it's
+ obvious what a click on a column is about to move. */
+ .pete-card[data-held="1"] {
+ z-index: 5;
+ transform: translateY(-0.55rem);
+ transition: transform 0.14s cubic-bezier(0.34, 1.56, 0.64, 1);
+ }
+ .pete-card[data-held="1"] .pete-card-front {
+ box-shadow:
+ 0 3px 0 rgba(0,0,0,0.18),
+ 0 10px 22px rgba(0,0,0,0.32),
+ 0 0 0 3px rgba(242, 181, 61, 0.9);
+ }
+ /* A place the held card would actually go. Lit *before* you click it, because
+ the alternative is learning the rules by being told no. */
+ .pete-slot[data-drop="1"],
+ .pete-col[data-drop="1"] .pete-slot,
+ .pete-col[data-drop="1"] .pete-card:last-child .pete-card-front {
+ border-color: rgba(242, 181, 61, 0.9);
+ box-shadow: 0 0 0 3px rgba(242, 181, 61, 0.45);
+ }
+ .pete-slot[data-drop="1"],
+ .pete-col[data-drop="1"] .pete-slot {
+ background: rgba(242, 181, 61, 0.12);
+ }
+
+ /* A move that won't go. Said in the one language a board can speak. */
+ .pete-nope { animation: pete-shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97); }
+
+ /* A card arriving on a foundation lands with a flash: it is the only move in
+ the game that pays you, so it is the only one that gets a noise. */
+ .pete-home-flash { animation: pete-home 0.5s ease-out; }
+ @keyframes pete-home {
+ 0% { box-shadow: 0 0 0 0 rgba(242, 181, 61, 0.9); }
+ 100% { box-shadow: 0 0 0 1.4rem rgba(242, 181, 61, 0); }
+ }
+
+ /* The rail: the house's rack, what you've banked, the meter. On a wide screen
+ it's a column down the right of the felt; on a narrow one it lies down and
+ sits under the board. */
+ .pete-rail {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ gap: 1rem 1.5rem;
+ }
+ @media (min-width: 1024px) {
+ .pete-rail {
+ flex-direction: column;
+ justify-content: flex-start;
+ width: 9.5rem;
+ gap: 1.5rem;
+ }
+ }
+ /* In the rail the rack is just another thing in a column, so it drops the
+ absolute positioning it uses when it's pinned to the corner of a felt. */
+ .pete-rack[data-at="rail"] {
+ position: static;
+ justify-content: center;
+ }
+
@media (prefers-reduced-motion: reduce) {
.pete-card,
.pete-card::after,
@@ -1089,6 +1296,9 @@ html[data-phase="night"] {
.pete-shake,
.pete-tile-hit,
.pete-meter[data-hit="1"] { animation: none; }
+ .pete-nope,
+ .pete-home-flash { animation: none; }
+ .pete-card[data-held="1"] { transition: none; }
}
}
diff --git a/internal/web/static/css/output.css b/internal/web/static/css/output.css
index 87e522e..1c313e0 100644
--- a/internal/web/static/css/output.css
+++ b/internal/web/static/css/output.css
@@ -1 +1 @@
-*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}body{font-family:Nunito,system-ui,sans-serif}html{scroll-behavior:smooth}body,html{overflow-x:hidden}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.pete-channel-nav{scrollbar-width:none;-ms-overflow-style:none}.pete-channel-nav::-webkit-scrollbar{display:none}.pete-reader-backdrop{position:absolute;inset:0;background:rgba(20,14,6,.55);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}html[data-phase=night] .pete-reader-backdrop{background:rgba(6,8,20,.65)}.pete-reader-shell{position:relative;height:100%;width:100%;max-width:44rem;margin:0 auto;display:flex;flex-direction:column;padding:max(env(safe-area-inset-top),.75rem) 1rem .75rem;gap:.75rem}.pete-reader-bar{display:flex;align-items:center;justify-content:space-between;gap:.75rem;color:#fff}.pete-reader-progress{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:600;font-size:.85rem;letter-spacing:.02em;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-nav{display:flex;align-items:center;gap:.4rem}.pete-reader-btn{display:inline-flex;align-items:center;justify-content:center;min-width:2.25rem;height:2.25rem;padding:0 .7rem;border-radius:9999px;background:hsla(0,0%,100%,.14);color:#fff;font-weight:700;font-size:.95rem;border:2px solid hsla(0,0%,100%,.18);cursor:pointer;transition:background .15s ease,transform .15s ease,opacity .15s ease}.pete-reader-btn:hover{background:hsla(0,0%,100%,.28);transform:translateY(-1px)}.pete-reader-btn:disabled{opacity:.35;cursor:default;transform:none}.pete-reader-btn-open{background:var(--accent);border-color:transparent;color:#1c1305;text-decoration:none}.pete-reader-btn-open:hover{background:var(--accent);filter:brightness(1.08)}.pete-reader-btn[aria-expanded=true],.pete-reader-btn[aria-pressed=true]{background:var(--accent);border-color:transparent;color:#1c1305}.pete-reader-type{position:relative;display:inline-flex}.pete-reader-typemenu[hidden]{display:none}.pete-reader-typemenu{position:absolute;top:calc(100% + .5rem);right:0;z-index:10;width:15rem;padding:.75rem;border-radius:1rem;background:var(--card);color:var(--ink);border:2px solid rgba(0,0,0,.08);box-shadow:0 10px 30px rgba(20,14,6,.28);display:flex;flex-direction:column;gap:.6rem}html[data-phase=night] .pete-reader-typemenu{border-color:hsla(0,0%,100%,.12)}.pete-reader-typerow{display:flex;align-items:center;justify-content:space-between;gap:.6rem}.pete-reader-typelabel{font-size:.8rem;font-weight:700;opacity:.7}.pete-reader-typebtns{display:inline-flex;gap:.25rem}.pete-reader-typebtns button{min-width:2rem;height:2rem;padding:0 .55rem;border-radius:.6rem;background:rgba(20,14,6,.06);color:var(--ink);font-weight:700;font-size:.82rem;border:2px solid transparent;cursor:pointer;transition:background .15s ease}html[data-phase=night] .pete-reader-typebtns button{background:hsla(0,0%,100%,.08)}.pete-reader-typebtns button:hover{background:rgba(20,14,6,.12)}.pete-reader-typebtns button.is-active{background:var(--accent);color:#1c1305;border-color:transparent}.pete-reader-voicerow[hidden]{display:none}.pete-reader-voice{font:inherit;font-size:.8rem;font-weight:600;padding:.25rem .4rem;border-radius:.55rem;border:1px solid rgba(20,14,6,.18);background:rgba(20,14,6,.06);color:inherit;max-width:11rem;cursor:pointer}html[data-phase=night] .pete-reader-voice{border-color:hsla(0,0%,100%,.14);background:hsla(0,0%,100%,.08)}.pete-reader-scroll{flex:1 1 auto;overflow-y:auto;-webkit-overflow-scrolling:touch;border-radius:1.75rem}.pete-reader-scroll:focus{outline:none}.pete-reader-article{background:var(--card);color:var(--ink);border-radius:1.75rem;border:2px solid rgba(0,0,0,.06);box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.18);padding:clamp(1.25rem,4vw,2.5rem)}html[data-phase=night] .pete-reader-article{border-color:hsla(0,0%,100%,.08)}.pete-reader-eyebrow{display:flex;align-items:center;flex-wrap:wrap;gap:.5rem;font-size:.75rem;margin-bottom:.85rem}.pete-reader-chip{display:inline-flex;align-items:center;gap:.35rem;border-radius:9999px;padding:.15rem .65rem;color:#fff;font-weight:700;text-transform:uppercase;letter-spacing:.06em}.pete-reader-source{font-weight:700;opacity:.75}.pete-reader-time{opacity:.55}.pete-reader-title{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:700;line-height:1.15;letter-spacing:-.01em;font-size:clamp(1.6rem,4.5vw,2.4rem);margin-bottom:1rem}.pete-reader-hero{width:100%;border-radius:1.1rem;margin-bottom:1.25rem;background:rgba(0,0,0,.05)}.pete-reader-scroll{--reader-fs:1.08rem}.pete-reader-body{font-size:var(--reader-fs);line-height:1.75}.pete-reader-body p{margin-bottom:1.05em}.pete-reader-body p:last-child{margin-bottom:0}.pete-reader-scroll.is-size-s{--reader-fs:0.98rem}.pete-reader-scroll.is-size-m{--reader-fs:1.08rem}.pete-reader-scroll.is-size-l{--reader-fs:1.22rem}.pete-reader-scroll.is-size-xl{--reader-fs:1.4rem}.pete-reader-scroll.is-serif .pete-reader-body{font-family:Georgia,Iowan Old Style,Times New Roman,serif}.pete-reader-scroll.is-sepia .pete-reader-article{background:#f6ecd6;color:#4a3a28;border-color:rgba(74,58,40,.16)}.pete-reader-scroll.is-sepia .pete-reader-note{border-top-color:rgba(74,58,40,.18);opacity:.8}.pete-reader-scroll.is-sepia .pete-reader-hero{background:rgba(74,58,40,.08)}.pete-reader-body p.is-speaking{background:color-mix(in srgb,var(--accent) 32%,transparent);border-radius:.4rem;box-shadow:0 0 0 .35rem color-mix(in srgb,var(--accent) 32%,transparent)}.pete-reader-note{margin-top:1.25rem;padding-top:1rem;border-top:1px solid rgba(0,0,0,.1);font-size:.85rem;opacity:.7}html[data-phase=night] .pete-reader-note{border-color:hsla(0,0%,100%,.12)}.pete-reader-note a{color:var(--accent);font-weight:700}.pete-reader-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:.75rem;padding:clamp(2rem,8vw,4rem) 1rem;color:var(--ink)}.pete-reader-empty-emoji{font-size:2.5rem}.pete-reader-toast{position:absolute;left:50%;bottom:4.5rem;transform:translate(-50%,.5rem);z-index:20;padding:.5rem .9rem;border-radius:9999px;background:rgba(20,14,6,.9);color:#fff;font-weight:700;font-size:.85rem;box-shadow:0 8px 24px rgba(0,0,0,.3);opacity:0;pointer-events:none;transition:opacity .2s ease,transform .2s ease}.pete-reader-toast.is-shown{opacity:1;transform:translate(-50%)}.pete-reader-hint{text-align:center;color:hsla(0,0%,100%,.85);font-size:.75rem;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-hint kbd{font-family:ui-monospace,monospace;background:hsla(0,0%,100%,.16);border-radius:.3rem;padding:.05rem .3rem}@media (max-width:640px){.pete-reader-hint{display:none}.pete-reader-bar{flex-wrap:wrap}.pete-reader-nav{flex-wrap:wrap;justify-content:flex-end;row-gap:.4rem}}.pete-reader-related{width:100%;margin:.85rem auto 0}.pete-reader-related-title{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:700;font-size:.95rem;color:#fff;margin:0 0 .6rem;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-related-grid{display:grid;gap:.6rem}.pete-reader-related-card{display:flex;align-items:center;gap:.75rem;background:var(--card);color:var(--ink);border-radius:1rem;border:2px solid rgba(0,0,0,.06);padding:.6rem;text-decoration:none;transition:transform .15s ease,box-shadow .15s ease}.pete-reader-related-card:hover{transform:translateY(-1px);box-shadow:0 6px 16px rgba(60,40,20,.16)}html[data-phase=night] .pete-reader-related-card{border-color:hsla(0,0%,100%,.08)}.pete-reader-related-thumb{width:4.5rem;height:3.25rem;flex-shrink:0;-o-object-fit:cover;object-fit:cover;border-radius:.6rem;background:rgba(0,0,0,.06)}.pete-reader-related-meta{min-width:0;display:flex;flex-direction:column;gap:.25rem}.pete-reader-related-eyebrow{display:flex;align-items:center;flex-wrap:wrap;gap:.4rem;font-size:.7rem}.pete-reader-related-source{font-weight:700;opacity:.7}.pete-reader-related-headline{font-weight:700;line-height:1.25;font-size:.92rem;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}[data-story-card][data-read="1"]{opacity:.5;transition:opacity .2s ease}[data-story-card][data-read="1"]:hover{opacity:1}[data-story-card][data-read="1"]:after{content:"✓";position:absolute;top:.6rem;right:.6rem;z-index:6;display:grid;place-items:center;height:1.5rem;width:1.5rem;border-radius:9999px;background:rgba(20,14,6,.72);color:#fff;font-size:.8rem;font-weight:700}.pete-felt{background:radial-gradient(120% 90% at 50% -10%,hsla(0,0%,100%,.1),transparent 60%),linear-gradient(160deg,#2f7d5b,#24614a 55%,#1c4d3c)}.pete-shoe{position:absolute;top:1.25rem;right:1.25rem;height:4.2rem;width:3rem;border-radius:.55rem;background:linear-gradient(150deg,#b4553f,#8d3f2f);border:2px solid rgba(0,0,0,.18);box-shadow:0 4px 0 rgba(0,0,0,.18),inset 0 0 0 3px hsla(0,0%,100%,.12)}.pete-shoe:after{content:"";position:absolute;inset:.45rem .4rem auto .4rem;height:.5rem;border-radius:.2rem;background:rgba(0,0,0,.22)}.pete-hand{display:flex;flex-wrap:wrap;gap:.6rem;min-height:8.6rem}.pete-card{perspective:700px;height:8.4rem;width:6rem;animation:pete-deal .42s cubic-bezier(.22,1,.36,1) backwards}.pete-card-inner{position:relative;height:100%;width:100%;transform-style:preserve-3d;transition:transform .45s cubic-bezier(.4,0,.2,1)}.pete-card[data-face=down] .pete-card-inner{transform:rotateY(180deg)}.pete-card-back,.pete-card-front{position:absolute;inset:0;display:grid;border-radius:.55rem;backface-visibility:hidden;-webkit-backface-visibility:hidden;box-shadow:0 3px 0 rgba(0,0,0,.18),0 6px 14px rgba(0,0,0,.22)}.pete-card-front{place-items:stretch;background:#fdfaf2;border:2px solid rgba(30,20,10,.12);color:#2b2118;font-family:Fredoka,Nunito,system-ui,sans-serif;line-height:1;overflow:hidden}.pete-card-front[data-red="1"]{color:#cc3d4a}.pete-card-back{transform:rotateY(180deg);background:repeating-linear-gradient(45deg,hsla(0,0%,100%,.1) 0 6px,transparent 6px 12px),linear-gradient(150deg,#b4553f,#8d3f2f);border:2px solid rgba(0,0,0,.15)}.pete-card-svg{width:100%;height:100%;display:block;fill:currentColor}.pete-card-idx{font-size:26px;font-weight:700;text-anchor:middle;fill:currentColor}.pete-card-panel{fill:currentColor;fill-opacity:.06;stroke:currentColor;stroke-opacity:.35;stroke-width:1.5}.pete-card-court{font-size:34px;font-weight:700;text-anchor:middle;fill:currentColor}@keyframes pete-deal{0%{opacity:0;transform:translate(var(--deal-x,14rem),var(--deal-y,-7rem)) scale(.72) rotate(9deg)}62%{opacity:1;transform:translateY(.35rem) scale(1.05) rotate(calc(var(--tilt, 0deg) - 2deg))}to{opacity:1;transform:translate(0) scale(1) rotate(var(--tilt,0deg))}}.pete-card:after{content:"";position:absolute;inset:auto 6% -.35rem 6%;height:.6rem;border-radius:999px;background:rgba(0,0,0,.35);filter:blur(5px);animation:pete-thud .42s cubic-bezier(.22,1,.36,1) backwards}@keyframes pete-thud{0%,55%{opacity:0;transform:scale(.4)}72%{opacity:.9;transform:scale(1.15)}to{opacity:.45;transform:scale(1)}}.pete-hand[data-won="1"] .pete-card{animation:pete-deal .42s cubic-bezier(.22,1,.36,1) backwards,pete-win .6s ease .1s}@keyframes pete-win{0%,to{transform:rotate(var(--tilt,0deg)) translateY(0)}40%{transform:rotate(var(--tilt,0deg)) translateY(-.6rem)}}.pete-hand[data-won="-1"] .pete-card{filter:saturate(.45) brightness(.78);transition:filter .5s ease .2s}.pete-disc{position:relative;border-radius:999px;background:radial-gradient(circle at 50% 30%,hsla(0,0%,100%,.3),transparent 62%),var(--chip,#e07a5f);border:2px solid rgba(0,0,0,.22);box-shadow:0 3px 0 rgba(0,0,0,.28),0 6px 14px rgba(0,0,0,.2)}.pete-disc:before{content:"";position:absolute;inset:11%;border-radius:999px;border:2px dashed hsla(0,0%,100%,.55)}.pete-disc[data-chip="5"]{--chip:#5aa9e6}.pete-disc[data-chip="25"]{--chip:#4caf7d}.pete-disc[data-chip="100"]{--chip:#2b2118}.pete-disc[data-chip="500"]{--chip:#b079d6}.pete-chip{transition:transform .12s ease}.pete-chip>span{position:relative}.pete-chip:active{transform:translateY(1px) scale(.94)}.pete-spot{position:relative;display:grid;place-items:center;height:7rem;width:7rem;flex:none;border-radius:999px;border:2px dashed hsla(0,0%,100%,.35);background:radial-gradient(circle at 50% 40%,hsla(0,0%,100%,.1),transparent 70%);box-shadow:inset 0 0 0 6px rgba(0,0,0,.08);transition:box-shadow .3s ease,border-color .3s ease}.pete-spot[data-live="1"]{border-color:rgba(var(--glow,242,181,61),.85);box-shadow:inset 0 0 0 6px rgba(0,0,0,.08),0 0 22px rgba(var(--glow,242,181,61),.35)}.pete-spot-label{font-size:.65rem;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:hsla(0,0%,100%,.35)}.pete-spot[data-live="1"] .pete-spot-label{opacity:0}.pete-stack{position:absolute;inset:0;pointer-events:none}.pete-stack .pete-disc{position:absolute;left:50%;top:50%;height:2.6rem;width:2.6rem;margin:-1.3rem 0 0 -1.3rem;transform:translateY(calc(-.5rem*var(--i, 0))) rotate(var(--spin,0deg));box-shadow:0 2px 0 rgba(0,0,0,.35),0 4px 8px rgba(0,0,0,.25);animation:pete-chip-land .28s cubic-bezier(.34,1.56,.64,1) backwards}@keyframes pete-chip-land{0%{transform:translateY(calc(-.5rem*var(--i, 0) - 1.1rem)) rotate(var(--spin,0deg)) scale(1.18)}}.pete-spot-total{position:absolute;bottom:-.6rem;left:50%;transform:translateX(-50%);white-space:nowrap;border-radius:999px;background:rgba(0,0,0,.45);padding:.1rem .6rem;font-size:.72rem;font-weight:700;font-variant-numeric:tabular-nums;color:#fff}.pete-fly-layer{position:fixed;inset:0;z-index:60;pointer-events:none;overflow:hidden}.pete-fly{position:absolute;height:2.6rem;width:2.6rem;margin:-1.3rem 0 0 -1.3rem;will-change:transform}.pete-rack{position:absolute;top:1.25rem;right:5.75rem;display:flex;align-items:flex-end;gap:.4rem;padding:.5rem .6rem .45rem;border-radius:.75rem;background:rgba(0,0,0,.2);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.06)}.pete-rack span{display:block;width:1.7rem;border-radius:.35rem;background:linear-gradient(90deg,rgba(0,0,0,.28),transparent 35%,transparent 65%,rgba(0,0,0,.28)),repeating-linear-gradient(180deg,hsla(0,0%,100%,.18) 0 .06rem,var(--chip,#e07a5f) .06rem .3rem,rgba(0,0,0,.35) .3rem .36rem);border:1px solid rgba(0,0,0,.35);height:calc(.36rem*var(--stack, 3))}.pete-rack span[data-chip="5"]{--chip:#5aa9e6}.pete-rack span[data-chip="25"]{--chip:#4caf7d}.pete-rack span[data-chip="100"]{--chip:#2b2118}.pete-rack span[data-chip="500"]{--chip:#b079d6}.pete-spark{position:absolute;height:.75rem;width:.45rem;border-radius:2px;box-shadow:0 1px 3px rgba(0,0,0,.35);will-change:transform,opacity}.pete-dealer-think{animation:pete-think .9s ease-in-out infinite}@keyframes pete-think{0%,to{opacity:.6}50%{opacity:1;transform:translateY(-1px)}}.pete-gallows{overflow:visible;fill:none;stroke-linecap:round;stroke-linejoin:round}.pete-gallows-frame path{stroke:rgba(0,0,0,.35);stroke-width:6}.pete-gallows-frame path:last-child{stroke:rgba(0,0,0,.3);stroke-width:3.5}.pete-gallows-body>*{stroke:#fff;stroke-width:5;opacity:0;filter:drop-shadow(0 2px 0 rgba(0,0,0,.25))}.pete-gallows-body>[data-on="1"]{opacity:1}.pete-part-draw{stroke-dasharray:260;animation:pete-part .45s cubic-bezier(.22,1,.36,1) backwards}@keyframes pete-part{0%{stroke-dashoffset:260}to{stroke-dashoffset:0}}.pete-shake{animation:pete-shake .42s cubic-bezier(.36,.07,.19,.97)}@keyframes pete-shake{10%,90%{transform:translateX(-1.5px)}20%,80%{transform:translateX(3px)}30%,50%,70%{transform:translateX(-5px)}40%,60%{transform:translateX(5px)}}.pete-board{display:flex;flex-wrap:wrap;gap:.35rem 1.1rem;min-height:5rem;align-items:center}.pete-word{display:flex;gap:.3rem}.pete-tile{display:grid;place-items:center;height:2.9rem;width:2.2rem;border-radius:.5rem;font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:1.4rem;font-weight:700;color:#fff;background:rgba(0,0,0,.22);border-bottom:3px solid rgba(0,0,0,.28);text-transform:uppercase}.pete-tile[data-up="1"]{background:hsla(0,0%,100%,.95);color:#2b2118;border-bottom-color:rgba(0,0,0,.35)}.pete-tile[data-punct="1"]{background:none;border:0;width:auto;min-width:.7rem;color:hsla(0,0%,100%,.55)}.pete-tile-hit{animation:pete-tile-hit .34s cubic-bezier(.34,1.56,.64,1)}@keyframes pete-tile-hit{0%{transform:rotateX(90deg) scale(1.1)}to{transform:rotateX(0) scale(1)}}.pete-missed{display:grid;place-items:center;height:1.5rem;min-width:1.5rem;padding:0 .3rem;border-radius:.375rem;background:rgba(0,0,0,.25);font-size:.75rem;font-weight:700;color:hsla(0,0%,100%,.5);text-decoration:line-through;text-transform:uppercase}.pete-meter{display:flex;align-items:baseline;gap:.5rem;border-radius:999px;background:rgba(0,0,0,.28);padding:.4rem .9rem;box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.1);transition:box-shadow .3s ease}.pete-meter-label{font-size:.65rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:hsla(0,0%,100%,.45)}.pete-meter-value{font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:1.5rem;font-weight:700;font-variant-numeric:tabular-nums;color:var(--accent)}.pete-meter[data-cold="1"] .pete-meter-value{color:hsla(0,0%,100%,.55)}.pete-meter[data-hit="1"]{box-shadow:inset 0 0 0 2px rgba(204,61,74,.9);animation:pete-meter-hit .4s ease}@keyframes pete-meter-hit{0%{transform:scale(1)}35%{transform:scale(.94)}to{transform:scale(1)}}.pete-keys{display:grid;gap:.35rem}.pete-key-row{display:flex;justify-content:center;gap:.35rem}.pete-key-row[data-digits="1"]{margin-top:.25rem;opacity:.75}.pete-key-row[data-digits="1"] .pete-key{height:2rem;min-width:1.8rem;font-size:.8rem}.pete-key{height:2.75rem;min-width:2.2rem;flex:0 1 2.4rem;border-radius:.6rem;background:color-mix(in srgb,var(--ink) 6%,transparent);border:2px solid color-mix(in srgb,var(--ink) 10%,transparent);font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-weight:700;color:var(--ink);transition:transform .08s ease,background .15s ease,opacity .15s ease}.pete-key:hover:not(:disabled){background:color-mix(in srgb,var(--ink) 12%,transparent)}.pete-key:active:not(:disabled){transform:translateY(1px) scale(.96)}.pete-key:disabled{cursor:default}.pete-key[data-state=hit]{background:#4caf7d;border-color:#3d9367;color:#fff;opacity:1}.pete-key[data-state=miss]{background:color-mix(in srgb,var(--ink) 4%,transparent);color:color-mix(in srgb,var(--ink) 30%,transparent);text-decoration:line-through}.pete-key:disabled[data-state=""]{opacity:.4}.pete-tier{background:color-mix(in srgb,var(--ink) 4%,transparent);border-color:color-mix(in srgb,var(--ink) 10%,transparent)}.pete-tier:hover{background:color-mix(in srgb,var(--ink) 8%,transparent)}.pete-tier[data-on="1"]{background:color-mix(in srgb,var(--accent) 18%,transparent);border-color:var(--accent)}@media (prefers-reduced-motion:reduce){.pete-card,.pete-card:after,.pete-dealer-think,.pete-stack .pete-disc{animation:none}.pete-card-inner{transition:none}.pete-hand[data-won="1"] .pete-card,.pete-meter[data-hit="1"],.pete-part-draw,.pete-shake,.pete-tile-hit{animation:none}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-right-6{right:-1.5rem}.-top-6{top:-1.5rem}.-z-10{z-index:-10}.-z-\[5\]{z-index:-5}.z-50{z-index:50}.order-none{order:0}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-auto{margin-left:auto;margin-right:auto}.-mt-1{margin-top:-.25rem}.mb-1{margin-bottom:.25rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-8{margin-bottom:2rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mt-0{margin-top:0}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-8{margin-top:2rem}.line-clamp-2{-webkit-line-clamp:2}.line-clamp-2,.line-clamp-3{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical}.line-clamp-3{-webkit-line-clamp:3}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.aspect-\[16\/10\]{aspect-ratio:16/10}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-20{height:5rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-52{height:13rem}.h-6{height:1.5rem}.h-9{height:2.25rem}.h-full{height:100%}.max-h-\[55vh\]{max-height:55vh}.max-h-\[60vh\]{max-height:60vh}.min-h-\[1\.75rem\]{min-height:1.75rem}.min-h-\[2\.75rem\]{min-height:2.75rem}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-2{width:.5rem}.w-2\.5{width:.625rem}.w-20{width:5rem}.w-24{width:6rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-9{width:2.25rem}.w-auto{width:auto}.w-full{width:100%}.min-w-0{min-width:0}.min-w-\[28rem\]{min-width:28rem}.min-w-\[52rem\]{min-width:52rem}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-\[7rem\]{max-width:7rem}.max-w-full{max-width:100%}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.basis-full{flex-basis:100%}.-translate-y-0{--tw-translate-y:-0px}.-translate-y-0,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-10{-moz-column-gap:2.5rem;column-gap:2.5rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-4{row-gap:1rem}.gap-y-8{row-gap:2rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.375rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-transparent{border-color:transparent}.bg-\[color\:var\(--accent\)\]{background-color:var(--accent)}.bg-\[color\:var\(--bg\)\]{background-color:var(--bg)}.bg-\[color\:var\(--bg-grad\)\]{background-color:var(--bg-grad)}.bg-\[color\:var\(--card\)\]{background-color:var(--card)}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-black\/25{background-color:rgba(0,0,0,.25)}.bg-emerald-500{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity,1))}.bg-emerald-500\/15{background-color:rgba(16,185,129,.15)}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/15{background-color:rgba(239,68,68,.15)}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/95{background-color:hsla(0,0%,100%,.95)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-0\.5{padding:.125rem}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0{padding-top:0;padding-bottom:0}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-10{padding-bottom:2.5rem}.pb-20{padding-bottom:5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pr-24{padding-right:6rem}.pt-1{padding-top:.25rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-8{padding-top:2rem}.pt-\[10vh\]{padding-top:10vh}.pt-\[12vh\]{padding-top:12vh}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.align-\[-4px\]{vertical-align:-4px}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12rem\]{font-size:12rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-\[0\.18em\]{letter-spacing:.18em}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-\[\#1c1305\]{--tw-text-opacity:1;color:rgb(28 19 5/var(--tw-text-opacity,1))}.text-\[\#20180c\]{--tw-text-opacity:1;color:rgb(32 24 12/var(--tw-text-opacity,1))}.text-\[\#2b2118\]{--tw-text-opacity:1;color:rgb(43 33 24/var(--tw-text-opacity,1))}.text-\[color\:var\(--accent\)\]{color:var(--accent)}.text-\[color\:var\(--ink\)\]{color:var(--ink)}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-emerald-700{--tw-text-opacity:1;color:rgb(4 120 87/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-white\/40{color:hsla(0,0%,100%,.4)}.text-white\/50{color:hsla(0,0%,100%,.5)}.text-white\/60{color:hsla(0,0%,100%,.6)}.text-white\/85{color:hsla(0,0%,100%,.85)}.text-white\/90{color:hsla(0,0%,100%,.9)}.underline{text-decoration-line:underline}.decoration-4{text-decoration-thickness:4px}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.accent-\[color\:var\(--accent\)\]{accent-color:var(--accent)}.opacity-20{opacity:.2}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.blur{--tw-blur:blur(8px)}.blur,.drop-shadow{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.drop-shadow{--tw-drop-shadow:drop-shadow(0 1px 2px rgba(0,0,0,.1)) drop-shadow(0 1px 1px rgba(0,0,0,.06))}.grayscale{--tw-grayscale:grayscale(100%)}.grayscale,.sepia{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.sepia{--tw-sepia:sepia(100%)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.backdrop-blur-sm,.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-1000{transition-duration:1s}.duration-500{transition-duration:.5s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}.font-display{font-family:Fredoka,Nunito,system-ui,sans-serif;letter-spacing:-.01em}.shadow-pete{box-shadow:0 4px 0 rgba(60,40,20,.1),0 8px 24px rgba(60,40,20,.08)}.shadow-pete-lg{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.bg-theme-gaming{background-color:#4caf7d}.bg-theme-tech{background-color:#5aa9e6}.bg-theme-politics{background-color:#e07a5f}.bg-theme-eu{background-color:#039}.bg-theme-music{background-color:#b079d6}.bg-theme-anime{background-color:#ec5e8a}.bg-theme-foss{background-color:#d97706}.bg-theme-kids{background-color:#14b8a6}.bg-theme-finance{background-color:#10b981}.bg-theme-lego{background-color:#d01012}.bg-theme-adventure{background-color:#6d4bd8}.text-theme-gaming{color:#2d8a5a}.text-theme-tech{color:#2f7fb8}.text-theme-politics{color:#b8523a}.text-theme-eu{color:#039}.text-theme-music{color:#8a4fb8}.text-theme-anime{color:#c33a6a}.text-theme-foss{color:#b8530a}.text-theme-kids{color:#0f766e}.text-theme-finance{color:#059669}.text-theme-lego{color:#b00d0e}.text-theme-adventure{color:#5836b8}.decoration-theme-gaming{text-decoration-color:#4caf7d}.decoration-theme-tech{text-decoration-color:#5aa9e6}.decoration-theme-politics{text-decoration-color:#e07a5f}.decoration-theme-eu{text-decoration-color:#039}.decoration-theme-music{text-decoration-color:#b079d6}.decoration-theme-anime{text-decoration-color:#ec5e8a}.decoration-theme-foss{text-decoration-color:#d97706}.decoration-theme-kids{text-decoration-color:#14b8a6}.decoration-theme-finance{text-decoration-color:#10b981}.decoration-theme-lego{text-decoration-color:#d01012}.decoration-theme-adventure{text-decoration-color:#6d4bd8}.border-theme-gaming{border-color:#4caf7d}.border-theme-tech{border-color:#5aa9e6}.border-theme-politics{border-color:#e07a5f}.border-theme-eu{border-color:#039}.border-theme-music{border-color:#b079d6}.border-theme-anime{border-color:#ec5e8a}.border-theme-foss{border-color:#d97706}.border-theme-kids{border-color:#14b8a6}.border-theme-finance{border-color:#10b981}.border-theme-lego{border-color:#d01012}.border-theme-adventure{border-color:#6d4bd8}.glow-theme-gaming{box-shadow:0 0 0 2px rgba(76,175,125,.35),0 0 24px 4px rgba(76,175,125,.45)}.glow-theme-gaming,.glow-theme-tech{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-tech{box-shadow:0 0 0 2px rgba(90,169,230,.35),0 0 24px 4px rgba(90,169,230,.45)}.glow-theme-politics{box-shadow:0 0 0 2px rgba(224,122,95,.35),0 0 24px 4px rgba(224,122,95,.45)}.glow-theme-eu,.glow-theme-politics{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-eu{box-shadow:0 0 0 2px rgba(0,51,153,.35),0 0 24px 4px rgba(0,51,153,.45)}.glow-theme-music{box-shadow:0 0 0 2px rgba(176,121,214,.35),0 0 24px 4px rgba(176,121,214,.45)}.glow-theme-anime,.glow-theme-music{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-anime{box-shadow:0 0 0 2px rgba(236,94,138,.35),0 0 24px 4px rgba(236,94,138,.45)}.glow-theme-foss{box-shadow:0 0 0 2px rgba(217,119,6,.35),0 0 24px 4px rgba(217,119,6,.45)}.glow-theme-foss,.glow-theme-kids{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-kids{box-shadow:0 0 0 2px rgba(20,184,166,.35),0 0 24px 4px rgba(20,184,166,.45)}.glow-theme-finance{box-shadow:0 0 0 2px rgba(16,185,129,.35),0 0 24px 4px rgba(16,185,129,.45)}.glow-theme-finance,.glow-theme-lego{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-lego{box-shadow:0 0 0 2px rgba(208,16,18,.35),0 0 24px 4px rgba(208,16,18,.45)}.glow-theme-adventure{box-shadow:0 0 0 2px rgba(109,75,216,.35),0 0 24px 4px rgba(109,75,216,.45);animation:pete-glow-pulse 2.4s ease-in-out infinite}@keyframes pete-glow-pulse{0%,to{filter:brightness(1)}50%{filter:brightness(1.08)}}.line-clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.paywall-stamp{position:absolute;inset:0;pointer-events:none;display:flex;align-items:center;justify-content:center;z-index:5;background:rgba(255,250,240,.18)}.paywall-stamp:before{content:"PAYWALLED";font-family:var(--font-display,ui-sans-serif),system-ui,sans-serif;font-weight:900;font-size:clamp(1rem,3.2vw,1.75rem);letter-spacing:.12em;color:rgba(180,30,30,.85);border:.28rem double rgba(180,30,30,.85);padding:.3rem .9rem;transform:rotate(-15deg);text-shadow:0 1px 0 hsla(0,0%,100%,.4);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.15);background:rgba(255,240,230,.55);border-radius:.25rem;filter:contrast(1.05)}:root,html[data-phase=day]{--bg:#fff7e4;--bg-grad:#ffeec2;--card:#fff;--ink:#3a2e1f;--accent:#f2a541}html[data-phase=dawn]{--bg:#ffe7d6;--bg-grad:#ffc9c9;--card:#fff4ea;--ink:#4a2e2a;--accent:#ff8a65}html[data-phase=dusk]{--bg:#ffd6a8;--bg-grad:#f7a07e;--card:#fff1de;--ink:#3d2417;--accent:#e6553a}html[data-phase=night]{--bg:#1a1f3a;--bg-grad:#2a3358;--card:#2d365a;--ink:#f1ecd8;--accent:#f9d976}html[data-room=casinopolis]{--bg:#16211c;--card:#23342b;--ink:#f6ecd8;--accent:#f2b53d;--felt-a:#2f7d5b;--felt-b:#24614a;--felt-c:#1c4d3c;--glow:242,181,61}html[data-room=casino-night]{--bg:#140f2e;--card:#241a4d;--ink:#f2ecff;--accent:#ffcc2f;--felt-a:#4a2fa8;--felt-b:#351f80;--felt-c:#241659;--glow:126,86,255}html[data-room] .cs-room{background:radial-gradient(80% 55% at 50% -8%,rgba(var(--glow),.18),transparent 62%),radial-gradient(120% 90% at 50% 120%,rgba(0,0,0,.45),transparent 55%)}html[data-room=casino-night] .cs-room:before{content:"";position:absolute;inset:0 -50% auto -50%;height:.5rem;background:repeating-linear-gradient(90deg,rgba(255,204,47,.55) 0 .5rem,transparent .5rem 2.25rem);filter:blur(1px);animation:cs-bulbs 2.4s linear infinite}@keyframes cs-bulbs{0%{transform:translateX(0)}to{transform:translateX(2.75rem)}}html[data-room] .shadow-pete{box-shadow:0 4px 0 rgba(0,0,0,.3),0 10px 26px rgba(0,0,0,.3)}html[data-room] .shadow-pete-lg{box-shadow:0 6px 0 rgba(0,0,0,.34),0 20px 40px rgba(0,0,0,.38)}html[data-room] .cs-comb svg{filter:drop-shadow(0 3px 0 rgba(0,0,0,.35))}html[data-room] .pete-felt{background:radial-gradient(120% 90% at 50% -10%,hsla(0,0%,100%,.1),transparent 60%),linear-gradient(160deg,var(--felt-a) 0,var(--felt-b) 55%,var(--felt-c) 100%)}@media (prefers-reduced-motion:reduce){html[data-room=casino-night] .cs-room:before{animation:none}}.cs-stack{display:block;width:2.75rem;height:calc(.45rem*var(--stack, 1) + .55rem);border-radius:999px;background:radial-gradient(circle at 50% 30%,hsla(0,0%,100%,.3),transparent 60%),var(--chip,#e07a5f);border:2px solid rgba(0,0,0,.25);box-shadow:0 3px 0 rgba(0,0,0,.3),inset 0 -.4rem 0 rgba(0,0,0,.16),inset 0 .35rem 0 hsla(0,0%,100%,.14)}.cs-stack[data-chip="5"]{--chip:#5aa9e6}.cs-stack[data-chip="25"]{--chip:#4caf7d}.cs-stack[data-chip="100"]{--chip:#2b2118}.cs-stack[data-chip="500"]{--chip:#b079d6}.last\:border-0:last-child{border-width:0}.empty\:hidden:empty{display:none}.hover\:-translate-y-0\.5:hover{--tw-translate-y:-0.125rem}.hover\:-translate-y-0\.5:hover,.hover\:-translate-y-1:hover{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:-translate-y-1:hover{--tw-translate-y:-0.25rem}.hover\:text-\[color\:var\(--ink\)\]:hover{color:var(--ink)}.hover\:brightness-105:hover{--tw-brightness:brightness(1.05);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.hover\:shadow-pete-lg:hover{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.focus\:border-\[color\:var\(--accent\)\]:focus{border-color:var(--accent)}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.active\:translate-y-px:active{--tw-translate-y:1px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:-rotate-6{--tw-rotate:-6deg}.group:hover .group-hover\:-rotate-6,.group:hover .group-hover\:scale-105{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:scale-105{--tw-scale-x:1.05;--tw-scale-y:1.05}.group:hover .group-hover\:underline{text-decoration-line:underline}@media (min-width:640px){.sm\:ml-auto{margin-left:auto}.sm\:block{display:block}.sm\:inline{display:inline}.sm\:flex{display:flex}.sm\:inline-flex{display:inline-flex}.sm\:h-56{height:14rem}.sm\:basis-auto{flex-basis:auto}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:gap-2{gap:.5rem}.sm\:gap-3{gap:.75rem}.sm\:gap-4{gap:1rem}.sm\:p-10{padding:2.5rem}.sm\:p-6{padding:1.5rem}.sm\:p-8{padding:2rem}.sm\:pr-28{padding-right:7rem}.sm\:pt-10{padding-top:2.5rem}.sm\:pt-12{padding-top:3rem}.sm\:text-2xl{font-size:1.5rem;line-height:2rem}.sm\:text-3xl{font-size:1.875rem;line-height:2.25rem}.sm\:text-4xl{font-size:2.25rem;line-height:2.5rem}.sm\:text-5xl{font-size:3rem;line-height:1}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-\[auto\2c 1fr\]{grid-template-columns:auto 1fr}.lg\:items-start{align-items:flex-start}}@media (prefers-color-scheme:dark){.dark\:text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dark\:text-emerald-300{--tw-text-opacity:1;color:rgb(110 231 183/var(--tw-text-opacity,1))}.dark\:text-red-300{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.dark\:text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}}
\ No newline at end of file
+*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}body{font-family:Nunito,system-ui,sans-serif}html{scroll-behavior:smooth}body,html{overflow-x:hidden}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.pete-channel-nav{scrollbar-width:none;-ms-overflow-style:none}.pete-channel-nav::-webkit-scrollbar{display:none}.pete-reader-backdrop{position:absolute;inset:0;background:rgba(20,14,6,.55);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}html[data-phase=night] .pete-reader-backdrop{background:rgba(6,8,20,.65)}.pete-reader-shell{position:relative;height:100%;width:100%;max-width:44rem;margin:0 auto;display:flex;flex-direction:column;padding:max(env(safe-area-inset-top),.75rem) 1rem .75rem;gap:.75rem}.pete-reader-bar{display:flex;align-items:center;justify-content:space-between;gap:.75rem;color:#fff}.pete-reader-progress{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:600;font-size:.85rem;letter-spacing:.02em;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-nav{display:flex;align-items:center;gap:.4rem}.pete-reader-btn{display:inline-flex;align-items:center;justify-content:center;min-width:2.25rem;height:2.25rem;padding:0 .7rem;border-radius:9999px;background:hsla(0,0%,100%,.14);color:#fff;font-weight:700;font-size:.95rem;border:2px solid hsla(0,0%,100%,.18);cursor:pointer;transition:background .15s ease,transform .15s ease,opacity .15s ease}.pete-reader-btn:hover{background:hsla(0,0%,100%,.28);transform:translateY(-1px)}.pete-reader-btn:disabled{opacity:.35;cursor:default;transform:none}.pete-reader-btn-open{background:var(--accent);border-color:transparent;color:#1c1305;text-decoration:none}.pete-reader-btn-open:hover{background:var(--accent);filter:brightness(1.08)}.pete-reader-btn[aria-expanded=true],.pete-reader-btn[aria-pressed=true]{background:var(--accent);border-color:transparent;color:#1c1305}.pete-reader-type{position:relative;display:inline-flex}.pete-reader-typemenu[hidden]{display:none}.pete-reader-typemenu{position:absolute;top:calc(100% + .5rem);right:0;z-index:10;width:15rem;padding:.75rem;border-radius:1rem;background:var(--card);color:var(--ink);border:2px solid rgba(0,0,0,.08);box-shadow:0 10px 30px rgba(20,14,6,.28);display:flex;flex-direction:column;gap:.6rem}html[data-phase=night] .pete-reader-typemenu{border-color:hsla(0,0%,100%,.12)}.pete-reader-typerow{display:flex;align-items:center;justify-content:space-between;gap:.6rem}.pete-reader-typelabel{font-size:.8rem;font-weight:700;opacity:.7}.pete-reader-typebtns{display:inline-flex;gap:.25rem}.pete-reader-typebtns button{min-width:2rem;height:2rem;padding:0 .55rem;border-radius:.6rem;background:rgba(20,14,6,.06);color:var(--ink);font-weight:700;font-size:.82rem;border:2px solid transparent;cursor:pointer;transition:background .15s ease}html[data-phase=night] .pete-reader-typebtns button{background:hsla(0,0%,100%,.08)}.pete-reader-typebtns button:hover{background:rgba(20,14,6,.12)}.pete-reader-typebtns button.is-active{background:var(--accent);color:#1c1305;border-color:transparent}.pete-reader-voicerow[hidden]{display:none}.pete-reader-voice{font:inherit;font-size:.8rem;font-weight:600;padding:.25rem .4rem;border-radius:.55rem;border:1px solid rgba(20,14,6,.18);background:rgba(20,14,6,.06);color:inherit;max-width:11rem;cursor:pointer}html[data-phase=night] .pete-reader-voice{border-color:hsla(0,0%,100%,.14);background:hsla(0,0%,100%,.08)}.pete-reader-scroll{flex:1 1 auto;overflow-y:auto;-webkit-overflow-scrolling:touch;border-radius:1.75rem}.pete-reader-scroll:focus{outline:none}.pete-reader-article{background:var(--card);color:var(--ink);border-radius:1.75rem;border:2px solid rgba(0,0,0,.06);box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.18);padding:clamp(1.25rem,4vw,2.5rem)}html[data-phase=night] .pete-reader-article{border-color:hsla(0,0%,100%,.08)}.pete-reader-eyebrow{display:flex;align-items:center;flex-wrap:wrap;gap:.5rem;font-size:.75rem;margin-bottom:.85rem}.pete-reader-chip{display:inline-flex;align-items:center;gap:.35rem;border-radius:9999px;padding:.15rem .65rem;color:#fff;font-weight:700;text-transform:uppercase;letter-spacing:.06em}.pete-reader-source{font-weight:700;opacity:.75}.pete-reader-time{opacity:.55}.pete-reader-title{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:700;line-height:1.15;letter-spacing:-.01em;font-size:clamp(1.6rem,4.5vw,2.4rem);margin-bottom:1rem}.pete-reader-hero{width:100%;border-radius:1.1rem;margin-bottom:1.25rem;background:rgba(0,0,0,.05)}.pete-reader-scroll{--reader-fs:1.08rem}.pete-reader-body{font-size:var(--reader-fs);line-height:1.75}.pete-reader-body p{margin-bottom:1.05em}.pete-reader-body p:last-child{margin-bottom:0}.pete-reader-scroll.is-size-s{--reader-fs:0.98rem}.pete-reader-scroll.is-size-m{--reader-fs:1.08rem}.pete-reader-scroll.is-size-l{--reader-fs:1.22rem}.pete-reader-scroll.is-size-xl{--reader-fs:1.4rem}.pete-reader-scroll.is-serif .pete-reader-body{font-family:Georgia,Iowan Old Style,Times New Roman,serif}.pete-reader-scroll.is-sepia .pete-reader-article{background:#f6ecd6;color:#4a3a28;border-color:rgba(74,58,40,.16)}.pete-reader-scroll.is-sepia .pete-reader-note{border-top-color:rgba(74,58,40,.18);opacity:.8}.pete-reader-scroll.is-sepia .pete-reader-hero{background:rgba(74,58,40,.08)}.pete-reader-body p.is-speaking{background:color-mix(in srgb,var(--accent) 32%,transparent);border-radius:.4rem;box-shadow:0 0 0 .35rem color-mix(in srgb,var(--accent) 32%,transparent)}.pete-reader-note{margin-top:1.25rem;padding-top:1rem;border-top:1px solid rgba(0,0,0,.1);font-size:.85rem;opacity:.7}html[data-phase=night] .pete-reader-note{border-color:hsla(0,0%,100%,.12)}.pete-reader-note a{color:var(--accent);font-weight:700}.pete-reader-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:.75rem;padding:clamp(2rem,8vw,4rem) 1rem;color:var(--ink)}.pete-reader-empty-emoji{font-size:2.5rem}.pete-reader-toast{position:absolute;left:50%;bottom:4.5rem;transform:translate(-50%,.5rem);z-index:20;padding:.5rem .9rem;border-radius:9999px;background:rgba(20,14,6,.9);color:#fff;font-weight:700;font-size:.85rem;box-shadow:0 8px 24px rgba(0,0,0,.3);opacity:0;pointer-events:none;transition:opacity .2s ease,transform .2s ease}.pete-reader-toast.is-shown{opacity:1;transform:translate(-50%)}.pete-reader-hint{text-align:center;color:hsla(0,0%,100%,.85);font-size:.75rem;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-hint kbd{font-family:ui-monospace,monospace;background:hsla(0,0%,100%,.16);border-radius:.3rem;padding:.05rem .3rem}@media (max-width:640px){.pete-reader-hint{display:none}.pete-reader-bar{flex-wrap:wrap}.pete-reader-nav{flex-wrap:wrap;justify-content:flex-end;row-gap:.4rem}}.pete-reader-related{width:100%;margin:.85rem auto 0}.pete-reader-related-title{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:700;font-size:.95rem;color:#fff;margin:0 0 .6rem;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-related-grid{display:grid;gap:.6rem}.pete-reader-related-card{display:flex;align-items:center;gap:.75rem;background:var(--card);color:var(--ink);border-radius:1rem;border:2px solid rgba(0,0,0,.06);padding:.6rem;text-decoration:none;transition:transform .15s ease,box-shadow .15s ease}.pete-reader-related-card:hover{transform:translateY(-1px);box-shadow:0 6px 16px rgba(60,40,20,.16)}html[data-phase=night] .pete-reader-related-card{border-color:hsla(0,0%,100%,.08)}.pete-reader-related-thumb{width:4.5rem;height:3.25rem;flex-shrink:0;-o-object-fit:cover;object-fit:cover;border-radius:.6rem;background:rgba(0,0,0,.06)}.pete-reader-related-meta{min-width:0;display:flex;flex-direction:column;gap:.25rem}.pete-reader-related-eyebrow{display:flex;align-items:center;flex-wrap:wrap;gap:.4rem;font-size:.7rem}.pete-reader-related-source{font-weight:700;opacity:.7}.pete-reader-related-headline{font-weight:700;line-height:1.25;font-size:.92rem;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}[data-story-card][data-read="1"]{opacity:.5;transition:opacity .2s ease}[data-story-card][data-read="1"]:hover{opacity:1}[data-story-card][data-read="1"]:after{content:"✓";position:absolute;top:.6rem;right:.6rem;z-index:6;display:grid;place-items:center;height:1.5rem;width:1.5rem;border-radius:9999px;background:rgba(20,14,6,.72);color:#fff;font-size:.8rem;font-weight:700}.pete-felt{background:radial-gradient(120% 90% at 50% -10%,hsla(0,0%,100%,.1),transparent 60%),linear-gradient(160deg,#2f7d5b,#24614a 55%,#1c4d3c)}.pete-shoe{position:absolute;top:1.25rem;right:1.25rem;height:4.2rem;width:3rem;border-radius:.55rem;background:linear-gradient(150deg,#b4553f,#8d3f2f);border:2px solid rgba(0,0,0,.18);box-shadow:0 4px 0 rgba(0,0,0,.18),inset 0 0 0 3px hsla(0,0%,100%,.12)}.pete-shoe:after{content:"";position:absolute;inset:.45rem .4rem auto .4rem;height:.5rem;border-radius:.2rem;background:rgba(0,0,0,.22)}.pete-hand{display:flex;flex-wrap:wrap;gap:.6rem;min-height:8.6rem}.pete-card{perspective:700px;height:var(--card-h,8.4rem);width:var(--card-w,6rem);animation:pete-deal .42s cubic-bezier(.22,1,.36,1) backwards}.pete-card-inner{position:relative;height:100%;width:100%;transform-style:preserve-3d;transition:transform .45s cubic-bezier(.4,0,.2,1)}.pete-card[data-face=down] .pete-card-inner{transform:rotateY(180deg)}.pete-card-back,.pete-card-front{position:absolute;inset:0;display:grid;border-radius:.55rem;backface-visibility:hidden;-webkit-backface-visibility:hidden;box-shadow:0 3px 0 rgba(0,0,0,.18),0 6px 14px rgba(0,0,0,.22)}.pete-card-front{place-items:stretch;background:#fdfaf2;border:2px solid rgba(30,20,10,.12);color:#2b2118;font-family:Fredoka,Nunito,system-ui,sans-serif;line-height:1;overflow:hidden}.pete-card-front[data-red="1"]{color:#cc3d4a}.pete-card-back{transform:rotateY(180deg);background:repeating-linear-gradient(45deg,hsla(0,0%,100%,.1) 0 6px,transparent 6px 12px),linear-gradient(150deg,#b4553f,#8d3f2f);border:2px solid rgba(0,0,0,.15)}.pete-card-svg{width:100%;height:100%;display:block;fill:currentColor}.pete-card-idx{font-size:26px;font-weight:700;text-anchor:middle;fill:currentColor}.pete-card-panel{fill:currentColor;fill-opacity:.06;stroke:currentColor;stroke-opacity:.35;stroke-width:1.5}.pete-card-court{font-size:34px;font-weight:700;text-anchor:middle;fill:currentColor}@keyframes pete-deal{0%{opacity:0;transform:translate(var(--deal-x,14rem),var(--deal-y,-7rem)) scale(.72) rotate(9deg)}62%{opacity:1;transform:translateY(.35rem) scale(1.05) rotate(calc(var(--tilt, 0deg) - 2deg))}to{opacity:1;transform:translate(0) scale(1) rotate(var(--tilt,0deg))}}.pete-card:after{content:"";position:absolute;inset:auto 6% -.35rem 6%;height:.6rem;border-radius:999px;background:rgba(0,0,0,.35);filter:blur(5px);animation:pete-thud .42s cubic-bezier(.22,1,.36,1) backwards}@keyframes pete-thud{0%,55%{opacity:0;transform:scale(.4)}72%{opacity:.9;transform:scale(1.15)}to{opacity:.45;transform:scale(1)}}.pete-hand[data-won="1"] .pete-card{animation:pete-deal .42s cubic-bezier(.22,1,.36,1) backwards,pete-win .6s ease .1s}@keyframes pete-win{0%,to{transform:rotate(var(--tilt,0deg)) translateY(0)}40%{transform:rotate(var(--tilt,0deg)) translateY(-.6rem)}}.pete-hand[data-won="-1"] .pete-card{filter:saturate(.45) brightness(.78);transition:filter .5s ease .2s}.pete-disc{position:relative;border-radius:999px;background:radial-gradient(circle at 50% 30%,hsla(0,0%,100%,.3),transparent 62%),var(--chip,#e07a5f);border:2px solid rgba(0,0,0,.22);box-shadow:0 3px 0 rgba(0,0,0,.28),0 6px 14px rgba(0,0,0,.2)}.pete-disc:before{content:"";position:absolute;inset:11%;border-radius:999px;border:2px dashed hsla(0,0%,100%,.55)}.pete-disc[data-chip="5"]{--chip:#5aa9e6}.pete-disc[data-chip="25"]{--chip:#4caf7d}.pete-disc[data-chip="100"]{--chip:#2b2118}.pete-disc[data-chip="500"]{--chip:#b079d6}.pete-chip{transition:transform .12s ease}.pete-chip>span{position:relative}.pete-chip:active{transform:translateY(1px) scale(.94)}.pete-spot{position:relative;display:grid;place-items:center;height:7rem;width:7rem;flex:none;border-radius:999px;border:2px dashed hsla(0,0%,100%,.35);background:radial-gradient(circle at 50% 40%,hsla(0,0%,100%,.1),transparent 70%);box-shadow:inset 0 0 0 6px rgba(0,0,0,.08);transition:box-shadow .3s ease,border-color .3s ease}.pete-spot[data-live="1"]{border-color:rgba(var(--glow,242,181,61),.85);box-shadow:inset 0 0 0 6px rgba(0,0,0,.08),0 0 22px rgba(var(--glow,242,181,61),.35)}.pete-spot-label{font-size:.65rem;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:hsla(0,0%,100%,.35)}.pete-spot[data-live="1"] .pete-spot-label{opacity:0}.pete-stack{position:absolute;inset:0;pointer-events:none}.pete-stack .pete-disc{position:absolute;left:50%;top:50%;height:2.6rem;width:2.6rem;margin:-1.3rem 0 0 -1.3rem;transform:translateY(calc(-.5rem*var(--i, 0))) rotate(var(--spin,0deg));box-shadow:0 2px 0 rgba(0,0,0,.35),0 4px 8px rgba(0,0,0,.25);animation:pete-chip-land .28s cubic-bezier(.34,1.56,.64,1) backwards}@keyframes pete-chip-land{0%{transform:translateY(calc(-.5rem*var(--i, 0) - 1.1rem)) rotate(var(--spin,0deg)) scale(1.18)}}.pete-spot-total{position:absolute;bottom:-.6rem;left:50%;transform:translateX(-50%);white-space:nowrap;border-radius:999px;background:rgba(0,0,0,.45);padding:.1rem .6rem;font-size:.72rem;font-weight:700;font-variant-numeric:tabular-nums;color:#fff}.pete-fly-layer{position:fixed;inset:0;z-index:60;pointer-events:none;overflow:hidden}.pete-fly{position:absolute;height:2.6rem;width:2.6rem;margin:-1.3rem 0 0 -1.3rem;will-change:transform}.pete-rack{position:absolute;top:1.25rem;right:5.75rem;display:flex;align-items:flex-end;gap:.4rem;padding:.5rem .6rem .45rem;border-radius:.75rem;background:rgba(0,0,0,.2);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.06)}.pete-rack span{display:block;width:1.7rem;border-radius:.35rem;background:linear-gradient(90deg,rgba(0,0,0,.28),transparent 35%,transparent 65%,rgba(0,0,0,.28)),repeating-linear-gradient(180deg,hsla(0,0%,100%,.18) 0 .06rem,var(--chip,#e07a5f) .06rem .3rem,rgba(0,0,0,.35) .3rem .36rem);border:1px solid rgba(0,0,0,.35);height:calc(.36rem*var(--stack, 3))}.pete-rack span[data-chip="5"]{--chip:#5aa9e6}.pete-rack span[data-chip="25"]{--chip:#4caf7d}.pete-rack span[data-chip="100"]{--chip:#2b2118}.pete-rack span[data-chip="500"]{--chip:#b079d6}.pete-spark{position:absolute;height:.75rem;width:.45rem;border-radius:2px;box-shadow:0 1px 3px rgba(0,0,0,.35);will-change:transform,opacity}.pete-dealer-think{animation:pete-think .9s ease-in-out infinite}@keyframes pete-think{0%,to{opacity:.6}50%{opacity:1;transform:translateY(-1px)}}.pete-gallows{overflow:visible;fill:none;stroke-linecap:round;stroke-linejoin:round}.pete-gallows-frame path{stroke:rgba(0,0,0,.35);stroke-width:6}.pete-gallows-frame path:last-child{stroke:rgba(0,0,0,.3);stroke-width:3.5}.pete-gallows-body>*{stroke:#fff;stroke-width:5;opacity:0;filter:drop-shadow(0 2px 0 rgba(0,0,0,.25))}.pete-gallows-body>[data-on="1"]{opacity:1}.pete-part-draw{stroke-dasharray:260;animation:pete-part .45s cubic-bezier(.22,1,.36,1) backwards}@keyframes pete-part{0%{stroke-dashoffset:260}to{stroke-dashoffset:0}}.pete-shake{animation:pete-shake .42s cubic-bezier(.36,.07,.19,.97)}@keyframes pete-shake{10%,90%{transform:translateX(-1.5px)}20%,80%{transform:translateX(3px)}30%,50%,70%{transform:translateX(-5px)}40%,60%{transform:translateX(5px)}}.pete-board{display:flex;flex-wrap:wrap;gap:.35rem 1.1rem;min-height:5rem;align-items:center}.pete-word{display:flex;gap:.3rem}.pete-tile{display:grid;place-items:center;height:2.9rem;width:2.2rem;border-radius:.5rem;font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:1.4rem;font-weight:700;color:#fff;background:rgba(0,0,0,.22);border-bottom:3px solid rgba(0,0,0,.28);text-transform:uppercase}.pete-tile[data-up="1"]{background:hsla(0,0%,100%,.95);color:#2b2118;border-bottom-color:rgba(0,0,0,.35)}.pete-tile[data-punct="1"]{background:none;border:0;width:auto;min-width:.7rem;color:hsla(0,0%,100%,.55)}.pete-tile-hit{animation:pete-tile-hit .34s cubic-bezier(.34,1.56,.64,1)}@keyframes pete-tile-hit{0%{transform:rotateX(90deg) scale(1.1)}to{transform:rotateX(0) scale(1)}}.pete-missed{display:grid;place-items:center;height:1.5rem;min-width:1.5rem;padding:0 .3rem;border-radius:.375rem;background:rgba(0,0,0,.25);font-size:.75rem;font-weight:700;color:hsla(0,0%,100%,.5);text-decoration:line-through;text-transform:uppercase}.pete-meter{display:flex;align-items:baseline;gap:.5rem;border-radius:999px;background:rgba(0,0,0,.28);padding:.4rem .9rem;box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.1);transition:box-shadow .3s ease}.pete-meter-label{font-size:.65rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:hsla(0,0%,100%,.45)}.pete-meter-value{font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:1.5rem;font-weight:700;font-variant-numeric:tabular-nums;color:var(--accent)}.pete-meter[data-cold="1"] .pete-meter-value{color:hsla(0,0%,100%,.55)}.pete-meter[data-hit="1"]{box-shadow:inset 0 0 0 2px rgba(204,61,74,.9);animation:pete-meter-hit .4s ease}@keyframes pete-meter-hit{0%{transform:scale(1)}35%{transform:scale(.94)}to{transform:scale(1)}}.pete-keys{display:grid;gap:.35rem}.pete-key-row{display:flex;justify-content:center;gap:.35rem}.pete-key-row[data-digits="1"]{margin-top:.25rem;opacity:.75}.pete-key-row[data-digits="1"] .pete-key{height:2rem;min-width:1.8rem;font-size:.8rem}.pete-key{height:2.75rem;min-width:2.2rem;flex:0 1 2.4rem;border-radius:.6rem;background:color-mix(in srgb,var(--ink) 6%,transparent);border:2px solid color-mix(in srgb,var(--ink) 10%,transparent);font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-weight:700;color:var(--ink);transition:transform .08s ease,background .15s ease,opacity .15s ease}.pete-key:hover:not(:disabled){background:color-mix(in srgb,var(--ink) 12%,transparent)}.pete-key:active:not(:disabled){transform:translateY(1px) scale(.96)}.pete-key:disabled{cursor:default}.pete-key[data-state=hit]{background:#4caf7d;border-color:#3d9367;color:#fff;opacity:1}.pete-key[data-state=miss]{background:color-mix(in srgb,var(--ink) 4%,transparent);color:color-mix(in srgb,var(--ink) 30%,transparent);text-decoration:line-through}.pete-key:disabled[data-state=""]{opacity:.4}.pete-tier{background:color-mix(in srgb,var(--ink) 4%,transparent);border-color:color-mix(in srgb,var(--ink) 10%,transparent)}.pete-tier:hover{background:color-mix(in srgb,var(--ink) 8%,transparent)}.pete-tier[data-on="1"]{background:color-mix(in srgb,var(--accent) 18%,transparent);border-color:var(--accent)}.pete-solitaire{--card-w:clamp(2.5rem,10.6vw,5.2rem);--card-h:calc(var(--card-w)*1.4);--fan-up:calc(var(--card-h)*0.29);--fan-down:calc(var(--card-h)*0.14)}.pete-slot{position:relative;display:grid;place-items:center;height:var(--card-h);width:var(--card-w);flex:none;border-radius:.55rem;border:2px dashed hsla(0,0%,100%,.25);background:rgba(0,0,0,.12);box-shadow:inset 0 2px 8px rgba(0,0,0,.18);transition:border-color .18s ease,background .18s ease,transform .12s ease}.pete-slot-glyph{font-size:calc(var(--card-w)*.42);line-height:1;color:hsla(0,0%,100%,.3)}.pete-slot-glyph[data-red="1"]{color:hsla(0,100%,83%,.35)}.pete-slot>.pete-card{position:absolute;inset:0;height:100%;width:100%}.pete-stock{border-style:solid;border-color:rgba(0,0,0,.15);background:repeating-linear-gradient(45deg,hsla(0,0%,100%,.1) 0 6px,transparent 6px 12px),linear-gradient(150deg,#b4553f,#8d3f2f);cursor:pointer}.pete-stock:hover{filter:brightness(1.08)}.pete-stock:active{transform:translateY(1px) scale(.98)}.pete-stock[data-empty="1"]{background:rgba(0,0,0,.12);border-style:dashed;border-color:hsla(0,0%,100%,.25)}.pete-stock[data-dead="1"]{cursor:default;opacity:.4}.pete-stock[data-dead="1"]:hover{filter:none}.pete-stock[data-dead="1"]:active{transform:none}.pete-slot-count{position:absolute;bottom:-.5rem;left:50%;transform:translateX(-50%);border-radius:999px;background:rgba(0,0,0,.5);padding:.05rem .5rem;font-size:.7rem;font-weight:700;font-variant-numeric:tabular-nums;color:#fff}.pete-slot-recycle{color:hsla(0,0%,100%,.45)}.pete-slot-recycle svg{height:1.4rem;width:1.4rem}.pete-waste{position:relative;display:flex;height:var(--card-h);min-width:var(--card-w)}.pete-waste .pete-card+.pete-card{margin-left:calc(var(--card-w)*-.72)}.pete-waste .pete-card{position:relative}.pete-tableau{display:grid;grid-template-columns:repeat(7,var(--card-w));justify-content:space-between;gap:.5rem;align-items:start;min-height:calc(var(--card-h)*2.6)}.pete-col{display:flex;flex-direction:column;align-items:center;min-height:var(--card-h)}.pete-col .pete-card{position:relative}.pete-col .pete-card[data-face=down]+.pete-card{margin-top:calc(var(--fan-down) - var(--card-h))}.pete-col .pete-card[data-face=up]+.pete-card{margin-top:calc(var(--fan-up) - var(--card-h))}.pete-card[data-live="1"]{cursor:pointer}.pete-card[data-live="1"]:hover .pete-card-front{filter:brightness(1.06)}.pete-card[data-held="1"]{z-index:5;transform:translateY(-.55rem);transition:transform .14s cubic-bezier(.34,1.56,.64,1)}.pete-card[data-held="1"] .pete-card-front{box-shadow:0 3px 0 rgba(0,0,0,.18),0 10px 22px rgba(0,0,0,.32),0 0 0 3px rgba(242,181,61,.9)}.pete-col[data-drop="1"] .pete-card:last-child .pete-card-front,.pete-col[data-drop="1"] .pete-slot,.pete-slot[data-drop="1"]{border-color:rgba(242,181,61,.9);box-shadow:0 0 0 3px rgba(242,181,61,.45)}.pete-col[data-drop="1"] .pete-slot,.pete-slot[data-drop="1"]{background:rgba(242,181,61,.12)}.pete-nope{animation:pete-shake .4s cubic-bezier(.36,.07,.19,.97)}.pete-home-flash{animation:pete-home .5s ease-out}@keyframes pete-home{0%{box-shadow:0 0 0 0 rgba(242,181,61,.9)}to{box-shadow:0 0 0 1.4rem rgba(242,181,61,0)}}.pete-rail{display:flex;flex-direction:row;flex-wrap:wrap;align-items:center;justify-content:center;gap:1rem 1.5rem}@media (min-width:1024px){.pete-rail{flex-direction:column;justify-content:flex-start;width:9.5rem;gap:1.5rem}}.pete-rack[data-at=rail]{position:static;justify-content:center}@media (prefers-reduced-motion:reduce){.pete-card,.pete-card:after,.pete-dealer-think,.pete-stack .pete-disc{animation:none}.pete-card-inner{transition:none}.pete-hand[data-won="1"] .pete-card,.pete-home-flash,.pete-meter[data-hit="1"],.pete-nope,.pete-part-draw,.pete-shake,.pete-tile-hit{animation:none}.pete-card[data-held="1"]{transition:none}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-right-6{right:-1.5rem}.-top-6{top:-1.5rem}.-z-10{z-index:-10}.-z-\[5\]{z-index:-5}.z-50{z-index:50}.order-none{order:0}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-auto{margin-left:auto;margin-right:auto}.-mt-1{margin-top:-.25rem}.mb-1{margin-bottom:.25rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-8{margin-bottom:2rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mt-0{margin-top:0}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-8{margin-top:2rem}.line-clamp-2{-webkit-line-clamp:2}.line-clamp-2,.line-clamp-3{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical}.line-clamp-3{-webkit-line-clamp:3}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.aspect-\[16\/10\]{aspect-ratio:16/10}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-20{height:5rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-52{height:13rem}.h-6{height:1.5rem}.h-9{height:2.25rem}.h-full{height:100%}.max-h-\[55vh\]{max-height:55vh}.max-h-\[60vh\]{max-height:60vh}.min-h-\[1\.75rem\]{min-height:1.75rem}.min-h-\[2\.75rem\]{min-height:2.75rem}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-2{width:.5rem}.w-2\.5{width:.625rem}.w-20{width:5rem}.w-24{width:6rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-9{width:2.25rem}.w-auto{width:auto}.w-full{width:100%}.min-w-0{min-width:0}.min-w-\[28rem\]{min-width:28rem}.min-w-\[52rem\]{min-width:52rem}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-\[7rem\]{max-width:7rem}.max-w-full{max-width:100%}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink,.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.basis-full{flex-basis:100%}.-translate-y-0{--tw-translate-y:-0px}.-translate-y-0,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-10{-moz-column-gap:2.5rem;column-gap:2.5rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-4{row-gap:1rem}.gap-y-8{row-gap:2rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.375rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-transparent{border-color:transparent}.bg-\[color\:var\(--accent\)\]{background-color:var(--accent)}.bg-\[color\:var\(--bg\)\]{background-color:var(--bg)}.bg-\[color\:var\(--bg-grad\)\]{background-color:var(--bg-grad)}.bg-\[color\:var\(--card\)\]{background-color:var(--card)}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-black\/25{background-color:rgba(0,0,0,.25)}.bg-emerald-500{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity,1))}.bg-emerald-500\/15{background-color:rgba(16,185,129,.15)}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/15{background-color:rgba(239,68,68,.15)}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/95{background-color:hsla(0,0%,100%,.95)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-0\.5{padding:.125rem}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0{padding-top:0;padding-bottom:0}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-10{padding-bottom:2.5rem}.pb-20{padding-bottom:5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pr-24{padding-right:6rem}.pt-1{padding-top:.25rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-8{padding-top:2rem}.pt-\[10vh\]{padding-top:10vh}.pt-\[12vh\]{padding-top:12vh}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.align-\[-4px\]{vertical-align:-4px}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12rem\]{font-size:12rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-\[0\.18em\]{letter-spacing:.18em}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-\[\#1c1305\]{--tw-text-opacity:1;color:rgb(28 19 5/var(--tw-text-opacity,1))}.text-\[\#20180c\]{--tw-text-opacity:1;color:rgb(32 24 12/var(--tw-text-opacity,1))}.text-\[\#2b2118\]{--tw-text-opacity:1;color:rgb(43 33 24/var(--tw-text-opacity,1))}.text-\[color\:var\(--accent\)\]{color:var(--accent)}.text-\[color\:var\(--ink\)\]{color:var(--ink)}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-emerald-700{--tw-text-opacity:1;color:rgb(4 120 87/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-white\/40{color:hsla(0,0%,100%,.4)}.text-white\/50{color:hsla(0,0%,100%,.5)}.text-white\/55{color:hsla(0,0%,100%,.55)}.text-white\/60{color:hsla(0,0%,100%,.6)}.text-white\/85{color:hsla(0,0%,100%,.85)}.text-white\/90{color:hsla(0,0%,100%,.9)}.underline{text-decoration-line:underline}.decoration-4{text-decoration-thickness:4px}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.accent-\[color\:var\(--accent\)\]{accent-color:var(--accent)}.opacity-20{opacity:.2}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.blur{--tw-blur:blur(8px)}.blur,.drop-shadow{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.drop-shadow{--tw-drop-shadow:drop-shadow(0 1px 2px rgba(0,0,0,.1)) drop-shadow(0 1px 1px rgba(0,0,0,.06))}.grayscale{--tw-grayscale:grayscale(100%)}.grayscale,.sepia{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.sepia{--tw-sepia:sepia(100%)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.backdrop-blur-sm,.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-1000{transition-duration:1s}.duration-500{transition-duration:.5s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}.font-display{font-family:Fredoka,Nunito,system-ui,sans-serif;letter-spacing:-.01em}.shadow-pete{box-shadow:0 4px 0 rgba(60,40,20,.1),0 8px 24px rgba(60,40,20,.08)}.shadow-pete-lg{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.bg-theme-gaming{background-color:#4caf7d}.bg-theme-tech{background-color:#5aa9e6}.bg-theme-politics{background-color:#e07a5f}.bg-theme-eu{background-color:#039}.bg-theme-music{background-color:#b079d6}.bg-theme-anime{background-color:#ec5e8a}.bg-theme-foss{background-color:#d97706}.bg-theme-kids{background-color:#14b8a6}.bg-theme-finance{background-color:#10b981}.bg-theme-lego{background-color:#d01012}.bg-theme-adventure{background-color:#6d4bd8}.text-theme-gaming{color:#2d8a5a}.text-theme-tech{color:#2f7fb8}.text-theme-politics{color:#b8523a}.text-theme-eu{color:#039}.text-theme-music{color:#8a4fb8}.text-theme-anime{color:#c33a6a}.text-theme-foss{color:#b8530a}.text-theme-kids{color:#0f766e}.text-theme-finance{color:#059669}.text-theme-lego{color:#b00d0e}.text-theme-adventure{color:#5836b8}.decoration-theme-gaming{text-decoration-color:#4caf7d}.decoration-theme-tech{text-decoration-color:#5aa9e6}.decoration-theme-politics{text-decoration-color:#e07a5f}.decoration-theme-eu{text-decoration-color:#039}.decoration-theme-music{text-decoration-color:#b079d6}.decoration-theme-anime{text-decoration-color:#ec5e8a}.decoration-theme-foss{text-decoration-color:#d97706}.decoration-theme-kids{text-decoration-color:#14b8a6}.decoration-theme-finance{text-decoration-color:#10b981}.decoration-theme-lego{text-decoration-color:#d01012}.decoration-theme-adventure{text-decoration-color:#6d4bd8}.border-theme-gaming{border-color:#4caf7d}.border-theme-tech{border-color:#5aa9e6}.border-theme-politics{border-color:#e07a5f}.border-theme-eu{border-color:#039}.border-theme-music{border-color:#b079d6}.border-theme-anime{border-color:#ec5e8a}.border-theme-foss{border-color:#d97706}.border-theme-kids{border-color:#14b8a6}.border-theme-finance{border-color:#10b981}.border-theme-lego{border-color:#d01012}.border-theme-adventure{border-color:#6d4bd8}.glow-theme-gaming{box-shadow:0 0 0 2px rgba(76,175,125,.35),0 0 24px 4px rgba(76,175,125,.45)}.glow-theme-gaming,.glow-theme-tech{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-tech{box-shadow:0 0 0 2px rgba(90,169,230,.35),0 0 24px 4px rgba(90,169,230,.45)}.glow-theme-politics{box-shadow:0 0 0 2px rgba(224,122,95,.35),0 0 24px 4px rgba(224,122,95,.45)}.glow-theme-eu,.glow-theme-politics{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-eu{box-shadow:0 0 0 2px rgba(0,51,153,.35),0 0 24px 4px rgba(0,51,153,.45)}.glow-theme-music{box-shadow:0 0 0 2px rgba(176,121,214,.35),0 0 24px 4px rgba(176,121,214,.45)}.glow-theme-anime,.glow-theme-music{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-anime{box-shadow:0 0 0 2px rgba(236,94,138,.35),0 0 24px 4px rgba(236,94,138,.45)}.glow-theme-foss{box-shadow:0 0 0 2px rgba(217,119,6,.35),0 0 24px 4px rgba(217,119,6,.45)}.glow-theme-foss,.glow-theme-kids{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-kids{box-shadow:0 0 0 2px rgba(20,184,166,.35),0 0 24px 4px rgba(20,184,166,.45)}.glow-theme-finance{box-shadow:0 0 0 2px rgba(16,185,129,.35),0 0 24px 4px rgba(16,185,129,.45)}.glow-theme-finance,.glow-theme-lego{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-lego{box-shadow:0 0 0 2px rgba(208,16,18,.35),0 0 24px 4px rgba(208,16,18,.45)}.glow-theme-adventure{box-shadow:0 0 0 2px rgba(109,75,216,.35),0 0 24px 4px rgba(109,75,216,.45);animation:pete-glow-pulse 2.4s ease-in-out infinite}@keyframes pete-glow-pulse{0%,to{filter:brightness(1)}50%{filter:brightness(1.08)}}.line-clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.paywall-stamp{position:absolute;inset:0;pointer-events:none;display:flex;align-items:center;justify-content:center;z-index:5;background:rgba(255,250,240,.18)}.paywall-stamp:before{content:"PAYWALLED";font-family:var(--font-display,ui-sans-serif),system-ui,sans-serif;font-weight:900;font-size:clamp(1rem,3.2vw,1.75rem);letter-spacing:.12em;color:rgba(180,30,30,.85);border:.28rem double rgba(180,30,30,.85);padding:.3rem .9rem;transform:rotate(-15deg);text-shadow:0 1px 0 hsla(0,0%,100%,.4);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.15);background:rgba(255,240,230,.55);border-radius:.25rem;filter:contrast(1.05)}:root,html[data-phase=day]{--bg:#fff7e4;--bg-grad:#ffeec2;--card:#fff;--ink:#3a2e1f;--accent:#f2a541}html[data-phase=dawn]{--bg:#ffe7d6;--bg-grad:#ffc9c9;--card:#fff4ea;--ink:#4a2e2a;--accent:#ff8a65}html[data-phase=dusk]{--bg:#ffd6a8;--bg-grad:#f7a07e;--card:#fff1de;--ink:#3d2417;--accent:#e6553a}html[data-phase=night]{--bg:#1a1f3a;--bg-grad:#2a3358;--card:#2d365a;--ink:#f1ecd8;--accent:#f9d976}html[data-room=casinopolis]{--bg:#16211c;--card:#23342b;--ink:#f6ecd8;--accent:#f2b53d;--felt-a:#2f7d5b;--felt-b:#24614a;--felt-c:#1c4d3c;--glow:242,181,61}html[data-room=casino-night]{--bg:#140f2e;--card:#241a4d;--ink:#f2ecff;--accent:#ffcc2f;--felt-a:#4a2fa8;--felt-b:#351f80;--felt-c:#241659;--glow:126,86,255}html[data-room] .cs-room{background:radial-gradient(80% 55% at 50% -8%,rgba(var(--glow),.18),transparent 62%),radial-gradient(120% 90% at 50% 120%,rgba(0,0,0,.45),transparent 55%)}html[data-room=casino-night] .cs-room:before{content:"";position:absolute;inset:0 -50% auto -50%;height:.5rem;background:repeating-linear-gradient(90deg,rgba(255,204,47,.55) 0 .5rem,transparent .5rem 2.25rem);filter:blur(1px);animation:cs-bulbs 2.4s linear infinite}@keyframes cs-bulbs{0%{transform:translateX(0)}to{transform:translateX(2.75rem)}}html[data-room] .shadow-pete{box-shadow:0 4px 0 rgba(0,0,0,.3),0 10px 26px rgba(0,0,0,.3)}html[data-room] .shadow-pete-lg{box-shadow:0 6px 0 rgba(0,0,0,.34),0 20px 40px rgba(0,0,0,.38)}html[data-room] .cs-comb svg{filter:drop-shadow(0 3px 0 rgba(0,0,0,.35))}html[data-room] .pete-felt{background:radial-gradient(120% 90% at 50% -10%,hsla(0,0%,100%,.1),transparent 60%),linear-gradient(160deg,var(--felt-a) 0,var(--felt-b) 55%,var(--felt-c) 100%)}@media (prefers-reduced-motion:reduce){html[data-room=casino-night] .cs-room:before{animation:none}}.cs-stack{display:block;width:2.75rem;height:calc(.45rem*var(--stack, 1) + .55rem);border-radius:999px;background:radial-gradient(circle at 50% 30%,hsla(0,0%,100%,.3),transparent 60%),var(--chip,#e07a5f);border:2px solid rgba(0,0,0,.25);box-shadow:0 3px 0 rgba(0,0,0,.3),inset 0 -.4rem 0 rgba(0,0,0,.16),inset 0 .35rem 0 hsla(0,0%,100%,.14)}.cs-stack[data-chip="5"]{--chip:#5aa9e6}.cs-stack[data-chip="25"]{--chip:#4caf7d}.cs-stack[data-chip="100"]{--chip:#2b2118}.cs-stack[data-chip="500"]{--chip:#b079d6}.last\:border-0:last-child{border-width:0}.empty\:hidden:empty{display:none}.hover\:-translate-y-0\.5:hover{--tw-translate-y:-0.125rem}.hover\:-translate-y-0\.5:hover,.hover\:-translate-y-1:hover{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:-translate-y-1:hover{--tw-translate-y:-0.25rem}.hover\:text-\[color\:var\(--ink\)\]:hover{color:var(--ink)}.hover\:brightness-105:hover{--tw-brightness:brightness(1.05);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.hover\:shadow-pete-lg:hover{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.focus\:border-\[color\:var\(--accent\)\]:focus{border-color:var(--accent)}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.active\:translate-y-px:active{--tw-translate-y:1px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:-rotate-6{--tw-rotate:-6deg}.group:hover .group-hover\:-rotate-6,.group:hover .group-hover\:scale-105{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:scale-105{--tw-scale-x:1.05;--tw-scale-y:1.05}.group:hover .group-hover\:underline{text-decoration-line:underline}@media (min-width:640px){.sm\:ml-auto{margin-left:auto}.sm\:block{display:block}.sm\:inline{display:inline}.sm\:flex{display:flex}.sm\:inline-flex{display:inline-flex}.sm\:h-56{height:14rem}.sm\:basis-auto{flex-basis:auto}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:gap-2{gap:.5rem}.sm\:gap-3{gap:.75rem}.sm\:gap-4{gap:1rem}.sm\:space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.sm\:p-10{padding:2.5rem}.sm\:p-6{padding:1.5rem}.sm\:p-8{padding:2rem}.sm\:pr-28{padding-right:7rem}.sm\:pt-10{padding-top:2.5rem}.sm\:pt-12{padding-top:3rem}.sm\:text-2xl{font-size:1.5rem;line-height:2rem}.sm\:text-3xl{font-size:1.875rem;line-height:2.25rem}.sm\:text-4xl{font-size:2.25rem;line-height:2.5rem}.sm\:text-5xl{font-size:3rem;line-height:1}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-\[auto\2c 1fr\]{grid-template-columns:auto 1fr}.lg\:grid-cols-\[minmax\(0\2c 1fr\)\2c auto\]{grid-template-columns:minmax(0,1fr) auto}.lg\:items-start{align-items:flex-start}.lg\:gap-8{gap:2rem}.lg\:p-8{padding:2rem}}@media (prefers-color-scheme:dark){.dark\:text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dark\:text-emerald-300{--tw-text-opacity:1;color:rgb(110 231 183/var(--tw-text-opacity,1))}.dark\:text-red-300{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.dark\:text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}}
\ No newline at end of file
diff --git a/internal/web/static/js/blackjack.js b/internal/web/static/js/blackjack.js
index 305514e..ba86c45 100644
--- a/internal/web/static/js/blackjack.js
+++ b/internal/web/static/js/blackjack.js
@@ -44,11 +44,12 @@
var spotTotalEl = root.querySelector("[data-spot-total]");
var houseEl = root.querySelector("[data-house]");
- // Nothing is bet until a chip is on the felt. The number in the panel is a
- // readout of the pile, so it starts where the pile does — at nothing — rather
- // than at a default stake nobody put down.
+ // The spot owns the chips on the felt and the number under them — see PeteFX.
+ // Nothing is bet until a chip is on it, so `bet` starts at nothing rather than
+ // at a default stake nobody put down.
+ var spot = FX.spot({ spot: spotEl, stack: stackEl, total: spotTotalEl });
+
var bet = 0; // what you're building between hands
- var staked = 0; // what is actually sitting on the spot right now
var busy = false; // a request is in flight, or cards are still landing
var hand = null; // the hand as the server last described it
@@ -68,194 +69,23 @@
}
// ---- drawing --------------------------------------------------------------
-
- var dealt = 0; // how many cards this table has put down, ever — the tilt seed
-
- // 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");
- // Where it comes to rest. A degree or two either way is the whole difference
- // between cards that were dealt onto a table and cards that were laid out in
- // a grid, and it costs one custom property.
- wrap.style.setProperty("--tilt", FX.jitter(dealt++, 2.4).toFixed(2) + "deg");
-
- 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;
- }
-
- // ---- the face ------------------------------------------------------------
//
- // A card is drawn, not typed. The first attempt set the pips as text — "♠" in a
- // span — and at the size a card actually is, a suit character renders as a
- // speck: the shape is whatever font happened to answer, it doesn't scale, and
- // it can't be positioned to the half-row a real pip layout needs.
- //
- // So each face is one SVG on a 100×140 field (the proportions of a real card),
- // with the suits as vector shapes. Everything below is coordinates on that
- // field, which is why the pips land where a printed deck puts them instead of
- // where a flexbox felt like putting them.
+ // The deck itself — the faces, the pips, the flip — is PeteCards, shared with
+ // every other table in the room. Here a card is always dealt out of the shoe
+ // and always lands with a degree or two of tilt on it, which are this table's
+ // two opinions about a card and the only ones it has.
- var SUIT_ART = {
- "♠": '
Buy the deck, win it back a card at a time.
++ Vegas rules. Your stake buys the deck and doesn't come back — every card you + get home pays a slice of it in. Cash the board whenever you like. +
+ + {{range .Soon}}You buy the deck. Every card you get home buys some of it back.
++ Click a card, then where it goes. Double-click sends it home. +
+ + ++ The stake buys the deck outright, so it doesn't come back. What comes back is + whatever you get home, a fifty-second of the multiple at a time. Stop whenever + you like and keep it. There's no undo. +
+ +