539 lines
28 KiB
Markdown
539 lines
28 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♥".
|
||
|
||
- **The money moves.** The felt grew the two things it was missing: a **bet spot**
|
||
in front of you and the **house's rack** beside the shoe, so every chip on the
|
||
table is always travelling between one of those and the other. A bet is *built*
|
||
by throwing chips onto the spot (the chip you clicked is the chip that flies);
|
||
the stake sits there through the hand; the house pays out of its rack into the
|
||
spot; the whole pile is then swept back to your pile. A loss goes to the rack and
|
||
doesn't come back. `casino-fx.js` is the shared engine — `fly`/`flyMany` (WAAPI,
|
||
on an arc, out of a fixed overlay so nothing clips them), `chipsFor` (an amount
|
||
broken into the fewest chips, capped at what's worth watching), `burst`, `count`.
|
||
|
||
Two rules hold it together, and both are load-bearing:
|
||
|
||
1. **The number under the pile is a readout of the pile**, never the other way
|
||
round. So the bet starts at zero rather than at a default nobody put down, and
|
||
a settled hand puts your stake *back on the spot* as a standing bet — otherwise
|
||
the panel prints "your bet: 300" over an empty circle.
|
||
2. **The chip bar does not move until the chips that justify it have landed.** On
|
||
a live hand the money applies immediately (your stake left your pile and is
|
||
visibly on the spot); on a settling hand `play()` holds the apply until the
|
||
payout has swept home. A counter that pays you before the dealer turns over is
|
||
a counter that has told you the ending.
|
||
|
||
Also: cards land with weight (overshoot, a shadow that takes the hit, a degree or
|
||
two of resting tilt each), the dealer takes a beat before drawing out, and a
|
||
natural gets confetti — the only thing in the room that does.
|
||
|
||
- **A way to actually look at it.** `internal/web/devcasino_test.go` is the casino on
|
||
a port with one signed-in, funded player: `PETE_DEV_CASINO=:7788 go test
|
||
./internal/web -run TestDevCasino -timeout 0`. Skipped without the env var. It
|
||
wires its own routes because `New()` decides whether the casino exists at the
|
||
moment it builds the mux, and the test rig signs the player in afterwards. **Drive
|
||
the table in a real browser before believing anything about it** — this pass found
|
||
a white-on-white verdict pill, a rack that collided with the dealer, and Hit still
|
||
being offered over a table that was being paid out, none of which a Go test can see.
|
||
|
||
- **Deployed, 2026-07-14.** https://games.parodia.dev is live. What that took, since
|
||
the shape of it was not quite what this plan guessed:
|
||
- The edge is **Traefik**, not Caddy (`/mash/traefik/config/provider.yml`, root-owned,
|
||
file provider so it hot-reloads). The casino needed no router of its own — the
|
||
existing `pete` router's rule grew a second host:
|
||
``Host(`news.parodia.dev`) || Host(`games.parodia.dev`)``, and ACME issued the cert
|
||
on its own. DNS for the games host already pointed at the box.
|
||
- Authentik lives at **auth.parodia.dev**, and the app's OAuth2 provider is "Pete News"
|
||
(pk 12). It now holds both callbacks, strict: news and games. The provider's
|
||
`redirect_uris` is a list of objects, not strings.
|
||
- Server config gained `[web.games]` (enabled, host, `matrix_server = "parodia.dev"`)
|
||
and `web.auth.cookie_domain = ".parodia.dev"`, which is what makes a news sign-in a
|
||
games sign-in. Old host-only session cookies don't carry over — a signed-in user
|
||
signs in once more, and after that the session spans both.
|
||
- **gogobee is not on that box.** It runs on the LAN at `reala@192.168.1.212`, in a
|
||
screen session, out of `~/gogobee`, and it has no key for its own GitHub remote —
|
||
deploy it with `ssh -A` so the pull rides your agent. Its escrow loop needs no new
|
||
config: it is gated on the `FEATURE_PETE_NEWS` / `PETE_INGEST_*` env in `~/.env`
|
||
that adventure news already set. Restarted, it logs
|
||
`pete games: escrow loop started interval=3s`.
|
||
|
||
### Next, in order
|
||
|
||
1. Phase 2 (trivia, hangman), 3 (UNO), 4 (hold'em) as below.
|
||
|
||
Still open on the table itself, none of it blocking: **split** isn't implemented (the
|
||
engine has no move for it), the felt is roomy at desktop widths with only one seat on
|
||
it, and the chip animations are tuned for one player — a second seat would need the
|
||
spot to be per-seat rather than the singleton it is now.
|
||
|
||
### 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.
|