Skip to content

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:

  1. 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 transitions seated → sitting_out.
  2. A rebuy window (per-table bustRebuyWindowSeconds, zod 5–600, default 60). A bust_rebuy_window { seatIndex, deadline } broadcast drives the seat owner's countdown; the deadline also rides game_state so a reconnect / spectator renders it.
  3. One of two outcomes. A credited deposit inside the window re-seats the player; no rebuy frees the seat (row reaches gone, player_left broadcasts).

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_balance and 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_players sum 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 makes delta = 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 ephemeral reserved state, 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 → reserved is 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_intents row 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 a gone player and be refused.
  • The expiry is a LOCAL 0-chip stand-up — no bank round-trip. The bank 400s nothing_to_withdraw on a 0 balance by design, so a round-trip only adds a failure mode. Defense in depth: a > 0 balance 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, the expireBustRebuyWindow expiry policy, boot re-arm, applyBustRebuyCredit)
  • bust-rebuy-window.ts — the rebuy-WINDOW primitive (window state + timers + broadcast + arm/clear/query). arm takes an onExpire callback (generic over the room type) so the window carries no bust/short-stack policy — the disposition passes its expireBustRebuyWindow. Extracted from bust-disposition.ts by 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, …}.