The settle was four autocommit statements — save, award, record, clear — sequenced so a crash between any two of them cost the player as little as possible. That reasoning holds for a game owned by one player, and the old comment made it well. It does not survive a pot, which is what the tables are about to become: pay the winner, die before the state write, and the hand still reads as live, so it settles again and pays again. Chips minted from nothing, and gogobee turns those into euros. The obvious fix is a trap. Award is a bare Get().Exec, so wrapping the settle in a transaction makes it wait for the connection the transaction is holding. Not an error — a hung process, and since the news app shares the pool it goes too. So storage.CommitHand does the lot in one Begin/Commit, with tx-taking award and recordHand beside the public ones. addChips has done it this way since the escrow ledger was written; this is only that pattern, applied where the money is. Two things fell out. A deal landing on a taken seat used to be refused and *then* refunded in a separate statement, so a crash in between took a stake for a game that existed nowhere — no felt, no audit row, nothing to find. And the audit row is now inside the settle, which means failing to write it rolls the payout back rather than paying quietly and logging: the payout and the audit row are the same fact, and a payout nobody can account for is worse than one that didn't happen. TestTheSettleDoesNotDeadlockAgainstItsOwnConnection is a canary, and it has been made to sing — put the bug back and it doesn't fail with a message, it hangs, and the timeout is the message. Which is exactly what production would do. A canary that has never sung is just a bird. Nothing a player can see has changed: eight blackjack hands conserving to the chip across win, lose and push (a natural is the sharp one — Fresh and Done in a single CommitHand), a double-deal 409 that refunds and leaves the live game alone, hangman, and a hold'em session that bought in for 200 and got up with 197. Also: the plan's deploy note was stale for the second time, with the lesson from the first time written directly underneath it. Everything is live and always was. A hand-written record of what is deployed will rot. Ask the box. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
1226 lines
76 KiB
Markdown
1226 lines
76 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.
|
||
|
||
### Start here (next session)
|
||
|
||
**The casino is going multiplayer, and the settle path has been rewritten to
|
||
survive it.** *(2026-07-14, later.)* The user asked for Blackjack, UNO and
|
||
Hold'em to be playable against real people. The design is settled (see "The
|
||
multiplayer build" below), and **Phase A — the atomic settle — is done, tested
|
||
and driven.** Nothing a player can see has changed. The next thing to build is
|
||
Phase B, the table runtime.
|
||
|
||
**And the deploy note was stale again — for the second time.** §0 said "a deploy
|
||
is owed, three commits behind". It wasn't: the box is on `a5b7e41`, its binary was
|
||
built three minutes after that commit landed, the front door serves a public 200
|
||
and `/og.png` renders. Everything is live. This is the *same note* that was wrong
|
||
two sessions ago, with the *same* lesson written directly underneath it, and it
|
||
went stale again anyway. Draw the obvious conclusion: **a hand-written record of
|
||
what is deployed will rot. Ask the box.** One `git log -1` in `/opt/pete`, one
|
||
`stat` on the binary, and one `curl` at a route only the new code serves — that is
|
||
thirty seconds and it is never wrong.
|
||
|
||
**The whole casino is live.** All six games (seven, counting the No Mercy dial)
|
||
are deployed and playing on https://games.parodia.dev.
|
||
|
||
**And the deploy note this plan had been carrying was wrong.** §0 said "only
|
||
blackjack is live" for two sessions running. It wasn't: hangman, solitaire, trivia
|
||
and UNO had already gone out at some point, and only hold'em was actually missing
|
||
(`/games/holdem` was the one route on the live box returning a 404 while the other
|
||
five returned a 302 to sign-in). The lesson is cheap and worth keeping: **ask the
|
||
running server what it serves — one loop over the routes — rather than trusting a
|
||
note about what was deployed.** The note was written by whoever last deployed; the
|
||
404 was written by the thing that is actually running.
|
||
|
||
**A bug the browser found in hold'em, and where it wasn't.** Six-handed, the felt
|
||
printed **CO on three seats at once**, and folding the small blind relabelled it
|
||
the cutoff. `Position()` walked the table with `nextIn` — which steps *over* folded
|
||
seats — while sizing that walk with a seat count that still *included* them, so
|
||
every muck slid the anchors round. There is now a second walker, `nextDealt`, which
|
||
skips only the seats that are not in the hand at all, because **where you sit is
|
||
decided when the button moves and does not change because somebody mucked**. The
|
||
pair is easy to confuse and now says so at both definitions. *(`03524ae`)*
|
||
|
||
Two things about that bug are the useful part. The bots never read `Position()` —
|
||
they read `InPosition()`, which genuinely does want the last seat still live — so
|
||
**the policy and the money were never touched; the only thing it ever broke was the
|
||
badge**, which is exactly why nothing caught it. And it was found only by dealing a
|
||
six-handed hand in a browser and reading the seats.
|
||
|
||
**A trap in the dev rig, which cost most of a session.** The felt appeared to show
|
||
the button posting a *big* blind heads-up. It was not: the engine is right (the
|
||
button posts the small blind and acts first before the flop, and there is a test).
|
||
What was on screen was a **half-played hand left behind by a failed drive script** —
|
||
the rig's table survives across runs of the driver. **A stale live hand will lie to
|
||
you.** Read the state from a hand you dealt yourself, and when the felt and your
|
||
model of the engine disagree, ask the engine directly (a throwaway `_test.go` in the
|
||
package beats an hour of staring at pixels).
|
||
|
||
**The money finding, and the thing to actually remember:** the *normal* UNO tiers
|
||
had been mispriced for a while, and it wasn't No Mercy's fault. They were set
|
||
against a naive win rate of 43/32/27%; re-measuring says 40.3/29.2/23.3%. The bots
|
||
improved at some point after the multiples were written down and nobody re-ran the
|
||
measurement — which §0 already warned about ("the bots and the tiers are a pair").
|
||
Table and Full House had quietly been charging an **18–19% house edge instead of
|
||
8%**. All six tiers are repriced off a fresh measurement, and
|
||
**`TestTheMultiplesAreStillPriced`** now fails the build if any of them drift out
|
||
of a 2–14% band. It is the test the normal tiers never had, which is precisely how
|
||
they drifted. *Any change to the bots, the deck, or a rule re-opens this.*
|
||
|
||
And the counterintuitive one, which would have shipped wrong if it had been
|
||
guessed: **No Mercy is easier than UNO at every table size** (naive wins 46.7% vs
|
||
40.3% heads-up), so it **pays less** — 2.0/3.1/3.8 against the normal deck's new
|
||
2.4/3.3/4.1. The mercy rule doesn't care whose hand hits twenty-five; it kills
|
||
*bots* too, and every bot it buries is one fewer seat that can beat you to the last
|
||
card. A deck built to be merciless is merciless mostly to the table.
|
||
|
||
### What this session did (2026-07-14, later)
|
||
|
||
**The casino had no share card, and meta tags would not have fixed it.** Every
|
||
route was behind `requirePlayer`, so a link pasted into a chat window got a 302
|
||
to sign-in and unfurled as the word "parodia.dev" — no title, no image. Tags on a
|
||
page a stranger cannot fetch are tags nobody reads. The casino now has a **front
|
||
door**: `/games` serves a real page to anybody, with the sign-in button and the
|
||
six tables on it. The tables still bounce you to sign-in; you cannot play from
|
||
the door. The **share card is drawn in Go** (`games_og.go`, `/games/og.png`,
|
||
public) and follows the clock like everything else — Casinopolis on green felt by
|
||
day, Casino Night Zone in neon after six — except the clock that decides is the
|
||
*server's*, because an unfurl bot has no evening of its own. *(`7ca1f7a`)*
|
||
|
||
Two things from building it worth not re-learning. **`color.RGBA` is
|
||
alpha-premultiplied**: the lamp over the table wrote raw channels beside a low
|
||
alpha, `image/draw` ran the result past 255 and wrapped the hue, and the first
|
||
card came out with a blue dome over a green stripe. If a colour comes out
|
||
impossible, look for a missing premultiply. And **an `og:image` has to be an
|
||
absolute URL that actually resolves** — which is two different addresses
|
||
depending on how you arrived (`/og.png` on the games host, where hostRouter puts
|
||
the `/games` back on; `/games/og.png` anywhere else). The test now reads the URL
|
||
off the page and goes and fetches it, on both hosts.
|
||
|
||
**Blackjack has a split.** *(`6f34a89`)* `State.Player` is gone: there is a slice
|
||
of `Hands`, each with its own cards, bet, outcome and payout, played left to
|
||
right. It is the only move in the game that takes chips *after* the cards are
|
||
out, and that is where the bugs live — `handleMove` was staking `st.Bet` for a
|
||
double, which was the same number as the hand's bet until today and is now the
|
||
whole table's, so doubling the third hand of a split would have charged you for
|
||
all three. `DoubleCost`/`SplitCost` are the active hand's. Each hand is **raked
|
||
on its own winnings**: netting them first would mean winning one and losing one
|
||
costs no rake at all, which is a discount for splitting rather than a rake. The
|
||
rules that cost real money if guessed: split aces get one card each and no say;
|
||
**21 on a split hand is not a natural** and does not pay 3:2; same rank, not same
|
||
value (K+Q is not a pair); four hands max; double after split is allowed; and if
|
||
every hand busts the dealer never turns over. `State.UnmarshalJSON` still reads
|
||
pre-split blobs, because a live hand outlives a deploy and a player mid-hand at
|
||
restart would otherwise be a player whose cards vanished.
|
||
|
||
### Deployed, as of right now
|
||
|
||
Everything through **`a5b7e41`** is on the box and running. *Verified by asking
|
||
the box, not by trusting this line — which is the only way this line is worth
|
||
anything. It has been wrong twice.*
|
||
|
||
---
|
||
|
||
## The multiplayer build
|
||
|
||
Blackjack, UNO and Hold'em, played against real people. This inverts the core
|
||
entity from **player** to **table**, and the whole difficulty is that two things
|
||
which were safe stop being safe: the settle path was written for one player and
|
||
one bet, and a table has two writers (an HTTP move, and a turn clock acting for
|
||
whoever walked away) where it used to have one.
|
||
|
||
### Decisions taken (from the user, 2026-07-14)
|
||
|
||
- **SSE, not WebSocket.** Latency was never the argument — a turn-based card game
|
||
does not care. The argument is that moves keep their existing POST endpoints and
|
||
the whole money path with them; a socket would mean rebuilding auth, ordering
|
||
and the double-click 409 on a message loop. EventSource also reconnects itself.
|
||
Chat is a POST up and an event down.
|
||
- The line where a socket *would* start paying for itself is **typing
|
||
indicators** — continuous upstream traffic. The user looked at that and said
|
||
messages only. If that changes, so does the transport.
|
||
- Plain polling was rejected for a specific reason: `MaxOpenConns(1)`. Every
|
||
idle player at a felt polling once a second is a serialized query against the
|
||
single connection the news app also shares.
|
||
- **Bots fill empty seats.** A human sitting down bumps a bot, so solo play is just
|
||
"a table nobody else joined yet" and there is no second mode to maintain.
|
||
- **UNO becomes a pot**, and the house multiple is deleted. Everyone antes, first
|
||
one out takes the pot less rake, bots ante from the house. The payout becomes
|
||
arithmetic rather than a measurement — which **kills the tier-drift problem and
|
||
`TestTheMultiplesAreStillPriced` with it**.
|
||
- The consequence was surfaced and accepted, and it is worth understanding
|
||
before anybody "fixes" it: against bots the pot is **harsher** than today.
|
||
Naive play loses ~20% a game rather than ~8%, because the bots beat a naive
|
||
player 60/40 heads-up and a pot does not compensate the way a 2.4× multiple
|
||
did. A pot is only fair between equals. **The casino already ships this exact
|
||
deal in hold'em** — you play bots for real chips and the house takes only rake
|
||
— so the outcome is one money model across the room instead of two.
|
||
- **Chat stays on the felt.** It does not mirror into Matrix.
|
||
- Tables are found through a **public lobby**. Room codes later, if ever; bots mean
|
||
a table is never empty.
|
||
|
||
### The four rules the runtime has to obey
|
||
|
||
Not style preferences. Breaking the first two hangs the process or mints money.
|
||
|
||
1. **Table lock first, DB second. Never begin a transaction before you hold the
|
||
lock; never take a lock while holding one.** SQLite is at `MaxOpenConns(1)`, so
|
||
the connection *is* a global mutex. A per-table mutex makes two locks, and two
|
||
locks in two orders is a deadlock that eats the only connection — taking the
|
||
news app down with the casino.
|
||
2. **The primary key decides, not a prior read.** Already this repo's idiom
|
||
(`StartLiveHand`'s `ON CONFLICT DO NOTHING`; `game_escrow.guid`). Every new
|
||
money path gets the same treatment.
|
||
3. **A settle is one transaction.** See Phase A.
|
||
4. **A `version` column is the concurrency authority; the mutex is only an
|
||
optimisation.** A mutex does not survive a redeploy — during a drain two
|
||
processes hold two different mutexes over the same row — and a mutex map you can
|
||
`delete()` from will hand two goroutines two different locks for one table.
|
||
|
||
### Phase A — the atomic settle. **Done.**
|
||
|
||
Shipped on its own, against the existing single-player games, changing nothing a
|
||
player can see. Every multiplayer money bug is downstream of it.
|
||
|
||
`commit()` was four separate autocommit statements: save → `Award` → `RecordHand`
|
||
→ `ClearLiveHand`, carefully sequenced so a crash between any two cost the player
|
||
as little as possible. That reasoning is sound for a game owned by one player, and
|
||
the old comment made it well. **It does not survive a pot.** Pay the winner, die
|
||
before the state write, and the hand still reads as live — so it settles again and
|
||
pays again. Chips minted from nothing, and gogobee turns them into euros.
|
||
|
||
Worse, the obvious fix is a trap. `storage.Award` is a bare `Get().Exec`, so
|
||
wrapping the settle in a transaction makes it wait for the connection *the
|
||
transaction is holding*. Not an error — **a hung process**, and the news app goes
|
||
with it.
|
||
|
||
So: `storage.CommitHand` does the lot — seat, pay, record, clear, touch — in one
|
||
`Begin`/`Commit`, with tx-taking `award`/`recordHand` helpers beside the public
|
||
ones. The model already existed: `addChips(tx, …)` has done it this way since the
|
||
escrow ledger was written.
|
||
|
||
Two things fell out that are worth keeping:
|
||
|
||
- **The refuse-and-refund is now atomic.** A deal landing on a taken seat used to
|
||
come back `ErrHandInProgress` and *then* refund in a separate statement — so a
|
||
crash in between took a player's stake for a game that existed nowhere: no felt,
|
||
no audit row, no way to find it. It is one transaction now.
|
||
- **The audit row moved inside the settle**, which means a failure to write it now
|
||
rolls the payout back rather than paying quietly and logging. Deliberate: the
|
||
payout and the audit row are the same fact, and a payout nobody can account for
|
||
is worse than one that didn't happen — the game stays live and settles again on
|
||
the next request.
|
||
|
||
**`TestTheSettleDoesNotDeadlockAgainstItsOwnConnection` is a canary, and it has
|
||
been made to sing.** Reintroduce the bug (call `Award` instead of `award` inside
|
||
`CommitHand`) and it does not fail with a nice message — it *hangs*, and the
|
||
timeout is the message, which is exactly the production failure. It was verified by
|
||
putting the bug back and watching it catch it. A canary that has never sung is
|
||
just a bird.
|
||
|
||
Driven end to end through the real HTTP path, not just unit tests: eight blackjack
|
||
hands conserving to the chip across win/lose/push (a natural is the sharp case —
|
||
it is `Fresh` *and* `Done` in one `CommitHand`), a double-deal 409 that refunds and
|
||
leaves the live game untouched, hangman, and a hold'em session (sit 200, fold the
|
||
blinds, get up with 197).
|
||
|
||
### Phase B — the table runtime. **Next.**
|
||
|
||
Full design in `~/.claude/plans/imperative-mixing-emerson.md`. The load-bearing
|
||
parts, which are the ones that are easy to get wrong:
|
||
|
||
- **Occupancy stays in `game_live_hands`** — add a nullable `table_id` rather than
|
||
making `game_seats.matrix_user` a second uniqueness domain. A split brain there
|
||
silently switches off three guards that already work, and the worst is the
|
||
cash-out check (`games_play.go`), which reads `LoadLiveHand`: a seated player
|
||
with no live-hand row could **cash out to zero while sitting at a poker table
|
||
with chips in the pot.**
|
||
- **The turn clock is the first goroutine in Pete that has ever mutated game
|
||
state.** Copy `StartTriviaBank`'s shape. It must collect table ids and *close the
|
||
rows* before taking any lock (rule 1), and act only if the `version` still
|
||
matches the one it saw at scan time. Without that check: Bob's raise lands in the
|
||
same second his clock expires, action passes to Cara, and the clock then **folds
|
||
Cara**, who had 25 seconds left. A one-second window that recurs on every turn of
|
||
every hand.
|
||
- **An absent human is not a bot**, and this is a product bug before it is a money
|
||
bug. The bot loop stops dead at a disconnected player's seat and waits out the
|
||
full clock; a table with three ghosts spends a minute an orbit folding air and
|
||
the one real player leaves. Seats go `away` after a timeout and get auto-acted.
|
||
- **SSE frames publish under the table lock** (which orders them correctly for
|
||
free) but with **non-blocking sends only** — one phone on a train must not block
|
||
a send, hold the lock, and stall the clock for the whole casino. And **the SSE
|
||
handler must never touch the DB after its first read**: holding rows open for the
|
||
life of a stream holds the only connection forever, and one subscriber bricks the
|
||
application.
|
||
- **`dropUnreadable` must never fire on a multi-seat table.** It discards the game
|
||
and keeps the stake — defensible for one player, but at a shared table it
|
||
silently incinerates four players' stacks.
|
||
- **`ReapIdleSessions` has never actually run.** Zero production callers; its doc
|
||
says "safe to run on a timer" and nothing ever put it on one, so chips in
|
||
abandoned sessions have been in limbo all along. Wire it while wiring the clock.
|
||
|
||
Then: **C — Hold'em** (its engine is already multiway: real seats, side pots,
|
||
`ToAct`. Mostly letting more than one seat be `Bot: false`. **Write the per-seat
|
||
redaction test first** — after SSE, one missed `i == You` fans every hole card to
|
||
every subscriber). **D — UNO** (`const You = 0` becomes a seat parameter, ~40
|
||
sites; the pot). **E — Blackjack** (seats invented from scratch, but the simplest
|
||
turn model. **Shuffle every round**: a persistent shoe broadcast over SSE is a
|
||
countable shoe with a free API, and counting is a real +EV attack on real euros).
|
||
|
||
Two that are easy to miss: **`RecordHand` takes one user, so `HouseTake` will
|
||
lie** — four rows each carrying the pot's full rake reports 4× the real take, and
|
||
that number is the inflation brake the economy was sized on. And **seeds stop
|
||
reproducing a hand**: at a shared table the cards depend on the order the others
|
||
acted, so the audit trail quietly stops being one.
|
||
|
||
---
|
||
|
||
### 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`.
|
||
|
||
- **Hangman, and it plays for chips.** *(2026-07-14. This revises §7's "Phase 2 —
|
||
no escrow": the decision was that a free game in a casino reads as a demo, so
|
||
hangman stakes chips like everything else and reuses the money path whole.)*
|
||
- **The gallows is the payout meter.** You pick a tier, stake, and get six
|
||
lives. Every wrong guess draws a limb *and* takes a tenth off the base
|
||
multiple — one event, shown as one event. Short phrases pay 2.6×, medium 2.0×,
|
||
long 1.6× (short is hardest: fewer letters, less to go on). Floored at 1×, so
|
||
a win never hands back less than the stake, and the rake still comes out of
|
||
winnings only.
|
||
- `internal/games/hangman` — the same pure reducer as blackjack, phrases
|
||
embedded (`phrases.txt`, 205 of them, video-game flavoured, lifted from
|
||
gogobee). `State.Pays()` is the number the felt quotes *and* the number
|
||
settle() lands on: they were briefly two sums and the table advertised a
|
||
pre-rake payout it didn't honour. One function now, and a test that walks a
|
||
game asserting the quote equals the payout at every step.
|
||
- **The browser never sees the phrase.** Cells carry the letter or an empty
|
||
string — not the letter with a hidden flag — and the phrase itself is only
|
||
added to the payload once the game is over and it decides nothing.
|
||
- Two things the storage layer already gave us for free, and one it didn't:
|
||
`game_live_hands` is keyed on the *player*, so "one game at a time" holds
|
||
across games with no new code (a live hangman 409s a blackjack deal). But
|
||
`table()` used to unmarshal any live row as a blackjack hand — which does not
|
||
*fail* on a hangman row, it just silently yields an empty hand. It now
|
||
dispatches on `live.Game`.
|
||
- `commit()` in games_play.go is the shared settle path (seat → pay → audit →
|
||
clear → touch). Both games go through it so neither re-derives an ordering
|
||
that took a while to get right. `casinoRoutes()` is likewise the single route
|
||
list, because devcasino_test.go has to wire its own mux and a second copy is
|
||
a copy that stops including the newest game.
|
||
- Driven in a real browser, win and loss: a 200 stake at 2.34× paid 455 and the
|
||
bar landed on it; six wrong took the stake and nothing more; a reload
|
||
mid-phrase brought back the board, the limbs, the multiple, the spent keys and
|
||
the stake on the spot. Two layout bugs only the browser could show: the lives
|
||
counter ran under the house rack, and the board wrapped a word early because
|
||
the rack's clearance padding was on the whole column instead of the one row
|
||
level with it.
|
||
|
||
- **Solitaire, and it plays for chips.** *(2026-07-14, jumping the queue ahead of
|
||
trivia because the user asked for it.)*
|
||
- **Vegas scoring**, which is the only way solitaire has ever actually been a
|
||
gambling game. You do not win or lose the deal — you **buy the deck** for your
|
||
stake, and every card you get home to a foundation pays a fifty-second of the
|
||
tier's multiple back. Cash the board whenever you like and keep what you've
|
||
banked; a board that has gone dead is therefore a decision, not a wall. There
|
||
is no undo, because the stake is spent the moment the deck is bought and an
|
||
undo would be a way to walk a losing board backwards until it wins.
|
||
- Three deals, and the two dials are the whole difficulty of Klondike: **Patient**
|
||
(draw 1, unlimited passes, 1.4×, square at 38 cards), **Vegas** (draw 3, three
|
||
passes, 2.2×, square at 24), **Cutthroat** (draw 3, one pass, 3.4×, square at
|
||
16). `Tier.BreakEven()` is what the felt quotes, because "2.2×" tells a player
|
||
nothing about a game where the multiple is paid a card at a time.
|
||
- `internal/games/klondike` — the same pure reducer. `Pays()` is one function for
|
||
the same reason hangman's is. Two fuzzers hold the deck together: no card is
|
||
ever lost or duplicated by any sequence of moves, and the board stays
|
||
well-formed (every face-up run is a run, no column has cards face-down under
|
||
nothing). The first thing a test caught was a **recycle that reversed the
|
||
waste** — it flips as a block, so the card drawn first comes out first, and
|
||
reversing would have dealt a different game on every pass and broken the seed.
|
||
- **The browser never sees the stock or a face-down card.** Bigger than
|
||
blackjack's hole card: that's most of the deck. Columns send a face-down
|
||
*count*, never the cards. The events, unlike blackjack's, need no filtering —
|
||
every card they carry is one the move just turned face up.
|
||
- **The table re-renders and animates the difference (FLIP).** Blackjack plays
|
||
back a script because a hand only grows at one end; solitaire moves runs from
|
||
anywhere to anywhere and an auto-finish moves eleven cards at once. So
|
||
`solitaire.js` measures where every card is, re-renders the board the server
|
||
sent, and plays each card from its old place to its new one. The board on
|
||
screen is therefore always exactly the board the server says exists. The events
|
||
supply only what a diff can't: where a *newly revealed* card came from (the
|
||
stock, or a flip in place) and what the board is now worth.
|
||
- **The rules are mirrored in JS**, deliberately, and only to light up the columns
|
||
a held card can go to. The server still decides every move; a disagreement
|
||
snaps the board back to whatever it says. Being shown where a card goes is the
|
||
game teaching you; being told no after you commit is the game scolding you.
|
||
- Two things got extracted rather than copied, which is the rule this room runs
|
||
on: **`casino-cards.js`** (the deck — faces, pips, the flip; was inside
|
||
blackjack.js) and **`PeteFX.spot()`** (the pile of chips and the number under
|
||
it, which owns the "the number is a readout of the pile" rule so no table can
|
||
break it). Blackjack now uses both.
|
||
- **Driven in a browser, 2026-07-14, and it holds up.** Every worry on the list
|
||
came back clean. A Patient deck bought for 200 dealt a correct Klondike (28 cards
|
||
across the seven columns, 24 left in the stock), quoted `+5.4 a card` and `38 more
|
||
to break even` — which is the tier's arithmetic, not a guess — and the money
|
||
conserved end to end: 5,000 → 4,800 to buy the deck → one card home banked 5 →
|
||
4,805 cashed out. The FLIP does not jump on a re-render. The seven columns fit at
|
||
390px with no horizontal overflow (`docScrollW == clientW`), the rail stacks under
|
||
the board rather than colliding with it, and the console is silent.
|
||
- **And blackjack survived the rewire**, which was the real thing to check. Five
|
||
hands, and the felt agreed with `/api/games/table` on every one. The rake still
|
||
comes out of winnings only: a 400 win paid back 780 (the stake, plus 400 less 5%),
|
||
and a push returned all 600 with nothing taken.
|
||
- One thing to know before you go looking for a bug that isn't there: the bare
|
||
`<span data-chip>` elements are the *house rack's decoration*. Only
|
||
`button[data-chip]` carries a listener. A driver script that clicks `[data-chip]`
|
||
hits the rack, nothing happens, and it looks like the bet is broken. Blackjack's
|
||
action buttons are also `[data-move="stand"]`, not `[data-stand]`.
|
||
|
||
- **Trivia, and it plays for chips.** *(2026-07-14. Built, and now **played** — see
|
||
"Driven in a browser" at the bottom of this entry, which is where the two bugs
|
||
were.)*
|
||
- **A ladder.** Stake once, then answer a run of twelve. Every right answer
|
||
multiplies what you're holding, a wrong one loses the lot, and you may walk
|
||
with what you've built. Clearing all twelve ends the run and banks it — a
|
||
ladder with no top is a slot machine you can't stop playing, and eventually
|
||
every player loses everything to one bad question.
|
||
- **The clock is the game, and it is the anti-google mechanism.** Trivia answers
|
||
are lookupable, so a right answer is worth what it's worth *when you give it*:
|
||
the multiple decays from Fast to Buzzer across the tier's limit (easy 1.30→1.10
|
||
over 20s, medium 1.55→1.20 over 18s, hard 1.90→1.30 over 15s), and running out
|
||
of time loses exactly as much as being wrong. A timeout that merely cost you the
|
||
speed bonus would make "look it up in the other tab" the strongest way to play.
|
||
The countdown in the browser is decoration; the clock that scores is
|
||
`time.Now()` against the `AskedAt` the server stamped. A reload does not restart
|
||
it.
|
||
- **A pure reducer still, but the time is an argument** — `ApplyMove(state, move,
|
||
now)`. A reducer cannot own a timer, so it doesn't: the only thing that knows
|
||
what o'clock it is remains the caller, and the engine stays value-in, value-out.
|
||
- **You cannot walk off the first rung** (`ErrNothingBanked`). If you could, seeing
|
||
question one and walking would be a free look: stake, peek, walk, restake, and
|
||
reshuffle until the question is one you happen to know. The first question is the
|
||
price of sitting down.
|
||
- **The browser never learns which answer is right.** The four answers cross the
|
||
wire without the index; that index is in the engine state, on the server. It
|
||
comes back only in the event that *decides* the question, by which point knowing
|
||
it is worth nothing. The ladder's remaining questions are never sent at all.
|
||
- `internal/games/trivia` — engine, 11 tests. The one that matters most is the
|
||
same one hangman needed: the number the felt quotes (`Pays()`) is asserted equal
|
||
to the number `settle()` lands on, at every rung.
|
||
- **The bank is prefetched, not fetched per question** (`internal/opentdb`,
|
||
`storage.DrawTrivia`, table `trivia_questions`). A ladder asks a question every
|
||
fifteen seconds with money on a clock the player is scored against; a live fetch
|
||
would put OpenTDB's latency and rate limit *inside* that clock. The refill is a
|
||
slow background drip (`StartTriviaBank`, 400 per difficulty, one request per six
|
||
seconds, stops early when a batch adds nothing new), and a round never waits on
|
||
it. Answers are shuffled per-game against the game's own seed, so where the right
|
||
answer sits in the table tells a player nothing.
|
||
- **The dev rig seeds its own bank.** A fresh dev database has an empty bank and
|
||
every start 503s, so `TestDevCasino` now takes one real batch per difficulty
|
||
from OpenTDB (`seedTriviaBank`) — fifty questions each, four ladders' worth,
|
||
through the same fetch-decode-store path production uses. It does *not* run
|
||
`StartTriviaBank`: a rig that spends its first two minutes dripping four hundred
|
||
questions per difficulty out of a free API is a rig you cannot use.
|
||
- **Driven in a browser, 2026-07-14, and the clock and the money hold up.** The
|
||
ladder plays: a 200 stake on Easy dealt a real OpenTDB question with its
|
||
entities decoded, the clock bar drained honestly (847px → 711px over three
|
||
seconds, countdown 18.7s → 15.7s), two right answers compounded 1.00× → 1.26×
|
||
→ 1.58×, and walking paid exactly the 311 the felt had been quoting. The
|
||
reveal marks the wrong pick red and the right answer green. A reload mid-rung
|
||
brought the board back and — the thing that matters — the server's clock kept
|
||
running through it (17.5s left before, 16.2s after; it does not restart).
|
||
**The timeout lands as a timeout**, which was the loudest worry: going quiet
|
||
through a 20s question fired the auto-submit at zero, came back 200 with a
|
||
`timeout` event and "Out of time.", not a "that move isn't legal". The next
|
||
question's answer is never sent (`correct: -1` in the ask event); only the
|
||
decided one reveals.
|
||
- **Two bugs, and only a browser could have found either.**
|
||
1. **The spot printed double the stake after every settled game.** `standing()`
|
||
set `spot.amount` and *then* poured the chips on, and `pour` grows the pile
|
||
from whatever it is told is already there — so a 200 stake settled to a spot
|
||
reading 400, and a 400 one to 800. This is exactly the rule the felt is built
|
||
on ("the number under the pile is a readout of the pile") failing quietly:
|
||
the money was always right, the *number under the chips* was not. Blackjack
|
||
and hangman pour without pre-setting; trivia now does too.
|
||
2. **The house rack sat on top of the multiplier at 390px.** The rack is a 147px
|
||
block inset 5.75rem from the edge, and that inset is not a margin — it is the
|
||
width of *blackjack's shoe*, which the rack sits beside. On a phone that puts
|
||
it in the middle of the felt, on top of trivia's "1.53×". On small screens the
|
||
rack now shrinks and, where there is nothing in the corner, pulls into the
|
||
corner. Which rack is which is what `data-at` says: unmarked is alone in the
|
||
corner, `shoe` is blackjack (pull that one to the edge and it slides under the
|
||
deck — this was caught after doing exactly that), `rail` is solitaire, whose
|
||
rack isn't on the felt at all. All four tables re-checked at 390px and 1280px,
|
||
live games on the felt: no overlap with text, no overlap with the shoe, no
|
||
horizontal overflow, desktop geometry unchanged.
|
||
|
||
- **UNO, and it plays for chips.** *(2026-07-14. Built, tested, and now **played** —
|
||
see "Driven in a browser" at the bottom of this entry, which is where the three
|
||
bugs were.)*
|
||
- **You beat the table, or you don't.** The user's call between three money
|
||
models: stake once, go out first and take the tier's multiple; anybody else
|
||
going out first takes the stake. **The table size is the tier**, which is the
|
||
one dial UNO actually has: Duel (1 bot, 2.2×), Table (2 bots, 2.9×), Full
|
||
House (3 bots, 3.6×). Rake on winnings only, as everywhere.
|
||
- **The multiples are measured, not guessed.** A player who just plays the first
|
||
legal card they hold goes out first 43% / 32% / 27% of the time against the
|
||
bots, so the tiers are priced to make that lose about 8% a game — which leaves
|
||
good play (holding the wilds back, dumping the colour you're long in) worth
|
||
roughly the house's edge. The measurement is a throwaway test, not in the tree;
|
||
re-run it if the bots or the tiers change, because the two are a pair.
|
||
- **The bots move inside `ApplyMove`, and that is what keeps solo UNO off a
|
||
socket.** One request plays your move *and every bot turn it hands off to*,
|
||
and returns the lot as a script of events the felt plays back in order. §7 said
|
||
solo first, no sockets; this is what that costs.
|
||
- **The RNG is in the state, not an argument.** The bots choose and a spent deck
|
||
reshuffles, so the engine needs randomness *mid-game* — but there is no rng
|
||
alive across requests to hand it. So the seed rides in the state (which never
|
||
leaves the server; the deck is in there too) and each step derives its own
|
||
generator from the seed and the step count. Value in, value out, and a game
|
||
still replays exactly as it fell.
|
||
- **The zero value of `Color` is Wild, deliberately.** It was Red for an hour, and
|
||
a wild played with the `color` field simply missing from the JSON went down as
|
||
a red one. The zero has to be "no colour named", so the omission is refused
|
||
instead of quietly meaning something. This is the kind of bug a rules test
|
||
finds and a browser never would.
|
||
- **The browser never sees a bot's card.** Not the deck, not a hand, not even the
|
||
face of a card a bot drew — that last one is most of the deck, and sending it
|
||
would turn counting cards into reading the network tab. Seats cross the wire as
|
||
a name and a *count*. There are two walls: the engine only attaches a face to
|
||
an event the seat may see it in, and `viewUnoEvents` drops it again anyway.
|
||
- `internal/games/uno` — engine, 22 tests. The census one is the load-bearing one:
|
||
108 cards, each in exactly one place, asserted after every move of 100 games
|
||
played out end to end. It is what would catch a reshuffle that leaks cards (the
|
||
wilds go back into the deck as *wilds*, not as the colour they were played as)
|
||
or a turn the bots never hand back.
|
||
- `PeteFX.flyNode` — the throw, with the chip taken out of it. `fly()` is now that
|
||
with a chip in it, because UNO wanted the same arc with a card in it. Extracted
|
||
rather than copied, same as `casino-cards.js` and `PeteFX.spot()` before it.
|
||
- The felt has no corner free for the house rack (bots along the top, piles in the
|
||
middle, your hand at the bottom), so it takes solitaire's **rail** instead:
|
||
`data-at="rail"`, off the felt, no collision to check for.
|
||
- **Driven in a browser, 2026-07-14, and it plays.** A Full House game went the
|
||
distance: the bots' turns come back as a readable script (a card flies from the
|
||
seat that played it, SKIPPED and +2 land on somebody), the wild picker takes a
|
||
colour and the felt changes to it, a reload mid-game brings back the hand, the
|
||
counts, the colour in play and the stake, and the money is right — a Duel staked
|
||
200 and won paid 428 back into a 4,600 stack (2.2× is 240 of winnings, less the
|
||
5% rake, so +228 net), while a lost Full House took the stake and nothing else.
|
||
A thirteen-card hand wraps to three rows at 390px with no sideways overflow and
|
||
nothing colliding. Console silent.
|
||
- **Three bugs, and the first one was the whole table.**
|
||
1. **Every visit to `/games/uno` was a 500.** The handler was wired, the route
|
||
was in `casinoRoutes()`, the template was written — and `uno` was never added
|
||
to the list of pages `server.go` parses into the games template set, so
|
||
`render()` answered "unknown page". No Go test saw it because the casino tests
|
||
all call the handlers *directly* and never go through `render()`. There is now
|
||
a test that does: `TestEveryCasinoPageRenders` walks the mux, asks for every
|
||
page the casino routes to, and fails on a 500 or a half-rendered body. **Add a
|
||
game, add it there.**
|
||
2. **The wrong card left your hand.** The play script hid `.pete-uno-card[data-on="1"]`
|
||
— the *first* card that lit up, not the one you clicked — so playing any other
|
||
playable card made an innocent one vanish while the card you played sat there
|
||
and a copy of it flew to the discard. It self-corrected on the re-render, which
|
||
is why it read as a flicker rather than a bug. The index you played is now kept
|
||
(`played`) and that card is the one lifted out.
|
||
3. **On a phone the card in play sat on top of the colour in play.** The mobile
|
||
query shrank `.pete-uno-discard`'s *box* with a raw height and width, but the
|
||
card inside it is a `.pete-uno-card` and takes its size from `--uno-h`/`--uno-w`,
|
||
which the discard never set — so a full-size card hung out of a small hole and
|
||
covered the RED/BLUE pill under it. The vars go on the discard now. Worth
|
||
remembering as a rule: **size a card by its vars, never by the box you put it
|
||
in.**
|
||
|
||
|
||
- **No Mercy, on the felt, and it plays.** *(2026-07-14. The engine landed in
|
||
`aca523e`; this is the half you can see. Driven in a browser, and that is where
|
||
both bugs were.)*
|
||
- **It is one switch, not a fourth table.** The betting panel grows a two-way
|
||
segmented control (UNO / No Mercy) that swaps the three tier cards for the other
|
||
three. The table size stays the tier — it is what you're paid for — and the deck
|
||
is the other dial. A settled game leaves the panel on the table you just played,
|
||
because the commonest thing anybody wants after a hand of No Mercy is another one.
|
||
- **The six faces the normal box doesn't print.** `FACES` in `uno.js` is one table
|
||
of {middle mark, corner mark, size}: the long ones ("DISCARD ALL", "SKIP ALL")
|
||
wrap across the tilted oval and carry a short mark for the corners, exactly as a
|
||
real deck does. Sized by a *font class*, never a box — `--uno-h`/`--uno-w` remain
|
||
the only thing that decides how big a card is, which is the rule the phone bug
|
||
taught this table.
|
||
- **The wild draws glow** (the user asked, and it turned out to be load-bearing).
|
||
A rainbow aura behind the card plus a slow shine across it, on any wild that
|
||
makes somebody draw. It is the one thing that tells the *wild* +4 apart from No
|
||
Mercy's **coloured** +4 sitting next to it in the same hand. It is a sibling
|
||
behind the card, not a shadow on it, because a box-shadow there would fight the
|
||
one that says which colour a played wild was named as.
|
||
- **The stack says what the bill is, in three places**: a red pill on the felt, the
|
||
turn line ("+6 — stack it, or take it"), and the button itself ("Take 6"). Under
|
||
a stack the hand only lights the cards that answer it, the deck is dead (you
|
||
cannot draw your way out of a bill), and the hint line changes to say so.
|
||
- **A buried seat is not an empty one.** This is the whole trap of the mercy rule
|
||
in the view layer: a seat killed at twenty-five holds zero cards, and so does a
|
||
seat that just went out and *won*. The view asks the engine (`State.Live`) rather
|
||
than inferring from a count of zero, so the winner is never the corpse.
|
||
`TestABuriedSeatIsNotTheWinner` is that, and it also covers the ending only this
|
||
deck has — you can win by **outliving** the table, with a hand still in front of
|
||
you ("Last one standing!"), and you can lose without anybody going out at all.
|
||
- **Two bugs, and a browser found both.**
|
||
1. **The bill was writing into the chip bar.** The felt's pending pill was
|
||
`[data-pending]` — and so is the chip bar's "chips still in flight from a
|
||
buy-in" readout, and the chip bar lives *inside* `[data-uno]` and comes first
|
||
in the document. So `root.querySelector` found the wrong one: a stack quietly
|
||
overwrote the escrow message and never appeared on the felt at all. It is
|
||
`[data-bill]` now. **A table's own attributes are not a private namespace —
|
||
the chip bar is in every one of these roots.**
|
||
2. **Hold'em let you click a button that did nothing** (found while re-driving
|
||
poker, below). `send()` drops a click that arrives while a move is in flight,
|
||
which is right — the board on screen during a script is a board the server has
|
||
already moved past — but the *between-hands* buttons (Deal, Leave, Top up)
|
||
stayed enabled through the whole deal animation. Clicking Leave while the
|
||
cards were still flying did nothing: no move, no message, no reason. The lock
|
||
lives on the buttons now (`lock()`), not only in the `busy` variable.
|
||
- **Driven in a browser, 2026-07-14.** A Full House game stacked a +6 onto us and
|
||
the felt said so; taking it worked; a bot went to twenty-five and the seat went
|
||
grey and said "Buried"; a Duel was won by outliving the table and paid 1,950 on a
|
||
1,000 stake (2.0× is 1,000 of winnings, less the 5% rake — the quote and the
|
||
payout agreed, as `Pays()` requires). A 21-card hand at 390px wraps to five rows
|
||
with every card painted and no sideways scroll. The normal deck still plays and
|
||
still pays (a Table win on 1,200 paid 3,822). Console silent.
|
||
|
||
- **Hold'em, re-driven on the 20M-hand policy.** *(2026-07-14, and it closes Phase
|
||
4.)* The policy is a data file, so the check was whether the bots still play a real
|
||
game off it and the money still conserves: sat down for 100 at the 1/2 table, six
|
||
hands (won a pot with a straight, lost one at showdown, folded the rest), got up
|
||
with 61, and 4,900 + 61 came back as 4,961 — conserved to the chip. The re-drive is
|
||
also what turned up the dead-button bug above, which is the argument for the rule:
|
||
**re-drive after a policy change even though "only data" moved.**
|
||
|
||
- **Hold'em, and it is a cash game.** *(2026-07-14. Built, tested, and driven in a
|
||
browser. The bots had to be retrained from scratch — see below, it is the whole
|
||
story of this phase.)*
|
||
- **You buy in, you play, you leave with what's in front of you.** This is the
|
||
only table in the casino that is a *session* rather than a game. Everywhere else
|
||
stakes once and pays a multiple; poker isn't that shape. So the live row lives
|
||
across hands, and chips cross the border exactly twice: once when you sit down
|
||
and once when you get up. In between every pot is inside the engine and storage
|
||
sees none of it. Three stakes (1/2, 5/10, 25/50), buy in for 20–100 big blinds,
|
||
top up between hands, bust and the session simply ends.
|
||
- **The rake is a real cardroom's rake**: five percent of a pot that *sees a flop*,
|
||
capped at three big blinds. No flop, no drop — so folding your blind round after
|
||
round costs you the blinds and no fee. Still winnings-only in the sense that
|
||
matters: you pay it out of a pot you win, never out of a bet you lose.
|
||
- **The bots move inside ApplyMove**, as UNO's do, which is what keeps poker off a
|
||
socket. One request plays your action, every bot action behind it, and whatever
|
||
streets that finishes — so shoving all-in returns the flop, turn, river, showdown
|
||
and payout in a single response, as a script the felt plays back.
|
||
- **The CFR policy was a lie, twice over, and this is the part worth reading.**
|
||
§5 called it "the single highest-value asset in either repository". It was not
|
||
being used *at all*, and could not have been:
|
||
1. **The key never matched.** The trainer packed a single "am I last to act" bit
|
||
and wrote its keys as `IP`/`OOP`. The runtime looked them up with the labels a
|
||
player would recognise — `BTN`, `SB`, `BB`, `UTG`. Not one key ever hit, for
|
||
the entire life of the game in gogobee. Nothing looked broken: a policy miss is
|
||
not an error, it is a silent fall back to a pot-odds heuristic. The bots played;
|
||
they just never once read the five million iterations sitting in policy.gob.
|
||
2. **And it was the wrong game anyway.** `TrainCFR` opens with `stack0, stack1 = 20,
|
||
20` at 1/2 blinds — a **ten big blind** push-fold stack. 82% of its nodes are in
|
||
the "stack smaller than the pot" bucket. A 20–100BB cash game lands almost
|
||
nowhere near it. Worse, the tree it trained on was not hold'em: a call always
|
||
ended the street (so no big-blind option and no check-check), turn order was
|
||
history-length parity, and the payoff was ±half the pot regardless of who had
|
||
put what in.
|
||
- **So the trainer was rewritten to play the real engine.** `internal/games/holdem/
|
||
train.go` + `cmd/holdem-train`. External-sampling MCCFR, every move applied through
|
||
`Step` — the same reducer the felt calls — so the blinds, the min-raise, street
|
||
completion, side pots and the money are the ones a player actually meets. The
|
||
stack depth is drawn fresh every hand across the whole 20–100BB range, because
|
||
poker at twenty big blinds and poker at a hundred are different games and a bot
|
||
that only knows one folds into the other.
|
||
- **The key is built by one function, `State.spot`, called by both the trainer and
|
||
the table.** That is the entire fix for bug (1), and it is structural: they cannot
|
||
drift apart because there is only one of them. And because a miss is *still* silent,
|
||
`Hits`/`Misses` are counted at the point a bot looks itself up, and
|
||
`TestTheBotsAreActuallyTrained` fails if the heads-up hit rate drops under 60%. It
|
||
is 95%. Multiway degrades on purpose — the policy is heads-up, six-handed reuses it
|
||
as a documented approximation, and the rest falls through to pot odds.
|
||
- **Three money bugs, and the tests earned their keep.** `TestChipsAreConserved`
|
||
plays a hundred sessions of real hands and counts every chip after every move; it
|
||
caught an **uncalled bet that minted chips** (the rule skipped folded players when
|
||
working out what had been matched, so a river bet folded to came back *whole*,
|
||
including the part called on the flop). A Go var-init ordering trap made `deck52`
|
||
build from an empty conversion table, so every card was identical, every showdown
|
||
tied and **every bot believed it held exactly 50% equity** — package-level vars are
|
||
built before `init()` runs. And the browser found the third: **the rake was
|
||
silently zero**, because the tiers declared `RakePct: 5` meaning percent while
|
||
`New()` overwrites it with blackjack's `0.05` meaning a fraction, and the integer
|
||
arithmetic floored 5% of a hundredth of the pot to nothing. Every rake test built
|
||
its own `State` by hand and so never saw the number the table runs on. There is now
|
||
one that does.
|
||
- **Driven in a browser, 2026-07-14, and it holds up.** Sitting down took 500 off a
|
||
5,000 stack and put it on the table; a hand played out; getting up put 1,004 back
|
||
(money conserved to the chip). The raise slider, the pot/half-pot/max presets, a
|
||
shove that runs the whole board out in one response, and a reload mid-hand that
|
||
brings back the hand, the board, the pot, the street and the stack — all clean.
|
||
**Side pots and split pots balance**: a three-way all-in paid 758 + 757 + 920 out
|
||
of a 2,495 pot with 60 raked, and `paid + rake == pot` on every multi-winner hand
|
||
sampled. A bot's cards never cross the wire until a showdown, and a folded seat's
|
||
never do at all. Six-handed at 390px: no sideways overflow, nothing colliding.
|
||
Console silent.
|
||
- Two felt bugs only the browser could show. **`.pete-stack` is `position: absolute;
|
||
inset: 0`** — so the pot's chips, sharing a box with the number under them, painted
|
||
straight over it: the pot showed a chip and no total. The pile needs a box of its
|
||
own. And **a `.pete-spot` is 7rem across** because blackjack has exactly one of
|
||
them; six of those is most of a felt, so a seat's bet spot is less than half the
|
||
size, with chips scaled to match. The bet total hangs *below* the ring
|
||
(`.pete-spot-total`), which is the existing rule for exactly this reason.
|
||
|
||
### Next, in order
|
||
|
||
1. **Phase B — the table runtime** (see "The multiplayer build" above). Phase A is
|
||
done; nothing is half-built.
|
||
2. Then C (hold'em), D (UNO), E (blackjack).
|
||
3. The open list, none of it blocking, none of it promised: **hold'em has no
|
||
multiway policy** (and multiplayer makes that matter more, not less — a
|
||
six-handed table of humans is not the heads-up game the policy was trained on);
|
||
the trivia bank refills on a 12h tick (`games: trivia bank refill started
|
||
target=400`), so a player trying a ladder in the first minute after a fresh
|
||
deploy can still meet a 503.
|
||
|
||
Still open on hold'em: the policy is **heads-up**, so a six-handed table is an
|
||
approximation of it (the hit rate falls from 95% to about 17% at six seats, and
|
||
the rest is pot odds) — a multiway policy would want its own training run with
|
||
more than two seats in the tree. Blackjack's split is **done**.
|
||
|
||
### 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** | ~1,400 LOC | the shell + **the whole CFR trainer** | See the warning below. |
|
||
| **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 the poker, not the brain
|
||
|
||
> **This section was wrong, and it cost most of Phase 4. Read §0's hold'em entry before
|
||
> you believe any of it.** `data/policy.gob` is **not** "the single highest-value asset in
|
||
> either repo". It is a 10-big-blind push-fold policy, trained against a model of poker
|
||
> that is not poker (a call always ends the street), and *it was never once read* — the
|
||
> trainer wrote its keys under `IP`/`OOP` and the runtime looked them up under
|
||
> `BTN`/`SB`/`BB`, so every lookup in the history of the game missed and fell through to a
|
||
> pot-odds heuristic. Nothing about that is visible from the outside: a policy miss is not
|
||
> an error. **Retrain against your own engine.** Pete now does — `internal/games/holdem/
|
||
> train.go` plays every move through the real reducer, and both the trainer and the table
|
||
> build the info-set key with the same function so they cannot drift apart again.
|
||
|
||
Already mautrix-free, verified by import check:
|
||
|
||
- `holdem_cfr.go` (1,285) — CFR trainer + NPC policy runtime, info-set packing into a
|
||
`uint64`, regret pruning, board-texture/SPR/equity bucketing. The *bucketing* is worth
|
||
taking (equity, SPR, board texture). The trainer, the tree and the trained policy are
|
||
not: see the warning above.
|
||
- `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.
|