# 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`)* - **The wire protocol.** Pete serves `GET /api/games/escrow/pending`, `POST …/claim`, `POST …/settled` (`internal/web/games.go`), bearer-authed on the adventure ingest token. gogobee polls every 3s (`internal/plugin/pete_games.go`), claims a row, calls `DebitIdem`/`CreditIdem` against the escrow GUID, and pushes the verdict back through `pete_emit_queue` — which grew a `path` column so escrow verdicts ride the same durable queue as adventure facts rather than getting a second one. `peteclient.Flush` sends the verdict immediately instead of waiting out the 15s sender tick, because a player is watching a spinner. A row re-offered after a gogobee crash replays as a no-op: 13 tests across both repos, including a fake Pete that offers the same row three times and a player who is charged once. ### Next, in order 1. **Identity.** `PreferredUsername` into the OIDC claims struct and the signed cookie; cookie `Domain: ".parodia.dev"` so a news session travels to games. Add the games redirect URI to the `pete` app in Authentik. 2. **Frontend.** Host branching in the mux, lobby + blackjack table, animated dealing. Nothing yet *opens* an escrow row from a browser — `RequestBuyIn`/`RequestCashOut` have no HTTP surface, on purpose: they need a signed-in Matrix identity, which is step 1. 3. 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`. - **A buy-in can currently take a player into debt.** `DebitIdem` inherits `BLACKJACK_DEBT_LIMIT` (default −1000), so someone with an empty wallet can buy €1,000 of chips, win, and cash out while still €1,000 down. That is exactly what gogobee's Matrix blackjack already allows, so it is consistent rather than a bug — but a web casino runs far more hands, and this is the knob to turn if the economy starts leaking. A buy-in-specific floor of 0 is a two-line change. - 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 `