Appearance
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:
| # | Flavor | Mechanism | Money outcome | Caught by conservation? |
|---|---|---|---|---|
| D1 | Folded winner | Pot eligibility stamped at collection; a later-street folder kept stale eligibility in earlier pots and their mucked hand still evaluated | Payout hit _players[seat]?.addToStack — the folded seat is null → chips DESTROYED (reproducer hand 21: 5,517 of 6,185 vanish) | Yes |
| D2 | All-in stripped | The 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 array | The all-in loses eligibility for the pot they funded → the pot pays the wrong live player — MISDIRECTED | No — perfectly conserved |
| D3 | No cross-street layering | Later-street bets piled into the LAST pot and replaced its eligibility instead of closing the all-in-capped pot and opening a new one | Enables D2 | Partially |
| D4 | Folded dead money lumped below its level | The 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 them | A 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/:
- 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._playersnow means dealt-in minus folds for the whole hand (all-ins stay); the per-street actor list is a separate_roundPlayersprojection (!folded && stack > 0), preserving the engine's single-active-player fast-play semantics exactly. - 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. - Showdown contestants = stamped eligibility ∩ (dealt-in ∧ ¬folded) — dead hands never evaluate; live all-ins always contest exactly what they covered.
- 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 typedShowdownIntegrityError— 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. - Restore identity (discovered pre-existing bug).
Engine.fromJson,Dealer.fromJsonandBettingRound.fromJsoneach 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_playersIS the engine's hand-players array); pre-fix snapshots derive folds viastack() > 0(a stack-0 player was all-in — they are never asked to act, so they cannot have folded).Dealer.fromJsonalso 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).