pokertools-arena 0.16.2 → 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.16.2.** Blind levels now advance by a default multiplier of ×2, so blinds double each level unless changed in the tournament settings.
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
@@ -114432,8 +114432,15 @@ async function fetchStreamingJson(url, options, { maxRetries = 1, onDelta = null
114432
114432
  throw new ArenaRequestError(message, { status: response.status, category: requestErrorCategory(response.status), payload, incidents });
114433
114433
  }
114434
114434
  }
114435
+ function requestErrorDetail(err) {
114436
+ const parts = [summarizeError(err)];
114437
+ const nested = err?.payload?.error?.metadata?.raw ?? err?.payload?.metadata?.raw;
114438
+ if (typeof nested === "string") parts.push(nested);
114439
+ else if (nested) parts.push(String(JSON.stringify(nested)));
114440
+ return parts.join(" ");
114441
+ }
114435
114442
  function isUnsupportedToolChoiceError(err) {
114436
- const message = summarizeError(err);
114443
+ const message = requestErrorDetail(err);
114437
114444
  return [400, 404, 422].includes(Number(err?.status)) && /tool[_ -]?choice|tool call|tools?.*(?:unsupported|support)|no endpoints?.*support/i.test(message);
114438
114445
  }
114439
114446
  function mapGet(mapish, key, fallback = 0) {
@@ -114459,13 +114466,23 @@ function jsonSafe(value) {
114459
114466
  }
114460
114467
  var protocolCapabilityCache = /* @__PURE__ */ new Map();
114461
114468
  var modelSupportedParameters = /* @__PURE__ */ new Map();
114469
+ var modelReasoningCapabilities = /* @__PURE__ */ new Map();
114462
114470
  var DEFAULT_TEMPERATURE = 0.3;
114463
114471
  function registerModelCapabilities(models = []) {
114464
114472
  for (const entry of models ?? []) {
114465
114473
  const id2 = typeof entry === "string" ? entry : entry?.id;
114474
+ if (!id2) continue;
114466
114475
  const parameters = typeof entry === "object" && entry ? entry.supported_parameters : null;
114467
- if (!id2 || !Array.isArray(parameters)) continue;
114468
- modelSupportedParameters.set(String(id2), new Set(parameters.map(String)));
114476
+ if (Array.isArray(parameters)) modelSupportedParameters.set(String(id2), new Set(parameters.map(String)));
114477
+ const reasoning = typeof entry === "object" && entry ? entry.reasoning : null;
114478
+ if (!reasoning || typeof reasoning !== "object") continue;
114479
+ const efforts = Array.isArray(reasoning.supported_efforts) ? reasoning.supported_efforts.map((value) => String(value).trim().toLowerCase()).filter(Boolean) : [];
114480
+ const declaredDefault = String(reasoning.default_effort || "").trim().toLowerCase();
114481
+ modelReasoningCapabilities.set(String(id2), {
114482
+ mandatory: reasoning.mandatory === true,
114483
+ supportedEfforts: efforts,
114484
+ defaultEffort: efforts.includes(declaredDefault) ? declaredDefault : null
114485
+ });
114469
114486
  }
114470
114487
  }
114471
114488
  function modelSupportsParameter(model, parameter) {
@@ -114473,6 +114490,34 @@ function modelSupportsParameter(model, parameter) {
114473
114490
  if (!supported) return null;
114474
114491
  return supported.has(String(parameter));
114475
114492
  }
114493
+ function reasoningCapabilities(model) {
114494
+ return modelReasoningCapabilities.get(String(model || "")) ?? null;
114495
+ }
114496
+ function reasoningEffortsFor(model) {
114497
+ return reasoningCapabilities(model)?.supportedEfforts ?? [];
114498
+ }
114499
+ function defaultReasoningEffort(model) {
114500
+ return reasoningCapabilities(model)?.defaultEffort ?? null;
114501
+ }
114502
+ function requiresReasoning(model) {
114503
+ return reasoningCapabilities(model)?.mandatory === true;
114504
+ }
114505
+ function resolveReasoningEffort(model, requested) {
114506
+ const value = String(requested || "").trim().toLowerCase();
114507
+ if (!value) return null;
114508
+ const capabilities = reasoningCapabilities(model);
114509
+ if (!capabilities) return value;
114510
+ return capabilities.supportedEfforts.includes(value) ? value : null;
114511
+ }
114512
+ function reasoningOverrideFor(agent) {
114513
+ const effort = resolveReasoningEffort(agent?.model, agent?.reasoningEffort);
114514
+ return effort ? { effort } : { max_tokens: 256, exclude: false };
114515
+ }
114516
+ function truncationReasoningOverride(model) {
114517
+ const capabilities = reasoningCapabilities(model);
114518
+ if (!capabilities) return { effort: "low" };
114519
+ return capabilities.supportedEfforts.includes("low") ? { effort: "low" } : { max_tokens: 4096, exclude: false };
114520
+ }
114476
114521
  function resolveTemperature(model, requested) {
114477
114522
  if (modelSupportsParameter(model, "temperature") === false) return void 0;
114478
114523
  return Number.isFinite(Number(requested)) ? Number(requested) : DEFAULT_TEMPERATURE;
@@ -114511,7 +114556,7 @@ function isReasoningModel(model) {
114511
114556
  return /(?:^|[\/._:-])(qwen3|qwq|deepseek-[rv]\d|magistral|glm-(?:\d+[a-z]*|latest|flash-latest)|gpt-oss|nemotron|reason(?:ing)?|thinking|o1|o3|o4)(?:$|[\/._:-])/.test(id2);
114512
114557
  }
114513
114558
  function wantsReasoning(agent) {
114514
- return isReasoningModel(agent?.model) || agent?.captureReasoning === true;
114559
+ return isReasoningModel(agent?.model) || agent?.captureReasoning === true || reasoningCapabilities(agent?.model)?.mandatory === true || Boolean(resolveReasoningEffort(agent?.model, agent?.reasoningEffort));
114515
114560
  }
114516
114561
  function effectiveProtocol(agent, connection) {
114517
114562
  if (isOpenRouterConnection(connection) && isJevModel(agent?.model)) return "jev_decisions";
@@ -114617,6 +114662,13 @@ function playerCards(player) {
114617
114662
  function playerStack(player) {
114618
114663
  return asNumber(player?.stack ?? player?.chips ?? 0);
114619
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
+ }
114620
114672
  function currentBet(state, seat) {
114621
114673
  return asNumber(mapGet(state?.currentBets, seat, state?.players?.[seat]?.bet ?? 0));
114622
114674
  }
@@ -114864,7 +114916,7 @@ function assertDecisionState(state) {
114864
114916
  }
114865
114917
  }
114866
114918
  const expectedRemaining = 1 + state.opponents.length;
114867
- 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}`);
114868
114920
  return state;
114869
114921
  }
114870
114922
  function extractTextContent(message) {
@@ -115287,7 +115339,7 @@ Respond with one of: ${criteriaKeys.map((k) => `${k} = ${criteria[k]}`).join(";
115287
115339
  }
115288
115340
  if (isOpenRouter) {
115289
115341
  body.provider = agent.provider ? { only: [agent.provider], allow_fallbacks: false, require_parameters: true } : { allow_fallbacks: true, require_parameters: true };
115290
- if (wantsReasoning(agent)) body.reasoning = reasoning ?? { max_tokens: 256, exclude: false };
115342
+ if (wantsReasoning(agent)) body.reasoning = reasoning ?? reasoningOverrideFor(agent);
115291
115343
  }
115292
115344
  if (stream) {
115293
115345
  body.stream = true;
@@ -115350,7 +115402,7 @@ async function decideOpenAICompatible({ agent, connection, state, legalActions,
115350
115402
  result = await execute(actualProtocol);
115351
115403
  } else if (err?.truncated) {
115352
115404
  truncationRetry = true;
115353
- result = await execute(actualProtocol, { maxTokens: 4096, reasoning: { effort: "low" } });
115405
+ result = await execute(actualProtocol, { maxTokens: 4096, reasoning: truncationReasoningOverride(agent.model) });
115354
115406
  } else throw err;
115355
115407
  }
115356
115408
  const normalized = normalizeDecisionObject(result.obj, legalActions, decisionId);
@@ -115526,7 +115578,7 @@ async function requestChatStage({ agent, connection, schema, toolName, systemPro
115526
115578
  }
115527
115579
  if (isOpenRouter) {
115528
115580
  body.provider = agent.provider ? { only: [agent.provider], allow_fallbacks: false, require_parameters: true } : { allow_fallbacks: true, require_parameters: true };
115529
- if (wantsReasoning(agent)) body.reasoning = override.reasoning ?? { max_tokens: 256, exclude: false };
115581
+ if (wantsReasoning(agent)) body.reasoning = override.reasoning ?? reasoningOverrideFor(agent);
115530
115582
  }
115531
115583
  if (onDelta) {
115532
115584
  body.stream = true;
@@ -115570,7 +115622,7 @@ async function requestChatStage({ agent, connection, schema, toolName, systemPro
115570
115622
  return { ...result, protocolFallback: "tool\u2192json_schema" };
115571
115623
  }
115572
115624
  if (err?.truncated) {
115573
- const result = await execute(actual, { maxTokens: 4096, reasoning: { effort: "low" } });
115625
+ const result = await execute(actual, { maxTokens: 4096, reasoning: truncationReasoningOverride(agent.model) });
115574
115626
  return { ...result, protocolFallback: "truncated\u2192retry" };
115575
115627
  }
115576
115628
  throw err;
@@ -115843,6 +115895,8 @@ var els = {
115843
115895
  seatProtocol: $("#seatProtocol"),
115844
115896
  seatProvider: $("#seatProvider"),
115845
115897
  seatCaptureReasoning: $("#seatCaptureReasoning"),
115898
+ seatReasoningEffort: $("#seatReasoningEffort"),
115899
+ seatReasoningEffortHelp: $("#seatReasoningEffortHelp"),
115846
115900
  seatError: $("#seatError"),
115847
115901
  removeSeatBtn: $("#removeSeatBtn"),
115848
115902
  cancelSeatBtn: $("#cancelSeatBtn"),
@@ -115893,6 +115947,7 @@ var WINNER_REVIEW_MS = 2e3;
115893
115947
  var seatAssignments = Array(MAX_LOBBY_SEATS).fill(null);
115894
115948
  var editingSeatIndex = null;
115895
115949
  var seatNameAuto = false;
115950
+ var seatReasoningEffortDraft = "";
115896
115951
  var lobbyVisible = true;
115897
115952
  var pendingAutostart = false;
115898
115953
  var arenaMaxDecisions = 0;
@@ -116198,7 +116253,8 @@ function injectedEnvironmentConfig() {
116198
116253
  model: String(player.model || ""),
116199
116254
  protocol: String(player.protocol || "tool"),
116200
116255
  provider: String(player.provider || ""),
116201
- captureReasoning: Boolean(player.captureReasoning)
116256
+ captureReasoning: Boolean(player.captureReasoning),
116257
+ reasoningEffort: String(player.reasoningEffort || "").trim().toLowerCase()
116202
116258
  }))
116203
116259
  };
116204
116260
  }
@@ -116515,7 +116571,7 @@ function normalizeConfig(input = {}) {
116515
116571
  if (!connIds.has(raw.connectionId)) throw new Error(`Connection missing for ${name}`);
116516
116572
  const model = String(raw.model || "").trim();
116517
116573
  if (!model) throw new Error(`Model missing for ${name}`);
116518
- return { id: `player-${lobbySeat + 1}`, seat: index, lobbySeat, name, connectionId: raw.connectionId, model, protocol: raw.protocol || "tool", provider: String(raw.provider || "").trim(), captureReasoning: raw.captureReasoning === true, temperature: clamp(Number(raw.temperature) || 0.3, 0, 2) };
116574
+ return { id: `player-${lobbySeat + 1}`, seat: index, lobbySeat, name, connectionId: raw.connectionId, model, protocol: raw.protocol || "tool", provider: String(raw.provider || "").trim(), captureReasoning: raw.captureReasoning === true, reasoningEffort: String(raw.reasoningEffort || "").trim().toLowerCase(), temperature: clamp(Number(raw.temperature) || 0.3, 0, 2) };
116519
116575
  })
116520
116576
  };
116521
116577
  }
@@ -116591,7 +116647,7 @@ var TournamentDirector = class {
116591
116647
  for (const player of this.config.players) {
116592
116648
  this.engine.sit(player.seat, player.id, player.name, this.config.startingStack);
116593
116649
  this.timeBanks[player.id] = this.config.timeBankSeconds * 1e3;
116594
- 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: "" };
116595
116651
  }
116596
116652
  this.logEvent("TOURNAMENT_START", { config: this.publicConfig() });
116597
116653
  this.broadcast();
@@ -116635,8 +116691,15 @@ var TournamentDirector = class {
116635
116691
  while (this.status === "PAUSED") await new Promise((resolve) => this.pauseResolvers.push(resolve));
116636
116692
  if (this.status === "STOPPED") throw new Error("Tournament stopped");
116637
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.
116638
116697
  playersRemaining() {
116639
- 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);
116640
116703
  }
116641
116704
  tournamentMeta() {
116642
116705
  const startingPlayers = this.config?.players.length ?? 0;
@@ -116703,9 +116766,9 @@ var TournamentDirector = class {
116703
116766
  async run() {
116704
116767
  while (["RUNNING", "PAUSED"].includes(this.status)) {
116705
116768
  await this.waitIfPaused();
116706
- const remaining = this.playersRemaining();
116769
+ const remaining = this.playersWithChips();
116707
116770
  if (remaining.length <= 1) {
116708
- this.finish(remaining[0]?.p ?? null);
116771
+ this.finish(remaining[0] ?? null);
116709
116772
  return;
116710
116773
  }
116711
116774
  if (this.handNumber > 0 && this.handNumber % this.config.handsPerLevel === 0) {
@@ -116716,10 +116779,18 @@ var TournamentDirector = class {
116716
116779
  this.logEvent("BLINDS_UP_FAILED", { error: summarizeError(err) });
116717
116780
  }
116718
116781
  }
116719
- await this.startHand();
116720
- await this.playHand();
116721
- this.completeHand();
116722
- 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
+ }
116723
116794
  if (this.budgetReached) {
116724
116795
  this.logEvent("DECISION_BUDGET_REACHED", { decisions: this.decisionCount, budget: this.decisionBudget });
116725
116796
  this.stop();
@@ -116729,6 +116800,52 @@ var TournamentDirector = class {
116729
116800
  await sleep(Math.max(WINNER_REVIEW_MS, this.config.betweenHandsMs));
116730
116801
  }
116731
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
+ }
116732
116849
  pruneBustedSeats() {
116733
116850
  const failures = [];
116734
116851
  for (const p of this.engine?.state?.players ?? []) {
@@ -116809,22 +116926,31 @@ var TournamentDirector = class {
116809
116926
  }
116810
116927
  async takeDecision(agent, seat, legalActions) {
116811
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";
116812
116932
  const recentHands = buildPublicTournamentMemory(this.events, this.handNumber);
116813
116933
  const publicPlayerStats = buildPublicPlayerStats(this.events, this.config.players, this.handNumber);
116814
116934
  const actionHistory = buildCurrentHandPublicActions(this.events, this.handNumber);
116815
- const baseState2 = serializeForAgent(this.engine, seat, this.tournamentMeta(), legalActions, recentHands, publicPlayerStats, actionHistory);
116816
- const stateForAgent = assertDecisionState(applyBenchmarkMode(baseState2, this.config.benchmarkMode)), startedAt = Date.now();
116817
- const connection = this.config.connections.find((c) => c.id === agent.connectionId);
116818
- const architecture = this.config.decisionArchitecture === DECISION_ARCHITECTURES.FLAT ? "flat" : "hierarchical";
116819
116935
  const sizesForFamily = (family) => legalAggressiveSizes(this.engine, seat, family);
116820
- let hierarchy = null;
116821
- if (architecture === "hierarchical") {
116822
- hierarchy = buildHierarchicalDecision(stateForAgent, { legalActions: stateForAgent.legalActions });
116823
- if (hierarchy.aggressiveFamily) {
116824
- const engineSizes = sizesForFamily(hierarchy.aggressiveFamily);
116825
- 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
+ }
116826
116948
  }
116949
+ } catch (contextError) {
116950
+ if (!this.forceSafeAction(seat, { error: contextError, errorCategory: "context", decisionId })) throw contextError;
116951
+ return;
116827
116952
  }
116953
+ const startedAt = Date.now();
116828
116954
  this.currentDecision = {
116829
116955
  id: decisionId,
116830
116956
  playerId: agent.id,
@@ -116833,6 +116959,7 @@ var TournamentDirector = class {
116833
116959
  model: agent.model,
116834
116960
  connection: connection?.name ?? agent.connectionId,
116835
116961
  provider: agent.provider || "auto",
116962
+ reasoningEffort: agent.reasoningEffort || null,
116836
116963
  protocol: effectiveProtocol(agent, connection),
116837
116964
  handNumber: this.handNumber,
116838
116965
  street: this.engine.state.street,
@@ -116850,7 +116977,6 @@ var TournamentDirector = class {
116850
116977
  };
116851
116978
  this.logEvent("DECISION_START", this.currentDecision);
116852
116979
  this.broadcast();
116853
- const stats = this.stats[agent.id];
116854
116980
  let result = null, error = null, errorCategory = null, elapsed = 0;
116855
116981
  const pausedTotal = () => (this.currentDecision?.pausedMs || 0) + (this.currentDecision?.pausedAt ? Math.max(0, Date.now() - this.currentDecision.pausedAt) : 0);
116856
116982
  const recordIncident = (incident) => {
@@ -116981,6 +117107,7 @@ var TournamentDirector = class {
116981
117107
  configuredModel: agent.model,
116982
117108
  resolvedModel: result?.model ?? agent.model,
116983
117109
  provider: agent.provider || "auto",
117110
+ reasoningEffort: agent.reasoningEffort || null,
116984
117111
  action: { id: chosen.id, type: chosen.type, amount: chosen.amount, description: chosen.description },
116985
117112
  forced,
116986
117113
  legalActions: legalActions.map((a) => ({ id: a.id, type: a.type, amount: a.amount ?? null, description: a.description })),
@@ -117023,6 +117150,72 @@ var TournamentDirector = class {
117023
117150
  }
117024
117151
  this.currentDecision = null;
117025
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
+ }
117026
117219
  enqueueSpectatorExplanation({ decisionId, agent, connection, stateForAgent, chosen }) {
117027
117220
  if (!connection || !["openai", "openrouter"].includes(connection.kind) || isJevModel(agent.model)) return this.explanationChain;
117028
117221
  this.explanationChain = this.explanationChain.then(async () => {
@@ -117063,7 +117256,7 @@ var TournamentDirector = class {
117063
117256
  const already = this.eliminations.some((e) => e.playerId === player.id);
117064
117257
  return p && playerStack(p) <= 0 && !already ? { player, seat, startStack: asNumber(this.handStartStacks?.[player.id]) } : null;
117065
117258
  }).filter(Boolean).sort((a, b) => b.startStack - a.startStack || a.seat - b.seat);
117066
- const survivors = this.playersRemaining().length;
117259
+ const survivors = this.playersWithChips().length;
117067
117260
  newlyEliminated.forEach((row, index) => {
117068
117261
  const elimination = { playerId: row.player.id, playerName: row.player.name, place: Math.max(2, survivors + 1 + index), handNumber: this.handNumber, at: Date.now() };
117069
117262
  this.eliminations.push(elimination);
@@ -117221,6 +117414,7 @@ function renderModelOptions(models, currentValue = "") {
117221
117414
  if (currentValue && !byId.has(currentValue)) byId.set(currentValue, { id: currentValue, name: currentValue });
117222
117415
  const rows = [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
117223
117416
  els.seatModelOptions.innerHTML = rows.map((model) => `<option value="${escapeHtml(model.id)}" label="${escapeHtml(model.name || model.id)}"></option>`).join("");
117417
+ updateSeatReasoningEffortOptions();
117224
117418
  }
117225
117419
  async function refreshSeatModelCatalog({ force = false } = {}) {
117226
117420
  const connection = fullConnectionById(els.seatConnection?.value);
@@ -117274,9 +117468,41 @@ function defaultSeatDraft(seatIndex) {
117274
117468
  model: conn?.kind === "typesafe" ? "jev-latest" : "",
117275
117469
  protocol: conn?.kind === "typesafe" ? "jev_native" : "tool",
117276
117470
  provider: "",
117277
- captureReasoning: false
117471
+ captureReasoning: false,
117472
+ reasoningEffort: ""
117278
117473
  };
117279
117474
  }
117475
+ function updateSeatReasoningEffortOptions(selected) {
117476
+ const select = els.seatReasoningEffort;
117477
+ if (!select) return;
117478
+ const openRouter = isOpenRouterConnection(connectionById(els.seatConnection.value));
117479
+ const model = els.seatModel.value.trim();
117480
+ const efforts = openRouter ? reasoningEffortsFor(model) : [];
117481
+ const modelDefault = efforts.length ? defaultReasoningEffort(model) : null;
117482
+ const current = String(selected ?? seatReasoningEffortDraft ?? "").trim().toLowerCase();
117483
+ select.innerHTML = [
117484
+ '<option value="">Arena default (capped)</option>',
117485
+ ...efforts.map((effort) => `<option value="${escapeHtml(effort)}">${escapeHtml(effort)}${effort === modelDefault ? " \xB7 model default" : ""}</option>`)
117486
+ ].join("");
117487
+ if (efforts.includes(current)) {
117488
+ select.value = current;
117489
+ seatReasoningEffortDraft = current;
117490
+ } else {
117491
+ select.value = "";
117492
+ if (!current) seatReasoningEffortDraft = "";
117493
+ }
117494
+ select.disabled = !efforts.length;
117495
+ const help = els.seatReasoningEffortHelp;
117496
+ if (!help) return;
117497
+ help.dataset.tone = "";
117498
+ if (!openRouter) help.textContent = "Reasoning effort is available for OpenRouter models that advertise it.";
117499
+ else if (!efforts.length) help.textContent = "This model does not advertise reasoning efforts; the capped default is used.";
117500
+ else if (!select.value && requiresReasoning(model)) {
117501
+ help.textContent = `This model always reasons and ignores the capped default, so it can exceed the action clock and auto-fold. Pick an effort \u2014 ${efforts.includes("minimal") ? "minimal" : efforts[0]} is usually fastest.`;
117502
+ help.dataset.tone = "bad";
117503
+ } else if (modelDefault) help.textContent = `Model default is ${modelDefault}. Lower it when a slow reasoner runs out of action clock and auto-folds.`;
117504
+ else help.textContent = "Lower it when a slow reasoner runs out of action clock and auto-folds.";
117505
+ }
117280
117506
  function applySeatProtocolRules() {
117281
117507
  const conn = connectionById(els.seatConnection.value);
117282
117508
  const kind = conn?.kind || "openrouter";
@@ -117285,6 +117511,7 @@ function applySeatProtocolRules() {
117285
117511
  const jevSeat = kind === "typesafe" || jevViaOpenRouter;
117286
117512
  document.querySelector(".seat-provider-field")?.classList.toggle("hidden", !openRouter || jevViaOpenRouter);
117287
117513
  document.querySelector(".seat-reasoning-field")?.classList.toggle("hidden", jevSeat);
117514
+ document.querySelector(".seat-effort-field")?.classList.toggle("hidden", jevSeat);
117288
117515
  if (els.seatCaptureReasoning) {
117289
117516
  els.seatCaptureReasoning.disabled = jevSeat;
117290
117517
  const alreadyReasoning = isReasoningModel(els.seatModel.value.trim());
@@ -117305,6 +117532,7 @@ function applySeatProtocolRules() {
117305
117532
  els.seatProvider.disabled = !openRouter;
117306
117533
  if (els.seatModel.value === "jev-latest") els.seatModel.value = "";
117307
117534
  }
117535
+ updateSeatReasoningEffortOptions();
117308
117536
  }
117309
117537
  function updateSeatSummary() {
117310
117538
  const count = seatAssignments.filter(Boolean).length;
@@ -117331,13 +117559,16 @@ function openSeatEditor(seatIndex) {
117331
117559
  els.seatProtocol.value = draft.protocol || "tool";
117332
117560
  els.seatProvider.value = draft.provider || "";
117333
117561
  if (els.seatCaptureReasoning) els.seatCaptureReasoning.checked = Boolean(draft.captureReasoning);
117562
+ seatReasoningEffortDraft = String(draft.reasoningEffort || "").trim().toLowerCase();
117334
117563
  applySeatProtocolRules();
117564
+ updateSeatReasoningEffortOptions(seatReasoningEffortDraft);
117335
117565
  syncSeatNameFromModel();
117336
117566
  void refreshSeatModelCatalog();
117337
117567
  els.seatError.classList.add("hidden");
117338
117568
  els.seatLockNotice.classList.toggle("hidden", !locked);
117339
117569
  for (const control of [els.seatName, els.seatConnection, els.seatModel, els.seatProtocol, els.seatProvider]) control.disabled = locked || control === els.seatProvider && (!isOpenRouterConnection(connectionById(els.seatConnection.value)) || isJevModel(els.seatModel.value));
117340
117570
  if (els.seatCaptureReasoning) els.seatCaptureReasoning.disabled = locked || els.seatCaptureReasoning.disabled;
117571
+ if (els.seatReasoningEffort) els.seatReasoningEffort.disabled = locked || els.seatReasoningEffort.disabled;
117341
117572
  els.saveSeatBtn.disabled = locked;
117342
117573
  els.removeSeatBtn.disabled = locked || !seatAssignments[seatIndex];
117343
117574
  if (!els.seatDialog.open) els.seatDialog.showModal();
@@ -117355,7 +117586,8 @@ function readSeatDraft() {
117355
117586
  model: els.seatModel.value.trim(),
117356
117587
  protocol: els.seatProtocol.value,
117357
117588
  provider: els.seatProvider.disabled ? "" : els.seatProvider.value.trim(),
117358
- captureReasoning: Boolean(els.seatCaptureReasoning && !els.seatCaptureReasoning.disabled && els.seatCaptureReasoning.checked)
117589
+ captureReasoning: Boolean(els.seatCaptureReasoning && !els.seatCaptureReasoning.disabled && els.seatCaptureReasoning.checked),
117590
+ reasoningEffort: els.seatReasoningEffort && !els.seatReasoningEffort.disabled ? els.seatReasoningEffort.value.trim().toLowerCase() : ""
117359
117591
  };
117360
117592
  }
117361
117593
  function readSeatPlayers() {
@@ -117384,7 +117616,8 @@ function restoreSeatAssignments(rows = []) {
117384
117616
  model: player.model || "",
117385
117617
  protocol: player.protocol || "tool",
117386
117618
  provider: player.provider || "",
117387
- captureReasoning: player.captureReasoning === true
117619
+ captureReasoning: player.captureReasoning === true,
117620
+ reasoningEffort: String(player.reasoningEffort || "").trim().toLowerCase()
117388
117621
  };
117389
117622
  });
117390
117623
  updateSeatSummary();
@@ -118110,13 +118343,13 @@ function renderStats(s) {
118110
118343
  const pc = (value) => `${Math.round(Number(value || 0) * 100)}%`;
118111
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>
118112
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>
118113
- <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>`;
118114
118347
  }).join("");
118115
118348
  }
118116
118349
  function tableRenderSignature(s) {
118117
118350
  const running = ["RUNNING", "PAUSED"].includes(s?.status);
118118
118351
  if (lobbyVisible && !running) {
118119
- return JSON.stringify(["lobby", seatAssignments.map((p) => p ? [p.name, p.model, p.protocol, p.connectionId, p.provider, Boolean(p.captureReasoning)] : null)]);
118352
+ return JSON.stringify(["lobby", seatAssignments.map((p) => p ? [p.name, p.model, p.protocol, p.connectionId, p.provider, Boolean(p.captureReasoning), p.reasoningEffort || ""] : null)]);
118120
118353
  }
118121
118354
  const t = s?.table;
118122
118355
  if (!t) return JSON.stringify(["empty", s?.status || "IDLE"]);
@@ -119500,6 +119733,9 @@ els.seatName.addEventListener("input", () => {
119500
119733
  });
119501
119734
  els.seatProtocol.addEventListener("change", applySeatProtocolRules);
119502
119735
  els.seatCaptureReasoning?.addEventListener("change", applySeatProtocolRules);
119736
+ els.seatReasoningEffort?.addEventListener("change", () => {
119737
+ seatReasoningEffortDraft = els.seatReasoningEffort.value;
119738
+ });
119503
119739
  els.refreshModelsBtn?.addEventListener("click", () => void refreshSeatModelCatalog({ force: true }));
119504
119740
  els.removeSeatBtn.addEventListener("click", () => {
119505
119741
  if (editingSeatIndex == null || director && ["RUNNING", "PAUSED"].includes(director.status)) return;
package/dist/index.html CHANGED
@@ -304,6 +304,11 @@
304
304
  <span class="checkbox-field"><input id="seatCaptureReasoning" type="checkbox" /> Capture raw model reasoning</span>
305
305
  <span class="field-help seat-reasoning-help hidden">This model is not a reasoning model by default. Capturing its reasoning can be far slower (Gemma measured ~2s → 27–48s per decision) and is outside the standard benchmark track. Raise the action clock before using it.</span>
306
306
  </label>
307
+ <label class="seat-effort-field">
308
+ <span class="field-label-row">Reasoning effort <button class="info-button inline-info" type="button" aria-label="About reasoning effort" data-tip="How much hidden thinking the model may spend. Higher effort is smarter but slower; a slow reasoner can exhaust the action clock and be forced to fold. Options come from the OpenRouter model catalogue, and an unset value keeps the arena's fixed reasoning cap.">i</button></span>
309
+ <select id="seatReasoningEffort"></select>
310
+ <span id="seatReasoningEffortHelp" class="field-help"></span>
311
+ </label>
307
312
  </div>
308
313
  </div>
309
314
  <div id="seatError" class="setup-error hidden"></div>