Appearance
Busted Player Disposition
Status: accepted + shipped (2026-07-14) · Full ADR archived at .indusk/planning/archive/busted-player-disposition/adr.md
Decision
A cash player whose stack reaches 0 at a hand's end gets a three-step disposition, uniform for humans and hosted agents:
- True-0 at the boundary. Busts are detected from the committed hand-end payload (
endStack === 0), never the live engine seats — the engine NULLs a busted seat at showdown, so an engine-seat scan misses it. The busted seat's balance is zeroed in the PM before the chip-balance checkpoint reads it, then the seat transitionsseated → sitting_out. - A rebuy window (per-table
bustRebuyWindowSeconds, zod 5–600, default 60). Abust_rebuy_window { seatIndex, deadline }broadcast drives the seat owner's countdown; the deadline also ridesgame_stateso a reconnect / spectator renders it. - One of two outcomes. A credited deposit inside the window re-seats the player; no rebuy frees the seat (row reaches
gone,player_leftbroadcasts).
See the current-state behavior in table-lifecycle.
Load-bearing rules
- The true-0 write is a HARD prerequisite of any system-initiated leave. The bank derives a forced withdrawal's amount from the seat row's
chip_balanceand only short-circuits at<= 0. Against the stale pre-hand balance, a forced leave would have paid out chips the player no longer held. Never ship the disposition without the true-0 write. - Stale balances break conservation, not just the table. The pre-fix bug wasn't merely a wedge: the stale busted balance made
table_playerssum diverge from the on-chain pool (293 vs 200), tripping the custody invariant → freeze → auto-escape of the whole table, evicting the winner too. True-0 makesdelta = 0. - The rebuy is admitted from PERSISTED state, never an in-process timer. A persisted
sitting_out+ 0-chip + held-seat row is uniquely a bust (a fresh sit-down is the ephemeralreservedstate, never persisted; a voluntary sit-out keeps its chips). Gating on an armed timer stranded rebuys whenever the reconstruction re-arm failed or was raced. sitting_out → reservedis an INVALID transition, so the credit-chips self-heal (reserve-re-establishing) path structurally cannot serve a busted rebuy — the explicit rebuy branch is required, not a preference.- The expiry never frees a seat while money is in flight. A non-terminal
deposit_intentsrow for that player defers the window (mirroring the idle reaper's in-flight exclusion); a failed read defers too. Freeing the seat mid-deposit would strand the funds — the credit would land on agoneplayer and be refused. - The expiry is a LOCAL 0-chip stand-up — no bank round-trip. The bank 400s
nothing_to_withdrawon a 0 balance by design, so a round-trip only adds a failure mode. Defense in depth: a> 0balance at expiry routes through the normal forced withdraw rather than discarding funds locally.
Tradeoffs accepted
- Two bust-detection sites — cash reads the boundary payload; tournaments keep the orchestrator's elimination path (they carry CTN equity, not engine chips). Unified only if a third consumer appears.
- A recomputed deadline, not a persisted one. Reconstruction re-derives each open window from
last_checkpoint_at + bustRebuyWindowSeconds— no migration, no column to keep consistent. The deadline can shift by restart latency; a late expiry is harmless and an early one is impossible. - One more in-process timer family, made restart-safe by the reconstruction re-arm.
Rejected
Engine keeps 0-stack seats (inverts the layering — nulling is engine-internal invariant maintenance); immediate stand-up with no window (kills the rebuy the UI already affords); leaving the player seated at 0 (the phantom state the PM conventions exist to prevent); routing the 0-chip stand-up through the bank (a round-trip that exists only to be refused); riding the self-heal reserve path (reserves are ephemeral 90s-TTL handshake state, the wrong primitive for a window the player already owns); a persisted deadline column.
Where it lives
Three poker sibling modules, following the copilot-time-bank / copilot-autopilot idiom (room-first exports, per-room state in a WeakMap, a narrow structural room interface reached via the table's public do* wrappers):
bust-detection.ts— PURE (bustsFromHandRows,isCashBustRebuy,CashBust)bust-disposition.ts— the DISPOSITION (detect+zero, theexpireBustRebuyWindowexpiry policy, boot re-arm,applyBustRebuyCredit)bust-rebuy-window.ts— the rebuy-WINDOW primitive (window state + timers + broadcast +arm/clear/query).armtakes anonExpirecallback (generic over the room type) so the window carries no bust/short-stack policy — the disposition passes itsexpireBustRebuyWindow. Extracted frombust-disposition.tsby felt-liveness-contract Phase 9 (single-responsibility; the window is a reusable primitive distinct from the disposition that consumes it).
table.ts / flow.ts / credit-chips.ts hold only thin call-throughs. Telemetry: poker.bust_disposition with disposition.outcome ∈ {sat_out, rebuy, stood_up, rearmed_at_boot, deferred_deposit_in_flight, …}.