pokertools-arena 0.17.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,7 @@
17
17
  | **Package** | `npx pokertools-arena` |
18
18
  | **Runtime** | Node.js ≥ 24 for tooling; any modern browser for the app |
19
19
 
20
- > **Current release: 0.17.0.** Seats on OpenRouter models that advertise reasoning efforts can now pick a per-seat reasoning effort, so a slow reasoner such as DeepSeek can be lowered from its `high` default to `low` instead of exhausting the action clock and being auto-folded.
20
+ > **Current release: 0.17.1.** A mid-hand all-in no longer aborts the tournament: the decision-context player count now tracks non-eliminated seats instead of stacks, and any context failure falls back to a deterministic safe action so the game continues.
21
21
 
22
22
  ---
23
23
 
package/dist/app.js CHANGED
@@ -114662,6 +114662,13 @@ function playerCards(player) {
114662
114662
  function playerStack(player) {
114663
114663
  return asNumber(player?.stack ?? player?.chips ?? 0);
114664
114664
  }
114665
+ function playersStillInTournament(players, eliminatedPlayerIds = []) {
114666
+ const eliminated = new Set(eliminatedPlayerIds ?? []);
114667
+ return (players ?? []).filter((player) => player?.id && !eliminated.has(player.id));
114668
+ }
114669
+ function playersWithChips(players) {
114670
+ return (players ?? []).filter((player) => player && playerStack(player) > 0);
114671
+ }
114665
114672
  function currentBet(state, seat) {
114666
114673
  return asNumber(mapGet(state?.currentBets, seat, state?.players?.[seat]?.bet ?? 0));
114667
114674
  }
@@ -114909,7 +114916,7 @@ function assertDecisionState(state) {
114909
114916
  }
114910
114917
  }
114911
114918
  const expectedRemaining = 1 + state.opponents.length;
114912
- if (Number(state.tournament?.playersRemaining) !== expectedRemaining) throw new Error(`playersRemaining mismatch: expected ${expectedRemaining}, got ${state.tournament?.playersRemaining}`);
114919
+ if (Number(state.tournament?.playersRemaining) !== expectedRemaining) throw new Error(`playersRemaining mismatch: expected ${expectedRemaining} (hero plus ${state.opponents.length} non-eliminated opponents), got ${state.tournament?.playersRemaining}`);
114913
114920
  return state;
114914
114921
  }
114915
114922
  function extractTextContent(message) {
@@ -116640,7 +116647,7 @@ var TournamentDirector = class {
116640
116647
  for (const player of this.config.players) {
116641
116648
  this.engine.sit(player.seat, player.id, player.name, this.config.startingStack);
116642
116649
  this.timeBanks[player.id] = this.config.timeBankSeconds * 1e3;
116643
- this.stats[player.id] = { decisions: 0, invalid: 0, modelErrors: 0, providerErrors: 0, rateLimits: 0, timeouts: 0, autoFallbacks: 0, protocolFallbacks: 0, retries: 0, totalLatencyMs: 0, lastAction: null, lastActionType: null, lastActionAmount: null, lastReason: "" };
116650
+ this.stats[player.id] = { decisions: 0, invalid: 0, modelErrors: 0, providerErrors: 0, rateLimits: 0, timeouts: 0, autoFallbacks: 0, contextErrors: 0, protocolFallbacks: 0, retries: 0, totalLatencyMs: 0, lastAction: null, lastActionType: null, lastActionAmount: null, lastReason: "" };
116644
116651
  }
116645
116652
  this.logEvent("TOURNAMENT_START", { config: this.publicConfig() });
116646
116653
  this.broadcast();
@@ -116684,8 +116691,15 @@ var TournamentDirector = class {
116684
116691
  while (this.status === "PAUSED") await new Promise((resolve) => this.pauseResolvers.push(resolve));
116685
116692
  if (this.status === "STOPPED") throw new Error("Tournament stopped");
116686
116693
  }
116694
+ // Decision-context/table count: seated players that are not eliminated. Must
116695
+ // equal `1 + non-eliminated opponents` in serializeForAgent, so it must NOT be
116696
+ // derived from stacks — an all-in player has stack 0 mid-hand and is still in.
116687
116697
  playersRemaining() {
116688
- return (this.engine?.state?.players ?? []).map((p, seat) => ({ p, seat })).filter(({ p }) => p && playerStack(p) > 0);
116698
+ return playersStillInTournament(this.engine?.state?.players, this.eliminations.map((e) => e.playerId));
116699
+ }
116700
+ // Tournament-end / place accounting: seats that can still contest chips.
116701
+ playersWithChips() {
116702
+ return playersWithChips(this.engine?.state?.players);
116689
116703
  }
116690
116704
  tournamentMeta() {
116691
116705
  const startingPlayers = this.config?.players.length ?? 0;
@@ -116752,9 +116766,9 @@ var TournamentDirector = class {
116752
116766
  async run() {
116753
116767
  while (["RUNNING", "PAUSED"].includes(this.status)) {
116754
116768
  await this.waitIfPaused();
116755
- const remaining = this.playersRemaining();
116769
+ const remaining = this.playersWithChips();
116756
116770
  if (remaining.length <= 1) {
116757
- this.finish(remaining[0]?.p ?? null);
116771
+ this.finish(remaining[0] ?? null);
116758
116772
  return;
116759
116773
  }
116760
116774
  if (this.handNumber > 0 && this.handNumber % this.config.handsPerLevel === 0) {
@@ -116765,10 +116779,18 @@ var TournamentDirector = class {
116765
116779
  this.logEvent("BLINDS_UP_FAILED", { error: summarizeError(err) });
116766
116780
  }
116767
116781
  }
116768
- await this.startHand();
116769
- await this.playHand();
116770
- this.completeHand();
116771
- this.broadcast();
116782
+ try {
116783
+ await this.startHand();
116784
+ await this.playHand();
116785
+ this.completeHand();
116786
+ this.broadcast();
116787
+ this.handErrorStreak = 0;
116788
+ } catch (err) {
116789
+ if (this.status === "STOPPED" || err?.name === "AbortError") throw err;
116790
+ await this.recoverHand(err);
116791
+ this.handErrorStreak = (this.handErrorStreak || 0) + 1;
116792
+ if (this.handErrorStreak >= 3) throw err;
116793
+ }
116772
116794
  if (this.budgetReached) {
116773
116795
  this.logEvent("DECISION_BUDGET_REACHED", { decisions: this.decisionCount, budget: this.decisionBudget });
116774
116796
  this.stop();
@@ -116778,6 +116800,52 @@ var TournamentDirector = class {
116778
116800
  await sleep(Math.max(WINNER_REVIEW_MS, this.config.betweenHandsMs));
116779
116801
  }
116780
116802
  }
116803
+ // Last-resort safety net for a hand. A model failure or a decision-context
116804
+ // failure is already handled inside the hand loop; anything else that escapes
116805
+ // (a future regression) would otherwise abort the whole tournament mid-game.
116806
+ // Here the hand is finished with deterministic safe actions so the tournament
116807
+ // continues. Recovery is bounded by the hand-action guard and by the caller's
116808
+ // streak counter, so a genuinely broken engine still surfaces as an error
116809
+ // instead of looping forever.
116810
+ async recoverHand(triggerError) {
116811
+ this.currentDecision = null;
116812
+ if (this.handComplete()) {
116813
+ this.broadcast();
116814
+ return;
116815
+ }
116816
+ this.logEvent("HAND_RECOVERY", { handNumber: this.handNumber, street: this.engine?.state?.street ?? null, error: summarizeError(triggerError) });
116817
+ this.broadcast();
116818
+ let guard = 0;
116819
+ while (!this.handComplete()) {
116820
+ if (++guard > 500) throw new Error("Hand recovery guard exceeded 500 actions");
116821
+ await this.waitIfPaused();
116822
+ const state = this.engine.state, seat = state.actionTo;
116823
+ if (seat == null) {
116824
+ if (Array.isArray(state.winners) && state.winners.length) break;
116825
+ await sleep(10);
116826
+ continue;
116827
+ }
116828
+ const player = state.players?.[seat];
116829
+ if (!player) break;
116830
+ if (state.street === "SHOWDOWN") {
116831
+ const show = { type: ACTION.SHOW, playerId: player.id, cardIndices: [0, 1] };
116832
+ if (this.engine.validate(show)?.valid) {
116833
+ this.engine.act(show);
116834
+ continue;
116835
+ }
116836
+ const muck = { type: ACTION.MUCK, playerId: player.id };
116837
+ if (this.engine.validate(muck)?.valid) {
116838
+ this.engine.act(muck);
116839
+ continue;
116840
+ }
116841
+ }
116842
+ if (!this.forceSafeAction(seat, { error: triggerError, errorCategory: "recovery" })) break;
116843
+ this.broadcast();
116844
+ }
116845
+ if (!this.handComplete()) throw triggerError;
116846
+ this.completeHand();
116847
+ this.broadcast();
116848
+ }
116781
116849
  pruneBustedSeats() {
116782
116850
  const failures = [];
116783
116851
  for (const p of this.engine?.state?.players ?? []) {
@@ -116858,22 +116926,31 @@ var TournamentDirector = class {
116858
116926
  }
116859
116927
  async takeDecision(agent, seat, legalActions) {
116860
116928
  const decisionId = id(`d-h${this.handNumber}-s${seat + 1}`), baseMs = this.config.actionSeconds * 1e3, bankBefore = this.timeBanks[agent.id] ?? 0, totalMs = baseMs + bankBefore;
116929
+ const stats = this.stats[agent.id];
116930
+ const connection = this.config.connections.find((c) => c.id === agent.connectionId);
116931
+ const architecture = this.config.decisionArchitecture === DECISION_ARCHITECTURES.FLAT ? "flat" : "hierarchical";
116861
116932
  const recentHands = buildPublicTournamentMemory(this.events, this.handNumber);
116862
116933
  const publicPlayerStats = buildPublicPlayerStats(this.events, this.config.players, this.handNumber);
116863
116934
  const actionHistory = buildCurrentHandPublicActions(this.events, this.handNumber);
116864
- const baseState2 = serializeForAgent(this.engine, seat, this.tournamentMeta(), legalActions, recentHands, publicPlayerStats, actionHistory);
116865
- const stateForAgent = assertDecisionState(applyBenchmarkMode(baseState2, this.config.benchmarkMode)), startedAt = Date.now();
116866
- const connection = this.config.connections.find((c) => c.id === agent.connectionId);
116867
- const architecture = this.config.decisionArchitecture === DECISION_ARCHITECTURES.FLAT ? "flat" : "hierarchical";
116868
116935
  const sizesForFamily = (family) => legalAggressiveSizes(this.engine, seat, family);
116869
- let hierarchy = null;
116870
- if (architecture === "hierarchical") {
116871
- hierarchy = buildHierarchicalDecision(stateForAgent, { legalActions: stateForAgent.legalActions });
116872
- if (hierarchy.aggressiveFamily) {
116873
- const engineSizes = sizesForFamily(hierarchy.aggressiveFamily);
116874
- hierarchy.stage2 = { ...hierarchy.stage2, sizes: engineSizes, criteria: sizeCriteria(engineSizes) };
116936
+ let stateForAgent = null, hierarchy = null;
116937
+ try {
116938
+ stateForAgent = assertDecisionState(applyBenchmarkMode(
116939
+ serializeForAgent(this.engine, seat, this.tournamentMeta(), legalActions, recentHands, publicPlayerStats, actionHistory),
116940
+ this.config.benchmarkMode
116941
+ ));
116942
+ if (architecture === "hierarchical") {
116943
+ hierarchy = buildHierarchicalDecision(stateForAgent, { legalActions: stateForAgent.legalActions });
116944
+ if (hierarchy.aggressiveFamily) {
116945
+ const engineSizes = sizesForFamily(hierarchy.aggressiveFamily);
116946
+ hierarchy.stage2 = { ...hierarchy.stage2, sizes: engineSizes, criteria: sizeCriteria(engineSizes) };
116947
+ }
116875
116948
  }
116949
+ } catch (contextError) {
116950
+ if (!this.forceSafeAction(seat, { error: contextError, errorCategory: "context", decisionId })) throw contextError;
116951
+ return;
116876
116952
  }
116953
+ const startedAt = Date.now();
116877
116954
  this.currentDecision = {
116878
116955
  id: decisionId,
116879
116956
  playerId: agent.id,
@@ -116900,7 +116977,6 @@ var TournamentDirector = class {
116900
116977
  };
116901
116978
  this.logEvent("DECISION_START", this.currentDecision);
116902
116979
  this.broadcast();
116903
- const stats = this.stats[agent.id];
116904
116980
  let result = null, error = null, errorCategory = null, elapsed = 0;
116905
116981
  const pausedTotal = () => (this.currentDecision?.pausedMs || 0) + (this.currentDecision?.pausedAt ? Math.max(0, Date.now() - this.currentDecision.pausedAt) : 0);
116906
116982
  const recordIncident = (incident) => {
@@ -117074,6 +117150,72 @@ var TournamentDirector = class {
117074
117150
  }
117075
117151
  this.currentDecision = null;
117076
117152
  }
117153
+ // Deterministic safe action for one seat, used whenever a decision cannot be
117154
+ // produced (context failure or emergency hand recovery). It always picks a
117155
+ // legal engine action, records a normal DECISION event so public action
117156
+ // history, replay and stats stay complete, and marks the row `forced`. It
117157
+ // never consults a model and never sees masked-out information.
117158
+ // Returns the chosen action, or null when the seat has no legal action.
117159
+ forceSafeAction(seat, { error = null, errorCategory = "context", decisionId = null } = {}) {
117160
+ const state = this.engine.state;
117161
+ const player = state.players?.[seat];
117162
+ if (!player) return null;
117163
+ const legalActions = legalActionCandidates(this.engine, seat);
117164
+ const chosen = fallbackAction(legalActions);
117165
+ if (!chosen) return null;
117166
+ this.engine.act(chosen.engineAction);
117167
+ const agent = (this.config?.players ?? []).find((p) => p.id === player.id) ?? null;
117168
+ const connection = agent ? this.config.connections.find((c) => c.id === agent.connectionId) ?? null : null;
117169
+ const stats = this.stats?.[player.id];
117170
+ if (stats) {
117171
+ stats.decisions++;
117172
+ stats.autoFallbacks++;
117173
+ stats.contextErrors = (stats.contextErrors || 0) + 1;
117174
+ stats.lastAction = chosen.description;
117175
+ stats.lastActionType = chosen.type;
117176
+ stats.lastActionAmount = chosen.amount ?? null;
117177
+ stats.lastReason = `Automatic ${chosen.description} after ${errorCategory} error.`;
117178
+ }
117179
+ this.decisionCount++;
117180
+ if (this.decisionBudget > 0 && this.decisionCount >= this.decisionBudget) this.budgetReached = true;
117181
+ this.currentDecision = null;
117182
+ const protocol = agent ? effectiveProtocol(agent, connection) : "none";
117183
+ this.logEvent("DECISION", {
117184
+ decisionId: decisionId ?? id(`d-h${this.handNumber}-s${seat + 1}`),
117185
+ handNumber: this.handNumber,
117186
+ street: state.street,
117187
+ position: positionForSeat(state, seat),
117188
+ potBefore: totalPot(state),
117189
+ playerId: player.id,
117190
+ playerName: player.name,
117191
+ connection: connection?.name ?? agent?.connectionId ?? null,
117192
+ protocol,
117193
+ requestedProtocol: protocol,
117194
+ protocolFallback: null,
117195
+ configuredModel: agent?.model ?? null,
117196
+ resolvedModel: agent?.model ?? null,
117197
+ provider: agent?.provider || "auto",
117198
+ reasoningEffort: agent?.reasoningEffort || null,
117199
+ action: { id: chosen.id, type: chosen.type, amount: chosen.amount, description: chosen.description },
117200
+ forced: true,
117201
+ legalActions: legalActions.map((a) => ({ id: a.id, type: a.type, amount: a.amount ?? null, description: a.description })),
117202
+ latencyMs: 0,
117203
+ primaryDecisionLatencyMs: 0,
117204
+ timeBankUsedMs: 0,
117205
+ timeBankRemainingMs: this.timeBanks?.[player.id] ?? 0,
117206
+ publicReason: stats?.lastReason ?? "",
117207
+ usage: null,
117208
+ decisionMeta: { method: "safe-fallback", decisionArchitecture: "safe-fallback-v1", reason: errorCategory, error: error ? summarizeError(error) : null },
117209
+ reasoning: null,
117210
+ benchmarkMode: this.config?.benchmarkMode ?? DEFAULT_BENCHMARK_MODE,
117211
+ decisionArchitecture: this.config?.decisionArchitecture ?? DEFAULT_DECISION_ARCHITECTURE,
117212
+ representation: this.config?.representation ?? DEFAULT_REPRESENTATION_MODE,
117213
+ replay: null,
117214
+ errorCategory,
117215
+ error: error ? summarizeError(error) : null
117216
+ });
117217
+ return chosen;
117218
+ }
117077
117219
  enqueueSpectatorExplanation({ decisionId, agent, connection, stateForAgent, chosen }) {
117078
117220
  if (!connection || !["openai", "openrouter"].includes(connection.kind) || isJevModel(agent.model)) return this.explanationChain;
117079
117221
  this.explanationChain = this.explanationChain.then(async () => {
@@ -117114,7 +117256,7 @@ var TournamentDirector = class {
117114
117256
  const already = this.eliminations.some((e) => e.playerId === player.id);
117115
117257
  return p && playerStack(p) <= 0 && !already ? { player, seat, startStack: asNumber(this.handStartStacks?.[player.id]) } : null;
117116
117258
  }).filter(Boolean).sort((a, b) => b.startStack - a.startStack || a.seat - b.seat);
117117
- const survivors = this.playersRemaining().length;
117259
+ const survivors = this.playersWithChips().length;
117118
117260
  newlyEliminated.forEach((row, index) => {
117119
117261
  const elimination = { playerId: row.player.id, playerName: row.player.name, place: Math.max(2, survivors + 1 + index), handNumber: this.handNumber, at: Date.now() };
117120
117262
  this.eliminations.push(elimination);
@@ -118201,7 +118343,7 @@ function renderStats(s) {
118201
118343
  const pc = (value) => `${Math.round(Number(value || 0) * 100)}%`;
118202
118344
  return `<div class="stat-card"><div class="stat-top"><div><div class="stat-name">${escapeHtml(visiblePlayerName(p.name, p.model))}</div><div class="stat-model mono">${escapeHtml(p.model)} \xB7 ${escapeHtml(effectiveProtocol(p, s.config.connections.find((c) => c.id === p.connectionId)))}</div></div><strong>${fmt(tableP?.stack || 0)}</strong></div>
118203
118345
  <div class="poker-profile"><div title="Voluntarily put chips in pot"><b>${pc(poker.vpipPct)}</b><span>VPIP</span></div><div title="Preflop raise"><b>${pc(poker.pfrPct)}</b><span>PFR</span></div><div title="Aggression frequency"><b>${pc(poker.aggressionPct)}</b><span>AFq</span></div><div title="Fold when folding was legal"><b>${pc(poker.foldPct)}</b><span>FOLD</span></div><div><b>${poker.sampleHands || 0}</b><span>HANDS</span></div></div>
118204
- <div class="stat-values"><div class="metric"><b>${st.decisions || 0}</b><span>moves</span></div><div class="metric"><b>${avg}ms</b><span>avg</span></div><div class="metric"><b>${st.autoFallbacks || 0}</b><span>auto</span></div></div><div class="stat-reliability"><span><b>${st.modelErrors || 0}</b> model</span><span><b>${st.providerErrors || 0}</b> provider</span><span><b>${st.rateLimits || 0}</b> rate</span><span><b>${st.timeouts || 0}</b> timeout</span><span><b>${st.protocolFallbacks || 0}</b> protocol</span><span><b>${st.retries || 0}</b> retry</span></div></div>`;
118346
+ <div class="stat-values"><div class="metric"><b>${st.decisions || 0}</b><span>moves</span></div><div class="metric"><b>${avg}ms</b><span>avg</span></div><div class="metric"><b>${st.autoFallbacks || 0}</b><span>auto</span></div></div><div class="stat-reliability"><span><b>${st.modelErrors || 0}</b> model</span><span><b>${st.providerErrors || 0}</b> provider</span><span><b>${st.rateLimits || 0}</b> rate</span><span><b>${st.timeouts || 0}</b> timeout</span><span><b>${st.contextErrors || 0}</b> context</span><span><b>${st.protocolFallbacks || 0}</b> protocol</span><span><b>${st.retries || 0}</b> retry</span></div></div>`;
118205
118347
  }).join("");
118206
118348
  }
118207
118349
  function tableRenderSignature(s) {
@@ -115826,6 +115826,13 @@ function playerCards(player) {
115826
115826
  function playerStack(player) {
115827
115827
  return asNumber(player?.stack ?? player?.chips ?? 0);
115828
115828
  }
115829
+ function playersStillInTournament(players, eliminatedPlayerIds = []) {
115830
+ const eliminated = new Set(eliminatedPlayerIds ?? []);
115831
+ return (players ?? []).filter((player) => player?.id && !eliminated.has(player.id));
115832
+ }
115833
+ function playersWithChips(players) {
115834
+ return (players ?? []).filter((player) => player && playerStack(player) > 0);
115835
+ }
115829
115836
  function currentBet(state, seat) {
115830
115837
  return asNumber(mapGet(state?.currentBets, seat, state?.players?.[seat]?.bet ?? 0));
115831
115838
  }
@@ -116073,7 +116080,7 @@ function assertDecisionState(state) {
116073
116080
  }
116074
116081
  }
116075
116082
  const expectedRemaining = 1 + state.opponents.length;
116076
- if (Number(state.tournament?.playersRemaining) !== expectedRemaining) throw new Error(`playersRemaining mismatch: expected ${expectedRemaining}, got ${state.tournament?.playersRemaining}`);
116083
+ if (Number(state.tournament?.playersRemaining) !== expectedRemaining) throw new Error(`playersRemaining mismatch: expected ${expectedRemaining} (hero plus ${state.opponents.length} non-eliminated opponents), got ${state.tournament?.playersRemaining}`);
116077
116084
  return state;
116078
116085
  }
116079
116086
  function extractTextContent(message) {
@@ -117804,7 +117811,7 @@ var TournamentDirector = class {
117804
117811
  for (const player of this.config.players) {
117805
117812
  this.engine.sit(player.seat, player.id, player.name, this.config.startingStack);
117806
117813
  this.timeBanks[player.id] = this.config.timeBankSeconds * 1e3;
117807
- this.stats[player.id] = { decisions: 0, invalid: 0, modelErrors: 0, providerErrors: 0, rateLimits: 0, timeouts: 0, autoFallbacks: 0, protocolFallbacks: 0, retries: 0, totalLatencyMs: 0, lastAction: null, lastActionType: null, lastActionAmount: null, lastReason: "" };
117814
+ this.stats[player.id] = { decisions: 0, invalid: 0, modelErrors: 0, providerErrors: 0, rateLimits: 0, timeouts: 0, autoFallbacks: 0, contextErrors: 0, protocolFallbacks: 0, retries: 0, totalLatencyMs: 0, lastAction: null, lastActionType: null, lastActionAmount: null, lastReason: "" };
117808
117815
  }
117809
117816
  this.logEvent("TOURNAMENT_START", { config: this.publicConfig() });
117810
117817
  this.broadcast();
@@ -117848,8 +117855,15 @@ var TournamentDirector = class {
117848
117855
  while (this.status === "PAUSED") await new Promise((resolve) => this.pauseResolvers.push(resolve));
117849
117856
  if (this.status === "STOPPED") throw new Error("Tournament stopped");
117850
117857
  }
117858
+ // Decision-context/table count: seated players that are not eliminated. Must
117859
+ // equal `1 + non-eliminated opponents` in serializeForAgent, so it must NOT be
117860
+ // derived from stacks — an all-in player has stack 0 mid-hand and is still in.
117851
117861
  playersRemaining() {
117852
- return (this.engine?.state?.players ?? []).map((p, seat) => ({ p, seat })).filter(({ p }) => p && playerStack(p) > 0);
117862
+ return playersStillInTournament(this.engine?.state?.players, this.eliminations.map((e) => e.playerId));
117863
+ }
117864
+ // Tournament-end / place accounting: seats that can still contest chips.
117865
+ playersWithChips() {
117866
+ return playersWithChips(this.engine?.state?.players);
117853
117867
  }
117854
117868
  tournamentMeta() {
117855
117869
  const startingPlayers = this.config?.players.length ?? 0;
@@ -117916,9 +117930,9 @@ var TournamentDirector = class {
117916
117930
  async run() {
117917
117931
  while (["RUNNING", "PAUSED"].includes(this.status)) {
117918
117932
  await this.waitIfPaused();
117919
- const remaining = this.playersRemaining();
117933
+ const remaining = this.playersWithChips();
117920
117934
  if (remaining.length <= 1) {
117921
- this.finish(remaining[0]?.p ?? null);
117935
+ this.finish(remaining[0] ?? null);
117922
117936
  return;
117923
117937
  }
117924
117938
  if (this.handNumber > 0 && this.handNumber % this.config.handsPerLevel === 0) {
@@ -117929,10 +117943,18 @@ var TournamentDirector = class {
117929
117943
  this.logEvent("BLINDS_UP_FAILED", { error: summarizeError(err) });
117930
117944
  }
117931
117945
  }
117932
- await this.startHand();
117933
- await this.playHand();
117934
- this.completeHand();
117935
- this.broadcast();
117946
+ try {
117947
+ await this.startHand();
117948
+ await this.playHand();
117949
+ this.completeHand();
117950
+ this.broadcast();
117951
+ this.handErrorStreak = 0;
117952
+ } catch (err) {
117953
+ if (this.status === "STOPPED" || err?.name === "AbortError") throw err;
117954
+ await this.recoverHand(err);
117955
+ this.handErrorStreak = (this.handErrorStreak || 0) + 1;
117956
+ if (this.handErrorStreak >= 3) throw err;
117957
+ }
117936
117958
  if (this.budgetReached) {
117937
117959
  this.logEvent("DECISION_BUDGET_REACHED", { decisions: this.decisionCount, budget: this.decisionBudget });
117938
117960
  this.stop();
@@ -117942,6 +117964,52 @@ var TournamentDirector = class {
117942
117964
  await sleep(Math.max(WINNER_REVIEW_MS, this.config.betweenHandsMs));
117943
117965
  }
117944
117966
  }
117967
+ // Last-resort safety net for a hand. A model failure or a decision-context
117968
+ // failure is already handled inside the hand loop; anything else that escapes
117969
+ // (a future regression) would otherwise abort the whole tournament mid-game.
117970
+ // Here the hand is finished with deterministic safe actions so the tournament
117971
+ // continues. Recovery is bounded by the hand-action guard and by the caller's
117972
+ // streak counter, so a genuinely broken engine still surfaces as an error
117973
+ // instead of looping forever.
117974
+ async recoverHand(triggerError) {
117975
+ this.currentDecision = null;
117976
+ if (this.handComplete()) {
117977
+ this.broadcast();
117978
+ return;
117979
+ }
117980
+ this.logEvent("HAND_RECOVERY", { handNumber: this.handNumber, street: this.engine?.state?.street ?? null, error: summarizeError(triggerError) });
117981
+ this.broadcast();
117982
+ let guard = 0;
117983
+ while (!this.handComplete()) {
117984
+ if (++guard > 500) throw new Error("Hand recovery guard exceeded 500 actions");
117985
+ await this.waitIfPaused();
117986
+ const state = this.engine.state, seat = state.actionTo;
117987
+ if (seat == null) {
117988
+ if (Array.isArray(state.winners) && state.winners.length) break;
117989
+ await sleep(10);
117990
+ continue;
117991
+ }
117992
+ const player = state.players?.[seat];
117993
+ if (!player) break;
117994
+ if (state.street === "SHOWDOWN") {
117995
+ const show = { type: ACTION.SHOW, playerId: player.id, cardIndices: [0, 1] };
117996
+ if (this.engine.validate(show)?.valid) {
117997
+ this.engine.act(show);
117998
+ continue;
117999
+ }
118000
+ const muck = { type: ACTION.MUCK, playerId: player.id };
118001
+ if (this.engine.validate(muck)?.valid) {
118002
+ this.engine.act(muck);
118003
+ continue;
118004
+ }
118005
+ }
118006
+ if (!this.forceSafeAction(seat, { error: triggerError, errorCategory: "recovery" })) break;
118007
+ this.broadcast();
118008
+ }
118009
+ if (!this.handComplete()) throw triggerError;
118010
+ this.completeHand();
118011
+ this.broadcast();
118012
+ }
117945
118013
  pruneBustedSeats() {
117946
118014
  const failures = [];
117947
118015
  for (const p of this.engine?.state?.players ?? []) {
@@ -118022,22 +118090,31 @@ var TournamentDirector = class {
118022
118090
  }
118023
118091
  async takeDecision(agent, seat, legalActions) {
118024
118092
  const decisionId = id(`d-h${this.handNumber}-s${seat + 1}`), baseMs = this.config.actionSeconds * 1e3, bankBefore = this.timeBanks[agent.id] ?? 0, totalMs = baseMs + bankBefore;
118093
+ const stats = this.stats[agent.id];
118094
+ const connection = this.config.connections.find((c) => c.id === agent.connectionId);
118095
+ const architecture = this.config.decisionArchitecture === DECISION_ARCHITECTURES.FLAT ? "flat" : "hierarchical";
118025
118096
  const recentHands = buildPublicTournamentMemory(this.events, this.handNumber);
118026
118097
  const publicPlayerStats = buildPublicPlayerStats(this.events, this.config.players, this.handNumber);
118027
118098
  const actionHistory = buildCurrentHandPublicActions(this.events, this.handNumber);
118028
- const baseState2 = serializeForAgent(this.engine, seat, this.tournamentMeta(), legalActions, recentHands, publicPlayerStats, actionHistory);
118029
- const stateForAgent = assertDecisionState(applyBenchmarkMode(baseState2, this.config.benchmarkMode)), startedAt = Date.now();
118030
- const connection = this.config.connections.find((c) => c.id === agent.connectionId);
118031
- const architecture = this.config.decisionArchitecture === DECISION_ARCHITECTURES.FLAT ? "flat" : "hierarchical";
118032
118099
  const sizesForFamily = (family) => legalAggressiveSizes(this.engine, seat, family);
118033
- let hierarchy = null;
118034
- if (architecture === "hierarchical") {
118035
- hierarchy = buildHierarchicalDecision(stateForAgent, { legalActions: stateForAgent.legalActions });
118036
- if (hierarchy.aggressiveFamily) {
118037
- const engineSizes = sizesForFamily(hierarchy.aggressiveFamily);
118038
- hierarchy.stage2 = { ...hierarchy.stage2, sizes: engineSizes, criteria: sizeCriteria(engineSizes) };
118100
+ let stateForAgent = null, hierarchy = null;
118101
+ try {
118102
+ stateForAgent = assertDecisionState(applyBenchmarkMode(
118103
+ serializeForAgent(this.engine, seat, this.tournamentMeta(), legalActions, recentHands, publicPlayerStats, actionHistory),
118104
+ this.config.benchmarkMode
118105
+ ));
118106
+ if (architecture === "hierarchical") {
118107
+ hierarchy = buildHierarchicalDecision(stateForAgent, { legalActions: stateForAgent.legalActions });
118108
+ if (hierarchy.aggressiveFamily) {
118109
+ const engineSizes = sizesForFamily(hierarchy.aggressiveFamily);
118110
+ hierarchy.stage2 = { ...hierarchy.stage2, sizes: engineSizes, criteria: sizeCriteria(engineSizes) };
118111
+ }
118039
118112
  }
118113
+ } catch (contextError) {
118114
+ if (!this.forceSafeAction(seat, { error: contextError, errorCategory: "context", decisionId })) throw contextError;
118115
+ return;
118040
118116
  }
118117
+ const startedAt = Date.now();
118041
118118
  this.currentDecision = {
118042
118119
  id: decisionId,
118043
118120
  playerId: agent.id,
@@ -118064,7 +118141,6 @@ var TournamentDirector = class {
118064
118141
  };
118065
118142
  this.logEvent("DECISION_START", this.currentDecision);
118066
118143
  this.broadcast();
118067
- const stats = this.stats[agent.id];
118068
118144
  let result = null, error = null, errorCategory = null, elapsed = 0;
118069
118145
  const pausedTotal = () => (this.currentDecision?.pausedMs || 0) + (this.currentDecision?.pausedAt ? Math.max(0, Date.now() - this.currentDecision.pausedAt) : 0);
118070
118146
  const recordIncident = (incident) => {
@@ -118238,6 +118314,72 @@ var TournamentDirector = class {
118238
118314
  }
118239
118315
  this.currentDecision = null;
118240
118316
  }
118317
+ // Deterministic safe action for one seat, used whenever a decision cannot be
118318
+ // produced (context failure or emergency hand recovery). It always picks a
118319
+ // legal engine action, records a normal DECISION event so public action
118320
+ // history, replay and stats stay complete, and marks the row `forced`. It
118321
+ // never consults a model and never sees masked-out information.
118322
+ // Returns the chosen action, or null when the seat has no legal action.
118323
+ forceSafeAction(seat, { error = null, errorCategory = "context", decisionId = null } = {}) {
118324
+ const state = this.engine.state;
118325
+ const player = state.players?.[seat];
118326
+ if (!player) return null;
118327
+ const legalActions = legalActionCandidates(this.engine, seat);
118328
+ const chosen = fallbackAction(legalActions);
118329
+ if (!chosen) return null;
118330
+ this.engine.act(chosen.engineAction);
118331
+ const agent = (this.config?.players ?? []).find((p) => p.id === player.id) ?? null;
118332
+ const connection = agent ? this.config.connections.find((c) => c.id === agent.connectionId) ?? null : null;
118333
+ const stats = this.stats?.[player.id];
118334
+ if (stats) {
118335
+ stats.decisions++;
118336
+ stats.autoFallbacks++;
118337
+ stats.contextErrors = (stats.contextErrors || 0) + 1;
118338
+ stats.lastAction = chosen.description;
118339
+ stats.lastActionType = chosen.type;
118340
+ stats.lastActionAmount = chosen.amount ?? null;
118341
+ stats.lastReason = `Automatic ${chosen.description} after ${errorCategory} error.`;
118342
+ }
118343
+ this.decisionCount++;
118344
+ if (this.decisionBudget > 0 && this.decisionCount >= this.decisionBudget) this.budgetReached = true;
118345
+ this.currentDecision = null;
118346
+ const protocol = agent ? effectiveProtocol(agent, connection) : "none";
118347
+ this.logEvent("DECISION", {
118348
+ decisionId: decisionId ?? id(`d-h${this.handNumber}-s${seat + 1}`),
118349
+ handNumber: this.handNumber,
118350
+ street: state.street,
118351
+ position: positionForSeat(state, seat),
118352
+ potBefore: totalPot(state),
118353
+ playerId: player.id,
118354
+ playerName: player.name,
118355
+ connection: connection?.name ?? agent?.connectionId ?? null,
118356
+ protocol,
118357
+ requestedProtocol: protocol,
118358
+ protocolFallback: null,
118359
+ configuredModel: agent?.model ?? null,
118360
+ resolvedModel: agent?.model ?? null,
118361
+ provider: agent?.provider || "auto",
118362
+ reasoningEffort: agent?.reasoningEffort || null,
118363
+ action: { id: chosen.id, type: chosen.type, amount: chosen.amount, description: chosen.description },
118364
+ forced: true,
118365
+ legalActions: legalActions.map((a) => ({ id: a.id, type: a.type, amount: a.amount ?? null, description: a.description })),
118366
+ latencyMs: 0,
118367
+ primaryDecisionLatencyMs: 0,
118368
+ timeBankUsedMs: 0,
118369
+ timeBankRemainingMs: this.timeBanks?.[player.id] ?? 0,
118370
+ publicReason: stats?.lastReason ?? "",
118371
+ usage: null,
118372
+ decisionMeta: { method: "safe-fallback", decisionArchitecture: "safe-fallback-v1", reason: errorCategory, error: error ? summarizeError(error) : null },
118373
+ reasoning: null,
118374
+ benchmarkMode: this.config?.benchmarkMode ?? DEFAULT_BENCHMARK_MODE,
118375
+ decisionArchitecture: this.config?.decisionArchitecture ?? DEFAULT_DECISION_ARCHITECTURE,
118376
+ representation: this.config?.representation ?? DEFAULT_REPRESENTATION_MODE,
118377
+ replay: null,
118378
+ errorCategory,
118379
+ error: error ? summarizeError(error) : null
118380
+ });
118381
+ return chosen;
118382
+ }
118241
118383
  enqueueSpectatorExplanation({ decisionId, agent, connection, stateForAgent, chosen }) {
118242
118384
  if (!connection || !["openai", "openrouter"].includes(connection.kind) || isJevModel(agent.model)) return this.explanationChain;
118243
118385
  this.explanationChain = this.explanationChain.then(async () => {
@@ -118278,7 +118420,7 @@ var TournamentDirector = class {
118278
118420
  const already = this.eliminations.some((e) => e.playerId === player.id);
118279
118421
  return p && playerStack(p) <= 0 && !already ? { player, seat, startStack: asNumber(this.handStartStacks?.[player.id]) } : null;
118280
118422
  }).filter(Boolean).sort((a, b) => b.startStack - a.startStack || a.seat - b.seat);
118281
- const survivors = this.playersRemaining().length;
118423
+ const survivors = this.playersWithChips().length;
118282
118424
  newlyEliminated.forEach((row, index) => {
118283
118425
  const elimination = { playerId: row.player.id, playerName: row.player.name, place: Math.max(2, survivors + 1 + index), handNumber: this.handNumber, at: Date.now() };
118284
118426
  this.eliminations.push(elimination);
@@ -119365,7 +119507,7 @@ function renderStats(s) {
119365
119507
  const pc = (value) => `${Math.round(Number(value || 0) * 100)}%`;
119366
119508
  return `<div class="stat-card"><div class="stat-top"><div><div class="stat-name">${escapeHtml(visiblePlayerName(p.name, p.model))}</div><div class="stat-model mono">${escapeHtml(p.model)} \xB7 ${escapeHtml(effectiveProtocol(p, s.config.connections.find((c) => c.id === p.connectionId)))}</div></div><strong>${fmt(tableP?.stack || 0)}</strong></div>
119367
119509
  <div class="poker-profile"><div title="Voluntarily put chips in pot"><b>${pc(poker.vpipPct)}</b><span>VPIP</span></div><div title="Preflop raise"><b>${pc(poker.pfrPct)}</b><span>PFR</span></div><div title="Aggression frequency"><b>${pc(poker.aggressionPct)}</b><span>AFq</span></div><div title="Fold when folding was legal"><b>${pc(poker.foldPct)}</b><span>FOLD</span></div><div><b>${poker.sampleHands || 0}</b><span>HANDS</span></div></div>
119368
- <div class="stat-values"><div class="metric"><b>${st.decisions || 0}</b><span>moves</span></div><div class="metric"><b>${avg}ms</b><span>avg</span></div><div class="metric"><b>${st.autoFallbacks || 0}</b><span>auto</span></div></div><div class="stat-reliability"><span><b>${st.modelErrors || 0}</b> model</span><span><b>${st.providerErrors || 0}</b> provider</span><span><b>${st.rateLimits || 0}</b> rate</span><span><b>${st.timeouts || 0}</b> timeout</span><span><b>${st.protocolFallbacks || 0}</b> protocol</span><span><b>${st.retries || 0}</b> retry</span></div></div>`;
119510
+ <div class="stat-values"><div class="metric"><b>${st.decisions || 0}</b><span>moves</span></div><div class="metric"><b>${avg}ms</b><span>avg</span></div><div class="metric"><b>${st.autoFallbacks || 0}</b><span>auto</span></div></div><div class="stat-reliability"><span><b>${st.modelErrors || 0}</b> model</span><span><b>${st.providerErrors || 0}</b> provider</span><span><b>${st.rateLimits || 0}</b> rate</span><span><b>${st.timeouts || 0}</b> timeout</span><span><b>${st.contextErrors || 0}</b> context</span><span><b>${st.protocolFallbacks || 0}</b> protocol</span><span><b>${st.retries || 0}</b> retry</span></div></div>`;
119369
119511
  }).join("");
119370
119512
  }
119371
119513
  function tableRenderSignature(s) {
@@ -1,4 +1,4 @@
1
- # pokertools-arena 0.17.0 — benchmark methodology & results
1
+ # pokertools-arena 0.17.1 — benchmark methodology & results
2
2
 
3
3
  Generated: 2026-09-19T18:19:58.352Z
4
4
  Methodology: paired fixed-state corpus; deterministic interleaving with recorded experiment seed.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "generatedAt": "2026-09-19T18:19:58.352Z",
5
5
  "experiments": [
6
6
  "paired"
@@ -1,5 +1,30 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.17.1 — mid-hand all-in no longer aborts the tournament
4
+
5
+ - Fixed a regression where a single all-in mid-hand ended the whole tournament
6
+ with `TOURNAMENT_ERROR: playersRemaining mismatch: expected 3, got 2`. The
7
+ decision context counted "players remaining" by chips, so a player who shoved
8
+ all-in (stack 0, `ALL_IN`, but not eliminated) dropped out of the count while
9
+ the masked opponent list still included them. The two counts now use distinct,
10
+ named helpers: `playersStillInTournament` (non-eliminated, used by the decision
11
+ context and table HUD) and `playersWithChips` (used only for tournament-end
12
+ detection and hand-end survivor/place accounting).
13
+ - Decision-context failures can no longer cut a game short. If a context cannot
14
+ be built for any reason, the seat takes a deterministic legal safe action
15
+ (check/fold/call), a normal `DECISION` event is recorded with
16
+ `errorCategory: "context"`, and the hand continues. The failed context is never
17
+ sent anywhere, so masking still fails closed.
18
+ - Added `Arena.recoverHand()` as a last-resort safety net: an unexpected error
19
+ that escapes the hand loop finishes that hand with safe actions instead of
20
+ aborting the tournament. It is bounded, so a genuinely broken engine still
21
+ surfaces as a tournament error rather than looping forever.
22
+ - Added `tests/unit/tournament-safety-tests.mjs`: 2–6 handed simulations that
23
+ replay every decision context through all-ins, eliminations, heads-up and four
24
+ stack/policy combinations, plus chip-conservation and forced-recovery
25
+ assertions. Runs as part of `npm test`.
26
+ - Reliability telemetry now reports a `context` error count per seat.
27
+
3
28
  ## 0.17.0 — per-seat reasoning effort
4
29
 
5
30
  - The seat editor now exposes **Reasoning effort** for OpenRouter models whose
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pokertools-arena",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "Browser-first AI poker benchmark powered by @pokertools/engine with generic OpenAI-compatible endpoints, optional OpenRouter/Jev Decisions routing, deterministic hierarchical decisions, paired fixed-state methodology, and reproducible reporting.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,7 @@
24
24
  "og": "rsvg-convert -w 1200 -h 630 -o src/assets/og-image.png src/assets/og-image.svg",
25
25
  "check": "node --check src/app.js && node --check src/benchmark/scenarios.js && node --check build.mjs && node --check bin/pokertools-arena.mjs && node tests/release-check.mjs",
26
26
  "test": "npm run build && npm run check && npm run test:unit && npm run test:integration",
27
- "test:unit": "node tests/unit/decision-scenarios.mjs && node tests/unit/decision-diagnostics.mjs && node tests/unit/hierarchical-decision-tests.mjs && node tests/unit/methodology-tests.mjs && node tests/unit/size-bucket-tests.mjs && node tests/unit/model-capabilities-tests.mjs && node tests/unit/streaming-reasoning-tests.mjs && node tests/unit/paired-architecture-tests.mjs && node tests/unit/archive-content.mjs && node tests/unit/report-consistency.mjs",
27
+ "test:unit": "node tests/unit/decision-scenarios.mjs && node tests/unit/decision-diagnostics.mjs && node tests/unit/hierarchical-decision-tests.mjs && node tests/unit/tournament-safety-tests.mjs && node tests/unit/methodology-tests.mjs && node tests/unit/size-bucket-tests.mjs && node tests/unit/model-capabilities-tests.mjs && node tests/unit/streaming-reasoning-tests.mjs && node tests/unit/paired-architecture-tests.mjs && node tests/unit/archive-content.mjs && node tests/unit/report-consistency.mjs",
28
28
  "test:integration": "node tests/integration/pokertools-integration.mjs && node tests/integration/env-bootstrap.mjs && node tests/integration/launcher-port-retry.mjs",
29
29
  "test:diagnostics": "node tests/real/real-decision-diagnostics.mjs",
30
30
  "test:diagnostics:dry": "node tests/real/real-decision-diagnostics.mjs --dry-run",
package/src/app.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  retryDelayMs, sleepWithSignal, fetchJsonWithRetry, isUnsupportedToolChoiceError, mapGet, jsonSafe,
11
11
  normalizeBaseUrl, completionsUrl, openRouterDecisionsUrl, isOpenRouterConnection, isJevModel,
12
12
  effectiveProtocol, modelsUrl, parseHeaders, makeHeaders, combineAbort,
13
- playerCards, playerStack, currentBet, totalPot, potChipBreakdown, positionForSeat, tableMarkerSeats,
13
+ playerCards, playerStack, playersStillInTournament, playersWithChips, currentBet, totalPot, potChipBreakdown, positionForSeat, tableMarkerSeats,
14
14
  describeAction, tryCandidate, legalActionCandidates, fallbackAction,
15
15
  serializeForAgent, assertDecisionState, extractTextContent, stripCodeFence,
16
16
  decideOpenAICompatible, decideJevDecisions, decideJevNative, decide, decideHierarchical, protocolCapabilityCache,
@@ -669,7 +669,7 @@ class TournamentDirector {
669
669
  for (const player of this.config.players) {
670
670
  this.engine.sit(player.seat, player.id, player.name, this.config.startingStack);
671
671
  this.timeBanks[player.id] = this.config.timeBankSeconds * 1000;
672
- this.stats[player.id] = { decisions: 0, invalid: 0, modelErrors: 0, providerErrors: 0, rateLimits: 0, timeouts: 0, autoFallbacks: 0, protocolFallbacks: 0, retries: 0, totalLatencyMs: 0, lastAction: null, lastActionType: null, lastActionAmount: null, lastReason: '' };
672
+ this.stats[player.id] = { decisions: 0, invalid: 0, modelErrors: 0, providerErrors: 0, rateLimits: 0, timeouts: 0, autoFallbacks: 0, contextErrors: 0, protocolFallbacks: 0, retries: 0, totalLatencyMs: 0, lastAction: null, lastActionType: null, lastActionAmount: null, lastReason: '' };
673
673
  }
674
674
  this.logEvent('TOURNAMENT_START', { config: this.publicConfig() }); this.broadcast();
675
675
  this.runPromise = this.run().catch(err => {
@@ -697,7 +697,12 @@ class TournamentDirector {
697
697
  }
698
698
  stop() { if (['RUNNING', 'PAUSED'].includes(this.status)) { this.status = 'STOPPED'; this.abortController?.abort(); for (const resolve of this.pauseResolvers.splice(0)) resolve(); this.persistLightweight(); this.broadcast(); } }
699
699
  async waitIfPaused() { while (this.status === 'PAUSED') await new Promise(resolve => this.pauseResolvers.push(resolve)); if (this.status === 'STOPPED') throw new Error('Tournament stopped'); }
700
- playersRemaining() { return (this.engine?.state?.players ?? []).map((p, seat) => ({ p, seat })).filter(({ p }) => p && playerStack(p) > 0); }
700
+ // Decision-context/table count: seated players that are not eliminated. Must
701
+ // equal `1 + non-eliminated opponents` in serializeForAgent, so it must NOT be
702
+ // derived from stacks — an all-in player has stack 0 mid-hand and is still in.
703
+ playersRemaining() { return playersStillInTournament(this.engine?.state?.players, this.eliminations.map(e => e.playerId)); }
704
+ // Tournament-end / place accounting: seats that can still contest chips.
705
+ playersWithChips() { return playersWithChips(this.engine?.state?.players); }
701
706
  tournamentMeta() {
702
707
  const startingPlayers = this.config?.players.length ?? 0;
703
708
  const eliminatedPlayerIds = this.eliminations.map(e => e.playerId);
@@ -741,17 +746,56 @@ class TournamentDirector {
741
746
  async run() {
742
747
  while (['RUNNING', 'PAUSED'].includes(this.status)) {
743
748
  await this.waitIfPaused();
744
- const remaining = this.playersRemaining();
745
- if (remaining.length <= 1) { this.finish(remaining[0]?.p ?? null); return; }
749
+ const remaining = this.playersWithChips();
750
+ if (remaining.length <= 1) { this.finish(remaining[0] ?? null); return; }
746
751
  if (this.handNumber > 0 && this.handNumber % this.config.handsPerLevel === 0) {
747
752
  try { this.engine.nextBlindLevel(); this.logEvent('BLINDS_UP', { level: this.engine.state.blindLevel, smallBlind: this.engine.state.smallBlind, bigBlind: this.engine.state.bigBlind, ante: this.engine.state.ante }); }
748
753
  catch (err) { this.logEvent('BLINDS_UP_FAILED', { error: summarizeError(err) }); }
749
754
  }
750
- await this.startHand(); await this.playHand(); this.completeHand(); this.broadcast();
755
+ try {
756
+ await this.startHand(); await this.playHand(); this.completeHand(); this.broadcast();
757
+ this.handErrorStreak = 0;
758
+ } catch (err) {
759
+ if (this.status === 'STOPPED' || err?.name === 'AbortError') throw err;
760
+ await this.recoverHand(err);
761
+ this.handErrorStreak = (this.handErrorStreak || 0) + 1;
762
+ if (this.handErrorStreak >= 3) throw err;
763
+ }
751
764
  if (this.budgetReached) { this.logEvent('DECISION_BUDGET_REACHED', { decisions: this.decisionCount, budget: this.decisionBudget }); this.stop(); return; }
752
765
  await this.waitIfPaused(); await sleep(Math.max(WINNER_REVIEW_MS, this.config.betweenHandsMs));
753
766
  }
754
767
  }
768
+ // Last-resort safety net for a hand. A model failure or a decision-context
769
+ // failure is already handled inside the hand loop; anything else that escapes
770
+ // (a future regression) would otherwise abort the whole tournament mid-game.
771
+ // Here the hand is finished with deterministic safe actions so the tournament
772
+ // continues. Recovery is bounded by the hand-action guard and by the caller's
773
+ // streak counter, so a genuinely broken engine still surfaces as an error
774
+ // instead of looping forever.
775
+ async recoverHand(triggerError) {
776
+ this.currentDecision = null;
777
+ if (this.handComplete()) { this.broadcast(); return; }
778
+ this.logEvent('HAND_RECOVERY', { handNumber: this.handNumber, street: this.engine?.state?.street ?? null, error: summarizeError(triggerError) });
779
+ this.broadcast();
780
+ let guard = 0;
781
+ while (!this.handComplete()) {
782
+ if (++guard > 500) throw new Error('Hand recovery guard exceeded 500 actions');
783
+ await this.waitIfPaused();
784
+ const state = this.engine.state, seat = state.actionTo;
785
+ if (seat == null) { if (Array.isArray(state.winners) && state.winners.length) break; await sleep(10); continue; }
786
+ const player = state.players?.[seat]; if (!player) break;
787
+ if (state.street === 'SHOWDOWN') {
788
+ const show = { type: ACTION.SHOW, playerId: player.id, cardIndices: [0, 1] };
789
+ if (this.engine.validate(show)?.valid) { this.engine.act(show); continue; }
790
+ const muck = { type: ACTION.MUCK, playerId: player.id };
791
+ if (this.engine.validate(muck)?.valid) { this.engine.act(muck); continue; }
792
+ }
793
+ if (!this.forceSafeAction(seat, { error: triggerError, errorCategory: 'recovery' })) break;
794
+ this.broadcast();
795
+ }
796
+ if (!this.handComplete()) throw triggerError;
797
+ this.completeHand(); this.broadcast();
798
+ }
755
799
  pruneBustedSeats() {
756
800
  const failures = [];
757
801
  for (const p of (this.engine?.state?.players ?? [])) {
@@ -802,22 +846,36 @@ class TournamentDirector {
802
846
  }
803
847
  async takeDecision(agent, seat, legalActions) {
804
848
  const decisionId = id(`d-h${this.handNumber}-s${seat + 1}`), baseMs = this.config.actionSeconds * 1000, bankBefore = this.timeBanks[agent.id] ?? 0, totalMs = baseMs + bankBefore;
849
+ const stats = this.stats[agent.id];
850
+ const connection = this.config.connections.find(c => c.id === agent.connectionId);
851
+ const architecture = this.config.decisionArchitecture === DECISION_ARCHITECTURES.FLAT ? 'flat' : 'hierarchical';
805
852
  const recentHands = buildPublicTournamentMemory(this.events, this.handNumber);
806
853
  const publicPlayerStats = buildPublicPlayerStats(this.events, this.config.players, this.handNumber);
807
854
  const actionHistory = buildCurrentHandPublicActions(this.events, this.handNumber);
808
- const baseState = serializeForAgent(this.engine, seat, this.tournamentMeta(), legalActions, recentHands, publicPlayerStats, actionHistory);
809
- const stateForAgent = assertDecisionState(applyBenchmarkMode(baseState, this.config.benchmarkMode)), startedAt = Date.now();
810
- const connection = this.config.connections.find(c => c.id === agent.connectionId);
811
- const architecture = this.config.decisionArchitecture === DECISION_ARCHITECTURES.FLAT ? 'flat' : 'hierarchical';
812
855
  const sizesForFamily = family => legalAggressiveSizes(this.engine, seat, family);
813
- let hierarchy = null;
814
- if (architecture === 'hierarchical') {
815
- hierarchy = buildHierarchicalDecision(stateForAgent, { legalActions: stateForAgent.legalActions });
816
- if (hierarchy.aggressiveFamily) {
817
- const engineSizes = sizesForFamily(hierarchy.aggressiveFamily);
818
- hierarchy.stage2 = { ...hierarchy.stage2, sizes: engineSizes, criteria: sizeCriteria(engineSizes) };
856
+ // Building the masked decision context is the only step that can fail on an
857
+ // unforeseen table state (for example a tournament-count invariant). It must
858
+ // never abort the tournament mid-hand: the failure is recorded and the hero
859
+ // takes a deterministic safe action, exactly like a model failure. The failed
860
+ // context is never sent anywhere, so masking still fails closed.
861
+ let stateForAgent = null, hierarchy = null;
862
+ try {
863
+ stateForAgent = assertDecisionState(applyBenchmarkMode(
864
+ serializeForAgent(this.engine, seat, this.tournamentMeta(), legalActions, recentHands, publicPlayerStats, actionHistory),
865
+ this.config.benchmarkMode,
866
+ ));
867
+ if (architecture === 'hierarchical') {
868
+ hierarchy = buildHierarchicalDecision(stateForAgent, { legalActions: stateForAgent.legalActions });
869
+ if (hierarchy.aggressiveFamily) {
870
+ const engineSizes = sizesForFamily(hierarchy.aggressiveFamily);
871
+ hierarchy.stage2 = { ...hierarchy.stage2, sizes: engineSizes, criteria: sizeCriteria(engineSizes) };
872
+ }
819
873
  }
874
+ } catch (contextError) {
875
+ if (!this.forceSafeAction(seat, { error: contextError, errorCategory: 'context', decisionId })) throw contextError;
876
+ return;
820
877
  }
878
+ const startedAt = Date.now();
821
879
  this.currentDecision = {
822
880
  id: decisionId, playerId: agent.id, playerName: agent.name, seat, model: agent.model, connection: connection?.name ?? agent.connectionId, provider: agent.provider || 'auto',
823
881
  reasoningEffort: agent.reasoningEffort || null,
@@ -827,7 +885,7 @@ class TournamentDirector {
827
885
  legalActions: legalActions.map(({ id: actionId, type, amount, description }) => ({ id: actionId, type, amount, description })),
828
886
  };
829
887
  this.logEvent('DECISION_START', this.currentDecision); this.broadcast();
830
- const stats = this.stats[agent.id]; let result = null, error = null, errorCategory = null, elapsed = 0;
888
+ let result = null, error = null, errorCategory = null, elapsed = 0;
831
889
  const pausedTotal = () => (this.currentDecision?.pausedMs || 0) + (this.currentDecision?.pausedAt ? Math.max(0, Date.now() - this.currentDecision.pausedAt) : 0);
832
890
  const recordIncident = (incident) => {
833
891
  if (incident?.category === 'rate_limit') stats.rateLimits++;
@@ -955,6 +1013,52 @@ class TournamentDirector {
955
1013
  }
956
1014
  this.currentDecision = null;
957
1015
  }
1016
+ // Deterministic safe action for one seat, used whenever a decision cannot be
1017
+ // produced (context failure or emergency hand recovery). It always picks a
1018
+ // legal engine action, records a normal DECISION event so public action
1019
+ // history, replay and stats stay complete, and marks the row `forced`. It
1020
+ // never consults a model and never sees masked-out information.
1021
+ // Returns the chosen action, or null when the seat has no legal action.
1022
+ forceSafeAction(seat, { error = null, errorCategory = 'context', decisionId = null } = {}) {
1023
+ const state = this.engine.state;
1024
+ const player = state.players?.[seat];
1025
+ if (!player) return null;
1026
+ const legalActions = legalActionCandidates(this.engine, seat);
1027
+ const chosen = fallbackAction(legalActions);
1028
+ if (!chosen) return null;
1029
+ this.engine.act(chosen.engineAction);
1030
+ const agent = (this.config?.players ?? []).find(p => p.id === player.id) ?? null;
1031
+ const connection = agent ? this.config.connections.find(c => c.id === agent.connectionId) ?? null : null;
1032
+ const stats = this.stats?.[player.id];
1033
+ if (stats) {
1034
+ stats.decisions++; stats.autoFallbacks++; stats.contextErrors = (stats.contextErrors || 0) + 1;
1035
+ stats.lastAction = chosen.description; stats.lastActionType = chosen.type; stats.lastActionAmount = chosen.amount ?? null;
1036
+ stats.lastReason = `Automatic ${chosen.description} after ${errorCategory} error.`;
1037
+ }
1038
+ this.decisionCount++;
1039
+ if (this.decisionBudget > 0 && this.decisionCount >= this.decisionBudget) this.budgetReached = true;
1040
+ this.currentDecision = null;
1041
+ const protocol = agent ? effectiveProtocol(agent, connection) : 'none';
1042
+ this.logEvent('DECISION', {
1043
+ decisionId: decisionId ?? id(`d-h${this.handNumber}-s${seat + 1}`),
1044
+ handNumber: this.handNumber, street: state.street, position: positionForSeat(state, seat), potBefore: totalPot(state),
1045
+ playerId: player.id, playerName: player.name, connection: connection?.name ?? agent?.connectionId ?? null,
1046
+ protocol, requestedProtocol: protocol, protocolFallback: null,
1047
+ configuredModel: agent?.model ?? null, resolvedModel: agent?.model ?? null, provider: agent?.provider || 'auto', reasoningEffort: agent?.reasoningEffort || null,
1048
+ action: { id: chosen.id, type: chosen.type, amount: chosen.amount, description: chosen.description }, forced: true,
1049
+ legalActions: legalActions.map(a => ({ id: a.id, type: a.type, amount: a.amount ?? null, description: a.description })),
1050
+ latencyMs: 0, primaryDecisionLatencyMs: 0, timeBankUsedMs: 0, timeBankRemainingMs: this.timeBanks?.[player.id] ?? 0,
1051
+ publicReason: stats?.lastReason ?? '', usage: null,
1052
+ decisionMeta: { method: 'safe-fallback', decisionArchitecture: 'safe-fallback-v1', reason: errorCategory, error: error ? summarizeError(error) : null },
1053
+ reasoning: null,
1054
+ benchmarkMode: this.config?.benchmarkMode ?? DEFAULT_BENCHMARK_MODE,
1055
+ decisionArchitecture: this.config?.decisionArchitecture ?? DEFAULT_DECISION_ARCHITECTURE,
1056
+ representation: this.config?.representation ?? DEFAULT_REPRESENTATION_MODE,
1057
+ replay: null,
1058
+ errorCategory, error: error ? summarizeError(error) : null,
1059
+ });
1060
+ return chosen;
1061
+ }
958
1062
  enqueueSpectatorExplanation({ decisionId, agent, connection, stateForAgent, chosen }) {
959
1063
  if (!connection || !['openai', 'openrouter'].includes(connection.kind) || isJevModel(agent.model)) return this.explanationChain;
960
1064
  this.explanationChain = this.explanationChain.then(async () => {
@@ -992,7 +1096,7 @@ class TournamentDirector {
992
1096
  const already = this.eliminations.some(e => e.playerId === player.id);
993
1097
  return p && playerStack(p) <= 0 && !already ? { player, seat, startStack: asNumber(this.handStartStacks?.[player.id]) } : null;
994
1098
  }).filter(Boolean).sort((a, b) => b.startStack - a.startStack || a.seat - b.seat);
995
- const survivors = this.playersRemaining().length;
1099
+ const survivors = this.playersWithChips().length;
996
1100
  newlyEliminated.forEach((row, index) => {
997
1101
  const elimination = { playerId: row.player.id, playerName: row.player.name, place: Math.max(2, survivors + 1 + index), handNumber: this.handNumber, at: Date.now() };
998
1102
  this.eliminations.push(elimination); this.logEvent('ELIMINATION', elimination);
@@ -2066,7 +2170,7 @@ function renderStats(s) {
2066
2170
  const pc = value => `${Math.round(Number(value || 0) * 100)}%`;
2067
2171
  return `<div class="stat-card"><div class="stat-top"><div><div class="stat-name">${escapeHtml(visiblePlayerName(p.name, p.model))}</div><div class="stat-model mono">${escapeHtml(p.model)} · ${escapeHtml(effectiveProtocol(p, s.config.connections.find(c => c.id === p.connectionId)))}</div></div><strong>${fmt(tableP?.stack || 0)}</strong></div>
2068
2172
  <div class="poker-profile"><div title="Voluntarily put chips in pot"><b>${pc(poker.vpipPct)}</b><span>VPIP</span></div><div title="Preflop raise"><b>${pc(poker.pfrPct)}</b><span>PFR</span></div><div title="Aggression frequency"><b>${pc(poker.aggressionPct)}</b><span>AFq</span></div><div title="Fold when folding was legal"><b>${pc(poker.foldPct)}</b><span>FOLD</span></div><div><b>${poker.sampleHands || 0}</b><span>HANDS</span></div></div>
2069
- <div class="stat-values"><div class="metric"><b>${st.decisions || 0}</b><span>moves</span></div><div class="metric"><b>${avg}ms</b><span>avg</span></div><div class="metric"><b>${st.autoFallbacks || 0}</b><span>auto</span></div></div><div class="stat-reliability"><span><b>${st.modelErrors || 0}</b> model</span><span><b>${st.providerErrors || 0}</b> provider</span><span><b>${st.rateLimits || 0}</b> rate</span><span><b>${st.timeouts || 0}</b> timeout</span><span><b>${st.protocolFallbacks || 0}</b> protocol</span><span><b>${st.retries || 0}</b> retry</span></div></div>`;
2173
+ <div class="stat-values"><div class="metric"><b>${st.decisions || 0}</b><span>moves</span></div><div class="metric"><b>${avg}ms</b><span>avg</span></div><div class="metric"><b>${st.autoFallbacks || 0}</b><span>auto</span></div></div><div class="stat-reliability"><span><b>${st.modelErrors || 0}</b> model</span><span><b>${st.providerErrors || 0}</b> provider</span><span><b>${st.rateLimits || 0}</b> rate</span><span><b>${st.timeouts || 0}</b> timeout</span><span><b>${st.contextErrors || 0}</b> context</span><span><b>${st.protocolFallbacks || 0}</b> protocol</span><span><b>${st.retries || 0}</b> retry</span></div></div>`;
2070
2174
  }).join('');
2071
2175
  }
2072
2176
  function tableRenderSignature(s) {
@@ -446,6 +446,26 @@ export function playerCards(player) {
446
446
  return Array.isArray(raw) ? raw.filter(Boolean).map(String) : [];
447
447
  }
448
448
  export function playerStack(player) { return asNumber(player?.stack ?? player?.chips ?? 0); }
449
+ // Two counts of "players still around" exist and must never be conflated:
450
+ //
451
+ // - playersStillInTournament: seated players that have not been eliminated.
452
+ // This is the tournament field exposed to models and the table HUD. It has to
453
+ // equal `1 + non-eliminated opponents` in serializeForAgent. Chip-based counts
454
+ // are wrong here: a player who shoves all-in has stack 0 for the rest of the
455
+ // hand but is not eliminated until the hand settles.
456
+ // - playersWithChips: seats that can still contest chips. Used for
457
+ // tournament-end detection and hand-end survivor/place accounting, where an
458
+ // all-in player may have already committed every chip.
459
+ //
460
+ // Mixing them lets one mid-hand all-in make the decision-context invariant throw
461
+ // and abort the whole tournament, so keep the callers aligned with the names.
462
+ export function playersStillInTournament(players, eliminatedPlayerIds = []) {
463
+ const eliminated = new Set(eliminatedPlayerIds ?? []);
464
+ return (players ?? []).filter(player => player?.id && !eliminated.has(player.id));
465
+ }
466
+ export function playersWithChips(players) {
467
+ return (players ?? []).filter(player => player && playerStack(player) > 0);
468
+ }
449
469
  export function currentBet(state, seat) { return asNumber(mapGet(state?.currentBets, seat, state?.players?.[seat]?.bet ?? 0)); }
450
470
  export function totalPot(state) {
451
471
  const pots = Array.isArray(state?.pots) ? state.pots : [];
@@ -697,8 +717,12 @@ export function assertDecisionState(state) {
697
717
  aggressiveByAmount.set(Number(action.amount), action.type);
698
718
  }
699
719
  }
720
+ // The tournament field must describe non-eliminated players only. It has to
721
+ // match the masked opponent list exactly (`playersStillInTournament`), so it
722
+ // must never be derived from stacks: an all-in player has stack 0 mid-hand but
723
+ // is not eliminated until the hand settles.
700
724
  const expectedRemaining = 1 + state.opponents.length;
701
- if (Number(state.tournament?.playersRemaining) !== expectedRemaining) throw new Error(`playersRemaining mismatch: expected ${expectedRemaining}, got ${state.tournament?.playersRemaining}`);
725
+ if (Number(state.tournament?.playersRemaining) !== expectedRemaining) throw new Error(`playersRemaining mismatch: expected ${expectedRemaining} (hero plus ${state.opponents.length} non-eliminated opponents), got ${state.tournament?.playersRemaining}`);
702
726
  return state;
703
727
  }
704
728