games: the payout that survives the crash, and the note that lied twice

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
This commit is contained in:
prosolis
2026-07-14 15:28:54 -07:00
parent a5b7e41929
commit 1f1a6cb6e8
4 changed files with 577 additions and 63 deletions

View File

@@ -14,9 +14,25 @@ A multi-session build. This section is the handover; read it before anything els
### Start here (next session)
**The whole casino is live.** *(2026-07-14.)* All six games (seven, counting the
No Mercy dial) are deployed and playing on https://games.parodia.dev. Nothing is
queued — what is left is the open list at the bottom of "Next, in order".
**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
@@ -110,12 +126,165 @@ restart would otherwise be a player whose cards vanished.
### Deployed, as of right now
The live box (`/opt/pete`) is on **`03524ae`**, and main is three commits past it.
Undeployed: the **UNO call-uno + live hand redraw** fix (`39ed293`), the **front
door and share card** (`7ca1f7a`), and **blackjack's split** (`6f34a89`). The UNO
one is a bug that is live right now: your own hand doesn't redraw during a lap.
The user has seen this list and chose to hold the deploy for now — *ask before
assuming it went out.*
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)
@@ -648,13 +817,15 @@ assuming it went out.*
### Next, in order
1. **A deploy is owed.** Three commits are on main and not on the box (see
"Deployed, as of right now" above), one of them a live UNO bug. Everything is
built and browser-verified; nothing is half-done.
2. Then the open list, none of it blocking, none of it promised: **hold'em has no
multiway policy**; 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.
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