games: a blackjack table you can actually sit down at

The engine, the escrow and the wire were all in place; nothing had a browser on
the end of it. This is that end: a lobby, a table, and the five endpoints between
them.

The browser holds no game. It sends intents and gets back a view — the cards it
is entitled to see, and the script of how they arrived, one event per card off
the shoe. The dealer's hole card is not in the payload at all until the reveal,
because a field the client is told to ignore is a field somebody reads in
devtools. The shoe lives in game_live_hands, which also means a redeploy
mid-hand no longer costs a player their stake: the hand is still there when they
come back.

The money is ordered so nothing can be spent twice. The stake leaves the stack in
the same statement that checks it exists, before a card is dealt. Every new hand
is seated with a plain INSERT, so a double-clicked Deal is decided by the primary
key rather than by a read that raced — it loses, gets its chips back, and the
hand in progress is untouched. A double takes its raise up front and hands it
straight back if the engine refuses the move.

Cards are dealt rather than swapped in — they fly out of the shoe and turn over,
which was a requirement and not a flourish. The faces and the chips are still
plain; that's next.
This commit is contained in:
prosolis
2026-07-13 23:20:42 -07:00
parent cb84e1d549
commit c69fbb63db
17 changed files with 1949 additions and 18 deletions

View File

@@ -8,7 +8,7 @@ This plan reuses that seam wholesale and does not invent a second one.
---
## 0. Progress — last updated 2026-07-13
## 0. Progress — last updated 2026-07-14
A multi-session build. This section is the handover; read it before anything else.
@@ -58,17 +58,49 @@ A multi-session build. This section is the handover; read it before anything els
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.
### Next, in order
1. **Identity.** `PreferredUsername` into the OIDC claims struct and the signed cookie;
cookie `Domain: ".parodia.dev"` so a news session travels to games. Add the games
redirect URI to the `pete` app in Authentik.
2. **Frontend.** Host branching in the mux, lobby + blackjack table, animated dealing.
Nothing yet *opens* an escrow row from a browser — `RequestBuyIn`/`RequestCashOut`
have no HTTP surface, on purpose: they need a signed-in Matrix identity, which is
step 1.
1. **Make the table lively.** The mechanics are all there and the dealing animates, but
the presentation is thin: card faces are a rank and a small suit (no pips, no corner
indices), chips never physically move, and nothing celebrates. This is a *stated
requirement*, not polish — see the decisions above. Ideas already sketched: chips that
fly to a bet spot and back on a win, cards landing with weight, a dealer beat before
drawing out, a burst on a natural.
2. **Deploy.** Add the `games.parodia.dev` redirect URI to the `pete` app in Authentik,
point Caddy at the same port, set `[web.games]` + `web.auth.cookie_domain` on the
server. Nothing else is host-specific.
3. Then Phase 2 (trivia, hangman), 3 (UNO), 4 (hold'em) as below.
### How the browser half fits together
- `GET /games` (lobby), `GET /games/blackjack` (table) — signed-in only. On the games
host, the mux prefixes `/games` onto the path, so the lobby is that host's `/`. Shared
paths (`/api/`, `/auth/`, `/static/`) mean the same thing on every host and are left
alone.
- `GET /api/games/table`, `POST /api/games/{buyin,cashout}`,
`POST /api/games/blackjack/{deal,move}` — session-authed, JSON, all returning the same
`tableView` so the money and the felt can never disagree.
- **The browser never sees the shoe.** The dealer's hole card is *absent* from the
payload — not flagged hidden — until the reveal, and the deck lives only in
`game_live_hands`. The response carries the engine's events (one per card off the
shoe), which is what the table plays back as an animation.
- Money order-of-operations: stake leaves the stack *before* the hand is dealt, in the
same statement that checks it's there; the hand is *seated* (a plain INSERT on the
primary key) before it can settle, which is what makes a double-clicked Deal a 409 with
the stake refunded rather than a silently overwritten hand.
### Notes for whoever picks this up
- SQLite runs at `MaxOpenConns(1)` in *both* repos. Any `db.Get().Exec` inside an