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 +1 -1
- package/dist/app.js +164 -22
- package/dist/pokertools-arena.html +164 -22
- package/docs/diagnostics/SUMMARY.md +1 -1
- package/docs/diagnostics/summary.json +1 -1
- package/docs/releases/RELEASE_NOTES.md +25 -0
- package/package.json +2 -2
- package/src/app.js +123 -19
- package/src/lib/decision-core.js +25 -1
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.
|
|
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
|
|
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.
|
|
116769
|
+
const remaining = this.playersWithChips();
|
|
116756
116770
|
if (remaining.length <= 1) {
|
|
116757
|
-
this.finish(remaining[0]
|
|
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
|
-
|
|
116769
|
-
|
|
116770
|
-
|
|
116771
|
-
|
|
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
|
-
|
|
116871
|
-
|
|
116872
|
-
|
|
116873
|
-
|
|
116874
|
-
|
|
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.
|
|
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
|
|
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.
|
|
117933
|
+
const remaining = this.playersWithChips();
|
|
117920
117934
|
if (remaining.length <= 1) {
|
|
117921
|
-
this.finish(remaining[0]
|
|
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
|
-
|
|
117933
|
-
|
|
117934
|
-
|
|
117935
|
-
|
|
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
|
-
|
|
118035
|
-
|
|
118036
|
-
|
|
118037
|
-
|
|
118038
|
-
|
|
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.
|
|
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,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.
|
|
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
|
-
|
|
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.
|
|
745
|
-
if (remaining.length <= 1) { this.finish(remaining[0]
|
|
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
|
-
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
|
|
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.
|
|
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) {
|
package/src/lib/decision-core.js
CHANGED
|
@@ -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
|
|