pokertools-arena 0.4.8 → 0.4.9

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.8.** 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, Stop immediately freezes the clock and table effects, seat cards clear their last action at the start of each hand, pots fly back to winners at showdown, and the header, table chrome and small-screen board cards are more compact.
20
+ > **Current release: 0.4.9.** 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, Stop immediately freezes the clock and table effects, seat cards clear their last action at the start of each hand, pots fly back to winners at showdown, the header/table chrome and small-screen board cards are more compact, and the inspector is an accessible tablist with corrected poker statistics.
21
21
 
22
22
  ---
23
23
 
package/dist/app.js CHANGED
@@ -115980,18 +115980,21 @@ function buildPublicPlayerStats(events, players, currentHand) {
115980
115980
  const completedHandNumbers = new Set((events ?? []).filter((e) => e.type === "HAND_END" && Number(e.handNumber) < Number(currentHand)).map((e) => Number(e.handNumber)));
115981
115981
  const decisions = (events ?? []).filter((e) => e.type === "DECISION" && completedHandNumbers.has(Number(e.handNumber)));
115982
115982
  const handEnds = (events ?? []).filter((e) => e.type === "HAND_END" && completedHandNumbers.has(Number(e.handNumber)));
115983
+ const handStarts = (events ?? []).filter((e) => e.type === "HAND_START" && completedHandNumbers.has(Number(e.handNumber)));
115983
115984
  return (players ?? []).map((player) => {
115984
115985
  const rows = decisions.filter((e) => e.playerId === player.id);
115985
115986
  const preflop = rows.filter((e) => e.street === "PREFLOP");
115986
- const preflopHands = new Set(preflop.map((e) => e.handNumber));
115987
+ const dealtHands = new Set(handStarts.filter((e) => Array.isArray(e.playerIds) && e.playerIds.includes(player.id)).map((e) => Number(e.handNumber)));
115988
+ if (!dealtHands.size) for (const e of rows) dealtHands.add(Number(e.handNumber));
115987
115989
  const vpipHands = new Set(preflop.filter((e) => ["CALL", "BET", "RAISE"].includes(e.action?.type)).map((e) => e.handNumber));
115988
115990
  const pfrHands = new Set(preflop.filter((e) => ["BET", "RAISE"].includes(e.action?.type)).map((e) => e.handNumber));
115989
115991
  const aggressive = rows.filter((e) => ["BET", "RAISE"].includes(e.action?.type)).length;
115990
115992
  const calls = rows.filter((e) => e.action?.type === "CALL").length;
115991
115993
  const checks = rows.filter((e) => e.action?.type === "CHECK").length;
115992
115994
  const folds = rows.filter((e) => e.action?.type === "FOLD").length;
115995
+ const foldOpportunities = rows.filter((e) => (e.legalActions ?? []).some((action2) => action2.type === "FOLD")).length;
115993
115996
  let wins = 0;
115994
- const observedHands = new Set(rows.map((e) => Number(e.handNumber)));
115997
+ const observedHands = new Set(dealtHands);
115995
115998
  for (const end of handEnds) {
115996
115999
  if ((end.winners ?? []).some((w) => (w.playerId ?? w.id ?? w) === player.id)) {
115997
116000
  wins++;
@@ -116003,12 +116006,13 @@ function buildPublicPlayerStats(events, players, currentHand) {
116003
116006
  playerId: player.id,
116004
116007
  playerName: player.name,
116005
116008
  sampleHands: observedHands.size,
116006
- preflopSamples: preflopHands.size,
116009
+ preflopSamples: dealtHands.size,
116007
116010
  decisions: rows.length,
116008
- vpipPct: pct(vpipHands.size, preflopHands.size),
116009
- pfrPct: pct(pfrHands.size, preflopHands.size),
116010
- aggressionPct: pct(aggressive, aggressive + calls + checks),
116011
- foldPct: pct(folds, strategicActions),
116011
+ vpipPct: pct(vpipHands.size, dealtHands.size),
116012
+ pfrPct: pct(pfrHands.size, dealtHands.size),
116013
+ // Standard aggression frequency (AFq), not the aggression-factor ratio.
116014
+ aggressionPct: pct(aggressive, aggressive + calls + folds),
116015
+ foldPct: pct(folds, foldOpportunities),
116012
116016
  callPct: pct(calls, strategicActions),
116013
116017
  checkPct: pct(checks, strategicActions),
116014
116018
  wins
@@ -116082,12 +116086,15 @@ function normalizeConfig(input = {}) {
116082
116086
  parseHeaders(c.headers);
116083
116087
  return { ...c, baseUrl: normalizeBaseUrl(c.baseUrl) };
116084
116088
  });
116089
+ const smallBlind = Math.max(1, Math.round(Number(input.smallBlind) || 25));
116090
+ const bigBlind = Math.max(2, Math.round(Number(input.bigBlind) || 50));
116091
+ if (bigBlind < smallBlind * 2) throw new Error("Big blind must be at least twice the small blind");
116085
116092
  return {
116086
116093
  id: id("tournament"),
116087
116094
  name: "pokertools-arena",
116088
116095
  startingStack: Math.max(100, Math.round(Number(input.startingStack) || 1e4)),
116089
- smallBlind: Math.max(1, Math.round(Number(input.smallBlind) || 25)),
116090
- bigBlind: Math.max(2, Math.round(Number(input.bigBlind) || 50)),
116096
+ smallBlind,
116097
+ bigBlind,
116091
116098
  ante: Math.max(0, Math.round(Number(input.ante) || 0)),
116092
116099
  handsPerLevel: Math.max(1, Math.round(Number(input.handsPerLevel) || 8)),
116093
116100
  blindMultiplier: clamp(Number(input.blindMultiplier) || 1.5, 1.1, 3),
@@ -116106,8 +116113,9 @@ function normalizeConfig(input = {}) {
116106
116113
  players: players.map((raw, index) => {
116107
116114
  const lobbySeat = clamp(Math.round(Number(raw.lobbySeat ?? index)), 0, MAX_LOBBY_SEATS - 1);
116108
116115
  const name = String(raw.name || `Player ${lobbySeat + 1}`).trim().slice(0, 40);
116109
- if (names.has(name)) throw new Error(`Duplicate player name: ${name}`);
116110
- names.add(name);
116116
+ const nameKey = name.toLocaleLowerCase();
116117
+ if (names.has(nameKey)) throw new Error(`Duplicate player name: ${name}`);
116118
+ names.add(nameKey);
116111
116119
  if (!connIds.has(raw.connectionId)) throw new Error(`Connection missing for ${name}`);
116112
116120
  const model = String(raw.model || "").trim();
116113
116121
  if (!model) throw new Error(`Model missing for ${name}`);
@@ -116352,7 +116360,14 @@ var TournamentDirector = class {
116352
116360
  }
116353
116361
  this.engine.deal();
116354
116362
  }
116355
- this.logEvent("HAND_START", { handNumber: this.handNumber, buttonSeat: this.engine.state.buttonSeat, smallBlind: this.engine.state.smallBlind, bigBlind: this.engine.state.bigBlind, ante: this.engine.state.ante });
116363
+ this.logEvent("HAND_START", {
116364
+ handNumber: this.handNumber,
116365
+ buttonSeat: this.engine.state.buttonSeat,
116366
+ smallBlind: this.engine.state.smallBlind,
116367
+ bigBlind: this.engine.state.bigBlind,
116368
+ ante: this.engine.state.ante,
116369
+ playerIds: (this.engine.state.players ?? []).filter((p) => p && playerStack(p) > 0).map((p) => p.id)
116370
+ });
116356
116371
  this.broadcast();
116357
116372
  }
116358
116373
  handComplete() {
@@ -116479,17 +116494,13 @@ var TournamentDirector = class {
116479
116494
  stats.retries += Number(result?.meta?.retryCount || 0);
116480
116495
  if (result?.meta?.protocolFallbackTriggered) stats.protocolFallbacks++;
116481
116496
  if (this.currentDecision?.id !== decisionId) throw new Error("Decision became stale");
116482
- const elapsedActive = Math.max(0, Date.now() - startedAt - pausedTotal());
116483
- if (elapsedActive > baseMs) this.timeBanks[agent.id] = Math.max(0, bankBefore - (elapsedActive - baseMs));
116484
116497
  } catch (err) {
116485
116498
  elapsed = Math.max(0, Date.now() - startedAt - pausedTotal());
116486
116499
  error = err;
116487
116500
  for (const incident of err?.incidents || []) recordIncident(incident);
116488
116501
  errorCategory = elapsed >= totalMs - 30 || err?.name === "AbortError" ? "timeout" : decisionErrorCategory(err);
116489
- if (errorCategory === "timeout") {
116490
- stats.timeouts++;
116491
- this.timeBanks[agent.id] = 0;
116492
- } else if (errorCategory === "rate_limit") stats.rateLimits++;
116502
+ if (errorCategory === "timeout") stats.timeouts++;
116503
+ else if (errorCategory === "rate_limit") stats.rateLimits++;
116493
116504
  else if (errorCategory === "provider") stats.providerErrors++;
116494
116505
  else {
116495
116506
  stats.modelErrors++;
@@ -116498,6 +116509,8 @@ var TournamentDirector = class {
116498
116509
  }
116499
116510
  if (this.status === "STOPPED") throw new Error("Tournament stopped");
116500
116511
  await this.waitIfPaused();
116512
+ const elapsedActive = Math.max(0, Date.now() - startedAt - pausedTotal());
116513
+ this.timeBanks[agent.id] = Math.max(0, bankBefore - Math.max(0, elapsedActive - baseMs));
116501
116514
  let chosen = result?.action ?? null, forced = false;
116502
116515
  if (chosen && architecture === "hierarchical") {
116503
116516
  const engineAction = isAggressiveType(chosen.type) ? { type: chosen.type, playerId: agent.id, amount: Math.max(1, Math.round(asNumber(chosen.amount))) } : { type: chosen.type, playerId: agent.id };
@@ -117512,7 +117525,7 @@ function renderStats(s) {
117512
117525
  const st = s.stats?.[p.id] || {}, poker = publicById.get(p.id) || {}, tableP = s.table?.players?.find?.((x) => x?.id === p.id), avg = st.decisions ? Math.round(st.totalLatencyMs / st.decisions) : 0;
117513
117526
  const pc = (value) => `${Math.round(Number(value || 0) * 100)}%`;
117514
117527
  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>
117515
- <div class="poker-profile"><div><b>${pc(poker.vpipPct)}</b><span>VPIP</span></div><div><b>${pc(poker.pfrPct)}</b><span>PFR</span></div><div><b>${pc(poker.aggressionPct)}</b><span>AGG</span></div><div><b>${pc(poker.foldPct)}</b><span>FOLD</span></div><div><b>${poker.sampleHands || 0}</b><span>SAMPLE</span></div></div>
117528
+ <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>
117516
117529
  <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>`;
117517
117530
  }).join("");
117518
117531
  }
@@ -117548,7 +117561,7 @@ function decisionRenderSignature(s) {
117548
117561
  return JSON.stringify([d ? [d.id, d.playerId, d.model, d.protocol, d.provider, d.startedAt, d.baseMs, d.timeBankMs, d.pausedMs || 0, Boolean(d.pausedAt), d.architecture || null, d.stage || null, d.legalActions] : null, last?.id || null, s?.status || "IDLE", seatAssignments.filter(Boolean).length]);
117549
117562
  }
117550
117563
  function feedRenderSignature(s) {
117551
- return (s?.events || []).filter((e) => e.type === "DECISION").slice(-12).map((e) => e.id).join("|");
117564
+ return (s?.events || []).filter((e) => e.type === "DECISION" || e.type === "SPECTATOR_EXPLANATION").slice(-24).map((e) => `${e.type}:${e.id}`).join("|");
117552
117565
  }
117553
117566
  function eventsRenderSignature(s) {
117554
117567
  const ev = s?.events || [];
@@ -117789,6 +117802,7 @@ function setRecordButton(active, label = null) {
117789
117802
  if (icon) icon.textContent = active ? "\u25A0" : "\u25CF";
117790
117803
  if (text) text.textContent = label || (active ? "Stop rec" : "Record");
117791
117804
  els.recordBtn.title = active ? "Stop table recording and save video" : "Record only the poker table";
117805
+ els.recordBtn.setAttribute("aria-label", els.recordBtn.title);
117792
117806
  }
117793
117807
  function saveRecordingBlob(blob) {
117794
117808
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
@@ -118264,6 +118278,8 @@ els.soundBtn.addEventListener("click", async () => {
118264
118278
  if (label) label.textContent = soundEnabled ? "Sound on" : "Sound";
118265
118279
  els.soundBtn.setAttribute("aria-pressed", String(soundEnabled));
118266
118280
  els.soundBtn.classList.toggle("active", soundEnabled);
118281
+ els.soundBtn.title = soundEnabled ? "Disable table sounds" : "Enable table sounds";
118282
+ els.soundBtn.setAttribute("aria-label", els.soundBtn.title);
118267
118283
  if (soundEnabled) {
118268
118284
  try {
118269
118285
  await getAudioContext()?.resume();
@@ -118365,6 +118381,7 @@ els.setupForm.addEventListener("submit", (event) => {
118365
118381
  try {
118366
118382
  const raw = collectSetupRaw(true);
118367
118383
  for (const connection of raw.connections) parseHeaders(connection.headers);
118384
+ normalizeConfig(raw);
118368
118385
  saveSetupWithoutSecrets(raw);
118369
118386
  els.setupDialog.close();
118370
118387
  render(currentState || { status: "IDLE", events: [] });
@@ -118405,10 +118422,20 @@ async function startConfiguredTournament() {
118405
118422
  }
118406
118423
  }
118407
118424
  els.startTopBtn.addEventListener("click", startConfiguredTournament);
118408
- $$(".tab").forEach((tab) => tab.addEventListener("click", () => {
118425
+ function activateInspectorTab(tab, { focus = false } = {}) {
118409
118426
  activeInspectorTab = tab.dataset.tab || "live";
118410
- $$(".tab").forEach((t) => t.classList.toggle("active", t === tab));
118411
- $$(".tab-panel").forEach((p) => p.classList.toggle("active", p.id === `tab-${tab.dataset.tab}`));
118427
+ $$(".tab").forEach((t) => {
118428
+ const active = t === tab;
118429
+ t.classList.toggle("active", active);
118430
+ t.setAttribute("aria-selected", String(active));
118431
+ t.tabIndex = active ? 0 : -1;
118432
+ });
118433
+ $$(".tab-panel").forEach((p) => {
118434
+ const active = p.id === `tab-${tab.dataset.tab}`;
118435
+ p.classList.toggle("active", active);
118436
+ p.hidden = !active;
118437
+ });
118438
+ if (focus) tab.focus();
118412
118439
  if (!currentState) return;
118413
118440
  if (activeInspectorTab === "live") {
118414
118441
  renderMemo.feed = feedRenderSignature(currentState);
@@ -118420,7 +118447,18 @@ $$(".tab").forEach((tab) => tab.addEventListener("click", () => {
118420
118447
  renderMemo.stats = statsRenderSignature(currentState);
118421
118448
  renderStats(currentState);
118422
118449
  }
118423
- }));
118450
+ }
118451
+ $$(".tab").forEach((tab) => {
118452
+ tab.addEventListener("click", () => activateInspectorTab(tab));
118453
+ tab.addEventListener("keydown", (event) => {
118454
+ if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
118455
+ event.preventDefault();
118456
+ const tabs = $$(".tab");
118457
+ const current = tabs.indexOf(tab);
118458
+ const next = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : (current + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length;
118459
+ activateInspectorTab(tabs[next], { focus: true });
118460
+ });
118461
+ });
118424
118462
  window.addEventListener("beforeunload", (event) => {
118425
118463
  if (tableRecording) tableRecording.stream?.getTracks?.().forEach((t) => t.stop());
118426
118464
  if (director && ["RUNNING", "PAUSED"].includes(director.status)) {
package/dist/index.html CHANGED
@@ -110,13 +110,13 @@
110
110
  </section>
111
111
 
112
112
  <aside class="inspector">
113
- <div class="tabs">
114
- <button class="tab active" data-tab="live">Live</button>
115
- <button class="tab" data-tab="log">Log</button>
116
- <button class="tab" data-tab="stats">Stats</button>
113
+ <div class="tabs" role="tablist" aria-label="Tournament inspector">
114
+ <button id="tabButton-live" class="tab active" data-tab="live" type="button" role="tab" aria-selected="true" aria-controls="tab-live">Live</button>
115
+ <button id="tabButton-log" class="tab" data-tab="log" type="button" role="tab" aria-selected="false" aria-controls="tab-log" tabindex="-1">Log</button>
116
+ <button id="tabButton-stats" class="tab" data-tab="stats" type="button" role="tab" aria-selected="false" aria-controls="tab-stats" tabindex="-1">Stats</button>
117
117
  </div>
118
118
 
119
- <div id="tab-live" class="tab-panel active">
119
+ <div id="tab-live" class="tab-panel active" role="tabpanel" aria-labelledby="tabButton-live">
120
120
  <section class="panel-block decision-panel">
121
121
  <div id="decisionPanelTitle" class="panel-title">Current decision</div>
122
122
  <div id="decisionEmpty" class="empty-state">Configure seats on the table. During play, the active model, clock, and legal actions appear here.</div>
@@ -138,13 +138,13 @@
138
138
  <section class="panel-block"><div class="panel-title">Recent decisions</div><div id="decisionFeed" class="decision-feed"></div></section>
139
139
  </div>
140
140
 
141
- <div id="tab-log" class="tab-panel">
141
+ <div id="tab-log" class="tab-panel" role="tabpanel" aria-labelledby="tabButton-log" hidden>
142
142
  <section class="panel-block grow">
143
143
  <div class="panel-title-row"><div class="panel-title">Tournament events</div><button id="exportBtn" class="button ghost small-button" type="button" disabled title="Download the tournament log as JSONL"><span class="action-icon">↓</span><span class="action-label">Download</span></button></div>
144
- <div id="eventLog" class="event-log mono"></div>
144
+ <div id="eventLog" class="event-log mono" aria-label="Tournament event log"></div>
145
145
  </section>
146
146
  </div>
147
- <div id="tab-stats" class="tab-panel"><section class="panel-block grow"><div class="panel-title">Models</div><div id="statsGrid" class="stats-grid"></div></section></div>
147
+ <div id="tab-stats" class="tab-panel" role="tabpanel" aria-labelledby="tabButton-stats" hidden><section class="panel-block grow"><div class="panel-title">Models</div><div id="statsGrid" class="stats-grid"></div></section></div>
148
148
  </aside>
149
149
  </main>
150
150
 
@@ -2404,6 +2404,30 @@ a.button{text-decoration:none}
2404
2404
  .poker-table[data-density="micro"] .board .card .card-corner.bottom{display:none!important}
2405
2405
  .poker-table[data-density="micro"] .board .card .card-suit{font-size:13px!important}
2406
2406
 
2407
+ /* Final accessibility and spectator-density pass. */
2408
+ .tab:focus-visible,.button:focus-visible,.icon-button:focus-visible,.arena-icon-button:focus-visible,.repo-link:focus-visible{outline:2px solid rgba(146,201,164,.7);outline-offset:2px}
2409
+ .tab[aria-selected="true"]{background:rgba(255,255,255,.06);color:var(--text)}
2410
+ .tab-panel[hidden]{display:none!important}
2411
+ .event-row{overflow-wrap:anywhere}
2412
+ .event-row .muted{font-size:9px}
2413
+ .stat-card,.stat-top>div{min-width:0}
2414
+ .stat-model{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
2415
+ .replay-dialog{padding:0;border:1px solid var(--modal-border);border-radius:14px;color:var(--text);background:var(--modal-bg)}
2416
+ .replay-dialog::backdrop{background:rgba(0,0,0,.72);backdrop-filter:blur(3px)}
2417
+ @media(max-width:520px){
2418
+ .stats-grid{grid-template-columns:1fr}
2419
+ .stat-top{flex-direction:row;align-items:start}
2420
+ .stat-name{font-size:12px}.stat-model{font-size:8px}
2421
+ .poker-profile b{font-size:10px}.poker-profile span{font-size:7px}
2422
+ .metric b{font-size:11px}.metric span{font-size:7px}
2423
+ .stat-reliability span{font-size:7px}.stat-reliability b{font-size:8px}
2424
+ }
2425
+ @media(max-width:720px){.poker-table[data-density="micro"] .last-action{font-size:6px!important}.event-log{font-size:9px;line-height:1.45}}
2426
+ @media(pointer:coarse){
2427
+ .tab{min-height:40px}
2428
+ .dialog-footer .button,.replay-share-actions .button{min-height:40px}
2429
+ .icon-button,.field-icon-button{min-width:40px;min-height:40px}
2430
+ }
2407
2431
 
2408
2432
  </style>
2409
2433
  </head>
@@ -2462,13 +2486,13 @@ a.button{text-decoration:none}
2462
2486
  </section>
2463
2487
 
2464
2488
  <aside class="inspector">
2465
- <div class="tabs">
2466
- <button class="tab active" data-tab="live">Live</button>
2467
- <button class="tab" data-tab="log">Log</button>
2468
- <button class="tab" data-tab="stats">Stats</button>
2489
+ <div class="tabs" role="tablist" aria-label="Tournament inspector">
2490
+ <button id="tabButton-live" class="tab active" data-tab="live" type="button" role="tab" aria-selected="true" aria-controls="tab-live">Live</button>
2491
+ <button id="tabButton-log" class="tab" data-tab="log" type="button" role="tab" aria-selected="false" aria-controls="tab-log" tabindex="-1">Log</button>
2492
+ <button id="tabButton-stats" class="tab" data-tab="stats" type="button" role="tab" aria-selected="false" aria-controls="tab-stats" tabindex="-1">Stats</button>
2469
2493
  </div>
2470
2494
 
2471
- <div id="tab-live" class="tab-panel active">
2495
+ <div id="tab-live" class="tab-panel active" role="tabpanel" aria-labelledby="tabButton-live">
2472
2496
  <section class="panel-block decision-panel">
2473
2497
  <div id="decisionPanelTitle" class="panel-title">Current decision</div>
2474
2498
  <div id="decisionEmpty" class="empty-state">Configure seats on the table. During play, the active model, clock, and legal actions appear here.</div>
@@ -2490,13 +2514,13 @@ a.button{text-decoration:none}
2490
2514
  <section class="panel-block"><div class="panel-title">Recent decisions</div><div id="decisionFeed" class="decision-feed"></div></section>
2491
2515
  </div>
2492
2516
 
2493
- <div id="tab-log" class="tab-panel">
2517
+ <div id="tab-log" class="tab-panel" role="tabpanel" aria-labelledby="tabButton-log" hidden>
2494
2518
  <section class="panel-block grow">
2495
2519
  <div class="panel-title-row"><div class="panel-title">Tournament events</div><button id="exportBtn" class="button ghost small-button" type="button" disabled title="Download the tournament log as JSONL"><span class="action-icon">↓</span><span class="action-label">Download</span></button></div>
2496
- <div id="eventLog" class="event-log mono"></div>
2520
+ <div id="eventLog" class="event-log mono" aria-label="Tournament event log"></div>
2497
2521
  </section>
2498
2522
  </div>
2499
- <div id="tab-stats" class="tab-panel"><section class="panel-block grow"><div class="panel-title">Models</div><div id="statsGrid" class="stats-grid"></div></section></div>
2523
+ <div id="tab-stats" class="tab-panel" role="tabpanel" aria-labelledby="tabButton-stats" hidden><section class="panel-block grow"><div class="panel-title">Models</div><div id="statsGrid" class="stats-grid"></div></section></div>
2500
2524
  </aside>
2501
2525
  </main>
2502
2526
 
@@ -118722,18 +118746,21 @@ function buildPublicPlayerStats(events, players, currentHand) {
118722
118746
  const completedHandNumbers = new Set((events ?? []).filter((e) => e.type === "HAND_END" && Number(e.handNumber) < Number(currentHand)).map((e) => Number(e.handNumber)));
118723
118747
  const decisions = (events ?? []).filter((e) => e.type === "DECISION" && completedHandNumbers.has(Number(e.handNumber)));
118724
118748
  const handEnds = (events ?? []).filter((e) => e.type === "HAND_END" && completedHandNumbers.has(Number(e.handNumber)));
118749
+ const handStarts = (events ?? []).filter((e) => e.type === "HAND_START" && completedHandNumbers.has(Number(e.handNumber)));
118725
118750
  return (players ?? []).map((player) => {
118726
118751
  const rows = decisions.filter((e) => e.playerId === player.id);
118727
118752
  const preflop = rows.filter((e) => e.street === "PREFLOP");
118728
- const preflopHands = new Set(preflop.map((e) => e.handNumber));
118753
+ const dealtHands = new Set(handStarts.filter((e) => Array.isArray(e.playerIds) && e.playerIds.includes(player.id)).map((e) => Number(e.handNumber)));
118754
+ if (!dealtHands.size) for (const e of rows) dealtHands.add(Number(e.handNumber));
118729
118755
  const vpipHands = new Set(preflop.filter((e) => ["CALL", "BET", "RAISE"].includes(e.action?.type)).map((e) => e.handNumber));
118730
118756
  const pfrHands = new Set(preflop.filter((e) => ["BET", "RAISE"].includes(e.action?.type)).map((e) => e.handNumber));
118731
118757
  const aggressive = rows.filter((e) => ["BET", "RAISE"].includes(e.action?.type)).length;
118732
118758
  const calls = rows.filter((e) => e.action?.type === "CALL").length;
118733
118759
  const checks = rows.filter((e) => e.action?.type === "CHECK").length;
118734
118760
  const folds = rows.filter((e) => e.action?.type === "FOLD").length;
118761
+ const foldOpportunities = rows.filter((e) => (e.legalActions ?? []).some((action2) => action2.type === "FOLD")).length;
118735
118762
  let wins = 0;
118736
- const observedHands = new Set(rows.map((e) => Number(e.handNumber)));
118763
+ const observedHands = new Set(dealtHands);
118737
118764
  for (const end of handEnds) {
118738
118765
  if ((end.winners ?? []).some((w) => (w.playerId ?? w.id ?? w) === player.id)) {
118739
118766
  wins++;
@@ -118745,12 +118772,13 @@ function buildPublicPlayerStats(events, players, currentHand) {
118745
118772
  playerId: player.id,
118746
118773
  playerName: player.name,
118747
118774
  sampleHands: observedHands.size,
118748
- preflopSamples: preflopHands.size,
118775
+ preflopSamples: dealtHands.size,
118749
118776
  decisions: rows.length,
118750
- vpipPct: pct(vpipHands.size, preflopHands.size),
118751
- pfrPct: pct(pfrHands.size, preflopHands.size),
118752
- aggressionPct: pct(aggressive, aggressive + calls + checks),
118753
- foldPct: pct(folds, strategicActions),
118777
+ vpipPct: pct(vpipHands.size, dealtHands.size),
118778
+ pfrPct: pct(pfrHands.size, dealtHands.size),
118779
+ // Standard aggression frequency (AFq), not the aggression-factor ratio.
118780
+ aggressionPct: pct(aggressive, aggressive + calls + folds),
118781
+ foldPct: pct(folds, foldOpportunities),
118754
118782
  callPct: pct(calls, strategicActions),
118755
118783
  checkPct: pct(checks, strategicActions),
118756
118784
  wins
@@ -118824,12 +118852,15 @@ function normalizeConfig(input = {}) {
118824
118852
  parseHeaders(c.headers);
118825
118853
  return { ...c, baseUrl: normalizeBaseUrl(c.baseUrl) };
118826
118854
  });
118855
+ const smallBlind = Math.max(1, Math.round(Number(input.smallBlind) || 25));
118856
+ const bigBlind = Math.max(2, Math.round(Number(input.bigBlind) || 50));
118857
+ if (bigBlind < smallBlind * 2) throw new Error("Big blind must be at least twice the small blind");
118827
118858
  return {
118828
118859
  id: id("tournament"),
118829
118860
  name: "pokertools-arena",
118830
118861
  startingStack: Math.max(100, Math.round(Number(input.startingStack) || 1e4)),
118831
- smallBlind: Math.max(1, Math.round(Number(input.smallBlind) || 25)),
118832
- bigBlind: Math.max(2, Math.round(Number(input.bigBlind) || 50)),
118862
+ smallBlind,
118863
+ bigBlind,
118833
118864
  ante: Math.max(0, Math.round(Number(input.ante) || 0)),
118834
118865
  handsPerLevel: Math.max(1, Math.round(Number(input.handsPerLevel) || 8)),
118835
118866
  blindMultiplier: clamp(Number(input.blindMultiplier) || 1.5, 1.1, 3),
@@ -118848,8 +118879,9 @@ function normalizeConfig(input = {}) {
118848
118879
  players: players.map((raw, index) => {
118849
118880
  const lobbySeat = clamp(Math.round(Number(raw.lobbySeat ?? index)), 0, MAX_LOBBY_SEATS - 1);
118850
118881
  const name = String(raw.name || `Player ${lobbySeat + 1}`).trim().slice(0, 40);
118851
- if (names.has(name)) throw new Error(`Duplicate player name: ${name}`);
118852
- names.add(name);
118882
+ const nameKey = name.toLocaleLowerCase();
118883
+ if (names.has(nameKey)) throw new Error(`Duplicate player name: ${name}`);
118884
+ names.add(nameKey);
118853
118885
  if (!connIds.has(raw.connectionId)) throw new Error(`Connection missing for ${name}`);
118854
118886
  const model = String(raw.model || "").trim();
118855
118887
  if (!model) throw new Error(`Model missing for ${name}`);
@@ -119094,7 +119126,14 @@ var TournamentDirector = class {
119094
119126
  }
119095
119127
  this.engine.deal();
119096
119128
  }
119097
- this.logEvent("HAND_START", { handNumber: this.handNumber, buttonSeat: this.engine.state.buttonSeat, smallBlind: this.engine.state.smallBlind, bigBlind: this.engine.state.bigBlind, ante: this.engine.state.ante });
119129
+ this.logEvent("HAND_START", {
119130
+ handNumber: this.handNumber,
119131
+ buttonSeat: this.engine.state.buttonSeat,
119132
+ smallBlind: this.engine.state.smallBlind,
119133
+ bigBlind: this.engine.state.bigBlind,
119134
+ ante: this.engine.state.ante,
119135
+ playerIds: (this.engine.state.players ?? []).filter((p) => p && playerStack(p) > 0).map((p) => p.id)
119136
+ });
119098
119137
  this.broadcast();
119099
119138
  }
119100
119139
  handComplete() {
@@ -119221,17 +119260,13 @@ var TournamentDirector = class {
119221
119260
  stats.retries += Number(result?.meta?.retryCount || 0);
119222
119261
  if (result?.meta?.protocolFallbackTriggered) stats.protocolFallbacks++;
119223
119262
  if (this.currentDecision?.id !== decisionId) throw new Error("Decision became stale");
119224
- const elapsedActive = Math.max(0, Date.now() - startedAt - pausedTotal());
119225
- if (elapsedActive > baseMs) this.timeBanks[agent.id] = Math.max(0, bankBefore - (elapsedActive - baseMs));
119226
119263
  } catch (err) {
119227
119264
  elapsed = Math.max(0, Date.now() - startedAt - pausedTotal());
119228
119265
  error = err;
119229
119266
  for (const incident of err?.incidents || []) recordIncident(incident);
119230
119267
  errorCategory = elapsed >= totalMs - 30 || err?.name === "AbortError" ? "timeout" : decisionErrorCategory(err);
119231
- if (errorCategory === "timeout") {
119232
- stats.timeouts++;
119233
- this.timeBanks[agent.id] = 0;
119234
- } else if (errorCategory === "rate_limit") stats.rateLimits++;
119268
+ if (errorCategory === "timeout") stats.timeouts++;
119269
+ else if (errorCategory === "rate_limit") stats.rateLimits++;
119235
119270
  else if (errorCategory === "provider") stats.providerErrors++;
119236
119271
  else {
119237
119272
  stats.modelErrors++;
@@ -119240,6 +119275,8 @@ var TournamentDirector = class {
119240
119275
  }
119241
119276
  if (this.status === "STOPPED") throw new Error("Tournament stopped");
119242
119277
  await this.waitIfPaused();
119278
+ const elapsedActive = Math.max(0, Date.now() - startedAt - pausedTotal());
119279
+ this.timeBanks[agent.id] = Math.max(0, bankBefore - Math.max(0, elapsedActive - baseMs));
119243
119280
  let chosen = result?.action ?? null, forced = false;
119244
119281
  if (chosen && architecture === "hierarchical") {
119245
119282
  const engineAction = isAggressiveType(chosen.type) ? { type: chosen.type, playerId: agent.id, amount: Math.max(1, Math.round(asNumber(chosen.amount))) } : { type: chosen.type, playerId: agent.id };
@@ -120254,7 +120291,7 @@ function renderStats(s) {
120254
120291
  const st = s.stats?.[p.id] || {}, poker = publicById.get(p.id) || {}, tableP = s.table?.players?.find?.((x) => x?.id === p.id), avg = st.decisions ? Math.round(st.totalLatencyMs / st.decisions) : 0;
120255
120292
  const pc = (value) => `${Math.round(Number(value || 0) * 100)}%`;
120256
120293
  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>
120257
- <div class="poker-profile"><div><b>${pc(poker.vpipPct)}</b><span>VPIP</span></div><div><b>${pc(poker.pfrPct)}</b><span>PFR</span></div><div><b>${pc(poker.aggressionPct)}</b><span>AGG</span></div><div><b>${pc(poker.foldPct)}</b><span>FOLD</span></div><div><b>${poker.sampleHands || 0}</b><span>SAMPLE</span></div></div>
120294
+ <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>
120258
120295
  <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>`;
120259
120296
  }).join("");
120260
120297
  }
@@ -120290,7 +120327,7 @@ function decisionRenderSignature(s) {
120290
120327
  return JSON.stringify([d ? [d.id, d.playerId, d.model, d.protocol, d.provider, d.startedAt, d.baseMs, d.timeBankMs, d.pausedMs || 0, Boolean(d.pausedAt), d.architecture || null, d.stage || null, d.legalActions] : null, last?.id || null, s?.status || "IDLE", seatAssignments.filter(Boolean).length]);
120291
120328
  }
120292
120329
  function feedRenderSignature(s) {
120293
- return (s?.events || []).filter((e) => e.type === "DECISION").slice(-12).map((e) => e.id).join("|");
120330
+ return (s?.events || []).filter((e) => e.type === "DECISION" || e.type === "SPECTATOR_EXPLANATION").slice(-24).map((e) => `${e.type}:${e.id}`).join("|");
120294
120331
  }
120295
120332
  function eventsRenderSignature(s) {
120296
120333
  const ev = s?.events || [];
@@ -120531,6 +120568,7 @@ function setRecordButton(active, label = null) {
120531
120568
  if (icon) icon.textContent = active ? "\u25A0" : "\u25CF";
120532
120569
  if (text) text.textContent = label || (active ? "Stop rec" : "Record");
120533
120570
  els.recordBtn.title = active ? "Stop table recording and save video" : "Record only the poker table";
120571
+ els.recordBtn.setAttribute("aria-label", els.recordBtn.title);
120534
120572
  }
120535
120573
  function saveRecordingBlob(blob) {
120536
120574
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
@@ -121006,6 +121044,8 @@ els.soundBtn.addEventListener("click", async () => {
121006
121044
  if (label) label.textContent = soundEnabled ? "Sound on" : "Sound";
121007
121045
  els.soundBtn.setAttribute("aria-pressed", String(soundEnabled));
121008
121046
  els.soundBtn.classList.toggle("active", soundEnabled);
121047
+ els.soundBtn.title = soundEnabled ? "Disable table sounds" : "Enable table sounds";
121048
+ els.soundBtn.setAttribute("aria-label", els.soundBtn.title);
121009
121049
  if (soundEnabled) {
121010
121050
  try {
121011
121051
  await getAudioContext()?.resume();
@@ -121107,6 +121147,7 @@ els.setupForm.addEventListener("submit", (event) => {
121107
121147
  try {
121108
121148
  const raw = collectSetupRaw(true);
121109
121149
  for (const connection of raw.connections) parseHeaders(connection.headers);
121150
+ normalizeConfig(raw);
121110
121151
  saveSetupWithoutSecrets(raw);
121111
121152
  els.setupDialog.close();
121112
121153
  render(currentState || { status: "IDLE", events: [] });
@@ -121147,10 +121188,20 @@ async function startConfiguredTournament() {
121147
121188
  }
121148
121189
  }
121149
121190
  els.startTopBtn.addEventListener("click", startConfiguredTournament);
121150
- $$(".tab").forEach((tab) => tab.addEventListener("click", () => {
121191
+ function activateInspectorTab(tab, { focus = false } = {}) {
121151
121192
  activeInspectorTab = tab.dataset.tab || "live";
121152
- $$(".tab").forEach((t) => t.classList.toggle("active", t === tab));
121153
- $$(".tab-panel").forEach((p) => p.classList.toggle("active", p.id === `tab-${tab.dataset.tab}`));
121193
+ $$(".tab").forEach((t) => {
121194
+ const active = t === tab;
121195
+ t.classList.toggle("active", active);
121196
+ t.setAttribute("aria-selected", String(active));
121197
+ t.tabIndex = active ? 0 : -1;
121198
+ });
121199
+ $$(".tab-panel").forEach((p) => {
121200
+ const active = p.id === `tab-${tab.dataset.tab}`;
121201
+ p.classList.toggle("active", active);
121202
+ p.hidden = !active;
121203
+ });
121204
+ if (focus) tab.focus();
121154
121205
  if (!currentState) return;
121155
121206
  if (activeInspectorTab === "live") {
121156
121207
  renderMemo.feed = feedRenderSignature(currentState);
@@ -121162,7 +121213,18 @@ $$(".tab").forEach((tab) => tab.addEventListener("click", () => {
121162
121213
  renderMemo.stats = statsRenderSignature(currentState);
121163
121214
  renderStats(currentState);
121164
121215
  }
121165
- }));
121216
+ }
121217
+ $$(".tab").forEach((tab) => {
121218
+ tab.addEventListener("click", () => activateInspectorTab(tab));
121219
+ tab.addEventListener("keydown", (event) => {
121220
+ if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
121221
+ event.preventDefault();
121222
+ const tabs = $$(".tab");
121223
+ const current = tabs.indexOf(tab);
121224
+ const next = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : (current + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length;
121225
+ activateInspectorTab(tabs[next], { focus: true });
121226
+ });
121227
+ });
121166
121228
  window.addEventListener("beforeunload", (event) => {
121167
121229
  if (tableRecording) tableRecording.stream?.getTracks?.().forEach((t) => t.stop());
121168
121230
  if (director && ["RUNNING", "PAUSED"].includes(director.status)) {
package/dist/styles.css CHANGED
@@ -2348,3 +2348,27 @@ a.button{text-decoration:none}
2348
2348
  .poker-table[data-density="micro"] .board .card .card-corner.bottom{display:none!important}
2349
2349
  .poker-table[data-density="micro"] .board .card .card-suit{font-size:13px!important}
2350
2350
 
2351
+ /* Final accessibility and spectator-density pass. */
2352
+ .tab:focus-visible,.button:focus-visible,.icon-button:focus-visible,.arena-icon-button:focus-visible,.repo-link:focus-visible{outline:2px solid rgba(146,201,164,.7);outline-offset:2px}
2353
+ .tab[aria-selected="true"]{background:rgba(255,255,255,.06);color:var(--text)}
2354
+ .tab-panel[hidden]{display:none!important}
2355
+ .event-row{overflow-wrap:anywhere}
2356
+ .event-row .muted{font-size:9px}
2357
+ .stat-card,.stat-top>div{min-width:0}
2358
+ .stat-model{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
2359
+ .replay-dialog{padding:0;border:1px solid var(--modal-border);border-radius:14px;color:var(--text);background:var(--modal-bg)}
2360
+ .replay-dialog::backdrop{background:rgba(0,0,0,.72);backdrop-filter:blur(3px)}
2361
+ @media(max-width:520px){
2362
+ .stats-grid{grid-template-columns:1fr}
2363
+ .stat-top{flex-direction:row;align-items:start}
2364
+ .stat-name{font-size:12px}.stat-model{font-size:8px}
2365
+ .poker-profile b{font-size:10px}.poker-profile span{font-size:7px}
2366
+ .metric b{font-size:11px}.metric span{font-size:7px}
2367
+ .stat-reliability span{font-size:7px}.stat-reliability b{font-size:8px}
2368
+ }
2369
+ @media(max-width:720px){.poker-table[data-density="micro"] .last-action{font-size:6px!important}.event-log{font-size:9px;line-height:1.45}}
2370
+ @media(pointer:coarse){
2371
+ .tab{min-height:40px}
2372
+ .dialog-footer .button,.replay-share-actions .button{min-height:40px}
2373
+ .icon-button,.field-icon-button{min-width:40px;min-height:40px}
2374
+ }
@@ -1,4 +1,4 @@
1
- # pokertools-arena 0.4.8 — benchmark methodology & results
1
+ # pokertools-arena 0.4.9 — 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.8",
3
+ "version": "0.4.9",
4
4
  "generatedAt": "2026-09-19T18:19:58.352Z",
5
5
  "experiments": [
6
6
  "paired"
@@ -1,5 +1,28 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.4.9 — Maintenance
4
+
5
+ - **Accessible inspector tabs.** The Live / Log / Stats tabs are a real
6
+ `role="tablist"`: `aria-selected`, `aria-controls`, roving `tabindex`, arrow /
7
+ Home / End keyboard navigation, and `hidden` panels instead of display-only
8
+ toggling.
9
+ - **Corrected poker statistics.** VPIP/PFR now use hands dealt (from
10
+ `HAND_START.playerIds`) rather than only hands with a preflop decision, so a
11
+ big blind walk counts. Aggression is reported as standard aggression frequency
12
+ (`AFq`, aggressive / aggressive+calls+folds) instead of the aggression-factor
13
+ ratio, and fold rate is measured against fold opportunities. Labels and
14
+ tooltips were updated, and old logs without player ids still work.
15
+ - **Consistent time-bank charging.** Active elapsed time is billed once, after
16
+ the request, for both successful and failed decisions. Provider/model failures
17
+ no longer preserve a seat's bank while successes consume theirs.
18
+ - **Blind structure validation.** A big blind below twice the small blind is
19
+ rejected, and the setup form now runs the same `normalizeConfig` validation as
20
+ tournament start, so configuration errors surface on save instead of at Start.
21
+ - **Case-insensitive player names.** Duplicate-name detection folds case.
22
+ - **Feed and accessibility polish.** New spectator explanations trigger a feed
23
+ re-render, sound and record buttons keep their `aria-label` in sync, and focus
24
+ outlines, coarse-pointer hit targets and narrow-screen stat layouts were added.
25
+
3
26
  ## 0.4.8 — Maintenance
4
27
 
5
28
  - **Compact header.** Left: the logo, `pokertools-arena`, and a backgroundless
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pokertools-arena",
3
- "version": "0.4.8",
3
+ "version": "0.4.9",
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
@@ -423,18 +423,24 @@ function buildPublicPlayerStats(events, players, currentHand) {
423
423
  const completedHandNumbers = new Set((events ?? []).filter(e => e.type === 'HAND_END' && Number(e.handNumber) < Number(currentHand)).map(e => Number(e.handNumber)));
424
424
  const decisions = (events ?? []).filter(e => e.type === 'DECISION' && completedHandNumbers.has(Number(e.handNumber)));
425
425
  const handEnds = (events ?? []).filter(e => e.type === 'HAND_END' && completedHandNumbers.has(Number(e.handNumber)));
426
+ const handStarts = (events ?? []).filter(e => e.type === 'HAND_START' && completedHandNumbers.has(Number(e.handNumber)));
426
427
  return (players ?? []).map(player => {
427
428
  const rows = decisions.filter(e => e.playerId === player.id);
428
429
  const preflop = rows.filter(e => e.street === 'PREFLOP');
429
- const preflopHands = new Set(preflop.map(e => e.handNumber));
430
+ // Use hands dealt, not only hands in which the player faced a preflop
431
+ // decision. A big blind can win a walk without ever acting.
432
+ const dealtHands = new Set(handStarts.filter(e => Array.isArray(e.playerIds) && e.playerIds.includes(player.id)).map(e => Number(e.handNumber)));
433
+ // Backward compatibility for logs created before HAND_START stored players.
434
+ if (!dealtHands.size) for (const e of rows) dealtHands.add(Number(e.handNumber));
430
435
  const vpipHands = new Set(preflop.filter(e => ['CALL','BET','RAISE'].includes(e.action?.type)).map(e => e.handNumber));
431
436
  const pfrHands = new Set(preflop.filter(e => ['BET','RAISE'].includes(e.action?.type)).map(e => e.handNumber));
432
437
  const aggressive = rows.filter(e => ['BET','RAISE'].includes(e.action?.type)).length;
433
438
  const calls = rows.filter(e => e.action?.type === 'CALL').length;
434
439
  const checks = rows.filter(e => e.action?.type === 'CHECK').length;
435
440
  const folds = rows.filter(e => e.action?.type === 'FOLD').length;
441
+ const foldOpportunities = rows.filter(e => (e.legalActions ?? []).some(action => action.type === 'FOLD')).length;
436
442
  let wins = 0;
437
- const observedHands = new Set(rows.map(e => Number(e.handNumber)));
443
+ const observedHands = new Set(dealtHands);
438
444
  for (const end of handEnds) {
439
445
  if ((end.winners ?? []).some(w => (w.playerId ?? w.id ?? w) === player.id)) { wins++; observedHands.add(Number(end.handNumber)); }
440
446
  }
@@ -443,12 +449,13 @@ function buildPublicPlayerStats(events, players, currentHand) {
443
449
  playerId: player.id,
444
450
  playerName: player.name,
445
451
  sampleHands: observedHands.size,
446
- preflopSamples: preflopHands.size,
452
+ preflopSamples: dealtHands.size,
447
453
  decisions: rows.length,
448
- vpipPct: pct(vpipHands.size, preflopHands.size),
449
- pfrPct: pct(pfrHands.size, preflopHands.size),
450
- aggressionPct: pct(aggressive, aggressive + calls + checks),
451
- foldPct: pct(folds, strategicActions),
454
+ vpipPct: pct(vpipHands.size, dealtHands.size),
455
+ pfrPct: pct(pfrHands.size, dealtHands.size),
456
+ // Standard aggression frequency (AFq), not the aggression-factor ratio.
457
+ aggressionPct: pct(aggressive, aggressive + calls + folds),
458
+ foldPct: pct(folds, foldOpportunities),
452
459
  callPct: pct(calls, strategicActions),
453
460
  checkPct: pct(checks, strategicActions),
454
461
  wins,
@@ -505,9 +512,12 @@ function normalizeConfig(input = {}) {
505
512
  parseHeaders(c.headers);
506
513
  return { ...c, baseUrl: normalizeBaseUrl(c.baseUrl) };
507
514
  });
515
+ const smallBlind = Math.max(1, Math.round(Number(input.smallBlind) || 25));
516
+ const bigBlind = Math.max(2, Math.round(Number(input.bigBlind) || 50));
517
+ if (bigBlind < smallBlind * 2) throw new Error('Big blind must be at least twice the small blind');
508
518
  return {
509
519
  id: id('tournament'), name: 'pokertools-arena', startingStack: Math.max(100, Math.round(Number(input.startingStack) || 10_000)),
510
- smallBlind: Math.max(1, Math.round(Number(input.smallBlind) || 25)), bigBlind: Math.max(2, Math.round(Number(input.bigBlind) || 50)), ante: Math.max(0, Math.round(Number(input.ante) || 0)),
520
+ smallBlind, bigBlind, ante: Math.max(0, Math.round(Number(input.ante) || 0)),
511
521
  handsPerLevel: Math.max(1, Math.round(Number(input.handsPerLevel) || 8)), blindMultiplier: clamp(Number(input.blindMultiplier) || 1.5, 1.1, 3),
512
522
  actionSeconds: clamp(Number(input.actionSeconds) || TIMING_DEFAULTS.actionSeconds, 1, 120), timeBankSeconds: clamp(Number(input.timeBankSeconds) || TIMING_DEFAULTS.timeBankSeconds, 0, 600),
513
523
  lowTimeSeconds: clamp(Number.isFinite(Number(input.lowTimeSeconds)) ? Number(input.lowTimeSeconds) : TIMING_DEFAULTS.lowTimeSeconds, 0, 600),
@@ -522,8 +532,9 @@ function normalizeConfig(input = {}) {
522
532
  players: players.map((raw, index) => {
523
533
  const lobbySeat = clamp(Math.round(Number(raw.lobbySeat ?? index)), 0, MAX_LOBBY_SEATS - 1);
524
534
  const name = String(raw.name || `Player ${lobbySeat + 1}`).trim().slice(0, 40);
525
- if (names.has(name)) throw new Error(`Duplicate player name: ${name}`);
526
- names.add(name);
535
+ const nameKey = name.toLocaleLowerCase();
536
+ if (names.has(nameKey)) throw new Error(`Duplicate player name: ${name}`);
537
+ names.add(nameKey);
527
538
  if (!connIds.has(raw.connectionId)) throw new Error(`Connection missing for ${name}`);
528
539
  const model = String(raw.model || '').trim();
529
540
  if (!model) throw new Error(`Model missing for ${name}`);
@@ -664,7 +675,14 @@ class TournamentDirector {
664
675
  for (const p of (this.engine.state.players ?? [])) if (p && playerStack(p) <= 0) { try { this.engine.stand(p.id); } catch {} }
665
676
  this.engine.deal();
666
677
  }
667
- this.logEvent('HAND_START', { handNumber: this.handNumber, buttonSeat: this.engine.state.buttonSeat, smallBlind: this.engine.state.smallBlind, bigBlind: this.engine.state.bigBlind, ante: this.engine.state.ante }); this.broadcast();
678
+ this.logEvent('HAND_START', {
679
+ handNumber: this.handNumber,
680
+ buttonSeat: this.engine.state.buttonSeat,
681
+ smallBlind: this.engine.state.smallBlind,
682
+ bigBlind: this.engine.state.bigBlind,
683
+ ante: this.engine.state.ante,
684
+ playerIds: (this.engine.state.players ?? []).filter(p => p && playerStack(p) > 0).map(p => p.id),
685
+ }); this.broadcast();
668
686
  }
669
687
  handComplete() {
670
688
  const s = this.engine.state;
@@ -743,19 +761,21 @@ class TournamentDirector {
743
761
  stats.retries += Number(result?.meta?.retryCount || 0);
744
762
  if (result?.meta?.protocolFallbackTriggered) stats.protocolFallbacks++;
745
763
  if (this.currentDecision?.id !== decisionId) throw new Error('Decision became stale');
746
- const elapsedActive = Math.max(0, Date.now() - startedAt - pausedTotal());
747
- if (elapsedActive > baseMs) this.timeBanks[agent.id] = Math.max(0, bankBefore - (elapsedActive - baseMs));
748
764
  } catch (err) {
749
765
  elapsed = Math.max(0, Date.now() - startedAt - pausedTotal()); error = err;
750
766
  for (const incident of err?.incidents || []) recordIncident(incident);
751
767
  errorCategory = (elapsed >= totalMs - 30 || err?.name === 'AbortError') ? 'timeout' : decisionErrorCategory(err);
752
- if (errorCategory === 'timeout') { stats.timeouts++; this.timeBanks[agent.id] = 0; }
768
+ if (errorCategory === 'timeout') stats.timeouts++;
753
769
  else if (errorCategory === 'rate_limit') stats.rateLimits++;
754
770
  else if (errorCategory === 'provider') stats.providerErrors++;
755
771
  else { stats.modelErrors++; stats.invalid++; }
756
772
  }
757
773
  if (this.status === 'STOPPED') throw new Error('Tournament stopped');
758
774
  await this.waitIfPaused();
775
+ // Charge active elapsed time consistently. Provider/model failures must not
776
+ // preserve a seat's bank while successful requests consume theirs.
777
+ const elapsedActive = Math.max(0, Date.now() - startedAt - pausedTotal());
778
+ this.timeBanks[agent.id] = Math.max(0, bankBefore - Math.max(0, elapsedActive - baseMs));
759
779
  let chosen = result?.action ?? null, forced = false;
760
780
  if (chosen && architecture === 'hierarchical') {
761
781
  // Final validation after both stages: reconstruct the exact engine action
@@ -1745,7 +1765,7 @@ function renderStats(s) {
1745
1765
  const st = s.stats?.[p.id] || {}, poker = publicById.get(p.id) || {}, tableP = s.table?.players?.find?.(x => x?.id === p.id), avg = st.decisions ? Math.round(st.totalLatencyMs / st.decisions) : 0;
1746
1766
  const pc = value => `${Math.round(Number(value || 0) * 100)}%`;
1747
1767
  return `<div class="stat-card"><div class="stat-top"><div><div class="stat-name">${escapeHtml(visiblePlayerName(p.name, p.model))}</div><div class="stat-model mono">${escapeHtml(p.model)} · ${escapeHtml(effectiveProtocol(p, s.config.connections.find(c => c.id === p.connectionId)))}</div></div><strong>${fmt(tableP?.stack || 0)}</strong></div>
1748
- <div class="poker-profile"><div><b>${pc(poker.vpipPct)}</b><span>VPIP</span></div><div><b>${pc(poker.pfrPct)}</b><span>PFR</span></div><div><b>${pc(poker.aggressionPct)}</b><span>AGG</span></div><div><b>${pc(poker.foldPct)}</b><span>FOLD</span></div><div><b>${poker.sampleHands || 0}</b><span>SAMPLE</span></div></div>
1768
+ <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>
1749
1769
  <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>`;
1750
1770
  }).join('');
1751
1771
  }
@@ -1770,7 +1790,13 @@ function decisionRenderSignature(s) {
1770
1790
  const last = latestDecisionEvent(s);
1771
1791
  return JSON.stringify([d ? [d.id, d.playerId, d.model, d.protocol, d.provider, d.startedAt, d.baseMs, d.timeBankMs, d.pausedMs || 0, Boolean(d.pausedAt), d.architecture || null, d.stage || null, d.legalActions] : null, last?.id || null, s?.status || 'IDLE', seatAssignments.filter(Boolean).length]);
1772
1792
  }
1773
- function feedRenderSignature(s) { return (s?.events || []).filter(e => e.type === 'DECISION').slice(-12).map(e => e.id).join('|'); }
1793
+ function feedRenderSignature(s) {
1794
+ return (s?.events || [])
1795
+ .filter(e => e.type === 'DECISION' || e.type === 'SPECTATOR_EXPLANATION')
1796
+ .slice(-24)
1797
+ .map(e => `${e.type}:${e.id}`)
1798
+ .join('|');
1799
+ }
1774
1800
  function eventsRenderSignature(s) { const ev = s?.events || []; return `${ev.length}:${ev.at(-1)?.id || ''}`; }
1775
1801
  function statsRenderSignature(s) {
1776
1802
  if (!s?.config) return 'none';
@@ -1989,6 +2015,7 @@ function setRecordButton(active, label = null) {
1989
2015
  if (icon) icon.textContent = active ? '■' : '●';
1990
2016
  if (text) text.textContent = label || (active ? 'Stop rec' : 'Record');
1991
2017
  els.recordBtn.title = active ? 'Stop table recording and save video' : 'Record only the poker table';
2018
+ els.recordBtn.setAttribute('aria-label', els.recordBtn.title);
1992
2019
  }
1993
2020
  function saveRecordingBlob(blob) {
1994
2021
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
@@ -2352,6 +2379,8 @@ els.soundBtn.addEventListener('click', async () => {
2352
2379
  if (label) label.textContent = soundEnabled ? 'Sound on' : 'Sound';
2353
2380
  els.soundBtn.setAttribute('aria-pressed', String(soundEnabled));
2354
2381
  els.soundBtn.classList.toggle('active', soundEnabled);
2382
+ els.soundBtn.title = soundEnabled ? 'Disable table sounds' : 'Enable table sounds';
2383
+ els.soundBtn.setAttribute('aria-label', els.soundBtn.title);
2355
2384
  if (soundEnabled) { try { await getAudioContext()?.resume(); } catch {} playTableSound('chip'); }
2356
2385
  });
2357
2386
 
@@ -2429,6 +2458,9 @@ els.setupForm.addEventListener('submit', event => {
2429
2458
  try {
2430
2459
  const raw = collectSetupRaw(true);
2431
2460
  for (const connection of raw.connections) parseHeaders(connection.headers);
2461
+ // Validate the same way the director will, so an invalid blind structure
2462
+ // (or any other config rule) is reported here instead of failing at Start.
2463
+ normalizeConfig(raw);
2432
2464
  saveSetupWithoutSecrets(raw);
2433
2465
  els.setupDialog.close();
2434
2466
  render(currentState || { status: 'IDLE', events: [] });
@@ -2471,15 +2503,36 @@ async function startConfiguredTournament() {
2471
2503
  }
2472
2504
  els.startTopBtn.addEventListener('click', startConfiguredTournament);
2473
2505
 
2474
- $$('.tab').forEach(tab => tab.addEventListener('click', () => {
2506
+ function activateInspectorTab(tab, { focus = false } = {}) {
2475
2507
  activeInspectorTab = tab.dataset.tab || 'live';
2476
- $$('.tab').forEach(t => t.classList.toggle('active', t === tab));
2477
- $$('.tab-panel').forEach(p => p.classList.toggle('active', p.id === `tab-${tab.dataset.tab}`));
2508
+ $$('.tab').forEach(t => {
2509
+ const active = t === tab;
2510
+ t.classList.toggle('active', active);
2511
+ t.setAttribute('aria-selected', String(active));
2512
+ t.tabIndex = active ? 0 : -1;
2513
+ });
2514
+ $$('.tab-panel').forEach(p => {
2515
+ const active = p.id === `tab-${tab.dataset.tab}`;
2516
+ p.classList.toggle('active', active);
2517
+ p.hidden = !active;
2518
+ });
2519
+ if (focus) tab.focus();
2478
2520
  if (!currentState) return;
2479
2521
  if (activeInspectorTab === 'live') { renderMemo.feed = feedRenderSignature(currentState); renderFeed(currentState); }
2480
2522
  else if (activeInspectorTab === 'log') { renderMemo.events = eventsRenderSignature(currentState); renderEvents(currentState); }
2481
2523
  else if (activeInspectorTab === 'stats') { renderMemo.stats = statsRenderSignature(currentState); renderStats(currentState); }
2482
- }));
2524
+ }
2525
+ $$('.tab').forEach(tab => {
2526
+ tab.addEventListener('click', () => activateInspectorTab(tab));
2527
+ tab.addEventListener('keydown', event => {
2528
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
2529
+ event.preventDefault();
2530
+ const tabs = $$('.tab');
2531
+ const current = tabs.indexOf(tab);
2532
+ const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : (current + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
2533
+ activateInspectorTab(tabs[next], { focus: true });
2534
+ });
2535
+ });
2483
2536
 
2484
2537
  window.addEventListener('beforeunload', event => {
2485
2538
  if (tableRecording) tableRecording.stream?.getTracks?.().forEach(t => t.stop());
package/src/index.html CHANGED
@@ -110,13 +110,13 @@
110
110
  </section>
111
111
 
112
112
  <aside class="inspector">
113
- <div class="tabs">
114
- <button class="tab active" data-tab="live">Live</button>
115
- <button class="tab" data-tab="log">Log</button>
116
- <button class="tab" data-tab="stats">Stats</button>
113
+ <div class="tabs" role="tablist" aria-label="Tournament inspector">
114
+ <button id="tabButton-live" class="tab active" data-tab="live" type="button" role="tab" aria-selected="true" aria-controls="tab-live">Live</button>
115
+ <button id="tabButton-log" class="tab" data-tab="log" type="button" role="tab" aria-selected="false" aria-controls="tab-log" tabindex="-1">Log</button>
116
+ <button id="tabButton-stats" class="tab" data-tab="stats" type="button" role="tab" aria-selected="false" aria-controls="tab-stats" tabindex="-1">Stats</button>
117
117
  </div>
118
118
 
119
- <div id="tab-live" class="tab-panel active">
119
+ <div id="tab-live" class="tab-panel active" role="tabpanel" aria-labelledby="tabButton-live">
120
120
  <section class="panel-block decision-panel">
121
121
  <div id="decisionPanelTitle" class="panel-title">Current decision</div>
122
122
  <div id="decisionEmpty" class="empty-state">Configure seats on the table. During play, the active model, clock, and legal actions appear here.</div>
@@ -138,13 +138,13 @@
138
138
  <section class="panel-block"><div class="panel-title">Recent decisions</div><div id="decisionFeed" class="decision-feed"></div></section>
139
139
  </div>
140
140
 
141
- <div id="tab-log" class="tab-panel">
141
+ <div id="tab-log" class="tab-panel" role="tabpanel" aria-labelledby="tabButton-log" hidden>
142
142
  <section class="panel-block grow">
143
143
  <div class="panel-title-row"><div class="panel-title">Tournament events</div><button id="exportBtn" class="button ghost small-button" type="button" disabled title="Download the tournament log as JSONL"><span class="action-icon">↓</span><span class="action-label">Download</span></button></div>
144
- <div id="eventLog" class="event-log mono"></div>
144
+ <div id="eventLog" class="event-log mono" aria-label="Tournament event log"></div>
145
145
  </section>
146
146
  </div>
147
- <div id="tab-stats" class="tab-panel"><section class="panel-block grow"><div class="panel-title">Models</div><div id="statsGrid" class="stats-grid"></div></section></div>
147
+ <div id="tab-stats" class="tab-panel" role="tabpanel" aria-labelledby="tabButton-stats" hidden><section class="panel-block grow"><div class="panel-title">Models</div><div id="statsGrid" class="stats-grid"></div></section></div>
148
148
  </aside>
149
149
  </main>
150
150
 
package/src/styles.css CHANGED
@@ -2348,3 +2348,27 @@ a.button{text-decoration:none}
2348
2348
  .poker-table[data-density="micro"] .board .card .card-corner.bottom{display:none!important}
2349
2349
  .poker-table[data-density="micro"] .board .card .card-suit{font-size:13px!important}
2350
2350
 
2351
+ /* Final accessibility and spectator-density pass. */
2352
+ .tab:focus-visible,.button:focus-visible,.icon-button:focus-visible,.arena-icon-button:focus-visible,.repo-link:focus-visible{outline:2px solid rgba(146,201,164,.7);outline-offset:2px}
2353
+ .tab[aria-selected="true"]{background:rgba(255,255,255,.06);color:var(--text)}
2354
+ .tab-panel[hidden]{display:none!important}
2355
+ .event-row{overflow-wrap:anywhere}
2356
+ .event-row .muted{font-size:9px}
2357
+ .stat-card,.stat-top>div{min-width:0}
2358
+ .stat-model{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
2359
+ .replay-dialog{padding:0;border:1px solid var(--modal-border);border-radius:14px;color:var(--text);background:var(--modal-bg)}
2360
+ .replay-dialog::backdrop{background:rgba(0,0,0,.72);backdrop-filter:blur(3px)}
2361
+ @media(max-width:520px){
2362
+ .stats-grid{grid-template-columns:1fr}
2363
+ .stat-top{flex-direction:row;align-items:start}
2364
+ .stat-name{font-size:12px}.stat-model{font-size:8px}
2365
+ .poker-profile b{font-size:10px}.poker-profile span{font-size:7px}
2366
+ .metric b{font-size:11px}.metric span{font-size:7px}
2367
+ .stat-reliability span{font-size:7px}.stat-reliability b{font-size:8px}
2368
+ }
2369
+ @media(max-width:720px){.poker-table[data-density="micro"] .last-action{font-size:6px!important}.event-log{font-size:9px;line-height:1.45}}
2370
+ @media(pointer:coarse){
2371
+ .tab{min-height:40px}
2372
+ .dialog-footer .button,.replay-share-actions .button{min-height:40px}
2373
+ .icon-button,.field-icon-button{min-width:40px;min-height:40px}
2374
+ }