games: the felt other people can sit at, and the version that settles the race

Phase B foundation for the multiplayer casino: the shared-table storage layer,
the SSE fan-out, and the lock that only ever pretends to be the authority.

- game_tables/game_seats/game_chat, plus a nullable table_id on game_live_hands
  so occupancy stays one row per player — the same primary key that stops a
  second solo hand stops a second seat. No second uniqueness domain, no split
  brain, no cash-out-to-zero while sitting on a pot.
- The money model the plan sketched turned out simpler than it drew: chips cross
  the border only at sit-down and get-up, so a hand settles by moving the pot
  *within* the state blob and credits nobody. That deletes the payout ledger
  the design called for — there is no money write to make idempotent, only a
  state write conditional on the version. A replayed settle affects zero rows.
- CommitTable/SitDown/LeaveTable each one transaction with the state write in it;
  the version column is the concurrency authority and the striped in-memory lock
  is only an optimisation over it, because a mutex does not survive a redeploy.
- The SSE hub is a dumb byte fan-out: non-blocking sends (a stalled phone must
  not hold the table lock and freeze the clock for the room) and never a DB
  touch after the first read (holding the one connection open bricks the app).
- DueTables/PushDeadlines for the turn clock to come; Chat keeps the hand_no it
  was said during, because at a money table collusion looks like chat.

Storage and hub tested, including the version race and the never-block publish.
No handlers wired yet, so nothing a player can see has changed.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
This commit is contained in:
prosolis
2026-07-14 15:43:39 -07:00
parent 1f1a6cb6e8
commit 4b3e5fe4c5
9 changed files with 1339 additions and 4 deletions

View File

@@ -239,9 +239,103 @@ CREATE TABLE IF NOT EXISTS game_live_hands (
state TEXT NOT NULL, -- JSON: the engine's State
seed1 INTEGER NOT NULL, -- carried to the audit log when it settles
seed2 INTEGER NOT NULL,
-- Set when the player is sitting at a shared table rather than playing alone.
-- The engine state then lives in game_tables.state, not here, and this row is
-- purely the occupancy claim: its PRIMARY KEY is what stops one player being
-- in two games at once, and it is the row the cash-out check reads. Making
-- game_seats a second uniqueness domain instead would be a split brain — see
-- the comment on game_seats.
table_id TEXT,
updated_at INTEGER NOT NULL
);
-- ---------------------------------------------------------------------------
-- Shared tables: the casino with more than one person at it.
-- ---------------------------------------------------------------------------
-- A table other people can sit at. The state column is the engine's State,
-- exactly as game_live_hands holds it for a solo game — one blob for the whole
-- felt, because a pot is not divisible into per-player rows.
--
-- version is the concurrency authority, and the mutex in the web layer is only
-- an optimisation on top of it. Every state write is a conditional UPDATE
-- against the version the writer read; zero rows affected means somebody moved
-- first. This has to live in the database rather than in a mutex map because a
-- mutex does not survive a redeploy — during a drain, two processes hold two
-- different mutexes over the same row and both believe they are alone.
CREATE TABLE IF NOT EXISTS game_tables (
id TEXT PRIMARY KEY,
game TEXT NOT NULL, -- 'holdem' | 'uno' | 'blackjack'
tier TEXT NOT NULL, -- the stake, as that game names it
state TEXT NOT NULL, -- JSON: the engine's State
seed1 INTEGER NOT NULL,
seed2 INTEGER NOT NULL,
phase TEXT NOT NULL, -- the engine's phase, lifted out so the lobby can read it
hand_no INTEGER NOT NULL DEFAULT 0, -- with id, the identity of one hand: the payout key
version INTEGER NOT NULL DEFAULT 0,
-- Unix seconds by which the seat to act must act, or 0 for no clock. The turn
-- clock scans this. It is set only when the turn lands on a human: bots resolve
-- inside ApplyMove and are never waited for.
deadline INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_game_tables_due ON game_tables(deadline) WHERE deadline > 0;
CREATE INDEX IF NOT EXISTS idx_game_tables_lobby ON game_tables(game, updated_at DESC);
-- Who is sitting where. A seat with no matrix_user is a bot.
--
-- This is deliberately *not* a uniqueness domain for players: there is no unique
-- index on matrix_user, and there must not be one. Occupancy is decided by
-- game_live_hands' primary key, which already stops a player being in two games,
-- already makes a double-clicked join a 409, and is already what the cash-out
-- check reads. A second domain that could disagree with the first would silently
-- switch all three off — the worst of them being a player who cashes out to zero
-- while sitting at a poker table with chips in the pot.
--
-- staked is what the player brought to the table and has not yet taken home. It
-- is the chip-conservation anchor: the chips are off their game_chips stack and
-- inside the table blob, where the idle reaper cannot see them.
CREATE TABLE IF NOT EXISTS game_seats (
table_id TEXT NOT NULL,
seat INTEGER NOT NULL,
matrix_user TEXT, -- NULL for a bot
name TEXT NOT NULL,
staked INTEGER NOT NULL DEFAULT 0,
-- Set once a human's clock has run out on them. An absent human is not a bot,
-- but the bot loop has to be allowed past their seat or a table with three
-- ghosts spends a minute an orbit folding air. They come back the moment they act.
away INTEGER NOT NULL DEFAULT 0,
last_seen INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (table_id, seat)
);
CREATE INDEX IF NOT EXISTS idx_game_seats_user ON game_seats(matrix_user) WHERE matrix_user IS NOT NULL;
-- There is no payout ledger here, and its absence is deliberate — the design
-- called for one and the money model made it unnecessary. Chips cross into a
-- table when a player sits down and back out when they get up; a hand ending
-- moves the pot *within* the state blob and credits nobody's game_chips row. So
-- there is no money write to make idempotent: a settle is a state write,
-- conditional on the version, and a replayed one affects zero rows and rolls
-- back. See the header of internal/storage/tables.go.
-- Chat on the felt. Messages only — no typing indicators, which is the one thing
-- that would have justified a socket. It does not mirror into Matrix.
--
-- hand_no is kept against every line for a reason: at a table of real people,
-- collusion looks like chat, and the only way to ever answer that question is to
-- be able to read what was said during the hand it was said in.
CREATE TABLE IF NOT EXISTS game_chat (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_id TEXT NOT NULL,
hand_no INTEGER NOT NULL,
matrix_user TEXT, -- NULL when the house is talking
name TEXT NOT NULL,
body TEXT NOT NULL,
said_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_game_chat_table ON game_chat(table_id, id);
-- The trivia bank: questions pulled from the Open Trivia Database ahead of time,
-- so that asking one is a local read.
--