diff --git a/cmd/holdem-train/main.go b/cmd/holdem-train/main.go new file mode 100644 index 0000000..8ff77ac --- /dev/null +++ b/cmd/holdem-train/main.go @@ -0,0 +1,89 @@ +// Command holdem-train trains the casino's poker bots and writes the policy the +// table embeds. +// +// go run ./cmd/holdem-train -iterations 5000000 -out internal/games/holdem/policy.gob +// +// It is not part of Pete. It runs when the engine's rules change, takes half an +// hour, and produces a file. Nothing at runtime imports it. +// +// The bots are trained heads-up, at every stack depth the table deals — that +// range is the point, because poker at twenty big blinds and poker at a hundred +// are different games and a bot that only knows one of them folds into the other. +// A six-handed table then reuses the same policy, which is an approximation, and +// a documented one. +package main + +import ( + "flag" + "fmt" + "log" + "os" + "runtime" + "time" + + "pete/internal/games/holdem" +) + +func main() { + iterations := flag.Int("iterations", 5_000_000, "hands to train on") + workers := flag.Int("workers", runtime.NumCPU(), "parallel workers") + out := flag.String("out", "internal/games/holdem/policy.gob", "where to write the policy") + stakes := flag.String("stakes", "low", "which table's blinds to train at") + minBB := flag.Int64("min-bb", 20, "shallowest stack, in big blinds") + maxBB := flag.Int64("max-bb", 100, "deepest stack, in big blinds") + seed := flag.Uint64("seed", 20260714, "seed, so a run can be repeated") + flag.Parse() + + tier, err := holdem.TierBySlug(*stakes) + if err != nil { + log.Fatalf("no such table %q", *stakes) + } + + fmt.Printf("training %s hands on %d workers — %s blinds, %d–%d BB deep\n", + commas(*iterations), *workers, tier.Name, *minBB, *maxBB) + + started := time.Now() + last := started + policy := holdem.Train(*iterations, *workers, tier, *minBB, *maxBB, *seed, func(done int) { + if time.Since(last) < 20*time.Second { + return + } + last = time.Now() + frac := float64(done) / float64(*iterations) + if frac <= 0 { + return + } + left := time.Duration(float64(time.Since(started)) * (1 - frac) / frac) + fmt.Printf(" %s / %s hands (%.0f%%), about %s to go\n", + commas(done), commas(*iterations), frac*100, left.Round(time.Second)) + }) + + f, err := os.Create(*out) + if err != nil { + log.Fatal(err) + } + defer f.Close() + if err := holdem.Save(f, policy); err != nil { + log.Fatal(err) + } + + info, _ := f.Stat() + fmt.Printf("\ndone in %s: %s nodes, %.1f MB → %s\n", + time.Since(started).Round(time.Second), commas(policy.Meta.Nodes), + float64(info.Size())/(1<<20), *out) +} + +func commas(n int) string { + s := fmt.Sprint(n) + if len(s) <= 3 { + return s + } + var out []byte + for i, c := range []byte(s) { + if i > 0 && (len(s)-i)%3 == 0 { + out = append(out, ',') + } + out = append(out, c) + } + return string(out) +} diff --git a/go.mod b/go.mod index 9c2151b..4166aa2 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( require ( filippo.io/edwards25519 v1.2.0 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect + github.com/chehsunliu/poker v0.1.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/golang-jwt/jwt/v5 v5.2.1 // indirect diff --git a/go.sum b/go.sum index a287e45..72b8029 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1 github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/chehsunliu/poker v0.1.0 h1:OeB4O+QROhA/DiXUhBBlkgbzCx0ZVWMpWgKNu+PX9vI= +github.com/chehsunliu/poker v0.1.0/go.mod h1:V6K4yyDbafp0k6lUnYbwoTS/KsHSB1EWiJdEk54uB1w= github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I= github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -31,6 +33,7 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/loganjspears/joker v0.0.0-20180219043703-3f2f69a75914/go.mod h1:76SAnflG7ZFhgtnaVCpP6A5Z1S/VMFzRBN7KGm5j4oc= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= @@ -48,6 +51,7 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/notnil/joker v0.0.0-20180219043703-3f2f69a75914/go.mod h1:L0Sdr2nYdktjerdXpIn9wOCn+GebPs/nCL2qH6RTGa0= github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM= github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -58,6 +62,7 @@ github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -157,6 +162,8 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= maunium.net/go/mautrix v0.28.0 h1:vBakLzf8MAdfED3NzAKiMeKQbc3AQ4EAS03NC+TVMXQ= diff --git a/internal/games/holdem/betting.go b/internal/games/holdem/betting.go new file mode 100644 index 0000000..7390941 --- /dev/null +++ b/internal/games/holdem/betting.go @@ -0,0 +1,279 @@ +package holdem + +import "sort" + +// The betting rules. These are the fiddly ones — min-raise, short all-ins that +// don't reopen the action, side pots — and they came over from gogobee, where +// they had no tests at all. They have some now. + +// blinds posts the small and the big. Heads-up is the exception every poker +// implementation gets wrong once: with two players the button *is* the small +// blind and acts first before the flop, and last after it. +func (s *State) blinds(evs *[]Event) (bb int) { + var sb int + if s.dealt() == 2 { + sb, bb = s.Button, s.nextIn(s.Button) + } else { + sb = s.nextIn(s.Button) + bb = s.nextIn(sb) + } + + s.post(sb, s.Tier.SB, "small", evs) + s.post(bb, s.Tier.BB, "big", evs) + + s.Bet = s.Tier.BB + s.MinRaise = s.Tier.BB + s.Aggressor = bb // the big blind has the option to raise their own blind + return bb +} + +// post puts a blind up. A player too short to cover it is all-in for what they +// have, which is legal and is why the amount is clamped rather than refused. +func (s *State) post(seat int, amount int64, which string, evs *[]Event) { + p := &s.Seats[seat] + if amount > p.Stack { + amount = p.Stack + } + p.Stack -= amount + p.Bet = amount + p.Total = amount + if p.Stack == 0 { + p.State = AllIn + } + *evs = append(*evs, Event{Kind: "blind", Seat: seat, Amount: amount, Text: which}) +} + +// firstPreFlop is under the gun: the seat after the big blind, or the button +// itself when the table is heads-up. +// +// The button only gets it if the button can still act. A short stack can be +// all-in on its own blind — post a small blind of 1 with 1 chip left and you are +// in the hand with no chips and no say — and handing the action to a seat that +// cannot act wedges the table. +func (s *State) firstPreFlop(bb int) int { + if s.dealt() == 2 && s.Seats[s.Button].State == Active { + return s.Button + } + if s.dealt() == 2 { + return s.nextCanAct(s.Button) + } + return s.nextCanAct(bb) +} + +// firstPostFlop is the first seat left of the button, on every street after the +// flop. The button acts last from here on, which is the whole point of it. +func (s *State) firstPostFlop() int { return s.nextCanAct(s.Button) } + +// ---- the five things a seat can do ---------------------------------------- + +func (s *State) fold(seat int, evs *[]Event) { + p := &s.Seats[seat] + p.State = Folded + p.Acted = true + s.History += "f" + *evs = append(*evs, Event{Kind: "action", Seat: seat, Text: "fold"}) +} + +func (s *State) check(seat int, evs *[]Event) error { + p := &s.Seats[seat] + if p.Bet < s.Bet { + return ErrCantCheck + } + p.Acted = true + s.History += "c" + *evs = append(*evs, Event{Kind: "action", Seat: seat, Text: "check"}) + return nil +} + +func (s *State) call(seat int, evs *[]Event) error { + p := &s.Seats[seat] + owed := s.Bet - p.Bet + if owed <= 0 { + return ErrNothingToCall + } + if owed > p.Stack { + owed = p.Stack // a call for less than the bet is a call all-in + } + + p.Stack -= owed + p.Bet += owed + p.Total += owed + p.Acted = true + + text := "call" + if p.Stack == 0 { + p.State = AllIn + text = "allin" + s.History += "a" + } else { + s.History += "c" + } + *evs = append(*evs, Event{Kind: "action", Seat: seat, Text: text, Amount: owed, Total: p.Bet}) + return nil +} + +// raise raises *to* a total, not *by* an amount. Every poker interface in the +// world means the total, and a browser that means the other thing bets wrong. +func (s *State) raise(seat int, to int64, evs *[]Event) error { + p := &s.Seats[seat] + most := p.Bet + p.Stack + + if to > most { + return ErrTooBig + } + if to < s.Bet+s.MinRaise && to < most { + return ErrTooSmall // only a shove may be smaller than a legal raise + } + + added := to - p.Bet + over := to - s.Bet + + p.Stack -= added + p.Bet = to + p.Total += added + p.Acted = true + + if over > 0 { + s.MinRaise = over + } + s.Bet = to + s.Aggressor = seat + + text := "raise" + if p.Stack == 0 { + p.State = AllIn + text = "allin" + s.History += "a" + } else { + // The policy was trained against a tree with two raise sizes in it, so the + // history it reads has to say which one this was: R for a pot-sized raise + // or bigger, r for anything smaller. + if pot := s.inPlay(); pot > 0 && float64(over) >= float64(pot)*0.75 { + s.History += "R" + } else { + s.History += "r" + } + } + *evs = append(*evs, Event{Kind: "action", Seat: seat, Text: text, Amount: added, Total: to}) + return nil +} + +// allin pushes the lot. +// +// A short all-in does not reopen the betting. If a player shoves for less than +// a full raise over the current bet, players who have already acted may call it +// but may not raise again — otherwise a tiny stack could be used to reopen the +// action for a partner, which is the oldest collusion trick there is. +func (s *State) allin(seat int, evs *[]Event) error { + p := &s.Seats[seat] + if p.Stack <= 0 { + return ErrNoChips + } + added := p.Stack + to := p.Bet + added + + p.Stack = 0 + p.Bet = to + p.Total += added + p.State = AllIn + p.Acted = true + + if to > s.Bet { + if over := to - s.Bet; over >= s.MinRaise { + s.MinRaise = over + s.Aggressor = seat + } + s.Bet = to + } + + s.History += "a" + *evs = append(*evs, Event{Kind: "action", Seat: seat, Text: "allin", Amount: added, Total: to}) + return nil +} + +// ---- when is a street over ------------------------------------------------ + +// streetDone reports whether the betting round is finished, given the seat the +// action would pass to next. +// +// The "has acted" check is the load-bearing half. The big blind has money in +// front of them without having chosen to put it there, so a round where +// everybody merely limps in has all bets matched while the blind has never had +// a say. Without this, they never get their option. +func (s *State) streetDone(next int) bool { + if s.canActCount() == 0 { + return true + } + for i := range s.Seats { + p := &s.Seats[i] + if p.State != Active { + continue + } + if p.Bet != s.Bet || !p.Acted { + return false + } + } + // The last aggressor being all-in means the action can't get back to them: + // everyone left has matched the bet above, so there is nothing more to do. + if s.Seats[s.Aggressor].State == AllIn { + return true + } + return next == s.Aggressor +} + +// ---- side pots ------------------------------------------------------------- + +// sidePots slices the pot into layers, one per distinct all-in level. A player +// can only win the part of the pot they could have lost, so each layer is +// contested by exactly the players who paid into it. +// +// Folded players' chips stay in the pot — they paid for the right to fold — but +// they are eligible for nothing. +func (s *State) sidePots() { + s.collect() + + var levels []int64 + for i := range s.Seats { + p := &s.Seats[i] + if p.State == Folded || p.State == Out || p.Total == 0 { + continue + } + levels = append(levels, p.Total) + } + if len(levels) == 0 { + return + } + sort.Slice(levels, func(i, j int) bool { return levels[i] < levels[j] }) + + var pots []Pot + var prev int64 + for _, level := range levels { + if level <= prev { + continue + } + var amount int64 + var eligible []int + for i := range s.Seats { + p := &s.Seats[i] + paid := p.Total - prev + if paid > level-prev { + paid = level - prev + } + if paid > 0 { + amount += paid // folded money counts toward the pot... + } + if p.State != Folded && p.State != Out && p.Total >= level { + eligible = append(eligible, i) // ...but wins no part of it + } + } + if amount > 0 { + pots = append(pots, Pot{Amount: amount, Eligible: eligible}) + } + prev = level + } + + if len(pots) > 0 { + s.Side = pots + s.Pot = 0 + } +} diff --git a/internal/games/holdem/cfr.go b/internal/games/holdem/cfr.go new file mode 100644 index 0000000..471d574 --- /dev/null +++ b/internal/games/holdem/cfr.go @@ -0,0 +1,416 @@ +package holdem + +import ( + _ "embed" + "fmt" + "log/slog" + "math/rand/v2" + "sync" + "sync/atomic" + + "pete/internal/games/cards" +) + +// The bots' brain. +// +// gogobee ran counterfactual regret minimisation against this game for a very +// long time, and policy.gob is what it converged on: a table from "the situation +// I am in" to "how often I fold, call, raise small, raise big, or shove". It is +// the single highest-value thing in either repository, and none of it is +// re-derived here — this file is the *runtime*, the trainer stayed behind. +// +// A situation is squeezed down to six things, and this is the whole reason the +// table fits in memory: the street, whether the bot is in position, which of +// twelve equity buckets its hand falls in, which of five stack-to-pot buckets, +// whether the board is dry, wet or paired, and the last six actions. Two hands +// that hash to the same key get the same strategy, and that is the approximation +// the whole thing is built on. +// +// The key has to be *exactly* the key the trainer wrote, character for +// character. Change the bucket edges, the position label or the history encoding +// and every lookup misses — silently, because a miss is not an error, it is a +// fall back to the pot-odds rule below. The bots would get quietly, unaccountably +// worse. So: don't touch these numbers. + +//go:embed policy.gob +var policyGob []byte + +// The five things the trainer let a bot consider. +const ( + actFold = iota + actCallCheck + actRaiseHalf + actRaisePot + actAllIn + numActions +) + +// policyTable maps an info-set key to how often to take each action. +type policyTable map[string][numActions]float64 + +var ( + policyOnce sync.Once + policy policyTable +) + +// loadPolicy decodes the embedded table, once, on the first hand anybody plays. +// +// Not in an init(): it is megabytes of gob, and Pete is a news server that mostly +// never deals a card. Every test in the repo and every cold start would pay for +// it. The first player to sit down pays for it instead, and only they do. +func loadPolicy() policyTable { + policyOnce.Do(func() { + t, err := loadTrained(policyGob) + if err != nil { + // The bots still play — on pot odds — rather than the table 500ing. + slog.Error("holdem: cannot decode CFR policy, bots fall back to pot odds", "err", err) + policy = policyTable{} + return + } + policy = t.Strategy + slog.Info("holdem: CFR policy loaded", "nodes", len(policy), + "iterations", t.Meta.Iterations, "stakes", t.Meta.Stakes, "depths", t.Meta.Depths) + }) + return policy +} + +// ---- the info-set key ------------------------------------------------------ +// +// Every function below is a load-bearing copy of the trainer's. See the note at +// the top of the file before changing a number in any of them. + +// equityBucket puts a hand's strength in one of twelve boxes, from trash to +// monster. +func equityBucket(eq float64) int { + switch { + case eq < 0.08: + return 0 + case eq < 0.17: + return 1 + case eq < 0.25: + return 2 + case eq < 0.33: + return 3 + case eq < 0.42: + return 4 + case eq < 0.50: + return 5 + case eq < 0.58: + return 6 + case eq < 0.67: + return 7 + case eq < 0.75: + return 8 + case eq < 0.83: + return 9 + case eq < 0.92: + return 10 + default: + return 11 + } +} + +// sprBucket is the stack-to-pot ratio: how much room is left to play. Under 1 is +// a pot that is already committed; over 12 is a pot you can still fold out of. +func sprBucket(spr float64) int { + switch { + case spr < 1: + return 0 + case spr < 3: + return 1 + case spr < 6: + return 2 + case spr < 12: + return 3 + default: + return 4 + } +} + +// Board textures, which is what makes the same hand a bet or a check. +const ( + boardDry = 0 + boardWet = 1 + boardPaired = 2 +) + +// boardTexture classifies the community cards. A paired board is one somebody +// might have trips on; a wet one has a flush or straight coming. +// It is on the trainer's hot path — millions of calls — so it counts into arrays +// rather than maps. A map here cost more than the poker did. +func boardTexture(board []cards.Card) int { + if len(board) < 3 { + return boardDry + } + + var ranks [14]int8 + var suits [4]int8 + var vals [5]int + for i, c := range board { + ranks[c.Rank]++ + suits[c.Suit]++ + vals[i] = int(c.Rank) + } + for _, n := range ranks { + if n >= 2 { + return boardPaired + } + } + for _, n := range suits { + if n >= 3 { + return boardWet + } + } + + // Three cards inside a five-rank window is a straight waiting to happen. + v := vals[:len(board)] + for i := 1; i < len(v); i++ { + for j := i; j > 0 && v[j] < v[j-1]; j-- { + v[j], v[j-1] = v[j-1], v[j] + } + } + for i := 0; i+2 < len(v); i++ { + if v[i+2]-v[i] <= 4 { + return boardWet + } + } + return boardDry +} + +// infoSet builds the string the policy is keyed on. The format is the trainer's. +// +// Position is IP or OOP — in position or out of it — and *nothing else*. This is +// the one thing gogobee got wrong, and it got it wrong invisibly for as long as +// the game has existed: the trainer packed a single "am I last to act" bit and +// wrote its keys as IP/OOP, while the runtime looked them up with the table +// labels a player would recognise (BTN, SB, BB, UTG…). Not one key ever matched. +// Every bot in every hand of hold'em gogobee ever dealt fell through to the +// pot-odds rule, and the five million training iterations sitting in policy.gob +// were never once read. +// +// Nothing about that looks broken from the outside. A missing key is not an +// error, it's a fallback — the bots played, they just played a heuristic. This is +// why the hit rate is now a test. +func infoSet(street Street, inPosition bool, eq, spr, texture int, history string) string { + pos := "OOP" + if inPosition { + pos = "IP" + } + return fmt.Sprintf("%d|%s|%d|%d|%d|%s", street, pos, eq, spr, texture, history) +} + +// recent keeps the last six actions, which is all the trainer's key had room for. +func recent(h string) string { + if len(h) > 6 { + return h[len(h)-6:] + } + return h +} + +// ---- where a seat stands --------------------------------------------------- + +// mcIters is how many runouts a bot samples to judge its hand at the table. +const mcIters = 1000 + +// spot is everything the policy knows about a seat's situation, and it is the +// one function that builds it. +// +// The trainer calls this too. That is the point of it: the key the policy is +// written under and the key it is read under come out of the same code, so they +// cannot quietly stop matching — which is exactly what went wrong the first time +// and went unnoticed for the life of the game. +func (s State) spot(seat, iters int, rng *rand.Rand) (string, Equity) { + eq := s.equityFor(seat, iters, rng) + return s.spotKey(seat, eq), eq +} + +// equityFor measures how good a seat's hand is right now. +// +// It depends only on the cards — the hand, the board, how many opponents — and +// not on a single thing that happened in the betting. Which is why the trainer +// can measure it once per street and reuse it down every branch it explores, and +// why doing that is most of the difference between a run that takes half an hour +// and one that takes four. +func (s State) equityFor(seat, iters int, rng *rand.Rand) Equity { + opponents := s.liveCount() - 1 + if opponents < 1 { + opponents = 1 + } + // Preflop heads-up is a lookup, not a simulation: there are only 169 hands + // that differ, they have been measured to death, and a sampled answer would + // only add noise to a bucket boundary. + if s.Street == PreFlop && opponents == 1 { + return preflopEquity(s.Seats[seat].Hole) + } + return equityOf(s.Seats[seat].Hole, s.Community, opponents, iters, rng) +} + +// spotKey builds the key from an equity already measured. +func (s State) spotKey(seat int, eq Equity) string { + pot := s.inPlay() + spr := 0.0 + if pot > 0 { + spr = float64(s.Seats[seat].Stack) / float64(pot) + } + return infoSet(s.Street, s.InPosition(seat), equityBucket(eq.Strength()), + sprBucket(spr), boardTexture(s.Community), recent(s.History)) +} + +// ---- choosing -------------------------------------------------------------- + +// Hits and Misses count how often a bot finds itself in the trained policy. +// +// They exist because the way this can break is silently. A key the policy has +// never seen is not an error — the bot shrugs and plays pot odds — so a policy +// that has stopped matching the game looks exactly like a policy that is working. +// gogobee's never matched once, for the whole life of the game, and nobody could +// have known by watching it play. Now a test reads these and fails. +var Hits, Misses atomic.Int64 + +// botActs plays one bot's turn: work out where it stands, look up what it does +// there, throw out anything illegal, and roll for it. +func (s *State) botActs(seat int, evs *[]Event, rng *rand.Rand) { + key, eq := s.spot(seat, mcIters, rng) + + probs, ok := loadPolicy()[key] + if ok { + Hits.Add(1) + } else { + Misses.Add(1) + probs = potOdds(eq, s, seat) + } + probs = legal(probs, s, seat) + + move := s.moveFor(pick(probs, rng), seat) + if err := s.act(seat, move, evs); err != nil { + // A bot cannot be allowed to wedge the table by choosing something the rules + // then refuse: it checks if it can and folds if it can't, and the mismatch + // is loud, because it means legal() and the betting rules disagree. + slog.Error("holdem: bot chose an illegal move", "seat", seat, "move", move.Kind, "err", err) + if s.Owed(seat) > 0 { + s.fold(seat, evs) + } else { + _ = s.check(seat, evs) + } + } +} + +// potOdds is what a bot does when the trained table has never seen this spot: it +// works out whether the price it is being offered beats its chance of winning, +// and mixes in enough aggression not to be a calling station. +func potOdds(eq Equity, s *State, seat int) [numActions]float64 { + p := &s.Seats[seat] + strength := eq.Strength() + owed := s.Bet - p.Bet + pot := s.inPlay() + + price := 0.0 + if owed > 0 && pot+owed > 0 { + price = float64(owed) / float64(pot+owed) + } + + var probs [numActions]float64 + switch { + case strength > 0.8: + probs[actRaisePot], probs[actAllIn], probs[actCallCheck] = 0.6, 0.2, 0.2 + case strength > 0.6: + probs[actRaiseHalf], probs[actCallCheck], probs[actFold] = 0.4, 0.5, 0.1 + case owed > 0 && strength > price: + probs[actCallCheck], probs[actRaiseHalf], probs[actFold] = 0.7, 0.2, 0.1 + case owed > 0: + probs[actFold], probs[actCallCheck] = 0.7, 0.3 + default: + probs[actCallCheck], probs[actRaiseHalf], probs[actFold] = 0.6, 0.3, 0.1 + } + return probs +} + +// mask is what a seat may actually do here. The trainer explores exactly this +// set, so it never learns a strategy the table would turn down. +func (s State) mask(seat int) (m [numActions]bool) { + owed := s.Bet - s.Seats[seat].Bet + + // Folding a hand you could see for free is a bug, not a strategy. + m[actFold] = owed > 0 + m[actCallCheck] = true + + // A raise needs chips behind the call, and somebody left to bet into. + raise := s.Seats[seat].Stack > owed && s.canBet() + m[actRaiseHalf], m[actRaisePot], m[actAllIn] = raise, raise, raise + return m +} + +// legal zeroes out what the seat cannot do and renormalises what's left. +func legal(probs [numActions]float64, s *State, seat int) [numActions]float64 { + m := s.mask(seat) + var total float64 + for i := range probs { + if !m[i] { + probs[i] = 0 + } + total += probs[i] + } + if total <= 0 { + var only [numActions]float64 + only[actCallCheck] = 1 + return only + } + for i := range probs { + probs[i] /= total + } + return probs +} + +// pick rolls against the distribution. +func pick(probs [numActions]float64, rng *rand.Rand) int { + r := rng.Float64() + sum := 0.0 + for i, p := range probs { + sum += p + if r < sum { + return i + } + } + return actCallCheck +} + +// moveFor turns an abstract action — "raise half the pot" — into a legal move at +// the actual size the table is at. A raise that would cost the bot everything it +// has is a shove, which is the same decision made honestly. +func (s *State) moveFor(action, seat int) Move { + p := &s.Seats[seat] + owed := s.Bet - p.Bet + pot := s.inPlay() + most := p.Bet + p.Stack + + sized := func(by int64) Move { + if by < s.MinRaise { + by = s.MinRaise + } + to := s.Bet + by + if to >= most { + return Move{Kind: Shove} + } + return Move{Kind: Raise, To: to} + } + + switch action { + case actFold: + if owed <= 0 { + return Move{Kind: Check} // never fold for free + } + return Move{Kind: Fold} + case actCallCheck: + if owed > 0 { + return Move{Kind: Call} + } + return Move{Kind: Check} + case actRaiseHalf: + return sized(pot / 2) + case actRaisePot: + return sized(pot) + case actAllIn: + return Move{Kind: Shove} + } + return Move{Kind: Check} +} diff --git a/internal/games/holdem/equity.go b/internal/games/holdem/equity.go new file mode 100644 index 0000000..76190e8 --- /dev/null +++ b/internal/games/holdem/equity.go @@ -0,0 +1,134 @@ +package holdem + +import ( + "math/rand/v2" + + "github.com/chehsunliu/poker" + + "pete/internal/games/cards" +) + +// How good is this hand, really? +// +// A bot's decision starts here: deal the cards it cannot see, a thousand times, +// and count how often it wins. That number — plus the board's texture, the +// stack-to-pot ratio and the action so far — is the key it looks its trained +// strategy up under. +// +// Monte Carlo rather than exhaustive because exhaustive is 2.1 million river +// runouts against one opponent and rather more against five, and a thousand +// samples puts the estimate inside a percentage point or so. The bot does not +// need the fourth decimal place; it needs to know whether it is ahead. + +// Equity is the fraction of runouts a hand wins, ties and loses against the +// given number of unknown opponents. +type Equity struct { + Win float64 + Tie float64 + Loss float64 +} + +// Strength collapses a result into the one number the policy is keyed on: a tie +// is worth half a win, because that is what half a pot is. +func (e Equity) Strength() float64 { return e.Win + e.Tie*0.5 } + +// deck52 is the evaluator's whole deck, built once. +var deck52 = func() []poker.Card { + d := make([]poker.Card, 0, 52) + for s := cards.Spades; s <= cards.Clubs; s++ { + for r := cards.Ace; r <= cards.King; r++ { + d = append(d, pokerOf[s][r]) + } + } + return d +}() + +// equityOf runs the simulation. The RNG is threaded like everything else here, +// so a bot's decision replays from the session's seed along with the deal. +func equityOf(hole [2]cards.Card, board []cards.Card, opponents, iterations int, rng *rand.Rand) Equity { + if opponents < 1 { + opponents = 1 + } + + h0, h1 := toPoker(hole[0]), toPoker(hole[1]) + + // Seven cards, checked by hand. A map here would be the most expensive thing + // in the trainer, which calls this function millions of times. + var known [7]poker.Card + n := 2 + known[0], known[1] = h0, h1 + pb := make([]poker.Card, len(board)) + for i, c := range board { + pb[i] = toPoker(c) + known[n] = pb[i] + n++ + } + + rest := make([]poker.Card, 0, 52) + for _, c := range deck52 { + seen := false + for _, k := range known[:n] { + if k == c { + seen = true + break + } + } + if !seen { + rest = append(rest, c) + } + } + + need := opponents*2 + (5 - len(pb)) + if need > len(rest) { + return Equity{Tie: 1} + } + + hero := make([]poker.Card, 7) + hero[0], hero[1] = h0, h1 + villain := make([]poker.Card, 7) + full := make([]poker.Card, 5) + + var wins, ties int + for i := 0; i < iterations; i++ { + // A partial Fisher-Yates: only the cards actually needed get shuffled into + // place, which is the difference between this being cheap and being the + // slowest thing in the request. + for j := 0; j < need; j++ { + k := j + rng.IntN(len(rest)-j) + rest[j], rest[k] = rest[k], rest[j] + } + + copy(full, pb) + at := opponents * 2 + for b := len(pb); b < 5; b++ { + full[b] = rest[at] + at++ + } + + copy(hero[2:], full) + mine := poker.Evaluate(hero) + + best := int32(7463) // one worse than the worst real hand + for o := 0; o < opponents; o++ { + villain[0], villain[1] = rest[o*2], rest[o*2+1] + copy(villain[2:], full) + if r := poker.Evaluate(villain); r < best { + best = r + } + } + + switch { + case mine < best: + wins++ + case mine == best: + ties++ + } + } + + total := float64(iterations) + return Equity{ + Win: float64(wins) / total, + Tie: float64(ties) / total, + Loss: float64(iterations-wins-ties) / total, + } +} diff --git a/internal/games/holdem/eval.go b/internal/games/holdem/eval.go new file mode 100644 index 0000000..3ef0e56 --- /dev/null +++ b/internal/games/holdem/eval.go @@ -0,0 +1,263 @@ +package holdem + +import ( + "math" + "sort" + "strings" + + "github.com/chehsunliu/poker" + + "pete/internal/games/cards" +) + +// The bridge to the evaluator. +// +// The engine deals Pete's own cards.Card — the same one blackjack, solitaire and +// the felt already speak — and converts at the door. Hand strength is the one +// thing in this package that is genuinely hard to get right (7-card best-of-5, +// 7,462 distinct hands), so it is not homegrown: github.com/chehsunliu/poker is +// a lookup table and it is correct. +// +// The conversion is a table built once. Doing it per evaluation would matter: +// a bot's equity estimate is a thousand seven-card evaluations, and it makes +// several of those per hand. + +var ( + pokerRanks = [14]string{"", "A", "2", "3", "4", "5", "6", "7", "8", "9", "T", "J", "Q", "K"} + pokerSuits = [4]string{"s", "h", "d", "c"} // cards.Spades, Hearts, Diamonds, Clubs + + // A var initializer, not an init(). Go builds package-level variables before + // it runs init functions, so anything else in this package that is itself a + // var built out of this table — equity.go's deck52 is — would otherwise be + // built out of an empty one. It was, briefly: every card came out identical, + // every showdown tied, and every bot believed it held exactly 50% equity. + pokerOf = func() (t [4][14]poker.Card) { + for s := cards.Spades; s <= cards.Clubs; s++ { + for r := cards.Ace; r <= cards.King; r++ { + t[s][r] = poker.NewCard(pokerRanks[r] + pokerSuits[s]) + } + } + return t + }() +) + +// toPoker converts one card for the evaluator. +func toPoker(c cards.Card) poker.Card { return pokerOf[c.Suit][c.Rank] } + +func toPokerAll(cs []cards.Card) []poker.Card { + out := make([]poker.Card, len(cs)) + for i, c := range cs { + out[i] = toPoker(c) + } + return out +} + +// rankOf evaluates a seat's best five from its hole cards and the board. Lower +// is better — 1 is a royal flush — which is the evaluator's convention and not +// worth inverting, since nothing outside this file ever sees the number. +func rankOf(hole [2]cards.Card, board []cards.Card) (int32, string) { + seven := make([]poker.Card, 0, 7) + seven = append(seven, toPoker(hole[0]), toPoker(hole[1])) + seven = append(seven, toPokerAll(board)...) + r := poker.Evaluate(seven) + return r, strings.ToLower(poker.RankString(r)) +} + +// ---- showdown ------------------------------------------------------------- + +type ranked struct { + seat int + rank int32 + desc string +} + +// showdown turns the cards over, splits the pots, and pays. Every player still +// in the hand shows, in the order the felt should turn them over: best hand +// first, so the winner is the first card face the player sees. +func (s *State) showdown(evs *[]Event) { + s.collect() + s.Street = Showdown + + // Say so. The last street's bets are still sitting in front of the seats that + // made them, as far as the felt knows, and nothing else in the script is going + // to tell it they have been swept in. + *evs = append(*evs, Event{Kind: "pot", Seat: -1, Amount: s.Total()}) + + var live []ranked + for i := range s.Seats { + p := &s.Seats[i] + if p.State == Folded || p.State == Out { + continue + } + r, desc := rankOf(p.Hole, s.Community) + live = append(live, ranked{seat: i, rank: r, desc: desc}) + } + sort.Slice(live, func(i, j int) bool { return live[i].rank < live[j].rank }) + + for _, e := range live { + *evs = append(*evs, Event{ + Kind: "show", Seat: e.seat, + Cards: []cards.Card{s.Seats[e.seat].Hole[0], s.Seats[e.seat].Hole[1]}, + Text: e.desc, + }) + } + + pots := s.Side + if len(pots) == 0 { + all := make([]int, 0, len(live)) + for _, e := range live { + all = append(all, e.seat) + } + pots = []Pot{{Amount: s.Pot, Eligible: all}} + s.Pot = 0 + } + s.Side = nil + + for _, pot := range pots { + s.payPot(pot, live, evs) + } + s.endHand(evs) +} + +// payPot rakes a pot and splits it between the best eligible hands. +// +// The rake comes out of the pot before it is split, which is what a cardroom +// does and is also the only thing consistent with the rest of this casino: a +// player pays it out of a pot they *win*, never out of a bet they lose. A hand +// that dies before the flop is not raked at all — no flop, no drop — so folding +// your blind round after round costs you exactly the blinds and no fee. +func (s *State) payPot(pot Pot, live []ranked, evs *[]Event) { + if pot.Amount <= 0 { + return + } + + amount := pot.Amount + if s.Flopped { + rake := int64(math.Floor(float64(amount) * s.Tier.RakePct)) + if cap := s.Tier.BB * rakeCapBB; rake > cap { + rake = cap + } + if rake > 0 { + amount -= rake + s.Rake += rake + *evs = append(*evs, Event{Kind: "rake", Seat: -1, Amount: rake}) + } + } + + eligible := make(map[int]bool, len(pot.Eligible)) + for _, seat := range pot.Eligible { + eligible[seat] = true + } + + var winners []ranked + best := int32(0) + for _, e := range live { + if !eligible[e.seat] { + continue + } + if len(winners) == 0 || e.rank < best { + best, winners = e.rank, []ranked{e} + } else if e.rank == best { + winners = append(winners, e) + } + } + if len(winners) == 0 { + return + } + + share := amount / int64(len(winners)) + odd := amount % int64(len(winners)) // the odd chip goes to the first seat left of the button + for i, w := range winners { + won := share + if i == 0 { + won += odd + } + s.Seats[w.seat].Stack += won + s.Seats[w.seat].Won += won + *evs = append(*evs, Event{Kind: "win", Seat: w.seat, Amount: won, Text: w.desc}) + } +} + +// takeit ends a hand nobody contested: everyone else folded, so the last player +// standing takes the pot without showing. Their own uncalled bet comes back +// first — it was never called, so it was never really in the pot. +func (s *State) takeit(evs *[]Event) { + s.uncalled(evs) + s.collect() + *evs = append(*evs, Event{Kind: "pot", Seat: -1, Amount: s.Total()}) + + winner := -1 + for i := range s.Seats { + if s.Seats[i].State != Folded && s.Seats[i].State != Out { + winner = i + break + } + } + if winner < 0 { + s.endHand(evs) + return + } + + // There are never side pots here: they are only cut once the betting is over + // because everybody is all-in, and a table where everybody is all-in is a table + // where nobody is left to fold. + pot := Pot{Amount: s.Pot, Eligible: []int{winner}} + s.Pot = 0 + s.payPot(pot, []ranked{{seat: winner, rank: 0}}, evs) + s.endHand(evs) +} + +// uncalled returns the unmatched top of a bet. If you shove 500 into a player +// with 200 behind, 300 of that was never contested and comes straight back. +// +// It must run *before* the bets are swept into the pot, and the matched level it +// measures against counts the players who folded. Their chips are in the pot — +// they paid to see the bet and then gave up — so the money they put in is money +// that called. Miss that and a bet folded to on the river comes back whole, +// including the part that was called on the flop, which mints chips out of air. +// +// The rake is the other reason this matters at all. When everybody folds, the +// winner takes the pot back either way and the arithmetic looks the same — but a +// pot with an uncalled bet still in it is a pot the house rakes, and it would be +// raking the player on their own money that nobody ever contested. +func (s *State) uncalled(evs *[]Event) { + top, topSeat := int64(-1), -1 + for i := range s.Seats { + p := &s.Seats[i] + if p.State == Folded || p.State == Out { + continue + } + if p.Total > top { + top, topSeat = p.Total, i + } + } + if topSeat < 0 { + return + } + + var matched int64 // the most anybody else put in, whether or not they're still in + for i := range s.Seats { + if i == topSeat || s.Seats[i].State == Out { + continue + } + if s.Seats[i].Total > matched { + matched = s.Seats[i].Total + } + } + + excess := top - matched + p := &s.Seats[topSeat] + if excess <= 0 || excess > p.Bet { + // An uncalled bet is always part of the street it was made on, so it cannot + // be bigger than what that seat has in front of them right now. + return + } + + p.Stack += excess + p.Total -= excess + p.Bet -= excess + if p.State == AllIn && p.Stack > 0 { + p.State = Active // they were never really all-in against anybody + } + *evs = append(*evs, Event{Kind: "uncalled", Seat: topSeat, Amount: excess}) +} diff --git a/internal/games/holdem/holdem.go b/internal/games/holdem/holdem.go new file mode 100644 index 0000000..89dcb10 --- /dev/null +++ b/internal/games/holdem/holdem.go @@ -0,0 +1,846 @@ +// Package holdem is a pure Texas Hold'em engine, played for chips against bots. +// +// Same seam as every other table in the casino: ApplyMove(state, move) (state, +// events, error), where an error means the move was illegal and nothing else. +// No HTTP, no timers, no sockets. The state is a plain value, so a hand survives +// a redeploy and replays from its seed. +// +// Three things make hold'em different from the five tables already on the felt. +// +// It is a cash game, not a stake. Every other game here takes a bet, plays once +// and pays a multiple. Poker isn't that: you buy chips onto the table, you play +// as many hands as you like, and you leave with whatever is in front of you. So +// the session is the unit, not the hand — the live row lives across hands, the +// buy-in is the only chip movement at the start, and the stack going home is the +// only one at the end. In between the money is entirely inside this engine. +// +// The bots move inside ApplyMove, as they do in UNO. One call plays the player's +// action and every bot action behind it, deals whatever streets that completes, +// and hands the lot back as a script of events. Poker is where you would reach +// for a socket, and this is what not reaching for one costs. +// +// The bots are the trained ones. gogobee spent a long time running CFR against +// this game and the policy it converged on is the best asset in either repo; it +// is embedded here whole (see cfr.go). What that means for the player: the house +// edge at this table is not a rule, it is an opponent. There is no 3:2 and no +// multiple. If you beat the bots you win, and the only thing the house takes is +// the rake on the pots you win. +package holdem + +import ( + "errors" + "math/rand/v2" + + "pete/internal/games/cards" +) + +// Errors an illegal move can produce. An error means nothing happened. +var ( + ErrOver = errors.New("holdem: you've left the table") + ErrNotYourTurn = errors.New("holdem: it isn't your turn") + ErrHandLive = errors.New("holdem: finish the hand first") + ErrNoHand = errors.New("holdem: no hand in progress") + ErrCantCheck = errors.New("holdem: there's a bet to you") + ErrNothingToCall = errors.New("holdem: nothing to call") + ErrTooSmall = errors.New("holdem: that's under the minimum raise") + ErrTooBig = errors.New("holdem: you don't have that many chips") + ErrNoChips = errors.New("holdem: you have no chips left") + ErrUnknownMove = errors.New("holdem: unknown move") + ErrUnknownTier = errors.New("holdem: no such table") + ErrBadBuyIn = errors.New("holdem: that isn't a legal buy-in") + ErrTableFull = errors.New("holdem: too many seats") +) + +// You are always seat zero. The bots are the seats after you. +const You = 0 + +// rakeCapBB caps the rake on any one pot at three big blinds, which is what a +// cardroom does. Without a cap, five percent of a big pot is a lot of money to +// take off a player for winning it. +const rakeCapBB = 3 + +// Street is how far the board has come. +type Street uint8 + +const ( + PreFlop Street = iota + Flop + Turn + River + Showdown +) + +var streetNames = [5]string{"preflop", "flop", "turn", "river", "showdown"} + +func (s Street) String() string { + if int(s) >= len(streetNames) { + return "?" + } + return streetNames[s] +} + +// SeatState is where a player stands in the hand being played. +type SeatState uint8 + +const ( + Active SeatState = iota // still has chips and a say + Folded // out of this hand + AllIn // in the hand, but has nothing left to bet + Out // not dealt in at all +) + +// Seat is one player at the table — you, or one of the bots. +// +// Hole is on the server and stays there. The view layer sends your two cards to +// you and sends nobody else's to anybody, right up until a showdown turns them +// over. A bot's cards are most of the information in this game; a browser that +// held them would make counting cards a matter of reading the network tab. +type Seat struct { + Name string `json:"name"` + Bot bool `json:"bot"` + Stack int64 `json:"stack"` + Hole [2]cards.Card `json:"hole"` + Bet int64 `json:"bet"` // put in on this street + Total int64 `json:"total"` // put in across this hand + Won int64 `json:"won"` // taken out of the pot this hand + State SeatState `json:"state"` + Acted bool `json:"acted"` // has chosen to do something this street +} + +// Pot is a pot and the seats that may win it. A hand with no all-in has exactly +// one; every all-in at a distinct level adds another. +type Pot struct { + Amount int64 `json:"amount"` + Eligible []int `json:"eligible"` +} + +// Tier is a table you can sit at. The dial is the stakes, and the stakes are +// what make a chip mean something: at 25/50 a careless call costs more than a +// whole session at 1/2. +// +// The buy-in range is the standard 20 to 100 big blinds. Sitting down short is +// a real strategy (fewer decisions, less to lose) and sitting down deep is the +// other one, so the range is a choice and not a formality. +type Tier struct { + Slug string `json:"slug"` + Name string `json:"name"` + SB int64 `json:"sb"` + BB int64 `json:"bb"` + MinBuy int64 `json:"min_buy"` + MaxBuy int64 `json:"max_buy"` + RakePct float64 `json:"rake_pct"` + Blurb string `json:"blurb"` +} + +// Tiers are the three tables. The rake is the casino's five percent everywhere, +// capped at three big blinds a pot, and taken only from a pot that saw a flop. +// +// RakePct is a *fraction*, 0.05, because that is what it is everywhere else in +// the casino — blackjack's DefaultRules says 0.05 and New() takes its word for it. +// It was 5 here for an afternoon, meaning percent, and since New overwrites the +// tier's value with the one it is handed, every rake worked out to five percent of +// a hundredth of the pot, which integer division rounded to nothing. The house took +// nothing at all and no test noticed, because every test set the tier up by hand. +var Tiers = []Tier{ + {Slug: "micro", Name: "The Kitchen Table", SB: 1, BB: 2, MinBuy: 40, MaxBuy: 200, RakePct: 0.05, + Blurb: "1/2 blinds. Cheap enough to learn what the bots do to you."}, + {Slug: "low", Name: "The Back Room", SB: 5, BB: 10, MinBuy: 200, MaxBuy: 1000, RakePct: 0.05, + Blurb: "5/10. A bluff here costs real chips, which is the only reason a bluff works."}, + {Slug: "high", Name: "The High Roller", SB: 25, BB: 50, MinBuy: 1000, MaxBuy: 5000, RakePct: 0.05, + Blurb: "25/50. Three streets of this and you know whether you can play."}, +} + +// TierBySlug finds a table 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 +} + +// MaxBots is five, which with you makes a six-handed table — the size most +// online poker is actually played at, and as many opponents as the felt can +// show without the cards getting too small to read. +const MaxBots = 5 + +// Phase is what the table is waiting for. +type Phase string + +const ( + PhaseBetting Phase = "betting" // a hand is live and it's your turn + PhaseHandOver Phase = "handover" // the hand is paid; deal, top up, or leave + PhaseDone Phase = "done" // you're up from the table; the stack goes home +) + +// State is the whole table. It never leaves the server: the deck is in here, +// and so is every bot's hand. +type State struct { + Tier Tier `json:"tier"` + Seats []Seat `json:"seats"` + Button int `json:"button"` + HandNo int `json:"hand_no"` + + Deck cards.Deck `json:"deck"` + Community []cards.Card `json:"community"` + Street Street `json:"street"` + Flopped bool `json:"flopped"` // this hand saw a flop, so its pot is rakeable + + Pot int64 `json:"pot"` + Side []Pot `json:"side,omitempty"` + Bet int64 `json:"bet"` // the bet to match on this street + MinRaise int64 `json:"min_raise"` // the smallest legal raise over it + Aggressor int `json:"aggressor"` + ToAct int `json:"to_act"` + + // History is the action so far on this street, as the f/c/r/R/a characters + // the CFR policy was trained to read. It is the bots' memory and nothing else. + History string `json:"history"` + + Phase Phase `json:"phase"` + + // The money that crosses the border. BoughtIn is every chip staked onto this + // table; Payout is the stack that goes home, and is only set once you're up. + BoughtIn int64 `json:"bought_in"` + Payout int64 `json:"payout"` + Rake int64 `json:"rake"` // taken by the house across the whole session + + // The seed rides in the state for the same reason it does in UNO: the bots + // choose and the deck is reshuffled every hand, so the engine needs randomness + // mid-session — and there is no generator alive between two HTTP requests to + // hand it. Each step derives its own from the seed and the step count, so the + // session still replays exactly as it fell. + Seed1 uint64 `json:"seed1"` + Seed2 uint64 `json:"seed2"` + Step uint64 `json:"step"` +} + +// Event is one beat of the script the felt plays back. Seat is -1 when the beat +// belongs to the table rather than a player. +type Event struct { + Kind string `json:"kind"` + Seat int `json:"seat"` + Cards []cards.Card `json:"cards,omitempty"` + Amount int64 `json:"amount,omitempty"` + Total int64 `json:"total,omitempty"` + Text string `json:"text,omitempty"` +} + +// The moves a player can make. The betting five, plus the three that are about +// the session rather than the hand. +const ( + Fold = "fold" + Check = "check" + Call = "call" + Raise = "raise" + Shove = "allin" // the move; AllIn is the seat state it puts you in + + Deal = "deal" // next hand + TopUp = "topup" // put more chips on the table, between hands + Leave = "leave" // get up; the stack goes back to your stack +) + +// Move is what the browser sends. To is the total a raise raises *to*, and +// Amount is chips added in a top-up. +type Move struct { + Kind string `json:"move"` + To int64 `json:"to,omitempty"` + Amount int64 `json:"amount,omitempty"` +} + +// botNames are the regulars. Six of them so a full table never has two. +var botNames = []string{"Dice", "Marjorie", "Ox", "Sunny", "Pinch", "The Reverend"} + +// New sits you down. The buy-in is chips the caller has already taken off the +// player's stack; this engine only ever gives them back through Leave. +// +// No hand is dealt yet — the table opens on PhaseHandOver, which is the state a +// table between hands is in, and the first Deal move starts the first hand. +func New(t Tier, bots int, buyIn int64, rakePct float64, seed1, seed2 uint64) (State, []Event, error) { + if bots < 1 || bots > MaxBots { + return State{}, nil, ErrTableFull + } + if buyIn < t.MinBuy || buyIn > t.MaxBuy { + return State{}, nil, ErrBadBuyIn + } + t.RakePct = rakePct + + s := State{ + Tier: t, + Button: 0, + Phase: PhaseHandOver, + BoughtIn: buyIn, + Seed1: seed1, + Seed2: seed2, + } + s.Seats = append(s.Seats, Seat{Name: "You", Stack: buyIn}) + for i := 0; i < bots; i++ { + s.Seats = append(s.Seats, Seat{Name: botNames[i], Bot: true, Stack: t.MaxBuy}) + } + + // The button starts to your right, so the first hand deals you the small blind + // heads-up and the button on a full table — either way you are in the action + // from the first card rather than folding your way in. + s.Button = len(s.Seats) - 1 + + evs := []Event{{Kind: "sit", Seat: You, Amount: buyIn, Text: t.Name}} + return s, evs, nil +} + +// ApplyMove is the whole engine. It plays your move, then every bot behind you, +// deals whatever streets that finishes, and stops either when it is your turn +// again or when the hand is over. +func ApplyMove(s State, m Move) (State, []Event, error) { + if s.Phase == PhaseDone { + return s, nil, ErrOver + } + rng := s.next() + evs := []Event{} + + switch m.Kind { + case Deal: + if s.Phase != PhaseHandOver { + return s, nil, ErrHandLive + } + s.deal(&evs, rng) + s.advance(&evs, rng, true) + + case TopUp: + if s.Phase != PhaseHandOver { + return s, nil, ErrHandLive + } + if m.Amount <= 0 || s.Seats[You].Stack+m.Amount > s.Tier.MaxBuy { + return s, nil, ErrBadBuyIn + } + s.Seats[You].Stack += m.Amount + s.BoughtIn += m.Amount + evs = append(evs, Event{Kind: "topup", Seat: You, Amount: m.Amount}) + + case Leave: + if s.Phase != PhaseHandOver { + return s, nil, ErrHandLive + } + s.Phase = PhaseDone + s.Payout = s.Seats[You].Stack + evs = append(evs, Event{Kind: "leave", Seat: You, Amount: s.Payout}) + + case Fold, Check, Call, Raise, Shove: + if s.Phase != PhaseBetting { + return s, nil, ErrNoHand + } + if s.ToAct != You { + return s, nil, ErrNotYourTurn + } + if err := s.act(You, m, &evs); err != nil { + return s, nil, err // nothing happened; the caller keeps the old state + } + s.ToAct = s.nextCanAct(You) + s.advance(&evs, rng, true) + + default: + return s, nil, ErrUnknownMove + } + + return s, evs, nil +} + +// Step plays a move for whoever is to act — bot or player — and advances only as +// far as the next decision, whoever's it is. Nobody's turn is taken for them. +// +// This is the seam the trainer plays through, and it exists so that the trainer +// is playing *this* game: the same betting rules, the same street completion, the +// same side pots, the same money. The alternative is a second, simplified model +// of poker written for the trainer alone — which is what gogobee had, and it is +// why its policy encoded a game nobody was dealing. +func Step(s State, m Move) (State, []Event, error) { + if s.Phase != PhaseBetting { + return s, nil, ErrNoHand + } + rng := s.next() + evs := []Event{} + + seat := s.ToAct + if err := s.act(seat, m, &evs); err != nil { + return s, nil, err + } + s.ToAct = s.nextCanAct(seat) + s.advance(&evs, rng, false) + return s, evs, nil +} + +// Open deals one heads-up hand at the given stacks, stopping at the first +// decision. For the trainer: a table with no history and no button rotation. +func Open(t Tier, stack0, stack1 int64, seed1, seed2 uint64) (State, error) { + if stack0 <= 0 || stack1 <= 0 { + return State{}, ErrBadBuyIn + } + s := State{ + Tier: t, + Phase: PhaseHandOver, + Seats: []Seat{ + {Name: "0", Stack: stack0}, + {Name: "1", Stack: stack1, Bot: true}, + }, + Button: 1, // deal() moves it, so seat 0 takes the button and the small blind + Seed1: seed1, + Seed2: seed2, + } + rng := s.next() + evs := []Event{} + s.deal(&evs, rng) + s.advance(&evs, rng, false) + return s, nil +} + +// Clone deep-copies the table. CFR walks a tree of what-ifs, and a shallow copy +// would have every branch writing into the same deck. +func (s State) Clone() State { + out := s + out.Seats = append([]Seat(nil), s.Seats...) + out.Deck = append(cards.Deck(nil), s.Deck...) + out.Community = append([]cards.Card(nil), s.Community...) + out.Side = make([]Pot, len(s.Side)) + for i, p := range s.Side { + out.Side[i] = Pot{Amount: p.Amount, Eligible: append([]int(nil), p.Eligible...)} + } + return out +} + +// act applies one seat's action, whoever it belongs to. +func (s *State) act(seat int, m Move, evs *[]Event) error { + switch m.Kind { + case Fold: + s.fold(seat, evs) + return nil + case Check: + return s.check(seat, evs) + case Call: + return s.call(seat, evs) + case Raise: + return s.raise(seat, m.To, evs) + case Shove: + return s.allin(seat, evs) + } + return ErrUnknownMove +} + +// advance runs the table forward until you have a decision to make, or until +// there is nothing left to decide. +// +// This is the loop the whole design turns on. Every other engine here returns +// after one move because there is nobody else at the table; this one keeps going +// — bot, bot, flop, bot, turn — and only stops when the answer has to come from +// the player. Which is why one HTTP request can be a whole hand: shove all-in +// and the board runs out and the pot is paid inside a single call. +// +// With bots false it stops at every decision instead of playing the bots' for +// them. That is the trainer's way in: it wants to choose both seats' moves. +func (s *State) advance(evs *[]Event, rng *rand.Rand, bots bool) { + for { + // Everyone else folded. Nobody shows; the last one standing takes it. + if s.liveCount() <= 1 { + s.takeit(evs) + return + } + + // Nobody left with a decision to make: the rest of the board is a formality, + // so deal it and turn the cards over. + // + // The subtle half is the lone player who still has chips. They only have a + // decision if there is a bet to them — call it or fold. If there isn't, they + // have nobody left to bet *into*, because everyone else is already all-in, + // and poker does not let you put chips in a pot nobody can contest. + switch s.canActCount() { + case 0: + s.runout(evs) + return + case 1: + lone := s.onlyActor() + if s.Owed(lone) == 0 { + s.runout(evs) + return + } + s.ToAct = lone + } + + if s.streetDone(s.ToAct) { + if s.Street == River { + s.showdown(evs) + return + } + s.street(evs) + continue + } + + if s.ToAct == You || !bots { + return // a decision that isn't ours to make + } + + s.botActs(s.ToAct, evs, rng) + s.ToAct = s.nextCanAct(s.ToAct) + } +} + +// deal starts a hand: rebuy the broke bots, move the button, shuffle, post the +// blinds, and put two cards in front of everybody. +func (s *State) deal(evs *[]Event, rng *rand.Rand) { + s.HandNo++ + + for i := range s.Seats { + p := &s.Seats[i] + // A bot that has been ground down to nothing reloads. It has to: a table + // where you have taken everybody's chips is a table with no game left in it, + // and their chips were never real anyway — the only real money at this table + // is yours, and the only thing the house takes is the rake. + if p.Bot && p.Stack < s.Tier.BB { + add := s.Tier.MaxBuy - p.Stack + p.Stack = s.Tier.MaxBuy + *evs = append(*evs, Event{Kind: "rebuy", Seat: i, Amount: add, Total: p.Stack}) + } + p.Bet, p.Total, p.Won, p.Acted = 0, 0, 0, false + p.Hole = [2]cards.Card{} + p.State = Active + if p.Stack <= 0 { + p.State = Out + } + } + + s.Community = nil + s.Side = nil + s.Pot = 0 + s.Street = PreFlop + s.Flopped = false + s.History = "" + s.Phase = PhaseBetting + + s.Deck = cards.NewDeck(1) + s.Deck.Shuffle(rng) + + s.Button = s.nextIn(s.Button) + *evs = append(*evs, Event{Kind: "hand", Seat: s.Button, Amount: int64(s.HandNo)}) + + bb := s.blinds(evs) + + // Two cards each, one at a time round the table, as they are actually dealt. + for round := 0; round < 2; round++ { + for i := 0; i < len(s.Seats); i++ { + seat := (s.Button + 1 + i) % len(s.Seats) + p := &s.Seats[seat] + if p.State == Out { + continue + } + c, _ := s.Deck.Draw() + p.Hole[round] = c + } + } + // Only your cards go into the script. The bots' are in the state, on this side + // of the wire, and the only thing that ever turns them over is a showdown. + *evs = append(*evs, Event{Kind: "hole", Seat: You, + Cards: []cards.Card{s.Seats[You].Hole[0], s.Seats[You].Hole[1]}}) + + s.ToAct = s.firstPreFlop(bb) +} + +// street burns one and deals the next board card or three. +func (s *State) street(evs *[]Event) { + s.collect() + s.resetBets() + + s.Deck.Draw() // the burn card, as printed in the rules and as dealt in a casino + + switch s.Street { + case PreFlop: + s.Street = Flop + s.Flopped = true + for i := 0; i < 3; i++ { + c, _ := s.Deck.Draw() + s.Community = append(s.Community, c) + } + case Flop: + s.Street = Turn + c, _ := s.Deck.Draw() + s.Community = append(s.Community, c) + case Turn: + s.Street = River + c, _ := s.Deck.Draw() + s.Community = append(s.Community, c) + } + + // Total, not Pot: by the time a board runs out behind an all-in the money has + // already been cut into side pots, and s.Pot is zero. + *evs = append(*evs, Event{Kind: s.Street.String(), Seat: -1, + Cards: s.Community[len(s.Community)-cardsOn(s.Street):], Amount: s.Total()}) + + s.ToAct = s.firstPostFlop() + s.Aggressor = s.ToAct // nobody has bet yet, so the option ends where it starts +} + +func cardsOn(st Street) int { + if st == Flop { + return 3 + } + return 1 +} + +// runout deals the rest of the board with no more betting, because there is no +// longer anybody able to bet. The side pots are built first: once the chips stop +// moving, who can win what is already decided. +func (s *State) runout(evs *[]Event) { + allIn := false + for i := range s.Seats { + if s.Seats[i].State == AllIn { + allIn = true + break + } + } + if allIn { + // A shove nobody could cover comes back first — while it is still a bet in + // front of a seat, and before the pots are cut around it. + s.uncalled(evs) + } + s.collect() + if allIn { + s.sidePots() + } + + for s.Street < River { + s.street(evs) + } + s.showdown(evs) +} + +// endHand pays out and parks the table between hands. +func (s *State) endHand(evs *[]Event) { + s.Pot = 0 + s.Side = nil + s.Phase = PhaseHandOver + *evs = append(*evs, Event{Kind: "end", Seat: -1, Amount: s.Seats[You].Stack}) + + // Busting is the end of the session, not the end of a hand. There is nothing + // to deal you and nothing to give back, so the table closes and you sit down + // again — which is a buy-in, and a buy-in is a decision worth making on purpose. + if s.Seats[You].Stack <= 0 { + s.Phase = PhaseDone + s.Payout = 0 + *evs = append(*evs, Event{Kind: "bust", Seat: You}) + } +} + +// ---- the small stuff ------------------------------------------------------- + +// next derives this step's generator and advances the step count. +func (s *State) next() *rand.Rand { + s.Step++ + return cards.NewRNG(s.Seed1, s.Seed2^s.Step) +} + +// collect sweeps the street's bets into the pot. +func (s *State) collect() { + for i := range s.Seats { + s.Pot += s.Seats[i].Bet + s.Seats[i].Bet = 0 + } +} + +// resetBets opens a new street: nothing to call, and nobody has spoken. +func (s *State) resetBets() { + for i := range s.Seats { + s.Seats[i].Bet = 0 + s.Seats[i].Acted = false + } + s.Bet = 0 + s.MinRaise = s.Tier.BB + s.History = "" +} + +// inPlay is the pot plus everything bet on this street — what a bot is actually +// deciding against, and what a pot-sized raise is a size of. +func (s State) inPlay() int64 { + total := s.Pot + for i := range s.Seats { + total += s.Seats[i].Bet + } + return total +} + +// Pot returns the money on the table, however it is currently sliced. +func (s State) Total() int64 { + total := s.inPlay() + for _, p := range s.Side { + total += p.Amount + } + return total +} + +// liveCount is the seats still in the hand, whether or not they can bet. +func (s State) liveCount() int { + n := 0 + for i := range s.Seats { + if st := s.Seats[i].State; st == Active || st == AllIn { + n++ + } + } + return n +} + +// canActCount is the seats that still have chips and a decision. +func (s State) canActCount() int { + n := 0 + for i := range s.Seats { + if s.Seats[i].State == Active { + n++ + } + } + return n +} + +// dealt is the seats in this hand at all. +func (s State) dealt() int { + n := 0 + for i := range s.Seats { + if s.Seats[i].State != Out { + n++ + } + } + return n +} + +// nextIn is the next seat that was dealt in — used to move the button, which +// moves past a seat that is sitting out rather than landing on it. +func (s State) nextIn(from int) int { + n := len(s.Seats) + for i := 1; i <= n; i++ { + next := (from + i) % n + if st := s.Seats[next].State; st == Active || st == AllIn { + return next + } + } + return from +} + +// onlyActor is the one seat that can still act. Call it when canActCount is 1. +func (s State) onlyActor() int { + for i := range s.Seats { + if s.Seats[i].State == Active { + return i + } + } + return s.ToAct +} + +// canBet reports whether there is anybody left to bet *into*. With one player +// still holding chips and the rest all-in, a raise is chips nobody can call, so +// no raise is offered — to the player or to a bot. +func (s State) canBet() bool { return s.canActCount() > 1 } + +// nextCanAct is the next seat with a decision to make. +func (s State) nextCanAct(from int) int { + n := len(s.Seats) + for i := 1; i <= n; i++ { + next := (from + i) % n + if s.Seats[next].State == Active { + return next + } + } + return from +} + +// Owed is what the seat must put in to call. +func (s State) Owed(seat int) int64 { + owed := s.Bet - s.Seats[seat].Bet + if owed < 0 { + return 0 + } + if owed > s.Seats[seat].Stack { + return s.Seats[seat].Stack + } + return owed +} + +// MinRaiseTo is the smallest total a raise may raise to, clamped to a shove when +// the seat cannot cover a full one. +func (s State) MinRaiseTo(seat int) int64 { + to := s.Bet + s.MinRaise + if most := s.Seats[seat].Bet + s.Seats[seat].Stack; to > most { + return most + } + return to +} + +// MaxRaiseTo is everything the seat has. +func (s State) MaxRaiseTo(seat int) int64 { + return s.Seats[seat].Bet + s.Seats[seat].Stack +} + +// CanRaise reports whether the seat may raise: they need chips behind the call, +// and somebody left to bet into. The felt asks so it never offers a button the +// table would refuse. +func (s State) CanRaise(seat int) bool { + return s.mask(seat)[actRaiseHalf] +} + +// InPosition reports whether the seat acts last after the flop, which is the +// only thing about position the trained bots actually know. +// +// The postflop order runs from the seat left of the button all the way round to +// the button itself, so the player in position is simply the last one still in +// the hand — the button, or whoever is nearest to it once the button has folded. +// The policy was trained heads-up, where this is exactly the button; applying it +// to a six-handed table is an approximation, and this is where the approximation +// lives. +func (s State) InPosition(seat int) bool { + last := -1 + for i := 1; i <= len(s.Seats); i++ { + at := (s.Button + i) % len(s.Seats) + if st := s.Seats[at].State; st == Active || st == AllIn { + last = at + } + } + return seat == last +} + +// Position is the seat's label at this table — BTN, SB, BB, and so on. It is for +// the felt to print. The bots do not use it: see InPosition, and the note on +// infoSet about what happens when you confuse the two. +func (s State) Position(seat int) string { + n := s.dealt() + if n < 2 { + return "" + } + if seat == s.Button { + return "BTN" + } + if n == 2 { + return "BB" // heads-up, the other seat is always the big blind + } + + sb := s.nextIn(s.Button) + bb := s.nextIn(sb) + switch seat { + case sb: + return "SB" + case bb: + return "BB" + } + + utg := s.nextIn(bb) + if seat == utg { + return "UTG" + } + + // Everyone between UTG and the button is somewhere in the middle; the seat + // closest to the button is the cutoff. + dist, cur := 0, utg + for i := 0; i < n; i++ { + cur = s.nextIn(cur) + dist++ + if cur == seat { + break + } + } + if remaining := n - 4; remaining > 0 && dist >= remaining { + return "CO" + } + return "MP" +} diff --git a/internal/games/holdem/holdem_test.go b/internal/games/holdem/holdem_test.go new file mode 100644 index 0000000..cc984e4 --- /dev/null +++ b/internal/games/holdem/holdem_test.go @@ -0,0 +1,641 @@ +package holdem + +import ( + "math/rand/v2" + "testing" + + "pete/internal/games/cards" +) + +// The one that matters: no chip is ever created or destroyed. +// +// Everything else in this package is a rule you could argue about. This is the +// one that would lose somebody money. Every chip at the table is in exactly one +// place — a stack, a bet in front of a seat, a pot, or the house's rake — and +// the only thing that ever adds to the total is a bot reloading. So: play a +// hundred sessions of real hands, with the trained bots making real decisions, +// and count the chips after every single move. +func TestChipsAreConserved(t *testing.T) { + for game := 0; game < 100; game++ { + rng := rand.New(rand.NewPCG(uint64(game), 99)) + bots := 1 + game%MaxBots + tier := Tiers[game%len(Tiers)] + + s, _, err := New(tier, bots, tier.MaxBuy, tier.RakePct, uint64(game), 7) + if err != nil { + t.Fatalf("new table: %v", err) + } + + want := chipsAt(s) // what the table started with + + for hand := 0; hand < 8 && s.Phase != PhaseDone; hand++ { + var evs []Event + s, evs, err = ApplyMove(s, Move{Kind: Deal}) + if err != nil { + t.Fatalf("game %d hand %d: deal: %v", game, hand, err) + } + want += reloaded(evs) // a bot that rebought brought new chips with it + check(t, s, want, game, hand, "deal") + + for step := 0; s.Phase == PhaseBetting; step++ { + if step > 200 { + t.Fatalf("game %d hand %d: the hand will not end", game, hand) + } + s, _, err = ApplyMove(s, randomMove(s, rng)) + if err != nil { + t.Fatalf("game %d hand %d: %v", game, hand, err) + } + check(t, s, want, game, hand, "move") + } + } + } +} + +// chipsAt totals every chip the table can see, plus every one the house has +// already taken out of it. +func chipsAt(s State) int64 { + total := s.Rake + s.Pot + for _, p := range s.Seats { + total += p.Stack + p.Bet + } + for _, pot := range s.Side { + total += pot.Amount + } + return total +} + +// reloaded is what the bots brought back to the table on this deal. It is the +// only thing in the game that is allowed to make chips out of nothing, which is +// exactly why the test has to know about it and nothing else does. +func reloaded(evs []Event) int64 { + var n int64 + for _, e := range evs { + if e.Kind == "rebuy" { + n += e.Amount + } + } + return n +} + +func check(t *testing.T, s State, want int64, game, hand int, when string) { + t.Helper() + if got := chipsAt(s); got != want { + t.Fatalf("game %d hand %d, after %s: %d chips on the table, want %d "+ + "(pot %d, rake %d, stacks %v)", game, hand, when, got, want, s.Pot, s.Rake, stacks(s)) + } + for i, p := range s.Seats { + if p.Stack < 0 { + t.Fatalf("game %d hand %d: seat %d has a negative stack (%d)", game, hand, i, p.Stack) + } + } +} + +func stacks(s State) []int64 { + out := make([]int64, len(s.Seats)) + for i, p := range s.Seats { + out[i] = p.Stack + } + return out +} + +// randomMove picks something legal for the player, without any thought at all. +// A bad player is exactly what this test wants: it gets into all-ins, folds, +// short stacks and split pots far faster than a good one would. +func randomMove(s State, rng *rand.Rand) Move { + owed := s.Owed(You) + var legal []Move + if owed > 0 { + legal = append(legal, Move{Kind: Fold}, Move{Kind: Call}) + } else { + legal = append(legal, Move{Kind: Check}) + } + if s.Seats[You].Stack > owed && s.canBet() { + legal = append(legal, Move{Kind: Shove}) + if to := s.MinRaiseTo(You); to < s.MaxRaiseTo(You) { + legal = append(legal, Move{Kind: Raise, To: to}) + } + } + return legal[rng.IntN(len(legal))] +} + +// ---- the rules a poker player would notice were wrong ----------------------- + +func TestHeadsUpButtonIsTheSmallBlindAndActsFirst(t *testing.T) { + s := table(t, Tiers[0], 1, 200) + s, evs, err := ApplyMove(s, Move{Kind: Deal}) + if err != nil { + t.Fatal(err) + } + + // The button posted the small blind, not the big one. + var small, big int + for _, e := range evs { + if e.Kind == "blind" && e.Text == "small" { + small = e.Seat + } + if e.Kind == "blind" && e.Text == "big" { + big = e.Seat + } + } + if small != s.Button { + t.Errorf("heads-up: seat %d posted the small blind, but the button is seat %d", small, s.Button) + } + if big == s.Button { + t.Error("heads-up: the button posted the big blind too") + } + // And it is the first to act before the flop. (If the button is a bot it has + // already acted, so what we can check is that the player didn't get skipped.) + if s.Phase != PhaseBetting { + t.Fatalf("phase %q: the hand should be waiting on somebody", s.Phase) + } +} + +func TestTheBigBlindGetsTheirOption(t *testing.T) { + // A table where everyone just calls: the big blind has the bet matched without + // ever having chosen anything, and the street must not end until they speak. + s := table(t, Tiers[0], 1, 200) + s, _, _ = ApplyMove(s, Move{Kind: Deal}) + + // Find a hand where the player is the big blind. The button alternates, so at + // most a couple of deals. + for i := 0; i < 6 && s.Position(You) != "BB"; i++ { + s = playOut(t, s) + s, _, _ = ApplyMove(s, Move{Kind: Deal}) + } + if s.Position(You) != "BB" { + t.Skip("never dealt the big blind") + } + if s.Phase != PhaseBetting { + return // the bot folded or raised; either way the option isn't the question + } + if s.ToAct == You && s.Owed(You) == 0 { + // This is the option: nothing to call, but the hand is still ours to act on. + if _, _, err := ApplyMove(s, Move{Kind: Check}); err != nil { + t.Errorf("the big blind cannot check their option: %v", err) + } + } +} + +func TestAShortAllInDoesNotReopenTheBetting(t *testing.T) { + s := State{ + Tier: Tiers[1], // 5/10 + Seats: []Seat{{Name: "You", Stack: 1000}, {Name: "Bot", Bot: true, Stack: 1000}}, + Bet: 100, + MinRaise: 100, // a full raise would be to 200 + Aggressor: You, + Phase: PhaseBetting, + } + s.Seats[You].Bet = 100 + s.Seats[1].Bet = 0 + s.Seats[1].Stack = 150 // can only get to 150, which is a raise of 50: not a full one + + var evs []Event + if err := s.allin(1, &evs); err != nil { + t.Fatal(err) + } + if s.Bet != 150 { + t.Errorf("the bet to call is %d, want 150", s.Bet) + } + if s.MinRaise != 100 { + t.Errorf("min raise moved to %d — a short all-in must not change it", s.MinRaise) + } + if s.Aggressor != You { + t.Errorf("the aggressor moved to seat %d — a short all-in must not reopen the action", s.Aggressor) + } +} + +func TestSidePotsPayInLayers(t *testing.T) { + // Three players all-in for different amounts. The short stack can only win + // what everyone could have lost to them. + s := State{ + Tier: Tiers[1], + Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}, {Name: "B", Bot: true}}, + } + s.Seats[0].Total, s.Seats[0].State = 100, AllIn // short + s.Seats[1].Total, s.Seats[1].State = 500, AllIn // middle + s.Seats[2].Total, s.Seats[2].State = 500, AllIn // covers + + s.sidePots() + + if len(s.Side) != 2 { + t.Fatalf("got %d pots, want 2: %+v", len(s.Side), s.Side) + } + main, side := s.Side[0], s.Side[1] + if main.Amount != 300 { // 100 from each of the three + t.Errorf("main pot is %d, want 300", main.Amount) + } + if len(main.Eligible) != 3 { + t.Errorf("main pot has %d eligible, want all 3", len(main.Eligible)) + } + if side.Amount != 800 { // 400 more from each of the two who had it + t.Errorf("side pot is %d, want 800", side.Amount) + } + if len(side.Eligible) != 2 { + t.Errorf("side pot has %d eligible, want 2 — the short stack cannot win it", len(side.Eligible)) + } + if total := main.Amount + side.Amount; total != 1100 { + t.Errorf("the pots hold %d, but %d went in", total, 1100) + } +} + +func TestFoldedChipsStayInThePotButWinNothing(t *testing.T) { + s := State{ + Tier: Tiers[1], + Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}, {Name: "B", Bot: true}}, + } + s.Seats[0].Total, s.Seats[0].State = 200, AllIn + s.Seats[1].Total, s.Seats[1].State = 50, Folded // called 50 and gave up + s.Seats[2].Total, s.Seats[2].State = 200, AllIn + + s.sidePots() + + var total int64 + for _, p := range s.Side { + total += p.Amount + for _, seat := range p.Eligible { + if seat == 1 { + t.Error("a folded seat is eligible to win a pot") + } + } + } + if total != 450 { + t.Errorf("the pots hold %d, want 450 — the folder's 50 has to still be in there", total) + } +} + +func TestAnUncalledBetComesBack(t *testing.T) { + s := State{ + Tier: Tiers[1], + Seats: []Seat{{Name: "You", Stack: 0}, {Name: "A", Bot: true}}, + } + s.Seats[0].Total, s.Seats[0].Bet, s.Seats[0].State = 500, 500, AllIn + s.Seats[1].Total, s.Seats[1].State = 200, Folded + + var evs []Event + s.uncalled(&evs) + + if s.Seats[You].Stack != 300 { + t.Errorf("got %d back, want the 300 nobody called", s.Seats[You].Stack) + } + if s.Seats[You].Total != 200 { + t.Errorf("still committed for %d, want 200 — the rest was never in the pot", s.Seats[You].Total) + } + if s.Seats[You].State != Active { + t.Error("still marked all-in for chips that came back") + } +} + +// ---- the rake -------------------------------------------------------------- + +func TestNoFlopNoDrop(t *testing.T) { + s := State{Tier: Tiers[1], Flopped: false, Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}} + var evs []Event + s.payPot(Pot{Amount: 1000, Eligible: []int{You}}, []ranked{{seat: You}}, &evs) + + if s.Rake != 0 { + t.Errorf("raked %d off a pot that never saw a flop", s.Rake) + } + if s.Seats[You].Stack != 1000 { + t.Errorf("paid %d of a 1000 pot", s.Seats[You].Stack) + } +} + +func TestTheRakeIsCapped(t *testing.T) { + s := State{Tier: Tiers[1], Flopped: true, Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}} + var evs []Event + // 5% of 10,000 is 500, but the cap is three big blinds — 30 at 5/10. + s.payPot(Pot{Amount: 10000, Eligible: []int{You}}, []ranked{{seat: You}}, &evs) + + want := s.Tier.BB * rakeCapBB + if s.Rake != want { + t.Errorf("raked %d, want the %d cap", s.Rake, want) + } + if s.Seats[You].Stack != 10000-want { + t.Errorf("paid %d, want %d", s.Seats[You].Stack, 10000-want) + } +} + +func TestTheRakeIsFivePercentUnderTheCap(t *testing.T) { + s := State{Tier: Tiers[0], Flopped: true, Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}} + var evs []Event + s.payPot(Pot{Amount: 100, Eligible: []int{You}}, []ranked{{seat: You}}, &evs) // cap is 6 at 1/2 + + if s.Rake != 5 { + t.Errorf("raked %d off a 100 pot, want 5", s.Rake) + } + if s.Seats[You].Stack != 95 { + t.Errorf("paid %d, want 95", s.Seats[You].Stack) + } +} + +// The rake has to survive the wiring, not only the arithmetic. +// +// This is the test that was missing, and a browser found what it would have +// found: New() overwrites the tier's rake with the one the casino hands it, and +// the casino hands it a *fraction* (blackjack's 0.05). The tier declared 5, +// meaning percent. Every other rake test builds a State by hand and sets the tier +// itself, so not one of them ever saw the number a real table runs on — and the +// house quietly took nothing from every pot for an afternoon. +func TestTheRakeSurvivesTheConstructor(t *testing.T) { + tier := Tiers[1] // 5/10, so the cap is 30 + s, _, err := New(tier, 1, tier.MaxBuy, 0.05, 1, 2) + if err != nil { + t.Fatal(err) + } + s.Flopped = true + + var evs []Event + s.payPot(Pot{Amount: 400, Eligible: []int{You}}, []ranked{{seat: You}}, &evs) + + if s.Rake != 20 { + t.Fatalf("the house took %d of a 400 pot, want 20 — five percent of it. "+ + "RakePct is a fraction (0.05), not a percentage (5): see the note on Tiers.", s.Rake) + } + if !has(evs, "rake") { + t.Error("the rake was taken with no event to say so, so the felt cannot show it") + } +} + +func TestASplitPotSplits(t *testing.T) { + s := State{Tier: Tiers[1], Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}} + var evs []Event + // Same rank: they chop. The odd chip goes to one of them, not into the air. + s.payPot(Pot{Amount: 101, Eligible: []int{0, 1}}, + []ranked{{seat: 0, rank: 500}, {seat: 1, rank: 500}}, &evs) + + if got := s.Seats[0].Stack + s.Seats[1].Stack; got != 101 { + t.Errorf("paid out %d of a 101 pot", got) + } + if s.Seats[0].Stack != 51 || s.Seats[1].Stack != 50 { + t.Errorf("split %d/%d, want 51/50", s.Seats[0].Stack, s.Seats[1].Stack) + } +} + +// ---- the session ----------------------------------------------------------- + +func TestYouCannotWalkOutOfALiveHand(t *testing.T) { + s := table(t, Tiers[0], 2, 200) + s, _, _ = ApplyMove(s, Move{Kind: Deal}) + if s.Phase != PhaseBetting { + t.Skip("the hand ended before the player could act") + } + if _, _, err := ApplyMove(s, Move{Kind: Leave}); err != ErrHandLive { + t.Errorf("leaving mid-hand gave %v, want ErrHandLive", err) + } + if _, _, err := ApplyMove(s, Move{Kind: TopUp, Amount: 10}); err != ErrHandLive { + t.Errorf("topping up mid-hand gave %v, want ErrHandLive", err) + } +} + +func TestLeavingTakesTheStackHome(t *testing.T) { + s := table(t, Tiers[0], 1, 200) + s, _, _ = ApplyMove(s, Move{Kind: Deal}) + s = playOut(t, s) + + stack := s.Seats[You].Stack + s, _, err := ApplyMove(s, Move{Kind: Leave}) + if err != nil { + t.Fatal(err) + } + if s.Phase != PhaseDone { + t.Errorf("phase %q after leaving, want done", s.Phase) + } + if s.Payout != stack { + t.Errorf("payout %d, want the %d that was in front of us", s.Payout, stack) + } + if _, _, err := ApplyMove(s, Move{Kind: Deal}); err != ErrOver { + t.Errorf("dealt a hand at a table we got up from: %v", err) + } +} + +func TestBustingEndsTheSession(t *testing.T) { + s := table(t, Tiers[0], 1, 200) + s.Seats[You].Stack = 0 + var evs []Event + s.endHand(&evs) + + if s.Phase != PhaseDone { + t.Errorf("phase %q with no chips left, want done", s.Phase) + } + if s.Payout != 0 { + t.Errorf("payout %d for a busted player", s.Payout) + } + if !has(evs, "bust") { + t.Error("no bust event") + } +} + +func TestATopUpCannotGoOverTheTableMax(t *testing.T) { + s := table(t, Tiers[0], 1, 200) // max buy is 200, and we're at it + if _, _, err := ApplyMove(s, Move{Kind: TopUp, Amount: 1}); err != ErrBadBuyIn { + t.Errorf("topped up over the table maximum: %v", err) + } + + s = table(t, Tiers[0], 1, 100) + s, _, err := ApplyMove(s, Move{Kind: TopUp, Amount: 50}) + if err != nil { + t.Fatal(err) + } + if s.Seats[You].Stack != 150 { + t.Errorf("stack is %d, want 150", s.Seats[You].Stack) + } + if s.BoughtIn != 150 { + t.Errorf("bought in for %d, want 150 — the top-up is real money too", s.BoughtIn) + } +} + +func TestABuyInHasToBeInRange(t *testing.T) { + tier := Tiers[0] + for _, amount := range []int64{0, tier.MinBuy - 1, tier.MaxBuy + 1} { + if _, _, err := New(tier, 1, amount, 5, 1, 2); err != ErrBadBuyIn { + t.Errorf("buy-in of %d at a %d–%d table: %v", amount, tier.MinBuy, tier.MaxBuy, err) + } + } +} + +// ---- what the player is allowed to know ------------------------------------ + +func TestTheScriptNeverCarriesABotsCards(t *testing.T) { + rng := rand.New(rand.NewPCG(4, 4)) + s := table(t, Tiers[0], 3, 200) + + for hand := 0; hand < 20 && s.Phase != PhaseDone; hand++ { + s, evs, err := ApplyMove(s, Move{Kind: Deal}) + if err != nil { + t.Fatal(err) + } + noBotCards(t, s, evs) + + for s.Phase == PhaseBetting { + var next []Event + s, next, err = ApplyMove(s, randomMove(s, rng)) + if err != nil { + t.Fatal(err) + } + noBotCards(t, s, next) + } + } +} + +// A bot's cards may appear in exactly one kind of event: the showdown that turns +// them face up, which is the moment they stop being secret. +func noBotCards(t *testing.T, s State, evs []Event) { + t.Helper() + for _, e := range evs { + if len(e.Cards) == 0 || e.Seat < 0 || e.Kind == "show" { + continue + } + if e.Seat != You && s.Seats[e.Seat].Bot { + t.Fatalf("a %q event carries seat %d's cards (%v) — that's a bot's hand", + e.Kind, e.Seat, e.Cards) + } + } +} + +// ---- hand strength --------------------------------------------------------- + +func TestTheEvaluatorKnowsWhichHandIsBetter(t *testing.T) { + board := []cards.Card{ + {Rank: 10, Suit: cards.Hearts}, {Rank: cards.Jack, Suit: cards.Hearts}, + {Rank: cards.Queen, Suit: cards.Hearts}, {Rank: 2, Suit: cards.Spades}, + {Rank: 7, Suit: cards.Clubs}, + } + flush, _ := rankOf([2]cards.Card{{Rank: 3, Suit: cards.Hearts}, {Rank: 5, Suit: cards.Hearts}}, board) + straight, _ := rankOf([2]cards.Card{{Rank: cards.King, Suit: cards.Spades}, {Rank: cards.Ace, Suit: cards.Clubs}}, board) + royal, _ := rankOf([2]cards.Card{{Rank: cards.King, Suit: cards.Hearts}, {Rank: cards.Ace, Suit: cards.Hearts}}, board) + pair, _ := rankOf([2]cards.Card{{Rank: 7, Suit: cards.Spades}, {Rank: 4, Suit: cards.Diamonds}}, board) + + if !(royal < flush && flush < straight && straight < pair) { + t.Errorf("hands rank royal=%d flush=%d straight=%d pair=%d — lower must be better, in that order", + royal, flush, straight, pair) + } +} + +func TestEquityKnowsAcesAreGood(t *testing.T) { + rng := rand.New(rand.NewPCG(1, 1)) + aces := equityOf([2]cards.Card{{Rank: cards.Ace, Suit: cards.Spades}, {Rank: cards.Ace, Suit: cards.Hearts}}, nil, 1, 2000, rng) + rags := equityOf([2]cards.Card{{Rank: 7, Suit: cards.Spades}, {Rank: 2, Suit: cards.Hearts}}, nil, 1, 2000, rng) + + if aces.Strength() < 0.8 { + t.Errorf("pocket aces are worth %.2f heads-up, want about 0.85", aces.Strength()) + } + if rags.Strength() > 0.4 { + t.Errorf("seven-deuce is worth %.2f heads-up, want about 0.35", rags.Strength()) + } + if aces.Strength() <= rags.Strength() { + t.Error("seven-deuce is not better than pocket aces") + } +} + +// The policy loads, and every node in it is a probability distribution. +func TestThePolicyLoads(t *testing.T) { + p := loadPolicy() + if len(p) < 1000 { + t.Fatalf("the CFR policy has %d nodes in it — it did not load, or it was never trained", len(p)) + } + for key, probs := range p { + var sum float64 + for _, v := range probs { + if v < 0 { + t.Fatalf("%s: a negative probability (%v)", key, probs) + } + sum += v + } + if sum < 0.99 || sum > 1.01 { + t.Fatalf("%s: the probabilities sum to %v, not 1", key, sum) + } + } +} + +// TestTheBotsAreActuallyTrained is the test this game most needed and did not +// have. +// +// A bot that cannot find itself in the policy does not fail. It shrugs, plays the +// pot-odds rule, and looks exactly like a bot that is working — which is how +// gogobee shipped a trained poker AI whose policy was *never read once* for the +// entire life of the game. The trainer wrote its keys under IP/OOP and the table +// looked them up under BTN/SB/BB, and there was nothing anywhere that would have +// said so. +// +// So: deal real hands, let the bots think, and count how often the thinking lands +// in the table. Heads-up is the number that has to hold — that is what the policy +// was trained on. A six-handed table is a documented approximation of it and +// drops off as seats are added, which is why this only asserts on the duel. +func TestTheBotsAreActuallyTrained(t *testing.T) { + Hits.Store(0) + Misses.Store(0) + + rng := rand.New(rand.NewPCG(11, 12)) + for game := 0; game < 40; game++ { + tier := Tiers[1] + s, _, err := New(tier, 1, tier.MaxBuy, tier.RakePct, uint64(game), 5) + if err != nil { + t.Fatal(err) + } + for hand := 0; hand < 6 && s.Phase != PhaseDone; hand++ { + s, _, err = ApplyMove(s, Move{Kind: Deal}) + if err != nil { + t.Fatal(err) + } + for s.Phase == PhaseBetting { + s, _, err = ApplyMove(s, randomMove(s, rng)) + if err != nil { + t.Fatal(err) + } + } + } + } + + hits, misses := Hits.Load(), Misses.Load() + if hits+misses < 100 { + t.Fatalf("the bots only made %d decisions — this test isn't measuring anything", hits+misses) + } + rate := float64(hits) / float64(hits+misses) + if rate < 0.6 { + t.Fatalf("heads-up, the bots found themselves in the trained policy %.0f%% of the time "+ + "(%d of %d decisions). They are playing the pot-odds fallback, which means the key the "+ + "trainer writes and the key the table reads have drifted apart. See infoSet.", + rate*100, hits, hits+misses) + } + t.Logf("heads-up policy hit rate: %.0f%% (%d of %d decisions)", rate*100, hits, hits+misses) +} + +// ---- helpers --------------------------------------------------------------- + +func table(t *testing.T, tier Tier, bots int, buyIn int64) State { + t.Helper() + s, _, err := New(tier, bots, buyIn, tier.RakePct, 1, 2) + if err != nil { + t.Fatalf("new table: %v", err) + } + return s +} + +// playOut folds every decision until the hand is over. +func playOut(t *testing.T, s State) State { + t.Helper() + for i := 0; s.Phase == PhaseBetting; i++ { + if i > 100 { + t.Fatal("the hand will not end") + } + move := Move{Kind: Fold} + if s.Owed(You) == 0 { + move = Move{Kind: Check} + } + var err error + s, _, err = ApplyMove(s, move) + if err != nil { + t.Fatalf("playing out: %v", err) + } + } + return s +} + +func has(evs []Event, kind string) bool { + for _, e := range evs { + if e.Kind == kind { + return true + } + } + return false +} diff --git a/internal/games/holdem/policy.gob b/internal/games/holdem/policy.gob new file mode 100644 index 0000000..329c286 Binary files /dev/null and b/internal/games/holdem/policy.gob differ diff --git a/internal/games/holdem/train.go b/internal/games/holdem/train.go new file mode 100644 index 0000000..3c5930c --- /dev/null +++ b/internal/games/holdem/train.go @@ -0,0 +1,428 @@ +package holdem + +import ( + "bytes" + "encoding/gob" + "fmt" + "io" + "math/rand/v2" + "sync" + + "pete/internal/games/cards" +) + +// The trainer. +// +// This is counterfactual regret minimisation, and what it produces is policy.gob +// — the table the bots read at the table. It is not on any request path; it runs +// from cmd/holdem-train, for half an hour, and then it is a file. +// +// The one thing worth understanding about it: **it plays the real game.** Every +// move it explores goes through Step, which is the same reducer the felt calls, +// so the blinds, the min-raise, the street completion and the money are the ones +// a player will actually meet. Its info-set key comes out of State.spot, which is +// the same function the bots look themselves up with. +// +// That is not tidiness, it is the whole lesson of the policy this replaces. That +// one was trained against a hand-written model of poker sitting beside the real +// engine — a model where a call always ended the street, the big blind had no +// option, and the payoff was half the pot no matter who had put what in. Then it +// was looked up under a key the trainer never wrote. The result was a 3.4MB file +// that had never once been read, and nobody could tell, because a policy miss is +// not an error. It just quietly isn't there. +// +// So: one engine, one key function, and a test that fails if the bots stop +// finding themselves in the table. + +// How much of the game tree to explore. Two raises a street keeps the tree small +// enough to converge; a third barely changes how anybody plays and multiplies the +// nodes. +const ( + maxRaisesPerStreet = 2 + maxDepth = 40 + trainMCIters = 60 // noisy, but it is only picking a bucket +) + +// regrets is what CFR accumulates: how much better each action would have been. +type regrets map[string]*[numActions]float64 + +// Trained is the file the bots read. +type Trained struct { + Strategy map[string][numActions]float64 + Meta TrainMeta +} + +// TrainMeta is what the policy can say about itself. Worth having: a policy is +// otherwise an opaque three megabytes and there is no way to tell a good one from +// a stale one by looking. +type TrainMeta struct { + Iterations int + Stakes string + Depths string + Nodes int +} + +// ---- preflop, measured once ------------------------------------------------ + +var ( + preflopOnce sync.Once + preflopTable [13][13]Equity // [hi][lo] offsuit, [lo][hi] suited, diagonal pairs +) + +// preflopEquity is the equity of a starting hand heads-up. There are only 169 +// hands that differ from each other, so they are measured properly, once, and +// then it is a lookup — which matters twice: it takes the noise out of a bucket +// boundary, and the trainer visits preflop on every single iteration. +func preflopEquity(hole [2]cards.Card) Equity { + preflopOnce.Do(func() { + rng := cards.NewRNG(20260714, 1) + for a := cards.Ace; a <= cards.King; a++ { + for b := a; b <= cards.King; b++ { + lo, hi := rankIdx(a), rankIdx(b) + + // Suited, and the pairs (which can only be offsuit) on the diagonal. + s1 := cards.Card{Rank: a, Suit: cards.Spades} + s2 := cards.Card{Rank: b, Suit: cards.Spades} + if a == b { + s2.Suit = cards.Hearts + } + preflopTable[lo][hi] = equityOf([2]cards.Card{s1, s2}, nil, 1, 10000, rng) + + if a != b { + o2 := cards.Card{Rank: b, Suit: cards.Hearts} + preflopTable[hi][lo] = equityOf([2]cards.Card{s1, o2}, nil, 1, 10000, rng) + } + } + } + }) + + lo, hi := rankIdx(hole[0].Rank), rankIdx(hole[1].Rank) + if lo > hi { + lo, hi = hi, lo + } + if hole[0].Suit == hole[1].Suit { + return preflopTable[lo][hi] // suited, and the pairs sit here too + } + if lo == hi { + return preflopTable[lo][hi] + } + return preflopTable[hi][lo] // offsuit +} + +// rankIdx maps a rank to 0–12, with the ace high — which is what it is, before +// the flop. +func rankIdx(r cards.Rank) int { + if r == cards.Ace { + return 12 + } + return int(r) - 2 +} + +// ---- the traversal --------------------------------------------------------- + +// Train runs external-sampling MCCFR for n hands and returns the average +// strategy. Each worker keeps its own tables and they are summed at the end, +// which is what makes this embarrassingly parallel and is the only reason it +// finishes in half an hour. +func Train(n, workers int, t Tier, minBB, maxBB int64, seed uint64, progress func(done int)) *Trained { + if workers < 1 { + workers = 1 + } + + type table struct { + reg regrets + avg regrets + } + out := make([]table, workers) + + var wg sync.WaitGroup + var done sync.Mutex + completed := 0 + + for w := 0; w < workers; w++ { + wg.Add(1) + go func(w int) { + defer wg.Done() + tr := &trainer{ + reg: regrets{}, + avg: regrets{}, + tier: t, + minBB: minBB, + maxBB: maxBB, + rng: cards.NewRNG(seed, uint64(w)+1), + } + + share := n / workers + if w < n%workers { + share++ + } + for i := 0; i < share; i++ { + tr.iterate(uint64(w)<<40 | uint64(i)) + if progress != nil && i%2000 == 0 { + done.Lock() + completed += 2000 + c := completed + done.Unlock() + progress(c) + } + } + out[w] = table{tr.reg, tr.avg} + }(w) + } + wg.Wait() + + // Sum the workers' average-strategy tables, then normalise each node into the + // probabilities a bot will actually play. + total := regrets{} + for _, tab := range out { + for key, v := range tab.avg { + acc, ok := total[key] + if !ok { + acc = &[numActions]float64{} + total[key] = acc + } + for i, x := range v { + acc[i] += x + } + } + } + + strategy := make(map[string][numActions]float64, len(total)) + for key, v := range total { + var sum float64 + for _, x := range v { + sum += x + } + var probs [numActions]float64 + if sum > 0 { + for i, x := range v { + probs[i] = x / sum + } + } else { + for i := range probs { + probs[i] = 1.0 / numActions + } + } + strategy[key] = probs + } + + return &Trained{ + Strategy: strategy, + Meta: TrainMeta{ + Iterations: n, + Stakes: fmt.Sprintf("%d/%d", t.SB, t.BB), + Depths: fmt.Sprintf("%d–%d BB", minBB, maxBB), + Nodes: len(strategy), + }, + } +} + +type trainer struct { + reg regrets + avg regrets + tier Tier + minBB int64 + maxBB int64 + rng *rand.Rand + + // A hand's equity on a given street depends on the cards and nothing else — + // not on how the betting went to get there. The deck is fixed for the whole + // iteration, so the flop is the same flop down every branch, and this is + // measured once per seat per street instead of once per node. + eq [2][4]Equity + have [2][4]bool +} + +// iterate deals one hand and walks it once for each player. +// +// The stack depth is drawn fresh every hand, across the whole range the table +// allows. This is the fix for the policy that came before: it was trained at ten +// big blinds and nothing else, so four out of five spots in a real cash game fell +// outside anything it had ever seen. A hand of poker is a different game at 20 +// big blinds than at 100 — that is most of what makes it a game — and the bots +// have to have played both. +func (tr *trainer) iterate(id uint64) { + depth := tr.minBB + if tr.maxBB > tr.minBB { + depth += tr.rng.Int64N(tr.maxBB - tr.minBB + 1) + } + stack := depth * tr.tier.BB + + // No rake while learning. The bots should learn to play poker, not to beat a + // fee, and the fee is the house's business. + t := tr.tier + t.RakePct = 0 + + s, err := Open(t, stack, stack, id, tr.rng.Uint64()) + if err != nil { + return + } + start := [2]int64{s.Seats[0].Stack + s.Seats[0].Bet, s.Seats[1].Stack + s.Seats[1].Bet} + + tr.have = [2][4]bool{} // one deal, one set of boards, one set of equities + + for me := 0; me < 2; me++ { + tr.walk(s.Clone(), me, start, 0) + } +} + +// equity is the cached measurement for this seat on this street. +func (tr *trainer) equity(s State, seat int) Equity { + st := s.Street + if st > River { + st = River + } + if !tr.have[seat][st] { + tr.eq[seat][st] = s.equityFor(seat, trainMCIters, tr.rng) + tr.have[seat][st] = true + } + return tr.eq[seat][st] +} + +// walk returns what the hand is worth to `me`, in chips, from here. +func (tr *trainer) walk(s State, me int, start [2]int64, depth int) float64 { + if s.Phase != PhaseBetting || depth > maxDepth { + // The hand is over (or we have gone far enough to call it over). What it was + // worth is simply what the player has now against what they sat down with — + // the real number, out of the real engine, side pots and all. + return float64(s.Seats[me].Stack - start[me]) + } + + seat := s.ToAct + key := s.spotKey(seat, tr.equity(s, seat)) + + mask := s.mask(seat) + if raises(s.History) >= maxRaisesPerStreet { + mask[actRaiseHalf], mask[actRaisePot] = false, false + } + + reg := tr.reg[key] + if reg == nil { + reg = &[numActions]float64{} + tr.reg[key] = reg + } + strat := match(*reg, mask) + + // The opponent's turn: sample one line and follow it. That is the "external + // sampling" part, and it is what keeps a hand from costing 5^12 traversals. + if seat != me { + avg := tr.avg[key] + if avg == nil { + avg = &[numActions]float64{} + tr.avg[key] = avg + } + for i, p := range strat { + avg[i] += p + } + return tr.walk(tr.play(s, seat, sample(strat, tr.rng)), me, start, depth+1) + } + + // Our turn: try everything, and regret what we didn't do. + var values [numActions]float64 + var node float64 + for a := 0; a < numActions; a++ { + if !mask[a] { + continue + } + values[a] = tr.walk(tr.play(s, seat, a), me, start, depth+1) + node += strat[a] * values[a] + } + for a := 0; a < numActions; a++ { + if mask[a] { + reg[a] += values[a] - node + } + } + return node +} + +// play applies one abstract action through the real reducer. +func (tr *trainer) play(s State, seat, action int) State { + next, _, err := Step(s.Clone(), s.moveFor(action, seat)) + if err != nil { + // The mask and the rules disagreed, which is a bug in one of them. Fold and + // carry on rather than poison the whole run. + next, _, err = Step(s.Clone(), Move{Kind: Fold}) + if err != nil { + return s + } + } + return next +} + +// match is regret matching: play each action in proportion to how much you wish +// you had played it. An action nobody regrets not taking gets played uniformly. +func match(reg [numActions]float64, mask [numActions]bool) [numActions]float64 { + var strat [numActions]float64 + var sum float64 + for i, r := range reg { + if mask[i] && r > 0 { + sum += r + } + } + if sum > 0 { + for i, r := range reg { + if mask[i] && r > 0 { + strat[i] = r / sum + } + } + return strat + } + + n := 0 + for _, ok := range mask { + if ok { + n++ + } + } + if n == 0 { + strat[actCallCheck] = 1 + return strat + } + for i, ok := range mask { + if ok { + strat[i] = 1 / float64(n) + } + } + return strat +} + +func sample(strat [numActions]float64, rng *rand.Rand) int { + r := rng.Float64() + var sum float64 + for i, p := range strat { + sum += p + if r < sum { + return i + } + } + return actCallCheck +} + +// raises counts the bets and raises on this street, which is what the tree is +// capped on. +func raises(history string) int { + n := 0 + for _, c := range history { + if c == 'r' || c == 'R' { + n++ + } + } + return n +} + +// ---- the file -------------------------------------------------------------- + +// Save writes a trained policy. +func Save(w io.Writer, t *Trained) error { return gob.NewEncoder(w).Encode(t) } + +// Load reads one. It is only used by the tests — the bots read the embedded copy. +func Load(r io.Reader) (*Trained, error) { + var t Trained + if err := gob.NewDecoder(r).Decode(&t); err != nil { + return nil, err + } + return &t, nil +} + +// loadTrained decodes the embedded policy in the new format. +func loadTrained(b []byte) (*Trained, error) { return Load(bytes.NewReader(b)) } diff --git a/internal/web/games_holdem.go b/internal/web/games_holdem.go new file mode 100644 index 0000000..fa28f6b --- /dev/null +++ b/internal/web/games_holdem.go @@ -0,0 +1,347 @@ +package web + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + + "pete/internal/games/blackjack" + "pete/internal/games/holdem" + "pete/internal/storage" +) + +// Texas hold'em, played for chips against the trained bots. +// +// This is the only table in the casino that is a *session* rather than a game. +// Everywhere else you stake, you play once, and a multiple pays: the hand is the +// unit and the money moves at both ends of it. Poker is not that shape. You buy +// chips onto the table, you play as many hands as you feel like, and you leave +// with whatever is in front of you — so the live row lives across hands, and the +// chips move exactly twice: once when you sit down, once when you get up. +// +// Which means there is no "payout" until you stand up, and `commit` is only ever +// told the game is Done when you do (or when you have nothing left to sit with). +// In between, every pot won and lost is inside the engine, and storage sees none +// of it. That is the honest model, and it is also the safe one: a hand that dies +// halfway leaves the chips where they were, on the table, in the live row. +// +// What the browser is allowed to see: your two cards, the board, everybody's +// stacks and bets, and nothing else. Not the deck, and not a bot's hand — until +// a showdown turns it over, which is the moment it stops being a secret. + +// holdemSeatView is one seat. Cards are present only when the viewer is entitled +// to them: yours always, a bot's never, until the hand is shown down. +type holdemSeatView struct { + Name string `json:"name"` + Bot bool `json:"bot"` + You bool `json:"you"` + Stack int64 `json:"stack"` + Bet int64 `json:"bet"` + State string `json:"state"` // active | folded | allin | out + Pos string `json:"pos"` // BTN, SB, BB, UTG… + Cards []cardView `json:"cards,omitempty"` + Won int64 `json:"won,omitempty"` +} + +var seatStates = map[holdem.SeatState]string{ + holdem.Active: "active", + holdem.Folded: "folded", + holdem.AllIn: "allin", + holdem.Out: "out", +} + +// holdemView is the table as its player may see it. +type holdemView struct { + Tier holdem.Tier `json:"tier"` + Seats []holdemSeatView `json:"seats"` + Button int `json:"button"` + HandNo int `json:"hand_no"` + + Board []cardView `json:"board"` + Street string `json:"street"` + Pot int64 `json:"pot"` + Side []int64 `json:"side,omitempty"` + + ToAct int `json:"to_act"` + Phase string `json:"phase"` + + // What you may do, decided here rather than in the browser — the felt should + // never offer a button the table would refuse. + Owed int64 `json:"owed"` + CanCheck bool `json:"can_check"` + CanRaise bool `json:"can_raise"` + MinRaise int64 `json:"min_raise_to"` + MaxRaise int64 `json:"max_raise_to"` + + Stack int64 `json:"stack"` // what's in front of you + BoughtIn int64 `json:"bought_in"` + Rake int64 `json:"rake"` // what the house has taken this session + MaxTopUp int64 `json:"max_topup"` + Payout int64 `json:"payout,omitempty"` +} + +func viewHoldem(g holdem.State) holdemView { + v := holdemView{ + Tier: g.Tier, + Button: g.Button, + HandNo: g.HandNo, + Street: g.Street.String(), + Pot: g.Total(), + ToAct: g.ToAct, + Phase: string(g.Phase), + Stack: g.Seats[holdem.You].Stack, + BoughtIn: g.BoughtIn, + Rake: g.Rake, + Payout: g.Payout, + } + for _, p := range g.Side { + v.Side = append(v.Side, p.Amount) + } + // An empty board is an empty board, not null. A Go slice with nothing in it + // marshals to null, and a browser that has to write `(board || [])` everywhere + // is a browser one forgotten guard away from a crash on every preflop. + v.Board = []cardView{} + for _, c := range g.Community { + v.Board = append(v.Board, viewCard(c)) + } + + // The wall. A bot's hand crosses the wire in exactly one situation — the hand + // was shown down and they did not fold — because that is the only situation in + // which a player at a real table would be looking at it. + shown := g.Street == holdem.Showdown + for i, p := range g.Seats { + seat := holdemSeatView{ + Name: p.Name, + Bot: p.Bot, + You: i == holdem.You, + Stack: p.Stack, + Bet: p.Bet, + State: seatStates[p.State], + Pos: g.Position(i), + Won: p.Won, + } + mine := i == holdem.You + dealt := p.State != holdem.Out && p.Hole[0].Rank != 0 + if dealt && (mine || (shown && p.State != holdem.Folded)) { + seat.Cards = []cardView{viewCard(p.Hole[0]), viewCard(p.Hole[1])} + } + v.Seats = append(v.Seats, seat) + } + + if g.Phase == holdem.PhaseBetting && g.ToAct == holdem.You { + v.Owed = g.Owed(holdem.You) + v.CanCheck = v.Owed == 0 + v.CanRaise = g.CanRaise(holdem.You) + v.MinRaise = g.MinRaiseTo(holdem.You) + v.MaxRaise = g.MaxRaiseTo(holdem.You) + } + if top := g.Tier.MaxBuy - g.Seats[holdem.You].Stack; top > 0 { + v.MaxTopUp = top + } + return v +} + +// holdemEventView is one beat of the script the felt plays back. The engine only +// ever attaches a bot's cards to a showdown; this drops them again anywhere else, +// which is the second of the two walls. +type holdemEventView struct { + Kind string `json:"kind"` + Seat int `json:"seat"` + Cards []cardView `json:"cards,omitempty"` + Amount int64 `json:"amount,omitempty"` + Total int64 `json:"total,omitempty"` + Text string `json:"text,omitempty"` +} + +func viewHoldemEvents(evs []holdem.Event, g holdem.State) []holdemEventView { + out := make([]holdemEventView, 0, len(evs)) + for _, e := range evs { + v := holdemEventView{Kind: e.Kind, Seat: e.Seat, Amount: e.Amount, Total: e.Total, Text: e.Text} + for _, c := range e.Cards { + v.Cards = append(v.Cards, viewCard(c)) + } + // A card may ride an event only if it is the board, your own hand, or a hand + // being shown down. Anything else is a bot's business. + if len(v.Cards) > 0 && e.Seat >= 0 && e.Seat != holdem.You && e.Kind != "show" { + v.Cards = nil + } + out = append(out, v) + } + return out +} + +// handleHoldemSit buys chips onto a table. The chips are staked first, in the +// same statement that checks they exist, so two sit-downs fired at once cannot +// buy in with the same chip. +func (s *Server) handleHoldemSit(w http.ResponseWriter, r *http.Request) { + user, ok := s.player(w, r) + if !ok { + return + } + var req struct { + Tier string `json:"tier"` + Bots int `json:"bots"` + BuyIn int64 `json:"buyin"` + } + if err := decodeJSON(r, &req); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + tier, err := holdem.TierBySlug(req.Tier) + if err != nil { + writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a table"}) + return + } + if req.BuyIn < tier.MinBuy || req.BuyIn > tier.MaxBuy { + writeJSONStatus(w, http.StatusBadRequest, map[string]string{ + "error": "that isn't a legal buy-in for this table", + }) + return + } + if req.Bots < 1 || req.Bots > holdem.MaxBots { + writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick some opponents"}) + return + } + + if err := storage.Stake(user, req.BuyIn); err != nil { + if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) { + writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips to sit down"}) + return + } + slog.Error("games: holdem buy-in", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + seed1, seed2 := newSeeds() + g, evs, err := holdem.New(tier, req.Bots, req.BuyIn, blackjack.DefaultRules().RakePct, seed1, seed2) + if err != nil { + _ = storage.Award(user, req.BuyIn) // nobody sat down, so nothing was bought + slog.Error("games: holdem sit", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + s.persistHoldem(w, user, g, evs, seed1, seed2, true) +} + +// handleHoldemMove plays one move: an action in a hand, or the three that are +// about the session — deal the next hand, put more chips on the table, get up. +func (s *Server) handleHoldemMove(w http.ResponseWriter, r *http.Request) { + user, ok := s.player(w, r) + if !ok { + return + } + var move holdem.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": "you're not at a table"}) + return + } + if err != nil { + slog.Error("games: holdem load", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if live.Game != gameHoldem { + writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"}) + return + } + var g holdem.State + if err := json.Unmarshal(live.State, &g); err != nil { + slog.Error("games: unreadable holdem table", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + // A top-up is real money crossing the border, so the chips come off the stack + // before the engine is asked — and go straight back if it says no. Same order, + // and the same reason, as doubling down at blackjack. + topped := int64(0) + if move.Kind == holdem.TopUp { + if err := storage.Stake(user, move.Amount); err != nil { + if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) { + writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you don't have those chips"}) + return + } + slog.Error("games: holdem top-up", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + topped = move.Amount + } + + next, evs, err := holdem.ApplyMove(g, move) + if err != nil { + if topped > 0 { + _ = storage.Award(user, topped) // the top-up didn't happen + } + msg := "that move isn't legal here" + switch { + case errors.Is(err, holdem.ErrHandLive): + msg = "finish the hand first" + case errors.Is(err, holdem.ErrNotYourTurn): + msg = "it isn't your turn" + case errors.Is(err, holdem.ErrCantCheck): + msg = "there's a bet to you" + case errors.Is(err, holdem.ErrTooSmall): + msg = "that's under the minimum raise" + case errors.Is(err, holdem.ErrTooBig): + msg = "you don't have that many chips" + case errors.Is(err, holdem.ErrBadBuyIn): + msg = "that would put you over the table maximum" + } + writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg}) + return + } + s.persistHoldem(w, user, next, evs, live.Seed1, live.Seed2, false) +} + +// persistHoldem writes the table back and answers the browser. +// +// The session settles exactly once — when the player gets up, or when they have +// nothing left to get up with. Until then Done is false and `commit` moves no +// chips at all, which is what makes a hundred hands of poker a single trip across +// the border rather than a hundred. +func (s *Server) persistHoldem(w http.ResponseWriter, user string, g holdem.State, evs []holdem.Event, seed1, seed2 uint64, fresh bool) { + blob, err := json.Marshal(g) + if err != nil { + slog.Error("games: marshal holdem", "user", user, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + done := g.Phase == holdem.PhaseDone + outcome := "left" + switch { + case done && g.Payout == 0: + outcome = "busted" + case done && g.Payout > g.BoughtIn: + outcome = "up" + case done && g.Payout < g.BoughtIn: + outcome = "down" + } + + v, ok := s.commit(w, user, finished{ + Game: gameHoldem, Blob: blob, + Bet: g.BoughtIn, Payout: g.Payout, Rake: g.Rake, + Outcome: outcome, Done: done, + Seed1: seed1, Seed2: seed2, Fresh: fresh, + }) + if !ok { + return + } + // A closed session is gone from storage, so the table view has none to show — + // but the browser still needs the last board to land the verdict on. + if done { + hv := viewHoldem(g) + v.Holdem = &hv + } + v.HoldemEvents = viewHoldemEvents(evs, g) + writeJSON(w, v) +} diff --git a/internal/web/games_holdem_test.go b/internal/web/games_holdem_test.go new file mode 100644 index 0000000..0df423e --- /dev/null +++ b/internal/web/games_holdem_test.go @@ -0,0 +1,239 @@ +package web + +import ( + "testing" + + "pete/internal/games/holdem" + "pete/internal/storage" +) + +// Sitting down is the only time chips leave your stack at this table, and getting +// up is the only time they come back. Everything in between is inside the engine. +func TestHoldemSitTakesTheBuyInAndNothingElse(t *testing.T) { + s := newCasino(t) + fund(t, 5000) + + v, code := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit", + map[string]any{"tier": "low", "bots": 2, "buyin": 600})) + if code != 200 { + t.Fatalf("sit = %d, want 200", code) + } + if v.Chips != 4400 { + t.Fatalf("chips after a 600 buy-in = %d, want 4400", v.Chips) + } + if v.Game != gameHoldem || v.Holdem == nil { + t.Fatalf("sit returned no table: game=%q", v.Game) + } + + g := v.Holdem + if g.Stack != 600 { + t.Errorf("you sat down with %d, want the 600 you bought in for", g.Stack) + } + if len(g.Seats) != 3 { + t.Fatalf("two bots and you is three seats, got %d", len(g.Seats)) + } + if g.Phase != "handover" { + t.Errorf("phase %q — a table you just sat at has no hand on it yet", g.Phase) + } + + // Play a whole hand, then check that not one chip has crossed the border. + deal, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move", + map[string]any{"move": "deal"})) + if code != 200 { + t.Fatalf("deal = %d, want 200", code) + } + for i := 0; deal.Holdem != nil && deal.Holdem.Phase == "betting" && i < 60; i++ { + move := "check" + if deal.Holdem.Owed > 0 { + move = "fold" + } + deal, code = call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move", + map[string]any{"move": move})) + if code != 200 { + t.Fatalf("%s = %d, want 200", move, code) + } + } + if deal.Chips != 4400 { + t.Errorf("chips moved during a hand: %d, want the 4400 that were left after the buy-in — "+ + "a pot is settled inside the engine, not across the border", deal.Chips) + } +} + +// The wall. A bot's hole cards are the game; a browser that held them would make +// this unplayable, and the payload is where that has to be true. +func TestHoldemNeverSendsABotsCards(t *testing.T) { + s := newCasino(t) + fund(t, 5000) + + v, _ := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit", + map[string]any{"tier": "micro", "bots": 3, "buyin": 200})) + if v.Holdem == nil { + t.Fatal("no table") + } + + // Play hands until one of them ends without a showdown, checking every payload + // on the way. Folding is the case that matters: nobody has earned the right to + // see anybody's cards, so nobody's may be in there. + for hand := 0; hand < 8; hand++ { + v, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move", + map[string]any{"move": "deal"})) + if code != 200 { + t.Fatalf("deal = %d", code) + } + noBotCards(t, v) + + for i := 0; v.Holdem != nil && v.Holdem.Phase == "betting" && i < 60; i++ { + move := "check" + if v.Holdem.Owed > 0 { + move = "call" + } + v, code = call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move", + map[string]any{"move": move})) + if code != 200 { + t.Fatalf("%s = %d", move, code) + } + noBotCards(t, v) + } + if v.Holdem == nil || v.Holdem.Phase == "done" { + return // busted out; the wall held all the way + } + } +} + +// noBotCards checks a payload. A bot's cards may appear in exactly one place: a +// seat that is being shown down, on a board that reached a showdown. +func noBotCards(t *testing.T, v tableView) { + t.Helper() + g := v.Holdem + if g == nil { + return + } + shown := g.Street == "showdown" + for i, seat := range g.Seats { + if i == 0 || len(seat.Cards) == 0 { + continue + } + if !shown { + t.Fatalf("seat %d (%s) sent %d cards on the %s — nobody has shown down", + i, seat.Name, len(seat.Cards), g.Street) + } + if seat.State == "folded" { + t.Fatalf("seat %d (%s) folded and its cards were sent anyway", i, seat.Name) + } + } + for _, e := range v.HoldemEvents { + if e.Seat > 0 && len(e.Cards) > 0 && e.Kind != "show" { + t.Fatalf("a %q event carries seat %d's cards — that's a bot's hand", e.Kind, e.Seat) + } + } +} + +// Getting up is what pays. Everything the session did lands in one movement. +func TestHoldemLeavingBringsTheStackBack(t *testing.T) { + s := newCasino(t) + fund(t, 5000) + + v, _ := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit", + map[string]any{"tier": "low", "bots": 1, "buyin": 500})) + if v.Chips != 4500 || v.Holdem == nil { + t.Fatalf("sit: chips=%d holdem=%v", v.Chips, v.Holdem) + } + stack := v.Holdem.Stack + + v, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move", + map[string]any{"move": "leave"})) + if code != 200 { + t.Fatalf("leave = %d, want 200", code) + } + if v.Chips != 4500+stack { + t.Errorf("chips after getting up = %d, want %d (the %d that was in front of us)", + v.Chips, 4500+stack, stack) + } + if v.Game != "" { + t.Errorf("still at a table after getting up: %q", v.Game) + } + + // And there is no table left to play at. + _, code = call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move", + map[string]any{"move": "deal"})) + if code != 409 { + t.Errorf("dealt a hand at a table we got up from: %d, want 409", code) + } +} + +// You cannot walk out on a hand you have chips riding on. +func TestHoldemCannotLeaveMidHand(t *testing.T) { + s := newCasino(t) + fund(t, 5000) + + call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit", + map[string]any{"tier": "low", "bots": 2, "buyin": 500})) + v, _ := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move", + map[string]any{"move": "deal"})) + if v.Holdem == nil || v.Holdem.Phase != "betting" { + t.Skip("the hand ended before we could act") + } + + _, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move", + map[string]any{"move": "leave"})) + if code != 400 { + t.Errorf("left in the middle of a hand: %d, want 400", code) + } + + // And the chips are still on the table, not back on the stack. + after, err := storage.Chips("@reala:parodia.dev") + if err != nil { + t.Fatal(err) + } + if after.Chips != 4500 { + t.Errorf("chips = %d, want 4500 — the buy-in is still on the table", after.Chips) + } +} + +// A buy-in outside the table's range is not a buy-in, and it must not take chips. +func TestHoldemRefusesABadBuyIn(t *testing.T) { + s := newCasino(t) + fund(t, 5000) + + tier, _ := holdem.TierBySlug("low") + for _, amount := range []int64{tier.MinBuy - 1, tier.MaxBuy + 1, 0} { + _, code := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit", + map[string]any{"tier": "low", "bots": 2, "buyin": amount})) + if code != 400 { + t.Errorf("buy-in of %d at a %d–%d table = %d, want 400", amount, tier.MinBuy, tier.MaxBuy, code) + } + } + + st, err := storage.Chips("@reala:parodia.dev") + if err != nil { + t.Fatal(err) + } + if st.Chips != 5000 { + t.Errorf("a refused buy-in took %d chips", 5000-st.Chips) + } +} + +// A top-up is real money crossing the border. If the engine refuses it, the chips +// come straight back — the same order, and the same reason, as doubling down. +func TestHoldemTopUpRefundsWhenRefused(t *testing.T) { + s := newCasino(t) + fund(t, 5000) + + // Sitting down at the maximum means there is no room to top up into. + call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit", + map[string]any{"tier": "low", "bots": 1, "buyin": 1000})) + + _, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move", + map[string]any{"move": "topup", "amount": 100})) + if code != 400 { + t.Errorf("topped up over the table maximum: %d, want 400", code) + } + + st, err := storage.Chips("@reala:parodia.dev") + if err != nil { + t.Fatal(err) + } + if st.Chips != 4000 { + t.Errorf("chips = %d, want 4000 — a refused top-up must give the chips back", st.Chips) + } +} diff --git a/internal/web/games_pages.go b/internal/web/games_pages.go index 838cf27..1745203 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/holdem" "pete/internal/games/klondike" "pete/internal/games/trivia" "pete/internal/games/uno" @@ -28,9 +29,10 @@ type gameTeaser struct { Blurb string } -var comingSoon = []gameTeaser{ - {Name: "Hold'em", Emoji: "♠️", Blurb: "Six seats, and the house bots know how to play."}, -} +// comingSoon is empty, and that is the point: every game the plan named is now on +// the felt. Leave it here — the lobby renders nothing for an empty list, and the +// next game to be dreamed up goes in it. +var comingSoon = []gameTeaser{} // betDenominations are the chips you build a bet out of. var betDenominations = []int64{5, 25, 100, 500} @@ -78,6 +80,8 @@ type gamesPage struct { Quizzes []trivia.Tier // trivia's three difficulties Rungs int // how long the trivia ladder is Tables []uno.Tier // uno's three tables, and how many bots sit at each + Stakes []holdem.Tier // hold'em's three tables, by blinds + MaxBots int // how many seats hold'em will fill with bots } // casinoRoutes hangs every table off the mux. @@ -93,6 +97,7 @@ func (s *Server) casinoRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /games/solitaire", s.handleSolitaire) mux.HandleFunc("GET /games/trivia", s.handleTrivia) mux.HandleFunc("GET /games/uno", s.handleUno) + mux.HandleFunc("GET /games/holdem", s.handleHoldem) mux.HandleFunc("GET /api/games/table", s.handleTable) mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn) @@ -112,6 +117,9 @@ func (s *Server) casinoRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /api/games/uno/start", s.handleUnoStart) mux.HandleFunc("POST /api/games/uno/move", s.handleUnoMove) + + mux.HandleFunc("POST /api/games/holdem/sit", s.handleHoldemSit) + mux.HandleFunc("POST /api/games/holdem/move", s.handleHoldemMove) } // requirePlayer sends an anonymous visitor to sign in and comes back here after. @@ -145,6 +153,8 @@ func (s *Server) gamesPage(r *http.Request) gamesPage { Quizzes: trivia.Tiers, Rungs: trivia.Rungs, Tables: uno.Tiers, + Stakes: holdem.Tiers, + MaxBots: holdem.MaxBots, } } @@ -189,3 +199,10 @@ func (s *Server) handleUno(w http.ResponseWriter, r *http.Request) { } s.render(w, "uno", s.gamesPage(r)) } + +func (s *Server) handleHoldem(w http.ResponseWriter, r *http.Request) { + if !s.requirePlayer(w, r) { + return + } + s.render(w, "holdem", s.gamesPage(r)) +} diff --git a/internal/web/games_play.go b/internal/web/games_play.go index 1f7987e..abeeb0e 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/holdem" "pete/internal/games/klondike" "pete/internal/games/trivia" "pete/internal/games/uno" @@ -197,6 +198,9 @@ type tableView struct { Uno *unoView `json:"uno,omitempty"` UnoEvents []unoEventView `json:"uno_events,omitempty"` + Holdem *holdemView `json:"holdem,omitempty"` + HoldemEvents []holdemEventView `json:"holdem_events,omitempty"` + Rake float64 `json:"rake_pct"` } @@ -264,6 +268,13 @@ func (s *Server) table(user string) (tableView, error) { } uv := viewUno(g) v.Uno = &uv + case gameHoldem: + var g holdem.State + if err := json.Unmarshal(live.State, &g); err != nil { + return s.dropUnreadable(user, v, err) + } + hv := viewHoldem(g) + v.Holdem = &hv default: return s.dropUnreadable(user, v, fmt.Errorf("unknown game %q", live.Game)) } @@ -521,6 +532,7 @@ const ( gameSolitaire = "solitaire" gameTrivia = "trivia" gameUno = "uno" + gameHoldem = "holdem" ) // finished is what commit needs to know about a game it's writing back: enough diff --git a/internal/web/games_render_test.go b/internal/web/games_render_test.go index d33a9c4..85ff60c 100644 --- a/internal/web/games_render_test.go +++ b/internal/web/games_render_test.go @@ -23,6 +23,7 @@ func TestEveryCasinoPageRenders(t *testing.T) { "/games/solitaire", "/games/trivia", "/games/uno", + "/games/holdem", } mux := http.NewServeMux() diff --git a/internal/web/server.go b/internal/web/server.go index d3c0f5a..4d55626 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -95,7 +95,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo pages []string }{ {"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}}, - {"games_layout", []string{"_chipbar"}, []string{"games", "blackjack", "hangman", "solitaire", "trivia", "uno"}}, + {"games_layout", []string{"_chipbar"}, []string{"games", "blackjack", "hangman", "solitaire", "trivia", "uno", "holdem"}}, } 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 54d6a31..747a6a9 100644 --- a/internal/web/static/css/input.css +++ b/internal/web/static/css/input.css @@ -1894,3 +1894,257 @@ html[data-room] .pete-felt { .cs-stack[data-chip="25"] { --chip: #4caf7d; } .cs-stack[data-chip="100"] { --chip: #2b2118; } .cs-stack[data-chip="500"] { --chip: #b079d6; } + +/* ── Hold'em ──────────────────────────────────────────────────────────────── + The one table with other people at it. Everything else in the casino has a + single bet spot, because there was only ever one player; here every seat has + its own, chips travel between them and the pot rather than between you and the + house, and the pot in the middle is the thing they all move toward. + + Seats are a wrapping row rather than an ellipse. An oval of six seats is what a + poker table looks like, and it is also what stops fitting the moment a phone is + held up — this keeps the geometry honest at 390px and still reads as a table + because the board and the pot sit in the middle of it, which is where the eye + goes anyway. */ + +.pete-poker { --seat-w: 7.5rem; } + +.pete-poker-seats { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: center; + gap: 0.75rem; +} + +.pete-seat { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + width: var(--seat-w); + transition: opacity 0.3s ease, filter 0.3s ease; +} +/* A folded seat is still there — you can see they're in the game and out of the + hand, which is information you're entitled to. It just stops competing. */ +.pete-seat[data-state="folded"] { opacity: 0.38; filter: grayscale(0.7); } +.pete-seat[data-state="out"] { opacity: 0.2; } + +/* Whose turn it is. The ring is the only thing on the felt that moves on its own, + because it is the only thing you are waiting for. */ +.pete-seat[data-turn="1"] .pete-seat-plate { + box-shadow: 0 0 0 2px var(--accent), 0 0 1.6rem -0.2rem var(--accent); + animation: pete-seat-wait 1.6s ease-in-out infinite; +} +@keyframes pete-seat-wait { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-2px); } +} + +.pete-seat-cards { + display: flex; + justify-content: center; + gap: 0.15rem; + min-height: 4.4rem; + --card-h: 4.4rem; + --card-w: 3.15rem; +} +/* A seat that folded throws its cards in. */ +.pete-seat-cards[data-mucked="1"] { opacity: 0; transform: translateY(-0.6rem) scale(0.9); transition: all 0.35s ease; } + +.pete-seat-plate { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + padding: 0.3rem 0.4rem; + border-radius: 0.85rem; + background: rgba(0,0,0,0.32); + border: 1px solid rgba(255,255,255,0.12); + transition: box-shadow 0.25s ease; +} +.pete-seat-name { + font-family: var(--font-display, inherit); + font-weight: 700; + font-size: 0.8rem; + line-height: 1.1; + color: rgba(255,255,255,0.92); + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.pete-seat-stack { + font-weight: 700; + font-size: 0.78rem; + font-variant-numeric: tabular-nums; + color: rgba(255,255,255,0.58); +} +.pete-seat[data-state="allin"] .pete-seat-stack::after { + content: " all in"; + color: var(--accent); +} + +/* The dealer button, and the blinds. Small, and worth having: position is most of + what makes one hand different from the next, and a player who cannot see where + the button is cannot see the game. */ +.pete-seat-pos { + position: absolute; + top: -0.5rem; + right: -0.5rem; + display: grid; + place-items: center; + min-width: 1.45rem; + height: 1.45rem; + padding: 0 0.3rem; + border-radius: 999px; + font-size: 0.58rem; + font-weight: 800; + letter-spacing: 0.02em; + background: rgba(255,255,255,0.9); + color: #2b2118; + box-shadow: 0 2px 0 rgba(0,0,0,0.3); +} +.pete-seat-pos[data-pos="BTN"] { background: #f5d76e; } + +/* Every seat's bet, sitting between them and the pot. + A .pete-spot is 7rem across, because blackjack has exactly one of them. Six of + those is most of a felt, so a seat's is less than half the size — and so are the + chips in it, which are otherwise bigger than the ring they land in. */ +.pete-seat-spot { + height: 3.6rem; + width: 3.6rem; + margin-bottom: 0.5rem; /* the bet total hangs below the ring; leave it somewhere to hang */ +} +.pete-seat-spot .pete-disc { + height: 1.6rem; + width: 1.6rem; + margin: -0.8rem 0 0 -0.8rem; +} +.pete-seat-spot .pete-spot-total { + font-size: 0.66rem; + padding: 0.05rem 0.45rem; +} + +/* The middle of the table: the board, and the pot under it. */ +.pete-poker-middle { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35rem; + padding: 0.5rem 0; +} +/* The pot's chips stack upwards, so they need room under the board or they climb + into the river card. */ +.pete-poker-pot { margin-top: 0.9rem; } +/* The board keeps its height when it is empty, so the felt does not jump a hundred + pixels the moment the flop lands. */ +.pete-poker-board { + display: flex; + gap: 0.35rem; + min-height: 6rem; + --card-h: 6rem; + --card-w: 4.3rem; +} +.pete-poker-pot { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.1rem; +} +/* The pot's chips need a box to themselves. .pete-stack is position:absolute with + inset:0, so a pile that shares a box with the number under it lands on top of + the number — which is exactly what it did: the pot showed a chip and no total. */ +.pete-poker-pot-pile { + position: relative; + width: 7rem; + height: 3rem; +} +.pete-poker-pot-label { + font-size: 0.62rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.08em; + color: rgba(255,255,255,0.42); +} +.pete-poker-pot-total { + font-family: var(--font-display, inherit); + font-size: 1.5rem; + font-weight: 800; + font-variant-numeric: tabular-nums; + color: #fff; + text-shadow: 0 2px 0 rgba(0,0,0,0.28); +} +.pete-poker-side { + font-size: 0.68rem; + font-weight: 700; + color: rgba(255,255,255,0.5); +} + +/* Your seat. Bigger cards, because they are the two you are actually reading. */ +.pete-poker-you .pete-seat { width: auto; } +.pete-poker-you .pete-seat-cards { + --card-h: 6.8rem; + --card-w: 4.85rem; + gap: 0.3rem; + min-height: 6.8rem; +} +.pete-poker-you .pete-seat-plate { padding: 0.4rem 1.1rem; } +.pete-poker-you .pete-seat-name { font-size: 0.95rem; } +.pete-poker-you .pete-seat-stack { font-size: 0.95rem; } + +/* What the hand did to you, said once, in the middle of the felt. */ +.pete-poker-verdict { + border-radius: 999px; + background: rgba(255,255,255,0.95); + padding: 0.5rem 1.25rem; + font-family: var(--font-display, inherit); + font-size: 1.05rem; + font-weight: 700; + color: #2b2118; + box-shadow: 0 4px 0 rgba(0,0,0,0.18); +} +.pete-poker-verdict[data-tone="win"] { background: #4caf7d; color: #fff; } +.pete-poker-verdict[data-tone="lose"] { background: rgba(255,255,255,0.9); color: #7a5c50; } + +/* The action bar. Raise is a slider, because a raise is a *size* and a text box + makes you type a number you have not thought about. */ +.pete-raise { + display: flex; + align-items: center; + gap: 0.6rem; + flex: 1 1 14rem; + min-width: 0; +} +.pete-raise input[type="range"] { + flex: 1; + min-width: 0; + accent-color: var(--accent); +} +.pete-raise-to { + font-family: var(--font-display, inherit); + font-size: 1.15rem; + font-weight: 800; + font-variant-numeric: tabular-nums; + min-width: 3.5rem; + text-align: right; +} + +.pete-poker-log { + max-height: 7rem; + overflow-y: auto; + font-size: 0.75rem; + line-height: 1.5; + color: rgba(255,255,255,0.55); +} +.pete-poker-log b { color: rgba(255,255,255,0.85); font-weight: 700; } + +@media (max-width: 640px) { + .pete-poker { --seat-w: 5.6rem; } + .pete-seat-cards { --card-h: 3.5rem; --card-w: 2.5rem; min-height: 3.5rem; } + .pete-poker-board { --card-h: 5rem; --card-w: 3.55rem; min-height: 5rem; gap: 0.25rem; } + .pete-poker-you .pete-seat-cards { --card-h: 6rem; --card-w: 4.3rem; min-height: 6rem; } + .pete-poker-pot-total { font-size: 1.25rem; } +} diff --git a/internal/web/static/css/output.css b/internal/web/static/css/output.css index b8afa8d..6cacbfc 100644 --- a/internal/web/static/css/output.css +++ b/internal/web/static/css/output.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}body{font-family:Nunito,system-ui,sans-serif}html{scroll-behavior:smooth}body,html{overflow-x:hidden}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.pete-channel-nav{scrollbar-width:none;-ms-overflow-style:none}.pete-channel-nav::-webkit-scrollbar{display:none}.pete-reader-backdrop{position:absolute;inset:0;background:rgba(20,14,6,.55);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}html[data-phase=night] .pete-reader-backdrop{background:rgba(6,8,20,.65)}.pete-reader-shell{position:relative;height:100%;width:100%;max-width:44rem;margin:0 auto;display:flex;flex-direction:column;padding:max(env(safe-area-inset-top),.75rem) 1rem .75rem;gap:.75rem}.pete-reader-bar{display:flex;align-items:center;justify-content:space-between;gap:.75rem;color:#fff}.pete-reader-progress{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:600;font-size:.85rem;letter-spacing:.02em;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-nav{display:flex;align-items:center;gap:.4rem}.pete-reader-btn{display:inline-flex;align-items:center;justify-content:center;min-width:2.25rem;height:2.25rem;padding:0 .7rem;border-radius:9999px;background:hsla(0,0%,100%,.14);color:#fff;font-weight:700;font-size:.95rem;border:2px solid hsla(0,0%,100%,.18);cursor:pointer;transition:background .15s ease,transform .15s ease,opacity .15s ease}.pete-reader-btn:hover{background:hsla(0,0%,100%,.28);transform:translateY(-1px)}.pete-reader-btn:disabled{opacity:.35;cursor:default;transform:none}.pete-reader-btn-open{background:var(--accent);border-color:transparent;color:#1c1305;text-decoration:none}.pete-reader-btn-open:hover{background:var(--accent);filter:brightness(1.08)}.pete-reader-btn[aria-expanded=true],.pete-reader-btn[aria-pressed=true]{background:var(--accent);border-color:transparent;color:#1c1305}.pete-reader-type{position:relative;display:inline-flex}.pete-reader-typemenu[hidden]{display:none}.pete-reader-typemenu{position:absolute;top:calc(100% + .5rem);right:0;z-index:10;width:15rem;padding:.75rem;border-radius:1rem;background:var(--card);color:var(--ink);border:2px solid rgba(0,0,0,.08);box-shadow:0 10px 30px rgba(20,14,6,.28);display:flex;flex-direction:column;gap:.6rem}html[data-phase=night] .pete-reader-typemenu{border-color:hsla(0,0%,100%,.12)}.pete-reader-typerow{display:flex;align-items:center;justify-content:space-between;gap:.6rem}.pete-reader-typelabel{font-size:.8rem;font-weight:700;opacity:.7}.pete-reader-typebtns{display:inline-flex;gap:.25rem}.pete-reader-typebtns button{min-width:2rem;height:2rem;padding:0 .55rem;border-radius:.6rem;background:rgba(20,14,6,.06);color:var(--ink);font-weight:700;font-size:.82rem;border:2px solid transparent;cursor:pointer;transition:background .15s ease}html[data-phase=night] .pete-reader-typebtns button{background:hsla(0,0%,100%,.08)}.pete-reader-typebtns button:hover{background:rgba(20,14,6,.12)}.pete-reader-typebtns button.is-active{background:var(--accent);color:#1c1305;border-color:transparent}.pete-reader-voicerow[hidden]{display:none}.pete-reader-voice{font:inherit;font-size:.8rem;font-weight:600;padding:.25rem .4rem;border-radius:.55rem;border:1px solid rgba(20,14,6,.18);background:rgba(20,14,6,.06);color:inherit;max-width:11rem;cursor:pointer}html[data-phase=night] .pete-reader-voice{border-color:hsla(0,0%,100%,.14);background:hsla(0,0%,100%,.08)}.pete-reader-scroll{flex:1 1 auto;overflow-y:auto;-webkit-overflow-scrolling:touch;border-radius:1.75rem}.pete-reader-scroll:focus{outline:none}.pete-reader-article{background:var(--card);color:var(--ink);border-radius:1.75rem;border:2px solid rgba(0,0,0,.06);box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.18);padding:clamp(1.25rem,4vw,2.5rem)}html[data-phase=night] .pete-reader-article{border-color:hsla(0,0%,100%,.08)}.pete-reader-eyebrow{display:flex;align-items:center;flex-wrap:wrap;gap:.5rem;font-size:.75rem;margin-bottom:.85rem}.pete-reader-chip{display:inline-flex;align-items:center;gap:.35rem;border-radius:9999px;padding:.15rem .65rem;color:#fff;font-weight:700;text-transform:uppercase;letter-spacing:.06em}.pete-reader-source{font-weight:700;opacity:.75}.pete-reader-time{opacity:.55}.pete-reader-title{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:700;line-height:1.15;letter-spacing:-.01em;font-size:clamp(1.6rem,4.5vw,2.4rem);margin-bottom:1rem}.pete-reader-hero{width:100%;border-radius:1.1rem;margin-bottom:1.25rem;background:rgba(0,0,0,.05)}.pete-reader-scroll{--reader-fs:1.08rem}.pete-reader-body{font-size:var(--reader-fs);line-height:1.75}.pete-reader-body p{margin-bottom:1.05em}.pete-reader-body p:last-child{margin-bottom:0}.pete-reader-scroll.is-size-s{--reader-fs:0.98rem}.pete-reader-scroll.is-size-m{--reader-fs:1.08rem}.pete-reader-scroll.is-size-l{--reader-fs:1.22rem}.pete-reader-scroll.is-size-xl{--reader-fs:1.4rem}.pete-reader-scroll.is-serif .pete-reader-body{font-family:Georgia,Iowan Old Style,Times New Roman,serif}.pete-reader-scroll.is-sepia .pete-reader-article{background:#f6ecd6;color:#4a3a28;border-color:rgba(74,58,40,.16)}.pete-reader-scroll.is-sepia .pete-reader-note{border-top-color:rgba(74,58,40,.18);opacity:.8}.pete-reader-scroll.is-sepia .pete-reader-hero{background:rgba(74,58,40,.08)}.pete-reader-body p.is-speaking{background:color-mix(in srgb,var(--accent) 32%,transparent);border-radius:.4rem;box-shadow:0 0 0 .35rem color-mix(in srgb,var(--accent) 32%,transparent)}.pete-reader-note{margin-top:1.25rem;padding-top:1rem;border-top:1px solid rgba(0,0,0,.1);font-size:.85rem;opacity:.7}html[data-phase=night] .pete-reader-note{border-color:hsla(0,0%,100%,.12)}.pete-reader-note a{color:var(--accent);font-weight:700}.pete-reader-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:.75rem;padding:clamp(2rem,8vw,4rem) 1rem;color:var(--ink)}.pete-reader-empty-emoji{font-size:2.5rem}.pete-reader-toast{position:absolute;left:50%;bottom:4.5rem;transform:translate(-50%,.5rem);z-index:20;padding:.5rem .9rem;border-radius:9999px;background:rgba(20,14,6,.9);color:#fff;font-weight:700;font-size:.85rem;box-shadow:0 8px 24px rgba(0,0,0,.3);opacity:0;pointer-events:none;transition:opacity .2s ease,transform .2s ease}.pete-reader-toast.is-shown{opacity:1;transform:translate(-50%)}.pete-reader-hint{text-align:center;color:hsla(0,0%,100%,.85);font-size:.75rem;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-hint kbd{font-family:ui-monospace,monospace;background:hsla(0,0%,100%,.16);border-radius:.3rem;padding:.05rem .3rem}@media (max-width:640px){.pete-reader-hint{display:none}.pete-reader-bar{flex-wrap:wrap}.pete-reader-nav{flex-wrap:wrap;justify-content:flex-end;row-gap:.4rem}}.pete-reader-related{width:100%;margin:.85rem auto 0}.pete-reader-related-title{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:700;font-size:.95rem;color:#fff;margin:0 0 .6rem;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-related-grid{display:grid;gap:.6rem}.pete-reader-related-card{display:flex;align-items:center;gap:.75rem;background:var(--card);color:var(--ink);border-radius:1rem;border:2px solid rgba(0,0,0,.06);padding:.6rem;text-decoration:none;transition:transform .15s ease,box-shadow .15s ease}.pete-reader-related-card:hover{transform:translateY(-1px);box-shadow:0 6px 16px rgba(60,40,20,.16)}html[data-phase=night] .pete-reader-related-card{border-color:hsla(0,0%,100%,.08)}.pete-reader-related-thumb{width:4.5rem;height:3.25rem;flex-shrink:0;-o-object-fit:cover;object-fit:cover;border-radius:.6rem;background:rgba(0,0,0,.06)}.pete-reader-related-meta{min-width:0;display:flex;flex-direction:column;gap:.25rem}.pete-reader-related-eyebrow{display:flex;align-items:center;flex-wrap:wrap;gap:.4rem;font-size:.7rem}.pete-reader-related-source{font-weight:700;opacity:.7}.pete-reader-related-headline{font-weight:700;line-height:1.25;font-size:.92rem;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}[data-story-card][data-read="1"]{opacity:.5;transition:opacity .2s ease}[data-story-card][data-read="1"]:hover{opacity:1}[data-story-card][data-read="1"]:after{content:"✓";position:absolute;top:.6rem;right:.6rem;z-index:6;display:grid;place-items:center;height:1.5rem;width:1.5rem;border-radius:9999px;background:rgba(20,14,6,.72);color:#fff;font-size:.8rem;font-weight:700}.pete-felt{background:radial-gradient(120% 90% at 50% -10%,hsla(0,0%,100%,.1),transparent 60%),linear-gradient(160deg,#2f7d5b,#24614a 55%,#1c4d3c)}.pete-shoe{position:absolute;top:1.25rem;right:1.25rem;height:4.2rem;width:3rem;border-radius:.55rem;background:linear-gradient(150deg,#b4553f,#8d3f2f);border:2px solid rgba(0,0,0,.18);box-shadow:0 4px 0 rgba(0,0,0,.18),inset 0 0 0 3px hsla(0,0%,100%,.12)}.pete-shoe:after{content:"";position:absolute;inset:.45rem .4rem auto .4rem;height:.5rem;border-radius:.2rem;background:rgba(0,0,0,.22)}.pete-hand{display:flex;flex-wrap:wrap;gap:.6rem;min-height:8.6rem}.pete-card{perspective:700px;height:var(--card-h,8.4rem);width:var(--card-w,6rem);animation:pete-deal .42s cubic-bezier(.22,1,.36,1) backwards}.pete-card-inner{position:relative;height:100%;width:100%;transform-style:preserve-3d;transition:transform .45s cubic-bezier(.4,0,.2,1)}.pete-card[data-face=down] .pete-card-inner{transform:rotateY(180deg)}.pete-card-back,.pete-card-front{position:absolute;inset:0;display:grid;border-radius:.55rem;backface-visibility:hidden;-webkit-backface-visibility:hidden;box-shadow:0 3px 0 rgba(0,0,0,.18),0 6px 14px rgba(0,0,0,.22)}.pete-card-front{place-items:stretch;background:#fdfaf2;border:2px solid rgba(30,20,10,.12);color:#2b2118;font-family:Fredoka,Nunito,system-ui,sans-serif;line-height:1;overflow:hidden}.pete-card-front[data-red="1"]{color:#cc3d4a}.pete-card-back{transform:rotateY(180deg);background:repeating-linear-gradient(45deg,hsla(0,0%,100%,.1) 0 6px,transparent 6px 12px),linear-gradient(150deg,#b4553f,#8d3f2f);border:2px solid rgba(0,0,0,.15)}.pete-card-svg{width:100%;height:100%;display:block;fill:currentColor}.pete-card-idx{font-size:26px;font-weight:700;text-anchor:middle;fill:currentColor}.pete-card-panel{fill:currentColor;fill-opacity:.06;stroke:currentColor;stroke-opacity:.35;stroke-width:1.5}.pete-card-court{font-size:34px;font-weight:700;text-anchor:middle;fill:currentColor}@keyframes pete-deal{0%{opacity:0;transform:translate(var(--deal-x,14rem),var(--deal-y,-7rem)) scale(.72) rotate(9deg)}62%{opacity:1;transform:translateY(.35rem) scale(1.05) rotate(calc(var(--tilt, 0deg) - 2deg))}to{opacity:1;transform:translate(0) scale(1) rotate(var(--tilt,0deg))}}.pete-card:after{content:"";position:absolute;inset:auto 6% -.35rem 6%;height:.6rem;border-radius:999px;background:rgba(0,0,0,.35);filter:blur(5px);animation:pete-thud .42s cubic-bezier(.22,1,.36,1) backwards}@keyframes pete-thud{0%,55%{opacity:0;transform:scale(.4)}72%{opacity:.9;transform:scale(1.15)}to{opacity:.45;transform:scale(1)}}.pete-hand[data-won="1"] .pete-card{animation:pete-deal .42s cubic-bezier(.22,1,.36,1) backwards,pete-win .6s ease .1s}@keyframes pete-win{0%,to{transform:rotate(var(--tilt,0deg)) translateY(0)}40%{transform:rotate(var(--tilt,0deg)) translateY(-.6rem)}}.pete-hand[data-won="-1"] .pete-card{filter:saturate(.45) brightness(.78);transition:filter .5s ease .2s}.pete-disc{position:relative;border-radius:999px;background:radial-gradient(circle at 50% 30%,hsla(0,0%,100%,.3),transparent 62%),var(--chip,#e07a5f);border:2px solid rgba(0,0,0,.22);box-shadow:0 3px 0 rgba(0,0,0,.28),0 6px 14px rgba(0,0,0,.2)}.pete-disc:before{content:"";position:absolute;inset:11%;border-radius:999px;border:2px dashed hsla(0,0%,100%,.55)}.pete-disc[data-chip="5"]{--chip:#5aa9e6}.pete-disc[data-chip="25"]{--chip:#4caf7d}.pete-disc[data-chip="100"]{--chip:#2b2118}.pete-disc[data-chip="500"]{--chip:#b079d6}.pete-chip{transition:transform .12s ease}.pete-chip>span{position:relative}.pete-chip:active{transform:translateY(1px) scale(.94)}.pete-spot{position:relative;display:grid;place-items:center;height:7rem;width:7rem;flex:none;border-radius:999px;border:2px dashed hsla(0,0%,100%,.35);background:radial-gradient(circle at 50% 40%,hsla(0,0%,100%,.1),transparent 70%);box-shadow:inset 0 0 0 6px rgba(0,0,0,.08);transition:box-shadow .3s ease,border-color .3s ease}.pete-spot[data-live="1"]{border-color:rgba(var(--glow,242,181,61),.85);box-shadow:inset 0 0 0 6px rgba(0,0,0,.08),0 0 22px rgba(var(--glow,242,181,61),.35)}.pete-spot-label{font-size:.65rem;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:hsla(0,0%,100%,.35)}.pete-spot[data-live="1"] .pete-spot-label{opacity:0}.pete-stack{position:absolute;inset:0;pointer-events:none}.pete-stack .pete-disc{position:absolute;left:50%;top:50%;height:2.6rem;width:2.6rem;margin:-1.3rem 0 0 -1.3rem;transform:translateY(calc(-.5rem*var(--i, 0))) rotate(var(--spin,0deg));box-shadow:0 2px 0 rgba(0,0,0,.35),0 4px 8px rgba(0,0,0,.25);animation:pete-chip-land .28s cubic-bezier(.34,1.56,.64,1) backwards}@keyframes pete-chip-land{0%{transform:translateY(calc(-.5rem*var(--i, 0) - 1.1rem)) rotate(var(--spin,0deg)) scale(1.18)}}.pete-spot-total{position:absolute;bottom:-.6rem;left:50%;transform:translateX(-50%);white-space:nowrap;border-radius:999px;background:rgba(0,0,0,.45);padding:.1rem .6rem;font-size:.72rem;font-weight:700;font-variant-numeric:tabular-nums;color:#fff}.pete-fly-layer{position:fixed;inset:0;z-index:60;pointer-events:none;overflow:hidden}.pete-fly{position:absolute;height:2.6rem;width:2.6rem;margin:-1.3rem 0 0 -1.3rem;will-change:transform}.pete-rack{position:absolute;top:1.25rem;right:5.75rem;display:flex;align-items:flex-end;gap:.4rem;padding:.5rem .6rem .45rem;border-radius:.75rem;background:rgba(0,0,0,.2);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.06)}.pete-rack span{display:block;width:1.7rem;border-radius:.35rem;background:linear-gradient(90deg,rgba(0,0,0,.28),transparent 35%,transparent 65%,rgba(0,0,0,.28)),repeating-linear-gradient(180deg,hsla(0,0%,100%,.18) 0 .06rem,var(--chip,#e07a5f) .06rem .3rem,rgba(0,0,0,.35) .3rem .36rem);border:1px solid rgba(0,0,0,.35);height:calc(.36rem*var(--stack, 3))}.pete-rack span[data-chip="5"]{--chip:#5aa9e6}.pete-rack span[data-chip="25"]{--chip:#4caf7d}.pete-rack span[data-chip="100"]{--chip:#2b2118}.pete-rack span[data-chip="500"]{--chip:#b079d6}@media (max-width:639px){.pete-rack:not([data-at=rail]){gap:.25rem;padding:.35rem .4rem .3rem}.pete-rack:not([data-at=rail]) span{width:1.15rem}.pete-rack:not([data-at]){right:1rem}}.pete-spark{position:absolute;height:.75rem;width:.45rem;border-radius:2px;box-shadow:0 1px 3px rgba(0,0,0,.35);will-change:transform,opacity}.pete-dealer-think{animation:pete-think .9s ease-in-out infinite}@keyframes pete-think{0%,to{opacity:.6}50%{opacity:1;transform:translateY(-1px)}}.pete-gallows{overflow:visible;fill:none;stroke-linecap:round;stroke-linejoin:round}.pete-gallows-frame path{stroke:rgba(0,0,0,.35);stroke-width:6}.pete-gallows-frame path:last-child{stroke:rgba(0,0,0,.3);stroke-width:3.5}.pete-gallows-body>*{stroke:#fff;stroke-width:5;opacity:0;filter:drop-shadow(0 2px 0 rgba(0,0,0,.25))}.pete-gallows-body>[data-on="1"]{opacity:1}.pete-part-draw{stroke-dasharray:260;animation:pete-part .45s cubic-bezier(.22,1,.36,1) backwards}@keyframes pete-part{0%{stroke-dashoffset:260}to{stroke-dashoffset:0}}.pete-shake{animation:pete-shake .42s cubic-bezier(.36,.07,.19,.97)}@keyframes pete-shake{10%,90%{transform:translateX(-1.5px)}20%,80%{transform:translateX(3px)}30%,50%,70%{transform:translateX(-5px)}40%,60%{transform:translateX(5px)}}.pete-board{display:flex;flex-wrap:wrap;gap:.35rem 1.1rem;min-height:5rem;align-items:center}.pete-word{display:flex;gap:.3rem}.pete-tile{display:grid;place-items:center;height:2.9rem;width:2.2rem;border-radius:.5rem;font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:1.4rem;font-weight:700;color:#fff;background:rgba(0,0,0,.22);border-bottom:3px solid rgba(0,0,0,.28);text-transform:uppercase}.pete-tile[data-up="1"]{background:hsla(0,0%,100%,.95);color:#2b2118;border-bottom-color:rgba(0,0,0,.35)}.pete-tile[data-punct="1"]{background:none;border:0;width:auto;min-width:.7rem;color:hsla(0,0%,100%,.55)}.pete-tile-hit{animation:pete-tile-hit .34s cubic-bezier(.34,1.56,.64,1)}@keyframes pete-tile-hit{0%{transform:rotateX(90deg) scale(1.1)}to{transform:rotateX(0) scale(1)}}.pete-missed{display:grid;place-items:center;height:1.5rem;min-width:1.5rem;padding:0 .3rem;border-radius:.375rem;background:rgba(0,0,0,.25);font-size:.75rem;font-weight:700;color:hsla(0,0%,100%,.5);text-decoration:line-through;text-transform:uppercase}.pete-meter{display:flex;align-items:baseline;gap:.5rem;border-radius:999px;background:rgba(0,0,0,.28);padding:.4rem .9rem;box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.1);transition:box-shadow .3s ease}.pete-meter-label{font-size:.65rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:hsla(0,0%,100%,.45)}.pete-meter-value{font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:1.5rem;font-weight:700;font-variant-numeric:tabular-nums;color:var(--accent)}.pete-meter[data-cold="1"] .pete-meter-value{color:hsla(0,0%,100%,.55)}.pete-meter[data-hit="1"]{box-shadow:inset 0 0 0 2px rgba(204,61,74,.9);animation:pete-meter-hit .4s ease}@keyframes pete-meter-hit{0%{transform:scale(1)}35%{transform:scale(.94)}to{transform:scale(1)}}.pete-keys{display:grid;gap:.35rem}.pete-key-row{display:flex;justify-content:center;gap:.35rem}.pete-key-row[data-digits="1"]{margin-top:.25rem;opacity:.75}.pete-key-row[data-digits="1"] .pete-key{height:2rem;min-width:1.8rem;font-size:.8rem}.pete-key{height:2.75rem;min-width:2.2rem;flex:0 1 2.4rem;border-radius:.6rem;background:color-mix(in srgb,var(--ink) 6%,transparent);border:2px solid color-mix(in srgb,var(--ink) 10%,transparent);font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-weight:700;color:var(--ink);transition:transform .08s ease,background .15s ease,opacity .15s ease}.pete-key:hover:not(:disabled){background:color-mix(in srgb,var(--ink) 12%,transparent)}.pete-key:active:not(:disabled){transform:translateY(1px) scale(.96)}.pete-key:disabled{cursor:default}.pete-key[data-state=hit]{background:#4caf7d;border-color:#3d9367;color:#fff;opacity:1}.pete-key[data-state=miss]{background:color-mix(in srgb,var(--ink) 4%,transparent);color:color-mix(in srgb,var(--ink) 30%,transparent);text-decoration:line-through}.pete-key:disabled[data-state=""]{opacity:.4}.pete-tier{background:color-mix(in srgb,var(--ink) 4%,transparent);border-color:color-mix(in srgb,var(--ink) 10%,transparent)}.pete-tier:hover{background:color-mix(in srgb,var(--ink) 8%,transparent)}.pete-tier[data-on="1"]{background:color-mix(in srgb,var(--accent) 18%,transparent);border-color:var(--accent)}.pete-clock{position:relative;height:.6rem;border-radius:999px;background:rgba(0,0,0,.3);overflow:hidden;box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.08)}.pete-clock-fill{height:100%;width:100%;border-radius:999px;background:var(--accent);transform-origin:left center;transform:scaleX(1)}.pete-clock[data-hot="1"] .pete-clock-fill{background:#cc3d4a}.pete-clock[data-hot="1"]{animation:pete-clock-pulse .7s ease-in-out infinite}@keyframes pete-clock-pulse{0%,to{box-shadow:inset 0 0 0 2px rgba(204,61,74,.35)}50%{box-shadow:inset 0 0 0 2px rgba(204,61,74,.95)}}.pete-answer{position:relative;display:flex;align-items:center;gap:.75rem;width:100%;border-radius:1rem;padding:.85rem 1rem;text-align:left;font-weight:600;color:#fff;background:rgba(0,0,0,.26);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.1);transition:background .15s ease,transform .1s ease,box-shadow .2s ease}.pete-answer:hover:not(:disabled){background:rgba(0,0,0,.36);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.28)}.pete-answer:active:not(:disabled){transform:translateY(1px)}.pete-answer:disabled{cursor:default}.pete-answer-key{display:grid;place-items:center;height:1.75rem;width:1.75rem;flex:none;border-radius:.6rem;background:hsla(0,0%,100%,.12);font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:.8rem;font-weight:700;color:hsla(0,0%,100%,.7)}.pete-answer[data-state=right]{background:rgba(46,160,103,.9);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.5)}.pete-answer[data-state=wrong]{background:rgba(204,61,74,.9);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.35);animation:pete-answer-no .45s ease}.pete-answer[data-state=missed]{box-shadow:inset 0 0 0 2px rgba(46,160,103,.95)}.pete-answer[data-state=dim]{opacity:.4}@keyframes pete-answer-no{0%,to{transform:translateX(0)}20%{transform:translateX(-6px)}45%{transform:translateX(5px)}70%{transform:translateX(-3px)}}.pete-ladder{display:flex;flex-wrap:wrap;gap:.3rem}.pete-rung{height:.5rem;width:1.1rem;border-radius:999px;background:hsla(0,0%,100%,.14);transition:background .3s ease,transform .3s ease}.pete-rung[data-on="1"]{background:var(--accent);transform:scaleY(1.35)}.pete-solitaire{--card-w:clamp(2.5rem,10.6vw,5.2rem);--card-h:calc(var(--card-w)*1.4);--fan-up:calc(var(--card-h)*0.29);--fan-down:calc(var(--card-h)*0.14)}.pete-slot{position:relative;display:grid;place-items:center;height:var(--card-h);width:var(--card-w);flex:none;border-radius:.55rem;border:2px dashed hsla(0,0%,100%,.25);background:rgba(0,0,0,.12);box-shadow:inset 0 2px 8px rgba(0,0,0,.18);transition:border-color .18s ease,background .18s ease,transform .12s ease}.pete-slot-glyph{font-size:calc(var(--card-w)*.42);line-height:1;color:hsla(0,0%,100%,.3)}.pete-slot-glyph[data-red="1"]{color:hsla(0,100%,83%,.35)}.pete-slot>.pete-card{position:absolute;inset:0;height:100%;width:100%}.pete-stock{border-style:solid;border-color:rgba(0,0,0,.15);background:repeating-linear-gradient(45deg,hsla(0,0%,100%,.1) 0 6px,transparent 6px 12px),linear-gradient(150deg,#b4553f,#8d3f2f);cursor:pointer}.pete-stock:hover{filter:brightness(1.08)}.pete-stock:active{transform:translateY(1px) scale(.98)}.pete-stock[data-empty="1"]{background:rgba(0,0,0,.12);border-style:dashed;border-color:hsla(0,0%,100%,.25)}.pete-stock[data-dead="1"]{cursor:default;opacity:.4}.pete-stock[data-dead="1"]:hover{filter:none}.pete-stock[data-dead="1"]:active{transform:none}.pete-slot-count{position:absolute;bottom:-.5rem;left:50%;transform:translateX(-50%);border-radius:999px;background:rgba(0,0,0,.5);padding:.05rem .5rem;font-size:.7rem;font-weight:700;font-variant-numeric:tabular-nums;color:#fff}.pete-slot-recycle{color:hsla(0,0%,100%,.45)}.pete-slot-recycle svg{height:1.4rem;width:1.4rem}.pete-waste{position:relative;display:flex;height:var(--card-h);min-width:var(--card-w)}.pete-waste .pete-card+.pete-card{margin-left:calc(var(--card-w)*-.72)}.pete-waste .pete-card{position:relative}.pete-tableau{display:grid;grid-template-columns:repeat(7,var(--card-w));justify-content:space-between;gap:.5rem;align-items:start;min-height:calc(var(--card-h)*2.6)}.pete-col{display:flex;flex-direction:column;align-items:center;min-height:var(--card-h)}.pete-col .pete-card{position:relative}.pete-col .pete-card[data-face=down]+.pete-card{margin-top:calc(var(--fan-down) - var(--card-h))}.pete-col .pete-card[data-face=up]+.pete-card{margin-top:calc(var(--fan-up) - var(--card-h))}.pete-card[data-live="1"]{cursor:pointer}.pete-card[data-live="1"]:hover .pete-card-front{filter:brightness(1.06)}.pete-card[data-held="1"]{z-index:5;transform:translateY(-.55rem);transition:transform .14s cubic-bezier(.34,1.56,.64,1)}.pete-card[data-held="1"] .pete-card-front{box-shadow:0 3px 0 rgba(0,0,0,.18),0 10px 22px rgba(0,0,0,.32),0 0 0 3px rgba(242,181,61,.9)}.pete-col[data-drop="1"] .pete-card:last-child .pete-card-front,.pete-col[data-drop="1"] .pete-slot,.pete-slot[data-drop="1"]{border-color:rgba(242,181,61,.9);box-shadow:0 0 0 3px rgba(242,181,61,.45)}.pete-col[data-drop="1"] .pete-slot,.pete-slot[data-drop="1"]{background:rgba(242,181,61,.12)}.pete-nope{animation:pete-shake .4s cubic-bezier(.36,.07,.19,.97)}.pete-home-flash{animation:pete-home .5s ease-out}@keyframes pete-home{0%{box-shadow:0 0 0 0 rgba(242,181,61,.9)}to{box-shadow:0 0 0 1.4rem rgba(242,181,61,0)}}.pete-rail{display:flex;flex-direction:row;flex-wrap:wrap;align-items:center;justify-content:center;gap:1rem 1.5rem}@media (min-width:1024px){.pete-rail{flex-direction:column;justify-content:flex-start;width:9.5rem;gap:1.5rem}}.pete-rack[data-at=rail]{position:static;justify-content:center}.pete-uno[data-c=red]{--play:#d64545}.pete-uno[data-c=blue]{--play:#3d7fd6}.pete-uno[data-c=yellow]{--play:#e0b02c}.pete-uno[data-c=green]{--play:#46a86b}.pete-uno:before{content:"";position:absolute;inset:0;pointer-events:none;background:radial-gradient(80% 55% at 50% 45%,var(--play,transparent),transparent 70%);opacity:.16;transition:opacity .5s ease,background .5s ease}.pete-uno-card{position:relative;height:var(--uno-h,6.4rem);width:var(--uno-w,4.3rem);border-radius:.55rem;padding:.28rem;background:#fff;box-shadow:0 3px 0 rgba(0,0,0,.22),0 6px 14px rgba(0,0,0,.18);flex:none}.pete-uno-face{position:relative;display:grid;place-items:center;height:100%;width:100%;border-radius:.38rem;overflow:hidden;background:var(--uno,#2b2118);color:#fff}.pete-uno-card[data-c=red]{--uno:#d64545}.pete-uno-card[data-c=blue]{--uno:#3d7fd6}.pete-uno-card[data-c=yellow]{--uno:#e0b02c}.pete-uno-card[data-c=green]{--uno:#46a86b}.pete-uno-card[data-c=wild]{--uno:#2b2118}.pete-uno-card[data-named=red]{box-shadow:0 0 0 3px #d64545,0 3px 0 rgba(0,0,0,.22)}.pete-uno-card[data-named=blue]{box-shadow:0 0 0 3px #3d7fd6,0 3px 0 rgba(0,0,0,.22)}.pete-uno-card[data-named=yellow]{box-shadow:0 0 0 3px #e0b02c,0 3px 0 rgba(0,0,0,.22)}.pete-uno-card[data-named=green]{box-shadow:0 0 0 3px #46a86b,0 3px 0 rgba(0,0,0,.22)}.pete-uno-oval{position:relative;z-index:1;display:grid;place-items:center;height:78%;width:62%;border-radius:50%;transform:rotate(-24deg);background:#fff;color:var(--uno,#2b2118);font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:1.45rem;font-weight:700;line-height:1;text-shadow:0 1px 0 rgba(0,0,0,.1)}.pete-uno-card[data-c=wild] .pete-uno-oval{color:#2b2118;font-size:1.15rem}.pete-uno-corner{position:absolute;z-index:1;font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:.62rem;font-weight:700;line-height:1;color:#fff;text-shadow:0 1px 1px rgba(0,0,0,.35)}.pete-uno-corner[data-at=tl]{top:.22rem;left:.28rem}.pete-uno-corner[data-at=br]{bottom:.22rem;right:.28rem;transform:rotate(180deg)}.pete-uno-wheel{position:absolute;inset:0;background:conic-gradient(#d64545 0 25%,#e0b02c 0 50%,#46a86b 0 75%,#3d7fd6 0);opacity:.92}.pete-uno-card-back{padding:.28rem}.pete-uno-back{display:grid;place-items:center;height:100%;width:100%;border-radius:.38rem;background:radial-gradient(120% 80% at 50% 0,hsla(0,0%,100%,.16),transparent 60%),#2b2118}.pete-uno-back:after{content:"UNO";display:block;transform:rotate(-24deg);padding:.1rem .35rem;border-radius:999px;background:#e0b02c;color:#2b2118;font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:.6rem;font-weight:700;letter-spacing:.02em}.pete-uno-hand{display:flex;flex-wrap:wrap;justify-content:center;gap:.4rem;min-height:6.8rem;padding-top:.6rem}.pete-uno-hand .pete-uno-card{animation:pete-uno-in .34s cubic-bezier(.22,1,.36,1) backwards;animation-delay:calc(var(--i, 0)*45ms);transition:transform .16s ease,filter .16s ease}.pete-uno-hand .pete-uno-card[data-on="1"]{cursor:pointer;transform:translateY(-.5rem)}.pete-uno-hand .pete-uno-card[data-on="1"]:hover{transform:translateY(-1.1rem) scale(1.03)}.pete-uno-hand .pete-uno-card[data-on="0"]{filter:brightness(.62) saturate(.7)}@keyframes pete-uno-in{0%{transform:translateY(2rem) rotate(6deg);opacity:0}to{transform:translateY(-.5rem);opacity:1}}.pete-uno-seat{display:flex;flex-direction:column;align-items:center;gap:.3rem;position:relative;padding:.5rem .7rem;border-radius:1rem;transition:background .25s ease,transform .25s ease}.pete-uno-seat[data-turn="1"]{background:hsla(0,0%,100%,.12);transform:translateY(-2px)}.pete-uno-seat[data-uno="1"] .pete-uno-count{color:#ffd76e}.pete-uno-fan{display:flex;--uno-h:3.2rem;--uno-w:2.15rem}.pete-uno-fan .pete-uno-card{margin-left:-1.35rem;transform:rotate(calc((var(--i, 0) - (var(--n, 1) - 1)/2)*4deg));transform-origin:bottom center;box-shadow:0 2px 0 rgba(0,0,0,.22)}.pete-uno-fan .pete-uno-card:first-child{margin-left:0}.pete-uno-fan .pete-uno-back:after{font-size:.4rem;padding:.06rem .22rem}.pete-uno-name{font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:.9rem;font-weight:700;color:#fff;line-height:1}.pete-uno-count{font-size:.7rem;font-weight:700;color:hsla(0,0%,100%,.55);line-height:1}.pete-uno-deck{position:relative;--uno-h:6.4rem;--uno-w:4.3rem;height:var(--uno-h);width:var(--uno-w);border-radius:.55rem;padding:.28rem;background:#fff;box-shadow:0 3px 0 rgba(0,0,0,.22),.28rem .28rem 0 -.06rem hsla(0,0%,100%,.35),.5rem .5rem 0 -.12rem hsla(0,0%,100%,.18);transition:transform .15s ease}.pete-uno-deck:not(:disabled):hover{transform:translateY(-3px)}.pete-uno-deck:disabled{opacity:.75}.pete-uno-deck-count{position:absolute;bottom:-.55rem;left:50%;transform:translateX(-50%);padding:.1rem .5rem;border-radius:999px;background:rgba(0,0,0,.55);color:#fff;font-size:.65rem;font-weight:700;line-height:1.4}.pete-uno-shuffle{animation:pete-uno-shuffle .42s ease}@keyframes pete-uno-shuffle{0%,to{transform:none}30%{transform:translateY(-4px) rotate(-3deg)}65%{transform:translateY(2px) rotate(2deg)}}.pete-uno-discard{display:grid;place-items:center;height:6.4rem;width:4.3rem;border-radius:.55rem;box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.14)}.pete-uno-land{animation:pete-uno-land .3s cubic-bezier(.34,1.56,.64,1)}@keyframes pete-uno-land{0%{transform:scale(1.14) rotate(-4deg)}to{transform:none}}.pete-uno-colour{font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;padding:.15rem .6rem;border-radius:999px;color:#fff;background:rgba(0,0,0,.3)}.pete-uno-colour[data-c=red]{background:#d64545}.pete-uno-colour[data-c=blue]{background:#3d7fd6}.pete-uno-colour[data-c=yellow]{background:#e0b02c;color:#2b2118}.pete-uno-colour[data-c=green]{background:#46a86b}.pete-uno-badge{position:absolute;top:-.4rem;left:50%;z-index:5;padding:.2rem .6rem;border-radius:999px;background:#fff;color:#2b2118;font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:.75rem;font-weight:700;white-space:nowrap;box-shadow:0 3px 0 rgba(0,0,0,.2);animation:pete-uno-badge .9s ease forwards}.pete-uno-badge[data-tone=bad]{background:#cc3d4a;color:#fff}.pete-uno-badge[data-tone=uno]{background:#e0b02c}@keyframes pete-uno-badge{0%{transform:translate(-50%,.4rem) scale(.7);opacity:0}25%{transform:translate(-50%,-.5rem) scale(1.06);opacity:1}75%{transform:translate(-50%,-.9rem) scale(1);opacity:1}to{transform:translate(-50%,-1.6rem) scale(.95);opacity:0}}.pete-uno-wild{position:absolute;inset:0;z-index:10;display:grid;place-items:center;background:rgba(10,20,15,.55);-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px)}.pete-uno-wild-box{padding:1.25rem 1.5rem;border-radius:1.25rem;background:rgba(20,40,30,.92);box-shadow:0 12px 30px rgba(0,0,0,.35);text-align:center}.pete-uno-swatch{padding:.7rem 1.4rem;border-radius:.75rem;font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:1rem;font-weight:700;color:#fff;background:var(--sw,#666);box-shadow:0 3px 0 rgba(0,0,0,.3);transition:transform .12s ease,filter .12s ease}.pete-uno-swatch:hover{transform:translateY(-2px);filter:brightness(1.08)}.pete-uno-swatch:active{transform:translateY(1px)}.pete-uno-swatch[data-c=red]{--sw:#d64545}.pete-uno-swatch[data-c=blue]{--sw:#3d7fd6}.pete-uno-swatch[data-c=yellow]{--sw:#e0b02c;color:#2b2118}.pete-uno-swatch[data-c=green]{--sw:#46a86b}@media (max-width:639px){.pete-uno-hand{gap:.3rem}.pete-uno-deck,.pete-uno-discard,.pete-uno-hand{--uno-h:5.2rem;--uno-w:3.5rem}.pete-uno-discard{height:5.2rem;width:3.5rem}.pete-uno-hand .pete-uno-card{padding:.22rem}.pete-uno-oval{font-size:1.15rem}.pete-uno-seat{padding:.35rem .45rem}}@media (prefers-reduced-motion:reduce){.pete-card,.pete-card:after,.pete-dealer-think,.pete-stack .pete-disc{animation:none}.pete-card-inner{transition:none}.pete-hand[data-won="1"] .pete-card,.pete-home-flash,.pete-meter[data-hit="1"],.pete-nope,.pete-part-draw,.pete-shake,.pete-tile-hit{animation:none}.pete-card[data-held="1"]{transition:none}.pete-answer[data-state=wrong],.pete-clock[data-hot="1"]{animation:none}.pete-rung{transition:none}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-right-6{right:-1.5rem}.-top-6{top:-1.5rem}.-z-10{z-index:-10}.-z-\[5\]{z-index:-5}.z-50{z-index:50}.order-none{order:0}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-auto{margin-left:auto;margin-right:auto}.-mt-1{margin-top:-.25rem}.mb-1{margin-bottom:.25rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-8{margin-bottom:2rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mt-0{margin-top:0}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.line-clamp-2{-webkit-line-clamp:2}.line-clamp-2,.line-clamp-3{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical}.line-clamp-3{-webkit-line-clamp:3}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.aspect-\[16\/10\]{aspect-ratio:16/10}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-20{height:5rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-52{height:13rem}.h-6{height:1.5rem}.h-9{height:2.25rem}.h-full{height:100%}.max-h-\[55vh\]{max-height:55vh}.max-h-\[60vh\]{max-height:60vh}.min-h-\[1\.75rem\]{min-height:1.75rem}.min-h-\[16rem\]{min-height:16rem}.min-h-\[2\.75rem\]{min-height:2.75rem}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-2{width:.5rem}.w-2\.5{width:.625rem}.w-20{width:5rem}.w-24{width:6rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-9{width:2.25rem}.w-auto{width:auto}.w-full{width:100%}.min-w-0{min-width:0}.min-w-\[28rem\]{min-width:28rem}.min-w-\[52rem\]{min-width:52rem}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-\[7rem\]{max-width:7rem}.max-w-full{max-width:100%}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink,.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.basis-full{flex-basis:100%}.-translate-y-0{--tw-translate-y:-0px}.-translate-y-0,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-10{-moz-column-gap:2.5rem;column-gap:2.5rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-3{row-gap:.75rem}.gap-y-4{row-gap:1rem}.gap-y-8{row-gap:2rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.375rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-transparent{border-color:transparent}.bg-\[color\:var\(--accent\)\]{background-color:var(--accent)}.bg-\[color\:var\(--bg\)\]{background-color:var(--bg)}.bg-\[color\:var\(--bg-grad\)\]{background-color:var(--bg-grad)}.bg-\[color\:var\(--card\)\]{background-color:var(--card)}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-black\/25{background-color:rgba(0,0,0,.25)}.bg-emerald-500{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity,1))}.bg-emerald-500\/15{background-color:rgba(16,185,129,.15)}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/15{background-color:rgba(239,68,68,.15)}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/95{background-color:hsla(0,0%,100%,.95)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-0\.5{padding:.125rem}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0{padding-top:0;padding-bottom:0}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-10{padding-bottom:2.5rem}.pb-20{padding-bottom:5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pr-24{padding-right:6rem}.pr-32{padding-right:8rem}.pt-1{padding-top:.25rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-8{padding-top:2rem}.pt-\[10vh\]{padding-top:10vh}.pt-\[12vh\]{padding-top:12vh}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.align-\[-4px\]{vertical-align:-4px}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12rem\]{font-size:12rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-\[0\.18em\]{letter-spacing:.18em}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-\[\#1c1305\]{--tw-text-opacity:1;color:rgb(28 19 5/var(--tw-text-opacity,1))}.text-\[\#20180c\]{--tw-text-opacity:1;color:rgb(32 24 12/var(--tw-text-opacity,1))}.text-\[\#2b2118\]{--tw-text-opacity:1;color:rgb(43 33 24/var(--tw-text-opacity,1))}.text-\[color\:var\(--accent\)\]{color:var(--accent)}.text-\[color\:var\(--ink\)\]{color:var(--ink)}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-emerald-700{--tw-text-opacity:1;color:rgb(4 120 87/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-white\/40{color:hsla(0,0%,100%,.4)}.text-white\/50{color:hsla(0,0%,100%,.5)}.text-white\/55{color:hsla(0,0%,100%,.55)}.text-white\/60{color:hsla(0,0%,100%,.6)}.text-white\/70{color:hsla(0,0%,100%,.7)}.text-white\/85{color:hsla(0,0%,100%,.85)}.text-white\/90{color:hsla(0,0%,100%,.9)}.underline{text-decoration-line:underline}.decoration-4{text-decoration-thickness:4px}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.accent-\[color\:var\(--accent\)\]{accent-color:var(--accent)}.opacity-20{opacity:.2}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.blur{--tw-blur:blur(8px)}.blur,.drop-shadow{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.drop-shadow{--tw-drop-shadow:drop-shadow(0 1px 2px rgba(0,0,0,.1)) drop-shadow(0 1px 1px rgba(0,0,0,.06))}.grayscale{--tw-grayscale:grayscale(100%)}.grayscale,.sepia{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.sepia{--tw-sepia:sepia(100%)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.backdrop-blur-sm,.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-1000{transition-duration:1s}.duration-500{transition-duration:.5s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}.font-display{font-family:Fredoka,Nunito,system-ui,sans-serif;letter-spacing:-.01em}.shadow-pete{box-shadow:0 4px 0 rgba(60,40,20,.1),0 8px 24px rgba(60,40,20,.08)}.shadow-pete-lg{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.bg-theme-gaming{background-color:#4caf7d}.bg-theme-tech{background-color:#5aa9e6}.bg-theme-politics{background-color:#e07a5f}.bg-theme-eu{background-color:#039}.bg-theme-music{background-color:#b079d6}.bg-theme-anime{background-color:#ec5e8a}.bg-theme-foss{background-color:#d97706}.bg-theme-kids{background-color:#14b8a6}.bg-theme-finance{background-color:#10b981}.bg-theme-lego{background-color:#d01012}.bg-theme-adventure{background-color:#6d4bd8}.text-theme-gaming{color:#2d8a5a}.text-theme-tech{color:#2f7fb8}.text-theme-politics{color:#b8523a}.text-theme-eu{color:#039}.text-theme-music{color:#8a4fb8}.text-theme-anime{color:#c33a6a}.text-theme-foss{color:#b8530a}.text-theme-kids{color:#0f766e}.text-theme-finance{color:#059669}.text-theme-lego{color:#b00d0e}.text-theme-adventure{color:#5836b8}.decoration-theme-gaming{text-decoration-color:#4caf7d}.decoration-theme-tech{text-decoration-color:#5aa9e6}.decoration-theme-politics{text-decoration-color:#e07a5f}.decoration-theme-eu{text-decoration-color:#039}.decoration-theme-music{text-decoration-color:#b079d6}.decoration-theme-anime{text-decoration-color:#ec5e8a}.decoration-theme-foss{text-decoration-color:#d97706}.decoration-theme-kids{text-decoration-color:#14b8a6}.decoration-theme-finance{text-decoration-color:#10b981}.decoration-theme-lego{text-decoration-color:#d01012}.decoration-theme-adventure{text-decoration-color:#6d4bd8}.border-theme-gaming{border-color:#4caf7d}.border-theme-tech{border-color:#5aa9e6}.border-theme-politics{border-color:#e07a5f}.border-theme-eu{border-color:#039}.border-theme-music{border-color:#b079d6}.border-theme-anime{border-color:#ec5e8a}.border-theme-foss{border-color:#d97706}.border-theme-kids{border-color:#14b8a6}.border-theme-finance{border-color:#10b981}.border-theme-lego{border-color:#d01012}.border-theme-adventure{border-color:#6d4bd8}.glow-theme-gaming{box-shadow:0 0 0 2px rgba(76,175,125,.35),0 0 24px 4px rgba(76,175,125,.45)}.glow-theme-gaming,.glow-theme-tech{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-tech{box-shadow:0 0 0 2px rgba(90,169,230,.35),0 0 24px 4px rgba(90,169,230,.45)}.glow-theme-politics{box-shadow:0 0 0 2px rgba(224,122,95,.35),0 0 24px 4px rgba(224,122,95,.45)}.glow-theme-eu,.glow-theme-politics{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-eu{box-shadow:0 0 0 2px rgba(0,51,153,.35),0 0 24px 4px rgba(0,51,153,.45)}.glow-theme-music{box-shadow:0 0 0 2px rgba(176,121,214,.35),0 0 24px 4px rgba(176,121,214,.45)}.glow-theme-anime,.glow-theme-music{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-anime{box-shadow:0 0 0 2px rgba(236,94,138,.35),0 0 24px 4px rgba(236,94,138,.45)}.glow-theme-foss{box-shadow:0 0 0 2px rgba(217,119,6,.35),0 0 24px 4px rgba(217,119,6,.45)}.glow-theme-foss,.glow-theme-kids{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-kids{box-shadow:0 0 0 2px rgba(20,184,166,.35),0 0 24px 4px rgba(20,184,166,.45)}.glow-theme-finance{box-shadow:0 0 0 2px rgba(16,185,129,.35),0 0 24px 4px rgba(16,185,129,.45)}.glow-theme-finance,.glow-theme-lego{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-lego{box-shadow:0 0 0 2px rgba(208,16,18,.35),0 0 24px 4px rgba(208,16,18,.45)}.glow-theme-adventure{box-shadow:0 0 0 2px rgba(109,75,216,.35),0 0 24px 4px rgba(109,75,216,.45);animation:pete-glow-pulse 2.4s ease-in-out infinite}@keyframes pete-glow-pulse{0%,to{filter:brightness(1)}50%{filter:brightness(1.08)}}.line-clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.paywall-stamp{position:absolute;inset:0;pointer-events:none;display:flex;align-items:center;justify-content:center;z-index:5;background:rgba(255,250,240,.18)}.paywall-stamp:before{content:"PAYWALLED";font-family:var(--font-display,ui-sans-serif),system-ui,sans-serif;font-weight:900;font-size:clamp(1rem,3.2vw,1.75rem);letter-spacing:.12em;color:rgba(180,30,30,.85);border:.28rem double rgba(180,30,30,.85);padding:.3rem .9rem;transform:rotate(-15deg);text-shadow:0 1px 0 hsla(0,0%,100%,.4);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.15);background:rgba(255,240,230,.55);border-radius:.25rem;filter:contrast(1.05)}:root,html[data-phase=day]{--bg:#fff7e4;--bg-grad:#ffeec2;--card:#fff;--ink:#3a2e1f;--accent:#f2a541}html[data-phase=dawn]{--bg:#ffe7d6;--bg-grad:#ffc9c9;--card:#fff4ea;--ink:#4a2e2a;--accent:#ff8a65}html[data-phase=dusk]{--bg:#ffd6a8;--bg-grad:#f7a07e;--card:#fff1de;--ink:#3d2417;--accent:#e6553a}html[data-phase=night]{--bg:#1a1f3a;--bg-grad:#2a3358;--card:#2d365a;--ink:#f1ecd8;--accent:#f9d976}html[data-room=casinopolis]{--bg:#16211c;--card:#23342b;--ink:#f6ecd8;--accent:#f2b53d;--felt-a:#2f7d5b;--felt-b:#24614a;--felt-c:#1c4d3c;--glow:242,181,61}html[data-room=casino-night]{--bg:#140f2e;--card:#241a4d;--ink:#f2ecff;--accent:#ffcc2f;--felt-a:#4a2fa8;--felt-b:#351f80;--felt-c:#241659;--glow:126,86,255}html[data-room] .cs-room{background:radial-gradient(80% 55% at 50% -8%,rgba(var(--glow),.18),transparent 62%),radial-gradient(120% 90% at 50% 120%,rgba(0,0,0,.45),transparent 55%)}html[data-room=casino-night] .cs-room:before{content:"";position:absolute;inset:0 -50% auto -50%;height:.5rem;background:repeating-linear-gradient(90deg,rgba(255,204,47,.55) 0 .5rem,transparent .5rem 2.25rem);filter:blur(1px);animation:cs-bulbs 2.4s linear infinite}@keyframes cs-bulbs{0%{transform:translateX(0)}to{transform:translateX(2.75rem)}}html[data-room] .shadow-pete{box-shadow:0 4px 0 rgba(0,0,0,.3),0 10px 26px rgba(0,0,0,.3)}html[data-room] .shadow-pete-lg{box-shadow:0 6px 0 rgba(0,0,0,.34),0 20px 40px rgba(0,0,0,.38)}html[data-room] .cs-comb svg{filter:drop-shadow(0 3px 0 rgba(0,0,0,.35))}html[data-room] .pete-felt{background:radial-gradient(120% 90% at 50% -10%,hsla(0,0%,100%,.1),transparent 60%),linear-gradient(160deg,var(--felt-a) 0,var(--felt-b) 55%,var(--felt-c) 100%)}@media (prefers-reduced-motion:reduce){html[data-room=casino-night] .cs-room:before{animation:none}}.cs-stack{display:block;width:2.75rem;height:calc(.45rem*var(--stack, 1) + .55rem);border-radius:999px;background:radial-gradient(circle at 50% 30%,hsla(0,0%,100%,.3),transparent 60%),var(--chip,#e07a5f);border:2px solid rgba(0,0,0,.25);box-shadow:0 3px 0 rgba(0,0,0,.3),inset 0 -.4rem 0 rgba(0,0,0,.16),inset 0 .35rem 0 hsla(0,0%,100%,.14)}.cs-stack[data-chip="5"]{--chip:#5aa9e6}.cs-stack[data-chip="25"]{--chip:#4caf7d}.cs-stack[data-chip="100"]{--chip:#2b2118}.cs-stack[data-chip="500"]{--chip:#b079d6}.last\:border-0:last-child{border-width:0}.empty\:hidden:empty{display:none}.hover\:-translate-y-0\.5:hover{--tw-translate-y:-0.125rem}.hover\:-translate-y-0\.5:hover,.hover\:-translate-y-1:hover{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:-translate-y-1:hover{--tw-translate-y:-0.25rem}.hover\:text-\[color\:var\(--ink\)\]:hover{color:var(--ink)}.hover\:text-white\/80:hover{color:hsla(0,0%,100%,.8)}.hover\:brightness-105:hover{--tw-brightness:brightness(1.05);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.hover\:shadow-pete-lg:hover{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.focus\:border-\[color\:var\(--accent\)\]:focus{border-color:var(--accent)}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.active\:translate-y-px:active{--tw-translate-y:1px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:-rotate-6{--tw-rotate:-6deg}.group:hover .group-hover\:-rotate-6,.group:hover .group-hover\:scale-105{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:scale-105{--tw-scale-x:1.05;--tw-scale-y:1.05}.group:hover .group-hover\:underline{text-decoration-line:underline}@media (min-width:640px){.sm\:ml-auto{margin-left:auto}.sm\:block{display:block}.sm\:inline{display:inline}.sm\:flex{display:flex}.sm\:inline-flex{display:inline-flex}.sm\:h-56{height:14rem}.sm\:basis-auto{flex-basis:auto}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:gap-2{gap:.5rem}.sm\:gap-3{gap:.75rem}.sm\:gap-4{gap:1rem}.sm\:gap-5{gap:1.25rem}.sm\:gap-8{gap:2rem}.sm\:space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.sm\:p-10{padding:2.5rem}.sm\:p-6{padding:1.5rem}.sm\:p-8{padding:2rem}.sm\:pr-28{padding-right:7rem}.sm\:pt-10{padding-top:2.5rem}.sm\:pt-12{padding-top:3rem}.sm\:text-2xl{font-size:1.5rem;line-height:2rem}.sm\:text-3xl{font-size:1.875rem;line-height:2.25rem}.sm\:text-4xl{font-size:2.25rem;line-height:2.5rem}.sm\:text-5xl{font-size:3rem;line-height:1}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-\[auto\2c 1fr\]{grid-template-columns:auto 1fr}.lg\:grid-cols-\[minmax\(0\2c 1fr\)\2c auto\]{grid-template-columns:minmax(0,1fr) auto}.lg\:items-start{align-items:flex-start}.lg\:gap-8{gap:2rem}.lg\:p-8{padding:2rem}}@media (prefers-color-scheme:dark){.dark\:text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dark\:text-emerald-300{--tw-text-opacity:1;color:rgb(110 231 183/var(--tw-text-opacity,1))}.dark\:text-red-300{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.dark\:text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}body{font-family:Nunito,system-ui,sans-serif}html{scroll-behavior:smooth}body,html{overflow-x:hidden}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.pete-channel-nav{scrollbar-width:none;-ms-overflow-style:none}.pete-channel-nav::-webkit-scrollbar{display:none}.pete-reader-backdrop{position:absolute;inset:0;background:rgba(20,14,6,.55);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}html[data-phase=night] .pete-reader-backdrop{background:rgba(6,8,20,.65)}.pete-reader-shell{position:relative;height:100%;width:100%;max-width:44rem;margin:0 auto;display:flex;flex-direction:column;padding:max(env(safe-area-inset-top),.75rem) 1rem .75rem;gap:.75rem}.pete-reader-bar{display:flex;align-items:center;justify-content:space-between;gap:.75rem;color:#fff}.pete-reader-progress{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:600;font-size:.85rem;letter-spacing:.02em;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-nav{display:flex;align-items:center;gap:.4rem}.pete-reader-btn{display:inline-flex;align-items:center;justify-content:center;min-width:2.25rem;height:2.25rem;padding:0 .7rem;border-radius:9999px;background:hsla(0,0%,100%,.14);color:#fff;font-weight:700;font-size:.95rem;border:2px solid hsla(0,0%,100%,.18);cursor:pointer;transition:background .15s ease,transform .15s ease,opacity .15s ease}.pete-reader-btn:hover{background:hsla(0,0%,100%,.28);transform:translateY(-1px)}.pete-reader-btn:disabled{opacity:.35;cursor:default;transform:none}.pete-reader-btn-open{background:var(--accent);border-color:transparent;color:#1c1305;text-decoration:none}.pete-reader-btn-open:hover{background:var(--accent);filter:brightness(1.08)}.pete-reader-btn[aria-expanded=true],.pete-reader-btn[aria-pressed=true]{background:var(--accent);border-color:transparent;color:#1c1305}.pete-reader-type{position:relative;display:inline-flex}.pete-reader-typemenu[hidden]{display:none}.pete-reader-typemenu{position:absolute;top:calc(100% + .5rem);right:0;z-index:10;width:15rem;padding:.75rem;border-radius:1rem;background:var(--card);color:var(--ink);border:2px solid rgba(0,0,0,.08);box-shadow:0 10px 30px rgba(20,14,6,.28);display:flex;flex-direction:column;gap:.6rem}html[data-phase=night] .pete-reader-typemenu{border-color:hsla(0,0%,100%,.12)}.pete-reader-typerow{display:flex;align-items:center;justify-content:space-between;gap:.6rem}.pete-reader-typelabel{font-size:.8rem;font-weight:700;opacity:.7}.pete-reader-typebtns{display:inline-flex;gap:.25rem}.pete-reader-typebtns button{min-width:2rem;height:2rem;padding:0 .55rem;border-radius:.6rem;background:rgba(20,14,6,.06);color:var(--ink);font-weight:700;font-size:.82rem;border:2px solid transparent;cursor:pointer;transition:background .15s ease}html[data-phase=night] .pete-reader-typebtns button{background:hsla(0,0%,100%,.08)}.pete-reader-typebtns button:hover{background:rgba(20,14,6,.12)}.pete-reader-typebtns button.is-active{background:var(--accent);color:#1c1305;border-color:transparent}.pete-reader-voicerow[hidden]{display:none}.pete-reader-voice{font:inherit;font-size:.8rem;font-weight:600;padding:.25rem .4rem;border-radius:.55rem;border:1px solid rgba(20,14,6,.18);background:rgba(20,14,6,.06);color:inherit;max-width:11rem;cursor:pointer}html[data-phase=night] .pete-reader-voice{border-color:hsla(0,0%,100%,.14);background:hsla(0,0%,100%,.08)}.pete-reader-scroll{flex:1 1 auto;overflow-y:auto;-webkit-overflow-scrolling:touch;border-radius:1.75rem}.pete-reader-scroll:focus{outline:none}.pete-reader-article{background:var(--card);color:var(--ink);border-radius:1.75rem;border:2px solid rgba(0,0,0,.06);box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.18);padding:clamp(1.25rem,4vw,2.5rem)}html[data-phase=night] .pete-reader-article{border-color:hsla(0,0%,100%,.08)}.pete-reader-eyebrow{display:flex;align-items:center;flex-wrap:wrap;gap:.5rem;font-size:.75rem;margin-bottom:.85rem}.pete-reader-chip{display:inline-flex;align-items:center;gap:.35rem;border-radius:9999px;padding:.15rem .65rem;color:#fff;font-weight:700;text-transform:uppercase;letter-spacing:.06em}.pete-reader-source{font-weight:700;opacity:.75}.pete-reader-time{opacity:.55}.pete-reader-title{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:700;line-height:1.15;letter-spacing:-.01em;font-size:clamp(1.6rem,4.5vw,2.4rem);margin-bottom:1rem}.pete-reader-hero{width:100%;border-radius:1.1rem;margin-bottom:1.25rem;background:rgba(0,0,0,.05)}.pete-reader-scroll{--reader-fs:1.08rem}.pete-reader-body{font-size:var(--reader-fs);line-height:1.75}.pete-reader-body p{margin-bottom:1.05em}.pete-reader-body p:last-child{margin-bottom:0}.pete-reader-scroll.is-size-s{--reader-fs:0.98rem}.pete-reader-scroll.is-size-m{--reader-fs:1.08rem}.pete-reader-scroll.is-size-l{--reader-fs:1.22rem}.pete-reader-scroll.is-size-xl{--reader-fs:1.4rem}.pete-reader-scroll.is-serif .pete-reader-body{font-family:Georgia,Iowan Old Style,Times New Roman,serif}.pete-reader-scroll.is-sepia .pete-reader-article{background:#f6ecd6;color:#4a3a28;border-color:rgba(74,58,40,.16)}.pete-reader-scroll.is-sepia .pete-reader-note{border-top-color:rgba(74,58,40,.18);opacity:.8}.pete-reader-scroll.is-sepia .pete-reader-hero{background:rgba(74,58,40,.08)}.pete-reader-body p.is-speaking{background:color-mix(in srgb,var(--accent) 32%,transparent);border-radius:.4rem;box-shadow:0 0 0 .35rem color-mix(in srgb,var(--accent) 32%,transparent)}.pete-reader-note{margin-top:1.25rem;padding-top:1rem;border-top:1px solid rgba(0,0,0,.1);font-size:.85rem;opacity:.7}html[data-phase=night] .pete-reader-note{border-color:hsla(0,0%,100%,.12)}.pete-reader-note a{color:var(--accent);font-weight:700}.pete-reader-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:.75rem;padding:clamp(2rem,8vw,4rem) 1rem;color:var(--ink)}.pete-reader-empty-emoji{font-size:2.5rem}.pete-reader-toast{position:absolute;left:50%;bottom:4.5rem;transform:translate(-50%,.5rem);z-index:20;padding:.5rem .9rem;border-radius:9999px;background:rgba(20,14,6,.9);color:#fff;font-weight:700;font-size:.85rem;box-shadow:0 8px 24px rgba(0,0,0,.3);opacity:0;pointer-events:none;transition:opacity .2s ease,transform .2s ease}.pete-reader-toast.is-shown{opacity:1;transform:translate(-50%)}.pete-reader-hint{text-align:center;color:hsla(0,0%,100%,.85);font-size:.75rem;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-hint kbd{font-family:ui-monospace,monospace;background:hsla(0,0%,100%,.16);border-radius:.3rem;padding:.05rem .3rem}@media (max-width:640px){.pete-reader-hint{display:none}.pete-reader-bar{flex-wrap:wrap}.pete-reader-nav{flex-wrap:wrap;justify-content:flex-end;row-gap:.4rem}}.pete-reader-related{width:100%;margin:.85rem auto 0}.pete-reader-related-title{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:700;font-size:.95rem;color:#fff;margin:0 0 .6rem;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-related-grid{display:grid;gap:.6rem}.pete-reader-related-card{display:flex;align-items:center;gap:.75rem;background:var(--card);color:var(--ink);border-radius:1rem;border:2px solid rgba(0,0,0,.06);padding:.6rem;text-decoration:none;transition:transform .15s ease,box-shadow .15s ease}.pete-reader-related-card:hover{transform:translateY(-1px);box-shadow:0 6px 16px rgba(60,40,20,.16)}html[data-phase=night] .pete-reader-related-card{border-color:hsla(0,0%,100%,.08)}.pete-reader-related-thumb{width:4.5rem;height:3.25rem;flex-shrink:0;-o-object-fit:cover;object-fit:cover;border-radius:.6rem;background:rgba(0,0,0,.06)}.pete-reader-related-meta{min-width:0;display:flex;flex-direction:column;gap:.25rem}.pete-reader-related-eyebrow{display:flex;align-items:center;flex-wrap:wrap;gap:.4rem;font-size:.7rem}.pete-reader-related-source{font-weight:700;opacity:.7}.pete-reader-related-headline{font-weight:700;line-height:1.25;font-size:.92rem;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}[data-story-card][data-read="1"]{opacity:.5;transition:opacity .2s ease}[data-story-card][data-read="1"]:hover{opacity:1}[data-story-card][data-read="1"]:after{content:"✓";position:absolute;top:.6rem;right:.6rem;z-index:6;display:grid;place-items:center;height:1.5rem;width:1.5rem;border-radius:9999px;background:rgba(20,14,6,.72);color:#fff;font-size:.8rem;font-weight:700}.pete-felt{background:radial-gradient(120% 90% at 50% -10%,hsla(0,0%,100%,.1),transparent 60%),linear-gradient(160deg,#2f7d5b,#24614a 55%,#1c4d3c)}.pete-shoe{position:absolute;top:1.25rem;right:1.25rem;height:4.2rem;width:3rem;border-radius:.55rem;background:linear-gradient(150deg,#b4553f,#8d3f2f);border:2px solid rgba(0,0,0,.18);box-shadow:0 4px 0 rgba(0,0,0,.18),inset 0 0 0 3px hsla(0,0%,100%,.12)}.pete-shoe:after{content:"";position:absolute;inset:.45rem .4rem auto .4rem;height:.5rem;border-radius:.2rem;background:rgba(0,0,0,.22)}.pete-hand{display:flex;flex-wrap:wrap;gap:.6rem;min-height:8.6rem}.pete-card{perspective:700px;height:var(--card-h,8.4rem);width:var(--card-w,6rem);animation:pete-deal .42s cubic-bezier(.22,1,.36,1) backwards}.pete-card-inner{position:relative;height:100%;width:100%;transform-style:preserve-3d;transition:transform .45s cubic-bezier(.4,0,.2,1)}.pete-card[data-face=down] .pete-card-inner{transform:rotateY(180deg)}.pete-card-back,.pete-card-front{position:absolute;inset:0;display:grid;border-radius:.55rem;backface-visibility:hidden;-webkit-backface-visibility:hidden;box-shadow:0 3px 0 rgba(0,0,0,.18),0 6px 14px rgba(0,0,0,.22)}.pete-card-front{place-items:stretch;background:#fdfaf2;border:2px solid rgba(30,20,10,.12);color:#2b2118;font-family:Fredoka,Nunito,system-ui,sans-serif;line-height:1;overflow:hidden}.pete-card-front[data-red="1"]{color:#cc3d4a}.pete-card-back{transform:rotateY(180deg);background:repeating-linear-gradient(45deg,hsla(0,0%,100%,.1) 0 6px,transparent 6px 12px),linear-gradient(150deg,#b4553f,#8d3f2f);border:2px solid rgba(0,0,0,.15)}.pete-card-svg{width:100%;height:100%;display:block;fill:currentColor}.pete-card-idx{font-size:26px;font-weight:700;text-anchor:middle;fill:currentColor}.pete-card-panel{fill:currentColor;fill-opacity:.06;stroke:currentColor;stroke-opacity:.35;stroke-width:1.5}.pete-card-court{font-size:34px;font-weight:700;text-anchor:middle;fill:currentColor}@keyframes pete-deal{0%{opacity:0;transform:translate(var(--deal-x,14rem),var(--deal-y,-7rem)) scale(.72) rotate(9deg)}62%{opacity:1;transform:translateY(.35rem) scale(1.05) rotate(calc(var(--tilt, 0deg) - 2deg))}to{opacity:1;transform:translate(0) scale(1) rotate(var(--tilt,0deg))}}.pete-card:after{content:"";position:absolute;inset:auto 6% -.35rem 6%;height:.6rem;border-radius:999px;background:rgba(0,0,0,.35);filter:blur(5px);animation:pete-thud .42s cubic-bezier(.22,1,.36,1) backwards}@keyframes pete-thud{0%,55%{opacity:0;transform:scale(.4)}72%{opacity:.9;transform:scale(1.15)}to{opacity:.45;transform:scale(1)}}.pete-hand[data-won="1"] .pete-card{animation:pete-deal .42s cubic-bezier(.22,1,.36,1) backwards,pete-win .6s ease .1s}@keyframes pete-win{0%,to{transform:rotate(var(--tilt,0deg)) translateY(0)}40%{transform:rotate(var(--tilt,0deg)) translateY(-.6rem)}}.pete-hand[data-won="-1"] .pete-card{filter:saturate(.45) brightness(.78);transition:filter .5s ease .2s}.pete-disc{position:relative;border-radius:999px;background:radial-gradient(circle at 50% 30%,hsla(0,0%,100%,.3),transparent 62%),var(--chip,#e07a5f);border:2px solid rgba(0,0,0,.22);box-shadow:0 3px 0 rgba(0,0,0,.28),0 6px 14px rgba(0,0,0,.2)}.pete-disc:before{content:"";position:absolute;inset:11%;border-radius:999px;border:2px dashed hsla(0,0%,100%,.55)}.pete-disc[data-chip="5"]{--chip:#5aa9e6}.pete-disc[data-chip="25"]{--chip:#4caf7d}.pete-disc[data-chip="100"]{--chip:#2b2118}.pete-disc[data-chip="500"]{--chip:#b079d6}.pete-chip{transition:transform .12s ease}.pete-chip>span{position:relative}.pete-chip:active{transform:translateY(1px) scale(.94)}.pete-spot{position:relative;display:grid;place-items:center;height:7rem;width:7rem;flex:none;border-radius:999px;border:2px dashed hsla(0,0%,100%,.35);background:radial-gradient(circle at 50% 40%,hsla(0,0%,100%,.1),transparent 70%);box-shadow:inset 0 0 0 6px rgba(0,0,0,.08);transition:box-shadow .3s ease,border-color .3s ease}.pete-spot[data-live="1"]{border-color:rgba(var(--glow,242,181,61),.85);box-shadow:inset 0 0 0 6px rgba(0,0,0,.08),0 0 22px rgba(var(--glow,242,181,61),.35)}.pete-spot-label{font-size:.65rem;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:hsla(0,0%,100%,.35)}.pete-spot[data-live="1"] .pete-spot-label{opacity:0}.pete-stack{position:absolute;inset:0;pointer-events:none}.pete-stack .pete-disc{position:absolute;left:50%;top:50%;height:2.6rem;width:2.6rem;margin:-1.3rem 0 0 -1.3rem;transform:translateY(calc(-.5rem*var(--i, 0))) rotate(var(--spin,0deg));box-shadow:0 2px 0 rgba(0,0,0,.35),0 4px 8px rgba(0,0,0,.25);animation:pete-chip-land .28s cubic-bezier(.34,1.56,.64,1) backwards}@keyframes pete-chip-land{0%{transform:translateY(calc(-.5rem*var(--i, 0) - 1.1rem)) rotate(var(--spin,0deg)) scale(1.18)}}.pete-spot-total{position:absolute;bottom:-.6rem;left:50%;transform:translateX(-50%);white-space:nowrap;border-radius:999px;background:rgba(0,0,0,.45);padding:.1rem .6rem;font-size:.72rem;font-weight:700;font-variant-numeric:tabular-nums;color:#fff}.pete-fly-layer{position:fixed;inset:0;z-index:60;pointer-events:none;overflow:hidden}.pete-fly{position:absolute;height:2.6rem;width:2.6rem;margin:-1.3rem 0 0 -1.3rem;will-change:transform}.pete-rack{position:absolute;top:1.25rem;right:5.75rem;display:flex;align-items:flex-end;gap:.4rem;padding:.5rem .6rem .45rem;border-radius:.75rem;background:rgba(0,0,0,.2);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.06)}.pete-rack span{display:block;width:1.7rem;border-radius:.35rem;background:linear-gradient(90deg,rgba(0,0,0,.28),transparent 35%,transparent 65%,rgba(0,0,0,.28)),repeating-linear-gradient(180deg,hsla(0,0%,100%,.18) 0 .06rem,var(--chip,#e07a5f) .06rem .3rem,rgba(0,0,0,.35) .3rem .36rem);border:1px solid rgba(0,0,0,.35);height:calc(.36rem*var(--stack, 3))}.pete-rack span[data-chip="5"]{--chip:#5aa9e6}.pete-rack span[data-chip="25"]{--chip:#4caf7d}.pete-rack span[data-chip="100"]{--chip:#2b2118}.pete-rack span[data-chip="500"]{--chip:#b079d6}@media (max-width:639px){.pete-rack:not([data-at=rail]){gap:.25rem;padding:.35rem .4rem .3rem}.pete-rack:not([data-at=rail]) span{width:1.15rem}.pete-rack:not([data-at]){right:1rem}}.pete-spark{position:absolute;height:.75rem;width:.45rem;border-radius:2px;box-shadow:0 1px 3px rgba(0,0,0,.35);will-change:transform,opacity}.pete-dealer-think{animation:pete-think .9s ease-in-out infinite}@keyframes pete-think{0%,to{opacity:.6}50%{opacity:1;transform:translateY(-1px)}}.pete-gallows{overflow:visible;fill:none;stroke-linecap:round;stroke-linejoin:round}.pete-gallows-frame path{stroke:rgba(0,0,0,.35);stroke-width:6}.pete-gallows-frame path:last-child{stroke:rgba(0,0,0,.3);stroke-width:3.5}.pete-gallows-body>*{stroke:#fff;stroke-width:5;opacity:0;filter:drop-shadow(0 2px 0 rgba(0,0,0,.25))}.pete-gallows-body>[data-on="1"]{opacity:1}.pete-part-draw{stroke-dasharray:260;animation:pete-part .45s cubic-bezier(.22,1,.36,1) backwards}@keyframes pete-part{0%{stroke-dashoffset:260}to{stroke-dashoffset:0}}.pete-shake{animation:pete-shake .42s cubic-bezier(.36,.07,.19,.97)}@keyframes pete-shake{10%,90%{transform:translateX(-1.5px)}20%,80%{transform:translateX(3px)}30%,50%,70%{transform:translateX(-5px)}40%,60%{transform:translateX(5px)}}.pete-board{display:flex;flex-wrap:wrap;gap:.35rem 1.1rem;min-height:5rem;align-items:center}.pete-word{display:flex;gap:.3rem}.pete-tile{display:grid;place-items:center;height:2.9rem;width:2.2rem;border-radius:.5rem;font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:1.4rem;font-weight:700;color:#fff;background:rgba(0,0,0,.22);border-bottom:3px solid rgba(0,0,0,.28);text-transform:uppercase}.pete-tile[data-up="1"]{background:hsla(0,0%,100%,.95);color:#2b2118;border-bottom-color:rgba(0,0,0,.35)}.pete-tile[data-punct="1"]{background:none;border:0;width:auto;min-width:.7rem;color:hsla(0,0%,100%,.55)}.pete-tile-hit{animation:pete-tile-hit .34s cubic-bezier(.34,1.56,.64,1)}@keyframes pete-tile-hit{0%{transform:rotateX(90deg) scale(1.1)}to{transform:rotateX(0) scale(1)}}.pete-missed{display:grid;place-items:center;height:1.5rem;min-width:1.5rem;padding:0 .3rem;border-radius:.375rem;background:rgba(0,0,0,.25);font-size:.75rem;font-weight:700;color:hsla(0,0%,100%,.5);text-decoration:line-through;text-transform:uppercase}.pete-meter{display:flex;align-items:baseline;gap:.5rem;border-radius:999px;background:rgba(0,0,0,.28);padding:.4rem .9rem;box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.1);transition:box-shadow .3s ease}.pete-meter-label{font-size:.65rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:hsla(0,0%,100%,.45)}.pete-meter-value{font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:1.5rem;font-weight:700;font-variant-numeric:tabular-nums;color:var(--accent)}.pete-meter[data-cold="1"] .pete-meter-value{color:hsla(0,0%,100%,.55)}.pete-meter[data-hit="1"]{box-shadow:inset 0 0 0 2px rgba(204,61,74,.9);animation:pete-meter-hit .4s ease}@keyframes pete-meter-hit{0%{transform:scale(1)}35%{transform:scale(.94)}to{transform:scale(1)}}.pete-keys{display:grid;gap:.35rem}.pete-key-row{display:flex;justify-content:center;gap:.35rem}.pete-key-row[data-digits="1"]{margin-top:.25rem;opacity:.75}.pete-key-row[data-digits="1"] .pete-key{height:2rem;min-width:1.8rem;font-size:.8rem}.pete-key{height:2.75rem;min-width:2.2rem;flex:0 1 2.4rem;border-radius:.6rem;background:color-mix(in srgb,var(--ink) 6%,transparent);border:2px solid color-mix(in srgb,var(--ink) 10%,transparent);font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-weight:700;color:var(--ink);transition:transform .08s ease,background .15s ease,opacity .15s ease}.pete-key:hover:not(:disabled){background:color-mix(in srgb,var(--ink) 12%,transparent)}.pete-key:active:not(:disabled){transform:translateY(1px) scale(.96)}.pete-key:disabled{cursor:default}.pete-key[data-state=hit]{background:#4caf7d;border-color:#3d9367;color:#fff;opacity:1}.pete-key[data-state=miss]{background:color-mix(in srgb,var(--ink) 4%,transparent);color:color-mix(in srgb,var(--ink) 30%,transparent);text-decoration:line-through}.pete-key:disabled[data-state=""]{opacity:.4}.pete-tier{background:color-mix(in srgb,var(--ink) 4%,transparent);border-color:color-mix(in srgb,var(--ink) 10%,transparent)}.pete-tier:hover{background:color-mix(in srgb,var(--ink) 8%,transparent)}.pete-tier[data-on="1"]{background:color-mix(in srgb,var(--accent) 18%,transparent);border-color:var(--accent)}.pete-clock{position:relative;height:.6rem;border-radius:999px;background:rgba(0,0,0,.3);overflow:hidden;box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.08)}.pete-clock-fill{height:100%;width:100%;border-radius:999px;background:var(--accent);transform-origin:left center;transform:scaleX(1)}.pete-clock[data-hot="1"] .pete-clock-fill{background:#cc3d4a}.pete-clock[data-hot="1"]{animation:pete-clock-pulse .7s ease-in-out infinite}@keyframes pete-clock-pulse{0%,to{box-shadow:inset 0 0 0 2px rgba(204,61,74,.35)}50%{box-shadow:inset 0 0 0 2px rgba(204,61,74,.95)}}.pete-answer{position:relative;display:flex;align-items:center;gap:.75rem;width:100%;border-radius:1rem;padding:.85rem 1rem;text-align:left;font-weight:600;color:#fff;background:rgba(0,0,0,.26);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.1);transition:background .15s ease,transform .1s ease,box-shadow .2s ease}.pete-answer:hover:not(:disabled){background:rgba(0,0,0,.36);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.28)}.pete-answer:active:not(:disabled){transform:translateY(1px)}.pete-answer:disabled{cursor:default}.pete-answer-key{display:grid;place-items:center;height:1.75rem;width:1.75rem;flex:none;border-radius:.6rem;background:hsla(0,0%,100%,.12);font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:.8rem;font-weight:700;color:hsla(0,0%,100%,.7)}.pete-answer[data-state=right]{background:rgba(46,160,103,.9);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.5)}.pete-answer[data-state=wrong]{background:rgba(204,61,74,.9);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.35);animation:pete-answer-no .45s ease}.pete-answer[data-state=missed]{box-shadow:inset 0 0 0 2px rgba(46,160,103,.95)}.pete-answer[data-state=dim]{opacity:.4}@keyframes pete-answer-no{0%,to{transform:translateX(0)}20%{transform:translateX(-6px)}45%{transform:translateX(5px)}70%{transform:translateX(-3px)}}.pete-ladder{display:flex;flex-wrap:wrap;gap:.3rem}.pete-rung{height:.5rem;width:1.1rem;border-radius:999px;background:hsla(0,0%,100%,.14);transition:background .3s ease,transform .3s ease}.pete-rung[data-on="1"]{background:var(--accent);transform:scaleY(1.35)}.pete-solitaire{--card-w:clamp(2.5rem,10.6vw,5.2rem);--card-h:calc(var(--card-w)*1.4);--fan-up:calc(var(--card-h)*0.29);--fan-down:calc(var(--card-h)*0.14)}.pete-slot{position:relative;display:grid;place-items:center;height:var(--card-h);width:var(--card-w);flex:none;border-radius:.55rem;border:2px dashed hsla(0,0%,100%,.25);background:rgba(0,0,0,.12);box-shadow:inset 0 2px 8px rgba(0,0,0,.18);transition:border-color .18s ease,background .18s ease,transform .12s ease}.pete-slot-glyph{font-size:calc(var(--card-w)*.42);line-height:1;color:hsla(0,0%,100%,.3)}.pete-slot-glyph[data-red="1"]{color:hsla(0,100%,83%,.35)}.pete-slot>.pete-card{position:absolute;inset:0;height:100%;width:100%}.pete-stock{border-style:solid;border-color:rgba(0,0,0,.15);background:repeating-linear-gradient(45deg,hsla(0,0%,100%,.1) 0 6px,transparent 6px 12px),linear-gradient(150deg,#b4553f,#8d3f2f);cursor:pointer}.pete-stock:hover{filter:brightness(1.08)}.pete-stock:active{transform:translateY(1px) scale(.98)}.pete-stock[data-empty="1"]{background:rgba(0,0,0,.12);border-style:dashed;border-color:hsla(0,0%,100%,.25)}.pete-stock[data-dead="1"]{cursor:default;opacity:.4}.pete-stock[data-dead="1"]:hover{filter:none}.pete-stock[data-dead="1"]:active{transform:none}.pete-slot-count{position:absolute;bottom:-.5rem;left:50%;transform:translateX(-50%);border-radius:999px;background:rgba(0,0,0,.5);padding:.05rem .5rem;font-size:.7rem;font-weight:700;font-variant-numeric:tabular-nums;color:#fff}.pete-slot-recycle{color:hsla(0,0%,100%,.45)}.pete-slot-recycle svg{height:1.4rem;width:1.4rem}.pete-waste{position:relative;display:flex;height:var(--card-h);min-width:var(--card-w)}.pete-waste .pete-card+.pete-card{margin-left:calc(var(--card-w)*-.72)}.pete-waste .pete-card{position:relative}.pete-tableau{display:grid;grid-template-columns:repeat(7,var(--card-w));justify-content:space-between;gap:.5rem;align-items:start;min-height:calc(var(--card-h)*2.6)}.pete-col{display:flex;flex-direction:column;align-items:center;min-height:var(--card-h)}.pete-col .pete-card{position:relative}.pete-col .pete-card[data-face=down]+.pete-card{margin-top:calc(var(--fan-down) - var(--card-h))}.pete-col .pete-card[data-face=up]+.pete-card{margin-top:calc(var(--fan-up) - var(--card-h))}.pete-card[data-live="1"]{cursor:pointer}.pete-card[data-live="1"]:hover .pete-card-front{filter:brightness(1.06)}.pete-card[data-held="1"]{z-index:5;transform:translateY(-.55rem);transition:transform .14s cubic-bezier(.34,1.56,.64,1)}.pete-card[data-held="1"] .pete-card-front{box-shadow:0 3px 0 rgba(0,0,0,.18),0 10px 22px rgba(0,0,0,.32),0 0 0 3px rgba(242,181,61,.9)}.pete-col[data-drop="1"] .pete-card:last-child .pete-card-front,.pete-col[data-drop="1"] .pete-slot,.pete-slot[data-drop="1"]{border-color:rgba(242,181,61,.9);box-shadow:0 0 0 3px rgba(242,181,61,.45)}.pete-col[data-drop="1"] .pete-slot,.pete-slot[data-drop="1"]{background:rgba(242,181,61,.12)}.pete-nope{animation:pete-shake .4s cubic-bezier(.36,.07,.19,.97)}.pete-home-flash{animation:pete-home .5s ease-out}@keyframes pete-home{0%{box-shadow:0 0 0 0 rgba(242,181,61,.9)}to{box-shadow:0 0 0 1.4rem rgba(242,181,61,0)}}.pete-rail{display:flex;flex-direction:row;flex-wrap:wrap;align-items:center;justify-content:center;gap:1rem 1.5rem}@media (min-width:1024px){.pete-rail{flex-direction:column;justify-content:flex-start;width:9.5rem;gap:1.5rem}}.pete-rack[data-at=rail]{position:static;justify-content:center}.pete-uno[data-c=red]{--play:#d64545}.pete-uno[data-c=blue]{--play:#3d7fd6}.pete-uno[data-c=yellow]{--play:#e0b02c}.pete-uno[data-c=green]{--play:#46a86b}.pete-uno:before{content:"";position:absolute;inset:0;pointer-events:none;background:radial-gradient(80% 55% at 50% 45%,var(--play,transparent),transparent 70%);opacity:.16;transition:opacity .5s ease,background .5s ease}.pete-uno-card{position:relative;height:var(--uno-h,6.4rem);width:var(--uno-w,4.3rem);border-radius:.55rem;padding:.28rem;background:#fff;box-shadow:0 3px 0 rgba(0,0,0,.22),0 6px 14px rgba(0,0,0,.18);flex:none}.pete-uno-face{position:relative;display:grid;place-items:center;height:100%;width:100%;border-radius:.38rem;overflow:hidden;background:var(--uno,#2b2118);color:#fff}.pete-uno-card[data-c=red]{--uno:#d64545}.pete-uno-card[data-c=blue]{--uno:#3d7fd6}.pete-uno-card[data-c=yellow]{--uno:#e0b02c}.pete-uno-card[data-c=green]{--uno:#46a86b}.pete-uno-card[data-c=wild]{--uno:#2b2118}.pete-uno-card[data-named=red]{box-shadow:0 0 0 3px #d64545,0 3px 0 rgba(0,0,0,.22)}.pete-uno-card[data-named=blue]{box-shadow:0 0 0 3px #3d7fd6,0 3px 0 rgba(0,0,0,.22)}.pete-uno-card[data-named=yellow]{box-shadow:0 0 0 3px #e0b02c,0 3px 0 rgba(0,0,0,.22)}.pete-uno-card[data-named=green]{box-shadow:0 0 0 3px #46a86b,0 3px 0 rgba(0,0,0,.22)}.pete-uno-oval{position:relative;z-index:1;display:grid;place-items:center;height:78%;width:62%;border-radius:50%;transform:rotate(-24deg);background:#fff;color:var(--uno,#2b2118);font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:1.45rem;font-weight:700;line-height:1;text-shadow:0 1px 0 rgba(0,0,0,.1)}.pete-uno-card[data-c=wild] .pete-uno-oval{color:#2b2118;font-size:1.15rem}.pete-uno-corner{position:absolute;z-index:1;font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:.62rem;font-weight:700;line-height:1;color:#fff;text-shadow:0 1px 1px rgba(0,0,0,.35)}.pete-uno-corner[data-at=tl]{top:.22rem;left:.28rem}.pete-uno-corner[data-at=br]{bottom:.22rem;right:.28rem;transform:rotate(180deg)}.pete-uno-wheel{position:absolute;inset:0;background:conic-gradient(#d64545 0 25%,#e0b02c 0 50%,#46a86b 0 75%,#3d7fd6 0);opacity:.92}.pete-uno-card-back{padding:.28rem}.pete-uno-back{display:grid;place-items:center;height:100%;width:100%;border-radius:.38rem;background:radial-gradient(120% 80% at 50% 0,hsla(0,0%,100%,.16),transparent 60%),#2b2118}.pete-uno-back:after{content:"UNO";display:block;transform:rotate(-24deg);padding:.1rem .35rem;border-radius:999px;background:#e0b02c;color:#2b2118;font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:.6rem;font-weight:700;letter-spacing:.02em}.pete-uno-hand{display:flex;flex-wrap:wrap;justify-content:center;gap:.4rem;min-height:6.8rem;padding-top:.6rem}.pete-uno-hand .pete-uno-card{animation:pete-uno-in .34s cubic-bezier(.22,1,.36,1) backwards;animation-delay:calc(var(--i, 0)*45ms);transition:transform .16s ease,filter .16s ease}.pete-uno-hand .pete-uno-card[data-on="1"]{cursor:pointer;transform:translateY(-.5rem)}.pete-uno-hand .pete-uno-card[data-on="1"]:hover{transform:translateY(-1.1rem) scale(1.03)}.pete-uno-hand .pete-uno-card[data-on="0"]{filter:brightness(.62) saturate(.7)}@keyframes pete-uno-in{0%{transform:translateY(2rem) rotate(6deg);opacity:0}to{transform:translateY(-.5rem);opacity:1}}.pete-uno-seat{display:flex;flex-direction:column;align-items:center;gap:.3rem;position:relative;padding:.5rem .7rem;border-radius:1rem;transition:background .25s ease,transform .25s ease}.pete-uno-seat[data-turn="1"]{background:hsla(0,0%,100%,.12);transform:translateY(-2px)}.pete-uno-seat[data-uno="1"] .pete-uno-count{color:#ffd76e}.pete-uno-fan{display:flex;--uno-h:3.2rem;--uno-w:2.15rem}.pete-uno-fan .pete-uno-card{margin-left:-1.35rem;transform:rotate(calc((var(--i, 0) - (var(--n, 1) - 1)/2)*4deg));transform-origin:bottom center;box-shadow:0 2px 0 rgba(0,0,0,.22)}.pete-uno-fan .pete-uno-card:first-child{margin-left:0}.pete-uno-fan .pete-uno-back:after{font-size:.4rem;padding:.06rem .22rem}.pete-uno-name{font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:.9rem;font-weight:700;color:#fff;line-height:1}.pete-uno-count{font-size:.7rem;font-weight:700;color:hsla(0,0%,100%,.55);line-height:1}.pete-uno-deck{position:relative;--uno-h:6.4rem;--uno-w:4.3rem;height:var(--uno-h);width:var(--uno-w);border-radius:.55rem;padding:.28rem;background:#fff;box-shadow:0 3px 0 rgba(0,0,0,.22),.28rem .28rem 0 -.06rem hsla(0,0%,100%,.35),.5rem .5rem 0 -.12rem hsla(0,0%,100%,.18);transition:transform .15s ease}.pete-uno-deck:not(:disabled):hover{transform:translateY(-3px)}.pete-uno-deck:disabled{opacity:.75}.pete-uno-deck-count{position:absolute;bottom:-.55rem;left:50%;transform:translateX(-50%);padding:.1rem .5rem;border-radius:999px;background:rgba(0,0,0,.55);color:#fff;font-size:.65rem;font-weight:700;line-height:1.4}.pete-uno-shuffle{animation:pete-uno-shuffle .42s ease}@keyframes pete-uno-shuffle{0%,to{transform:none}30%{transform:translateY(-4px) rotate(-3deg)}65%{transform:translateY(2px) rotate(2deg)}}.pete-uno-discard{display:grid;place-items:center;height:6.4rem;width:4.3rem;border-radius:.55rem;box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.14)}.pete-uno-land{animation:pete-uno-land .3s cubic-bezier(.34,1.56,.64,1)}@keyframes pete-uno-land{0%{transform:scale(1.14) rotate(-4deg)}to{transform:none}}.pete-uno-colour{font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;padding:.15rem .6rem;border-radius:999px;color:#fff;background:rgba(0,0,0,.3)}.pete-uno-colour[data-c=red]{background:#d64545}.pete-uno-colour[data-c=blue]{background:#3d7fd6}.pete-uno-colour[data-c=yellow]{background:#e0b02c;color:#2b2118}.pete-uno-colour[data-c=green]{background:#46a86b}.pete-uno-badge{position:absolute;top:-.4rem;left:50%;z-index:5;padding:.2rem .6rem;border-radius:999px;background:#fff;color:#2b2118;font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:.75rem;font-weight:700;white-space:nowrap;box-shadow:0 3px 0 rgba(0,0,0,.2);animation:pete-uno-badge .9s ease forwards}.pete-uno-badge[data-tone=bad]{background:#cc3d4a;color:#fff}.pete-uno-badge[data-tone=uno]{background:#e0b02c}@keyframes pete-uno-badge{0%{transform:translate(-50%,.4rem) scale(.7);opacity:0}25%{transform:translate(-50%,-.5rem) scale(1.06);opacity:1}75%{transform:translate(-50%,-.9rem) scale(1);opacity:1}to{transform:translate(-50%,-1.6rem) scale(.95);opacity:0}}.pete-uno-wild{position:absolute;inset:0;z-index:10;display:grid;place-items:center;background:rgba(10,20,15,.55);-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px)}.pete-uno-wild-box{padding:1.25rem 1.5rem;border-radius:1.25rem;background:rgba(20,40,30,.92);box-shadow:0 12px 30px rgba(0,0,0,.35);text-align:center}.pete-uno-swatch{padding:.7rem 1.4rem;border-radius:.75rem;font-family:Fredoka,ui-sans-serif,system-ui,sans-serif;font-size:1rem;font-weight:700;color:#fff;background:var(--sw,#666);box-shadow:0 3px 0 rgba(0,0,0,.3);transition:transform .12s ease,filter .12s ease}.pete-uno-swatch:hover{transform:translateY(-2px);filter:brightness(1.08)}.pete-uno-swatch:active{transform:translateY(1px)}.pete-uno-swatch[data-c=red]{--sw:#d64545}.pete-uno-swatch[data-c=blue]{--sw:#3d7fd6}.pete-uno-swatch[data-c=yellow]{--sw:#e0b02c;color:#2b2118}.pete-uno-swatch[data-c=green]{--sw:#46a86b}@media (max-width:639px){.pete-uno-hand{gap:.3rem}.pete-uno-deck,.pete-uno-discard,.pete-uno-hand{--uno-h:5.2rem;--uno-w:3.5rem}.pete-uno-discard{height:5.2rem;width:3.5rem}.pete-uno-hand .pete-uno-card{padding:.22rem}.pete-uno-oval{font-size:1.15rem}.pete-uno-seat{padding:.35rem .45rem}}@media (prefers-reduced-motion:reduce){.pete-card,.pete-card:after,.pete-dealer-think,.pete-stack .pete-disc{animation:none}.pete-card-inner{transition:none}.pete-hand[data-won="1"] .pete-card,.pete-home-flash,.pete-meter[data-hit="1"],.pete-nope,.pete-part-draw,.pete-shake,.pete-tile-hit{animation:none}.pete-card[data-held="1"]{transition:none}.pete-answer[data-state=wrong],.pete-clock[data-hot="1"]{animation:none}.pete-rung{transition:none}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-right-6{right:-1.5rem}.-top-6{top:-1.5rem}.-z-10{z-index:-10}.-z-\[5\]{z-index:-5}.z-50{z-index:50}.order-none{order:0}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-auto{margin-left:auto;margin-right:auto}.-mt-1{margin-top:-.25rem}.mb-1{margin-bottom:.25rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-8{margin-bottom:2rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mt-0{margin-top:0}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.line-clamp-2{-webkit-line-clamp:2}.line-clamp-2,.line-clamp-3{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical}.line-clamp-3{-webkit-line-clamp:3}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.aspect-\[16\/10\]{aspect-ratio:16/10}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-20{height:5rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-52{height:13rem}.h-6{height:1.5rem}.h-9{height:2.25rem}.h-full{height:100%}.max-h-\[55vh\]{max-height:55vh}.max-h-\[60vh\]{max-height:60vh}.min-h-\[1\.75rem\]{min-height:1.75rem}.min-h-\[16rem\]{min-height:16rem}.min-h-\[2\.75rem\]{min-height:2.75rem}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-2{width:.5rem}.w-2\.5{width:.625rem}.w-20{width:5rem}.w-24{width:6rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-9{width:2.25rem}.w-auto{width:auto}.w-full{width:100%}.min-w-0{min-width:0}.min-w-\[28rem\]{min-width:28rem}.min-w-\[52rem\]{min-width:52rem}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-\[7rem\]{max-width:7rem}.max-w-full{max-width:100%}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink,.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.basis-full{flex-basis:100%}.-translate-y-0{--tw-translate-y:-0px}.-translate-y-0,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-10{-moz-column-gap:2.5rem;column-gap:2.5rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-3{row-gap:.75rem}.gap-y-4{row-gap:1rem}.gap-y-8{row-gap:2rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.375rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.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-t-2{border-top-width:2px}.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-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0{padding-top:0;padding-bottom:0}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-10{padding-bottom:2.5rem}.pb-20{padding-bottom:5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pr-24{padding-right:6rem}.pr-32{padding-right:8rem}.pt-1{padding-top:.25rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-8{padding-top:2rem}.pt-\[10vh\]{padding-top:10vh}.pt-\[12vh\]{padding-top:12vh}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.align-\[-4px\]{vertical-align:-4px}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12rem\]{font-size:12rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-\[0\.18em\]{letter-spacing:.18em}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-\[\#1c1305\]{--tw-text-opacity:1;color:rgb(28 19 5/var(--tw-text-opacity,1))}.text-\[\#20180c\]{--tw-text-opacity:1;color:rgb(32 24 12/var(--tw-text-opacity,1))}.text-\[\#2b2118\]{--tw-text-opacity:1;color:rgb(43 33 24/var(--tw-text-opacity,1))}.text-\[color\:var\(--accent\)\]{color:var(--accent)}.text-\[color\:var\(--ink\)\]{color:var(--ink)}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-emerald-700{--tw-text-opacity:1;color:rgb(4 120 87/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-white\/40{color:hsla(0,0%,100%,.4)}.text-white\/50{color:hsla(0,0%,100%,.5)}.text-white\/55{color:hsla(0,0%,100%,.55)}.text-white\/60{color:hsla(0,0%,100%,.6)}.text-white\/70{color:hsla(0,0%,100%,.7)}.text-white\/85{color:hsla(0,0%,100%,.85)}.text-white\/90{color:hsla(0,0%,100%,.9)}.underline{text-decoration-line:underline}.decoration-4{text-decoration-thickness:4px}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.accent-\[color\:var\(--accent\)\]{accent-color:var(--accent)}.opacity-20{opacity:.2}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.blur{--tw-blur:blur(8px)}.blur,.drop-shadow{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.drop-shadow{--tw-drop-shadow:drop-shadow(0 1px 2px rgba(0,0,0,.1)) drop-shadow(0 1px 1px rgba(0,0,0,.06))}.grayscale{--tw-grayscale:grayscale(100%)}.grayscale,.sepia{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.sepia{--tw-sepia:sepia(100%)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.backdrop-blur-sm,.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-1000{transition-duration:1s}.duration-500{transition-duration:.5s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}.font-display{font-family:Fredoka,Nunito,system-ui,sans-serif;letter-spacing:-.01em}.shadow-pete{box-shadow:0 4px 0 rgba(60,40,20,.1),0 8px 24px rgba(60,40,20,.08)}.shadow-pete-lg{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.bg-theme-gaming{background-color:#4caf7d}.bg-theme-tech{background-color:#5aa9e6}.bg-theme-politics{background-color:#e07a5f}.bg-theme-eu{background-color:#039}.bg-theme-music{background-color:#b079d6}.bg-theme-anime{background-color:#ec5e8a}.bg-theme-foss{background-color:#d97706}.bg-theme-kids{background-color:#14b8a6}.bg-theme-finance{background-color:#10b981}.bg-theme-lego{background-color:#d01012}.bg-theme-adventure{background-color:#6d4bd8}.text-theme-gaming{color:#2d8a5a}.text-theme-tech{color:#2f7fb8}.text-theme-politics{color:#b8523a}.text-theme-eu{color:#039}.text-theme-music{color:#8a4fb8}.text-theme-anime{color:#c33a6a}.text-theme-foss{color:#b8530a}.text-theme-kids{color:#0f766e}.text-theme-finance{color:#059669}.text-theme-lego{color:#b00d0e}.text-theme-adventure{color:#5836b8}.decoration-theme-gaming{text-decoration-color:#4caf7d}.decoration-theme-tech{text-decoration-color:#5aa9e6}.decoration-theme-politics{text-decoration-color:#e07a5f}.decoration-theme-eu{text-decoration-color:#039}.decoration-theme-music{text-decoration-color:#b079d6}.decoration-theme-anime{text-decoration-color:#ec5e8a}.decoration-theme-foss{text-decoration-color:#d97706}.decoration-theme-kids{text-decoration-color:#14b8a6}.decoration-theme-finance{text-decoration-color:#10b981}.decoration-theme-lego{text-decoration-color:#d01012}.decoration-theme-adventure{text-decoration-color:#6d4bd8}.border-theme-gaming{border-color:#4caf7d}.border-theme-tech{border-color:#5aa9e6}.border-theme-politics{border-color:#e07a5f}.border-theme-eu{border-color:#039}.border-theme-music{border-color:#b079d6}.border-theme-anime{border-color:#ec5e8a}.border-theme-foss{border-color:#d97706}.border-theme-kids{border-color:#14b8a6}.border-theme-finance{border-color:#10b981}.border-theme-lego{border-color:#d01012}.border-theme-adventure{border-color:#6d4bd8}.glow-theme-gaming{box-shadow:0 0 0 2px rgba(76,175,125,.35),0 0 24px 4px rgba(76,175,125,.45)}.glow-theme-gaming,.glow-theme-tech{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-tech{box-shadow:0 0 0 2px rgba(90,169,230,.35),0 0 24px 4px rgba(90,169,230,.45)}.glow-theme-politics{box-shadow:0 0 0 2px rgba(224,122,95,.35),0 0 24px 4px rgba(224,122,95,.45)}.glow-theme-eu,.glow-theme-politics{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-eu{box-shadow:0 0 0 2px rgba(0,51,153,.35),0 0 24px 4px rgba(0,51,153,.45)}.glow-theme-music{box-shadow:0 0 0 2px rgba(176,121,214,.35),0 0 24px 4px rgba(176,121,214,.45)}.glow-theme-anime,.glow-theme-music{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-anime{box-shadow:0 0 0 2px rgba(236,94,138,.35),0 0 24px 4px rgba(236,94,138,.45)}.glow-theme-foss{box-shadow:0 0 0 2px rgba(217,119,6,.35),0 0 24px 4px rgba(217,119,6,.45)}.glow-theme-foss,.glow-theme-kids{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-kids{box-shadow:0 0 0 2px rgba(20,184,166,.35),0 0 24px 4px rgba(20,184,166,.45)}.glow-theme-finance{box-shadow:0 0 0 2px rgba(16,185,129,.35),0 0 24px 4px rgba(16,185,129,.45)}.glow-theme-finance,.glow-theme-lego{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-lego{box-shadow:0 0 0 2px rgba(208,16,18,.35),0 0 24px 4px rgba(208,16,18,.45)}.glow-theme-adventure{box-shadow:0 0 0 2px rgba(109,75,216,.35),0 0 24px 4px rgba(109,75,216,.45);animation:pete-glow-pulse 2.4s ease-in-out infinite}@keyframes pete-glow-pulse{0%,to{filter:brightness(1)}50%{filter:brightness(1.08)}}.line-clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.paywall-stamp{position:absolute;inset:0;pointer-events:none;display:flex;align-items:center;justify-content:center;z-index:5;background:rgba(255,250,240,.18)}.paywall-stamp:before{content:"PAYWALLED";font-family:var(--font-display,ui-sans-serif),system-ui,sans-serif;font-weight:900;font-size:clamp(1rem,3.2vw,1.75rem);letter-spacing:.12em;color:rgba(180,30,30,.85);border:.28rem double rgba(180,30,30,.85);padding:.3rem .9rem;transform:rotate(-15deg);text-shadow:0 1px 0 hsla(0,0%,100%,.4);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.15);background:rgba(255,240,230,.55);border-radius:.25rem;filter:contrast(1.05)}:root,html[data-phase=day]{--bg:#fff7e4;--bg-grad:#ffeec2;--card:#fff;--ink:#3a2e1f;--accent:#f2a541}html[data-phase=dawn]{--bg:#ffe7d6;--bg-grad:#ffc9c9;--card:#fff4ea;--ink:#4a2e2a;--accent:#ff8a65}html[data-phase=dusk]{--bg:#ffd6a8;--bg-grad:#f7a07e;--card:#fff1de;--ink:#3d2417;--accent:#e6553a}html[data-phase=night]{--bg:#1a1f3a;--bg-grad:#2a3358;--card:#2d365a;--ink:#f1ecd8;--accent:#f9d976}html[data-room=casinopolis]{--bg:#16211c;--card:#23342b;--ink:#f6ecd8;--accent:#f2b53d;--felt-a:#2f7d5b;--felt-b:#24614a;--felt-c:#1c4d3c;--glow:242,181,61}html[data-room=casino-night]{--bg:#140f2e;--card:#241a4d;--ink:#f2ecff;--accent:#ffcc2f;--felt-a:#4a2fa8;--felt-b:#351f80;--felt-c:#241659;--glow:126,86,255}html[data-room] .cs-room{background:radial-gradient(80% 55% at 50% -8%,rgba(var(--glow),.18),transparent 62%),radial-gradient(120% 90% at 50% 120%,rgba(0,0,0,.45),transparent 55%)}html[data-room=casino-night] .cs-room:before{content:"";position:absolute;inset:0 -50% auto -50%;height:.5rem;background:repeating-linear-gradient(90deg,rgba(255,204,47,.55) 0 .5rem,transparent .5rem 2.25rem);filter:blur(1px);animation:cs-bulbs 2.4s linear infinite}@keyframes cs-bulbs{0%{transform:translateX(0)}to{transform:translateX(2.75rem)}}html[data-room] .shadow-pete{box-shadow:0 4px 0 rgba(0,0,0,.3),0 10px 26px rgba(0,0,0,.3)}html[data-room] .shadow-pete-lg{box-shadow:0 6px 0 rgba(0,0,0,.34),0 20px 40px rgba(0,0,0,.38)}html[data-room] .cs-comb svg{filter:drop-shadow(0 3px 0 rgba(0,0,0,.35))}html[data-room] .pete-felt{background:radial-gradient(120% 90% at 50% -10%,hsla(0,0%,100%,.1),transparent 60%),linear-gradient(160deg,var(--felt-a) 0,var(--felt-b) 55%,var(--felt-c) 100%)}@media (prefers-reduced-motion:reduce){html[data-room=casino-night] .cs-room:before{animation:none}}.cs-stack{display:block;width:2.75rem;height:calc(.45rem*var(--stack, 1) + .55rem);border-radius:999px;background:radial-gradient(circle at 50% 30%,hsla(0,0%,100%,.3),transparent 60%),var(--chip,#e07a5f);border:2px solid rgba(0,0,0,.25);box-shadow:0 3px 0 rgba(0,0,0,.3),inset 0 -.4rem 0 rgba(0,0,0,.16),inset 0 .35rem 0 hsla(0,0%,100%,.14)}.cs-stack[data-chip="5"]{--chip:#5aa9e6}.cs-stack[data-chip="25"]{--chip:#4caf7d}.cs-stack[data-chip="100"]{--chip:#2b2118}.cs-stack[data-chip="500"]{--chip:#b079d6}.pete-poker{--seat-w:7.5rem}.pete-poker-seats{display:flex;flex-wrap:wrap;align-items:flex-start;justify-content:center;gap:.75rem}.pete-seat{position:relative;display:flex;flex-direction:column;align-items:center;gap:.4rem;width:var(--seat-w);transition:opacity .3s ease,filter .3s ease}.pete-seat[data-state=folded]{opacity:.38;filter:grayscale(.7)}.pete-seat[data-state=out]{opacity:.2}.pete-seat[data-turn="1"] .pete-seat-plate{box-shadow:0 0 0 2px var(--accent),0 0 1.6rem -.2rem var(--accent);animation:pete-seat-wait 1.6s ease-in-out infinite}@keyframes pete-seat-wait{0%,to{transform:translateY(0)}50%{transform:translateY(-2px)}}.pete-seat-cards{display:flex;justify-content:center;gap:.15rem;min-height:4.4rem;--card-h:4.4rem;--card-w:3.15rem}.pete-seat-cards[data-mucked="1"]{opacity:0;transform:translateY(-.6rem) scale(.9);transition:all .35s ease}.pete-seat-plate{position:relative;display:flex;flex-direction:column;align-items:center;width:100%;padding:.3rem .4rem;border-radius:.85rem;background:rgba(0,0,0,.32);border:1px solid hsla(0,0%,100%,.12);transition:box-shadow .25s ease}.pete-seat-name{font-family:var(--font-display,inherit);font-weight:700;font-size:.8rem;line-height:1.1;color:hsla(0,0%,100%,.92);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pete-seat-stack{font-weight:700;font-size:.78rem;font-variant-numeric:tabular-nums;color:hsla(0,0%,100%,.58)}.pete-seat[data-state=allin] .pete-seat-stack:after{content:" all in";color:var(--accent)}.pete-seat-pos{position:absolute;top:-.5rem;right:-.5rem;display:grid;place-items:center;min-width:1.45rem;height:1.45rem;padding:0 .3rem;border-radius:999px;font-size:.58rem;font-weight:800;letter-spacing:.02em;background:hsla(0,0%,100%,.9);color:#2b2118;box-shadow:0 2px 0 rgba(0,0,0,.3)}.pete-seat-pos[data-pos=BTN]{background:#f5d76e}.pete-seat-spot{height:3.6rem;width:3.6rem;margin-bottom:.5rem}.pete-seat-spot .pete-disc{height:1.6rem;width:1.6rem;margin:-.8rem 0 0 -.8rem}.pete-seat-spot .pete-spot-total{font-size:.66rem;padding:.05rem .45rem}.pete-poker-middle{display:flex;flex-direction:column;align-items:center;gap:.35rem;padding:.5rem 0}.pete-poker-pot{margin-top:.9rem}.pete-poker-board{display:flex;gap:.35rem;min-height:6rem;--card-h:6rem;--card-w:4.3rem}.pete-poker-pot{display:flex;flex-direction:column;align-items:center;gap:.1rem}.pete-poker-pot-pile{position:relative;width:7rem;height:3rem}.pete-poker-pot-label{font-size:.62rem;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:hsla(0,0%,100%,.42)}.pete-poker-pot-total{font-family:var(--font-display,inherit);font-size:1.5rem;font-weight:800;font-variant-numeric:tabular-nums;color:#fff;text-shadow:0 2px 0 rgba(0,0,0,.28)}.pete-poker-side{font-size:.68rem;font-weight:700;color:hsla(0,0%,100%,.5)}.pete-poker-you .pete-seat{width:auto}.pete-poker-you .pete-seat-cards{--card-h:6.8rem;--card-w:4.85rem;gap:.3rem;min-height:6.8rem}.pete-poker-you .pete-seat-plate{padding:.4rem 1.1rem}.pete-poker-you .pete-seat-name,.pete-poker-you .pete-seat-stack{font-size:.95rem}.pete-poker-verdict{border-radius:999px;background:hsla(0,0%,100%,.95);padding:.5rem 1.25rem;font-family:var(--font-display,inherit);font-size:1.05rem;font-weight:700;color:#2b2118;box-shadow:0 4px 0 rgba(0,0,0,.18)}.pete-poker-verdict[data-tone=win]{background:#4caf7d;color:#fff}.pete-poker-verdict[data-tone=lose]{background:hsla(0,0%,100%,.9);color:#7a5c50}.pete-raise{display:flex;align-items:center;gap:.6rem;flex:1 1 14rem;min-width:0}.pete-raise input[type=range]{flex:1;min-width:0;accent-color:var(--accent)}.pete-raise-to{font-family:var(--font-display,inherit);font-size:1.15rem;font-weight:800;font-variant-numeric:tabular-nums;min-width:3.5rem;text-align:right}.pete-poker-log{max-height:7rem;overflow-y:auto;font-size:.75rem;line-height:1.5;color:hsla(0,0%,100%,.55)}.pete-poker-log b{color:hsla(0,0%,100%,.85);font-weight:700}@media (max-width:640px){.pete-poker{--seat-w:5.6rem}.pete-seat-cards{--card-h:3.5rem;--card-w:2.5rem;min-height:3.5rem}.pete-poker-board{--card-h:5rem;--card-w:3.55rem;min-height:5rem;gap:.25rem}.pete-poker-you .pete-seat-cards{--card-h:6rem;--card-w:4.3rem;min-height:6rem}.pete-poker-pot-total{font-size:1.25rem}}.last\:border-0:last-child{border-width:0}.empty\:hidden:empty{display:none}.hover\:-translate-y-0\.5:hover{--tw-translate-y:-0.125rem}.hover\:-translate-y-0\.5:hover,.hover\:-translate-y-1:hover{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:-translate-y-1:hover{--tw-translate-y:-0.25rem}.hover\:text-\[color\:var\(--ink\)\]:hover{color:var(--ink)}.hover\:text-white\/80:hover{color:hsla(0,0%,100%,.8)}.hover\:brightness-105:hover{--tw-brightness:brightness(1.05);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.hover\:shadow-pete-lg:hover{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.focus\:border-\[color\:var\(--accent\)\]:focus{border-color:var(--accent)}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.active\:translate-y-px:active{--tw-translate-y:1px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:-rotate-6{--tw-rotate:-6deg}.group:hover .group-hover\:-rotate-6,.group:hover .group-hover\:scale-105{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:scale-105{--tw-scale-x:1.05;--tw-scale-y:1.05}.group:hover .group-hover\:underline{text-decoration-line:underline}@media (min-width:640px){.sm\:ml-auto{margin-left:auto}.sm\:block{display:block}.sm\:inline{display:inline}.sm\:flex{display:flex}.sm\:inline-flex{display:inline-flex}.sm\:h-56{height:14rem}.sm\:basis-auto{flex-basis:auto}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:gap-2{gap:.5rem}.sm\:gap-3{gap:.75rem}.sm\:gap-4{gap:1rem}.sm\:gap-5{gap:1.25rem}.sm\:gap-8{gap:2rem}.sm\:space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.sm\:p-10{padding:2.5rem}.sm\:p-6{padding:1.5rem}.sm\:p-8{padding:2rem}.sm\:pr-28{padding-right:7rem}.sm\:pt-10{padding-top:2.5rem}.sm\:pt-12{padding-top:3rem}.sm\:text-2xl{font-size:1.5rem;line-height:2rem}.sm\:text-3xl{font-size:1.875rem;line-height:2.25rem}.sm\:text-4xl{font-size:2.25rem;line-height:2.5rem}.sm\:text-5xl{font-size:3rem;line-height:1}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-\[auto\2c 1fr\]{grid-template-columns:auto 1fr}.lg\:grid-cols-\[minmax\(0\2c 1fr\)\2c auto\]{grid-template-columns:minmax(0,1fr) auto}.lg\:items-start{align-items:flex-start}.lg\:gap-8{gap:2rem}.lg\:p-8{padding:2rem}}@media (prefers-color-scheme:dark){.dark\:text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dark\:text-emerald-300{--tw-text-opacity:1;color:rgb(110 231 183/var(--tw-text-opacity,1))}.dark\:text-red-300{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.dark\:text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}} \ No newline at end of file diff --git a/internal/web/static/js/holdem.js b/internal/web/static/js/holdem.js new file mode 100644 index 0000000..6e15c6d --- /dev/null +++ b/internal/web/static/js/holdem.js @@ -0,0 +1,648 @@ +// The hold'em table. +// +// Same bargain as every other table in the room: the browser holds no game. It +// sends one move, and what comes back is that move *and every bot action behind +// it* — plus whatever streets that finished and whatever the pot did — as a +// script of events. So a round trip here can be a whole hand: shove all-in and +// the flop, turn, river, showdown and payout all arrive in one response, and this +// file's job is to play them back slowly enough that you can watch it happen to +// you. +// +// The one thing here that no other table has is a *second seat with money on it*. +// Everywhere else the spot is a singleton, because there was only ever you and +// the house. Here every seat has its own, chips move from a seat to its spot and +// from every spot into the pot, and out of the pot to whoever won it. PeteFX.spot +// still owns the rule that the number under a pile is a readout of that pile, so +// none of that arithmetic lives in here. +// +// And the browser never learns a bot's hand. Their cards are backs until a +// showdown turns them over, because a showdown is the only time a player at a +// real table would be looking at them. +(function () { + "use strict"; + + var root = document.querySelector("[data-holdem]"); + if (!root) return; + + var FX = window.PeteFX; + var Cards = window.PeteCards; + + var seatsEl = root.querySelector("[data-seats]"); + var youEl = root.querySelector("[data-you]"); + var boardEl = root.querySelector("[data-board]"); + var potStack = root.querySelector("[data-pot-stack]"); + var potTotal = root.querySelector("[data-pot-total]"); + var sideEl = root.querySelector("[data-side]"); + var blindsEl = root.querySelector("[data-blinds]"); + var tableName = root.querySelector("[data-table-name]"); + var verdictEl = root.querySelector("[data-verdict]"); + var houseEl = root.querySelector("[data-house]"); + + var acting = root.querySelector("[data-acting]"); + var between = root.querySelector("[data-between]"); + var sitting = root.querySelector("[data-sitting]"); + + var foldBtn = root.querySelector('[data-move="fold"]'); + var checkBtn = root.querySelector('[data-move="check"]'); + var callBtn = root.querySelector('[data-move="call"]'); + var raiseBtn = root.querySelector('[data-move="raise"]'); + var callAmt = root.querySelector("[data-call-amount]"); + var raiseRow = root.querySelector("[data-raise-row]"); + var slider = root.querySelector("[data-raise-slider]"); + var raiseTo = root.querySelector("[data-raise-to]"); + var raiseLbl = root.querySelector("[data-raise-label]"); + var raiseVerb = root.querySelector("[data-raise-verb]"); + + var dealBtn = root.querySelector("[data-deal]"); + var topupBtn = root.querySelector("[data-topup]"); + var leaveBtn = root.querySelector("[data-leave]"); + var stackEl = root.querySelector("[data-table-stack]"); + var boughtEl = root.querySelector("[data-bought-in]"); + var rakeEl = root.querySelector("[data-session-rake]"); + + var sitBtn = root.querySelector("[data-sit]"); + var buySlider = root.querySelector("[data-buyin-slider]"); + var buyLabel = root.querySelector("[data-buyin]"); + var buyNote = root.querySelector("[data-buyin-note]"); + var botsNote = root.querySelector("[data-bots-note]"); + var gameMsg = root.querySelector("[data-game-msg]"); + var betweenMsg = root.querySelector("[data-between-msg]"); + var tableMsg = root.querySelector("[data-table-msg]"); + + var view = null; // the table, as the server last described it + var busy = false; + var seatEls = []; // one per seat: { root, cards, plate, stack, spot } + var shown = []; // what each seat's stack label currently reads + var pot = null; // the middle pile, a PeteFX.spot + + var tierBtns = Array.prototype.slice.call(root.querySelectorAll("[data-tier]")); + var botBtns = Array.prototype.slice.call(root.querySelectorAll("[data-bot-count]")); + var tier = null; + var bots = 2; + var buyIn = 0; + + function money(n) { return (n || 0).toLocaleString(); } + + function say(el, text, tone) { + if (!el) return; + if (!text) { el.classList.add("hidden"); return; } + el.textContent = text; + el.classList.remove("hidden"); + el.style.color = tone === "bad" ? "#cc3d4a" : ""; + } + + // ---- building the felt ---------------------------------------------------- + + // seat builds one player: their cards, their name and stack, and the spot their + // bet sits on. Bots go in the row along the top; you get your own, bigger. + function seat(s, i, mine) { + var wrap = document.createElement("div"); + wrap.className = "pete-seat"; + wrap.dataset.seat = i; + wrap.dataset.state = s.state; + + var cards = document.createElement("div"); + cards.className = "pete-seat-cards"; + + var plate = document.createElement("div"); + plate.className = "pete-seat-plate"; + var name = document.createElement("span"); + name.className = "pete-seat-name"; + name.textContent = s.name; + var stack = document.createElement("span"); + stack.className = "pete-seat-stack"; + stack.textContent = money(s.stack); + plate.appendChild(name); + plate.appendChild(stack); + + // The button, the blinds. It hangs off the name plate rather than the seat, + // because the seat's corner is a different place for you than for a bot — your + // bet spot is above your cards and theirs is below — and a badge that floats + // over an empty betting circle reads as a bug. + if (s.pos) { + var pos = document.createElement("span"); + pos.className = "pete-seat-pos"; + pos.dataset.pos = s.pos; + pos.textContent = s.pos; + plate.appendChild(pos); + } + + var spotEl = document.createElement("div"); + spotEl.className = "pete-spot pete-seat-spot"; + var pile = document.createElement("div"); + pile.className = "pete-stack"; + var total = document.createElement("span"); + // The shared class, not one of our own: it hangs the number *below* the ring, + // which is what keeps the chips from landing on top of it. + total.className = "pete-spot-total"; + spotEl.appendChild(pile); + spotEl.appendChild(total); + + // Your bet sits between you and the board, so it goes above your cards; a + // bot's sits between them and the board, so it goes below theirs. + if (mine) { + wrap.appendChild(spotEl); + wrap.appendChild(cards); + wrap.appendChild(plate); + } else { + wrap.appendChild(cards); + wrap.appendChild(plate); + wrap.appendChild(spotEl); + } + + var api = FX.spot({ spot: spotEl, stack: pile, total: total }); + api.render(s.bet); + + paintCards(cards, s, mine); + return { root: wrap, cards: cards, plate: plate, stackEl: stack, spot: api }; + } + + // paintCards puts the two cards in front of a seat. A bot's are backs, unless + // the server has actually sent us faces — which it only ever does at a showdown. + function paintCards(el, s, mine) { + el.innerHTML = ""; + if (s.state === "out") return; + var faces = s.cards || []; + var n = faces.length ? faces.length : (s.state === "folded" ? 0 : 2); + for (var i = 0; i < n; i++) { + el.appendChild(Cards.el(faces[i] || null, { deal: false, tilt: !mine })); + } + } + + function render(v) { + view = v; + + // The seats along the top, and you underneath. + seatsEl.innerHTML = ""; + youEl.innerHTML = ""; + seatEls = []; + shown = []; + v.seats.forEach(function (s, i) { + var mine = i === 0; + var built = seat(s, i, mine); + seatEls[i] = built; + shown[i] = s.stack; + (mine ? youEl : seatsEl).appendChild(built.root); + built.root.dataset.turn = (v.phase === "betting" && v.to_act === i) ? "1" : "0"; + }); + + // The board. + boardEl.innerHTML = ""; + (v.board || []).forEach(function (c) { + boardEl.appendChild(Cards.el(c, { deal: false, tilt: false })); + }); + + // The pot. Its chips live in a box of their own — see .pete-poker-pot-pile — + // so the number underneath stays readable. + pot = FX.spot({ spot: potStack.parentNode, stack: potStack, total: null }); + pot.render(v.pot); + potTotal.textContent = money(v.pot); + blindsEl.textContent = v.tier.sb + "/" + v.tier.bb; + tableName.textContent = v.tier.name; + if (v.side && v.side.length > 1) { + sideEl.textContent = v.side.map(function (n, i) { + return (i === 0 ? "main " : "side ") + money(n); + }).join(" · "); + sideEl.classList.remove("hidden"); + } else { + sideEl.classList.add("hidden"); + } + + stackEl.textContent = money(v.stack); + boughtEl.textContent = money(v.bought_in); + rakeEl.textContent = money(v.rake); + + panels(); + } + + // panels decides which of the three bars is showing: the one that acts, the one + // between hands, or the one you sit down from. + function panels() { + // A session you have got up from is not a live one: the felt still shows the + // last hand, but the table you sit down at is the one that's open to you. + var live = !!view && view.phase !== "done"; + sitting.classList.toggle("hidden", live); + acting.classList.toggle("hidden", !live || view.phase !== "betting" || view.to_act !== 0); + between.classList.toggle("hidden", !live || view.phase !== "handover"); + if (!live) return; + + if (view.phase === "betting" && view.to_act === 0) { + checkBtn.classList.toggle("hidden", !view.can_check); + callBtn.classList.toggle("hidden", view.can_check); + callAmt.textContent = money(view.owed); + + raiseRow.classList.toggle("hidden", !view.can_raise); + if (view.can_raise) { + slider.min = view.min_raise_to; + slider.max = view.max_raise_to; + slider.step = Math.max(1, view.tier.bb / 2); + slider.value = Math.min(view.max_raise_to, view.min_raise_to); + // "Bet" when nobody has, "Raise to" when somebody has. It is the same + // move and the same button, but calling a bet a raise is how you tell a + // player who has never played that this table is confused. + raiseVerb.textContent = view.owed > 0 ? "Raise to" : "Bet"; + showRaise(); + } + } + if (view.phase === "handover") { + topupBtn.disabled = !view.max_topup; + topupBtn.textContent = view.max_topup ? "Top up " + money(view.max_topup) : "Top up"; + } + } + + function showRaise() { + var to = Number(slider.value); + raiseTo.textContent = money(to); + raiseLbl.textContent = money(to); + } + + // ---- playing the script --------------------------------------------------- + + var STREETS = { flop: 1, turn: 1, river: 1 }; + + function wait(ms) { + return new Promise(function (r) { setTimeout(r, FX.reduced ? 0 : ms); }); + } + + // play walks the events the server sent, one beat at a time, and only then + // re-renders the table it ended on. Everything the player is meant to see + // happening happens here; render() is the state it settles into. + function play(events, final) { + var chain = Promise.resolve(); + if (!events || !events.length) { render(final); return chain; } + + events.forEach(function (e) { + chain = chain.then(function () { return beat(e, final); }); + }); + return chain.then(function () { + render(final); + verdict(events, final); + }); + } + + function beat(e, final) { + var s = seatEls[e.seat]; + + switch (e.kind) { + case "hand": + // A new deal: clear the felt before anything lands on it. + boardEl.innerHTML = ""; + verdictEl.classList.add("hidden"); + seatEls.forEach(function (x) { x.spot.render(0); x.cards.innerHTML = ""; }); + pot.render(0); + potTotal.textContent = "0"; + sideEl.classList.add("hidden"); + return wait(140); + + case "rebuy": + if (s) { shown[e.seat] = e.total; FX.count(s.stackEl, e.total); } + return wait(220); + + case "blind": + if (!s) return; + moveStack(e.seat, -e.amount); + return s.spot.pour(s.plate, e.amount); + + case "hole": + // Two cards to everybody, round the table, as they are actually dealt. + return dealHoles(final); + + case "action": + return action(e, final); + + case "flop": + case "turn": + case "river": + return street(e); + + case "pot": + // The bets in front of the seats have been swept in. Nothing else in the + // script says so, and the pot is about to be paid out of. + return collect(e.amount); + + case "uncalled": + if (!s) return; + return s.spot.sweep(s.plate).then(function () { moveStack(e.seat, e.amount); }); + + case "show": + if (!s) return; + paintCards(s.cards, { state: "active", cards: e.cards }, e.seat === 0); + flash(s.root); + return wait(420); + + case "rake": + // The house takes its cut out of the pot, in front of you, so it is a + // thing that visibly happens rather than a number that quietly differs. + return pot.sweep(houseEl, e.amount).then(function () { + potTotal.textContent = money(pot.amount); + return wait(160); + }); + + case "win": + if (!s) return; + return pot.sweep(s.plate, e.amount, { gap: 55 }).then(function () { + potTotal.textContent = money(pot.amount); + moveStack(e.seat, e.amount); + if (e.seat === 0 && e.amount > 0) FX.burst(s.plate, { count: 18 }); + return wait(260); + }); + + case "end": + return wait(280); + } + return Promise.resolve(); + } + + // dealHoles puts two cards in front of everyone still in the hand. Yours land + // face up; theirs land as backs, because that is all that came over the wire. + function dealHoles(final) { + var chain = Promise.resolve(); + for (var round = 0; round < 2; round++) { + (function (round) { + chain = chain.then(function () { + var beats = []; + final.seats.forEach(function (s, i) { + if (s.state === "out") return; + var built = seatEls[i]; + if (!built) return; + var face = (i === 0 && s.cards) ? s.cards[round] : null; + var card = Cards.el(face, { deal: true, tilt: i !== 0 }); + built.cards.appendChild(card); + beats.push(wait(70 * i)); + }); + return Promise.all(beats).then(function () { return wait(180); }); + }); + })(round); + } + return chain; + } + + // action animates one seat doing one thing. + function action(e, final) { + var s = seatEls[e.seat]; + if (!s) return Promise.resolve(); + + if (e.text === "fold") { + s.root.dataset.state = "folded"; + s.cards.dataset.mucked = "1"; + return wait(320); + } + if (e.text === "check") { + flash(s.root); + return wait(320); + } + // call, raise, allin: chips leave their stack for their spot. + if (!e.amount) return wait(200); + moveStack(e.seat, -e.amount); + return s.spot.pour(s.plate, e.amount).then(function () { + if (e.text === "allin") flash(s.root); + return wait(180); + }); + } + + // collect sweeps every seat's bet into the middle. The total is worked out up + // front rather than accumulated as the chips land, because the sweeps run at the + // same time and would otherwise race each other into the pot's counter. + function collect(total) { + var moved = 0; + var sweeps = seatEls.map(function (s) { + if (!s || s.spot.amount <= 0) return Promise.resolve(); + moved += s.spot.amount; + return s.spot.sweep(potStack.parentNode, s.spot.amount, { gap: 30 }); + }); + if (!moved) { + if (total != null) { pot.render(total); potTotal.textContent = money(total); } + return Promise.resolve(); + } + var to = total != null ? total : pot.amount + moved; + return Promise.all(sweeps).then(function () { + pot.render(to); + potTotal.textContent = money(to); + return wait(200); + }); + } + + // street sweeps the bets in, then turns the cards. + function street(e) { + return collect(e.amount).then(function () { + // The board turns one card at a time, even the flop. Three cards appearing + // at once is a screenshot; three cards appearing in a row is a flop. + var chain = Promise.resolve(); + (e.cards || []).forEach(function (c) { + chain = chain.then(function () { + boardEl.appendChild(Cards.el(c, { deal: true, tilt: false })); + return wait(240); + }); + }); + return chain; + }).then(function () { + return wait(200); + }); + } + + // moveStack keeps a seat's stack label honest *while the chips are moving*. The + // authoritative number is always the server's, and render() puts it back at the + // end of the script — but a stack that only updates then would sit unchanged + // through the whole hand and then jump, which reads as the table correcting + // itself rather than as chips being paid. + function moveStack(i, delta) { + var s = seatEls[i]; + if (!s) return; + shown[i] = Math.max(0, (shown[i] || 0) + delta); + FX.count(s.stackEl, shown[i]); + } + + function flash(el) { + el.animate( + [{ transform: "scale(1)" }, { transform: "scale(1.06)" }, { transform: "scale(1)" }], + { duration: 320, easing: "ease-out" } + ); + } + + // verdict says what the hand did to you, once, in words. + function verdict(events, final) { + var won = 0, showed = false, busted = false; + events.forEach(function (e) { + if (e.kind === "win" && e.seat === 0) won += e.amount; + if (e.kind === "show") showed = true; + if (e.kind === "bust") busted = true; + }); + if (busted) { + show("You're out of chips. Sit down again when you're ready.", "lose"); + return; + } + if (!events.some(function (e) { return e.kind === "end"; })) return; + + var me = final.seats[0]; + if (won > 0) { + show(showed + ? "You win " + money(won) + " with " + article(handName(events)) + "." + : "They folded. You take " + money(won) + ".", "win"); + } else if (me.state === "folded") { + show("Folded.", "lose"); + } else { + show("No good this time.", "lose"); + } + + function show(text, tone) { + verdictEl.textContent = text; + verdictEl.dataset.tone = tone; + verdictEl.classList.remove("hidden"); + } + } + + function handName(events) { + var mine = events.filter(function (e) { return e.kind === "show" && e.seat === 0; })[0]; + return mine && mine.text ? mine.text : "the best hand"; + } + + // "You win 975 with straight" is not a sentence. Most hands take an article and + // the counted ones don't. + function article(desc) { + if (/^(two pair|three of a kind|four of a kind|high card|the best hand)$/.test(desc)) return desc; + return "a " + desc; + } + + // ---- talking to the table -------------------------------------------------- + + function send(body, msgEl) { + if (busy) return Promise.resolve(); + busy = true; + say(msgEl, ""); + // Whatever the last hand said about itself stops being true the moment you do + // something. Only the "hand" beat used to clear this, so a verdict could linger + // over a hand it had nothing to do with. + verdictEl.classList.add("hidden"); + return window.PeteGames + .post("/api/games/holdem/move", body) + .then(function (v) { + window.PeteGames.apply(v); + return play(v.holdem_events, v.holdem || null).then(function () { + if (!v.holdem) { render0(); return; } + if (v.holdem.phase === "done") { + var got = v.holdem.payout, put = v.holdem.bought_in; + say(tableMsg, got > put + ? "You got up " + money(got - put) + " ahead. " + money(got) + " back on your stack." + : got === 0 + ? "Cleaned out. Better luck at the next table." + : "You got up " + money(put - got) + " down. " + money(got) + " back on your stack."); + setTimeout(render0, 2600); + } + }); + }) + .catch(function (err) { say(msgEl, err.message, "bad"); }) + .then(function () { busy = false; }); + } + + // render0 is the table with nobody at it. + function render0() { + view = null; + seatsEl.innerHTML = ""; + youEl.innerHTML = ""; + boardEl.innerHTML = ""; + potStack.innerHTML = ""; + potTotal.textContent = "0"; + sideEl.classList.add("hidden"); + panels(); + syncSit(); + } + + if (foldBtn) foldBtn.addEventListener("click", function () { send({ move: "fold" }, gameMsg); }); + if (checkBtn) checkBtn.addEventListener("click", function () { send({ move: "check" }, gameMsg); }); + if (callBtn) callBtn.addEventListener("click", function () { send({ move: "call" }, gameMsg); }); + if (raiseBtn) raiseBtn.addEventListener("click", function () { + var to = Number(slider.value); + // Sliding all the way to the top is shoving, and the table would rather be + // told that than be told to raise to exactly everything you have. + send(to >= view.max_raise_to ? { move: "allin" } : { move: "raise", to: to }, gameMsg); + }); + + if (slider) slider.addEventListener("input", showRaise); + root.querySelectorAll("[data-raise-preset]").forEach(function (b) { + b.addEventListener("click", function () { + var which = b.dataset.raisePreset; + var to; + if (which === "max") to = view.max_raise_to; + else to = view.owed + view.pot * Number(which) + (view.pot > 0 ? view.owed : 0); + to = Math.max(view.min_raise_to, Math.min(view.max_raise_to, Math.round(to))); + slider.value = to; + showRaise(); + }); + }); + + if (dealBtn) dealBtn.addEventListener("click", function () { send({ move: "deal" }, betweenMsg); }); + if (leaveBtn) leaveBtn.addEventListener("click", function () { send({ move: "leave" }, betweenMsg); }); + if (topupBtn) topupBtn.addEventListener("click", function () { + send({ move: "topup", amount: view.max_topup }, betweenMsg); + }); + + // ---- sitting down ---------------------------------------------------------- + + function pickTier(btn) { + tier = btn; + tierBtns.forEach(function (b) { b.dataset.on = b === btn ? "1" : "0"; }); + var min = Number(btn.dataset.min), max = Number(btn.dataset.max), bb = Number(btn.dataset.bb); + buySlider.min = min; + buySlider.max = max; + buySlider.step = bb; + buySlider.value = Math.min(max, Math.max(min, 50 * bb)); // fifty big blinds, the default anybody sensible picks + syncSit(); + } + + function pickBots(btn) { + bots = Number(btn.dataset.botCount); + botBtns.forEach(function (b) { b.dataset.on = b === btn ? "1" : "0"; }); + syncSit(); + } + + function syncSit() { + if (!tier) return; + buyIn = Number(buySlider.value); + var bb = Number(tier.dataset.bb); + buyLabel.textContent = money(buyIn); + buyNote.textContent = Math.round(buyIn / bb) + " big blinds. Short is fewer decisions; deep is more of them."; + botsNote.textContent = bots === 1 + ? "Heads up. The bots know this game best when there's only one of them." + : bots + " bots. More opponents, and a hand has to be better to be worth playing."; + + var chips = window.PeteGames.view(); + sitBtn.disabled = !chips || chips.chips < buyIn; + say(tableMsg, sitBtn.disabled ? "You need " + money(buyIn) + " chips to sit at this table." : ""); + } + + tierBtns.forEach(function (b) { b.addEventListener("click", function () { pickTier(b); }); }); + botBtns.forEach(function (b) { b.addEventListener("click", function () { pickBots(b); }); }); + if (buySlider) buySlider.addEventListener("input", syncSit); + + if (sitBtn) sitBtn.addEventListener("click", function () { + if (busy || !tier) return; + busy = true; + say(tableMsg, ""); + window.PeteGames + .post("/api/games/holdem/sit", { + tier: tier.dataset.tier, + bots: bots, + buyin: Number(buySlider.value), + }) + .then(function (v) { + window.PeteGames.apply(v); + render(v.holdem); + // A table with nobody dealt in yet is a table waiting for you to say go. + say(betweenMsg, "You're in. Deal when you're ready."); + }) + .catch(function (err) { say(tableMsg, err.message, "bad"); }) + .then(function () { busy = false; }); + }); + + // ---- boot ------------------------------------------------------------------ + + window.PeteGames.onUpdate(function () { if (!view) syncSit(); }); + + pickTier(tierBtns[0]); + pickBots(botBtns[1]); + + window.PeteGames.refresh().then(function (v) { + if (v && v.holdem) render(v.holdem); + else render0(); + }); +})(); diff --git a/internal/web/templates/games.html b/internal/web/templates/games.html index 1607fe1..85f1a10 100644 --- a/internal/web/templates/games.html +++ b/internal/web/templates/games.html @@ -106,6 +106,22 @@

+ +
+ ♠️ +
+

Texas Hold'em

+

Buy in. Beat them. Get up.

+
+ Open +
+

+ A real cash game against bots that were trained on it, not scripted. No multiple, no + 3:2 — you leave with whatever is in front of you, less the rake on the pots you win. +

+
+ {{range .Soon}}
diff --git a/internal/web/templates/holdem.html b/internal/web/templates/holdem.html new file mode 100644 index 0000000..abfa84d --- /dev/null +++ b/internal/web/templates/holdem.html @@ -0,0 +1,212 @@ +{{define "title"}}Hold'em · {{.Room.Name}}{{end}} + +{{define "main"}} +
+ +
+
+ + + Back to the casino + +

Texas Hold'em

+
+

Buy in, play as long as you like, leave with what's in front of you

+
+ + {{template "_chipbar" .}} + + +
+
+ +
+
+ +
+
+ +
+ +
+
+
+ Pot + 0 + +
+ +
+ +
+
+ +
+
+ + + +
+
+ + + + + + + + +
+ +
What are you playing for?
+
+ {{range .Stakes}} + + {{end}} +
+ +
+
+
How many of them?
+
+ + + + + +
+

+
+ +
+
Buying in for
+
+ + 0 +
+

+
+
+ + + +

+ The bots are trained, not scripted. The house takes {{.RakePct}}% of a pot that sees a flop, capped at three big blinds, and nothing at all from a hand that doesn't. +

+ +
+ +
+{{end}} + +{{define "scripts"}} + + + + +{{end}} diff --git a/pete_games_plan.md b/pete_games_plan.md index 84ad661..bea1007 100644 --- a/pete_games_plan.md +++ b/pete_games_plan.md @@ -399,21 +399,106 @@ A multi-session build. This section is the handover; read it before anything els remembering as a rule: **size a card by its vars, never by the box you put it in.** + +- **Hold'em, and it is a cash game.** *(2026-07-14. Built, tested, and driven in a + browser. The bots had to be retrained from scratch — see below, it is the whole + story of this phase.)* + - **You buy in, you play, you leave with what's in front of you.** This is the + only table in the casino that is a *session* rather than a game. Everywhere else + stakes once and pays a multiple; poker isn't that shape. So the live row lives + across hands, and chips cross the border exactly twice: once when you sit down + and once when you get up. In between every pot is inside the engine and storage + sees none of it. Three stakes (1/2, 5/10, 25/50), buy in for 20–100 big blinds, + top up between hands, bust and the session simply ends. + - **The rake is a real cardroom's rake**: five percent of a pot that *sees a flop*, + capped at three big blinds. No flop, no drop — so folding your blind round after + round costs you the blinds and no fee. Still winnings-only in the sense that + matters: you pay it out of a pot you win, never out of a bet you lose. + - **The bots move inside ApplyMove**, as UNO's do, which is what keeps poker off a + socket. One request plays your action, every bot action behind it, and whatever + streets that finishes — so shoving all-in returns the flop, turn, river, showdown + and payout in a single response, as a script the felt plays back. + - **The CFR policy was a lie, twice over, and this is the part worth reading.** + §5 called it "the single highest-value asset in either repository". It was not + being used *at all*, and could not have been: + 1. **The key never matched.** The trainer packed a single "am I last to act" bit + and wrote its keys as `IP`/`OOP`. The runtime looked them up with the labels a + player would recognise — `BTN`, `SB`, `BB`, `UTG`. Not one key ever hit, for + the entire life of the game in gogobee. Nothing looked broken: a policy miss is + not an error, it is a silent fall back to a pot-odds heuristic. The bots played; + they just never once read the five million iterations sitting in policy.gob. + 2. **And it was the wrong game anyway.** `TrainCFR` opens with `stack0, stack1 = 20, + 20` at 1/2 blinds — a **ten big blind** push-fold stack. 82% of its nodes are in + the "stack smaller than the pot" bucket. A 20–100BB cash game lands almost + nowhere near it. Worse, the tree it trained on was not hold'em: a call always + ended the street (so no big-blind option and no check-check), turn order was + history-length parity, and the payoff was ±half the pot regardless of who had + put what in. + - **So the trainer was rewritten to play the real engine.** `internal/games/holdem/ + train.go` + `cmd/holdem-train`. External-sampling MCCFR, every move applied through + `Step` — the same reducer the felt calls — so the blinds, the min-raise, street + completion, side pots and the money are the ones a player actually meets. The + stack depth is drawn fresh every hand across the whole 20–100BB range, because + poker at twenty big blinds and poker at a hundred are different games and a bot + that only knows one folds into the other. + - **The key is built by one function, `State.spot`, called by both the trainer and + the table.** That is the entire fix for bug (1), and it is structural: they cannot + drift apart because there is only one of them. And because a miss is *still* silent, + `Hits`/`Misses` are counted at the point a bot looks itself up, and + `TestTheBotsAreActuallyTrained` fails if the heads-up hit rate drops under 60%. It + is 95%. Multiway degrades on purpose — the policy is heads-up, six-handed reuses it + as a documented approximation, and the rest falls through to pot odds. + - **Three money bugs, and the tests earned their keep.** `TestChipsAreConserved` + plays a hundred sessions of real hands and counts every chip after every move; it + caught an **uncalled bet that minted chips** (the rule skipped folded players when + working out what had been matched, so a river bet folded to came back *whole*, + including the part called on the flop). A Go var-init ordering trap made `deck52` + build from an empty conversion table, so every card was identical, every showdown + tied and **every bot believed it held exactly 50% equity** — package-level vars are + built before `init()` runs. And the browser found the third: **the rake was + silently zero**, because the tiers declared `RakePct: 5` meaning percent while + `New()` overwrites it with blackjack's `0.05` meaning a fraction, and the integer + arithmetic floored 5% of a hundredth of the pot to nothing. Every rake test built + its own `State` by hand and so never saw the number the table runs on. There is now + one that does. + - **Driven in a browser, 2026-07-14, and it holds up.** Sitting down took 500 off a + 5,000 stack and put it on the table; a hand played out; getting up put 1,004 back + (money conserved to the chip). The raise slider, the pot/half-pot/max presets, a + shove that runs the whole board out in one response, and a reload mid-hand that + brings back the hand, the board, the pot, the street and the stack — all clean. + **Side pots and split pots balance**: a three-way all-in paid 758 + 757 + 920 out + of a 2,495 pot with 60 raked, and `paid + rake == pot` on every multi-winner hand + sampled. A bot's cards never cross the wire until a showdown, and a folded seat's + never do at all. Six-handed at 390px: no sideways overflow, nothing colliding. + Console silent. + - Two felt bugs only the browser could show. **`.pete-stack` is `position: absolute; + inset: 0`** — so the pot's chips, sharing a box with the number under them, painted + straight over it: the pot showed a chip and no total. The pile needs a box of its + own. And **a `.pete-spot` is 7rem across** because blackjack has exactly one of + them; six of those is most of a felt, so a seat's bet spot is less than half the + size, with chips scaled to match. The bet total hangs *below* the ring + (`.pete-spot-total`), which is the existing rule for exactly this reason. + ### Next, in order -1. **Phase 4 — hold'em**, as below. It is the last game on the list, and the first - one where a hand has other people in it: the spot is a singleton and the chip - animations are tuned for one seat (see "still open" below). -2. **Deploy.** Hangman, solitaire, trivia and UNO are all played, and all four are - still sitting on main un-deployed — the live casino is blackjack and nothing else. - The server runs `StartTriviaBank`, so trivia's bank fills itself once the binary - is out there, but the first player to try a ladder in the first minute after a - deploy gets the 503. +1. **Deploy.** Hangman, solitaire, trivia, UNO and hold'em are all played and all + five are sitting on main un-deployed — the live casino is blackjack and nothing + else. The server runs `StartTriviaBank`, so trivia's bank fills itself once the + binary is out there, but the first player to try a ladder in the first minute + after a deploy gets the 503. +2. **No Mercy UNO.** The plan's header line has always promised "UNO (normal + + no-mercy)" and only normal was ever built. gogobee has the rules + (`uno_nomercy.go`: a 168-card deck, draw-stacking, elimination at 25 cards, + sudden-death point scoring). It is a *rules dial orthogonal to the table-size + tier*, so the lobby becomes 3 sizes × 2 rule sets — and **the multiples have to be + re-measured**, because the current 2.2×/2.9×/3.6× are priced off a measured + go-out-first rate (43/32/27%) that draw-stacking and mercy elimination change + completely. Shipping No Mercy on the regular tier's prices would misprice it. -Still open on the table itself, none of it blocking: **split** isn't implemented (the -engine has no move for it), the felt is roomy at desktop widths with only one seat on -it, and the chip animations are tuned for one player — a second seat would need the -spot to be per-seat rather than the singleton it is now. +Still open on hold'em, none of it blocking: the policy is **heads-up**, so a +six-handed table is an approximation of it (the hit rate falls from 95% to about 17% +at six seats, and the rest is pot odds) — a multiway policy would want its own +training run with more than two seats in the tree. Blackjack still has no **split**. ### How the browser half fits together @@ -592,20 +677,31 @@ into `pete/internal/games/`, let them drift, no shared module. | Game | Copy | Rewrite | Notes | |---|---|---|---| -| **Hold'em** | ~2,700 LOC | the shell | The crown jewel. Take it all. | +| **Hold'em** | ~1,400 LOC | the shell + **the whole CFR trainer** | See the warning below. | | **UNO** | ~1,400 LOC | the turn engine | Great primitives, unshippable engine. | | **Hangman** | ~250 LOC | loading/persistence | Clean rune-safe state machine. | | **Blackjack** | ~95 LOC | everything else | 95 lines is the entire core. | | **Trivia** | ~80 LOC | everything else | **No question bank exists.** | -### Hold'em — take almost all of it +### Hold'em — take the poker, not the brain + +> **This section was wrong, and it cost most of Phase 4. Read §0's hold'em entry before +> you believe any of it.** `data/policy.gob` is **not** "the single highest-value asset in +> either repo". It is a 10-big-blind push-fold policy, trained against a model of poker +> that is not poker (a call always ends the street), and *it was never once read* — the +> trainer wrote its keys under `IP`/`OOP` and the runtime looked them up under +> `BTN`/`SB`/`BB`, so every lookup in the history of the game missed and fell through to a +> pot-odds heuristic. Nothing about that is visible from the outside: a policy miss is not +> an error. **Retrain against your own engine.** Pete now does — `internal/games/holdem/ +> train.go` plays every move through the real reducer, and both the trainer and the table +> build the info-set key with the same function so they cannot drift apart again. Already mautrix-free, verified by import check: -- `holdem_cfr.go` (1,285) — full CFR trainer + NPC policy runtime, info-set packing into a - `uint64`, regret pruning, board-texture/SPR/equity bucketing. Plus the trained - `data/policy.gob` (3.4 MB) and `cmd/holdem-train`, `cmd/holdem-seed`. **This is the single - highest-value asset in either repo.** +- `holdem_cfr.go` (1,285) — CFR trainer + NPC policy runtime, info-set packing into a + `uint64`, regret pruning, board-texture/SPR/equity bucketing. The *bucketing* is worth + taking (equity, SPR, board texture). The trainer, the tree and the trained policy are + not: see the warning above. - `holdem_equity.go` + `holdem_equity_range.go` (548) — Monte-Carlo equity, equity-vs-range, draw/out detection. 100% pure, well tested. - `holdem_betting.go` (383) — side pots, min-raise, all-in, street completion. The fiddly