Skip to content

Engine Showdown Soundness (folded-winner fix)

Date: 2026-07-23 · Status: shipped · Plan: engine-showdown-folded-winner

The defect

A randomized conservation soak (2×2,500 seeded hands through the bare engine) found that showdown() could name a folded player the winner of a pot — and then destroy the chips. Bisect-proven pre-existing (unrelated to the felt-sweep uncalled-return work). The planning investigation verified a three-flavor taxonomy:

#FlavorMechanismMoney outcomeCaught by conservation?
D1Folded winnerPot eligibility stamped at collection; a later-street folder kept stale eligibility in earlier pots and their mucked hand still evaluatedPayout hit _players[seat]?.addToStack — the folded seat is null → chips DESTROYED (reproducer hand 21: 5,517 of 6,185 vanish)Yes
D2All-in strippedThe next-street branch replaced _players with round actives (all-ins nulled, indistinguishable from folds); every later collection rewrote the last pot's eligibility from that arrayThe all-in loses eligibility for the pot they funded → the pot pays the wrong live player — MISDIRECTEDNo — perfectly conserved
D3No cross-street layeringLater-street bets piled into the LAST pot and replaced its eligibility instead of closing the all-in-capped pot and opening a new oneEnables D2Partially
D4Folded dead money lumped below its levelThe old min(aggregate, n×minBet) allocation dumped a folder's ENTIRE bet into the pot being collected — chips above an all-in cap belong only to the deeper pots that covered themA short all-in could win a slice of the folder's money that only deeper stacks covered — misdirected between live winners (found by the plan's own falsification ritual)NO — conserved AND membership-clean; only per-level AMOUNT assertions see it

Because the folder was never actually paid, there was no visible theft — the chips vanished, presenting downstream as unexplained chip divergence, the same signature the freeze/escape/custody machinery kept tripping on and attributing to other causes. Three prior downstream patches (foldedSeatsThisHand, the ohh_show_cards_folded observatory rule, divergence freezes) each treated a symptom of this one engine root.

The fix (a pot-eligibility subsystem, not a filter)

All in packages/game-logic/src/poker/:

  1. A first-class fold record. Dealer._folded[] (serialized) — a null seat alone can never distinguish a fold from an all-in, and that ambiguity caused both flavors. _players now means dealt-in minus folds for the whole hand (all-ins stay); the per-street actor list is a separate _roundPlayers projection (!folded && stack > 0), preserving the engine's single-active-player fast-play semantics exactly.
  2. Eligibility is maintenance, not rewrite. A fold sweeps every pot's eligibility (Pot.removeFolded); nothing ever rebuilds eligibility from a seat array. An all-in-capped pot closes (Pot.closed, serialized); later-street bets open a NEW pot the all-in has no claim on — proper cross-street side pots.
  3. Showdown contestants = stamped eligibility ∩ (dealt-in ∧ ¬folded) — dead hands never evaluate; live all-ins always contest exactly what they covered.
  4. Payment through stable references, loudly. Winners are paid via _handPlayers (dealt-in Player references, never reassigned). Every pot is validated before any pot pays; an unpayable pot throws a typed ShowdownIntegrityError — and the hand flips to "over" only after validation, so a corrupt showdown leaves the hand in progress and the felt-liveness watchdog freezes the table loudly. The old failure mode (a silent ?. no-op) is structurally impossible.
  5. Restore identity (discovered pre-existing bug). Engine.fromJson, Dealer.fromJson and BettingRound.fromJson each deserialized their own Player copies — a restored mid-hand engine threw on the first continued betting action (bets landed on one copy set; collection read another). The serialization suite only ever asserted structural round-trip equality, never played a restored hand forward. Restore now wires ONE object set (the dealer's _players IS the engine's hand-players array); pre-fix snapshots derive folds via stack() > 0 (a stack-0 player was all-in — they are never asked to act, so they cannot have folded). Dealer.fromJson also never initialized _uncalledReturns (Object.create bypasses field initializers) — fixed.

The two-property discipline (the durable lesson)

Conservation catches destruction; winner-membership catches misdirection. Neither alone is sufficient. D2 pays the wrong player while conserving perfectly — the soak that found D1 was provably blind to it. The permanent suite (src/poker/__tests__/conservation-soak.test.ts + _soak-driver.ts) asserts both per hand across 2×2,500 seeded hands (rake off/on):

  • A5 conservation: Σ totalChips(after) + rake == Σ buy-ins(before).
  • A13 membership: every winner was dealt in, never folded, and contributed; every never-folded contributor stays eligible for the main pot.

Targeted rigged-deck anchors (showdown-folded-winner.test.ts, allin-eligibility.test.ts, resume-identity.test.ts) pin the reproducer hand 21, folds on each street, the check-it-down, cross-street layering, ties/odd-chips, fold-win uncalled returns, the loud-error guard, and mid-hand checkpoint identity.

Downstream

buildHandResult, the OHH writer (setPots/player_wins), and getRoundResult all consume engine.winners() verbatim — the corrected selection flows through with no re-filtering. The display-level folded-exclusion patches (PR #37 foldedSeatsThisHand, ohh_show_cards_folded) stay as defense-in-depth.

Evidence pass (U1)

Staging held 250 chip_reconciliation_delta anomaly envelopes (216 negative-delta). Zero matched the D1 signature — none had a real showdown (≥2 Show Cards); all were fold-win recording artifacts (OHH under-records fast-path fold-wins in some shapes — a separate pre-existing recording issue, filed as a finding). No real hand is known to have destroyed chips via the folded-winner path; the launch surface (shove-heavy agent tables) is exactly where it would have started.

The falsification lesson: each property class is blind to the next flavor

  • Conservation (the soak) catches destruction — chips vanishing.
  • Winner-membership catches who-misdirection — a pot paid outside its live contestants.
  • Per-level amounts catch how-much-misdirection — the right players paid the wrong splits (D4).

Each new property was added because the previous one was provably blind to the flavor it catches. The durable rule: when a fund-path invariant goes green, ask what failure shape it CANNOT see — that shape is where the next bug lives. Fixed: folded bets are tracked PER FOLD (PotManager._foldedBets) and consumed min(remaining, level) per pot, so dead money is winnable only at the levels it was bet (folded-dead-money-levels.test.ts).