419 lines
20 KiB
Markdown
419 lines
20 KiB
Markdown
# 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.
|