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 +1 -1
- package/dist/app.js +62 -24
- package/dist/index.html +8 -8
- package/dist/pokertools-arena.html +94 -32
- package/dist/styles.css +24 -0
- package/docs/diagnostics/SUMMARY.md +1 -1
- package/docs/diagnostics/summary.json +1 -1
- package/docs/releases/RELEASE_NOTES.md +23 -0
- package/package.json +1 -1
- package/src/app.js +73 -20
- package/src/index.html +8 -8
- package/src/styles.css +24 -0
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.
|
|
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
|
|
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(
|
|
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:
|
|
116009
|
+
preflopSamples: dealtHands.size,
|
|
116007
116010
|
decisions: rows.length,
|
|
116008
|
-
vpipPct: pct(vpipHands.size,
|
|
116009
|
-
pfrPct: pct(pfrHands.size,
|
|
116010
|
-
|
|
116011
|
-
|
|
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
|
|
116090
|
-
bigBlind
|
|
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
|
-
|
|
116110
|
-
names.
|
|
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", {
|
|
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
|
-
|
|
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>
|
|
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(-
|
|
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
|
-
|
|
118425
|
+
function activateInspectorTab(tab, { focus = false } = {}) {
|
|
118409
118426
|
activeInspectorTab = tab.dataset.tab || "live";
|
|
118410
|
-
$$(".tab").forEach((t) =>
|
|
118411
|
-
|
|
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
|
|
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(
|
|
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:
|
|
118775
|
+
preflopSamples: dealtHands.size,
|
|
118749
118776
|
decisions: rows.length,
|
|
118750
|
-
vpipPct: pct(vpipHands.size,
|
|
118751
|
-
pfrPct: pct(pfrHands.size,
|
|
118752
|
-
|
|
118753
|
-
|
|
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
|
|
118832
|
-
bigBlind
|
|
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
|
-
|
|
118852
|
-
names.
|
|
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", {
|
|
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
|
-
|
|
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>
|
|
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(-
|
|
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
|
-
|
|
121191
|
+
function activateInspectorTab(tab, { focus = false } = {}) {
|
|
121151
121192
|
activeInspectorTab = tab.dataset.tab || "live";
|
|
121152
|
-
$$(".tab").forEach((t) =>
|
|
121153
|
-
|
|
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,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.
|
|
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
|
-
|
|
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(
|
|
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:
|
|
452
|
+
preflopSamples: dealtHands.size,
|
|
447
453
|
decisions: rows.length,
|
|
448
|
-
vpipPct: pct(vpipHands.size,
|
|
449
|
-
pfrPct: pct(pfrHands.size,
|
|
450
|
-
|
|
451
|
-
|
|
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
|
|
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
|
-
|
|
526
|
-
names.
|
|
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', {
|
|
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')
|
|
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>
|
|
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) {
|
|
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
|
-
|
|
2506
|
+
function activateInspectorTab(tab, { focus = false } = {}) {
|
|
2475
2507
|
activeInspectorTab = tab.dataset.tab || 'live';
|
|
2476
|
-
$$('.tab').forEach(t =>
|
|
2477
|
-
|
|
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
|
+
}
|