diff --git a/internal/storage/games.go b/internal/storage/games.go index b3a1621..ec0018a 100644 --- a/internal/storage/games.go +++ b/internal/storage/games.go @@ -398,12 +398,38 @@ func Stake(user string, amount int64) error { // Award returns chips to a player when a hand settles: stake plus winnings, net // of rake, exactly as the engine computed it. A losing hand awards nothing and // should not call this. +// +// This is the standalone form, for a caller with no transaction of its own. A +// settle must not use it — see award, and the warning on CommitHand. func Award(user string, amount int64) error { if amount <= 0 { return nil } - now := nowUnix() - if _, err := Get().Exec( + tx, err := Get().Begin() + if err != nil { + return fmt.Errorf("games: begin award: %w", err) + } + defer tx.Rollback() //nolint:errcheck // no-op once committed + + if err := award(tx, user, amount, nowUnix()); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("games: commit award: %w", err) + } + return nil +} + +// award credits a stack inside an open transaction. +// +// It differs from addChips in one deliberate way: it moves last_played, because +// being paid is something that happens at a table and the reaper should see it. +// A buy-in is not — that is why addChips leaves the idle clock alone. +func award(tx *sql.Tx, user string, amount int64, now int64) error { + if amount <= 0 { + return nil + } + if _, err := tx.Exec( `INSERT INTO game_chips (matrix_user, chips, last_played, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(matrix_user) DO UPDATE SET @@ -431,11 +457,28 @@ type Hand struct { // with them, any hand in the log can be dealt again exactly as it fell, which is // how a dispute gets answered with a fact instead of an apology. func RecordHand(h Hand) error { - if _, err := Get().Exec( + tx, err := Get().Begin() + if err != nil { + return fmt.Errorf("games: begin record hand: %w", err) + } + defer tx.Rollback() //nolint:errcheck // no-op once committed + + if err := recordHand(tx, h, nowUnix()); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("games: commit record hand: %w", err) + } + return nil +} + +// recordHand writes the audit row inside an open transaction. +func recordHand(tx *sql.Tx, h Hand, now int64) error { + if _, err := tx.Exec( `INSERT INTO game_hands (matrix_user, game, bet, payout, rake, outcome, seed1, seed2, played_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, h.MatrixUser, h.Game, h.Bet, h.Payout, h.Rake, h.Outcome, - int64(h.Seed1), int64(h.Seed2), nowUnix(), + int64(h.Seed1), int64(h.Seed2), now, ); err != nil { return fmt.Errorf("games: record hand: %w", err) } @@ -588,6 +631,127 @@ func ClearLiveHand(user string) error { return nil } +// ---- the settle ------------------------------------------------------------ + +// Commit is one write-back of a game: the state, and — if the game is over — +// everything settling it takes. +type Commit struct { + Live LiveHand + Fresh bool // a game just started, which is the one write that may be refused + + // Stake is what the player put up to open this game. It is refunded, in this + // same transaction, if the seat turns out to be taken. Only meaningful with + // Fresh. + Stake int64 + + Done bool + Payout int64 // stake plus winnings, net of rake. Zero on a loss. + Audit Hand // the audit row. Ignored unless Done. +} + +// CommitHand writes a game back and settles it if it is over — all of it in one +// transaction. +// +// It used to be four separate autocommit statements (save, award, record, +// clear), which was survivable while a game belonged to exactly one player: the +// ordering paid first and cleared second, so a crash in between left a settled +// game on the felt, which reads as done and can be cleared. It does not survive +// a game with a pot in it. Pay the winner, die before the state write, and the +// table still says the hand is live — so it settles a second time and the winner +// is paid twice. Chips minted from nothing, and gogobee will happily turn them +// into euros. +// +// So: one Begin, one Commit, and the money and the state move together or not at +// all. +// +// The rule this enforces, and the reason award/recordHand exist in tx-taking +// form at all: **nothing inside here may call Get().Exec**. The pool runs at +// MaxOpenConns(1), so a bare Exec inside an open transaction waits for the one +// connection that this transaction is holding — forever. It is not an error, it +// is a hung process, and since the news app shares the pool it takes that down +// too. The tx-taking helper is the pattern; addChips has done it this way since +// the escrow ledger was written. +func CommitHand(user string, c Commit) error { + now := nowUnix() + + tx, err := Get().Begin() + if err != nil { + return fmt.Errorf("games: begin commit: %w", err) + } + defer tx.Rollback() //nolint:errcheck // no-op once committed + + // Seat the game first, even one that is already over — a blackjack natural + // settles the instant it is dealt. The INSERT is what enforces one game at a + // time, and it has to happen for *every* new one, or a natural dealt on top of + // a game already in progress would settle, clear the felt, and take the other + // game's stake with it. + if c.Fresh { + res, err := tx.Exec( + `INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(matrix_user) DO NOTHING`, + user, c.Live.Game, string(c.Live.State), int64(c.Live.Seed1), int64(c.Live.Seed2), now, + ) + if err != nil { + return fmt.Errorf("games: start live hand: %w", err) + } + if n, _ := res.RowsAffected(); n == 0 { + // Somebody is already sitting here. This game was never seated, so the + // chips it staked go back — and they go back *in this transaction*, which + // is the point. As two statements, a crash between the refusal and the + // refund took the player's stake for a game that never existed anywhere. + if err := award(tx, user, c.Stake, now); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("games: commit refund: %w", err) + } + return ErrHandInProgress + } + } else if _, err := tx.Exec( + `INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(matrix_user) DO UPDATE SET + game = excluded.game, state = excluded.state, + seed1 = excluded.seed1, seed2 = excluded.seed2, updated_at = excluded.updated_at`, + user, c.Live.Game, string(c.Live.State), int64(c.Live.Seed1), int64(c.Live.Seed2), now, + ); err != nil { + return fmt.Errorf("games: save live hand: %w", err) + } + + if c.Done { + if err := award(tx, user, c.Payout, now); err != nil { + return err + } + // The audit row is now inside the transaction with the payout, which means a + // failure to write it rolls the payout back rather than paying quietly and + // logging. That is a deliberate change: the two are the same fact, and a + // payout nobody can account for is worse than a payout that didn't happen — + // the game stays live and settles again on the next request. + if err := recordHand(tx, c.Audit, now); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM game_live_hands WHERE matrix_user = ?`, user); err != nil { + return fmt.Errorf("games: clear live hand: %w", err) + } + } + + // Touch, folded in: a deliberate action at a table, so the reaper leaves them + // alone. A player with no chip row yet has nothing to touch, and this is a + // no-op for them. + if _, err := tx.Exec( + `UPDATE game_chips SET last_played = ?, updated_at = ? WHERE matrix_user = ?`, + now, now, user, + ); err != nil { + return fmt.Errorf("games: touch session: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("games: commit hand: %w", err) + } + return nil +} + // HouseTake is the total rake collected since a given time — the number that // answers "is this economy inflating". func HouseTake(since int64) (int64, error) { diff --git a/internal/storage/settle_test.go b/internal/storage/settle_test.go new file mode 100644 index 0000000..97ff89f --- /dev/null +++ b/internal/storage/settle_test.go @@ -0,0 +1,193 @@ +package storage + +import ( + "errors" + "testing" + "time" +) + +// The settle used to be four autocommit statements in the web layer — save the +// state, award the payout, write the audit row, clear the felt — sequenced so +// that a crash between any two of them cost the player as little as possible. +// +// These are the tests for the thing that replaced it. They are less about the +// happy path (the game tests already cover that: a hand pays what it says it +// pays) and more about the two properties the old shape did not have, and which +// a shared table with a pot in it cannot do without. + +func liveBlob(game string) LiveHand { + return LiveHand{Game: game, State: []byte(`{"phase":"player"}`), Seed1: 7, Seed2: 9} +} + +// A settled hand moves the money, writes the audit row, and leaves the felt +// empty — and it does all three or none of them. +// +// The old code logged and carried on if the clear failed. That is the double-pay: +// a settled hand still sitting in game_live_hands is a hand that settles again on +// the next request, and pays again with it. +func TestSettleMovesTheMoneyAndTheFeltTogether(t *testing.T) { + setupTestDB(t) + fund(t, player, 1000) + + if err := Stake(player, 200); err != nil { + t.Fatal(err) + } + if err := CommitHand(player, Commit{ + Live: liveBlob("blackjack"), Fresh: true, Stake: 200, + Done: true, Payout: 390, + Audit: Hand{MatrixUser: player, Game: "blackjack", Bet: 200, Payout: 390, Rake: 10, Outcome: "won"}, + }); err != nil { + t.Fatal(err) + } + + // Paid: 1000 - 200 staked + 390 back. + if c := chipsOf(t, player); c != 1190 { + t.Fatalf("chips = %d after a 390 payout on a 200 stake, want 1190", c) + } + // The felt is empty, which is what stops it settling a second time. + if _, err := LoadLiveHand(player); !errors.Is(err, ErrNoLiveHand) { + t.Fatalf("live hand after settle: err = %v, want ErrNoLiveHand", err) + } + // And the house took its cut, once. + take, err := HouseTake(0) + if err != nil { + t.Fatal(err) + } + if take != 10 { + t.Fatalf("house take = %d, want 10", take) + } +} + +// A seat that is already taken refuses the game *and hands the stake back*, in +// the same transaction that refused it. +// +// This was two statements: the save came back ErrHandInProgress, and then a +// separate Award put the chips back. A crash in between took a player's stake for +// a game that never existed anywhere — no felt, no audit row, no way to find it. +func TestARefusedSeatGivesTheStakeBackInTheSameBreath(t *testing.T) { + setupTestDB(t) + fund(t, player, 1000) + + // A game is already in progress. + if err := Stake(player, 200); err != nil { + t.Fatal(err) + } + if err := CommitHand(player, Commit{Live: liveBlob("uno"), Fresh: true, Stake: 200}); err != nil { + t.Fatal(err) + } + if c := chipsOf(t, player); c != 800 { + t.Fatalf("chips = %d with 200 staked on a live game, want 800", c) + } + + // A second game is dealt on top of it: the stake leaves, as it must, in the + // same statement that checks it's there. + if err := Stake(player, 300); err != nil { + t.Fatal(err) + } + err := CommitHand(player, Commit{ + Live: liveBlob("blackjack"), Fresh: true, Stake: 300, + Done: true, Payout: 600, // a natural, which would settle the instant it's dealt + Audit: Hand{MatrixUser: player, Game: "blackjack", Bet: 300, Payout: 600}, + }) + if !errors.Is(err, ErrHandInProgress) { + t.Fatalf("err = %v, want ErrHandInProgress", err) + } + + // The 300 is back, and the natural was *not* paid — a game that was never + // seated must not settle. + if c := chipsOf(t, player); c != 800 { + t.Fatalf("chips = %d after a refused deal, want 800 (the 300 refunded, the 600 never paid)", c) + } + // And the game already in progress is untouched. This is the bit that matters: + // a natural dealt on top of a live game used to be able to settle, clear the + // felt, and take the other game's stake down with it. + live, err := LoadLiveHand(player) + if err != nil { + t.Fatalf("the live game is gone: %v", err) + } + if live.Game != "uno" { + t.Fatalf("live game = %q, want the uno game still sitting there", live.Game) + } + // Nothing was recorded, because nothing finished. + if take, err := HouseTake(0); err != nil || take != 0 { + t.Fatalf("house take = %d (err %v), want 0 — no hand finished", take, err) + } +} + +// The deadlock canary. +// +// SQLite runs at MaxOpenConns(1), so the connection is a global mutex. A bare +// Get().Exec inside an open transaction waits for the one connection that the +// transaction is holding, and waits forever — it is not an error, it is a hung +// process, and because the news app shares the pool it hangs that too. +// +// So: if anybody ever reaches for Award or RecordHand (rather than award/ +// recordHand) from inside CommitHand, this test stops returning. It does not +// fail with a nice message; it hangs, and the timeout is the message. That is +// exactly the failure mode in production, which is the point of catching it here. +func TestTheSettleDoesNotDeadlockAgainstItsOwnConnection(t *testing.T) { + setupTestDB(t) + fund(t, player, 1000) + if err := Stake(player, 100); err != nil { + t.Fatal(err) + } + + done := make(chan error, 1) + go func() { + done <- CommitHand(player, Commit{ + Live: liveBlob("hangman"), Fresh: true, Stake: 100, + Done: true, Payout: 234, + Audit: Hand{MatrixUser: player, Game: "hangman", Bet: 100, Payout: 234, Rake: 12, Outcome: "won"}, + }) + }() + + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("CommitHand did not return: something inside the transaction is waiting " + + "for the connection the transaction is holding. Look for a Get().Exec that " + + "should be a tx.Exec.") + } + + if c := chipsOf(t, player); c != 1134 { + t.Fatalf("chips = %d, want 1134", c) + } +} + +// Being paid moves the idle clock, so the reaper leaves a player who is +// mid-session alone. Touch used to be a separate statement after the settle; it +// is inside it now, and this is what would notice if it got dropped on the way. +func TestSettlingKeepsTheReaperAway(t *testing.T) { + setupTestDB(t) + fund(t, player, 1000) + + // Backdate the session well past the reaper's patience. + if _, err := Get().Exec( + `UPDATE game_chips SET last_played = ? WHERE matrix_user = ?`, + nowUnix()-int64((2 * SessionIdleAfter).Seconds()), player, + ); err != nil { + t.Fatal(err) + } + + if err := Stake(player, 100); err != nil { + t.Fatal(err) + } + if err := CommitHand(player, Commit{ + Live: liveBlob("trivia"), Fresh: true, Stake: 100, + Done: true, Payout: 150, + Audit: Hand{MatrixUser: player, Game: "trivia", Bet: 100, Payout: 150, Outcome: "won"}, + }); err != nil { + t.Fatal(err) + } + + stacks, _, err := IdleStacks(SessionIdleAfter) + if err != nil { + t.Fatal(err) + } + if len(stacks) != 0 { + t.Fatalf("the reaper found %d idle stacks; a player who just settled a hand is not idle", len(stacks)) + } +} diff --git a/internal/web/games_play.go b/internal/web/games_play.go index 82723bb..fdc1284 100644 --- a/internal/web/games_play.go +++ b/internal/web/games_play.go @@ -593,58 +593,44 @@ type finished struct { } // commit writes a game back and settles it if it's over. It is the money path, -// and both games go through it so that neither has to re-derive an ordering -// that took a while to get right. +// and every game goes through it so that none has to re-derive an ordering that +// took a while to get right. +// +// The ordering now lives in storage.CommitHand, which does the whole thing — +// seat, pay, record, clear, touch — in one transaction. It used to be four +// autocommit statements here, carefully sequenced so that a crash between them +// cost the player as little as possible. That was survivable for a game owned by +// one player. It is not survivable for a game with a pot in it, 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 them twice. // // It returns the table as it now stands. ok is false when it has already // written an error response and the caller must simply return. func (s *Server) commit(w http.ResponseWriter, user string, f finished) (tableView, bool) { - // Seat the game before doing anything else with it — even one that is already - // over, because a blackjack natural settles the instant it's dealt. The insert - // is what enforces one game at a time, and it has to happen for *every* new - // one: a natural dealt on top of a game already in progress would otherwise - // settle, clear the felt, and take the other game's stake down with it. - live := storage.LiveHand{Game: f.Game, State: f.Blob, Seed1: f.Seed1, Seed2: f.Seed2} - save := storage.SaveLiveHand - if f.Fresh { - save = storage.StartLiveHand - } - if err := save(user, live); err != nil { - if errors.Is(err, storage.ErrHandInProgress) { - // Somebody was already sitting here. This game was never seated, so the - // chips it staked go back: the player is in one game, not two. - _ = storage.Award(user, f.Bet) - writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're already in a game"}) - return tableView{}, false - } - slog.Error("games: save game", "user", user, "game", f.Game, "err", err) - http.Error(w, "internal error", http.StatusInternalServerError) - return tableView{}, false - } - - if f.Done { - // Pay first, then clear. If Pete dies between the two, the player has been - // paid and the worst case is a settled game still showing on the felt — - // which reads as done and can be cleared. The other order loses them a win. - if err := storage.Award(user, f.Payout); err != nil { - slog.Error("games: award", "user", user, "payout", f.Payout, "err", err) - http.Error(w, "internal error", http.StatusInternalServerError) - return tableView{}, false - } - if err := storage.RecordHand(storage.Hand{ + err := storage.CommitHand(user, storage.Commit{ + Live: storage.LiveHand{Game: f.Game, State: f.Blob, Seed1: f.Seed1, Seed2: f.Seed2}, + Fresh: f.Fresh, + Stake: f.Bet, + Done: f.Done, + Payout: f.Payout, + Audit: storage.Hand{ MatrixUser: user, Game: f.Game, Bet: f.Bet, Payout: f.Payout, Rake: f.Rake, Outcome: f.Outcome, Seed1: f.Seed1, Seed2: f.Seed2, - }); err != nil { - slog.Error("games: record hand", "user", user, "err", err) // audit only; don't fail the player's game - } - if err := storage.ClearLiveHand(user); err != nil { - slog.Error("games: clear game", "user", user, "err", err) - } + }, + }) + switch { + case errors.Is(err, storage.ErrHandInProgress): + // Somebody was already sitting here. The game was never seated and the chips + // it staked have gone back — in the same transaction that refused it. + writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're already in a game"}) + return tableView{}, false + case err != nil: + slog.Error("games: commit", "user", user, "game", f.Game, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return tableView{}, false } - storage.Touch(user) - v, err := s.table(user) if err != nil { slog.Error("games: table", "user", user, "err", err) diff --git a/pete_games_plan.md b/pete_games_plan.md index 2ded892..67b57db 100644 --- a/pete_games_plan.md +++ b/pete_games_plan.md @@ -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