games: the plan learns what we decided and what we built

This commit is contained in:
prosolis
2026-07-13 22:49:26 -07:00
parent f9a98f72a6
commit a442cfccaa

418
pete_games_plan.md Normal file
View File

@@ -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) -> @<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
`<script defer>` tags, npm present only to run the Tailwind CLI. games.parodia.dev is the first
real client-side app in the repo.
Precedent says this is survivable: `weather-gl.js` is 1,028 lines of hand-written WebGL2 with
no build step. Do the same here — vanilla JS per game, no framework, no bundler, Tailwind for
layout. Revisit only if it actually hurts.
### Auth
Session cookie is host-only today — `auth.go:151` sets `Path` but no `Domain`, so a
`news.parodia.dev` session will not travel to `games.parodia.dev`. Set `Domain: ".parodia.dev"`.
Note this widens the cookie to every parodia.dev host including the landing site — a deliberate
loosening, fine here, but not a freebie. Add the `games.parodia.dev` redirect URI to the `pete`
app in Authentik.
Games require login. No anonymous play — there's money in it.
---
## 7. Ship order
**Phase 0 — euro idempotency (gogobee).** `external_id` column + unique index +
`CreditIdem`/`DebitIdem`. Blocking; nothing else is safe without it.
**Phase 1 — escrow + Blackjack.** The full money loop against the simplest possible game
(95 lines of logic). Buy in, play, cash out. This proves cross-subdomain auth, the identity
mapping, the poll loop, the escrow state machine, the reaper, and the frontend shape — all
against a game where the *game* cannot be what's broken.
**Phase 2 — Trivia + Hangman.** No escrow (trivia has no euro coupling; keep hangman's
collaborative credit out of v1). Pure frontend and content work. Cheap wins, and they make the
site feel like a place rather than a demo.
**Phase 3 — UNO.** Solo first (single-player vs bot, no sockets). Then multi, which is where
the WebSocket infrastructure gets built. Forgiving latency, simple turn model — the right place
to learn multiplayer.
**Phase 4 — Hold'em.** Last. It's the hardest engine (side pots, all-ins, split pots), the
biggest port, and the one where collusion is a real threat rather than a theoretical one. Do it
when the multiplayer plumbing has already survived contact with real players.
---
## 8. Risks
**Economy inflation.** A web casino runs orders of magnitude more hands per hour than
Matrix-paced games ever did. Whatever the house edge is, it now compounds far faster in both
directions. Before Phase 1 ships, decide: session buy-in caps, a daily net-win/loss ceiling, or
a rake. This is the risk most likely to be discovered too late.
**Restart mid-hand.** Game state is in memory, so a Pete redeploy kills live tables — the same
property gogobee has today, and it redeploys far less often than Pete does. Mitigate with
serializable state (which the pure-reducer design gives for free) plus a drain-before-restart,
not a second process.
**Collusion in hold'em.** Two browsers, one person, one table. Not solvable in v1; at minimum
log seat/IP/session overlap so it's *detectable* after the fact.
**The gogobee contract is cross-repo.** `roster_test.go` already guards it: an unknown
`event_type` is a 400 that makes gogobee's sender park the row. Add the same guard on the new
escrow endpoints, and keep the payload structs in step across both repos by hand — that's the
cost of copying instead of sharing, and it's the right trade here.
---
## 9. Open questions
- **Chips 1:1 with euros, or a separate denomination?** 1:1 is simpler and honest. A separate
denomination gives you a knob for the inflation problem.
- **Do web results feed the Matrix room?** Pete already has a priority poster
(`adventure.go:151`). "Reala just took a 12k pot" is a good bulletin, and this is nearly free.
- **NPC opponents at the web tables?** The CFR bot is right there and it's good. It also means
a table never sits empty, which matters a lot for a small community.