pokertools-arena 0.4.1 → 0.4.5

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.4.1.** A maintenance release on top of the 0.4.0 methodology work: modal tooltips now float above overflow, the launcher starts exactly once when its default port is busy, and the real-API diagnostics skip cleanly when repository secrets are absent.
20
+ > **Current release: 0.4.5.** A maintenance release on top of the 0.4.0 methodology work: modal tooltips float above overflow, the launcher starts exactly once when its default port is busy, real-API diagnostics skip cleanly when repository secrets are absent, the Decision sanity suite attributes results to the correct model, seat editors show the model-derived name, close buttons use centered SVG icons, decision logs no longer repeat the typed-decision caption, and Stop immediately freezes the clock and table effects.
21
21
 
22
22
  ---
23
23
 
package/dist/app.js CHANGED
@@ -115138,7 +115138,6 @@ function decide(agent, connection, args) {
115138
115138
  }
115139
115139
  var FAMILY_OBJECTIVE = "Choose exactly one legal action family that best maximizes tournament chip EV from the supplied state. Do not choose an amount; a size is chosen in a separate step.";
115140
115140
  var SIZE_OBJECTIVE = (family) => `The action family ${String(family).toUpperCase()} has already been selected. Choose exactly one legal ${family === ACTION_FAMILY.RAISE ? "raise size" : "bet size"} that best maximizes tournament chip EV. Do not change the action family.`;
115141
- var SPECTATOR_NOTE = "This model returns typed decisions rather than a text rationale.";
115142
115141
  function buildJevFamilyQuestions(families, { instructions = FAMILY_OBJECTIVE, criteria = null } = {}) {
115143
115142
  return { type: "choice", instructions, criteria: criteria ?? familyCriteria(families) };
115144
115143
  }
@@ -115594,6 +115593,7 @@ var lastProcessedEventId = null;
115594
115593
  var MAX_LOBBY_SEATS = 10;
115595
115594
  var seatAssignments = Array(MAX_LOBBY_SEATS).fill(null);
115596
115595
  var editingSeatIndex = null;
115596
+ var seatNameAuto = false;
115597
115597
  var lobbyVisible = true;
115598
115598
  var pendingAutostart = false;
115599
115599
  var arenaMaxDecisions = 0;
@@ -115700,6 +115700,16 @@ function showActionToast(text, type = "") {
115700
115700
  function seatEl(playerId) {
115701
115701
  return $(`.seat[data-player-id="${CSS.escape(String(playerId))}"]`, els.seatsLayer);
115702
115702
  }
115703
+ function stopTableEffects() {
115704
+ clearTimeout(showActionToast.timer);
115705
+ els.actionToast?.classList.add("hidden");
115706
+ els.pokerTable?.classList.remove("action-message-visible");
115707
+ els.fxLayer?.replaceChildren();
115708
+ for (const el of $$(".seat.fold-flash, .seat.check-flash")) {
115709
+ el.classList.remove("fold-flash");
115710
+ el.classList.remove("check-flash");
115711
+ }
115712
+ }
115703
115713
  function animateNewHand(s) {
115704
115714
  if (!animationsAllowed()) return;
115705
115715
  requestAnimationFrame(() => {
@@ -115727,6 +115737,12 @@ function animateBoardCards(previousCount, currentCount) {
115727
115737
  }
115728
115738
  function processVisualEffects(s) {
115729
115739
  const events = s?.events || [];
115740
+ if (["STOPPED", "ERROR"].includes(s?.status)) {
115741
+ if (events.length) lastProcessedEventId = events.at(-1).id;
115742
+ lastVisualState = s;
115743
+ stopTableEffects();
115744
+ return;
115745
+ }
115730
115746
  if (!animationsAllowed()) {
115731
115747
  if (events.length) lastProcessedEventId = events.at(-1).id;
115732
115748
  lastVisualState = s;
@@ -116863,19 +116879,28 @@ function updateSeatSummary() {
116863
116879
  const count = seatAssignments.filter(Boolean).length;
116864
116880
  if (els.seatSummary) els.seatSummary.textContent = `${count} / ${MAX_LOBBY_SEATS} seated`;
116865
116881
  }
116882
+ function syncSeatNameFromModel() {
116883
+ if (!seatNameAuto || editingSeatIndex == null) return;
116884
+ const model = els.seatModel.value.trim();
116885
+ els.seatName.value = model ? displayModelName(model) : `Player ${editingSeatIndex + 1}`;
116886
+ }
116866
116887
  function openSeatEditor(seatIndex) {
116867
116888
  if (!Number.isInteger(seatIndex) || seatIndex < 0 || seatIndex >= MAX_LOBBY_SEATS) return;
116868
116889
  editingSeatIndex = seatIndex;
116869
116890
  const locked = Boolean(director && ["RUNNING", "PAUSED"].includes(director.status));
116870
116891
  const draft = seatAssignments[seatIndex] ? { ...seatAssignments[seatIndex] } : defaultSeatDraft(seatIndex);
116871
116892
  els.seatDialogTitle.textContent = `Seat ${seatIndex + 1}`;
116872
- els.seatName.value = draft.name || `Player ${seatIndex + 1}`;
116893
+ const placeholderName = `Player ${seatIndex + 1}`;
116894
+ const storedName = String(draft.name || "").trim();
116895
+ seatNameAuto = !storedName || /^player\s+\d+$/i.test(storedName) || Boolean(draft.model) && storedName === displayModelName(draft.model);
116896
+ els.seatName.value = seatNameAuto && draft.model ? displayModelName(draft.model) : storedName || placeholderName;
116873
116897
  refreshSeatConnectionSelect(draft.connectionId || "");
116874
116898
  if (draft.connectionId && [...els.seatConnection.options].some((option) => option.value === draft.connectionId)) els.seatConnection.value = draft.connectionId;
116875
116899
  els.seatModel.value = draft.model || "";
116876
116900
  els.seatProtocol.value = draft.protocol || "tool";
116877
116901
  els.seatProvider.value = draft.provider || "";
116878
116902
  applySeatProtocolRules();
116903
+ syncSeatNameFromModel();
116879
116904
  void refreshSeatModelCatalog();
116880
116905
  els.seatError.classList.add("hidden");
116881
116906
  els.seatLockNotice.classList.toggle("hidden", !locked);
@@ -117317,8 +117342,6 @@ function decisionTelemetryHtml(event, { limit = 4 } = {}) {
117317
117342
  const reasoningTokens = event?.usage?.completion_tokens_details?.reasoning_tokens;
117318
117343
  if (reasoningTokens) facts.push(`<span>Reasoning <b>${reasoningTokens} tokens</b></span>`);
117319
117344
  if (facts.length) parts.push(`<div class="decision-facts">${facts.join("")}</div>`);
117320
- const isTypedDecision = meta.family != null || meta.aggression != null || meta.bluffSpot != null || meta.method === "openrouter-decisions" || meta.method === "jev-choice" || String(meta.method || "").includes("hierarchical");
117321
- if (isTypedDecision) parts.push(`<div class="decision-facts-note">${escapeHtml(SPECTATOR_NOTE)} Bluff opportunity is a property of the spot, not the reason for the chosen action.</div>`);
117322
117345
  return parts.join("");
117323
117346
  }
117324
117347
  function setDecisionContext({ hand = "\u2014", street = "\u2014", position = "\u2014", options = "\u2014", label = "Legal actions", hint = "Choose one", labels = null } = {}) {
@@ -117346,6 +117369,10 @@ function championBadgeHtml() {
117346
117369
  return '<span class="champion-badge" role="img" aria-label="Tournament champion"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6.6 2.6h10.8v4.7a5.4 5.4 0 0 1-10.8 0V2.6Z"/><path d="M10.7 12.4h2.6V15h-2.6z"/><path d="M7.2 15h9.6v2.1H7.2z"/><path d="M5.6 18.2h12.8v2.3H5.6z"/></svg></span>';
117347
117370
  }
117348
117371
  function renderDecision(s) {
117372
+ if (["STOPPED", "ERROR"].includes(s?.status)) {
117373
+ stopClock();
117374
+ return;
117375
+ }
117349
117376
  const d = s?.currentDecision;
117350
117377
  const last = latestDecisionEvent(s);
117351
117378
  const shouldShowCard = Boolean(d || last || seatAssignments.filter(Boolean).length);
@@ -117576,7 +117603,7 @@ function cloneJson(value) {
117576
117603
  }
117577
117604
  function sanityAgents() {
117578
117605
  const connections = readConnections();
117579
- return readSeatPlayers().map((player) => ({ ...player, connection: connections.find((c) => c.id === player.connectionId) })).filter((row) => row.connection);
117606
+ return readSeatPlayers().map((player) => ({ ...player, id: player.id || `player-${player.lobbySeat + 1}`, connection: connections.find((c) => c.id === player.connectionId) })).filter((row) => row.connection);
117580
117607
  }
117581
117608
  function sanityProtocolLabel(agent) {
117582
117609
  const protocol = effectiveProtocol(agent, agent.connection);
@@ -118262,7 +118289,12 @@ els.pauseBtn.addEventListener("click", () => {
118262
118289
  if (!director) return;
118263
118290
  currentState?.status === "PAUSED" ? director.resume() : director.pause();
118264
118291
  });
118265
- els.stopBtn.addEventListener("click", () => director?.stop());
118292
+ els.stopBtn.addEventListener("click", () => {
118293
+ if (!director) return;
118294
+ stopClock();
118295
+ stopTableEffects();
118296
+ director.stop();
118297
+ });
118266
118298
  els.exportBtn.addEventListener("click", () => {
118267
118299
  if (!director?.events?.length) return;
118268
118300
  downloadText(`${director.config?.id || "pokertools-arena"}.jsonl`, director.exportJsonl());
@@ -118288,9 +118320,16 @@ for (const dialog of [els.setupDialog, els.seatDialog, els.testsDialog, els.repl
118288
118320
  }
118289
118321
  els.seatConnection.addEventListener("change", () => {
118290
118322
  applySeatProtocolRules();
118323
+ syncSeatNameFromModel();
118291
118324
  void refreshSeatModelCatalog();
118292
118325
  });
118293
- els.seatModel.addEventListener("input", applySeatProtocolRules);
118326
+ els.seatModel.addEventListener("input", () => {
118327
+ applySeatProtocolRules();
118328
+ syncSeatNameFromModel();
118329
+ });
118330
+ els.seatName.addEventListener("input", () => {
118331
+ seatNameAuto = false;
118332
+ });
118294
118333
  els.seatProtocol.addEventListener("change", applySeatProtocolRules);
118295
118334
  els.refreshModelsBtn?.addEventListener("click", () => void refreshSeatModelCatalog({ force: true }));
118296
118335
  els.removeSeatBtn.addEventListener("click", () => {
package/dist/index.html CHANGED
@@ -165,7 +165,7 @@
165
165
  <h2>Settings</h2>
166
166
  <button class="info-button" type="button" aria-label="About settings" data-tip="API keys stay in this browser tab and are never written to saved configuration or tournament logs.">i</button>
167
167
  </div>
168
- <button id="closeSetup" type="button" class="icon-button" aria-label="Close settings">×</button>
168
+ <button id="closeSetup" type="button" class="icon-button" aria-label="Close settings"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
169
169
  </div>
170
170
 
171
171
  <div class="dialog-body setup-body">
@@ -246,7 +246,7 @@
246
246
  <h2 id="seatDialogTitle">Seat 1</h2>
247
247
  <button class="info-button" type="button" aria-label="About seat context" data-tip="Each model receives only its own hole cards plus the same canonical public table state, recent public hands, opponent statistics, and legal actions.">i</button>
248
248
  </div>
249
- <button id="closeSeat" type="button" class="icon-button" aria-label="Close seat editor">×</button>
249
+ <button id="closeSeat" type="button" class="icon-button" aria-label="Close seat editor"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
250
250
  </div>
251
251
  <div class="dialog-body seat-editor-body">
252
252
  <div id="seatLockNotice" class="seat-lock-notice hidden">Seat assignments are locked during a tournament.</div>
@@ -284,7 +284,7 @@
284
284
  <div class="dialog-title-row"><h2>Decision sanity suite</h2><button class="info-button" type="button" aria-label="About decision tests" data-tip="A small comprehension and reliability check. It is intentionally not a GTO solver, Elo score, or tournament-strength ranking.">i</button></div>
285
285
  <div class="dialog-subtitle">The same 10 fixed poker spots are sent independently to every seated model.</div>
286
286
  </div>
287
- <button id="closeTests" type="button" class="icon-button" aria-label="Close tests">×</button>
287
+ <button id="closeTests" type="button" class="icon-button" aria-label="Close tests"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
288
288
  </div>
289
289
  <div class="tests-body">
290
290
  <div class="tests-how" aria-label="How the suite works">
@@ -317,7 +317,7 @@
317
317
  <div class="dialog-title-row"><h2 id="replayTitle">Decision replay</h2><span id="replayBadge" class="replay-badge">HAND —</span></div>
318
318
  <div id="replaySubtitle" class="dialog-subtitle">Snapshot captured immediately before the model acted.</div>
319
319
  </div>
320
- <button id="closeReplay" type="button" class="icon-button" aria-label="Close decision replay">×</button>
320
+ <button id="closeReplay" type="button" class="icon-button" aria-label="Close decision replay"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
321
321
  </div>
322
322
  <div class="dialog-body replay-body">
323
323
  <section class="replay-table-wrap">
@@ -361,7 +361,7 @@
361
361
  <span class="field-label-row">Preset <button class="info-button inline-info" type="button" aria-label="About connection presets" data-tip="OpenAI-compatible keeps Base URL fully editable. OpenRouter only fills its standard URL and enables OpenRouter-specific routing. TypeSafe uses the native System One endpoint.">i</button></span>
362
362
  <select data-field="kind"><option value="openai">OpenAI-compatible</option><option value="openrouter">OpenRouter</option><option value="typesafe">TypeSafe System One</option></select>
363
363
  </label>
364
- <button type="button" class="remove-connection icon-button" title="Remove connection" aria-label="Remove connection">×</button>
364
+ <button type="button" class="remove-connection icon-button" title="Remove connection" aria-label="Remove connection"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
365
365
  </div>
366
366
  <div class="connection-main-grid">
367
367
  <label class="connection-url">Base URL<input data-field="baseUrl" spellcheck="false" placeholder="https://api.example.com/v1" /></label>
@@ -895,7 +895,11 @@ input::placeholder{color:#4e5862}
895
895
  .button,.icon-button,.field-icon-button,input,select{box-sizing:border-box}
896
896
  .button{min-height:var(--control-h);padding:0 12px;border-radius:var(--radius-sm);font-size:10px;font-weight:800;line-height:1;white-space:nowrap}
897
897
  .small-button{min-height:30px;height:30px;padding-inline:10px;font-size:9px}
898
- .icon-button{width:30px;height:30px;min-width:30px;border-radius:var(--radius-sm);display:grid;place-items:center;padding:0}
898
+ .icon-button{width:30px;height:30px;min-width:30px;border-radius:var(--radius-sm);display:grid;place-items:center;padding:0;line-height:1}
899
+ /* SVG glyphs center geometrically; a text "×" sits on its baseline and looks
900
+ vertically off-centre inside these small buttons. */
901
+ .icon-button svg{width:14px;height:14px;display:block;pointer-events:none}
902
+ .dialog-head .icon-button svg{width:13px;height:13px}
899
903
  input,select{width:100%;min-width:0;height:var(--control-h);padding:0 9px;border-radius:var(--radius-sm);font-size:11px;background:#0a0d10;border:1px solid var(--modal-border);color:var(--text)}
900
904
  input:focus,select:focus{outline:none;border-color:rgba(146,201,164,.34);box-shadow:0 0 0 3px rgba(146,201,164,.05)}
901
905
  label{min-width:0}
@@ -2321,10 +2325,7 @@ a.button{text-decoration:none}
2321
2325
  .card .card-corner.bottom{display:none!important}
2322
2326
  }
2323
2327
 
2324
- /* 0.2.23 — decision diagnostics + deterministic hand evaluation
2325
- Spectator-only annotations. The note explicitly separates typed model
2326
- answers from any fabricated rationale. */
2327
- .decision-facts-note{margin-top:6px;font-size:10px;line-height:1.4;color:var(--muted,#8aa098);border-left:2px solid rgba(184,236,111,.35);padding-left:8px}
2328
+ /* 0.2.23 — decision diagnostics + deterministic hand evaluation. */
2328
2329
  .replay-reason-text{margin-bottom:10px;line-height:1.45}
2329
2330
  .replay-summary span b{white-space:nowrap}
2330
2331
 
@@ -2339,7 +2340,6 @@ a.button{text-decoration:none}
2339
2340
  .stage-chip{white-space:nowrap}
2340
2341
  .telemetry-group{margin-top:6px}
2341
2342
  .telemetry-caption{display:block;font-size:9px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted,#8aa098);margin-bottom:3px}
2342
- .decision-facts-note{white-space:normal}
2343
2343
 
2344
2344
  /* 0.4.0 — methodology, reproducibility and repository-organization release.
2345
2345
  Browser application source moved under src/; visual behavior is unchanged. */
@@ -2456,7 +2456,7 @@ a.button{text-decoration:none}
2456
2456
  <h2>Settings</h2>
2457
2457
  <button class="info-button" type="button" aria-label="About settings" data-tip="API keys stay in this browser tab and are never written to saved configuration or tournament logs.">i</button>
2458
2458
  </div>
2459
- <button id="closeSetup" type="button" class="icon-button" aria-label="Close settings">×</button>
2459
+ <button id="closeSetup" type="button" class="icon-button" aria-label="Close settings"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
2460
2460
  </div>
2461
2461
 
2462
2462
  <div class="dialog-body setup-body">
@@ -2537,7 +2537,7 @@ a.button{text-decoration:none}
2537
2537
  <h2 id="seatDialogTitle">Seat 1</h2>
2538
2538
  <button class="info-button" type="button" aria-label="About seat context" data-tip="Each model receives only its own hole cards plus the same canonical public table state, recent public hands, opponent statistics, and legal actions.">i</button>
2539
2539
  </div>
2540
- <button id="closeSeat" type="button" class="icon-button" aria-label="Close seat editor">×</button>
2540
+ <button id="closeSeat" type="button" class="icon-button" aria-label="Close seat editor"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
2541
2541
  </div>
2542
2542
  <div class="dialog-body seat-editor-body">
2543
2543
  <div id="seatLockNotice" class="seat-lock-notice hidden">Seat assignments are locked during a tournament.</div>
@@ -2575,7 +2575,7 @@ a.button{text-decoration:none}
2575
2575
  <div class="dialog-title-row"><h2>Decision sanity suite</h2><button class="info-button" type="button" aria-label="About decision tests" data-tip="A small comprehension and reliability check. It is intentionally not a GTO solver, Elo score, or tournament-strength ranking.">i</button></div>
2576
2576
  <div class="dialog-subtitle">The same 10 fixed poker spots are sent independently to every seated model.</div>
2577
2577
  </div>
2578
- <button id="closeTests" type="button" class="icon-button" aria-label="Close tests">×</button>
2578
+ <button id="closeTests" type="button" class="icon-button" aria-label="Close tests"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
2579
2579
  </div>
2580
2580
  <div class="tests-body">
2581
2581
  <div class="tests-how" aria-label="How the suite works">
@@ -2608,7 +2608,7 @@ a.button{text-decoration:none}
2608
2608
  <div class="dialog-title-row"><h2 id="replayTitle">Decision replay</h2><span id="replayBadge" class="replay-badge">HAND —</span></div>
2609
2609
  <div id="replaySubtitle" class="dialog-subtitle">Snapshot captured immediately before the model acted.</div>
2610
2610
  </div>
2611
- <button id="closeReplay" type="button" class="icon-button" aria-label="Close decision replay">×</button>
2611
+ <button id="closeReplay" type="button" class="icon-button" aria-label="Close decision replay"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
2612
2612
  </div>
2613
2613
  <div class="dialog-body replay-body">
2614
2614
  <section class="replay-table-wrap">
@@ -2652,7 +2652,7 @@ a.button{text-decoration:none}
2652
2652
  <span class="field-label-row">Preset <button class="info-button inline-info" type="button" aria-label="About connection presets" data-tip="OpenAI-compatible keeps Base URL fully editable. OpenRouter only fills its standard URL and enables OpenRouter-specific routing. TypeSafe uses the native System One endpoint.">i</button></span>
2653
2653
  <select data-field="kind"><option value="openai">OpenAI-compatible</option><option value="openrouter">OpenRouter</option><option value="typesafe">TypeSafe System One</option></select>
2654
2654
  </label>
2655
- <button type="button" class="remove-connection icon-button" title="Remove connection" aria-label="Remove connection">×</button>
2655
+ <button type="button" class="remove-connection icon-button" title="Remove connection" aria-label="Remove connection"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
2656
2656
  </div>
2657
2657
  <div class="connection-main-grid">
2658
2658
  <label class="connection-url">Base URL<input data-field="baseUrl" spellcheck="false" placeholder="https://api.example.com/v1" /></label>
@@ -117813,7 +117813,6 @@ function decide(agent, connection, args) {
117813
117813
  }
117814
117814
  var FAMILY_OBJECTIVE = "Choose exactly one legal action family that best maximizes tournament chip EV from the supplied state. Do not choose an amount; a size is chosen in a separate step.";
117815
117815
  var SIZE_OBJECTIVE = (family) => `The action family ${String(family).toUpperCase()} has already been selected. Choose exactly one legal ${family === ACTION_FAMILY.RAISE ? "raise size" : "bet size"} that best maximizes tournament chip EV. Do not change the action family.`;
117816
- var SPECTATOR_NOTE = "This model returns typed decisions rather than a text rationale.";
117817
117816
  function buildJevFamilyQuestions(families, { instructions = FAMILY_OBJECTIVE, criteria = null } = {}) {
117818
117817
  return { type: "choice", instructions, criteria: criteria ?? familyCriteria(families) };
117819
117818
  }
@@ -118269,6 +118268,7 @@ var lastProcessedEventId = null;
118269
118268
  var MAX_LOBBY_SEATS = 10;
118270
118269
  var seatAssignments = Array(MAX_LOBBY_SEATS).fill(null);
118271
118270
  var editingSeatIndex = null;
118271
+ var seatNameAuto = false;
118272
118272
  var lobbyVisible = true;
118273
118273
  var pendingAutostart = false;
118274
118274
  var arenaMaxDecisions = 0;
@@ -118375,6 +118375,16 @@ function showActionToast(text, type = "") {
118375
118375
  function seatEl(playerId) {
118376
118376
  return $(`.seat[data-player-id="${CSS.escape(String(playerId))}"]`, els.seatsLayer);
118377
118377
  }
118378
+ function stopTableEffects() {
118379
+ clearTimeout(showActionToast.timer);
118380
+ els.actionToast?.classList.add("hidden");
118381
+ els.pokerTable?.classList.remove("action-message-visible");
118382
+ els.fxLayer?.replaceChildren();
118383
+ for (const el of $$(".seat.fold-flash, .seat.check-flash")) {
118384
+ el.classList.remove("fold-flash");
118385
+ el.classList.remove("check-flash");
118386
+ }
118387
+ }
118378
118388
  function animateNewHand(s) {
118379
118389
  if (!animationsAllowed()) return;
118380
118390
  requestAnimationFrame(() => {
@@ -118402,6 +118412,12 @@ function animateBoardCards(previousCount, currentCount) {
118402
118412
  }
118403
118413
  function processVisualEffects(s) {
118404
118414
  const events = s?.events || [];
118415
+ if (["STOPPED", "ERROR"].includes(s?.status)) {
118416
+ if (events.length) lastProcessedEventId = events.at(-1).id;
118417
+ lastVisualState = s;
118418
+ stopTableEffects();
118419
+ return;
118420
+ }
118405
118421
  if (!animationsAllowed()) {
118406
118422
  if (events.length) lastProcessedEventId = events.at(-1).id;
118407
118423
  lastVisualState = s;
@@ -119538,19 +119554,28 @@ function updateSeatSummary() {
119538
119554
  const count = seatAssignments.filter(Boolean).length;
119539
119555
  if (els.seatSummary) els.seatSummary.textContent = `${count} / ${MAX_LOBBY_SEATS} seated`;
119540
119556
  }
119557
+ function syncSeatNameFromModel() {
119558
+ if (!seatNameAuto || editingSeatIndex == null) return;
119559
+ const model = els.seatModel.value.trim();
119560
+ els.seatName.value = model ? displayModelName(model) : `Player ${editingSeatIndex + 1}`;
119561
+ }
119541
119562
  function openSeatEditor(seatIndex) {
119542
119563
  if (!Number.isInteger(seatIndex) || seatIndex < 0 || seatIndex >= MAX_LOBBY_SEATS) return;
119543
119564
  editingSeatIndex = seatIndex;
119544
119565
  const locked = Boolean(director && ["RUNNING", "PAUSED"].includes(director.status));
119545
119566
  const draft = seatAssignments[seatIndex] ? { ...seatAssignments[seatIndex] } : defaultSeatDraft(seatIndex);
119546
119567
  els.seatDialogTitle.textContent = `Seat ${seatIndex + 1}`;
119547
- els.seatName.value = draft.name || `Player ${seatIndex + 1}`;
119568
+ const placeholderName = `Player ${seatIndex + 1}`;
119569
+ const storedName = String(draft.name || "").trim();
119570
+ seatNameAuto = !storedName || /^player\s+\d+$/i.test(storedName) || Boolean(draft.model) && storedName === displayModelName(draft.model);
119571
+ els.seatName.value = seatNameAuto && draft.model ? displayModelName(draft.model) : storedName || placeholderName;
119548
119572
  refreshSeatConnectionSelect(draft.connectionId || "");
119549
119573
  if (draft.connectionId && [...els.seatConnection.options].some((option) => option.value === draft.connectionId)) els.seatConnection.value = draft.connectionId;
119550
119574
  els.seatModel.value = draft.model || "";
119551
119575
  els.seatProtocol.value = draft.protocol || "tool";
119552
119576
  els.seatProvider.value = draft.provider || "";
119553
119577
  applySeatProtocolRules();
119578
+ syncSeatNameFromModel();
119554
119579
  void refreshSeatModelCatalog();
119555
119580
  els.seatError.classList.add("hidden");
119556
119581
  els.seatLockNotice.classList.toggle("hidden", !locked);
@@ -119992,8 +120017,6 @@ function decisionTelemetryHtml(event, { limit = 4 } = {}) {
119992
120017
  const reasoningTokens = event?.usage?.completion_tokens_details?.reasoning_tokens;
119993
120018
  if (reasoningTokens) facts.push(`<span>Reasoning <b>${reasoningTokens} tokens</b></span>`);
119994
120019
  if (facts.length) parts.push(`<div class="decision-facts">${facts.join("")}</div>`);
119995
- const isTypedDecision = meta.family != null || meta.aggression != null || meta.bluffSpot != null || meta.method === "openrouter-decisions" || meta.method === "jev-choice" || String(meta.method || "").includes("hierarchical");
119996
- if (isTypedDecision) parts.push(`<div class="decision-facts-note">${escapeHtml(SPECTATOR_NOTE)} Bluff opportunity is a property of the spot, not the reason for the chosen action.</div>`);
119997
120020
  return parts.join("");
119998
120021
  }
119999
120022
  function setDecisionContext({ hand = "\u2014", street = "\u2014", position = "\u2014", options = "\u2014", label = "Legal actions", hint = "Choose one", labels = null } = {}) {
@@ -120021,6 +120044,10 @@ function championBadgeHtml() {
120021
120044
  return '<span class="champion-badge" role="img" aria-label="Tournament champion"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6.6 2.6h10.8v4.7a5.4 5.4 0 0 1-10.8 0V2.6Z"/><path d="M10.7 12.4h2.6V15h-2.6z"/><path d="M7.2 15h9.6v2.1H7.2z"/><path d="M5.6 18.2h12.8v2.3H5.6z"/></svg></span>';
120022
120045
  }
120023
120046
  function renderDecision(s) {
120047
+ if (["STOPPED", "ERROR"].includes(s?.status)) {
120048
+ stopClock();
120049
+ return;
120050
+ }
120024
120051
  const d = s?.currentDecision;
120025
120052
  const last = latestDecisionEvent(s);
120026
120053
  const shouldShowCard = Boolean(d || last || seatAssignments.filter(Boolean).length);
@@ -120251,7 +120278,7 @@ function cloneJson(value) {
120251
120278
  }
120252
120279
  function sanityAgents() {
120253
120280
  const connections = readConnections();
120254
- return readSeatPlayers().map((player) => ({ ...player, connection: connections.find((c) => c.id === player.connectionId) })).filter((row) => row.connection);
120281
+ return readSeatPlayers().map((player) => ({ ...player, id: player.id || `player-${player.lobbySeat + 1}`, connection: connections.find((c) => c.id === player.connectionId) })).filter((row) => row.connection);
120255
120282
  }
120256
120283
  function sanityProtocolLabel(agent) {
120257
120284
  const protocol = effectiveProtocol(agent, agent.connection);
@@ -120937,7 +120964,12 @@ els.pauseBtn.addEventListener("click", () => {
120937
120964
  if (!director) return;
120938
120965
  currentState?.status === "PAUSED" ? director.resume() : director.pause();
120939
120966
  });
120940
- els.stopBtn.addEventListener("click", () => director?.stop());
120967
+ els.stopBtn.addEventListener("click", () => {
120968
+ if (!director) return;
120969
+ stopClock();
120970
+ stopTableEffects();
120971
+ director.stop();
120972
+ });
120941
120973
  els.exportBtn.addEventListener("click", () => {
120942
120974
  if (!director?.events?.length) return;
120943
120975
  downloadText(`${director.config?.id || "pokertools-arena"}.jsonl`, director.exportJsonl());
@@ -120963,9 +120995,16 @@ for (const dialog of [els.setupDialog, els.seatDialog, els.testsDialog, els.repl
120963
120995
  }
120964
120996
  els.seatConnection.addEventListener("change", () => {
120965
120997
  applySeatProtocolRules();
120998
+ syncSeatNameFromModel();
120966
120999
  void refreshSeatModelCatalog();
120967
121000
  });
120968
- els.seatModel.addEventListener("input", applySeatProtocolRules);
121001
+ els.seatModel.addEventListener("input", () => {
121002
+ applySeatProtocolRules();
121003
+ syncSeatNameFromModel();
121004
+ });
121005
+ els.seatName.addEventListener("input", () => {
121006
+ seatNameAuto = false;
121007
+ });
120969
121008
  els.seatProtocol.addEventListener("change", applySeatProtocolRules);
120970
121009
  els.refreshModelsBtn?.addEventListener("click", () => void refreshSeatModelCatalog({ force: true }));
120971
121010
  els.removeSeatBtn.addEventListener("click", () => {
package/dist/styles.css CHANGED
@@ -839,7 +839,11 @@ input::placeholder{color:#4e5862}
839
839
  .button,.icon-button,.field-icon-button,input,select{box-sizing:border-box}
840
840
  .button{min-height:var(--control-h);padding:0 12px;border-radius:var(--radius-sm);font-size:10px;font-weight:800;line-height:1;white-space:nowrap}
841
841
  .small-button{min-height:30px;height:30px;padding-inline:10px;font-size:9px}
842
- .icon-button{width:30px;height:30px;min-width:30px;border-radius:var(--radius-sm);display:grid;place-items:center;padding:0}
842
+ .icon-button{width:30px;height:30px;min-width:30px;border-radius:var(--radius-sm);display:grid;place-items:center;padding:0;line-height:1}
843
+ /* SVG glyphs center geometrically; a text "×" sits on its baseline and looks
844
+ vertically off-centre inside these small buttons. */
845
+ .icon-button svg{width:14px;height:14px;display:block;pointer-events:none}
846
+ .dialog-head .icon-button svg{width:13px;height:13px}
843
847
  input,select{width:100%;min-width:0;height:var(--control-h);padding:0 9px;border-radius:var(--radius-sm);font-size:11px;background:#0a0d10;border:1px solid var(--modal-border);color:var(--text)}
844
848
  input:focus,select:focus{outline:none;border-color:rgba(146,201,164,.34);box-shadow:0 0 0 3px rgba(146,201,164,.05)}
845
849
  label{min-width:0}
@@ -2265,10 +2269,7 @@ a.button{text-decoration:none}
2265
2269
  .card .card-corner.bottom{display:none!important}
2266
2270
  }
2267
2271
 
2268
- /* 0.2.23 — decision diagnostics + deterministic hand evaluation
2269
- Spectator-only annotations. The note explicitly separates typed model
2270
- answers from any fabricated rationale. */
2271
- .decision-facts-note{margin-top:6px;font-size:10px;line-height:1.4;color:var(--muted,#8aa098);border-left:2px solid rgba(184,236,111,.35);padding-left:8px}
2272
+ /* 0.2.23 — decision diagnostics + deterministic hand evaluation. */
2272
2273
  .replay-reason-text{margin-bottom:10px;line-height:1.45}
2273
2274
  .replay-summary span b{white-space:nowrap}
2274
2275
 
@@ -2283,7 +2284,6 @@ a.button{text-decoration:none}
2283
2284
  .stage-chip{white-space:nowrap}
2284
2285
  .telemetry-group{margin-top:6px}
2285
2286
  .telemetry-caption{display:block;font-size:9px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted,#8aa098);margin-bottom:3px}
2286
- .decision-facts-note{white-space:normal}
2287
2287
 
2288
2288
  /* 0.4.0 — methodology, reproducibility and repository-organization release.
2289
2289
  Browser application source moved under src/; visual behavior is unchanged. */
@@ -1,4 +1,4 @@
1
- # pokertools-arena 0.4.1 — benchmark methodology & results
1
+ # pokertools-arena 0.4.5 — benchmark methodology & results
2
2
 
3
3
  Generated: 2026-09-19T18:19:58.352Z
4
4
  Methodology: paired fixed-state corpus; deterministic interleaving with recorded experiment seed.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.4.1",
3
+ "version": "0.4.5",
4
4
  "generatedAt": "2026-09-19T18:19:58.352Z",
5
5
  "experiments": [
6
6
  "paired"
@@ -1,5 +1,43 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.4.5 — Maintenance
4
+
5
+ - **Stop now freezes the display.** Stopping a tournament clears the decision
6
+ clock and cancels every table effect still in flight (chip flies, action
7
+ toast, fold/check flashes and their pending timers). Previously the turn ring
8
+ and clock kept counting and queued animations finished after the run had
9
+ stopped, because the last broadcast still carried `currentDecision` and the
10
+ effects queue was never drained. `renderDecision` and `processVisualEffects`
11
+ now hard-stop on `STOPPED`/`ERROR`.
12
+
13
+ ## 0.4.4 — Maintenance
14
+
15
+ - **Cleaner decision logs.** Removed the repeated spectator caption ("This
16
+ model returns typed decisions rather than a text rationale…") that appeared on
17
+ every typed decision in the live feed and the replay panel. Typed decisions
18
+ already show their selected family/size plus confidence telemetry, so logs are
19
+ concise and no longer restate that a rationale is absent. `SPECTATOR_NOTE` and
20
+ its CSS were removed; release checks guard against reintroduction.
21
+
22
+ ## 0.4.3 — Maintenance
23
+
24
+ - **Seat editor name.** Opening a seat restored from `.env` or saved
25
+ configuration now shows the model-derived name (for example `Gemma`) instead
26
+ of the generic `Player 1`, matching the table, lobby and stats surfaces. The
27
+ name follows the chosen model until it is edited by hand.
28
+ - **Close buttons.** Dialog close buttons and the remove-connection button use a
29
+ centered inline SVG cross instead of a baseline-aligned text `×`, so the glyph
30
+ is vertically centered.
31
+
32
+ ## 0.4.2 — Maintenance
33
+
34
+ - **Decision sanity suite model attribution.** Seat assignments restored from
35
+ `.env` or saved configuration had no `id`, so every sanity agent shared an
36
+ undefined id. Each model summary then counted every model's decisions (for
37
+ example `24/10` when only 10 spots exist) and every scenario column rendered
38
+ the last model's result. Sanity agents now derive the same stable
39
+ `player-<seat>` id the tournament uses, so each model is reported separately.
40
+
3
41
  ## 0.4.1 — Maintenance
4
42
 
5
43
  A maintenance release on top of the 0.4.0 methodology work.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pokertools-arena",
3
- "version": "0.4.1",
3
+ "version": "0.4.5",
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": {
package/src/app.js CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  BENCHMARK_MODES, DECISION_ARCHITECTURES, DEFAULT_BENCHMARK_MODE, DEFAULT_DECISION_ARCHITECTURE,
15
15
  REPRESENTATION_MODES, DEFAULT_REPRESENTATION_MODE, DECISION_ARCHITECTURE_VERSION,
16
16
  legalActionFamilies, legalAggressiveSizes, buildHierarchicalDecision, familyCriteria, sizeCriteria,
17
- applyBenchmarkMode, renderDecisionState, aggregateActionProbabilitiesByFamily, probabilityStats, SIZE_LABELS, SPECTATOR_NOTE,
17
+ applyBenchmarkMode, renderDecisionState, aggregateActionProbabilitiesByFamily, probabilityStats, SIZE_LABELS,
18
18
  isAggressiveType, familyForActionType, FAMILY_LABELS, decisionClockPhase,
19
19
  } from './lib/decision-core.js';
20
20
 
@@ -47,6 +47,7 @@ let lastProcessedEventId = null;
47
47
  const MAX_LOBBY_SEATS = 10;
48
48
  let seatAssignments = Array(MAX_LOBBY_SEATS).fill(null);
49
49
  let editingSeatIndex = null;
50
+ let seatNameAuto = false;
50
51
  let lobbyVisible = true;
51
52
  let pendingAutostart = false;
52
53
  let arenaMaxDecisions = 0;
@@ -141,6 +142,17 @@ function showActionToast(text, type = '') {
141
142
  }, 1550);
142
143
  }
143
144
  function seatEl(playerId) { return $(`.seat[data-player-id="${CSS.escape(String(playerId))}"]`, els.seatsLayer); }
145
+ // Cancel every effect the table may still have in flight: chip flies, toast,
146
+ // fold/check flashes and their pending timeouts. Called when a run stops so the
147
+ // display freezes instead of finishing queued animations.
148
+ function stopTableEffects() {
149
+ clearTimeout(showActionToast.timer);
150
+ els.actionToast?.classList.add('hidden');
151
+ els.pokerTable?.classList.remove('action-message-visible');
152
+ els.fxLayer?.replaceChildren();
153
+ for (const el of $$('.seat.fold-flash, .seat.check-flash'))
154
+ { el.classList.remove('fold-flash'); el.classList.remove('check-flash'); }
155
+ }
144
156
  function animateNewHand(s) {
145
157
  if (!animationsAllowed()) return;
146
158
  requestAnimationFrame(() => {
@@ -168,6 +180,15 @@ function animateBoardCards(previousCount, currentCount) {
168
180
  }
169
181
  function processVisualEffects(s) {
170
182
  const events = s?.events || [];
183
+ // Once a run is stopped (or errored) no further effects may be scheduled.
184
+ // Without this, already-live chip flies, flashes and sounds continued after
185
+ // Stop, and an in-flight broadcast could replay the last decision's effects.
186
+ if (['STOPPED', 'ERROR'].includes(s?.status)) {
187
+ if (events.length) lastProcessedEventId = events.at(-1).id;
188
+ lastVisualState = s;
189
+ stopTableEffects();
190
+ return;
191
+ }
171
192
  if (!animationsAllowed()) {
172
193
  if (events.length) lastProcessedEventId = events.at(-1).id;
173
194
  lastVisualState = s;
@@ -1065,19 +1086,31 @@ function updateSeatSummary() {
1065
1086
  const count = seatAssignments.filter(Boolean).length;
1066
1087
  if (els.seatSummary) els.seatSummary.textContent = `${count} / ${MAX_LOBBY_SEATS} seated`;
1067
1088
  }
1089
+ // The seat name follows the chosen model until the user edits it by hand, so
1090
+ // seats restored from .env or saved config show "Gemma" instead of the generic
1091
+ // "Player 1" the other surfaces already hide via visiblePlayerName().
1092
+ function syncSeatNameFromModel() {
1093
+ if (!seatNameAuto || editingSeatIndex == null) return;
1094
+ const model = els.seatModel.value.trim();
1095
+ els.seatName.value = model ? displayModelName(model) : `Player ${editingSeatIndex + 1}`;
1096
+ }
1068
1097
  function openSeatEditor(seatIndex) {
1069
1098
  if (!Number.isInteger(seatIndex) || seatIndex < 0 || seatIndex >= MAX_LOBBY_SEATS) return;
1070
1099
  editingSeatIndex = seatIndex;
1071
1100
  const locked = Boolean(director && ['RUNNING', 'PAUSED'].includes(director.status));
1072
1101
  const draft = seatAssignments[seatIndex] ? { ...seatAssignments[seatIndex] } : defaultSeatDraft(seatIndex);
1073
1102
  els.seatDialogTitle.textContent = `Seat ${seatIndex + 1}`;
1074
- els.seatName.value = draft.name || `Player ${seatIndex + 1}`;
1103
+ const placeholderName = `Player ${seatIndex + 1}`;
1104
+ const storedName = String(draft.name || '').trim();
1105
+ seatNameAuto = !storedName || /^player\s+\d+$/i.test(storedName) || (Boolean(draft.model) && storedName === displayModelName(draft.model));
1106
+ els.seatName.value = seatNameAuto && draft.model ? displayModelName(draft.model) : (storedName || placeholderName);
1075
1107
  refreshSeatConnectionSelect(draft.connectionId || '');
1076
1108
  if (draft.connectionId && [...els.seatConnection.options].some(option => option.value === draft.connectionId)) els.seatConnection.value = draft.connectionId;
1077
1109
  els.seatModel.value = draft.model || '';
1078
1110
  els.seatProtocol.value = draft.protocol || 'tool';
1079
1111
  els.seatProvider.value = draft.provider || '';
1080
1112
  applySeatProtocolRules();
1113
+ syncSeatNameFromModel();
1081
1114
  void refreshSeatModelCatalog();
1082
1115
  els.seatError.classList.add('hidden');
1083
1116
  els.seatLockNotice.classList.toggle('hidden', !locked);
@@ -1522,8 +1555,9 @@ function decisionTelemetryHtml(event, { limit = 4 } = {}) {
1522
1555
  const reasoningTokens = event?.usage?.completion_tokens_details?.reasoning_tokens;
1523
1556
  if (reasoningTokens) facts.push(`<span>Reasoning <b>${reasoningTokens} tokens</b></span>`);
1524
1557
  if (facts.length) parts.push(`<div class="decision-facts">${facts.join('')}</div>`);
1525
- const isTypedDecision = meta.family != null || meta.aggression != null || meta.bluffSpot != null || meta.method === 'openrouter-decisions' || meta.method === 'jev-choice' || String(meta.method || '').includes('hierarchical');
1526
- if (isTypedDecision) parts.push(`<div class="decision-facts-note">${escapeHtml(SPECTATOR_NOTE)} Bluff opportunity is a property of the spot, not the reason for the chosen action.</div>`);
1558
+ // Typed decisions (hierarchical / Jev / OpenRouter-decisions) have no prose
1559
+ // rationale; the telemetry above already shows family, size and confidence.
1560
+ // A repeated "no text rationale" caption was removed as log noise.
1527
1561
  return parts.join('');
1528
1562
  }
1529
1563
  function setDecisionContext({ hand = '—', street = '—', position = '—', options = '—', label = 'Legal actions', hint = 'Choose one', labels = null } = {}) {
@@ -1555,6 +1589,9 @@ function championBadgeHtml() {
1555
1589
  + '</svg></span>';
1556
1590
  }
1557
1591
  function renderDecision(s) {
1592
+ // A stopped/errored run must not keep a live clock. stop() clears
1593
+ // currentDecision, but the last broadcast can still carry it, so guard here.
1594
+ if (['STOPPED', 'ERROR'].includes(s?.status)) { stopClock(); return; }
1558
1595
  const d = s?.currentDecision;
1559
1596
  const last = latestDecisionEvent(s);
1560
1597
  const shouldShowCard = Boolean(d || last || seatAssignments.filter(Boolean).length);
@@ -1746,7 +1783,13 @@ function openSetup({ preserveError = false } = {}) { if (!preserveError) els.set
1746
1783
  function cloneJson(value) { return JSON.parse(JSON.stringify(value)); }
1747
1784
  function sanityAgents() {
1748
1785
  const connections = readConnections();
1749
- return readSeatPlayers().map(player => ({ ...player, connection: connections.find(c => c.id === player.connectionId) })).filter(row => row.connection);
1786
+ // Seat assignments restored from .env or saved config carry no id, so derive
1787
+ // the same stable id the tournament uses. Without it every agent.id is
1788
+ // undefined, so sanity results from all models collapse into one summary
1789
+ // (e.g. "24/10") and every grid column shows the last model's result.
1790
+ return readSeatPlayers()
1791
+ .map(player => ({ ...player, id: player.id || `player-${player.lobbySeat + 1}`, connection: connections.find(c => c.id === player.connectionId) }))
1792
+ .filter(row => row.connection);
1750
1793
  }
1751
1794
  function sanityProtocolLabel(agent) {
1752
1795
  const protocol = effectiveProtocol(agent, agent.connection);
@@ -2306,7 +2349,11 @@ els.setupBtn.addEventListener('click', openSetup);
2306
2349
  els.closeSetup.addEventListener('click', () => els.setupDialog.close());
2307
2350
  els.addConnectionBtn.addEventListener('click', () => addConnectionRow({ name: `API ${els.connectionsEditor.children.length + 1}`, kind: 'openai', baseUrl: 'https://api.openai.com/v1' }));
2308
2351
  els.pauseBtn.addEventListener('click', () => { if (!director) return; currentState?.status === 'PAUSED' ? director.resume() : director.pause(); });
2309
- els.stopBtn.addEventListener('click', () => director?.stop());
2352
+ els.stopBtn.addEventListener('click', () => {
2353
+ if (!director) return;
2354
+ stopClock(); stopTableEffects();
2355
+ director.stop();
2356
+ });
2310
2357
  els.exportBtn.addEventListener('click', () => { if (!director?.events?.length) return; downloadText(`${director.config?.id || 'pokertools-arena'}.jsonl`, director.exportJsonl()); });
2311
2358
  els.seatsBtn.addEventListener('click', () => {
2312
2359
  if (director && ['RUNNING', 'PAUSED'].includes(director.status)) return;
@@ -2326,8 +2373,9 @@ for (const dialog of [els.setupDialog, els.seatDialog, els.testsDialog, els.repl
2326
2373
  dialog.addEventListener('click', event => { if (event.target === dialog) dialog.close(); });
2327
2374
  dialog.addEventListener('close', hideTooltip);
2328
2375
  }
2329
- els.seatConnection.addEventListener('change', () => { applySeatProtocolRules(); void refreshSeatModelCatalog(); });
2330
- els.seatModel.addEventListener('input', applySeatProtocolRules);
2376
+ els.seatConnection.addEventListener('change', () => { applySeatProtocolRules(); syncSeatNameFromModel(); void refreshSeatModelCatalog(); });
2377
+ els.seatModel.addEventListener('input', () => { applySeatProtocolRules(); syncSeatNameFromModel(); });
2378
+ els.seatName.addEventListener('input', () => { seatNameAuto = false; });
2331
2379
  els.seatProtocol.addEventListener('change', applySeatProtocolRules);
2332
2380
  els.refreshModelsBtn?.addEventListener('click', () => void refreshSeatModelCatalog({ force: true }));
2333
2381
  els.removeSeatBtn.addEventListener('click', () => {
package/src/index.html CHANGED
@@ -165,7 +165,7 @@
165
165
  <h2>Settings</h2>
166
166
  <button class="info-button" type="button" aria-label="About settings" data-tip="API keys stay in this browser tab and are never written to saved configuration or tournament logs.">i</button>
167
167
  </div>
168
- <button id="closeSetup" type="button" class="icon-button" aria-label="Close settings">×</button>
168
+ <button id="closeSetup" type="button" class="icon-button" aria-label="Close settings"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
169
169
  </div>
170
170
 
171
171
  <div class="dialog-body setup-body">
@@ -246,7 +246,7 @@
246
246
  <h2 id="seatDialogTitle">Seat 1</h2>
247
247
  <button class="info-button" type="button" aria-label="About seat context" data-tip="Each model receives only its own hole cards plus the same canonical public table state, recent public hands, opponent statistics, and legal actions.">i</button>
248
248
  </div>
249
- <button id="closeSeat" type="button" class="icon-button" aria-label="Close seat editor">×</button>
249
+ <button id="closeSeat" type="button" class="icon-button" aria-label="Close seat editor"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
250
250
  </div>
251
251
  <div class="dialog-body seat-editor-body">
252
252
  <div id="seatLockNotice" class="seat-lock-notice hidden">Seat assignments are locked during a tournament.</div>
@@ -284,7 +284,7 @@
284
284
  <div class="dialog-title-row"><h2>Decision sanity suite</h2><button class="info-button" type="button" aria-label="About decision tests" data-tip="A small comprehension and reliability check. It is intentionally not a GTO solver, Elo score, or tournament-strength ranking.">i</button></div>
285
285
  <div class="dialog-subtitle">The same 10 fixed poker spots are sent independently to every seated model.</div>
286
286
  </div>
287
- <button id="closeTests" type="button" class="icon-button" aria-label="Close tests">×</button>
287
+ <button id="closeTests" type="button" class="icon-button" aria-label="Close tests"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
288
288
  </div>
289
289
  <div class="tests-body">
290
290
  <div class="tests-how" aria-label="How the suite works">
@@ -317,7 +317,7 @@
317
317
  <div class="dialog-title-row"><h2 id="replayTitle">Decision replay</h2><span id="replayBadge" class="replay-badge">HAND —</span></div>
318
318
  <div id="replaySubtitle" class="dialog-subtitle">Snapshot captured immediately before the model acted.</div>
319
319
  </div>
320
- <button id="closeReplay" type="button" class="icon-button" aria-label="Close decision replay">×</button>
320
+ <button id="closeReplay" type="button" class="icon-button" aria-label="Close decision replay"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
321
321
  </div>
322
322
  <div class="dialog-body replay-body">
323
323
  <section class="replay-table-wrap">
@@ -361,7 +361,7 @@
361
361
  <span class="field-label-row">Preset <button class="info-button inline-info" type="button" aria-label="About connection presets" data-tip="OpenAI-compatible keeps Base URL fully editable. OpenRouter only fills its standard URL and enables OpenRouter-specific routing. TypeSafe uses the native System One endpoint.">i</button></span>
362
362
  <select data-field="kind"><option value="openai">OpenAI-compatible</option><option value="openrouter">OpenRouter</option><option value="typesafe">TypeSafe System One</option></select>
363
363
  </label>
364
- <button type="button" class="remove-connection icon-button" title="Remove connection" aria-label="Remove connection">×</button>
364
+ <button type="button" class="remove-connection icon-button" title="Remove connection" aria-label="Remove connection"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
365
365
  </div>
366
366
  <div class="connection-main-grid">
367
367
  <label class="connection-url">Base URL<input data-field="baseUrl" spellcheck="false" placeholder="https://api.example.com/v1" /></label>
@@ -1000,8 +1000,6 @@ export function decide(agent, connection, args) {
1000
1000
  // ===========================================================================
1001
1001
  export const FAMILY_OBJECTIVE = 'Choose exactly one legal action family that best maximizes tournament chip EV from the supplied state. Do not choose an amount; a size is chosen in a separate step.';
1002
1002
  export const SIZE_OBJECTIVE = family => `The action family ${String(family).toUpperCase()} has already been selected. Choose exactly one legal ${family === ACTION_FAMILY.RAISE ? 'raise size' : 'bet size'} that best maximizes tournament chip EV. Do not change the action family.`;
1003
- export const SPECTATOR_NOTE = 'This model returns typed decisions rather than a text rationale.';
1004
-
1005
1003
  export function buildJevFamilyQuestions(families, { instructions = FAMILY_OBJECTIVE, criteria = null } = {}) {
1006
1004
  return { type: 'choice', instructions, criteria: criteria ?? familyCriteria(families) };
1007
1005
  }
package/src/styles.css CHANGED
@@ -839,7 +839,11 @@ input::placeholder{color:#4e5862}
839
839
  .button,.icon-button,.field-icon-button,input,select{box-sizing:border-box}
840
840
  .button{min-height:var(--control-h);padding:0 12px;border-radius:var(--radius-sm);font-size:10px;font-weight:800;line-height:1;white-space:nowrap}
841
841
  .small-button{min-height:30px;height:30px;padding-inline:10px;font-size:9px}
842
- .icon-button{width:30px;height:30px;min-width:30px;border-radius:var(--radius-sm);display:grid;place-items:center;padding:0}
842
+ .icon-button{width:30px;height:30px;min-width:30px;border-radius:var(--radius-sm);display:grid;place-items:center;padding:0;line-height:1}
843
+ /* SVG glyphs center geometrically; a text "×" sits on its baseline and looks
844
+ vertically off-centre inside these small buttons. */
845
+ .icon-button svg{width:14px;height:14px;display:block;pointer-events:none}
846
+ .dialog-head .icon-button svg{width:13px;height:13px}
843
847
  input,select{width:100%;min-width:0;height:var(--control-h);padding:0 9px;border-radius:var(--radius-sm);font-size:11px;background:#0a0d10;border:1px solid var(--modal-border);color:var(--text)}
844
848
  input:focus,select:focus{outline:none;border-color:rgba(146,201,164,.34);box-shadow:0 0 0 3px rgba(146,201,164,.05)}
845
849
  label{min-width:0}
@@ -2265,10 +2269,7 @@ a.button{text-decoration:none}
2265
2269
  .card .card-corner.bottom{display:none!important}
2266
2270
  }
2267
2271
 
2268
- /* 0.2.23 — decision diagnostics + deterministic hand evaluation
2269
- Spectator-only annotations. The note explicitly separates typed model
2270
- answers from any fabricated rationale. */
2271
- .decision-facts-note{margin-top:6px;font-size:10px;line-height:1.4;color:var(--muted,#8aa098);border-left:2px solid rgba(184,236,111,.35);padding-left:8px}
2272
+ /* 0.2.23 — decision diagnostics + deterministic hand evaluation. */
2272
2273
  .replay-reason-text{margin-bottom:10px;line-height:1.45}
2273
2274
  .replay-summary span b{white-space:nowrap}
2274
2275
 
@@ -2283,7 +2284,6 @@ a.button{text-decoration:none}
2283
2284
  .stage-chip{white-space:nowrap}
2284
2285
  .telemetry-group{margin-top:6px}
2285
2286
  .telemetry-caption{display:block;font-size:9px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted,#8aa098);margin-bottom:3px}
2286
- .decision-facts-note{white-space:normal}
2287
2287
 
2288
2288
  /* 0.4.0 — methodology, reproducibility and repository-organization release.
2289
2289
  Browser application source moved under src/; visual behavior is unchanged. */