diff --git a/pete_games_plan.md b/pete_games_plan.md new file mode 100644 index 0000000..9adf95f --- /dev/null +++ b/pete_games_plan.md @@ -0,0 +1,418 @@ +# Pete Games — games.parodia.dev + +A web casino/arcade on Pete, authenticated by Authentik, playing for gogobee euros. +Blackjack, Texas Hold'em, UNO (normal + no-mercy), Hangman, Trivia. + +Companion to `gogobee_mischief_plan.md`, which already established the web↔game seam. +This plan reuses that seam wholesale and does not invent a second one. + +--- + +## 0. Progress — last updated 2026-07-13 + +A multi-session build. This section is the handover; read it before anything else. + +### Decisions taken (these close §9's open questions) + +- **Chips are 1:1 with euros.** No second denomination. +- **Session buy-in cap: €10,000**, enforced against chips held *plus* buy-ins still + in flight, so it can't be cleared by firing several requests at once. +- **A house rake**, 5% in blackjack's `DefaultRules`, taken from *winnings only* — + never the stake. A push returns the bet untouched; a loss is never charged a fee. +- **The site must look like Pete.** Same cute, bubbly interface: `layout.html`, + Fredoka/Nunito, the CSS vars, `rounded-3xl` cards, `shadow-pete`. Not a + separate-looking SPA bolted onto the same box. +- **Dealing is animated.** Cards visibly dealt and flipped, chips that move. This is + a requirement, not polish to drop when the clock runs out. + +### Done + +- **Phase 0 — euro idempotency (gogobee).** `euro_transactions.external_id` + a + partial unique index, and `CreditIdem`/`DebitIdem` in `internal/plugin/euro.go`: + balance mutation and transaction log in one tx, keyed by the escrow GUID. A replay + reports success without moving money again; a rejection writes nothing, so the same + GUID stays retryable once the player is good for it. Six tests, including eight + goroutines racing one GUID. *(gogobee `ab2bcf0`)* +- **`pete/internal/games/cards`** — the shared deck gogobee never had. RNG is + threaded, never the package global, so a hand is reproducible from its seed. + *(pete `8310b30`)* +- **`pete/internal/games/blackjack`** — pure reducer, + `ApplyMove(state, move) (state, []Event, error)`, where an error means the move was + illegal and nothing else. State is a plain value: it serializes, so a hand survives + a redeploy, and it replays. Six decks, 3:2, dealer hits soft 17, plus the rake. + *(pete `8310b30`)* +- **`pete/internal/storage/games.go`** — the euro/chip border. `game_chips`, + `game_escrow`, `game_hands`. Chips appear only once gogobee confirms it took the + euros; chips are destroyed the moment a cash-out opens (so they can't be bet while + their euros are in flight) and come back if the credit fails. Table cap, 30-minute + reaper, per-hand audit log with seeds. 17 tests. *(pete `f9a98f7`)* + +### Next, in order + +1. **Wire protocol.** Pete: `GET /api/games/escrow/pending`, `POST …/claim`, + `POST …/settled`, all bearer-authed like the adventure seam. gogobee: the poll + loop in `internal/peteclient` — its first GET path — calling `DebitIdem`/`CreditIdem` + and pushing verdicts back through the existing `pete_emit_queue`. The storage layer + underneath this is done and tested; only the HTTP and the loop remain. +2. **Identity.** `PreferredUsername` into the OIDC claims struct and the signed cookie; + cookie `Domain: ".parodia.dev"` so a news session travels to games. Add the games + redirect URI to the `pete` app in Authentik. +3. **Frontend.** Host branching in the mux, lobby + blackjack table, animated dealing. +4. Then Phase 2 (trivia, hangman), 3 (UNO), 4 (hold'em) as below. + +### Notes for whoever picks this up + +- SQLite runs at `MaxOpenConns(1)` in *both* repos. Any `db.Get().Exec` inside an + open transaction deadlocks against itself. Do the pre-work before `Begin`. +- gogobee's blackjack taxes 5% of the *gross* payout into a community pot + (`communityTax`). Pete's rake takes 5% of the *profit*. Deliberately different, and + gentler; don't "fix" one to match the other without deciding which is right. + +--- + +## 1. The three constraints everything else follows from + +**gogobee owns the euros.** The ledger is `euro_balances` / `euro_transactions` +(gogobee `internal/db/db.go:1316,1324`), tied to the wider economy — adventure, shop, +lottery, mischief. Pete does not get a second wallet. Pete never writes a balance. + +**gogobee has no inbound API and isn't getting one.** The only listening socket in the +whole repo is the Matrix appservice transaction endpoint (`internal/bot/appservice.go:255`). +Pete's own source says it plainly (`internal/web/roster.go:23`): + +> Direction of travel is gogobee → Pete ... Pete has no route back into the game box's +> network and we are not opening one. + +So gogobee stays the only initiator. It **polls** Pete for work and **pushes** results +back through the existing durable queue. Same as mischief (`gogobee_mischief_plan.md:191-197`). + +**One binary.** Games live in the Pete process. gogobee already runs ~50 plugins and six +games in one process with in-memory table state and it's fine. Caddy points +`games.parodia.dev` at the same port; the mux branches on Host. + +--- + +## 2. Identity — free, no link codes + +MAS imports the Authentik `preferred_username` as the Matrix localpart +(`gogobee_mischief_plan.md:176-186`). So an Authentik session on Pete maps to a Matrix +user deterministically: + +``` +OIDC preferred_username -> strings.ToLower(u) -> @:parodia.dev +``` + +Pete's `SessionUser` (`internal/web/auth.go`) carries `Sub`/`Name`/`Email` today. Add +`PreferredUsername` to the claims struct and the signed cookie payload. That is the whole +identity story. + +Note the existing precedent: `email_nag.go:52` already asserts "Authentik usernames == +Matrix localparts". + +--- + +## 3. Money — session escrow, not per-hand settlement + +### Why not settle each hand + +Mischief is fire-and-forget: place an order, gogobee claims it within 30s, nobody cares. +A blackjack hand cannot work that way. If every bet round-trips through a poll loop you +wait half a minute to be dealt, and again for the payout. + +### The model + +Borrow the semantics hold'em already uses — `euro.Debit(..., "holdem_buyin")` +(`holdem.go:319`), `euro.Credit(..., "holdem_cashout")` (`holdem.go:371`) — and apply it +to the whole web casino: + +1. **Buy in.** You convert euros to *chips* for a games session. One debit. Tolerates poll latency. +2. **Play.** Blackjack, UNO, hold'em, all at full speed against chips held in Pete's SQLite. + Zero economy calls in the hot path. +3. **Cash out.** Chips convert back to euros. One credit. Tolerates the same latency. + +Two economy touches per *session* instead of two per *hand*. Poll latency stops mattering. + +### The invariant + +> A euro is either in gogobee's `euro_balances` or in Pete's chip escrow. Never both. +> It moves between them only via a GUID-idempotent claim. + +Pete's balance display is advisory only, sourced from the roster push and up to 2 minutes +stale. The authoritative check is `euro.Debit` at claim time. This preserves +`gogobee_mischief_plan.md:198-202` — *"Pete never writes a balance, so no double-spend surface."* + +### The prerequisite: euro idempotency (BLOCKING) + +`euro_transactions` (`db.go:1324`) has **no external id and no unique constraint**. `Debit` +is an atomic conditional UPDATE, but calling it twice debits twice. That is safe today only +because every caller is a Matrix message that arrives once. A *retrying poll loop* breaks +that: a claim that succeeds but whose ack is lost on the wire gets retried, and the player +pays twice. + +**Before any of this ships:** + +```sql +ALTER TABLE euro_transactions ADD COLUMN external_id TEXT; +CREATE UNIQUE INDEX idx_euro_tx_external ON euro_transactions(external_id) + WHERE external_id IS NOT NULL; +``` + +plus `CreditIdem(userID, amount, reason, externalID)` / `DebitIdem(...)` in `euro.go` that +do the balance mutation and the transaction insert **in one tx**, and treat a unique-violation +on `external_id` as success-already-applied. Everything web-initiated goes through these. + +--- + +## 4. The wire protocol + +All new Pete endpoints are bearer-authed with the existing ingest token +(`internal/web/adventure.go:307` `bearerOK`). gogobee grows its first GET path in +`internal/peteclient/client.go` — the poll loop the mischief plan already calls for. + +### Pete serves (gogobee polls, ~3s interval) + +``` +GET /api/games/escrow/pending -> [{guid, matrix_user, kind: buyin|cashout, amount}] +POST /api/games/escrow/claim <- {guid} idempotent, marks claimed +``` + +### gogobee pushes (existing peteclient queue, guid-idempotent) + +``` +POST /api/games/escrow/settled -> {guid, ok: bool, reason?: "insufficient_funds", balance_after} +``` + +Reuse `pete_emit_queue` (`client.go:121-125`, `INSERT OR IGNORE` on `guid` PK) — it already +does durability, backoff and parking. Don't build a second queue. + +### State machine (Pete side, table `game_escrow`) + +``` +requested -> claimed -> funded (buyin ok; chips become spendable) + -> rejected (insufficient funds; nothing spendable) +requested -> claimed -> settled (cashout ok; chips destroyed, euros credited) +``` + +Poll interval 3s, not 30s: a player waiting to be dealt is watching a spinner. 3s of +"buying chips…" is acceptable; 30s is not. + +### The reaper + +Chips left in an abandoned session are euros in limbo. Auto-cash-out any session idle for +30 minutes. A crashed Pete must reconcile on boot: any `claimed` escrow with no `settled` +push gets re-polled by GUID. + +--- + +## 5. Code reuse — copy, don't share + +Separate modules, both mine, and the shells diverge (Matrix vs HTTP). Copy the pure cores +into `pete/internal/games/`, let them drift, no shared module. + +### Verdict per game + +| Game | Copy | Rewrite | Notes | +|---|---|---|---| +| **Hold'em** | ~2,700 LOC | the shell | The crown jewel. Take it all. | +| **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 + +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_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 + poker rules you do not want to rewrite. **Untested in gogobee — write tests as you port.** +- `holdem_game.go`, `holdem_eval.go`, `holdem_render.go`. + +Entanglements to break (mechanical): +1. `id.UserID` → `PlayerID string` (`holdem_betting.go:283,314`; `holdem_eval.go` winnings maps). +2. Delete `RoomID`/`DMRoomID` from `HoldemGame` — table identity belongs to the shell. +3. Hoist the four `*time.Timer` fields out of `HoldemGame` (`holdem_game.go:92-95`). +4. `LoadPolicy(path)` does `os.Open` → `LoadPolicyFrom(io.Reader)`, so the policy can be `embed.FS`'d. + +Hand evaluation is **not** homegrown — `holdem_eval.go:12` wraps `poker.Evaluate`. Just take +the `github.com/chehsunliu/poker` dependency. + +### UNO — lift the primitives, rewrite the engine + +Copy verbatim (already unit-tested in `uno_test.go`): +- `unoCard`/`unoColor`/`unoValue`, `canPlayOn`, `newUnoDeck`, draw/reshuffle (`uno.go:21-364`) +- The bot AI as free functions: `botPickCard`, `botPickNormal`, `botPickAggressive`, + `botPickColor` (`uno.go:1465-1585`) +- `uno_nomercy.go` is ~90% pure: scoring, stacking rules, no-mercy deck, second bot. + +**Rewrite the turn engine.** In gogobee the engine *is* the message sender — +`executeMultiTurn`, `applyAndAnnounce`, `handlePlayerPlay` mutate state and call +`p.SendReply(...)` mid-turn, and their `error` returns mean "send failed", not "illegal move". +There is no `ApplyMove(game, move) (Result, error)` seam anywhere. Disentangling that costs +more than rewriting it against the (good) primitives. One near-seam worth keeping: +`applyCardEffects` (`uno_multi.go:1459`) already returns a struct instead of sending. + +### Hangman — take the struct + +`hangmanGame` + `guessLetter`/`guessSolution`/`displayPhrase` + the `gallows [7]string` ASCII +art (`hangman.go:26-274`). Strip three fields (`participants`, `solvedBy`, `threadID`). Copy +`hangman_phrases.txt` (237 lines) and `embed` it instead of `os.Getenv("HANGMAN_PHRASE_FILE")`. +Drop the dreamclient translation path for v1. + +### Blackjack — retype it + +`handValue` (correct soft-ace demotion), `isBlackjack`, and their tests. That's it — 95 lines. +The rest is `bjTable` keyed by `id.RoomID` with timers embedded, and raw `db.Exec` SQL at +`blackjack.go:867`. + +### Trivia — the question bank does not exist + +`trivia.go:288` fetches from OpenTDB live, one question per round: + +```go +apiURL := "https://opentdb.com/api.php?amount=1" +``` + +Reuse the category map (`trivia.go:24-53`) and `calculateScore` (time-decay, `:536`). For the +web version, **pre-fetch and cache a bank locally** — a per-question HTTP call in a web game +loop is a latency and rate-limit problem gogobee never had to care about at Matrix pace. Route +outbound fetches through Pete's `internal/safehttp` (SSRF guard). + +Trivia has **no euro coupling today** (points only). Keep it that way in v1 — it's the one +game that can ship with zero escrow risk. + +### Two things that apply to every copied engine + +**Thread the RNG.** Every card game uses the `math/rand/v2` package global — +`blackjack.go:60`, `uno.go:186,277`, `holdem_game.go:102`, and throughout the CFR/Monte-Carlo +code. Nothing is seedable, which is why `TestBotPickCard_*` can only assert weak properties. +The adventure half of gogobee already does this right (`dnd_zone_combat.go:361` threads an +explicit `*rand.Rand` via `rand.NewPCG`). The card games never adopted it. **Threading +`rng *rand.Rand` through the deck constructors is mandatory, not optional** — ~20 call sites, +and it's the difference between a testable engine and one you can only smoke-test. It also +gives you a reproducible shuffle for dispute resolution. + +**Hoist the timers.** `bjTable.joinTimer/turnTimer/reminderTimers`, `unoGame.idleTimer/warningTimer`, +`HoldemGame.actionTimer/warningTimer/idleTimer/idleWarningTimer` — all live inside the game +structs today. Timers are a shell concern. Game state must be a plain value you can serialize, +which is also what makes restart-mid-hand survivable. + +### Build a `cards` package while you're at it + +There is **no shared cards package in gogobee** — blackjack has its own deck +(`blackjack.go:32-75`), UNO has its own (`uno.go:130-189`), hold'em uses the third-party lib. +Consolidate into `pete/internal/games/cards` during the port rather than importing the +duplication. + +--- + +## 6. Architecture in Pete + +``` +internal/games/ + cards/ shared deck primitives (new; consolidates gogobee's duplicates) + blackjack/ pure engine — ApplyMove(state, move) (state, events, error) + holdem/ pure engine + cfr/ (copied) + policy.gob (embedded) + uno/ pure engine (rewritten) over copied primitives + bots + hangman/ pure engine (copied) + phrases.txt (embedded) + trivia/ pure engine (new) + cached question bank + escrow/ chip ledger, the gogobee poll/push seam + table/ session, seating, turn clocks, reconnect — the shell +``` + +**Server-authoritative, always.** The browser sends intents and never sees the deck. Any +game with money attached cannot trust a client-reported result. This is why the engines have +to be Go on Pete's side rather than ported to JS. + +**Every engine is a pure reducer**: `ApplyMove(state, move) (newState, []Event, error)`. +Timers, sockets and persistence all live in `table/`. That's the seam gogobee never had, and +it's what buys testability, replay, and surviving a redeploy. + +### Transport + +- **Blackjack, Hangman, Trivia, UNO-solo** — request/response over `fetch`. No sockets. +- **Hold'em, UNO-multi** — WebSocket. Lobby, seating, presence, turn clocks, reconnect-mid-hand, + spectators. This is the bulk of the total effort, and it is the only genuinely new + infrastructure in the project. + +### Frontend + +Pete has **no SPA and no bundler** today — server-rendered `html/template` + `embed.FS`, plain +`