The tables were living in the news app's shell: Pete's face in the header and the footer, the channel nav, search, the reader, the weather canvas, the PWA. A casino is not a news page with a felt on it. So it gets its own layout. What carries over is the design language — the four palette vars, Fredoka/Nunito, the fat rounded cards, the dropped shadow. What doesn't is every control it has no use for. gamesPage stops embedding the news pageData, which is what keeps the furniture from drifting back one convenient field at a time. It keeps a clock, but tells a different joke with it: Casinopolis by day, Casino Night Zone from six, palette and felt and the sign over the door all changing together. The rule lives in roomAt() for the first paint and again in the browser, so a player abroad gets their own evening. And the cards are cards now — corner indices in both corners, the bottom one upside down as printed, pips on the three-by-seven grid every real deck has used for four hundred years, courts as a letter with the suit over each shoulder. Driven in a real browser, both rooms, dealt through to a payout.
485 lines
24 KiB
Markdown
485 lines
24 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-14
|
||
|
||
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 shares Pete's design, not Pete's shell.** *(Revised 2026-07-13 — this
|
||
replaces the earlier "the site must look like Pete", which meant `layout.html`
|
||
itself.)* The casino is its own place. It takes the design language — Fredoka/
|
||
Nunito, the four palette vars, `rounded-3xl`, `shadow-pete`, the bubbly weight of
|
||
everything — and takes none of the furniture: no Pete avatar, no channel nav, no
|
||
search, no reader, no settings, no weather canvas, no PWA. It has its own layout
|
||
(`games_layout.html`), its own header, its own footer, its own scripts. Still not
|
||
an SPA; still server-rendered `html/template`.
|
||
- **It has two names, on a clock.** Casinopolis by day, Casino Night Zone from six
|
||
in the evening — palette, felt and the sign over the door all change together.
|
||
This is the news app's phase system pointed at a joke: one `data-room` attribute,
|
||
two palette blocks, and a rule shared between `roomAt()` in Go (first paint) and
|
||
the same rule in JS (the player's own clock, so a player abroad gets their own
|
||
evening).
|
||
- **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.
|
||
|
||
- **Identity.** `preferred_username` now rides in the signed session, and
|
||
`SessionUser.MatrixUser(server)` maps it to `@user:parodia.dev`. The session cookie
|
||
takes an opt-in `web.auth.cookie_domain`, so a sign-in on news is a sign-in on games;
|
||
the OAuth round-trip cookie deliberately stays host-only, and the redirect_uri is
|
||
derived per-request so a login that starts on games comes back to games. A Host we
|
||
don't own is never echoed into a redirect. *(pete `cb84e1d`)*
|
||
- **Blackjack, playable end to end.** `game_live_hands` (the hand in progress,
|
||
engine state and all, so a redeploy mid-hand is survivable), the session-authed play
|
||
surface (`internal/web/games_play.go`), the lobby and table pages, and the dealing
|
||
animation. Driven in a real browser: chips staked before the deal, hole card withheld
|
||
from the payload until the reveal, payout settled back into the stack.
|
||
|
||
- **The casino moved out.** Its own layout (`games_layout.html`), parsed as its own
|
||
template set alongside the news one; `gamesPage` no longer embeds the news
|
||
`pageData`, which is what stops the old furniture drifting back one convenient
|
||
field at a time. Two rooms on a clock (above), the felt reupholstered from the
|
||
room's vars, and a house mark that is a honeycomb chip rather than a face.
|
||
- **The cards are cards.** Corner indices in both corners (the bottom one upside
|
||
down, as printed), pips laid out on the three-by-seven grid a real deck uses,
|
||
bottom-half pips inverted, courts as a letter with the suit over each shoulder,
|
||
and a screen-reader label that says "Queen of hearts" instead of "Q♥".
|
||
|
||
### Next, in order
|
||
|
||
1. **Finish making the table lively.** Card faces are done. Still thin: chips never
|
||
physically move, and nothing celebrates. Ideas already sketched: chips that fly to
|
||
a bet spot and back on a win, cards landing with weight, a dealer beat before
|
||
drawing out, a burst on a natural.
|
||
2. **Deploy.** Add the `games.parodia.dev` redirect URI to the `pete` app in Authentik,
|
||
point Caddy at the same port, set `[web.games]` + `web.auth.cookie_domain` on the
|
||
server. Nothing else is host-specific.
|
||
3. Then Phase 2 (trivia, hangman), 3 (UNO), 4 (hold'em) as below.
|
||
|
||
### How the browser half fits together
|
||
|
||
- `GET /games` (lobby), `GET /games/blackjack` (table) — signed-in only. On the games
|
||
host, the mux prefixes `/games` onto the path, so the lobby is that host's `/`. Shared
|
||
paths (`/api/`, `/auth/`, `/static/`) mean the same thing on every host and are left
|
||
alone.
|
||
- `GET /api/games/table`, `POST /api/games/{buyin,cashout}`,
|
||
`POST /api/games/blackjack/{deal,move}` — session-authed, JSON, all returning the same
|
||
`tableView` so the money and the felt can never disagree.
|
||
- **The browser never sees the shoe.** The dealer's hole card is *absent* from the
|
||
payload — not flagged hidden — until the reveal, and the deck lives only in
|
||
`game_live_hands`. The response carries the engine's events (one per card off the
|
||
shoe), which is what the table plays back as an animation.
|
||
- Money order-of-operations: stake leaves the stack *before* the hand is dealt, in the
|
||
same statement that checks it's there; the hand is *seated* (a plain INSERT on the
|
||
primary key) before it can settle, which is what makes a double-clicked Deal a 409 with
|
||
the stake refunded rather than a silently overwritten hand.
|
||
|
||
### 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) -> @<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.
|