pokertools-arena 0.4.0
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/.env.example +8 -0
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/bin/pokertools-arena.mjs +197 -0
- package/dist/.nojekyll +0 -0
- package/dist/app.js +118427 -0
- package/dist/arena-env.js +2 -0
- package/dist/favicon.svg +4 -0
- package/dist/index.html +382 -0
- package/dist/og-image.png +0 -0
- package/dist/og-image.svg +190 -0
- package/dist/pokertools-arena.html +121106 -0
- package/dist/styles.css +2289 -0
- package/docs/architecture/CONTEXT.md +202 -0
- package/docs/diagnostics/SUMMARY.md +102 -0
- package/docs/diagnostics/summary.json +337 -0
- package/docs/legal/THIRD_PARTY_NOTICES.md +15 -0
- package/docs/releases/RELEASE_NOTES.md +171 -0
- package/docs/verification/0.3.x.md +250 -0
- package/package.json +77 -0
- package/src/app.js +2458 -0
- package/src/assets/favicon.svg +4 -0
- package/src/assets/og-image.png +0 -0
- package/src/assets/og-image.svg +190 -0
- package/src/benchmark/scenarios.js +108 -0
- package/src/env/arena-env.js +2 -0
- package/src/index.html +382 -0
- package/src/lib/decision-core.js +1288 -0
- package/src/shims/crypto.cjs +19 -0
- package/src/styles.css +2289 -0
package/src/app.js
ADDED
|
@@ -0,0 +1,2458 @@
|
|
|
1
|
+
import { createBrowserEngine as createPokerToolsBrowserEngine } from '@pokertools/engine/browser';
|
|
2
|
+
import { DECISION_SANITY_SCENARIOS } from './benchmark/scenarios.js';
|
|
3
|
+
import {
|
|
4
|
+
ACTION, DECISION_CONTEXT_VERSION, RECENT_PUBLIC_HANDS, DECISION_OBJECTIVE, INFORMATION_POLICY,
|
|
5
|
+
asNumber, round, clamp, summarizeError, ArenaRequestError, requestErrorCategory, decisionErrorCategory,
|
|
6
|
+
retryDelayMs, sleepWithSignal, fetchJsonWithRetry, isUnsupportedToolChoiceError, mapGet, jsonSafe,
|
|
7
|
+
normalizeBaseUrl, completionsUrl, openRouterDecisionsUrl, isOpenRouterConnection, isJevModel, isReasoningModel,
|
|
8
|
+
effectiveProtocol, modelsUrl, parseHeaders, makeHeaders, combineAbort,
|
|
9
|
+
playerCards, playerStack, currentBet, totalPot, clockwiseSeats, POSITION_TABLE, positionForSeat,
|
|
10
|
+
describeAction, tryCandidate, legalActionCandidates, fallbackAction,
|
|
11
|
+
serializeForAgent, assertDecisionState, extractTextContent, stripCodeFence, normalizeDecisionObject, pokerPrompt,
|
|
12
|
+
decideOpenAICompatible, decideJevDecisions, decideJevNative, decide, decideHierarchical, protocolCapabilityCache,
|
|
13
|
+
actionCriteria, heroHandSummary,
|
|
14
|
+
BENCHMARK_MODES, DECISION_ARCHITECTURES, DEFAULT_BENCHMARK_MODE, DEFAULT_DECISION_ARCHITECTURE,
|
|
15
|
+
REPRESENTATION_MODES, DEFAULT_REPRESENTATION_MODE, DECISION_ARCHITECTURE_VERSION,
|
|
16
|
+
legalActionFamilies, legalAggressiveSizes, buildHierarchicalDecision, familyCriteria, sizeCriteria,
|
|
17
|
+
applyBenchmarkMode, renderDecisionState, aggregateActionProbabilitiesByFamily, probabilityStats, SIZE_LABELS, SPECTATOR_NOTE,
|
|
18
|
+
isAggressiveType, familyForActionType, FAMILY_LABELS, decisionClockPhase,
|
|
19
|
+
} from './lib/decision-core.js';
|
|
20
|
+
|
|
21
|
+
const $ = (sel, root = document) => root.querySelector(sel);
|
|
22
|
+
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
|
|
23
|
+
|
|
24
|
+
const els = {
|
|
25
|
+
startTopBtn: $('#startTopBtn'), seatsBtn: $('#seatsBtn'), soundBtn: $('#soundBtn'), recordBtn: $('#recordBtn'), exportBtn: $('#exportBtn'), setupBtn: $('#setupBtn'), testsBtn: $('#testsBtn'), pauseBtn: $('#pauseBtn'), stopBtn: $('#stopBtn'),
|
|
26
|
+
statusDot: $('#statusDot'), statusLabel: $('#statusLabel'), tournamentMeta: $('#tournamentMeta'),
|
|
27
|
+
pokerTable: $('#pokerTable'), seatsLayer: $('#seatsLayer'), fxLayer: $('#fxLayer'), actionToast: $('#actionToast'), board: $('#board'), potValue: $('#potValue'), blindsValue: $('#blindsValue'), anteValue: $('#anteValue'), handValue: $('#handValue'), levelValue: $('#levelValue'), streetLabel: $('#streetLabel'), winnerBanner: $('#winnerBanner'),
|
|
28
|
+
decisionPanelTitle: $('#decisionPanelTitle'), decisionEmpty: $('#decisionEmpty'), decisionCard: $('#decisionCard'), decisionPlayer: $('#decisionPlayer'), decisionModel: $('#decisionModel'), decisionPhase: $('#decisionPhase'), decisionClock: $('#decisionClock'), bankClock: $('#bankClock'), decisionHand: $('#decisionHand'), decisionStreet: $('#decisionStreet'), decisionPosition: $('#decisionPosition'), decisionOptionCount: $('#decisionOptionCount'), decisionActionLabel: $('#decisionActionLabel'), decisionActionHint: $('#decisionActionHint'), legalActions: $('#legalActions'), decisionLabelHand: $('#decisionLabelHand'), decisionLabelStreet: $('#decisionLabelStreet'), decisionLabelPosition: $('#decisionLabelPosition'), decisionLabelOptions: $('#decisionLabelOptions'),
|
|
29
|
+
decisionFeed: $('#decisionFeed'), eventLog: $('#eventLog'), statsGrid: $('#statsGrid'),
|
|
30
|
+
setupDialog: $('#setupDialog'), setupForm: $('#setupForm'), closeSetup: $('#closeSetup'), setupError: $('#setupError'), saveSettingsBtn: $('#saveSettingsBtn'), seatSummary: $('#seatSummary'),
|
|
31
|
+
connectionsEditor: $('#connectionsEditor'), connectionRowTemplate: $('#connectionRowTemplate'), addConnectionBtn: $('#addConnectionBtn'),
|
|
32
|
+
seatDialog: $('#seatDialog'), seatForm: $('#seatForm'), closeSeat: $('#closeSeat'), seatDialogTitle: $('#seatDialogTitle'), seatLockNotice: $('#seatLockNotice'),
|
|
33
|
+
seatName: $('#seatName'), seatConnection: $('#seatConnection'), seatModel: $('#seatModel'), seatModelOptions: $('#seatModelOptions'), seatModelStatus: $('#seatModelStatus'), refreshModelsBtn: $('#refreshModelsBtn'), seatProtocol: $('#seatProtocol'), seatProvider: $('#seatProvider'), seatError: $('#seatError'), removeSeatBtn: $('#removeSeatBtn'), cancelSeatBtn: $('#cancelSeatBtn'), saveSeatBtn: $('#saveSeatBtn'),
|
|
34
|
+
testsDialog: $('#testsDialog'), closeTests: $('#closeTests'), runTestsBtn: $('#runTestsBtn'), clearTestsBtn: $('#clearTestsBtn'), testsStatus: $('#testsStatus'), testsParticipants: $('#testsParticipants'), testsProgressLabel: $('#testsProgressLabel'), testsProgressFill: $('#testsProgressFill'), testsResults: $('#testsResults'),
|
|
35
|
+
replayDialog: $('#replayDialog'), closeReplay: $('#closeReplay'), replayTitle: $('#replayTitle'), replayBadge: $('#replayBadge'), replaySubtitle: $('#replaySubtitle'), replayOpponents: $('#replayOpponents'), replayStreet: $('#replayStreet'), replayBoard: $('#replayBoard'), replayPot: $('#replayPot'), replayHero: $('#replayHero'), replaySummary: $('#replaySummary'), replayAction: $('#replayAction'), replayReason: $('#replayReason'), replayHistory: $('#replayHistory'), replayLegal: $('#replayLegal'), replayShareStatus: $('#replayShareStatus'), copyReplayImage: $('#copyReplayImage'), shareReplayImage: $('#shareReplayImage'), saveReplayImage: $('#saveReplayImage'),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
let engineModule = null;
|
|
39
|
+
let director = null;
|
|
40
|
+
let currentState = null;
|
|
41
|
+
let clockTimer = null;
|
|
42
|
+
let rowSeq = 0;
|
|
43
|
+
let soundEnabled = false;
|
|
44
|
+
let audioContext = null;
|
|
45
|
+
let lastVisualState = null;
|
|
46
|
+
let lastProcessedEventId = null;
|
|
47
|
+
const MAX_LOBBY_SEATS = 10;
|
|
48
|
+
let seatAssignments = Array(MAX_LOBBY_SEATS).fill(null);
|
|
49
|
+
let editingSeatIndex = null;
|
|
50
|
+
let lobbyVisible = true;
|
|
51
|
+
let pendingAutostart = false;
|
|
52
|
+
let arenaMaxDecisions = 0;
|
|
53
|
+
const modelCatalogCache = new Map();
|
|
54
|
+
const OPENROUTER_DECISION_MODELS = Object.freeze([
|
|
55
|
+
{ id: 'typesafe/jev-1.13', name: 'TypeSafe · Jev 1.13 · Decisions API' },
|
|
56
|
+
{ id: '~typesafe/jev-latest', name: 'TypeSafe · Jev Latest · Decisions API' },
|
|
57
|
+
]);
|
|
58
|
+
let sanityResults = [];
|
|
59
|
+
let sanityRunAbort = null;
|
|
60
|
+
let activeInspectorTab = 'live';
|
|
61
|
+
let activeDecisionClockId = null;
|
|
62
|
+
let tableRecording = null;
|
|
63
|
+
let currentReplayEvent = null;
|
|
64
|
+
const renderMemo = { status: '', table: '', decision: '', feed: '', events: '', stats: '' };
|
|
65
|
+
|
|
66
|
+
// Single source of truth for timing defaults. Every value here is overridable
|
|
67
|
+
// from the setup form (and therefore from saved config / launcher injection), so
|
|
68
|
+
// the seat ring, the decision clock and the request timeout can never disagree
|
|
69
|
+
// about how much time a player actually has.
|
|
70
|
+
const TIMING_DEFAULTS = Object.freeze({
|
|
71
|
+
actionSeconds: 12,
|
|
72
|
+
timeBankSeconds: 30,
|
|
73
|
+
lowTimeSeconds: 5,
|
|
74
|
+
lowTimeFraction: 0.25,
|
|
75
|
+
betweenActionsMs: 250,
|
|
76
|
+
betweenHandsMs: 700,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
function animationsAllowed() {
|
|
80
|
+
return document.visibilityState === 'visible' && !globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getAudioContext() {
|
|
84
|
+
if (!audioContext) {
|
|
85
|
+
const Ctx = globalThis.AudioContext || globalThis.webkitAudioContext;
|
|
86
|
+
if (Ctx) audioContext = new Ctx();
|
|
87
|
+
}
|
|
88
|
+
return audioContext;
|
|
89
|
+
}
|
|
90
|
+
function tone(freq, duration = 0.05, gain = 0.035, offset = 0, type = 'sine') {
|
|
91
|
+
if (!soundEnabled) return;
|
|
92
|
+
const ctx = getAudioContext();
|
|
93
|
+
if (!ctx) return;
|
|
94
|
+
const start = ctx.currentTime + offset;
|
|
95
|
+
const osc = ctx.createOscillator(), amp = ctx.createGain();
|
|
96
|
+
osc.type = type; osc.frequency.setValueAtTime(freq, start);
|
|
97
|
+
amp.gain.setValueAtTime(0.0001, start); amp.gain.exponentialRampToValueAtTime(gain, start + 0.008);
|
|
98
|
+
amp.gain.exponentialRampToValueAtTime(0.0001, start + duration);
|
|
99
|
+
osc.connect(amp).connect(ctx.destination); osc.start(start); osc.stop(start + duration + 0.02);
|
|
100
|
+
}
|
|
101
|
+
function playTableSound(kind) {
|
|
102
|
+
if (!soundEnabled) return;
|
|
103
|
+
if (kind === 'deal') { tone(720, .035, .018, 0, 'triangle'); tone(510, .04, .014, .035, 'triangle'); }
|
|
104
|
+
else if (kind === 'chip') { tone(1320, .028, .025, 0, 'square'); tone(940, .035, .018, .026, 'square'); }
|
|
105
|
+
else if (kind === 'fold') { tone(240, .08, .018, 0, 'triangle'); }
|
|
106
|
+
else if (kind === 'check') { tone(480, .035, .015, 0, 'sine'); }
|
|
107
|
+
else if (kind === 'winner') { tone(523, .12, .028, 0); tone(659, .12, .026, .1); tone(784, .18, .03, .2); }
|
|
108
|
+
}
|
|
109
|
+
function elementCenter(el, relativeTo) {
|
|
110
|
+
if (!el || !relativeTo) return null;
|
|
111
|
+
const r = el.getBoundingClientRect(), p = relativeTo.getBoundingClientRect();
|
|
112
|
+
return { x: r.left - p.left + r.width / 2, y: r.top - p.top + r.height / 2 };
|
|
113
|
+
}
|
|
114
|
+
function flyChips(fromEl, toEl, count = 3, reverse = false) {
|
|
115
|
+
if (!animationsAllowed() || !els.fxLayer || !fromEl || !toEl) return;
|
|
116
|
+
const tight = els.pokerTable?.dataset?.density === 'tight';
|
|
117
|
+
count = Math.min(count, tight ? 2 : 3);
|
|
118
|
+
const from = elementCenter(fromEl, els.fxLayer), to = elementCenter(toEl, els.fxLayer);
|
|
119
|
+
if (!from || !to) return;
|
|
120
|
+
for (let i = 0; i < count; i++) {
|
|
121
|
+
const chip = document.createElement('span'); chip.className = `flying-chip chip-${i % 3}`;
|
|
122
|
+
chip.style.left = `${from.x - 8 + i * 3}px`; chip.style.top = `${from.y - 8 - i * 2}px`; els.fxLayer.append(chip);
|
|
123
|
+
chip.animate([
|
|
124
|
+
{ transform: 'translate3d(0,0,0) scale(.8)', opacity: 0 },
|
|
125
|
+
{ opacity: 1, offset: .12 },
|
|
126
|
+
{ transform: `translate3d(${to.x - from.x}px, ${to.y - from.y}px, 0) scale(1.05)`, opacity: 1, offset: .84 },
|
|
127
|
+
{ transform: `translate3d(${to.x - from.x}px, ${to.y - from.y}px, 0) scale(.65)`, opacity: 0 },
|
|
128
|
+
], { duration: 520 + i * 55, delay: i * 45, easing: reverse ? 'cubic-bezier(.2,.8,.2,1)' : 'cubic-bezier(.2,.75,.15,1)', fill: 'forwards' }).finished.finally(() => chip.remove());
|
|
129
|
+
}
|
|
130
|
+
playTableSound('chip');
|
|
131
|
+
}
|
|
132
|
+
function showActionToast(text, type = '') {
|
|
133
|
+
if (!els.actionToast) return;
|
|
134
|
+
els.actionToast.textContent = text;
|
|
135
|
+
els.actionToast.className = `action-toast ${String(type).toLowerCase()}`;
|
|
136
|
+
els.pokerTable?.classList.add('action-message-visible');
|
|
137
|
+
clearTimeout(showActionToast.timer);
|
|
138
|
+
showActionToast.timer = setTimeout(() => {
|
|
139
|
+
els.actionToast.classList.add('hidden');
|
|
140
|
+
els.pokerTable?.classList.remove('action-message-visible');
|
|
141
|
+
}, 1550);
|
|
142
|
+
}
|
|
143
|
+
function seatEl(playerId) { return $(`.seat[data-player-id="${CSS.escape(String(playerId))}"]`, els.seatsLayer); }
|
|
144
|
+
function animateNewHand(s) {
|
|
145
|
+
if (!animationsAllowed()) return;
|
|
146
|
+
requestAnimationFrame(() => {
|
|
147
|
+
const cards = $$('.seat .card:not(.empty)', els.seatsLayer);
|
|
148
|
+
cards.forEach((card, i) => {
|
|
149
|
+
card.animate([
|
|
150
|
+
{ transform: `translate(${i % 2 ? -160 : 160}px, ${i % 3 ? -180 : 180}px) rotate(${i % 2 ? -18 : 18}deg) scale(.72)`, opacity: 0 },
|
|
151
|
+
{ transform: 'translate(0,0) rotate(0deg) scale(1)', opacity: 1 }
|
|
152
|
+
], { duration: 480, delay: 55 * i, easing: 'cubic-bezier(.2,.85,.25,1)', fill: 'both' });
|
|
153
|
+
});
|
|
154
|
+
if (cards.length) playTableSound('deal');
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
function animateBoardCards(previousCount, currentCount) {
|
|
158
|
+
if (!animationsAllowed() || currentCount <= previousCount) return;
|
|
159
|
+
requestAnimationFrame(() => {
|
|
160
|
+
$$('.board .card', els.board).slice(previousCount, currentCount).forEach((card, i) => {
|
|
161
|
+
card.animate([
|
|
162
|
+
{ transform: 'translateY(-18px) rotateY(88deg) scale(.88)', opacity: .15 },
|
|
163
|
+
{ transform: 'translateY(0) rotateY(0deg) scale(1)', opacity: 1 }
|
|
164
|
+
], { duration: 420, delay: i * 100, easing: 'cubic-bezier(.2,.8,.2,1)', fill: 'both' });
|
|
165
|
+
});
|
|
166
|
+
playTableSound('deal');
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
function processVisualEffects(s) {
|
|
170
|
+
const events = s?.events || [];
|
|
171
|
+
if (!animationsAllowed()) {
|
|
172
|
+
if (events.length) lastProcessedEventId = events.at(-1).id;
|
|
173
|
+
lastVisualState = s;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const previous = lastVisualState;
|
|
177
|
+
if (s?.table && (!previous?.table || s.table.handNumber !== previous.table.handNumber)) animateNewHand(s);
|
|
178
|
+
const prevBoard = previous?.table?.board?.length || 0, nextBoard = s?.table?.board?.length || 0;
|
|
179
|
+
if (s?.table?.handNumber === previous?.table?.handNumber) animateBoardCards(prevBoard, nextBoard);
|
|
180
|
+
|
|
181
|
+
let start = 0;
|
|
182
|
+
if (lastProcessedEventId) { const idx = events.findIndex(e => e.id === lastProcessedEventId); start = idx >= 0 ? idx + 1 : Math.max(0, events.length - 4); }
|
|
183
|
+
for (const e of events.slice(start)) {
|
|
184
|
+
if (e.type === 'DECISION') {
|
|
185
|
+
const seat = seatEl(e.playerId), pot = $('.hud-pot', document);
|
|
186
|
+
const type = e.action?.type || '';
|
|
187
|
+
if ([ACTION.CALL, ACTION.BET, ACTION.RAISE].includes(type)) flyChips(seat, pot, type === ACTION.RAISE ? 4 : 3);
|
|
188
|
+
if (type === ACTION.FOLD && seat) { seat.classList.add('fold-flash'); setTimeout(() => seat.classList.remove('fold-flash'), 650); playTableSound('fold'); }
|
|
189
|
+
if (type === ACTION.CHECK && seat) { seat.classList.add('check-flash'); setTimeout(() => seat.classList.remove('check-flash'), 500); playTableSound('check'); }
|
|
190
|
+
showActionToast(`${displayModelName(e.configuredModel || e.resolvedModel || e.playerName)} · ${e.action?.description || type}`, type);
|
|
191
|
+
}
|
|
192
|
+
if (e.type === 'HAND_END' && previous?.table) {
|
|
193
|
+
const before = new Map((previous.table.players || []).filter(Boolean).map(p => [p.id, Number(p.stack || 0)]));
|
|
194
|
+
const winners = (s?.table?.players || []).filter(p => p && Number(p.stack || 0) > (before.get(p.id) ?? Number(p.stack || 0)));
|
|
195
|
+
const pot = $('.hud-pot', document); winners.forEach((p, i) => setTimeout(() => flyChips(pot, seatEl(p.id), 5, true), i * 130));
|
|
196
|
+
if (winners.length) playTableSound('winner');
|
|
197
|
+
}
|
|
198
|
+
if (e.type === 'TOURNAMENT_END') playTableSound('winner');
|
|
199
|
+
}
|
|
200
|
+
if (events.length) lastProcessedEventId = events.at(-1).id;
|
|
201
|
+
lastVisualState = s;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const defaultConnections = [
|
|
205
|
+
{ name: 'API', kind: 'openai', baseUrl: 'https://api.openai.com/v1', apiKey: '', headers: '' },
|
|
206
|
+
];
|
|
207
|
+
const defaultPlayers = [];
|
|
208
|
+
|
|
209
|
+
function injectedEnvironmentConfig() {
|
|
210
|
+
const raw = globalThis.__POKERTOOLS_ENV__;
|
|
211
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
212
|
+
const connections = Array.isArray(raw.connections) ? raw.connections.filter(c => c && c.baseUrl) : [];
|
|
213
|
+
const players = Array.isArray(raw.players) ? raw.players.filter(p => p && p.model) : [];
|
|
214
|
+
if (!connections.length && !players.length) return null;
|
|
215
|
+
const settings = raw.settings && typeof raw.settings === 'object'
|
|
216
|
+
? {
|
|
217
|
+
startingStack: Math.max(100, Math.round(Number(raw.settings.startingStack) || 10_000)),
|
|
218
|
+
autostart: Boolean(raw.settings.autostart),
|
|
219
|
+
maxDecisions: Math.max(0, Math.round(Number(raw.settings.maxDecisions) || 0)),
|
|
220
|
+
}
|
|
221
|
+
: null;
|
|
222
|
+
return {
|
|
223
|
+
settings,
|
|
224
|
+
connections: connections.map((c, index) => ({
|
|
225
|
+
id: String(c.id || `env-connection-${index + 1}`),
|
|
226
|
+
name: String(c.name || 'API'),
|
|
227
|
+
kind: ['openai','openrouter','typesafe'].includes(c.kind) ? c.kind : 'openai',
|
|
228
|
+
baseUrl: String(c.baseUrl || 'https://api.openai.com/v1'),
|
|
229
|
+
apiKey: String(c.apiKey || ''),
|
|
230
|
+
headers: String(c.headers || ''),
|
|
231
|
+
})),
|
|
232
|
+
players: players.slice(0, MAX_LOBBY_SEATS).map((player, index) => ({
|
|
233
|
+
lobbySeat: clamp(Math.round(Number(player.lobbySeat ?? index)), 0, MAX_LOBBY_SEATS - 1),
|
|
234
|
+
name: String(player.name || `Player ${index + 1}`),
|
|
235
|
+
connectionId: String(player.connectionId || connections[0]?.id || ''),
|
|
236
|
+
model: String(player.model || ''),
|
|
237
|
+
protocol: String(player.protocol || 'tool'),
|
|
238
|
+
provider: String(player.provider || ''),
|
|
239
|
+
})),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function escapeHtml(value) {
|
|
244
|
+
return String(value ?? '').replace(/[&<>'"]/g, c => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[c]));
|
|
245
|
+
}
|
|
246
|
+
function fmt(n) { return Number(n || 0).toLocaleString('en-US'); }
|
|
247
|
+
function formatDuration(ms) {
|
|
248
|
+
const total = Math.max(0, Math.round(asNumber(ms) / 1000));
|
|
249
|
+
const minutes = Math.floor(total / 60), seconds = total % 60;
|
|
250
|
+
return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
|
|
251
|
+
}
|
|
252
|
+
function fmtHud(n) {
|
|
253
|
+
const value = asNumber(n);
|
|
254
|
+
const abs = Math.abs(value);
|
|
255
|
+
if (abs < 10_000) return fmt(value);
|
|
256
|
+
const units = ['', 'K', 'M', 'B', 'T', 'Q'];
|
|
257
|
+
const tier = Math.min(units.length - 1, Math.floor(Math.log10(abs) / 3));
|
|
258
|
+
if (tier <= 0) return fmt(value);
|
|
259
|
+
const scaled = value / (1000 ** tier);
|
|
260
|
+
const digits = Math.abs(scaled) >= 100 ? 0 : Math.abs(scaled) >= 10 ? 1 : 2;
|
|
261
|
+
return `${Number(scaled.toFixed(digits)).toLocaleString('en-US', { maximumFractionDigits: digits })}${units[tier]}`;
|
|
262
|
+
}
|
|
263
|
+
function shortModel(model = '') { const bits = String(model).replace(/^~/, '').split('/'); return bits.at(-1) || model; }
|
|
264
|
+
function displayModelName(model = '') {
|
|
265
|
+
const raw = shortModel(model).trim();
|
|
266
|
+
const lower = raw.toLowerCase();
|
|
267
|
+
if (!raw) return 'Model';
|
|
268
|
+
if (lower.startsWith('jev')) return 'Jev';
|
|
269
|
+
if (lower.startsWith('gemma')) return 'Gemma';
|
|
270
|
+
if (lower.startsWith('qwen')) return lower.includes('flash') ? 'Qwen Flash' : 'Qwen';
|
|
271
|
+
if (lower.startsWith('claude')) return 'Claude';
|
|
272
|
+
if (lower.startsWith('deepseek')) return 'DeepSeek';
|
|
273
|
+
if (lower.startsWith('llama')) return 'Llama';
|
|
274
|
+
if (lower.startsWith('mistral') || lower.startsWith('ministral')) return 'Mistral';
|
|
275
|
+
if (lower.startsWith('gpt-oss')) return 'GPT OSS';
|
|
276
|
+
if (lower.startsWith('gpt')) return 'GPT';
|
|
277
|
+
if (/^o[134](?:-|$)/i.test(raw)) return raw.match(/^o[134]/i)?.[0]?.toUpperCase() || raw;
|
|
278
|
+
if (lower.startsWith('glm')) return 'GLM';
|
|
279
|
+
const simplified = raw
|
|
280
|
+
.replace(/[-_]?v?\d+(?:\.\d+)+(?:[-_]|$).*/i, '')
|
|
281
|
+
.replace(/[-_]?\d+(?:b|m|k)(?:[-_].*)?$/i, '')
|
|
282
|
+
.replace(/[-_]+$/g, '')
|
|
283
|
+
.trim();
|
|
284
|
+
return simplified ? simplified.replace(/(^|[-_\s])([a-z])/g, (_, a, b) => `${a}${b.toUpperCase()}`) : raw;
|
|
285
|
+
}
|
|
286
|
+
function visiblePlayerName(name = '', model = '') {
|
|
287
|
+
return /^Player\s+\d+$/i.test(String(name).trim()) ? displayModelName(model) : String(name || displayModelName(model));
|
|
288
|
+
}
|
|
289
|
+
function protocolDisplay(protocol = '') {
|
|
290
|
+
const value = String(protocol || '').toLowerCase();
|
|
291
|
+
if (value === 'json_schema') return 'JSON Schema';
|
|
292
|
+
if (value === 'jev_decisions' || value === 'openrouter-decisions') return 'Jev Decisions';
|
|
293
|
+
if (value === 'jev_native') return 'Jev Native';
|
|
294
|
+
if (value === 'prompt_json') return 'Prompt JSON';
|
|
295
|
+
if (value === 'tool') return 'Tool';
|
|
296
|
+
return protocol ? String(protocol).replaceAll('_', ' ') : 'Protocol';
|
|
297
|
+
}
|
|
298
|
+
function ordinal(value) {
|
|
299
|
+
const n = Number(value) || 0, mod100 = n % 100;
|
|
300
|
+
if (mod100 >= 11 && mod100 <= 13) return `${n}TH`;
|
|
301
|
+
return `${n}${n % 10 === 1 ? 'ST' : n % 10 === 2 ? 'ND' : n % 10 === 3 ? 'RD' : 'TH'}`;
|
|
302
|
+
}
|
|
303
|
+
function sleep(ms) { return new Promise(resolve => setTimeout(resolve, Math.max(0, ms))); }
|
|
304
|
+
function storageGet(key) { try { return globalThis.localStorage?.getItem(key) ?? null; } catch { return null; } }
|
|
305
|
+
function storageSet(key, value) { try { globalThis.localStorage?.setItem(key, value); return true; } catch { return false; } }
|
|
306
|
+
function id(prefix = 'id') { return `${prefix}-${Date.now().toString(36)}-${crypto.getRandomValues(new Uint32Array(1))[0].toString(36)}`; }
|
|
307
|
+
function cardHtml(card, empty = false, extraClass = '', rankOnlyCorners = false) {
|
|
308
|
+
if (!card || empty) return `<div class="card empty ${extraClass}" aria-hidden="true"><span class="card-back-mark">♠</span></div>`;
|
|
309
|
+
const raw = String(card).trim();
|
|
310
|
+
const normalized = raw.replace(/10/i, 'T');
|
|
311
|
+
const match = normalized.match(/^([2-9TJQKA])([shdc♠♥♦♣])$/i);
|
|
312
|
+
const suitMap = { s: '♠', h: '♥', d: '♦', c: '♣', '♠': '♠', '♥': '♥', '♦': '♦', '♣': '♣' };
|
|
313
|
+
const rank = match ? match[1].toUpperCase() : raw.slice(0, -1).toUpperCase();
|
|
314
|
+
const suit = match ? suitMap[match[2].toLowerCase()] || match[2] : suitMap[raw.slice(-1).toLowerCase()] || raw.slice(-1);
|
|
315
|
+
const red = suit === '♥' || suit === '♦';
|
|
316
|
+
const corner = `<span class="card-corner"><b>${escapeHtml(rank)}</b>${rankOnlyCorners ? '' : `<i>${escapeHtml(suit)}</i>`}</span>`;
|
|
317
|
+
const bottomCorner = `<span class="card-corner bottom"><b>${escapeHtml(rank)}</b>${rankOnlyCorners ? '' : `<i>${escapeHtml(suit)}</i>`}</span>`;
|
|
318
|
+
return `<div class="card ${red ? 'red' : 'black'} ${rankOnlyCorners ? 'rank-only-corners' : ''} ${extraClass}" aria-label="${escapeHtml(rank + suit)}">${corner}<span class="card-suit">${escapeHtml(suit)}</span>${bottomCorner}</div>`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function loadPokerTools() {
|
|
322
|
+
engineModule = { createBrowserEngine: createPokerToolsBrowserEngine };
|
|
323
|
+
return engineModule;
|
|
324
|
+
}
|
|
325
|
+
function serializeActionHistory(state, limit = 128) {
|
|
326
|
+
const rows = Array.isArray(state?.actionHistory) ? state.actionHistory.slice(-limit) : [];
|
|
327
|
+
return rows.map(row => {
|
|
328
|
+
if (typeof row === 'string') return row;
|
|
329
|
+
const seat = row.seat ?? row.playerSeat ?? null;
|
|
330
|
+
const player = seat != null ? state.players?.[seat] : null;
|
|
331
|
+
const action = row.action && typeof row.action === 'object' ? row.action : row;
|
|
332
|
+
const who = row.playerName ?? player?.name ?? action.playerId ?? (seat != null ? `Seat ${seat + 1}` : 'Table');
|
|
333
|
+
const amount = action.amount != null ? ` ${action.amount}` : '';
|
|
334
|
+
return `${who}: ${action.type ?? 'ACTION'}${amount}`;
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
function publicWinnerSummary(winners, players = []) {
|
|
338
|
+
if (!Array.isArray(winners)) return [];
|
|
339
|
+
const byId = new Map((players ?? []).filter(Boolean).map(p => [p.id, p]));
|
|
340
|
+
return winners.map(w => {
|
|
341
|
+
if (typeof w === 'string') {
|
|
342
|
+
const player = byId.get(w);
|
|
343
|
+
const seat = player ? players.findIndex(p => p?.id === player.id) : null;
|
|
344
|
+
return { seat: seat >= 0 ? seat + 1 : null, playerId: w, playerName: player?.name ?? null, amount: null };
|
|
345
|
+
}
|
|
346
|
+
if (!w || typeof w !== 'object') return { value: String(w) };
|
|
347
|
+
const rawSeat = Number.isInteger(w.seat) ? w.seat : (Number.isInteger(w.playerSeat) ? w.playerSeat : null);
|
|
348
|
+
const seatPlayer = rawSeat != null ? players?.[rawSeat] : null;
|
|
349
|
+
const playerId = w.playerId ?? w.id ?? seatPlayer?.id ?? null;
|
|
350
|
+
const player = playerId ? byId.get(playerId) : seatPlayer;
|
|
351
|
+
const resolvedSeat = rawSeat != null ? rawSeat : (player ? players.findIndex(p => p?.id === player.id) : null);
|
|
352
|
+
return {
|
|
353
|
+
seat: resolvedSeat != null && resolvedSeat >= 0 ? resolvedSeat + 1 : null,
|
|
354
|
+
playerId,
|
|
355
|
+
playerName: w.playerName ?? w.name ?? player?.name ?? null,
|
|
356
|
+
amount: w.amount ?? w.winnings ?? w.payout ?? null,
|
|
357
|
+
handRank: w.handRank ?? null,
|
|
358
|
+
};
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
function publicDecisionAction(e) {
|
|
362
|
+
return {
|
|
363
|
+
street: e.street ?? null,
|
|
364
|
+
playerId: e.playerId ?? null,
|
|
365
|
+
playerName: e.playerName ?? null,
|
|
366
|
+
position: e.position ?? null,
|
|
367
|
+
action: {
|
|
368
|
+
type: e.action?.type ?? null,
|
|
369
|
+
amount: e.action?.amount ?? null,
|
|
370
|
+
description: e.action?.description ?? e.action?.type ?? null,
|
|
371
|
+
},
|
|
372
|
+
potBefore: e.potBefore ?? null,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
function buildCurrentHandPublicActions(events, currentHand) {
|
|
376
|
+
return (events ?? []).filter(e => e.type === 'DECISION' && Number(e.handNumber) === Number(currentHand)).map(publicDecisionAction);
|
|
377
|
+
}
|
|
378
|
+
function buildPublicTournamentMemory(events, currentHand, limitHands = RECENT_PUBLIC_HANDS) {
|
|
379
|
+
const completed = (events ?? []).filter(e => e.type === 'HAND_END' && Number(e.handNumber) < Number(currentHand)).slice(-limitHands);
|
|
380
|
+
return completed.map(end => ({
|
|
381
|
+
handNumber: end.handNumber,
|
|
382
|
+
board: Array.isArray(end.board) ? end.board : [],
|
|
383
|
+
winners: Array.isArray(end.winners) ? end.winners.map(w => ({ playerId: w.playerId ?? null, playerName: w.playerName ?? null, amount: w.amount ?? null })) : [],
|
|
384
|
+
stacksAfter: Array.isArray(end.stacks) ? end.stacks.map(x => ({ seat: x.seat, name: x.name, stack: x.stack })) : [],
|
|
385
|
+
actions: (events ?? []).filter(e => e.type === 'DECISION' && e.handNumber === end.handNumber).map(publicDecisionAction),
|
|
386
|
+
}));
|
|
387
|
+
}
|
|
388
|
+
function pct(n, d) { return d > 0 ? round(n / d, 3) : 0; }
|
|
389
|
+
function buildPublicPlayerStats(events, players, currentHand) {
|
|
390
|
+
const completedHandNumbers = new Set((events ?? []).filter(e => e.type === 'HAND_END' && Number(e.handNumber) < Number(currentHand)).map(e => Number(e.handNumber)));
|
|
391
|
+
const decisions = (events ?? []).filter(e => e.type === 'DECISION' && completedHandNumbers.has(Number(e.handNumber)));
|
|
392
|
+
const handEnds = (events ?? []).filter(e => e.type === 'HAND_END' && completedHandNumbers.has(Number(e.handNumber)));
|
|
393
|
+
return (players ?? []).map(player => {
|
|
394
|
+
const rows = decisions.filter(e => e.playerId === player.id);
|
|
395
|
+
const preflop = rows.filter(e => e.street === 'PREFLOP');
|
|
396
|
+
const preflopHands = new Set(preflop.map(e => e.handNumber));
|
|
397
|
+
const vpipHands = new Set(preflop.filter(e => ['CALL','BET','RAISE'].includes(e.action?.type)).map(e => e.handNumber));
|
|
398
|
+
const pfrHands = new Set(preflop.filter(e => ['BET','RAISE'].includes(e.action?.type)).map(e => e.handNumber));
|
|
399
|
+
const aggressive = rows.filter(e => ['BET','RAISE'].includes(e.action?.type)).length;
|
|
400
|
+
const calls = rows.filter(e => e.action?.type === 'CALL').length;
|
|
401
|
+
const checks = rows.filter(e => e.action?.type === 'CHECK').length;
|
|
402
|
+
const folds = rows.filter(e => e.action?.type === 'FOLD').length;
|
|
403
|
+
let wins = 0;
|
|
404
|
+
const observedHands = new Set(rows.map(e => Number(e.handNumber)));
|
|
405
|
+
for (const end of handEnds) {
|
|
406
|
+
if ((end.winners ?? []).some(w => (w.playerId ?? w.id ?? w) === player.id)) { wins++; observedHands.add(Number(end.handNumber)); }
|
|
407
|
+
}
|
|
408
|
+
const strategicActions = aggressive + calls + checks + folds;
|
|
409
|
+
return {
|
|
410
|
+
playerId: player.id,
|
|
411
|
+
playerName: player.name,
|
|
412
|
+
sampleHands: observedHands.size,
|
|
413
|
+
preflopSamples: preflopHands.size,
|
|
414
|
+
decisions: rows.length,
|
|
415
|
+
vpipPct: pct(vpipHands.size, preflopHands.size),
|
|
416
|
+
pfrPct: pct(pfrHands.size, preflopHands.size),
|
|
417
|
+
aggressionPct: pct(aggressive, aggressive + calls + checks),
|
|
418
|
+
foldPct: pct(folds, strategicActions),
|
|
419
|
+
callPct: pct(calls, strategicActions),
|
|
420
|
+
checkPct: pct(checks, strategicActions),
|
|
421
|
+
wins,
|
|
422
|
+
};
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
function spectatorState(engine, tournamentMeta = {}) {
|
|
426
|
+
if (!engine) return null;
|
|
427
|
+
const state = engine.state;
|
|
428
|
+
const bb = Math.max(1, asNumber(state.bigBlind, 1));
|
|
429
|
+
return {
|
|
430
|
+
handNumber: tournamentMeta.handNumber ?? state.handNumber ?? 0, street: state.street, board: state.board ?? [], buttonSeat: state.buttonSeat,
|
|
431
|
+
playersRemaining: tournamentMeta.playersRemaining ?? null, startingPlayers: tournamentMeta.startingPlayers ?? null,
|
|
432
|
+
actionTo: state.actionTo, smallBlind: state.smallBlind, bigBlind: state.bigBlind, ante: state.ante, blindLevel: state.blindLevel, pot: totalPot(state),
|
|
433
|
+
players: (state.players ?? []).map((p, seat) => p ? {
|
|
434
|
+
id: p.id, name: p.name, seat, stack: playerStack(p), stackBB: round(playerStack(p) / bb, 1), cards: playerCards(p), currentBet: currentBet(state, seat),
|
|
435
|
+
status: p.status, position: positionForSeat(state, seat),
|
|
436
|
+
} : null),
|
|
437
|
+
winners: state.winners ?? null,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function buildBlindStructure(config) {
|
|
442
|
+
const levels = [];
|
|
443
|
+
let sb = Math.max(1, Math.round(config.smallBlind));
|
|
444
|
+
let bb = Math.max(sb * 2, Math.round(config.bigBlind));
|
|
445
|
+
let ante = Math.max(0, Math.round(config.ante || 0));
|
|
446
|
+
const multiplier = clamp(Number(config.blindMultiplier) || 1.5, 1.1, 3);
|
|
447
|
+
const safe = value => Number.isSafeInteger(value) && value >= 0;
|
|
448
|
+
for (let i = 0; i < 60; i++) {
|
|
449
|
+
if (!safe(sb) || !safe(bb) || !safe(ante)) break;
|
|
450
|
+
levels.push({ smallBlind: sb, bigBlind: bb, ante });
|
|
451
|
+
const nextSb = Math.max(sb + 1, Math.round(sb * multiplier));
|
|
452
|
+
const nextBb = Math.max(nextSb * 2, Math.round(bb * multiplier));
|
|
453
|
+
const nextAnte = ante > 0 ? Math.max(1, Math.round(ante * multiplier)) : 0;
|
|
454
|
+
if (!safe(nextSb) || !safe(nextBb) || !safe(nextAnte)) break;
|
|
455
|
+
sb = nextSb; bb = nextBb; ante = nextAnte;
|
|
456
|
+
}
|
|
457
|
+
if (!levels.length) throw new Error('Unable to build a safe blind structure');
|
|
458
|
+
return levels;
|
|
459
|
+
}
|
|
460
|
+
function normalizeConfig(input = {}) {
|
|
461
|
+
const players = Array.isArray(input.players) ? input.players.slice(0, 10) : [];
|
|
462
|
+
const connections = Array.isArray(input.connections) ? input.connections : [];
|
|
463
|
+
if (players.length < 2) throw new Error('At least 2 players are required');
|
|
464
|
+
if (!connections.length) throw new Error('Add at least one API connection');
|
|
465
|
+
const names = new Set();
|
|
466
|
+
const connIds = new Set(connections.map(c => c.id));
|
|
467
|
+
const usedConnectionIds = new Set(players.map(p => p.connectionId));
|
|
468
|
+
const cleanConnections = connections.filter(c => usedConnectionIds.has(c.id)).map(c => {
|
|
469
|
+
if (!c.name) throw new Error('Every used connection must have a name');
|
|
470
|
+
if (!c.baseUrl) throw new Error(`Base URL is missing: ${c.name}`);
|
|
471
|
+
if ((isOpenRouterConnection(c) || c.kind === 'typesafe') && !c.apiKey) throw new Error(`API key is missing: ${c.name}`);
|
|
472
|
+
parseHeaders(c.headers);
|
|
473
|
+
return { ...c, baseUrl: normalizeBaseUrl(c.baseUrl) };
|
|
474
|
+
});
|
|
475
|
+
return {
|
|
476
|
+
id: id('tournament'), name: 'pokertools-arena', startingStack: Math.max(100, Math.round(Number(input.startingStack) || 10_000)),
|
|
477
|
+
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)),
|
|
478
|
+
handsPerLevel: Math.max(1, Math.round(Number(input.handsPerLevel) || 8)), blindMultiplier: clamp(Number(input.blindMultiplier) || 1.5, 1.1, 3),
|
|
479
|
+
actionSeconds: clamp(Number(input.actionSeconds) || TIMING_DEFAULTS.actionSeconds, 1, 120), timeBankSeconds: clamp(Number(input.timeBankSeconds) || TIMING_DEFAULTS.timeBankSeconds, 0, 600),
|
|
480
|
+
lowTimeSeconds: clamp(Number.isFinite(Number(input.lowTimeSeconds)) ? Number(input.lowTimeSeconds) : TIMING_DEFAULTS.lowTimeSeconds, 0, 600),
|
|
481
|
+
lowTimeFraction: clamp(Number.isFinite(Number(input.lowTimeFraction)) ? Number(input.lowTimeFraction) : TIMING_DEFAULTS.lowTimeFraction, 0, 1),
|
|
482
|
+
betweenActionsMs: clamp(Number(input.betweenActionsMs) || TIMING_DEFAULTS.betweenActionsMs, 0, 5000), betweenHandsMs: clamp(Number(input.betweenHandsMs) || TIMING_DEFAULTS.betweenHandsMs, 0, 10000),
|
|
483
|
+
connections: cleanConnections,
|
|
484
|
+
benchmarkMode: input.benchmarkMode === BENCHMARK_MODES.RAW ? BENCHMARK_MODES.RAW : DEFAULT_BENCHMARK_MODE,
|
|
485
|
+
decisionArchitecture: input.decisionArchitecture === DECISION_ARCHITECTURES.FLAT ? DECISION_ARCHITECTURES.FLAT : DEFAULT_DECISION_ARCHITECTURE,
|
|
486
|
+
representation: REPRESENTATION_MODES.includes(input.representation) ? input.representation : DEFAULT_REPRESENTATION_MODE,
|
|
487
|
+
spectatorExplanations: Boolean(input.spectatorExplanations),
|
|
488
|
+
maxDecisions: Math.max(0, Math.round(Number(input.maxDecisions) || 0)),
|
|
489
|
+
players: players.map((raw, index) => {
|
|
490
|
+
const lobbySeat = clamp(Math.round(Number(raw.lobbySeat ?? index)), 0, MAX_LOBBY_SEATS - 1);
|
|
491
|
+
const name = String(raw.name || `Player ${lobbySeat + 1}`).trim().slice(0, 40);
|
|
492
|
+
if (names.has(name)) throw new Error(`Duplicate player name: ${name}`);
|
|
493
|
+
names.add(name);
|
|
494
|
+
if (!connIds.has(raw.connectionId)) throw new Error(`Connection missing for ${name}`);
|
|
495
|
+
const model = String(raw.model || '').trim();
|
|
496
|
+
if (!model) throw new Error(`Model missing for ${name}`);
|
|
497
|
+
return { id: `player-${lobbySeat + 1}`, seat: index, lobbySeat, name, connectionId: raw.connectionId, model, protocol: raw.protocol || 'tool', provider: String(raw.provider || '').trim(), temperature: clamp(Number(raw.temperature) || 0.3, 0, 2) };
|
|
498
|
+
}),
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
class TournamentDirector {
|
|
503
|
+
constructor({ onUpdate = () => {} } = {}) {
|
|
504
|
+
this.onUpdate = onUpdate; this.status = 'IDLE'; this.config = null; this.engine = null; this.currentDecision = null; this.events = [];
|
|
505
|
+
this.stats = {}; this.timeBanks = {}; this.eliminations = []; this.handNumber = 0; this.startedAt = null; this.finishedAt = null; this.winner = null;
|
|
506
|
+
this.abortController = null; this.pauseResolvers = []; this.runPromise = null;
|
|
507
|
+
this.explanationChain = Promise.resolve();
|
|
508
|
+
this.decisionBudget = 0; this.decisionCount = 0; this.budgetReached = false;
|
|
509
|
+
this.persistTimer = null; this.publicStatsVersion = 0; this.publicStatsCache = []; this.publicStatsCacheVersion = -1; this.publicStatsCacheHand = -1;
|
|
510
|
+
}
|
|
511
|
+
publicConfig() {
|
|
512
|
+
if (!this.config) return null;
|
|
513
|
+
return {
|
|
514
|
+
...this.config,
|
|
515
|
+
connections: this.config.connections.map(({ apiKey, headers, ...c }) => ({ ...c })),
|
|
516
|
+
players: this.config.players.map(p => ({ ...p })),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
async start(rawConfig) {
|
|
520
|
+
if (!engineModule) await loadPokerTools();
|
|
521
|
+
if (['RUNNING', 'PAUSED'].includes(this.status)) throw new Error('Tournament already running');
|
|
522
|
+
this.config = normalizeConfig(rawConfig); this.status = 'RUNNING'; this.startedAt = Date.now(); this.finishedAt = null; this.winner = null;
|
|
523
|
+
this.events = []; this.eliminations = []; this.handNumber = 0; this.currentDecision = null; this.abortController = new AbortController(); this.stats = {}; this.timeBanks = {};
|
|
524
|
+
this.decisionBudget = this.config.maxDecisions; this.decisionCount = 0; this.budgetReached = false;
|
|
525
|
+
this.publicStatsVersion = 0; this.publicStatsCache = []; this.publicStatsCacheVersion = -1; this.publicStatsCacheHand = -1;
|
|
526
|
+
this.engine = engineModule.createBrowserEngine({
|
|
527
|
+
smallBlind: this.config.smallBlind, bigBlind: this.config.bigBlind, ante: this.config.ante, maxPlayers: this.config.players.length,
|
|
528
|
+
blindStructure: buildBlindStructure(this.config), timeBankSeconds: this.config.timeBankSeconds, rakePercent: 0, validateIntegrity: true,
|
|
529
|
+
});
|
|
530
|
+
for (const player of this.config.players) {
|
|
531
|
+
this.engine.sit(player.seat, player.id, player.name, this.config.startingStack);
|
|
532
|
+
this.timeBanks[player.id] = this.config.timeBankSeconds * 1000;
|
|
533
|
+
this.stats[player.id] = { decisions: 0, invalid: 0, modelErrors: 0, providerErrors: 0, rateLimits: 0, timeouts: 0, autoFallbacks: 0, protocolFallbacks: 0, retries: 0, totalLatencyMs: 0, lastAction: null, lastReason: '' };
|
|
534
|
+
}
|
|
535
|
+
this.logEvent('TOURNAMENT_START', { config: this.publicConfig() }); this.broadcast();
|
|
536
|
+
this.runPromise = this.run().catch(err => {
|
|
537
|
+
if (this.status !== 'STOPPED') { this.status = 'ERROR'; this.logEvent('TOURNAMENT_ERROR', { error: summarizeError(err) }); this.broadcast(); }
|
|
538
|
+
});
|
|
539
|
+
return this.snapshot();
|
|
540
|
+
}
|
|
541
|
+
pause() {
|
|
542
|
+
if (this.status === 'RUNNING') {
|
|
543
|
+
this.status = 'PAUSED';
|
|
544
|
+
if (this.currentDecision && !this.currentDecision.pausedAt) this.currentDecision.pausedAt = Date.now();
|
|
545
|
+
this.broadcast();
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
resume() {
|
|
549
|
+
if (this.status === 'PAUSED') {
|
|
550
|
+
if (this.currentDecision?.pausedAt) {
|
|
551
|
+
this.currentDecision.pausedMs = (this.currentDecision.pausedMs || 0) + Math.max(0, Date.now() - this.currentDecision.pausedAt);
|
|
552
|
+
this.currentDecision.pausedAt = null;
|
|
553
|
+
}
|
|
554
|
+
this.status = 'RUNNING';
|
|
555
|
+
for (const resolve of this.pauseResolvers.splice(0)) resolve();
|
|
556
|
+
this.broadcast();
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
stop() { if (['RUNNING', 'PAUSED'].includes(this.status)) { this.status = 'STOPPED'; this.abortController?.abort(); for (const resolve of this.pauseResolvers.splice(0)) resolve(); this.persistLightweight(); this.broadcast(); } }
|
|
560
|
+
async waitIfPaused() { while (this.status === 'PAUSED') await new Promise(resolve => this.pauseResolvers.push(resolve)); if (this.status === 'STOPPED') throw new Error('Tournament stopped'); }
|
|
561
|
+
playersRemaining() { return (this.engine?.state?.players ?? []).map((p, seat) => ({ p, seat })).filter(({ p }) => p && playerStack(p) > 0); }
|
|
562
|
+
tournamentMeta() {
|
|
563
|
+
const startingPlayers = this.config?.players.length ?? 0;
|
|
564
|
+
const eliminatedPlayerIds = this.eliminations.map(e => e.playerId);
|
|
565
|
+
return {
|
|
566
|
+
handNumber: this.handNumber,
|
|
567
|
+
playersRemaining: Math.max(0, startingPlayers - eliminatedPlayerIds.length),
|
|
568
|
+
startingPlayers, levelIndex: this.engine?.state?.blindLevel ?? 0, eliminatedPlayerIds,
|
|
569
|
+
benchmarkMode: this.config?.benchmarkMode ?? DEFAULT_BENCHMARK_MODE,
|
|
570
|
+
decisionArchitecture: this.config?.decisionArchitecture ?? DEFAULT_DECISION_ARCHITECTURE,
|
|
571
|
+
representation: this.config?.representation ?? DEFAULT_REPRESENTATION_MODE,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
snapshot() {
|
|
575
|
+
const statsHand = this.status === 'FINISHED' ? this.handNumber + 1 : this.handNumber;
|
|
576
|
+
if (this.config && (this.publicStatsCacheVersion !== this.publicStatsVersion || this.publicStatsCacheHand !== statsHand)) {
|
|
577
|
+
this.publicStatsCache = buildPublicPlayerStats(this.events, this.config.players, statsHand);
|
|
578
|
+
this.publicStatsCacheVersion = this.publicStatsVersion;
|
|
579
|
+
this.publicStatsCacheHand = statsHand;
|
|
580
|
+
}
|
|
581
|
+
const publicPlayerStats = this.config ? this.publicStatsCache : [];
|
|
582
|
+
return { tournamentId: this.config?.id ?? null, status: this.status, config: this.publicConfig(), startedAt: this.startedAt, finishedAt: this.finishedAt, winner: this.winner,
|
|
583
|
+
eliminations: [...this.eliminations], currentDecision: this.currentDecision, table: spectatorState(this.engine, this.tournamentMeta()), stats: this.stats, publicPlayerStats, timeBanks: this.timeBanks, events: this.events.slice(-300) };
|
|
584
|
+
}
|
|
585
|
+
broadcast() { this.onUpdate(this.snapshot()); }
|
|
586
|
+
logEvent(type, data = {}) {
|
|
587
|
+
const event = { id: id('event'), at: Date.now(), type, ...jsonSafe(data) };
|
|
588
|
+
this.events.push(event); if (this.events.length > 3000) this.events.splice(0, this.events.length - 3000);
|
|
589
|
+
this.schedulePersist(); return event;
|
|
590
|
+
}
|
|
591
|
+
schedulePersist() {
|
|
592
|
+
if (this.persistTimer) return;
|
|
593
|
+
this.persistTimer = setTimeout(() => { this.persistTimer = null; this.persistLightweight(); }, 900);
|
|
594
|
+
}
|
|
595
|
+
persistLightweight() {
|
|
596
|
+
try {
|
|
597
|
+
const state = this.snapshot();
|
|
598
|
+
const lightweight = { ...state, events: state.events.slice(-100), currentDecision: null };
|
|
599
|
+
storageSet('pokertoolsArenaLastState', JSON.stringify(lightweight));
|
|
600
|
+
} catch {}
|
|
601
|
+
}
|
|
602
|
+
async run() {
|
|
603
|
+
while (['RUNNING', 'PAUSED'].includes(this.status)) {
|
|
604
|
+
await this.waitIfPaused();
|
|
605
|
+
const remaining = this.playersRemaining();
|
|
606
|
+
if (remaining.length <= 1) { this.finish(remaining[0]?.p ?? null); return; }
|
|
607
|
+
if (this.handNumber > 0 && this.handNumber % this.config.handsPerLevel === 0) {
|
|
608
|
+
try { this.engine.nextBlindLevel(); this.logEvent('BLINDS_UP', { level: this.engine.state.blindLevel, smallBlind: this.engine.state.smallBlind, bigBlind: this.engine.state.bigBlind, ante: this.engine.state.ante }); }
|
|
609
|
+
catch (err) { this.logEvent('BLINDS_UP_FAILED', { error: summarizeError(err) }); }
|
|
610
|
+
}
|
|
611
|
+
await this.startHand(); await this.playHand(); this.completeHand(); this.broadcast();
|
|
612
|
+
if (this.budgetReached) { this.logEvent('DECISION_BUDGET_REACHED', { decisions: this.decisionCount, budget: this.decisionBudget }); this.stop(); return; }
|
|
613
|
+
await this.waitIfPaused(); await sleep(this.config.betweenHandsMs);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
pruneBustedSeats() {
|
|
617
|
+
for (const p of (this.engine?.state?.players ?? [])) {
|
|
618
|
+
if (!p || playerStack(p) > 0) continue;
|
|
619
|
+
try { this.engine.stand(p.id); } catch {}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
async startHand() {
|
|
623
|
+
await this.waitIfPaused(); this.pruneBustedSeats(); this.handNumber++;
|
|
624
|
+
this.handStartStacks = Object.fromEntries((this.engine?.state?.players ?? []).filter(Boolean).map(p => [p.id, playerStack(p)]));
|
|
625
|
+
try { this.engine.deal(); }
|
|
626
|
+
catch {
|
|
627
|
+
for (const p of (this.engine.state.players ?? [])) if (p && playerStack(p) <= 0) { try { this.engine.stand(p.id); } catch {} }
|
|
628
|
+
this.engine.deal();
|
|
629
|
+
}
|
|
630
|
+
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();
|
|
631
|
+
}
|
|
632
|
+
handComplete() {
|
|
633
|
+
const s = this.engine.state;
|
|
634
|
+
if (Array.isArray(s.winners) && s.winners.length > 0 && s.actionTo == null) return true;
|
|
635
|
+
const aliveInHand = (s.activePlayers ?? []).filter(seat => s.players?.[seat] && playerStack(s.players[seat]) >= 0);
|
|
636
|
+
return s.actionTo == null && (s.street === 'SHOWDOWN' || aliveInHand.length <= 1) && Array.isArray(s.winners);
|
|
637
|
+
}
|
|
638
|
+
async playHand() {
|
|
639
|
+
let guard = 0;
|
|
640
|
+
while (!this.handComplete()) {
|
|
641
|
+
if (++guard > 500) throw new Error('Hand action guard exceeded 500 actions');
|
|
642
|
+
await this.waitIfPaused();
|
|
643
|
+
const state = this.engine.state, seat = state.actionTo;
|
|
644
|
+
if (seat == null) { if (Array.isArray(state.winners) && state.winners.length) break; await sleep(10); continue; }
|
|
645
|
+
const player = state.players?.[seat]; if (!player) throw new Error(`No player at action seat ${seat}`);
|
|
646
|
+
if (state.street === 'SHOWDOWN') {
|
|
647
|
+
const show = { type: ACTION.SHOW, playerId: player.id, cardIndices: [0, 1] };
|
|
648
|
+
if (this.engine.validate(show)?.valid) { this.engine.act(show); this.logEvent('AUTO_SHOW', { playerId: player.id, playerName: player.name }); this.broadcast(); continue; }
|
|
649
|
+
const muck = { type: ACTION.MUCK, playerId: player.id };
|
|
650
|
+
if (this.engine.validate(muck)?.valid) { this.engine.act(muck); this.logEvent('AUTO_MUCK', { playerId: player.id, playerName: player.name }); this.broadcast(); continue; }
|
|
651
|
+
}
|
|
652
|
+
const legalActions = legalActionCandidates(this.engine, seat); if (!legalActions.length) throw new Error(`No legal actions for ${player.name}`);
|
|
653
|
+
const agent = this.config.players.find(p => p.id === player.id); if (!agent) throw new Error(`No agent config for ${player.id}`);
|
|
654
|
+
await this.takeDecision(agent, seat, legalActions); this.broadcast(); await this.waitIfPaused(); await sleep(this.config.betweenActionsMs);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
async takeDecision(agent, seat, legalActions) {
|
|
658
|
+
const decisionId = id(`d-h${this.handNumber}-s${seat + 1}`), baseMs = this.config.actionSeconds * 1000, bankBefore = this.timeBanks[agent.id] ?? 0, totalMs = baseMs + bankBefore;
|
|
659
|
+
const recentHands = buildPublicTournamentMemory(this.events, this.handNumber);
|
|
660
|
+
const publicPlayerStats = buildPublicPlayerStats(this.events, this.config.players, this.handNumber);
|
|
661
|
+
const actionHistory = buildCurrentHandPublicActions(this.events, this.handNumber);
|
|
662
|
+
const baseState = serializeForAgent(this.engine, seat, this.tournamentMeta(), legalActions, recentHands, publicPlayerStats, actionHistory);
|
|
663
|
+
const stateForAgent = assertDecisionState(applyBenchmarkMode(baseState, this.config.benchmarkMode)), startedAt = Date.now();
|
|
664
|
+
const connection = this.config.connections.find(c => c.id === agent.connectionId);
|
|
665
|
+
const architecture = this.config.decisionArchitecture === DECISION_ARCHITECTURES.FLAT ? 'flat' : 'hierarchical';
|
|
666
|
+
// Engine-validated, deterministic size set shared by every model. Built once
|
|
667
|
+
// per decision so both the request and the UI show the same values.
|
|
668
|
+
const sizesForFamily = family => legalAggressiveSizes(this.engine, seat, family);
|
|
669
|
+
let hierarchy = null;
|
|
670
|
+
if (architecture === 'hierarchical') {
|
|
671
|
+
hierarchy = buildHierarchicalDecision(stateForAgent, { legalActions: stateForAgent.legalActions });
|
|
672
|
+
if (hierarchy.aggressiveFamily) {
|
|
673
|
+
const engineSizes = sizesForFamily(hierarchy.aggressiveFamily);
|
|
674
|
+
hierarchy.stage2 = { ...hierarchy.stage2, sizes: engineSizes, criteria: sizeCriteria(engineSizes) };
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
this.currentDecision = {
|
|
678
|
+
id: decisionId, playerId: agent.id, playerName: agent.name, seat, model: agent.model, connection: connection?.name ?? agent.connectionId, provider: agent.provider || 'auto',
|
|
679
|
+
protocol: effectiveProtocol(agent, connection), handNumber: this.handNumber, street: this.engine.state.street, position: positionForSeat(this.engine.state, seat),
|
|
680
|
+
startedAt, baseMs, timeBankMs: bankBefore, lowTimeMs: Math.round(this.config.lowTimeSeconds * 1000), lowTimeFraction: this.config.lowTimeFraction, pausedMs: 0, pausedAt: null, architecture,
|
|
681
|
+
hierarchy: hierarchy ? { families: hierarchy.families, stage1: hierarchy.stage1, stage2: hierarchy.stage2, aggressiveFamily: hierarchy.aggressiveFamily } : null,
|
|
682
|
+
legalActions: legalActions.map(({ id: actionId, type, amount, description }) => ({ id: actionId, type, amount, description })),
|
|
683
|
+
};
|
|
684
|
+
this.logEvent('DECISION_START', this.currentDecision); this.broadcast();
|
|
685
|
+
const stats = this.stats[agent.id]; let result = null, error = null, errorCategory = null, elapsed = 0;
|
|
686
|
+
// Paused time is billed to the pause button, not to the model.
|
|
687
|
+
const pausedTotal = () => (this.currentDecision?.pausedMs || 0) + (this.currentDecision?.pausedAt ? Math.max(0, Date.now() - this.currentDecision.pausedAt) : 0);
|
|
688
|
+
const recordIncident = (incident) => {
|
|
689
|
+
if (incident?.category === 'rate_limit') stats.rateLimits++;
|
|
690
|
+
else if (incident?.category === 'provider') stats.providerErrors++;
|
|
691
|
+
};
|
|
692
|
+
try {
|
|
693
|
+
const before = performance.now();
|
|
694
|
+
if (architecture === 'hierarchical') {
|
|
695
|
+
result = await decideHierarchical({
|
|
696
|
+
agent, connection, state: stateForAgent, legalActions, decisionId, timeoutMs: totalMs,
|
|
697
|
+
abortSignal: this.abortController?.signal, pauseClock: this.currentDecision,
|
|
698
|
+
representationMode: this.config.representation, sizesForFamily,
|
|
699
|
+
onStage: info => { if (this.currentDecision?.id === decisionId) { this.currentDecision.stage = info; this.broadcast(); } },
|
|
700
|
+
});
|
|
701
|
+
} else {
|
|
702
|
+
result = await decide(agent, connection, { state: stateForAgent, legalActions, decisionId, timeoutMs: totalMs, abortSignal: this.abortController?.signal, pauseClock: this.currentDecision, representationMode: this.config.representation });
|
|
703
|
+
}
|
|
704
|
+
elapsed = Math.max(0, Math.round(performance.now() - before - pausedTotal()));
|
|
705
|
+
for (const incident of result?.meta?.incidents || []) recordIncident(incident);
|
|
706
|
+
stats.retries += Number(result?.meta?.retryCount || 0);
|
|
707
|
+
if (result?.meta?.protocolFallbackTriggered) stats.protocolFallbacks++;
|
|
708
|
+
if (this.currentDecision?.id !== decisionId) throw new Error('Decision became stale');
|
|
709
|
+
const elapsedActive = Math.max(0, Date.now() - startedAt - pausedTotal());
|
|
710
|
+
if (elapsedActive > baseMs) this.timeBanks[agent.id] = Math.max(0, bankBefore - (elapsedActive - baseMs));
|
|
711
|
+
} catch (err) {
|
|
712
|
+
elapsed = Math.max(0, Date.now() - startedAt - pausedTotal()); error = err;
|
|
713
|
+
for (const incident of err?.incidents || []) recordIncident(incident);
|
|
714
|
+
errorCategory = (elapsed >= totalMs - 30 || err?.name === 'AbortError') ? 'timeout' : decisionErrorCategory(err);
|
|
715
|
+
if (errorCategory === 'timeout') { stats.timeouts++; this.timeBanks[agent.id] = 0; }
|
|
716
|
+
else if (errorCategory === 'rate_limit') stats.rateLimits++;
|
|
717
|
+
else if (errorCategory === 'provider') stats.providerErrors++;
|
|
718
|
+
else { stats.modelErrors++; stats.invalid++; }
|
|
719
|
+
}
|
|
720
|
+
if (this.status === 'STOPPED') throw new Error('Tournament stopped');
|
|
721
|
+
await this.waitIfPaused();
|
|
722
|
+
let chosen = result?.action ?? null, forced = false;
|
|
723
|
+
if (chosen && architecture === 'hierarchical') {
|
|
724
|
+
// Final validation after both stages: reconstruct the exact engine action
|
|
725
|
+
// and let PokerTools validate it before it is applied.
|
|
726
|
+
const engineAction = isAggressiveType(chosen.type)
|
|
727
|
+
? { type: chosen.type, playerId: agent.id, amount: Math.max(1, Math.round(asNumber(chosen.amount))) }
|
|
728
|
+
: { type: chosen.type, playerId: agent.id };
|
|
729
|
+
if ((isAggressiveType(chosen.type) && !Number.isFinite(Number(chosen.amount))) || !this.engine.validate(engineAction)?.valid) {
|
|
730
|
+
error = new Error('Returned hierarchical action was no longer legal'); errorCategory = 'model'; stats.modelErrors++; stats.invalid++; chosen = null;
|
|
731
|
+
} else {
|
|
732
|
+
const known = legalActionCandidates(this.engine, seat).find(a => a.type === chosen.type && (chosen.amount == null || Number(a.amount) === Number(chosen.amount)));
|
|
733
|
+
chosen = { ...chosen, id: chosen.id ?? known?.id ?? null, description: chosen.description || known?.description || describeAction(chosen.type, chosen.amount, this.engine.state, seat), engineAction };
|
|
734
|
+
}
|
|
735
|
+
} else if (chosen) {
|
|
736
|
+
const fresh = legalActionCandidates(this.engine, seat).find(a => a.id === chosen.id && a.type === chosen.type && a.amount === chosen.amount);
|
|
737
|
+
if (!fresh || !this.engine.validate(fresh.engineAction)?.valid) {
|
|
738
|
+
error = new Error('Returned action was no longer legal'); errorCategory = 'model'; stats.modelErrors++; stats.invalid++; chosen = null;
|
|
739
|
+
} else chosen = fresh;
|
|
740
|
+
}
|
|
741
|
+
if (!chosen) { chosen = fallbackAction(legalActionCandidates(this.engine, seat)); forced = true; stats.autoFallbacks++; }
|
|
742
|
+
if (!chosen) throw new Error(`No fallback action for ${agent.name}`);
|
|
743
|
+
this.engine.act(chosen.engineAction);
|
|
744
|
+
const fallbackReason = errorCategory ? errorCategory.replace('_', ' ') : 'invalid response';
|
|
745
|
+
const reportedLatency = result?.primaryDecisionLatencyMs != null ? Math.max(0, result.primaryDecisionLatencyMs - pausedTotal()) : (result?.latencyMs != null ? Math.max(0, result.latencyMs - pausedTotal()) : elapsed);
|
|
746
|
+
const typedReason = architecture === 'hierarchical' && result?.family
|
|
747
|
+
? `Typed decision · ${String(result.family.choice).toUpperCase()}${result.sizing ? ` ${SIZE_LABELS[result.sizing.choice] ?? result.sizing.choice}` : ''}`
|
|
748
|
+
: '';
|
|
749
|
+
stats.decisions++; stats.totalLatencyMs += reportedLatency; stats.lastAction = chosen.description;
|
|
750
|
+
this.decisionCount++;
|
|
751
|
+
if (this.decisionBudget > 0 && this.decisionCount >= this.decisionBudget) this.budgetReached = true;
|
|
752
|
+
stats.lastReason = result?.publicReason || typedReason || (forced ? `Automatic ${chosen.description} after ${fallbackReason}.` : '');
|
|
753
|
+
const decisionMeta = result?.meta ? {
|
|
754
|
+
...result.meta,
|
|
755
|
+
decisionArchitecture: architecture === 'hierarchical' ? DECISION_ARCHITECTURE_VERSION : 'flat-v1',
|
|
756
|
+
family: result.family ? { choice: result.family.choice, probabilities: result.family.probabilities ?? null, confidence: result.family.confidence ?? null, latencyMs: result.family.latencyMs ?? null } : null,
|
|
757
|
+
sizing: result.sizing ? { choice: result.sizing.choice, probabilities: result.sizing.probabilities ?? null, confidence: result.sizing.confidence ?? null, latencyMs: result.sizing.latencyMs ?? null, amount: result.sizing.amount ?? null } : null,
|
|
758
|
+
finalAction: { type: chosen.type, amount: chosen.amount ?? null },
|
|
759
|
+
} : null;
|
|
760
|
+
this.logEvent('DECISION', {
|
|
761
|
+
decisionId, handNumber: this.handNumber, street: stateForAgent.street, position: stateForAgent.hero.position, potBefore: stateForAgent.pot,
|
|
762
|
+
playerId: agent.id, playerName: agent.name, connection: connection?.name,
|
|
763
|
+
protocol: result?.meta?.method ?? effectiveProtocol(agent, connection), requestedProtocol: effectiveProtocol(agent, connection), protocolFallback: result?.meta?.protocolFallback ?? null,
|
|
764
|
+
configuredModel: agent.model, resolvedModel: result?.model ?? agent.model, provider: agent.provider || 'auto',
|
|
765
|
+
action: { id: chosen.id, type: chosen.type, amount: chosen.amount, description: chosen.description }, forced,
|
|
766
|
+
legalActions: legalActions.map(a => ({ id: a.id, type: a.type, amount: a.amount ?? null, description: a.description })),
|
|
767
|
+
latencyMs: reportedLatency, primaryDecisionLatencyMs: result?.primaryDecisionLatencyMs ?? reportedLatency,
|
|
768
|
+
timeBankUsedMs: Math.max(0, Math.min(bankBefore, elapsed - baseMs)), timeBankRemainingMs: this.timeBanks[agent.id],
|
|
769
|
+
publicReason: stats.lastReason, usage: result?.usage ?? null, decisionMeta,
|
|
770
|
+
benchmarkMode: this.config.benchmarkMode, decisionArchitecture: architecture, representation: this.config.representation,
|
|
771
|
+
replay: {
|
|
772
|
+
handNumber: stateForAgent.tournament?.handNumber ?? this.handNumber,
|
|
773
|
+
street: stateForAgent.street,
|
|
774
|
+
board: stateForAgent.board ?? [],
|
|
775
|
+
pot: stateForAgent.pot,
|
|
776
|
+
blinds: stateForAgent.blinds,
|
|
777
|
+
buttonSeat: stateForAgent.buttonSeat,
|
|
778
|
+
betting: stateForAgent.betting,
|
|
779
|
+
hero: stateForAgent.hero,
|
|
780
|
+
heroHand: stateForAgent.heroHand ?? null,
|
|
781
|
+
opponents: stateForAgent.opponents,
|
|
782
|
+
actionHistory: stateForAgent.actionHistory,
|
|
783
|
+
legalActions: stateForAgent.legalActions,
|
|
784
|
+
},
|
|
785
|
+
errorCategory, error: error ? summarizeError(error) : null,
|
|
786
|
+
});
|
|
787
|
+
if (this.config.spectatorExplanations) this.enqueueSpectatorExplanation({ decisionId, agent, connection, stateForAgent, chosen });
|
|
788
|
+
// Promote a proven OpenRouter tool incompatibility to the seat configuration.
|
|
789
|
+
// Future hands/tournaments in this browser can go straight to JSON Schema
|
|
790
|
+
// instead of repeating the same expected capability-probe 404.
|
|
791
|
+
if (result?.meta?.protocolFallbackTriggered && result?.meta?.method === 'json_schema') {
|
|
792
|
+
agent.protocol = 'json_schema';
|
|
793
|
+
const lobbySeat = Number(agent.lobbySeat);
|
|
794
|
+
if (Number.isInteger(lobbySeat) && seatAssignments[lobbySeat]?.model === agent.model && seatAssignments[lobbySeat]?.connectionId === agent.connectionId) {
|
|
795
|
+
seatAssignments[lobbySeat].protocol = 'json_schema';
|
|
796
|
+
saveSeatAssignments();
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
this.currentDecision = null;
|
|
800
|
+
}
|
|
801
|
+
// Spectator explanation is explicitly NOT part of the decision contract. It
|
|
802
|
+
// runs after the move has been applied, in an isolated serial chain, is never
|
|
803
|
+
// fed back into any future model context, and never counts toward primary
|
|
804
|
+
// decision latency. Jev returns typed telemetry instead; prose is never
|
|
805
|
+
// fabricated for it.
|
|
806
|
+
enqueueSpectatorExplanation({ decisionId, agent, connection, stateForAgent, chosen }) {
|
|
807
|
+
if (!connection || !['openai', 'openrouter'].includes(connection.kind) || isJevModel(agent.model)) return this.explanationChain;
|
|
808
|
+
this.explanationChain = this.explanationChain.then(async () => {
|
|
809
|
+
if (this.status === 'STOPPED' || this.status === 'ERROR') return;
|
|
810
|
+
// A short isolated budget so a slow explanation can never contend with the
|
|
811
|
+
// next decision; failures are silently dropped.
|
|
812
|
+
const controller = new AbortController();
|
|
813
|
+
const timer = setTimeout(() => controller.abort(), 8000);
|
|
814
|
+
try {
|
|
815
|
+
const body = {
|
|
816
|
+
model: agent.model,
|
|
817
|
+
messages: [
|
|
818
|
+
{ role: 'system', content: 'You are a spectator commentator. Explain a poker move in one short descriptive sentence. This explanation never affects the game.' },
|
|
819
|
+
{ role: 'user', content: `Hero action: ${chosen.description}. State: ${JSON.stringify({ street: stateForAgent.street, board: stateForAgent.board, pot: stateForAgent.pot, hero: stateForAgent.hero, betting: stateForAgent.betting })}. One short spectator-facing sentence.` },
|
|
820
|
+
],
|
|
821
|
+
temperature: 0.5, max_tokens: 80,
|
|
822
|
+
};
|
|
823
|
+
const response = await fetch(completionsUrl(connection.baseUrl), { method: 'POST', headers: makeHeaders(connection), body: JSON.stringify(body), signal: controller.signal });
|
|
824
|
+
if (!response.ok) return;
|
|
825
|
+
const payload = await response.json().catch(() => ({}));
|
|
826
|
+
const text = stripCodeFence(extractTextContent(payload?.choices?.[0]?.message)).trim().slice(0, 220);
|
|
827
|
+
if (!text) return;
|
|
828
|
+
this.logEvent('SPECTATOR_EXPLANATION', { decisionId, playerId: agent.id, configuredModel: agent.model, text });
|
|
829
|
+
this.broadcast();
|
|
830
|
+
} catch {}
|
|
831
|
+
finally { clearTimeout(timer); }
|
|
832
|
+
});
|
|
833
|
+
return this.explanationChain;
|
|
834
|
+
}
|
|
835
|
+
completeHand() {
|
|
836
|
+
const state = this.engine.state;
|
|
837
|
+
this.logEvent('HAND_END', { handNumber: this.handNumber, board: state.board, winners: publicWinnerSummary(state.winners, state.players), stacks: (state.players ?? []).map((p, seat) => p ? ({ seat: seat + 1, id: p.id, name: p.name, stack: playerStack(p) }) : null).filter(Boolean) });
|
|
838
|
+
this.publicStatsVersion++;
|
|
839
|
+
const newlyEliminated = this.config.players.map(player => {
|
|
840
|
+
const seat = (state.players ?? []).findIndex(x => x?.id === player.id);
|
|
841
|
+
const p = seat >= 0 ? state.players[seat] : null;
|
|
842
|
+
const already = this.eliminations.some(e => e.playerId === player.id);
|
|
843
|
+
return p && playerStack(p) <= 0 && !already ? { player, seat, startStack: asNumber(this.handStartStacks?.[player.id]) } : null;
|
|
844
|
+
}).filter(Boolean).sort((a, b) => b.startStack - a.startStack || a.seat - b.seat);
|
|
845
|
+
const survivors = this.playersRemaining().length;
|
|
846
|
+
newlyEliminated.forEach((row, index) => {
|
|
847
|
+
const elimination = { playerId: row.player.id, playerName: row.player.name, place: Math.max(2, survivors + 1 + index), handNumber: this.handNumber, at: Date.now() };
|
|
848
|
+
this.eliminations.push(elimination); this.logEvent('ELIMINATION', elimination);
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
finish(player) {
|
|
852
|
+
const configured = player ? this.config.players.find(p => p.id === player.id) : null;
|
|
853
|
+
this.winner = player ? { playerId: player.id, playerName: player.name, model: configured?.model ?? null, protocol: configured?.protocol ?? null, stack: playerStack(player) } : null;
|
|
854
|
+
this.finishedAt = Date.now(); this.status = 'FINISHED'; this.currentDecision = null; this.logEvent('TOURNAMENT_END', { winner: this.winner, hands: this.handNumber }); this.persistLightweight(); this.broadcast();
|
|
855
|
+
}
|
|
856
|
+
exportJsonl() { return this.events.map(e => JSON.stringify(e)).join('\n') + '\n'; }
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function connectionOptionsHtml(selected = '') {
|
|
860
|
+
const rows = $$('.connection-row', els.connectionsEditor);
|
|
861
|
+
return rows.map(row => {
|
|
862
|
+
const id = row.dataset.id, name = $('[data-field=name]', row).value.trim() || id;
|
|
863
|
+
return `<option value="${escapeHtml(id)}" ${id === selected ? 'selected' : ''}>${escapeHtml(name)}</option>`;
|
|
864
|
+
}).join('');
|
|
865
|
+
}
|
|
866
|
+
function refreshSeatConnectionSelect(selected = null) {
|
|
867
|
+
if (!els.seatConnection) return;
|
|
868
|
+
const old = selected ?? els.seatConnection.value;
|
|
869
|
+
els.seatConnection.innerHTML = connectionOptionsHtml(old);
|
|
870
|
+
if (!els.seatConnection.value && els.seatConnection.options.length) els.seatConnection.selectedIndex = 0;
|
|
871
|
+
applySeatProtocolRules();
|
|
872
|
+
}
|
|
873
|
+
function connectionPresetUrl(kind) {
|
|
874
|
+
if (kind === 'openrouter') return 'https://openrouter.ai/api/v1';
|
|
875
|
+
if (kind === 'typesafe') return 'https://api.typesafe.ai/v1/systemone';
|
|
876
|
+
return 'https://api.openai.com/v1';
|
|
877
|
+
}
|
|
878
|
+
function shouldReplacePresetUrl(value) {
|
|
879
|
+
const normalized = normalizeBaseUrl(value);
|
|
880
|
+
return !normalized || [
|
|
881
|
+
'https://api.openai.com/v1',
|
|
882
|
+
'https://openrouter.ai/api/v1',
|
|
883
|
+
'https://api.typesafe.ai/v1/systemone',
|
|
884
|
+
].includes(normalized);
|
|
885
|
+
}
|
|
886
|
+
function addConnectionRow(connection = {}) {
|
|
887
|
+
const row = els.connectionRowTemplate.content.firstElementChild.cloneNode(true);
|
|
888
|
+
row.dataset.id = connection.id || `conn-${++rowSeq}`;
|
|
889
|
+
$('[data-field=name]', row).value = connection.name || `API ${rowSeq}`;
|
|
890
|
+
$('[data-field=kind]', row).value = connection.kind || 'openai';
|
|
891
|
+
$('[data-field=baseUrl]', row).value = connection.baseUrl || connectionPresetUrl(connection.kind || 'openai');
|
|
892
|
+
$('[data-field=apiKey]', row).value = connection.apiKey || '';
|
|
893
|
+
$('[data-field=headers]', row).value = connection.headers || '';
|
|
894
|
+
$('[data-field=name]', row).addEventListener('input', () => refreshSeatConnectionSelect());
|
|
895
|
+
$('[data-field=kind]', row).addEventListener('change', event => {
|
|
896
|
+
const baseInput = $('[data-field=baseUrl]', row);
|
|
897
|
+
if (shouldReplacePresetUrl(baseInput.value)) baseInput.value = connectionPresetUrl(event.target.value);
|
|
898
|
+
const nameInput = $('[data-field=name]', row);
|
|
899
|
+
if (!nameInput.value.trim() || nameInput.value === 'API' || /^API \d+$/.test(nameInput.value) || nameInput.value === 'OpenRouter' || nameInput.value === 'TypeSafe') {
|
|
900
|
+
nameInput.value = event.target.value === 'openrouter' ? 'OpenRouter' : event.target.value === 'typesafe' ? 'TypeSafe' : `API ${rowSeq}`;
|
|
901
|
+
}
|
|
902
|
+
refreshSeatConnectionSelect();
|
|
903
|
+
});
|
|
904
|
+
$('[data-field=kind]', row).addEventListener('change', () => {
|
|
905
|
+
const kind = $('[data-field=kind]', row).value;
|
|
906
|
+
const url = $('[data-field=baseUrl]', row);
|
|
907
|
+
if (kind === 'typesafe' && (!url.value || url.value.includes('openrouter.ai'))) url.value = 'https://api.typesafe.ai/v1/systemone';
|
|
908
|
+
if (kind === 'openrouter' && (!url.value || url.value.includes('typesafe.ai'))) url.value = 'https://openrouter.ai/api/v1';
|
|
909
|
+
if (kind === 'openai' && (!url.value || url.value.includes('typesafe.ai'))) url.value = 'https://api.openai.com/v1';
|
|
910
|
+
refreshSeatConnectionSelect();
|
|
911
|
+
});
|
|
912
|
+
$('.remove-connection', row).addEventListener('click', () => {
|
|
913
|
+
if (els.connectionsEditor.children.length <= 1) return;
|
|
914
|
+
const removedId = row.dataset.id;
|
|
915
|
+
row.remove();
|
|
916
|
+
for (let i = 0; i < seatAssignments.length; i++) {
|
|
917
|
+
if (seatAssignments[i]?.connectionId === removedId) seatAssignments[i] = { ...seatAssignments[i], connectionId: '' };
|
|
918
|
+
}
|
|
919
|
+
refreshSeatConnectionSelect();
|
|
920
|
+
renderLobbyIfVisible();
|
|
921
|
+
});
|
|
922
|
+
$('.test-connection', row).addEventListener('click', () => testConnectionRow(row));
|
|
923
|
+
els.connectionsEditor.append(row);
|
|
924
|
+
refreshSeatConnectionSelect();
|
|
925
|
+
}
|
|
926
|
+
function readConnections() {
|
|
927
|
+
return $$('.connection-row', els.connectionsEditor).map(row => ({
|
|
928
|
+
id: row.dataset.id, name: $('[data-field=name]', row).value.trim(), kind: $('[data-field=kind]', row).value,
|
|
929
|
+
baseUrl: $('[data-field=baseUrl]', row).value.trim(), apiKey: $('[data-field=apiKey]', row).value.trim(), headers: $('[data-field=headers]', row).value.trim(),
|
|
930
|
+
}));
|
|
931
|
+
}
|
|
932
|
+
async function testConnectionRow(row) {
|
|
933
|
+
const status = $('.connection-status', row), button = $('.test-connection', row);
|
|
934
|
+
button.disabled = true; status.textContent = 'testing…';
|
|
935
|
+
try {
|
|
936
|
+
const connection = readConnections().find(c => c.id === row.dataset.id);
|
|
937
|
+
if (!connection?.baseUrl) throw new Error('Base URL is required');
|
|
938
|
+
if ((isOpenRouterConnection(connection) || connection.kind === 'typesafe') && !connection.apiKey) throw new Error('API key is required for this preset');
|
|
939
|
+
if (connection.kind === 'openai' || connection.kind === 'openrouter') {
|
|
940
|
+
const response = await fetch(modelsUrl(connection.baseUrl), { method: 'GET', headers: makeHeaders(connection) });
|
|
941
|
+
const payload = await response.json().catch(() => ({}));
|
|
942
|
+
if (!response.ok) throw new Error(payload?.error?.message ?? payload?.message ?? `HTTP ${response.status}`);
|
|
943
|
+
const count = Array.isArray(payload?.data) ? payload.data.length : '?';
|
|
944
|
+
status.textContent = `Connected · ${count} models`;
|
|
945
|
+
status.className = 'connection-status tiny good-text';
|
|
946
|
+
} else {
|
|
947
|
+
const response = await fetch(normalizeBaseUrl(connection.baseUrl), { method: 'POST', headers: makeHeaders(connection), body: '{}' });
|
|
948
|
+
if (response.status === 401 || response.status === 403) throw new Error(`HTTP ${response.status}: key rejected`);
|
|
949
|
+
status.textContent = `Connected · HTTP ${response.status}`;
|
|
950
|
+
status.className = 'connection-status tiny good-text';
|
|
951
|
+
}
|
|
952
|
+
} catch (err) {
|
|
953
|
+
status.textContent = `✕ ${summarizeError(err)}${String(err).includes('Failed to fetch') ? ' (often CORS)' : ''}`;
|
|
954
|
+
status.className = 'connection-status tiny bad-text';
|
|
955
|
+
} finally { button.disabled = false; }
|
|
956
|
+
}
|
|
957
|
+
function connectionById(id) {
|
|
958
|
+
const row = $$('.connection-row', els.connectionsEditor).find(r => r.dataset.id === id);
|
|
959
|
+
return row ? { id, name: $('[data-field=name]', row).value.trim(), kind: $('[data-field=kind]', row).value, baseUrl: $('[data-field=baseUrl]', row).value.trim() } : null;
|
|
960
|
+
}
|
|
961
|
+
function fullConnectionById(id) {
|
|
962
|
+
return readConnections().find(connection => connection.id === id) || null;
|
|
963
|
+
}
|
|
964
|
+
function modelCacheKey(connection) {
|
|
965
|
+
return `${connection?.kind || ''}|${normalizeBaseUrl(connection?.baseUrl || '')}`;
|
|
966
|
+
}
|
|
967
|
+
function setModelStatus(text, tone = 'muted') {
|
|
968
|
+
if (!els.seatModelStatus) return;
|
|
969
|
+
els.seatModelStatus.textContent = text;
|
|
970
|
+
els.seatModelStatus.dataset.tone = tone;
|
|
971
|
+
}
|
|
972
|
+
function mergeOpenRouterModelCatalog(models = []) {
|
|
973
|
+
const byId = new Map();
|
|
974
|
+
for (const model of [...OPENROUTER_DECISION_MODELS, ...models]) {
|
|
975
|
+
const id = typeof model === 'string' ? model : model?.id;
|
|
976
|
+
if (!id) continue;
|
|
977
|
+
byId.set(id, { id, name: typeof model === 'string' ? model : (model?.name || id) });
|
|
978
|
+
}
|
|
979
|
+
return [...byId.values()];
|
|
980
|
+
}
|
|
981
|
+
function renderModelOptions(models, currentValue = '') {
|
|
982
|
+
if (!els.seatModelOptions) return;
|
|
983
|
+
const normalized = (models || []).map(model => typeof model === 'string' ? ({ id: model, name: model }) : model).filter(model => model?.id);
|
|
984
|
+
const byId = new Map(normalized.map(model => [model.id, model]));
|
|
985
|
+
if (currentValue && !byId.has(currentValue)) byId.set(currentValue, { id: currentValue, name: currentValue });
|
|
986
|
+
const rows = [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
987
|
+
els.seatModelOptions.innerHTML = rows.map(model => `<option value="${escapeHtml(model.id)}" label="${escapeHtml(model.name || model.id)}"></option>`).join('');
|
|
988
|
+
}
|
|
989
|
+
async function refreshSeatModelCatalog({ force = false } = {}) {
|
|
990
|
+
const connection = fullConnectionById(els.seatConnection?.value);
|
|
991
|
+
const currentValue = els.seatModel?.value?.trim() || '';
|
|
992
|
+
if (!connection) {
|
|
993
|
+
renderModelOptions([], currentValue);
|
|
994
|
+
setModelStatus('Choose a connection first.');
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
if (!['openai', 'openrouter'].includes(connection.kind)) {
|
|
998
|
+
renderModelOptions([], currentValue);
|
|
999
|
+
setModelStatus('Enter the model ID accepted by this endpoint.');
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
const key = modelCacheKey(connection);
|
|
1003
|
+
if (!force && modelCatalogCache.has(key)) {
|
|
1004
|
+
const models = modelCatalogCache.get(key);
|
|
1005
|
+
renderModelOptions(models, currentValue);
|
|
1006
|
+
setModelStatus(`${models.length} models available.`, 'good');
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
setModelStatus('Loading models…');
|
|
1010
|
+
if (els.refreshModelsBtn) els.refreshModelsBtn.disabled = true;
|
|
1011
|
+
try {
|
|
1012
|
+
const response = await fetch(modelsUrl(connection.baseUrl), { method: 'GET', headers: makeHeaders(connection) });
|
|
1013
|
+
const payload = await response.json().catch(() => ({}));
|
|
1014
|
+
if (!response.ok) throw new Error(payload?.error?.message ?? payload?.message ?? `HTTP ${response.status}`);
|
|
1015
|
+
const catalogModels = Array.isArray(payload?.data) ? payload.data.map(model => ({ id: model?.id, name: model?.name || model?.id })).filter(model => model.id) : [];
|
|
1016
|
+
const models = isOpenRouterConnection(connection) ? mergeOpenRouterModelCatalog(catalogModels) : catalogModels;
|
|
1017
|
+
modelCatalogCache.set(key, models);
|
|
1018
|
+
renderModelOptions(models, currentValue);
|
|
1019
|
+
setModelStatus(`${models.length} models available.`, 'good');
|
|
1020
|
+
} catch (err) {
|
|
1021
|
+
const models = isOpenRouterConnection(connection) ? mergeOpenRouterModelCatalog([]) : [];
|
|
1022
|
+
modelCatalogCache.set(key, models);
|
|
1023
|
+
renderModelOptions(models, currentValue);
|
|
1024
|
+
setModelStatus(models.length ? `Catalog unavailable; Jev shortcuts remain available.` : `Model list unavailable. Enter an ID manually.`, 'bad');
|
|
1025
|
+
} finally {
|
|
1026
|
+
if (els.refreshModelsBtn) els.refreshModelsBtn.disabled = false;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
function defaultSeatDraft(seatIndex) {
|
|
1030
|
+
const firstRow = $('.connection-row', els.connectionsEditor);
|
|
1031
|
+
const connectionId = firstRow?.dataset.id || '';
|
|
1032
|
+
const conn = connectionById(connectionId);
|
|
1033
|
+
return {
|
|
1034
|
+
lobbySeat: seatIndex,
|
|
1035
|
+
name: `Player ${seatIndex + 1}`,
|
|
1036
|
+
connectionId,
|
|
1037
|
+
model: conn?.kind === 'typesafe' ? 'jev-latest' : '',
|
|
1038
|
+
protocol: conn?.kind === 'typesafe' ? 'jev_native' : 'tool',
|
|
1039
|
+
provider: '',
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
function applySeatProtocolRules() {
|
|
1043
|
+
const conn = connectionById(els.seatConnection.value);
|
|
1044
|
+
const kind = conn?.kind || 'openrouter';
|
|
1045
|
+
const openRouter = isOpenRouterConnection(conn);
|
|
1046
|
+
const jevViaOpenRouter = openRouter && isJevModel(els.seatModel.value);
|
|
1047
|
+
document.querySelector('.seat-provider-field')?.classList.toggle('hidden', !openRouter || jevViaOpenRouter);
|
|
1048
|
+
[...els.seatProtocol.options].forEach(option => {
|
|
1049
|
+
option.disabled = (option.value === 'jev_native' && kind !== 'typesafe') || (option.value === 'jev_decisions' && !openRouter);
|
|
1050
|
+
});
|
|
1051
|
+
if (kind === 'typesafe') {
|
|
1052
|
+
els.seatProtocol.value = 'jev_native';
|
|
1053
|
+
els.seatProvider.disabled = true;
|
|
1054
|
+
if (!els.seatModel.value || els.seatModel.value.includes('/')) els.seatModel.value = 'jev-latest';
|
|
1055
|
+
} else if (jevViaOpenRouter) {
|
|
1056
|
+
els.seatProtocol.value = 'jev_decisions';
|
|
1057
|
+
els.seatProvider.disabled = true;
|
|
1058
|
+
} else {
|
|
1059
|
+
if (['jev_native', 'jev_decisions'].includes(els.seatProtocol.value)) els.seatProtocol.value = 'tool';
|
|
1060
|
+
els.seatProvider.disabled = !openRouter;
|
|
1061
|
+
if (els.seatModel.value === 'jev-latest') els.seatModel.value = '';
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
function updateSeatSummary() {
|
|
1065
|
+
const count = seatAssignments.filter(Boolean).length;
|
|
1066
|
+
if (els.seatSummary) els.seatSummary.textContent = `${count} / ${MAX_LOBBY_SEATS} seated`;
|
|
1067
|
+
}
|
|
1068
|
+
function openSeatEditor(seatIndex) {
|
|
1069
|
+
if (!Number.isInteger(seatIndex) || seatIndex < 0 || seatIndex >= MAX_LOBBY_SEATS) return;
|
|
1070
|
+
editingSeatIndex = seatIndex;
|
|
1071
|
+
const locked = Boolean(director && ['RUNNING', 'PAUSED'].includes(director.status));
|
|
1072
|
+
const draft = seatAssignments[seatIndex] ? { ...seatAssignments[seatIndex] } : defaultSeatDraft(seatIndex);
|
|
1073
|
+
els.seatDialogTitle.textContent = `Seat ${seatIndex + 1}`;
|
|
1074
|
+
els.seatName.value = draft.name || `Player ${seatIndex + 1}`;
|
|
1075
|
+
refreshSeatConnectionSelect(draft.connectionId || '');
|
|
1076
|
+
if (draft.connectionId && [...els.seatConnection.options].some(option => option.value === draft.connectionId)) els.seatConnection.value = draft.connectionId;
|
|
1077
|
+
els.seatModel.value = draft.model || '';
|
|
1078
|
+
els.seatProtocol.value = draft.protocol || 'tool';
|
|
1079
|
+
els.seatProvider.value = draft.provider || '';
|
|
1080
|
+
applySeatProtocolRules();
|
|
1081
|
+
void refreshSeatModelCatalog();
|
|
1082
|
+
els.seatError.classList.add('hidden');
|
|
1083
|
+
els.seatLockNotice.classList.toggle('hidden', !locked);
|
|
1084
|
+
for (const control of [els.seatName, els.seatConnection, els.seatModel, els.seatProtocol, els.seatProvider]) control.disabled = locked || (control === els.seatProvider && (!isOpenRouterConnection(connectionById(els.seatConnection.value)) || isJevModel(els.seatModel.value)));
|
|
1085
|
+
els.saveSeatBtn.disabled = locked;
|
|
1086
|
+
els.removeSeatBtn.disabled = locked || !seatAssignments[seatIndex];
|
|
1087
|
+
if (!els.seatDialog.open) els.seatDialog.showModal();
|
|
1088
|
+
if (!locked) requestAnimationFrame(() => { els.seatName.focus(); els.seatName.select(); });
|
|
1089
|
+
}
|
|
1090
|
+
function readSeatDraft() {
|
|
1091
|
+
return {
|
|
1092
|
+
lobbySeat: editingSeatIndex,
|
|
1093
|
+
name: els.seatName.value.trim(),
|
|
1094
|
+
connectionId: els.seatConnection.value,
|
|
1095
|
+
model: els.seatModel.value.trim(),
|
|
1096
|
+
protocol: els.seatProtocol.value,
|
|
1097
|
+
provider: els.seatProvider.disabled ? '' : els.seatProvider.value.trim(),
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
function readSeatPlayers() {
|
|
1101
|
+
return seatAssignments.map((p, lobbySeat) => p ? ({ ...p, lobbySeat }) : null).filter(Boolean).sort((a, b) => a.lobbySeat - b.lobbySeat);
|
|
1102
|
+
}
|
|
1103
|
+
function saveSeatAssignments() {
|
|
1104
|
+
const raw = collectSetupRaw(false);
|
|
1105
|
+
saveSetupWithoutSecrets(raw);
|
|
1106
|
+
updateSeatSummary();
|
|
1107
|
+
renderLobbyIfVisible();
|
|
1108
|
+
}
|
|
1109
|
+
function restoreSeatAssignments(rows = []) {
|
|
1110
|
+
seatAssignments = Array(MAX_LOBBY_SEATS).fill(null);
|
|
1111
|
+
const sources = rows.length ? rows : defaultPlayers;
|
|
1112
|
+
sources.slice(0, MAX_LOBBY_SEATS).forEach((player, index) => {
|
|
1113
|
+
const lobbySeat = clamp(Math.round(Number(player.lobbySeat ?? index)), 0, MAX_LOBBY_SEATS - 1);
|
|
1114
|
+
let connectionId = player.connectionId || '';
|
|
1115
|
+
if (!connectionId && player.connectionName) {
|
|
1116
|
+
const match = $$('.connection-row', els.connectionsEditor).find(r => $('[data-field=name]', r).value.trim() === player.connectionName);
|
|
1117
|
+
connectionId = match?.dataset.id || '';
|
|
1118
|
+
}
|
|
1119
|
+
seatAssignments[lobbySeat] = {
|
|
1120
|
+
lobbySeat,
|
|
1121
|
+
name: player.name || `Player ${lobbySeat + 1}`,
|
|
1122
|
+
connectionId,
|
|
1123
|
+
model: player.model || '',
|
|
1124
|
+
protocol: player.protocol || 'tool',
|
|
1125
|
+
provider: player.provider || '',
|
|
1126
|
+
};
|
|
1127
|
+
});
|
|
1128
|
+
updateSeatSummary();
|
|
1129
|
+
}
|
|
1130
|
+
function collectSetupRaw(includeSecrets = true) {
|
|
1131
|
+
const fd = new FormData(els.setupForm);
|
|
1132
|
+
const connections = readConnections();
|
|
1133
|
+
return {
|
|
1134
|
+
startingStack: Number(fd.get('startingStack')), smallBlind: Number(fd.get('smallBlind')), bigBlind: Number(fd.get('bigBlind')), ante: Number(fd.get('ante')),
|
|
1135
|
+
handsPerLevel: Number(fd.get('handsPerLevel')), blindMultiplier: Number(fd.get('blindMultiplier')), actionSeconds: Number(fd.get('actionSeconds')), timeBankSeconds: Number(fd.get('timeBankSeconds')),
|
|
1136
|
+
lowTimeSeconds: Number(fd.get('lowTimeSeconds')), lowTimeFraction: Number(fd.get('lowTimeFraction')),
|
|
1137
|
+
betweenActionsMs: Number(fd.get('betweenActionsMs')), betweenHandsMs: Number(fd.get('betweenHandsMs')),
|
|
1138
|
+
benchmarkMode: String(fd.get('benchmarkMode') || DEFAULT_BENCHMARK_MODE),
|
|
1139
|
+
decisionArchitecture: String(fd.get('decisionArchitecture') || DEFAULT_DECISION_ARCHITECTURE),
|
|
1140
|
+
representation: String(fd.get('representation') || DEFAULT_REPRESENTATION_MODE),
|
|
1141
|
+
spectatorExplanations: fd.get('spectatorExplanations') === 'on' || fd.get('spectatorExplanations') === 'true',
|
|
1142
|
+
maxDecisions: arenaMaxDecisions,
|
|
1143
|
+
connections: includeSecrets ? connections : connections.map(({ apiKey, ...c }) => ({ ...c, apiKey: '' })),
|
|
1144
|
+
players: readSeatPlayers(),
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
function saveSetupWithoutSecrets(raw) {
|
|
1148
|
+
try {
|
|
1149
|
+
const safe = { ...raw, connections: raw.connections.map(({ apiKey, headers, ...c }) => ({ ...c, headers: '' })) };
|
|
1150
|
+
storageSet('pokertoolsArenaBrowserConfig', JSON.stringify(safe));
|
|
1151
|
+
} catch {}
|
|
1152
|
+
}
|
|
1153
|
+
function restoreSetup() {
|
|
1154
|
+
let saved = null; try { saved = JSON.parse(storageGet('pokertoolsArenaBrowserConfig')); } catch {}
|
|
1155
|
+
const injected = injectedEnvironmentConfig();
|
|
1156
|
+
const conns = injected?.connections?.length ? injected.connections : (saved?.connections?.length ? saved.connections : defaultConnections);
|
|
1157
|
+
conns.forEach(addConnectionRow);
|
|
1158
|
+
restoreSeatAssignments(injected?.players?.length ? injected.players : (saved?.players?.length ? saved.players : []));
|
|
1159
|
+
if (saved) for (const [key, value] of Object.entries(saved)) {
|
|
1160
|
+
if (key === 'players' || key === 'connections') continue;
|
|
1161
|
+
const input = els.setupForm.elements.namedItem(key); if (!input) continue;
|
|
1162
|
+
if (input.type === 'checkbox') input.checked = Boolean(value); else input.value = value;
|
|
1163
|
+
}
|
|
1164
|
+
if (injected?.settings) for (const [key, value] of Object.entries(injected.settings)) {
|
|
1165
|
+
if (value == null) continue;
|
|
1166
|
+
const input = els.setupForm.elements.namedItem(key); if (!input) continue;
|
|
1167
|
+
if (input.type === 'checkbox') input.checked = Boolean(value); else input.value = value;
|
|
1168
|
+
}
|
|
1169
|
+
if (injected?.settings) {
|
|
1170
|
+
pendingAutostart = Boolean(injected.settings.autostart);
|
|
1171
|
+
arenaMaxDecisions = Math.max(0, Number(injected.settings.maxDecisions) || 0);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
function renderLobbyIfVisible() {
|
|
1175
|
+
if (lobbyVisible && (!director || !['RUNNING', 'PAUSED'].includes(director.status))) renderTable(currentState || { status: 'IDLE', events: [] });
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
const TABLE_RING_POINTS = [
|
|
1179
|
+
'bottom-center', 'bottom-left', 'left-lower', 'left-middle', 'left-upper', 'top-left',
|
|
1180
|
+
'top-center', 'top-right', 'right-upper', 'right-middle', 'right-lower', 'bottom-right'
|
|
1181
|
+
];
|
|
1182
|
+
const LOBBY_RING_INDICES = [0, 1, 2, 4, 5, 6, 7, 8, 10, 11];
|
|
1183
|
+
|
|
1184
|
+
function ringIndicesForCount(count) {
|
|
1185
|
+
const safeCount = Math.max(1, Math.min(10, Number(count) || 1));
|
|
1186
|
+
if (safeCount === 10) return [...LOBBY_RING_INDICES];
|
|
1187
|
+
return Array.from({ length: safeCount }, (_, index) => Math.round(index * TABLE_RING_POINTS.length / safeCount) % TABLE_RING_POINTS.length);
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function ringPointPosition(point, bounds) {
|
|
1191
|
+
const { minX, maxX, minY, maxY } = bounds;
|
|
1192
|
+
const midX = (minX + maxX) / 2;
|
|
1193
|
+
const midY = (minY + maxY) / 2;
|
|
1194
|
+
const upperY = minY + (maxY - minY) * 0.32;
|
|
1195
|
+
const lowerY = minY + (maxY - minY) * 0.68;
|
|
1196
|
+
const points = {
|
|
1197
|
+
'bottom-center': [midX, maxY],
|
|
1198
|
+
'bottom-left': [minX, maxY],
|
|
1199
|
+
'left-lower': [minX, lowerY],
|
|
1200
|
+
'left-middle': [minX, midY],
|
|
1201
|
+
'left-upper': [minX, upperY],
|
|
1202
|
+
'top-left': [minX, minY],
|
|
1203
|
+
'top-center': [midX, minY],
|
|
1204
|
+
'top-right': [maxX, minY],
|
|
1205
|
+
'right-upper': [maxX, upperY],
|
|
1206
|
+
'right-middle': [maxX, midY],
|
|
1207
|
+
'right-lower': [maxX, lowerY],
|
|
1208
|
+
'bottom-right': [maxX, maxY]
|
|
1209
|
+
};
|
|
1210
|
+
const [x, y] = points[point] || [midX, midY];
|
|
1211
|
+
return { x, y };
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
function seatRectsOverlap(a, b, gap = 4) {
|
|
1215
|
+
return !(a.right + gap <= b.left || b.right + gap <= a.left || a.bottom + gap <= b.top || b.bottom + gap <= a.top);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function seatLayoutDiagnostics(seats, tableRect, insetX, insetY) {
|
|
1219
|
+
const rects = seats.map(seat => seat.getBoundingClientRect());
|
|
1220
|
+
const overflow = rects.some(rect => (
|
|
1221
|
+
rect.left < tableRect.left + insetX - 0.5 ||
|
|
1222
|
+
rect.right > tableRect.right - insetX + 0.5 ||
|
|
1223
|
+
rect.top < tableRect.top + insetY - 0.5 ||
|
|
1224
|
+
rect.bottom > tableRect.bottom - insetY + 0.5
|
|
1225
|
+
));
|
|
1226
|
+
let overlaps = 0;
|
|
1227
|
+
for (let i = 0; i < rects.length; i++) {
|
|
1228
|
+
for (let j = i + 1; j < rects.length; j++) if (seatRectsOverlap(rects[i], rects[j])) overlaps++;
|
|
1229
|
+
}
|
|
1230
|
+
return { rects, overflow, overlaps };
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
function clampSeatIntoTable(seat, tableRect, insetX, insetY) {
|
|
1234
|
+
const rect = seat.getBoundingClientRect();
|
|
1235
|
+
let dx = 0, dy = 0;
|
|
1236
|
+
const leftLimit = tableRect.left + insetX;
|
|
1237
|
+
const rightLimit = tableRect.right - insetX;
|
|
1238
|
+
const topLimit = tableRect.top + insetY;
|
|
1239
|
+
const bottomLimit = tableRect.bottom - insetY;
|
|
1240
|
+
if (rect.left < leftLimit) dx += leftLimit - rect.left;
|
|
1241
|
+
if (rect.right > rightLimit) dx -= rect.right - rightLimit;
|
|
1242
|
+
if (rect.top < topLimit) dy += topLimit - rect.top;
|
|
1243
|
+
if (rect.bottom > bottomLimit) dy -= rect.bottom - bottomLimit;
|
|
1244
|
+
if (dx) seat.style.left = `${parseFloat(seat.style.left || '0') + dx}px`;
|
|
1245
|
+
if (dy) seat.style.top = `${parseFloat(seat.style.top || '0') + dy}px`;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function densityOrderForTable(tableRect, seatCount, lobby) {
|
|
1249
|
+
// Start from what the *actual* table rectangle can support, not from viewport
|
|
1250
|
+
// breakpoints. High seat counts also step down a density earlier.
|
|
1251
|
+
const crowded = seatCount >= 8;
|
|
1252
|
+
if (tableRect.height < 440 || tableRect.width < 610 || (crowded && tableRect.height < 500)) return ['micro'];
|
|
1253
|
+
if (tableRect.height < 545 || tableRect.width < 760 || (crowded && tableRect.height < 590)) return ['tight', 'micro'];
|
|
1254
|
+
if (tableRect.height < 640 || tableRect.width < 980 || (crowded && tableRect.width < 1120)) return ['compact', 'tight', 'micro'];
|
|
1255
|
+
return lobby ? ['roomy', 'compact', 'tight', 'micro'] : ['roomy', 'compact', 'tight', 'micro'];
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
function layoutTableSeats({ lobby = false } = {}) {
|
|
1259
|
+
const seats = [...els.seatsLayer.querySelectorAll('.seat')];
|
|
1260
|
+
if (!seats.length) return;
|
|
1261
|
+
const tableRect = els.pokerTable.getBoundingClientRect();
|
|
1262
|
+
if (!tableRect.width || !tableRect.height) return;
|
|
1263
|
+
|
|
1264
|
+
const compactViewport = globalThis.matchMedia?.('(max-width: 440px)').matches;
|
|
1265
|
+
const ringIndices = lobby ? LOBBY_RING_INDICES : ringIndicesForCount(seats.length);
|
|
1266
|
+
const densities = densityOrderForTable(tableRect, seats.length, lobby);
|
|
1267
|
+
let chosen = densities.at(-1) || 'micro';
|
|
1268
|
+
|
|
1269
|
+
for (const density of densities) {
|
|
1270
|
+
els.pokerTable.dataset.density = density;
|
|
1271
|
+
// Reading offsetHeight forces the density CSS to be applied before measuring.
|
|
1272
|
+
void els.pokerTable.offsetHeight;
|
|
1273
|
+
|
|
1274
|
+
const sizes = seats.map(seat => {
|
|
1275
|
+
const rect = seat.getBoundingClientRect();
|
|
1276
|
+
return { width: rect.width, height: rect.height };
|
|
1277
|
+
});
|
|
1278
|
+
const maxWidth = Math.max(...sizes.map(size => size.width), 1);
|
|
1279
|
+
const maxHeight = Math.max(...sizes.map(size => size.height), 1);
|
|
1280
|
+
const marginX = compactViewport ? 4 : (density === 'micro' ? 5 : lobby ? 8 : 10);
|
|
1281
|
+
const marginY = compactViewport ? 4 : (density === 'micro' ? 5 : lobby ? 8 : density === 'roomy' ? 12 : 8);
|
|
1282
|
+
const bounds = {
|
|
1283
|
+
minX: marginX + maxWidth / 2,
|
|
1284
|
+
maxX: Math.max(marginX + maxWidth / 2, tableRect.width - marginX - maxWidth / 2),
|
|
1285
|
+
minY: marginY + maxHeight / 2,
|
|
1286
|
+
maxY: Math.max(marginY + maxHeight / 2, tableRect.height - marginY - maxHeight / 2)
|
|
1287
|
+
};
|
|
1288
|
+
|
|
1289
|
+
seats.forEach((seat, index) => {
|
|
1290
|
+
const ringIndex = ringIndices[index] ?? 0;
|
|
1291
|
+
const pointName = TABLE_RING_POINTS[ringIndex];
|
|
1292
|
+
const pos = ringPointPosition(pointName, bounds);
|
|
1293
|
+
seat.style.left = `${Math.round(pos.x * 10) / 10}px`;
|
|
1294
|
+
seat.style.top = `${Math.round(pos.y * 10) / 10}px`;
|
|
1295
|
+
seat.dataset.ringPoint = pointName;
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
// A second, real-rectangle clamp is the important part. Seat content can
|
|
1299
|
+
// become taller than its CSS min-height (cards, all-in labels, long model
|
|
1300
|
+
// names). Clamp what the browser actually rendered rather than trusting
|
|
1301
|
+
// the nominal density dimensions.
|
|
1302
|
+
seats.forEach(seat => clampSeatIntoTable(seat, tableRect, marginX, marginY));
|
|
1303
|
+
const diagnostics = seatLayoutDiagnostics(seats, tableRect, marginX, marginY);
|
|
1304
|
+
chosen = density;
|
|
1305
|
+
if (!diagnostics.overflow && diagnostics.overlaps === 0) break;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
els.pokerTable.dataset.density = chosen;
|
|
1309
|
+
// Re-clamp on the next frame after fonts/cards have had a chance to settle.
|
|
1310
|
+
requestAnimationFrame(() => {
|
|
1311
|
+
const currentRect = els.pokerTable.getBoundingClientRect();
|
|
1312
|
+
if (!currentRect.width || !currentRect.height) return;
|
|
1313
|
+
const inset = chosen === 'micro' ? 4 : 6;
|
|
1314
|
+
seats.forEach(seat => clampSeatIntoTable(seat, currentRect, inset, inset));
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
function lobbySeatHtml(seatIndex) {
|
|
1319
|
+
const player = seatAssignments[seatIndex];
|
|
1320
|
+
if (!player) {
|
|
1321
|
+
return `<button type="button" class="seat lobby-seat empty-seat" data-lobby-seat="${seatIndex}" aria-label="Configure seat ${seatIndex + 1}">
|
|
1322
|
+
<span class="empty-seat-plus">+</span><span class="empty-seat-number">Seat ${seatIndex + 1}</span><span class="empty-seat-copy">Add model</span>
|
|
1323
|
+
</button>`;
|
|
1324
|
+
}
|
|
1325
|
+
const conn = connectionById(player.connectionId);
|
|
1326
|
+
return `<button type="button" class="seat lobby-seat configured-seat" data-lobby-seat="${seatIndex}" aria-label="Edit ${escapeHtml(player.name)} in seat ${seatIndex + 1}">
|
|
1327
|
+
<div class="seat-head"><div><div class="seat-name">${escapeHtml(visiblePlayerName(player.name, player.model))}</div><div class="seat-model mono">${escapeHtml(shortModel(player.model))}</div></div><span class="seat-pos">S${seatIndex + 1}</span></div>
|
|
1328
|
+
<div class="configured-seat-meta"><span>${escapeHtml(conn?.name || 'Connection')}</span><span>${escapeHtml(effectiveProtocol(player, conn) === 'jev_decisions' ? 'Jev Decisions' : effectiveProtocol(player, conn) === 'jev_native' ? 'Jev native' : effectiveProtocol(player, conn).replace('_', ' '))}</span></div>
|
|
1329
|
+
<div class="lobby-edit-hint">Click to edit</div>
|
|
1330
|
+
</button>`;
|
|
1331
|
+
}
|
|
1332
|
+
function renderLobbyTable() {
|
|
1333
|
+
els.pokerTable.classList.add('lobby-mode');
|
|
1334
|
+
els.seatsLayer.innerHTML = Array.from({ length: MAX_LOBBY_SEATS }, (_, i) => lobbySeatHtml(i)).join('');
|
|
1335
|
+
layoutTableSeats({ lobby: true });
|
|
1336
|
+
els.board.innerHTML = Array.from({ length: 5 }, () => cardHtml(null, true)).join('');
|
|
1337
|
+
const count = seatAssignments.filter(Boolean).length;
|
|
1338
|
+
els.potValue.textContent = `${count}/${MAX_LOBBY_SEATS}`;
|
|
1339
|
+
els.blindsValue.textContent = '—';
|
|
1340
|
+
els.anteValue.textContent = '—';
|
|
1341
|
+
els.handValue.textContent = '—';
|
|
1342
|
+
els.levelValue.textContent = '—';
|
|
1343
|
+
els.streetLabel.textContent = 'LOBBY';
|
|
1344
|
+
els.winnerBanner.classList.add('hidden');
|
|
1345
|
+
}
|
|
1346
|
+
function renderStatus(s) {
|
|
1347
|
+
const status = s?.status || 'IDLE';
|
|
1348
|
+
els.statusLabel.textContent = status;
|
|
1349
|
+
els.statusDot.className = `status-dot ${status.toLowerCase()}`;
|
|
1350
|
+
const running = ['RUNNING', 'PAUSED'].includes(status);
|
|
1351
|
+
els.pauseBtn.classList.toggle('hidden', !running);
|
|
1352
|
+
els.stopBtn.classList.toggle('hidden', !running);
|
|
1353
|
+
els.startTopBtn.classList.toggle('hidden', running);
|
|
1354
|
+
els.seatsBtn.classList.toggle('hidden', running);
|
|
1355
|
+
els.setupBtn.disabled = running;
|
|
1356
|
+
els.testsBtn.disabled = running;
|
|
1357
|
+
const pauseLabel = $('.action-label', els.pauseBtn);
|
|
1358
|
+
if (pauseLabel) pauseLabel.textContent = status === 'PAUSED' ? 'Resume' : 'Pause';
|
|
1359
|
+
const pauseIcon = $('.action-icon', els.pauseBtn);
|
|
1360
|
+
if (pauseIcon) pauseIcon.textContent = status === 'PAUSED' ? '▶' : 'Ⅱ';
|
|
1361
|
+
els.pauseBtn.title = status === 'PAUSED' ? 'Resume tournament' : 'Pause tournament';
|
|
1362
|
+
els.exportBtn.disabled = !(s?.events?.length);
|
|
1363
|
+
const seated = seatAssignments.filter(Boolean).length;
|
|
1364
|
+
if (lobbyVisible && !running) els.tournamentMeta.textContent = `${seated} model${seated === 1 ? '' : 's'} seated · click a seat to edit`;
|
|
1365
|
+
else if (s?.table && s?.status === 'FINISHED' && s?.winner) els.tournamentMeta.textContent = `Winner · ${displayModelName(s.winner.model || '')} · ${fmt(s.winner.stack)} chips · ${s.table.handNumber} hands`;
|
|
1366
|
+
else if (s?.table && s?.status === 'PAUSED') els.tournamentMeta.textContent = `Paused · Hand ${s.table.handNumber} · Level ${Number(s.table.blindLevel || 0) + 1} · ${s.table.playersRemaining ?? s.config?.players.length ?? 0}/${s.table.startingPlayers ?? s.config?.players.length ?? 0} left`;
|
|
1367
|
+
else if (s?.table) els.tournamentMeta.textContent = `Hand ${s.table.handNumber} · Level ${Number(s.table.blindLevel || 0) + 1} · ${s.table.playersRemaining ?? s.config?.players.length ?? 0}/${s.table.startingPlayers ?? s.config?.players.length ?? 0} left`;
|
|
1368
|
+
else els.tournamentMeta.textContent = 'Seat 2–10 models, then press Start';
|
|
1369
|
+
}
|
|
1370
|
+
function renderTable(s) {
|
|
1371
|
+
const running = ['RUNNING', 'PAUSED'].includes(s?.status);
|
|
1372
|
+
if (lobbyVisible && !running) { renderLobbyTable(); return; }
|
|
1373
|
+
const table = s?.table;
|
|
1374
|
+
if (!table) { renderLobbyTable(); return; }
|
|
1375
|
+
els.pokerTable.classList.remove('lobby-mode');
|
|
1376
|
+
const players = table.players.filter(Boolean);
|
|
1377
|
+
const eliminatedIds = new Set((s.eliminations ?? []).map(e => e.playerId));
|
|
1378
|
+
els.seatsLayer.innerHTML = players.map((p, i) => {
|
|
1379
|
+
const cfg = s.config?.players.find(x => x.id === p.id) || {}, stat = s.stats?.[p.id] || {};
|
|
1380
|
+
const active = table.actionTo === p.seat || s.currentDecision?.playerId === p.id, isWinner = s.status === 'FINISHED' && s.winner?.playerId === p.id, busted = !isWinner && (eliminatedIds.has(p.id) || p.status === 'BUSTED'), allIn = p.status === 'ALL_IN' && !busted;
|
|
1381
|
+
const elimination = (s.eliminations ?? []).find(e => e.playerId === p.id);
|
|
1382
|
+
const actionText = s.status === 'FINISHED' ? (isWinner ? 'WINNER · 1ST' : elimination ? `${ordinal(elimination.place)} · ELIMINATED` : (stat.lastAction || '')) : (stat.lastAction || (busted ? 'ELIMINATED' : allIn ? 'ALL IN' : ''));
|
|
1383
|
+
const actionKind = isWinner ? 'winner' : /raise/i.test(actionText) ? 'raise' : /bet/i.test(actionText) ? 'bet' : /call/i.test(actionText) ? 'call' : /fold/i.test(actionText) ? 'fold' : /check/i.test(actionText) ? 'check' : '';
|
|
1384
|
+
return `<div class="seat ${active ? 'active' : ''} ${isWinner ? 'winner' : ''} ${busted ? 'busted' : ''} ${allIn ? 'all-in' : ''}" data-player-id="${escapeHtml(p.id)}" data-lobby-seat="${Number(cfg.lobbySeat ?? i)}">
|
|
1385
|
+
<i class="seat-turn" aria-hidden="true"></i>
|
|
1386
|
+
<div class="seat-head"><div><div class="seat-name">${escapeHtml(visiblePlayerName(p.name, cfg.model))}</div><div class="seat-model mono">${escapeHtml(shortModel(cfg.model))}</div></div><span class="seat-pos">${escapeHtml(p.position || '')}</span></div>
|
|
1387
|
+
<div class="seat-stack"><strong><span class="chip-dot"></span>${fmt(p.stack)}</strong><span>${Number(p.stackBB || 0).toFixed(1)} BB</span></div>
|
|
1388
|
+
<div class="hole-cards">${[0, 1].map((k) => cardHtml(p.cards?.[k], !p.cards?.[k], `hole-card card-${k + 1}`, true)).join('')}</div>
|
|
1389
|
+
<div class="last-action ${actionKind}">${escapeHtml(actionText)}</div></div>`;
|
|
1390
|
+
}).join('');
|
|
1391
|
+
layoutTableSeats();
|
|
1392
|
+
els.board.innerHTML = Array.from({ length: 5 }, (_, i) => cardHtml(table.board?.[i], !table.board?.[i], `board-card board-${i + 1}`)).join('');
|
|
1393
|
+
els.potValue.textContent = fmtHud(table.pot);
|
|
1394
|
+
els.potValue.title = fmt(table.pot);
|
|
1395
|
+
els.blindsValue.textContent = `${fmtHud(table.smallBlind)} / ${fmtHud(table.bigBlind)}`;
|
|
1396
|
+
els.blindsValue.title = `${fmt(table.smallBlind)} / ${fmt(table.bigBlind)}`;
|
|
1397
|
+
els.anteValue.textContent = table.ante ? fmtHud(table.ante) : '—';
|
|
1398
|
+
els.anteValue.title = table.ante ? fmt(table.ante) : 'No ante';
|
|
1399
|
+
els.handValue.textContent = table.handNumber ?? '—';
|
|
1400
|
+
els.handValue.title = table.handNumber == null ? '' : `Hand ${table.handNumber}`;
|
|
1401
|
+
els.levelValue.textContent = Number(table.blindLevel ?? 0) + 1;
|
|
1402
|
+
els.levelValue.title = `Level ${Number(table.blindLevel ?? 0) + 1}`;
|
|
1403
|
+
els.streetLabel.textContent = s.status === 'FINISHED' && s.winner ? `WINNER · ${displayModelName(s.winner.model || '')}` : (table.street || '—');
|
|
1404
|
+
els.streetLabel.classList.toggle('winner-street', s.status === 'FINISHED' && Boolean(s.winner));
|
|
1405
|
+
els.winnerBanner.classList.add('hidden');
|
|
1406
|
+
}
|
|
1407
|
+
// The active seat gets a depleting border ring (no numbers) so spectators can
|
|
1408
|
+
// see whose turn it is and how much of their clock remains.
|
|
1409
|
+
function clearTurnRings(keep = null) {
|
|
1410
|
+
for (const el of $$('.seat.turn-active')) if (el !== keep) el.classList.remove('turn-active');
|
|
1411
|
+
}
|
|
1412
|
+
function stopClock() {
|
|
1413
|
+
if (clockTimer) { cancelAnimationFrame(clockTimer); clearInterval(clockTimer); }
|
|
1414
|
+
clockTimer = null; activeDecisionClockId = null;
|
|
1415
|
+
clearTurnRings();
|
|
1416
|
+
}
|
|
1417
|
+
function startClock(decision) {
|
|
1418
|
+
if (!decision) { stopClock(); return; }
|
|
1419
|
+
if (activeDecisionClockId === decision.id && clockTimer) return;
|
|
1420
|
+
stopClock(); activeDecisionClockId = decision.id;
|
|
1421
|
+
const frame = () => {
|
|
1422
|
+
// Freeze the clock at the moment of the pause. `pausedMs` already accounts
|
|
1423
|
+
// for every completed pause and `pausedAt` is the frozen "now"; subtracting
|
|
1424
|
+
// the in-flight pause a second time made the clock count upward on pause.
|
|
1425
|
+
const now = decision.pausedAt || Date.now();
|
|
1426
|
+
const elapsed = Math.max(0, now - decision.startedAt - (decision.pausedMs || 0));
|
|
1427
|
+
// One canonical phase for the number AND the ring. Thresholds are config.
|
|
1428
|
+
const phase = decisionClockPhase({
|
|
1429
|
+
baseMs: decision.baseMs,
|
|
1430
|
+
timeBankMs: decision.timeBankMs,
|
|
1431
|
+
elapsedMs: elapsed,
|
|
1432
|
+
lowTimeMs: Number.isFinite(Number(decision.lowTimeMs)) ? Number(decision.lowTimeMs) : TIMING_DEFAULTS.lowTimeSeconds * 1000,
|
|
1433
|
+
lowTimeFraction: Number.isFinite(Number(decision.lowTimeFraction)) ? Number(decision.lowTimeFraction) : TIMING_DEFAULTS.lowTimeFraction,
|
|
1434
|
+
});
|
|
1435
|
+
els.decisionClock.textContent = (phase.shownMs / 1000).toFixed(1);
|
|
1436
|
+
setBankClock(decision.pausedAt
|
|
1437
|
+
? 'Paused'
|
|
1438
|
+
: (phase.inBank
|
|
1439
|
+
? `Time bank ${(phase.bankLeft / 1000).toFixed(1)}s`
|
|
1440
|
+
: (decision.timeBankMs > 0 ? `Bank ${(decision.timeBankMs / 1000).toFixed(0)}s` : 'On the clock')));
|
|
1441
|
+
const seat = seatEl(decision.playerId);
|
|
1442
|
+
clearTurnRings(seat);
|
|
1443
|
+
if (seat) {
|
|
1444
|
+
seat.classList.add('turn-active');
|
|
1445
|
+
seat.style.setProperty('--turn', phase.ringFraction.toFixed(4));
|
|
1446
|
+
seat.classList.toggle('turn-low', phase.isLow);
|
|
1447
|
+
}
|
|
1448
|
+
if (activeDecisionClockId === decision.id) clockTimer = requestAnimationFrame(frame);
|
|
1449
|
+
};
|
|
1450
|
+
frame();
|
|
1451
|
+
}
|
|
1452
|
+
function latestDecisionEvent(s) {
|
|
1453
|
+
const events = s?.events || [];
|
|
1454
|
+
for (let i = events.length - 1; i >= 0; i--) if (events[i]?.type === 'DECISION') return events[i];
|
|
1455
|
+
return null;
|
|
1456
|
+
}
|
|
1457
|
+
function compactPercent(value) { return `${Math.round(Math.max(0, Math.min(1, Number(value) || 0)) * 100)}%`; }
|
|
1458
|
+
// Spectator-only, deterministic hand label. This is generated by code and must
|
|
1459
|
+
// never be presented as model reasoning.
|
|
1460
|
+
function deterministicHandLabel(heroCards, board) {
|
|
1461
|
+
const hand = heroHandSummary(heroCards, board);
|
|
1462
|
+
return hand ? hand.category : null;
|
|
1463
|
+
}
|
|
1464
|
+
function confidenceBand(value) {
|
|
1465
|
+
const v = Number(value);
|
|
1466
|
+
if (!Number.isFinite(v)) return null;
|
|
1467
|
+
return v < 0.34 ? 'LOW' : v < 0.67 ? 'MEDIUM' : 'HIGH';
|
|
1468
|
+
}
|
|
1469
|
+
function aggressionTendency(value) {
|
|
1470
|
+
const v = Number(value);
|
|
1471
|
+
if (!Number.isFinite(v)) return null;
|
|
1472
|
+
const labels = ['Very passive', 'Cautious', 'Balanced', 'Aggressive', 'Maximum pressure'];
|
|
1473
|
+
const idx = clamp(Math.round(v), 0, 4);
|
|
1474
|
+
const direction = v > 2.6 ? 'Aggressive' : v < 1.4 ? 'Passive' : 'Balanced';
|
|
1475
|
+
return `${v.toFixed(1)} / 4 · ${labels[idx]} → ${direction}`;
|
|
1476
|
+
}
|
|
1477
|
+
function probabilityStrip(entries, labelFor, limit = 4) {
|
|
1478
|
+
const rows = Object.entries(entries ?? {})
|
|
1479
|
+
.filter(([, value]) => Number.isFinite(Number(value)))
|
|
1480
|
+
.sort((a, b) => Number(b[1]) - Number(a[1]))
|
|
1481
|
+
.slice(0, limit);
|
|
1482
|
+
if (!rows.length) return '';
|
|
1483
|
+
return `<div class="probability-strip">${rows.map(([id, probability]) => {
|
|
1484
|
+
const pct = Math.round(Number(probability) * 100);
|
|
1485
|
+
const label = labelFor(id);
|
|
1486
|
+
return `<span class="probability-item" title="${escapeHtml(label)} — ${pct}%"><b>${escapeHtml(label)}</b><i><u style="width:${Math.max(2, pct)}%"></u></i><em>${pct}%</em></span>`;
|
|
1487
|
+
}).join('')}</div>`;
|
|
1488
|
+
}
|
|
1489
|
+
// Translate internal identifiers (A0/A1, bet/check) into human labels. Raw
|
|
1490
|
+
// identifiers are never shown unless they are the only available label.
|
|
1491
|
+
function decisionTelemetryHtml(event, { limit = 4 } = {}) {
|
|
1492
|
+
const meta = event?.decisionMeta || {};
|
|
1493
|
+
const labelFor = id => (event?.legalActions || []).find(a => a.id === id)?.description || id;
|
|
1494
|
+
const familyLabel = key => familyCriteria([String(key).toLowerCase()])[String(key).toLowerCase()] ?? String(key).toUpperCase();
|
|
1495
|
+
const sizeLabel = key => SIZE_LABELS[String(key).toLowerCase()] ?? String(key).toUpperCase();
|
|
1496
|
+
const parts = [];
|
|
1497
|
+
if (meta.family?.probabilities && typeof meta.family.probabilities === 'object') {
|
|
1498
|
+
parts.push('<div class="telemetry-group"><span class="telemetry-caption">Selected family</span>' + probabilityStrip(meta.family.probabilities, familyLabel, limit) + '</div>');
|
|
1499
|
+
if (meta.sizing?.probabilities && typeof meta.sizing.probabilities === 'object') {
|
|
1500
|
+
parts.push('<div class="telemetry-group"><span class="telemetry-caption">Sizing</span>' + probabilityStrip(meta.sizing.probabilities, sizeLabel, limit) + '</div>');
|
|
1501
|
+
}
|
|
1502
|
+
} else if (meta.probabilities && typeof meta.probabilities === 'object') {
|
|
1503
|
+
// Legacy flat event: aggregate sizes into families so CHECK, CALL, FOLD,
|
|
1504
|
+
// BET and RAISE stay comparable. The historical selected action is never
|
|
1505
|
+
// changed.
|
|
1506
|
+
const familyMass = aggregateActionProbabilitiesByFamily(meta.probabilities, event?.legalActions, { labelResolver: id => ({ description: labelFor(id) }) });
|
|
1507
|
+
const ordered = {};
|
|
1508
|
+
for (const key of ['check', 'bet', 'call', 'raise', 'fold', 'other']) if (Number.isFinite(Number(familyMass[key]))) ordered[key] = familyMass[key];
|
|
1509
|
+
parts.push('<div class="telemetry-group"><span class="telemetry-caption">Aggregated from legacy flat action probabilities</span>' + probabilityStrip(ordered, familyLabel, 6) + '</div>');
|
|
1510
|
+
parts.push('<div class="telemetry-group"><span class="telemetry-caption">Individual flat actions</span>' + probabilityStrip(meta.probabilities, labelFor, limit) + '</div>');
|
|
1511
|
+
}
|
|
1512
|
+
const facts = [];
|
|
1513
|
+
const familyConfidence = meta.family?.confidence ?? meta.confidence;
|
|
1514
|
+
if (Number.isFinite(Number(familyConfidence))) {
|
|
1515
|
+
const band = confidenceBand(familyConfidence);
|
|
1516
|
+
facts.push(`<span>Confidence <b>${band ? `${band} · ` : ''}${compactPercent(familyConfidence)}</b></span>`);
|
|
1517
|
+
}
|
|
1518
|
+
if (Number.isFinite(Number(meta.sizing?.confidence))) facts.push(`<span>Size confidence <b>${compactPercent(meta.sizing.confidence)}</b></span>`);
|
|
1519
|
+
if (Number.isFinite(Number(meta.aggression))) facts.push(`<span>Aggression tendency <b>${escapeHtml(aggressionTendency(meta.aggression))}</b></span>`);
|
|
1520
|
+
// bluff_spot is a property of the spot, not a rationale for the chosen action.
|
|
1521
|
+
if (Number.isFinite(Number(meta.bluffSpot))) facts.push(`<span>Bluff opportunity (spot) <b>${compactPercent(meta.bluffSpot)}</b></span>`);
|
|
1522
|
+
const reasoningTokens = event?.usage?.completion_tokens_details?.reasoning_tokens;
|
|
1523
|
+
if (reasoningTokens) facts.push(`<span>Reasoning <b>${reasoningTokens} tokens</b></span>`);
|
|
1524
|
+
if (facts.length) parts.push(`<div class="decision-facts">${facts.join('')}</div>`);
|
|
1525
|
+
const isTypedDecision = meta.family != null || meta.aggression != null || meta.bluffSpot != null || meta.method === 'openrouter-decisions' || meta.method === 'jev-choice' || String(meta.method || '').includes('hierarchical');
|
|
1526
|
+
if (isTypedDecision) parts.push(`<div class="decision-facts-note">${escapeHtml(SPECTATOR_NOTE)} Bluff opportunity is a property of the spot, not the reason for the chosen action.</div>`);
|
|
1527
|
+
return parts.join('');
|
|
1528
|
+
}
|
|
1529
|
+
function setDecisionContext({ hand = '—', street = '—', position = '—', options = '—', label = 'Legal actions', hint = 'Choose one', labels = null } = {}) {
|
|
1530
|
+
els.decisionHand.textContent = hand ?? '—';
|
|
1531
|
+
els.decisionStreet.textContent = String(street ?? '—').replaceAll('_', ' ');
|
|
1532
|
+
els.decisionPosition.textContent = position || '—';
|
|
1533
|
+
els.decisionOptionCount.textContent = options ?? '—';
|
|
1534
|
+
els.decisionActionLabel.textContent = label;
|
|
1535
|
+
setActionHint(hint);
|
|
1536
|
+
const names = Array.isArray(labels) && labels.length === 4 ? labels : ['Hand', 'Street', 'Position', 'Options'];
|
|
1537
|
+
els.decisionLabelHand.textContent = names[0];
|
|
1538
|
+
els.decisionLabelStreet.textContent = names[1];
|
|
1539
|
+
els.decisionLabelPosition.textContent = names[2];
|
|
1540
|
+
els.decisionLabelOptions.textContent = names[3];
|
|
1541
|
+
}
|
|
1542
|
+
// Status text is single-line and truncated in CSS; the title keeps the full text
|
|
1543
|
+
// available on hover without letting it reflow the panel.
|
|
1544
|
+
function setBankClock(text) { els.bankClock.textContent = text; els.bankClock.title = text; }
|
|
1545
|
+
function setActionHint(text) { els.decisionActionHint.textContent = text; els.decisionActionHint.title = text; }
|
|
1546
|
+
// Champion badge: an SVG trophy in a gold medal so the winner mark renders the
|
|
1547
|
+
// same everywhere (no emoji font differences) and carries a subtle sheen.
|
|
1548
|
+
function championBadgeHtml() {
|
|
1549
|
+
return '<span class="champion-badge" role="img" aria-label="Tournament champion">'
|
|
1550
|
+
+ '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">'
|
|
1551
|
+
+ '<path d="M6.6 2.6h10.8v4.7a5.4 5.4 0 0 1-10.8 0V2.6Z"/>'
|
|
1552
|
+
+ '<path d="M10.7 12.4h2.6V15h-2.6z"/>'
|
|
1553
|
+
+ '<path d="M7.2 15h9.6v2.1H7.2z"/>'
|
|
1554
|
+
+ '<path d="M5.6 18.2h12.8v2.3H5.6z"/>'
|
|
1555
|
+
+ '</svg></span>';
|
|
1556
|
+
}
|
|
1557
|
+
function renderDecision(s) {
|
|
1558
|
+
const d = s?.currentDecision;
|
|
1559
|
+
const last = latestDecisionEvent(s);
|
|
1560
|
+
const shouldShowCard = Boolean(d || last || seatAssignments.filter(Boolean).length);
|
|
1561
|
+
els.decisionEmpty.classList.toggle('hidden', shouldShowCard);
|
|
1562
|
+
els.decisionCard.classList.toggle('hidden', !shouldShowCard);
|
|
1563
|
+
if (!shouldShowCard) { stopClock(); return; }
|
|
1564
|
+
|
|
1565
|
+
const finished = s?.status === 'FINISHED' && Boolean(s?.winner);
|
|
1566
|
+
els.decisionCard.classList.toggle('winner-card', finished);
|
|
1567
|
+
els.decisionPanelTitle.classList.toggle('winner-title', finished);
|
|
1568
|
+
|
|
1569
|
+
if (d) {
|
|
1570
|
+
const paused = s?.status === 'PAUSED';
|
|
1571
|
+
els.decisionPanelTitle.textContent = 'Decision';
|
|
1572
|
+
els.decisionPhase.textContent = paused ? 'PAUSED' : 'THINKING';
|
|
1573
|
+
els.decisionPhase.className = `decision-phase ${paused ? 'paused' : 'thinking'}`;
|
|
1574
|
+
els.decisionPlayer.textContent = displayModelName(d.model);
|
|
1575
|
+
els.decisionPlayer.title = d.model;
|
|
1576
|
+
els.decisionModel.textContent = `${d.connection || 'Connection'} · ${protocolDisplay(d.protocol)}`;
|
|
1577
|
+
if (d.architecture === 'hierarchical') {
|
|
1578
|
+
const stage = d.stage ?? d.hierarchy?.stage1 ?? null;
|
|
1579
|
+
const families = stage?.families ?? d.hierarchy?.families ?? [];
|
|
1580
|
+
if (stage?.stage === 'size') {
|
|
1581
|
+
setDecisionContext({ hand: d.handNumber ?? s?.table?.handNumber ?? '—', street: d.street ?? s?.table?.street ?? '—', position: d.position ?? '—', options: stage.sizes?.length ?? 0, label: `${String(stage.family).toUpperCase()} size`, hint: 'Stage 2 of 2 · sizing' });
|
|
1582
|
+
els.legalActions.innerHTML = (stage.sizes ?? []).map(size => `<span class="action-chip stage-chip">${escapeHtml(SIZE_LABELS[size.id] ?? size.id)} · ${escapeHtml(size.label)}</span>`).join('') || '<span class="action-chip">No legal sizes</span>';
|
|
1583
|
+
} else {
|
|
1584
|
+
setDecisionContext({ hand: d.handNumber ?? s?.table?.handNumber ?? '—', street: d.street ?? s?.table?.street ?? '—', position: d.position ?? '—', options: families.length, label: 'Action family', hint: 'Stage 1 of 2 · action' });
|
|
1585
|
+
const criteria = stage?.criteria ?? familyCriteria(families);
|
|
1586
|
+
els.legalActions.innerHTML = families.map(family => `<span class="action-chip stage-chip">${escapeHtml(criteria[family] ?? family)}</span>`).join('') || '<span class="action-chip">…</span>';
|
|
1587
|
+
}
|
|
1588
|
+
startClock(d);
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
setDecisionContext({ hand: d.handNumber ?? s?.table?.handNumber ?? '—', street: d.street ?? s?.table?.street ?? '—', position: d.position ?? '—', options: d.legalActions?.length ?? 0, label: 'Legal actions', hint: 'Choosing…' });
|
|
1592
|
+
els.legalActions.innerHTML = d.legalActions.map(a => `<span class="action-chip" title="${escapeHtml(a.id)}">${escapeHtml(a.description)}</span>`).join('');
|
|
1593
|
+
startClock(d);
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
stopClock();
|
|
1598
|
+
if (finished) {
|
|
1599
|
+
const winnerLast = [...(s.events || [])].reverse().find(e => e.type === 'DECISION' && e.playerId === s.winner.playerId) || last;
|
|
1600
|
+
const hands = s.table?.handNumber || 0;
|
|
1601
|
+
const players = s.table?.startingPlayers ?? s.config?.players?.length ?? null;
|
|
1602
|
+
const eliminated = Array.isArray(s.eliminations) ? s.eliminations.length : 0;
|
|
1603
|
+
const runnerUp = [...(s.eliminations || [])].reverse().find(e => e.place === 2) || (s.eliminations || [])[0] || null;
|
|
1604
|
+
const duration = (s.startedAt && s.finishedAt) ? formatDuration(s.finishedAt - s.startedAt) : null;
|
|
1605
|
+
els.decisionPanelTitle.textContent = 'Tournament complete';
|
|
1606
|
+
els.decisionPhase.textContent = 'WINNER';
|
|
1607
|
+
els.decisionPhase.className = 'decision-phase winner';
|
|
1608
|
+
els.decisionPlayer.textContent = displayModelName(s.winner.model || s.winner.playerName || 'Tournament winner');
|
|
1609
|
+
els.decisionPlayer.title = s.winner.model || s.winner.playerName || '';
|
|
1610
|
+
els.decisionModel.textContent = `${fmt(s.winner.stack)} chips${duration ? ` · ${duration}` : ''}`;
|
|
1611
|
+
els.decisionClock.innerHTML = championBadgeHtml();
|
|
1612
|
+
els.decisionClock.title = 'Tournament champion';
|
|
1613
|
+
setBankClock(winnerLast ? `Won on hand ${winnerLast.handNumber ?? hands}` : 'All hands settled');
|
|
1614
|
+
const modelForPlayer = (playerId) => (s.config?.players || []).find(p => p.id === playerId)?.model || '';
|
|
1615
|
+
const standings = [
|
|
1616
|
+
{ place: 1, model: s.winner.model || '', name: s.winner.playerName || '', meta: `${fmt(s.winner.stack)} chips` },
|
|
1617
|
+
...(s.eliminations || []).map(e => ({ place: e.place, model: modelForPlayer(e.playerId), name: e.playerName || '', meta: `out · hand ${e.handNumber}` })),
|
|
1618
|
+
].sort((a, b) => a.place - b.place);
|
|
1619
|
+
const medal = place => place === 1 ? '🥇' : place === 2 ? '🥈' : '🥉';
|
|
1620
|
+
setDecisionContext({
|
|
1621
|
+
labels: ['Players', 'Hands', 'Eliminated', 'Runner-up'],
|
|
1622
|
+
hand: players != null ? String(players) : '—',
|
|
1623
|
+
street: String(hands),
|
|
1624
|
+
position: String(eliminated),
|
|
1625
|
+
options: runnerUp ? displayModelName(modelForPlayer(runnerUp.playerId) || runnerUp.playerName || '') : '—',
|
|
1626
|
+
label: 'Final standings',
|
|
1627
|
+
hint: winnerLast ? `Won with ${winnerLast.action?.description || 'the final hand'}` : 'Complete',
|
|
1628
|
+
});
|
|
1629
|
+
els.legalActions.innerHTML = `<div class="winner-podium">${standings.map(row => `<div class="podium-row${row.place === 1 ? ' first' : ''}"><span class="podium-medal">${medal(row.place)}</span><span class="podium-name" title="${escapeHtml(row.model || row.name)}">${escapeHtml(displayModelName(row.model || row.name || ''))}</span><span class="podium-meta">${escapeHtml(row.meta)}</span></div>`).join('')}</div>`;
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
els.decisionPanelTitle.textContent = 'Decision';
|
|
1634
|
+
if (last) {
|
|
1635
|
+
const paused = s?.status === 'PAUSED';
|
|
1636
|
+
els.decisionPhase.textContent = paused ? 'PAUSED' : 'LAST';
|
|
1637
|
+
els.decisionPhase.className = `decision-phase ${paused ? 'paused' : 'settled'}`;
|
|
1638
|
+
els.decisionPlayer.textContent = displayModelName(last.configuredModel || last.resolvedModel || '');
|
|
1639
|
+
els.decisionPlayer.title = last.configuredModel || last.resolvedModel || '';
|
|
1640
|
+
els.decisionModel.textContent = `${last.connection || 'Connection'} · ${protocolDisplay(last.protocol || last.requestedProtocol)}`;
|
|
1641
|
+
els.decisionClock.textContent = `${(((last.latencyMs || 0) / 1000) || 0).toFixed(1)}s`;
|
|
1642
|
+
setBankClock(paused ? 'Reading mode' : 'Awaiting next');
|
|
1643
|
+
setDecisionContext({ hand: last.handNumber ?? s?.table?.handNumber ?? '—', street: last.street ?? '—', position: last.position ?? '—', options: 1, label: 'Last action', hint: 'See history' });
|
|
1644
|
+
els.legalActions.innerHTML = `<span class="action-chip selected">${escapeHtml(last.action?.description || 'No action')}</span>`;
|
|
1645
|
+
} else {
|
|
1646
|
+
els.decisionPhase.textContent = 'READY';
|
|
1647
|
+
els.decisionPhase.className = 'decision-phase settled';
|
|
1648
|
+
els.decisionPlayer.textContent = 'Waiting for first decision';
|
|
1649
|
+
els.decisionModel.textContent = 'Configured models are ready';
|
|
1650
|
+
els.decisionClock.textContent = '—';
|
|
1651
|
+
setBankClock('Idle');
|
|
1652
|
+
setDecisionContext({ hand: '—', street: '—', position: '—', options: '—', label: 'Decision stream', hint: 'Not started' });
|
|
1653
|
+
els.legalActions.innerHTML = '<span class="action-chip">Start the tournament to stream model actions here.</span>';
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
function renderFeed(s) {
|
|
1657
|
+
|
|
1658
|
+
const events = (s?.events || []).filter(e => e.type === 'DECISION').slice(-12).reverse();
|
|
1659
|
+
const explanations = new Map((s?.events || []).filter(e => e.type === 'SPECTATOR_EXPLANATION').map(e => [e.decisionId, e.text]));
|
|
1660
|
+
els.decisionFeed.innerHTML = events.length ? events.map(e => {
|
|
1661
|
+
const infra = [];
|
|
1662
|
+
if (e.protocolFallback) infra.push(e.protocolFallback);
|
|
1663
|
+
if (e.decisionMeta?.retryCount) infra.push(`retry ${e.decisionMeta.retryCount}`);
|
|
1664
|
+
if (e.errorCategory) infra.push(e.errorCategory.replace('_', ' '));
|
|
1665
|
+
if (e.forced) infra.push('AUTO FALLBACK');
|
|
1666
|
+
const telemetry = decisionTelemetryHtml(e);
|
|
1667
|
+
const reason = explanations.get(e.decisionId) || e.publicReason || (e.decisionMeta?.family ? 'Typed decision (no text rationale)' : 'No public rationale');
|
|
1668
|
+
return `<button type="button" class="decision-item decision-history-item ${e.error ? 'error' : ''}" data-decision-id="${escapeHtml(e.id)}" aria-label="Replay ${escapeHtml(displayModelName(e.configuredModel || e.resolvedModel || ''))} decision"><div class="decision-item-head"><strong title="${escapeHtml(e.configuredModel || e.resolvedModel || '')}">${escapeHtml(displayModelName(e.configuredModel || e.resolvedModel || ''))}</strong><span class="decision-item-action">${escapeHtml(e.action?.description || '—')}</span></div><div class="decision-reason">${escapeHtml(reason)}</div>${telemetry ? `<div class="decision-item-telemetry">${telemetry}</div>` : ''}<div class="decision-meta mono">${escapeHtml(e.connection || '')} · ${escapeHtml(shortModel(e.resolvedModel || e.configuredModel || ''))} · ${e.primaryDecisionLatencyMs || e.latencyMs || 0}ms${infra.length ? ` · ${escapeHtml(infra.join(' · '))}` : ''}${e.error ? ` · ${escapeHtml(e.error)}` : ''}<span class="decision-replay-hint">View hand ↗</span></div></button>`;
|
|
1669
|
+
}).join('') : '<div class="empty-state">No decisions yet.</div>';
|
|
1670
|
+
}
|
|
1671
|
+
function renderEvents(s) {
|
|
1672
|
+
const events = (s?.events || []).slice(-180).reverse();
|
|
1673
|
+
els.eventLog.innerHTML = events.map(e => {
|
|
1674
|
+
let detail = '';
|
|
1675
|
+
if (e.type === 'DECISION') detail = `${displayModelName(e.configuredModel || e.resolvedModel || e.playerName)} → ${e.action?.description}${e.error ? ` (${e.error})` : ''}`;
|
|
1676
|
+
else if (e.type === 'HAND_START') detail = `#${e.handNumber} · ${e.smallBlind}/${e.bigBlind}`;
|
|
1677
|
+
else if (e.type === 'HAND_END') detail = `#${e.handNumber}`;
|
|
1678
|
+
else if (e.type === 'ELIMINATION') detail = `${replayDisplayName(e.playerId, e.playerName)} · place ${e.place}`;
|
|
1679
|
+
else if (e.type === 'BLINDS_UP') detail = `${e.smallBlind}/${e.bigBlind}`;
|
|
1680
|
+
else if (e.type === 'TOURNAMENT_END') detail = e.winner?.model ? displayModelName(e.winner.model) : replayDisplayName(e.winner?.playerId, e.winner?.playerName);
|
|
1681
|
+
else if (e.error) detail = e.error;
|
|
1682
|
+
const attrs = e.type === 'DECISION' ? ` role="button" tabindex="0" data-decision-id="${escapeHtml(e.id)}" class="event-row replayable-event"` : ` class="event-row"`;
|
|
1683
|
+
return `<div${attrs}><span class="event-type">${escapeHtml(e.type)}</span> <span class="muted">${new Date(e.at).toLocaleTimeString()}</span><br>${escapeHtml(detail)}${e.type === 'DECISION' ? '<span class="event-replay-hint">View hand ↗</span>' : ''}</div>`;
|
|
1684
|
+
}).join('') || '<div class="empty-state">No events yet.</div>';
|
|
1685
|
+
}
|
|
1686
|
+
function renderStats(s) {
|
|
1687
|
+
if (!s?.config) { els.statsGrid.innerHTML = '<div class="empty-state">Statistics will appear after the tournament starts.</div>'; return; }
|
|
1688
|
+
const publicById = new Map((s.publicPlayerStats ?? []).map(row => [row.playerId, row]));
|
|
1689
|
+
els.statsGrid.innerHTML = s.config.players.map(p => {
|
|
1690
|
+
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;
|
|
1691
|
+
const pc = value => `${Math.round(Number(value || 0) * 100)}%`;
|
|
1692
|
+
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>
|
|
1693
|
+
<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>
|
|
1694
|
+
<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>`;
|
|
1695
|
+
}).join('');
|
|
1696
|
+
}
|
|
1697
|
+
function tableRenderSignature(s) {
|
|
1698
|
+
const running = ['RUNNING', 'PAUSED'].includes(s?.status);
|
|
1699
|
+
if (lobbyVisible && !running) {
|
|
1700
|
+
return JSON.stringify(['lobby', seatAssignments.map(p => p ? [p.name, p.model, p.protocol, p.connectionId, p.provider] : null)]);
|
|
1701
|
+
}
|
|
1702
|
+
const t = s?.table;
|
|
1703
|
+
if (!t) return JSON.stringify(['empty', s?.status || 'IDLE']);
|
|
1704
|
+
return JSON.stringify([
|
|
1705
|
+
s?.status, s?.winner?.playerId || null, t.handNumber, t.street, t.pot, t.smallBlind, t.bigBlind, t.ante, t.blindLevel, t.actionTo,
|
|
1706
|
+
t.board || [], (s?.eliminations || []).map(e => e.playerId),
|
|
1707
|
+
(t.players || []).filter(Boolean).map(p => [p.id, p.stack, p.stackBB, p.status, p.position, p.currentBet, p.cards || [], s?.stats?.[p.id]?.lastAction || ''])
|
|
1708
|
+
]);
|
|
1709
|
+
}
|
|
1710
|
+
function statusRenderSignature(s) {
|
|
1711
|
+
return JSON.stringify([s?.status || 'IDLE', lobbyVisible, seatAssignments.filter(Boolean).length, s?.table?.handNumber, s?.table?.blindLevel, s?.table?.playersRemaining, s?.table?.startingPlayers]);
|
|
1712
|
+
}
|
|
1713
|
+
function decisionRenderSignature(s) {
|
|
1714
|
+
const d = s?.currentDecision;
|
|
1715
|
+
const last = latestDecisionEvent(s);
|
|
1716
|
+
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]);
|
|
1717
|
+
}
|
|
1718
|
+
function feedRenderSignature(s) { return (s?.events || []).filter(e => e.type === 'DECISION').slice(-12).map(e => e.id).join('|'); }
|
|
1719
|
+
function eventsRenderSignature(s) { const ev = s?.events || []; return `${ev.length}:${ev.at(-1)?.id || ''}`; }
|
|
1720
|
+
function statsRenderSignature(s) {
|
|
1721
|
+
if (!s?.config) return 'none';
|
|
1722
|
+
return JSON.stringify([(s.config.players || []).map(p => p.id), s.stats, s.publicPlayerStats, (s.table?.players || []).filter(Boolean).map(p => [p.id,p.stack])]);
|
|
1723
|
+
}
|
|
1724
|
+
function render(s, { force = false } = {}) {
|
|
1725
|
+
currentState = s;
|
|
1726
|
+
const statusSig = statusRenderSignature(s);
|
|
1727
|
+
if (force || renderMemo.status !== statusSig) { renderMemo.status = statusSig; renderStatus(s); }
|
|
1728
|
+
const tableSig = tableRenderSignature(s);
|
|
1729
|
+
if (force || renderMemo.table !== tableSig) { renderMemo.table = tableSig; renderTable(s); }
|
|
1730
|
+
const decisionSig = decisionRenderSignature(s);
|
|
1731
|
+
if (force || renderMemo.decision !== decisionSig) { renderMemo.decision = decisionSig; renderDecision(s); }
|
|
1732
|
+
if (activeInspectorTab === 'live') {
|
|
1733
|
+
const sig = feedRenderSignature(s);
|
|
1734
|
+
if (force || renderMemo.feed !== sig) { renderMemo.feed = sig; renderFeed(s); }
|
|
1735
|
+
} else if (activeInspectorTab === 'log') {
|
|
1736
|
+
const sig = eventsRenderSignature(s);
|
|
1737
|
+
if (force || renderMemo.events !== sig) { renderMemo.events = sig; renderEvents(s); }
|
|
1738
|
+
} else if (activeInspectorTab === 'stats') {
|
|
1739
|
+
const sig = statsRenderSignature(s);
|
|
1740
|
+
if (force || renderMemo.stats !== sig) { renderMemo.stats = sig; renderStats(s); }
|
|
1741
|
+
}
|
|
1742
|
+
if (s?.status === 'FINISHED' && tableRecording && !tableRecording.stopping) setTimeout(() => stopTableRecording(), 900);
|
|
1743
|
+
if (!(lobbyVisible && !['RUNNING', 'PAUSED'].includes(s?.status))) processVisualEffects(s);
|
|
1744
|
+
}
|
|
1745
|
+
function openSetup({ preserveError = false } = {}) { if (!preserveError) els.setupError.classList.add('hidden'); if (!els.setupDialog.open) els.setupDialog.showModal(); }
|
|
1746
|
+
function cloneJson(value) { return JSON.parse(JSON.stringify(value)); }
|
|
1747
|
+
function sanityAgents() {
|
|
1748
|
+
const connections = readConnections();
|
|
1749
|
+
return readSeatPlayers().map(player => ({ ...player, connection: connections.find(c => c.id === player.connectionId) })).filter(row => row.connection);
|
|
1750
|
+
}
|
|
1751
|
+
function sanityProtocolLabel(agent) {
|
|
1752
|
+
const protocol = effectiveProtocol(agent, agent.connection);
|
|
1753
|
+
return protocol === 'jev_decisions' ? 'Jev Decisions' : protocol === 'jev_native' ? 'Jev native' : protocol.replace('_', ' ');
|
|
1754
|
+
}
|
|
1755
|
+
function renderSanityParticipants(agents = sanityAgents()) {
|
|
1756
|
+
if (!els.testsParticipants) return;
|
|
1757
|
+
if (!agents.length) {
|
|
1758
|
+
els.testsParticipants.innerHTML = '<div class="tests-no-models">No configured seats yet. Close this window and click a seat to add a model.</div>';
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
els.testsParticipants.innerHTML = agents.map((agent, index) => `<div class="tests-model-chip">
|
|
1762
|
+
<span class="tests-model-index">${index + 1}</span>
|
|
1763
|
+
<span class="tests-model-copy"><strong>${escapeHtml(displayModelName(agent.model))}</strong><small title="${escapeHtml(agent.model)}">${escapeHtml(shortModel(agent.model))}</small></span>
|
|
1764
|
+
<span class="tests-model-protocol">${escapeHtml(sanityProtocolLabel(agent))}</span>
|
|
1765
|
+
</div>`).join('');
|
|
1766
|
+
}
|
|
1767
|
+
function updateSanityProgress(agents = sanityAgents()) {
|
|
1768
|
+
const total = DECISION_SANITY_SCENARIOS.length * agents.length;
|
|
1769
|
+
const completed = sanityResults.filter(r => !r.running).length;
|
|
1770
|
+
if (els.testsProgressLabel) els.testsProgressLabel.textContent = `${completed} / ${total} decisions`;
|
|
1771
|
+
if (els.testsProgressFill) els.testsProgressFill.style.width = `${total ? (completed / total) * 100 : 0}%`;
|
|
1772
|
+
}
|
|
1773
|
+
function sanityModelSummary(agent) {
|
|
1774
|
+
const rows = sanityResults.filter(r => r.agentId === agent.id && !r.running);
|
|
1775
|
+
const pass = rows.filter(r => r.pass).length;
|
|
1776
|
+
const errors = rows.filter(r => r.error).length;
|
|
1777
|
+
const miss = rows.length - pass - errors;
|
|
1778
|
+
const avg = rows.length ? Math.round(rows.reduce((sum, r) => sum + Number(r.latencyMs || 0), 0) / rows.length) : 0;
|
|
1779
|
+
return { rows, pass, miss, errors, avg };
|
|
1780
|
+
}
|
|
1781
|
+
function renderSanityResults() {
|
|
1782
|
+
if (!els.testsResults) return;
|
|
1783
|
+
const agents = sanityAgents();
|
|
1784
|
+
renderSanityParticipants(agents);
|
|
1785
|
+
updateSanityProgress(agents);
|
|
1786
|
+
|
|
1787
|
+
if (!sanityResults.length) {
|
|
1788
|
+
const catalog = DECISION_SANITY_SCENARIOS.map((scenario, index) => `<div class="tests-catalog-row">
|
|
1789
|
+
<span class="tests-catalog-no">${String(index + 1).padStart(2, '0')}</span>
|
|
1790
|
+
<span class="tests-catalog-main"><strong>${escapeHtml(scenario.title)}</strong><small>${escapeHtml(scenario.category)} · expected ${escapeHtml(scenario.expectedTypes.join(' / '))}</small></span>
|
|
1791
|
+
</div>`).join('');
|
|
1792
|
+
els.testsResults.innerHTML = `<div class="tests-empty-explainer"><strong>What will run?</strong><span>${DECISION_SANITY_SCENARIOS.length} deterministic spots × ${agents.length || 0} seated model${agents.length === 1 ? '' : 's'}. Each cell below is one real model request.</span></div><div class="tests-catalog">${catalog}</div>`;
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
const summaries = agents.map(agent => {
|
|
1797
|
+
const m = sanityModelSummary(agent);
|
|
1798
|
+
const totalExpected = DECISION_SANITY_SCENARIOS.length;
|
|
1799
|
+
const pct = totalExpected ? Math.round((m.pass / totalExpected) * 100) : 0;
|
|
1800
|
+
return `<article class="tests-model-summary">
|
|
1801
|
+
<div class="tests-model-summary-head"><div><strong>${escapeHtml(displayModelName(agent.model))}</strong><small>${escapeHtml(shortModel(agent.model))}</small></div><b>${m.pass}/${totalExpected}</b></div>
|
|
1802
|
+
<div class="tests-score-bar"><i style="width:${pct}%"></i></div>
|
|
1803
|
+
<div class="tests-model-metrics"><span><b>${m.pass}</b> pass</span><span><b>${m.miss}</b> miss</span><span><b>${m.errors}</b> error</span><span><b>${m.avg || 0}ms</b> avg</span></div>
|
|
1804
|
+
</article>`;
|
|
1805
|
+
}).join('');
|
|
1806
|
+
|
|
1807
|
+
const byKey = new Map(sanityResults.map(r => [`${r.scenarioId}|${r.agentId}`, r]));
|
|
1808
|
+
const scenarios = DECISION_SANITY_SCENARIOS.map((scenario, index) => {
|
|
1809
|
+
const modelRows = agents.map(agent => {
|
|
1810
|
+
const r = byKey.get(`${scenario.id}|${agent.id}`);
|
|
1811
|
+
if (!r) return `<div class="tests-agent-result pending"><div class="tests-agent-result-head"><strong>${escapeHtml(displayModelName(agent.model))}</strong><span>PENDING</span></div><p>Waiting for this model.</p></div>`;
|
|
1812
|
+
if (r.running) return `<div class="tests-agent-result running"><div class="tests-agent-result-head"><strong>${escapeHtml(displayModelName(agent.model))}</strong><span><i class="test-state-dot"></i> RUNNING</span></div><p>Request in progress…</p></div>`;
|
|
1813
|
+
if (r.error) return `<div class="tests-agent-result error"><div class="tests-agent-result-head"><strong>${escapeHtml(displayModelName(agent.model))}</strong><span>ERROR</span></div><b>${r.latencyMs || 0} ms</b><p>${escapeHtml(r.error)}</p></div>`;
|
|
1814
|
+
return `<div class="tests-agent-result ${r.pass ? 'pass' : 'miss'}"><div class="tests-agent-result-head"><strong>${escapeHtml(displayModelName(agent.model))}</strong><span>${r.pass ? 'PASS' : 'MISS'}</span></div><div class="tests-agent-action"><b>${escapeHtml(r.actionDescription || r.actionType || '—')}</b><small>${r.latencyMs || 0} ms · ${escapeHtml(r.protocol || sanityProtocolLabel(agent))}</small></div>${r.publicReason ? `<p>${escapeHtml(r.publicReason)}</p>` : ''}</div>`;
|
|
1815
|
+
}).join('');
|
|
1816
|
+
return `<article class="tests-scenario-card">
|
|
1817
|
+
<header class="tests-scenario-head"><span class="tests-scenario-number">${String(index + 1).padStart(2, '0')}</span><div><small>${escapeHtml(scenario.category)}</small><strong>${escapeHtml(scenario.title)}</strong></div><span class="tests-expected">Expected: ${escapeHtml(scenario.expectedTypes.join(' / '))}</span></header>
|
|
1818
|
+
<p class="tests-scenario-note">${escapeHtml(scenario.note)}</p>
|
|
1819
|
+
<div class="tests-agent-results">${modelRows}</div>
|
|
1820
|
+
</article>`;
|
|
1821
|
+
}).join('');
|
|
1822
|
+
|
|
1823
|
+
const completed = sanityResults.filter(r => !r.running);
|
|
1824
|
+
const passed = completed.filter(r => r.pass).length;
|
|
1825
|
+
const errored = completed.filter(r => r.error).length;
|
|
1826
|
+
const missed = completed.length - passed - errored;
|
|
1827
|
+
els.testsResults.innerHTML = `<div class="tests-summary-line"><div><strong>Model summary</strong><span>Compare sanity pass rate, reliability and latency. Do not treat this as a GTO ranking.</span></div><div class="tests-summary-pills"><span><b>${passed}</b> pass</span><span><b>${missed}</b> miss</span><span><b>${errored}</b> error</span></div></div><div class="tests-model-summaries">${summaries}</div><div class="tests-scenarios" style="--test-model-count:${Math.max(1, agents.length)}">${scenarios}</div>`;
|
|
1828
|
+
}
|
|
1829
|
+
async function runDecisionSanitySuite() {
|
|
1830
|
+
if (sanityRunAbort) return;
|
|
1831
|
+
if (director && ['RUNNING','PAUSED'].includes(director.status)) {
|
|
1832
|
+
els.testsStatus.textContent = 'Stop or finish the tournament before running the suite.';
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
const agents = sanityAgents();
|
|
1836
|
+
renderSanityParticipants(agents);
|
|
1837
|
+
if (!agents.length) {
|
|
1838
|
+
els.testsStatus.textContent = 'No models to test. Configure at least one seat first.';
|
|
1839
|
+
updateSanityProgress(agents);
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
for (const agent of agents) {
|
|
1843
|
+
if (!agent.model || !agent.connection?.baseUrl || ((isOpenRouterConnection(agent.connection) || agent.connection.kind === 'typesafe') && !agent.connection?.apiKey)) {
|
|
1844
|
+
els.testsStatus.textContent = `Missing model or connection details for ${displayModelName(agent.model)}.`;
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
sanityRunAbort = new AbortController();
|
|
1849
|
+
sanityResults = [];
|
|
1850
|
+
els.runTestsBtn.disabled = true;
|
|
1851
|
+
els.runTestsBtn.textContent = 'Running…';
|
|
1852
|
+
els.clearTestsBtn.textContent = 'Stop run';
|
|
1853
|
+
els.clearTestsBtn.classList.add('danger');
|
|
1854
|
+
const total = DECISION_SANITY_SCENARIOS.length * agents.length;
|
|
1855
|
+
let ordinalRun = 0;
|
|
1856
|
+
els.testsStatus.textContent = `Running ${DECISION_SANITY_SCENARIOS.length} spots across ${agents.length} model${agents.length === 1 ? '' : 's'}, sequentially to reduce rate-limit noise.`;
|
|
1857
|
+
renderSanityResults();
|
|
1858
|
+
try {
|
|
1859
|
+
for (let scenarioIndex = 0; scenarioIndex < DECISION_SANITY_SCENARIOS.length; scenarioIndex++) {
|
|
1860
|
+
const scenario = DECISION_SANITY_SCENARIOS[scenarioIndex];
|
|
1861
|
+
for (const agent of agents) {
|
|
1862
|
+
if (sanityRunAbort.signal.aborted) throw new DOMException('Aborted', 'AbortError');
|
|
1863
|
+
ordinalRun++;
|
|
1864
|
+
els.testsStatus.textContent = `Request ${ordinalRun}/${total} · Spot ${scenarioIndex + 1}/${DECISION_SANITY_SCENARIOS.length} · ${displayModelName(agent.model)}`;
|
|
1865
|
+
const placeholder = { scenarioId:scenario.id, agentId:agent.id, agentName:agent.name, model:agent.model, running:true };
|
|
1866
|
+
sanityResults.push(placeholder); renderSanityResults();
|
|
1867
|
+
const decisionId = id(`sanity-${scenario.id}`);
|
|
1868
|
+
const legalActions = scenario.state.legalActions.map(a => ({ ...a }));
|
|
1869
|
+
const state = cloneJson(scenario.state);
|
|
1870
|
+
const started = performance.now();
|
|
1871
|
+
try {
|
|
1872
|
+
const result = await decide(agent, agent.connection, { state, legalActions, decisionId, timeoutMs: 45_000, abortSignal: sanityRunAbort.signal });
|
|
1873
|
+
Object.assign(placeholder, {
|
|
1874
|
+
running:false, pass:scenario.expectedTypes.includes(result.action?.type), actionType:result.action?.type ?? null,
|
|
1875
|
+
actionDescription:result.action?.description ?? null, publicReason:result.publicReason ?? '', latencyMs:Math.round(performance.now()-started),
|
|
1876
|
+
protocol:result.meta?.protocol ?? effectiveProtocol(agent, agent.connection), error:null,
|
|
1877
|
+
});
|
|
1878
|
+
} catch (err) {
|
|
1879
|
+
Object.assign(placeholder, { running:false, pass:false, error:summarizeError(err), latencyMs:Math.round(performance.now()-started) });
|
|
1880
|
+
}
|
|
1881
|
+
renderSanityResults();
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
const completed = sanityResults.filter(r => !r.running);
|
|
1885
|
+
els.testsStatus.textContent = `Complete · ${completed.filter(r=>r.pass).length}/${completed.length} decisions matched the sanity expectations.`;
|
|
1886
|
+
} catch (err) {
|
|
1887
|
+
if (err?.name === 'AbortError') {
|
|
1888
|
+
const done = sanityResults.filter(r => !r.running).length;
|
|
1889
|
+
sanityResults = sanityResults.filter(r => !r.running);
|
|
1890
|
+
els.testsStatus.textContent = `Stopped · ${done}/${total} decisions completed.`;
|
|
1891
|
+
} else els.testsStatus.textContent = `Suite stopped: ${summarizeError(err)}`;
|
|
1892
|
+
} finally {
|
|
1893
|
+
sanityRunAbort = null;
|
|
1894
|
+
els.runTestsBtn.disabled = false;
|
|
1895
|
+
els.runTestsBtn.textContent = 'Run all models';
|
|
1896
|
+
els.clearTestsBtn.textContent = 'Clear results';
|
|
1897
|
+
els.clearTestsBtn.classList.remove('danger');
|
|
1898
|
+
renderSanityResults();
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
function openTests() {
|
|
1902
|
+
const agents = sanityAgents();
|
|
1903
|
+
renderSanityParticipants(agents);
|
|
1904
|
+
renderSanityResults();
|
|
1905
|
+
if (!sanityResults.length) els.testsStatus.textContent = agents.length ? `Ready · ${DECISION_SANITY_SCENARIOS.length} spots × ${agents.length} model${agents.length === 1 ? '' : 's'} = ${DECISION_SANITY_SCENARIOS.length * agents.length} API decisions.` : 'Configure at least one seat to run the suite.';
|
|
1906
|
+
if (!els.testsDialog.open) els.testsDialog.showModal();
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
function recorderMimeType() {
|
|
1910
|
+
if (!globalThis.MediaRecorder) return '';
|
|
1911
|
+
for (const type of ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm']) {
|
|
1912
|
+
if (MediaRecorder.isTypeSupported?.(type)) return type;
|
|
1913
|
+
}
|
|
1914
|
+
return '';
|
|
1915
|
+
}
|
|
1916
|
+
function recordingVideoBitrate(track) {
|
|
1917
|
+
const settings = track?.getSettings?.() || {};
|
|
1918
|
+
const width = Math.max(640, Number(settings.width) || els.pokerTable?.clientWidth || 1280);
|
|
1919
|
+
const height = Math.max(360, Number(settings.height) || els.pokerTable?.clientHeight || 720);
|
|
1920
|
+
const fps = Math.min(60, Math.max(24, Number(settings.frameRate) || 30));
|
|
1921
|
+
return Math.round(clamp(width * height * fps * 0.08, 8_000_000, 24_000_000));
|
|
1922
|
+
}
|
|
1923
|
+
function setRecordButton(active, label = null) {
|
|
1924
|
+
if (!els.recordBtn) return;
|
|
1925
|
+
els.recordBtn.classList.toggle('active', active);
|
|
1926
|
+
els.recordBtn.setAttribute('aria-pressed', String(active));
|
|
1927
|
+
const icon = $('.action-icon', els.recordBtn), text = $('.action-label', els.recordBtn);
|
|
1928
|
+
if (icon) icon.textContent = active ? '■' : '●';
|
|
1929
|
+
if (text) text.textContent = label || (active ? 'Stop rec' : 'Record');
|
|
1930
|
+
els.recordBtn.title = active ? 'Stop table recording and save video' : 'Record only the poker table';
|
|
1931
|
+
}
|
|
1932
|
+
function saveRecordingBlob(blob) {
|
|
1933
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1934
|
+
const url = URL.createObjectURL(blob), a = document.createElement('a');
|
|
1935
|
+
a.href = url; a.download = `pokertools-arena-${stamp}.webm`; document.body.append(a); a.click(); a.remove();
|
|
1936
|
+
setTimeout(() => URL.revokeObjectURL(url), 4000);
|
|
1937
|
+
}
|
|
1938
|
+
async function restrictCaptureToTable(track) {
|
|
1939
|
+
if (globalThis.RestrictionTarget?.fromElement && typeof track.restrictTo === 'function') {
|
|
1940
|
+
const target = await RestrictionTarget.fromElement(els.pokerTable);
|
|
1941
|
+
await track.restrictTo(target);
|
|
1942
|
+
return true;
|
|
1943
|
+
}
|
|
1944
|
+
if (globalThis.CropTarget?.fromElement && typeof track.cropTo === 'function') {
|
|
1945
|
+
const target = await CropTarget.fromElement(els.pokerTable);
|
|
1946
|
+
await track.cropTo(target);
|
|
1947
|
+
return true;
|
|
1948
|
+
}
|
|
1949
|
+
return false;
|
|
1950
|
+
}
|
|
1951
|
+
async function startTableRecording() {
|
|
1952
|
+
if (tableRecording || !els.pokerTable) return;
|
|
1953
|
+
if (!navigator.mediaDevices?.getDisplayMedia || !globalThis.MediaRecorder) {
|
|
1954
|
+
showActionToast('Table recording is not supported in this browser', 'fold');
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1957
|
+
let stream = null;
|
|
1958
|
+
try {
|
|
1959
|
+
stream = await navigator.mediaDevices.getDisplayMedia({ video: { displaySurface: 'browser', frameRate: { ideal: 60, max: 60 } }, audio: false, preferCurrentTab: true });
|
|
1960
|
+
const track = stream.getVideoTracks()[0];
|
|
1961
|
+
if (!track) throw new Error('No video track was shared');
|
|
1962
|
+
try { await track.applyConstraints?.({ frameRate: { ideal: 60, max: 60 } }); } catch {}
|
|
1963
|
+
const restricted = await restrictCaptureToTable(track);
|
|
1964
|
+
if (!restricted) {
|
|
1965
|
+
track.stop();
|
|
1966
|
+
throw new Error('Table-only capture requires Chromium Region/Element Capture. Use a current Chrome/Edge build and share this tab.');
|
|
1967
|
+
}
|
|
1968
|
+
const chunks = [], mimeType = recorderMimeType();
|
|
1969
|
+
const videoBitsPerSecond = recordingVideoBitrate(track);
|
|
1970
|
+
const recorderOptions = mimeType ? { mimeType, videoBitsPerSecond } : { videoBitsPerSecond };
|
|
1971
|
+
const recorder = new MediaRecorder(stream, recorderOptions);
|
|
1972
|
+
tableRecording = { recorder, stream, chunks, stopping: false, videoBitsPerSecond };
|
|
1973
|
+
recorder.addEventListener('dataavailable', event => { if (event.data?.size) chunks.push(event.data); });
|
|
1974
|
+
recorder.addEventListener('stop', () => {
|
|
1975
|
+
const type = recorder.mimeType || mimeType || 'video/webm';
|
|
1976
|
+
const blob = new Blob(chunks, { type });
|
|
1977
|
+
stream.getTracks().forEach(t => t.stop());
|
|
1978
|
+
tableRecording = null;
|
|
1979
|
+
setRecordButton(false);
|
|
1980
|
+
if (blob.size) saveRecordingBlob(blob);
|
|
1981
|
+
}, { once: true });
|
|
1982
|
+
track.addEventListener('ended', () => { if (tableRecording && recorder.state !== 'inactive') recorder.stop(); }, { once: true });
|
|
1983
|
+
recorder.start(1000);
|
|
1984
|
+
setRecordButton(true);
|
|
1985
|
+
showActionToast('Recording table', 'check');
|
|
1986
|
+
} catch (err) {
|
|
1987
|
+
stream?.getTracks?.().forEach(t => t.stop());
|
|
1988
|
+
tableRecording = null;
|
|
1989
|
+
setRecordButton(false);
|
|
1990
|
+
showActionToast(summarizeError(err), 'fold');
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
function stopTableRecording() {
|
|
1994
|
+
const active = tableRecording;
|
|
1995
|
+
if (!active || active.stopping) return;
|
|
1996
|
+
active.stopping = true;
|
|
1997
|
+
if (active.recorder.state !== 'inactive') active.recorder.stop();
|
|
1998
|
+
else active.stream.getTracks().forEach(t => t.stop());
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
function modelForPlayerId(playerId) {
|
|
2002
|
+
return currentState?.config?.players?.find?.(p => p.id === playerId)?.model || '';
|
|
2003
|
+
}
|
|
2004
|
+
function replayDisplayName(playerId, fallbackName = '') {
|
|
2005
|
+
const model = modelForPlayerId(playerId);
|
|
2006
|
+
return model ? displayModelName(model) : (/^Player\s+\d+$/i.test(String(fallbackName || '')) ? fallbackName : (fallbackName || 'Model'));
|
|
2007
|
+
}
|
|
2008
|
+
function replayHistoryRow(row) {
|
|
2009
|
+
const label = replayDisplayName(row?.playerId, row?.playerName);
|
|
2010
|
+
const action = row?.action?.description || row?.action?.type || 'Action';
|
|
2011
|
+
return `<div class="replay-history-row"><span><b>${escapeHtml(label)}</b>${row?.position ? `<small>${escapeHtml(row.position)}</small>` : ''}</span><strong>${escapeHtml(action)}</strong></div>`;
|
|
2012
|
+
}
|
|
2013
|
+
function openDecisionReplay(eventId) {
|
|
2014
|
+
const event = (currentState?.events || []).find(e => e.id === eventId && e.type === 'DECISION');
|
|
2015
|
+
if (!event || !els.replayDialog) return;
|
|
2016
|
+
currentReplayEvent = event;
|
|
2017
|
+
if (els.replayShareStatus) els.replayShareStatus.textContent = event.replay ? 'Creates a 1080×1350 PNG from this exact replay snapshot.' : 'This older event has limited replay data; the image will include the available decision details.';
|
|
2018
|
+
if (els.shareReplayImage) els.shareReplayImage.classList.toggle('hidden', !(navigator.share && navigator.canShare));
|
|
2019
|
+
const replay = event.replay;
|
|
2020
|
+
const modelName = displayModelName(event.configuredModel || event.resolvedModel || event.playerName || 'Model');
|
|
2021
|
+
els.replayTitle.textContent = `${modelName} decision`;
|
|
2022
|
+
els.replayBadge.textContent = `HAND ${event.handNumber || replay?.handNumber || '—'} · ${event.street || replay?.street || '—'}`;
|
|
2023
|
+
els.replaySubtitle.textContent = replay ? 'Exact decision snapshot captured immediately before the model acted.' : 'This older decision does not contain a replay snapshot.';
|
|
2024
|
+
els.replayAction.textContent = event.action?.description || event.action?.type || '—';
|
|
2025
|
+
els.replayReason.textContent = event.publicReason || 'No public rationale was returned.';
|
|
2026
|
+
|
|
2027
|
+
if (!replay) {
|
|
2028
|
+
els.replayOpponents.innerHTML = '';
|
|
2029
|
+
els.replayStreet.textContent = event.street || '—';
|
|
2030
|
+
els.replayBoard.innerHTML = Array.from({ length: 5 }, () => cardHtml(null, true, 'replay-card')).join('');
|
|
2031
|
+
els.replayPot.textContent = `POT ${fmt(event.potBefore || 0)}`;
|
|
2032
|
+
els.replayHero.innerHTML = `<div class="replay-unavailable">Snapshot unavailable for decisions recorded before replay capture was enabled.</div>`;
|
|
2033
|
+
els.replaySummary.innerHTML = `<span>Position <b>${escapeHtml(event.position || '—')}</b></span><span>Latency <b>${fmt(event.latencyMs || 0)} ms</b></span>`;
|
|
2034
|
+
els.replayHistory.innerHTML = '<div class="empty-state">No snapshot history stored.</div>';
|
|
2035
|
+
els.replayLegal.innerHTML = '<div class="empty-state">No legal-action snapshot stored.</div>';
|
|
2036
|
+
els.replayDialog.showModal();
|
|
2037
|
+
return;
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
const opponents = replay.opponents || [];
|
|
2041
|
+
els.replayOpponents.innerHTML = opponents.map(o => `<div class="replay-opponent"><div><b>${escapeHtml(replayDisplayName(o.id, o.name))}</b><small>${escapeHtml(o.position || `Seat ${o.seat || '—'}`)}</small></div><span>${fmt(o.stack)} <small>${Number(o.stackBB || 0).toFixed(1)} BB</small></span></div>`).join('');
|
|
2042
|
+
els.replayStreet.textContent = String(replay.street || '—').toUpperCase();
|
|
2043
|
+
els.replayBoard.innerHTML = Array.from({ length: 5 }, (_, i) => cardHtml(replay.board?.[i], !replay.board?.[i], 'replay-card')).join('');
|
|
2044
|
+
els.replayPot.textContent = `POT ${fmt(replay.pot || 0)}`;
|
|
2045
|
+
const hero = replay.hero || {};
|
|
2046
|
+
els.replayHero.innerHTML = `<div class="replay-hero-head"><div><b>${escapeHtml(modelName)}</b><small>${escapeHtml(hero.position || '—')}</small></div><span>${fmt(hero.stack)} <small>${Number(hero.stackBB || 0).toFixed(1)} BB</small></span></div><div class="replay-hero-cards">${[0,1].map(i => cardHtml(hero.cards?.[i], !hero.cards?.[i], 'replay-hole', true)).join('')}</div>`;
|
|
2047
|
+
const b = replay.betting || {};
|
|
2048
|
+
els.replaySummary.innerHTML = [
|
|
2049
|
+
['Stack', fmt(hero.stack)], ['Pot', fmt(replay.pot)], ['To call', fmt(b.toCall)], ['Effective call', fmt(b.effectiveCall)],
|
|
2050
|
+
['Blinds', `${fmt(replay.blinds?.smallBlind)} / ${fmt(replay.blinds?.bigBlind)}`], ['Position', hero.position || '—']
|
|
2051
|
+
].map(([k,v]) => `<span>${escapeHtml(k)} <b>${escapeHtml(v)}</b></span>`).join('');
|
|
2052
|
+
els.replayHistory.innerHTML = (replay.actionHistory || []).length ? replay.actionHistory.map(replayHistoryRow).join('') : '<div class="empty-state">No actions before this decision.</div>';
|
|
2053
|
+
els.replayLegal.innerHTML = (replay.legalActions || []).map(a => `<span class="action-chip ${a.id === event.action?.id ? 'selected' : ''}" title="${escapeHtml(a.id)}">${escapeHtml(a.description || a.type)}</span>`).join('') || '<div class="empty-state">No legal actions stored.</div>';
|
|
2054
|
+
// Spectator-only deterministic hand evaluation, clearly labelled as not model reasoning.
|
|
2055
|
+
const handLabel = replay.heroHand?.category || deterministicHandLabel(hero.cards, replay.board);
|
|
2056
|
+
if (handLabel) els.replaySummary.insertAdjacentHTML('beforeend', `<span>Deterministic hand evaluation <b>${escapeHtml(handLabel)}</b></span>`);
|
|
2057
|
+
// Show every action probability in the replay modal, not just the top four.
|
|
2058
|
+
els.replayReason.innerHTML = `<div class="replay-reason-text">${escapeHtml(event.publicReason || 'No public rationale was returned.')}</div>${decisionTelemetryHtml(event, { limit: 99 })}`;
|
|
2059
|
+
els.replayDialog.showModal();
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
|
|
2063
|
+
function replayShareFilename(event) {
|
|
2064
|
+
const model = displayModelName(event?.configuredModel || event?.resolvedModel || event?.playerName || 'model').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'model';
|
|
2065
|
+
return `pokertools-arena-${model}-hand-${event?.handNumber || 'x'}.png`;
|
|
2066
|
+
}
|
|
2067
|
+
function roundedRect(ctx, x, y, w, h, r, fill, stroke = null, lineWidth = 1) {
|
|
2068
|
+
const rr = Math.min(r, w / 2, h / 2);
|
|
2069
|
+
ctx.beginPath();
|
|
2070
|
+
ctx.moveTo(x + rr, y); ctx.arcTo(x + w, y, x + w, y + h, rr); ctx.arcTo(x + w, y + h, x, y + h, rr); ctx.arcTo(x, y + h, x, y, rr); ctx.arcTo(x, y, x + w, y, rr); ctx.closePath();
|
|
2071
|
+
if (fill) { ctx.fillStyle = fill; ctx.fill(); }
|
|
2072
|
+
if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = lineWidth; ctx.stroke(); }
|
|
2073
|
+
}
|
|
2074
|
+
function wrapCanvasText(ctx, text, x, y, maxWidth, lineHeight, maxLines = 8) {
|
|
2075
|
+
const words = String(text || '').split(/\s+/).filter(Boolean);
|
|
2076
|
+
let line = '', lines = [];
|
|
2077
|
+
for (const word of words) {
|
|
2078
|
+
const test = line ? `${line} ${word}` : word;
|
|
2079
|
+
if (ctx.measureText(test).width > maxWidth && line) { lines.push(line); line = word; }
|
|
2080
|
+
else line = test;
|
|
2081
|
+
if (lines.length >= maxLines) break;
|
|
2082
|
+
}
|
|
2083
|
+
if (line && lines.length < maxLines) lines.push(line);
|
|
2084
|
+
if (words.length && lines.length === maxLines) {
|
|
2085
|
+
let last = lines[maxLines - 1];
|
|
2086
|
+
while (ctx.measureText(`${last}…`).width > maxWidth && last.length > 2) last = last.slice(0, -1);
|
|
2087
|
+
lines[maxLines - 1] = `${last.replace(/[.,;:]?$/, '')}…`;
|
|
2088
|
+
}
|
|
2089
|
+
lines.forEach((l, i) => ctx.fillText(l, x, y + i * lineHeight));
|
|
2090
|
+
return y + lines.length * lineHeight;
|
|
2091
|
+
}
|
|
2092
|
+
function parsePokerCard(code) {
|
|
2093
|
+
if (!code || typeof code !== 'string' || code.length < 2) return null;
|
|
2094
|
+
const suitCode = code.at(-1).toLowerCase();
|
|
2095
|
+
const rank = code.slice(0, -1).toUpperCase();
|
|
2096
|
+
const suits = { h: ['♥', '#e95667'], d: ['♦', '#e95667'], c: ['♣', '#151a1f'], s: ['♠', '#151a1f'] };
|
|
2097
|
+
return suits[suitCode] ? { rank, suit: suits[suitCode][0], color: suits[suitCode][1] } : null;
|
|
2098
|
+
}
|
|
2099
|
+
function drawShareCard(ctx, code, x, y, w = 90, h = 126) {
|
|
2100
|
+
const card = parsePokerCard(code);
|
|
2101
|
+
roundedRect(ctx, x, y, w, h, 12, card ? '#f7f7f2' : '#15211c', card ? '#d8ddd8' : '#294138', 2);
|
|
2102
|
+
if (!card) {
|
|
2103
|
+
ctx.fillStyle = 'rgba(255,255,255,.12)'; ctx.font = '700 30px system-ui'; ctx.textAlign = 'center'; ctx.fillText('♠', x + w/2, y + h/2 + 10); ctx.textAlign = 'left'; return;
|
|
2104
|
+
}
|
|
2105
|
+
const rankSize = Math.max(13, Math.round(w * 0.34)), suitSize = Math.max(18, Math.round(w * 0.54));
|
|
2106
|
+
ctx.fillStyle = card.color; ctx.font = `900 ${rankSize}px system-ui`; ctx.fillText(card.rank, x + Math.max(5, w * .13), y + Math.max(16, h * .27));
|
|
2107
|
+
ctx.font = `900 ${suitSize}px system-ui`; ctx.textAlign = 'center'; ctx.fillText(card.suit, x + w/2, y + h/2 + suitSize * .28); ctx.textAlign = 'left';
|
|
2108
|
+
}
|
|
2109
|
+
function replayReasonText(event) {
|
|
2110
|
+
const explanation = (currentState?.events || []).find(e => e.type === 'SPECTATOR_EXPLANATION' && e.decisionId === event?.decisionId);
|
|
2111
|
+
if (explanation?.text) return explanation.text;
|
|
2112
|
+
if (event?.publicReason) return event.publicReason;
|
|
2113
|
+
const meta = event?.decisionMeta || {};
|
|
2114
|
+
const familyLabel = key => familyCriteria([String(key).toLowerCase()])[String(key).toLowerCase()] ?? String(key).toUpperCase();
|
|
2115
|
+
const sizeLabel = key => SIZE_LABELS[String(key).toLowerCase()] ?? String(key).toUpperCase();
|
|
2116
|
+
const pairs = (obj, label, limit) => Object.entries(obj ?? {}).filter(([, v]) => Number.isFinite(Number(v))).sort((a, b) => Number(b[1]) - Number(a[1])).slice(0, limit).map(([k, v]) => `${label(k)} ${Math.round(Number(v) * 100)}%`).join(' · ');
|
|
2117
|
+
const lines = [];
|
|
2118
|
+
if (meta.family?.probabilities) lines.push(`Family ${pairs(meta.family.probabilities, familyLabel, 3)}`);
|
|
2119
|
+
if (meta.sizing?.probabilities) lines.push(`Size ${pairs(meta.sizing.probabilities, sizeLabel, 3)}`);
|
|
2120
|
+
else if (meta.probabilities) {
|
|
2121
|
+
const familyMass = aggregateActionProbabilitiesByFamily(meta.probabilities, event?.legalActions, { labelResolver: id => ({ description: (event?.legalActions || []).find(a => a.id === id)?.description || id }) });
|
|
2122
|
+
lines.push(`Family (aggregated) ${pairs(familyMass, familyLabel, 4)}`);
|
|
2123
|
+
}
|
|
2124
|
+
if (Number.isFinite(Number(meta.family?.confidence))) lines.push(`Confidence ${confidenceBand(meta.family.confidence)} · ${compactPercent(meta.family.confidence)}`);
|
|
2125
|
+
return lines.length ? lines.join('\n') : (meta.family ? 'Typed decision; no text rationale.' : 'No public rationale was returned.');
|
|
2126
|
+
}
|
|
2127
|
+
function makeReplayShareCanvas(event) {
|
|
2128
|
+
const replay = event?.replay || {};
|
|
2129
|
+
const hero = replay.hero || {};
|
|
2130
|
+
const modelName = displayModelName(event?.configuredModel || event?.resolvedModel || event?.playerName || 'Model');
|
|
2131
|
+
const action = event?.action?.description || event?.action?.type || 'Decision';
|
|
2132
|
+
const canvas = document.createElement('canvas'); canvas.width = 1080; canvas.height = 1350;
|
|
2133
|
+
const ctx = canvas.getContext('2d');
|
|
2134
|
+
const bg = ctx.createLinearGradient(0, 0, 1080, 1350); bg.addColorStop(0, '#090c0e'); bg.addColorStop(1, '#101614'); ctx.fillStyle = bg; ctx.fillRect(0,0,1080,1350);
|
|
2135
|
+
// subtle felt glow
|
|
2136
|
+
const glow = ctx.createRadialGradient(540, 565, 30, 540, 565, 520); glow.addColorStop(0,'rgba(22,102,68,.36)'); glow.addColorStop(1,'rgba(4,20,14,0)'); ctx.fillStyle=glow; ctx.fillRect(0,190,1080,780);
|
|
2137
|
+
ctx.fillStyle='rgba(235,243,238,.55)'; ctx.font='800 23px system-ui'; ctx.fillText('♠ pokertools-arena',72,74);
|
|
2138
|
+
ctx.fillStyle='rgba(210,220,214,.34)'; ctx.font='700 14px ui-monospace,monospace'; ctx.fillText('MODEL BENCHMARK TABLE · DECISION REPLAY',72,104);
|
|
2139
|
+
ctx.fillStyle='#f4f7f5'; ctx.font='900 58px system-ui'; ctx.fillText(modelName,72,190);
|
|
2140
|
+
ctx.fillStyle='rgba(211,221,215,.56)'; ctx.font='700 20px ui-monospace,monospace'; ctx.fillText(`HAND ${event?.handNumber || replay.handNumber || '—'} · ${(event?.street || replay.street || '—').toUpperCase()} · ${event?.latencyMs || 0} ms`,72,228);
|
|
2141
|
+
roundedRect(ctx,72,260,936,82,24,'rgba(184,236,111,.08)','rgba(184,236,111,.28)',2);
|
|
2142
|
+
ctx.fillStyle='#dff5b9'; ctx.font='900 30px system-ui'; ctx.fillText(action,100,312);
|
|
2143
|
+
// table
|
|
2144
|
+
roundedRect(ctx,72,382,936,470,210,'#0b4d32','#1e2a25',16);
|
|
2145
|
+
const felt = ctx.createRadialGradient(540,595,40,540,595,430); felt.addColorStop(0,'#126342'); felt.addColorStop(1,'#093b28'); ctx.fillStyle=felt; ctx.beginPath(); ctx.ellipse(540,617,442,209,0,0,Math.PI*2); ctx.fill();
|
|
2146
|
+
ctx.fillStyle='rgba(231,242,235,.18)'; ctx.font='900 38px system-ui'; ctx.textAlign='center'; ctx.fillText('pokertools-arena',540,490); ctx.font='700 12px system-ui'; ctx.fillText('model benchmark table',540,516); ctx.textAlign='left';
|
|
2147
|
+
const board = replay.board || [];
|
|
2148
|
+
const bw=86,bh=120,gap=12,total=5*bw+4*gap,start=(1080-total)/2;
|
|
2149
|
+
for(let i=0;i<5;i++) drawShareCard(ctx,board[i],start+i*(bw+gap),545,bw,bh);
|
|
2150
|
+
ctx.fillStyle='rgba(236,243,239,.60)'; ctx.font='800 18px ui-monospace,monospace'; ctx.textAlign='center'; ctx.fillText(`POT ${fmt(replay.pot ?? event?.potBefore ?? 0)} · BLINDS ${fmt(replay.blinds?.smallBlind ?? 0)}/${fmt(replay.blinds?.bigBlind ?? 0)}`,540,700); ctx.textAlign='left';
|
|
2151
|
+
// hero
|
|
2152
|
+
roundedRect(ctx,350,727,380,105,18,'rgba(8,14,16,.82)','rgba(184,236,111,.23)',2);
|
|
2153
|
+
ctx.fillStyle='#f2f5f3'; ctx.font='850 23px system-ui'; ctx.fillText(modelName,375,762);
|
|
2154
|
+
ctx.fillStyle='rgba(213,224,217,.50)'; ctx.font='700 15px ui-monospace,monospace'; ctx.fillText(`${hero.position || event?.position || '—'} · ${fmt(hero.stack ?? 0)} chips`,375,789);
|
|
2155
|
+
drawShareCard(ctx,hero.cards?.[0],613,739,46,64); drawShareCard(ctx,hero.cards?.[1],668,739,46,64);
|
|
2156
|
+
// metrics
|
|
2157
|
+
const b=replay.betting||{};
|
|
2158
|
+
const metrics=[['STACK',fmt(hero.stack ?? 0)],['TO CALL',fmt(b.toCall ?? 0)],['POSITION',hero.position||event?.position||'—'],['LATENCY',`${event?.latencyMs||0} ms`]];
|
|
2159
|
+
metrics.forEach((m,i)=>{const x=72+i*234;roundedRect(ctx,x,892,216,78,15,'rgba(255,255,255,.028)','rgba(255,255,255,.06)',1);ctx.fillStyle='rgba(210,220,214,.38)';ctx.font='800 12px ui-monospace,monospace';ctx.fillText(m[0],x+16,918);ctx.fillStyle='#e8eeea';ctx.font='900 22px system-ui';ctx.fillText(m[1],x+16,950)});
|
|
2160
|
+
// rationale / typed telemetry
|
|
2161
|
+
roundedRect(ctx,72,1004,936,226,22,'rgba(255,255,255,.026)','rgba(255,255,255,.065)',1);
|
|
2162
|
+
const telemetryHeading = event?.decisionMeta?.family ? 'TYPED DECISION TELEMETRY' : 'PUBLIC RATIONALE';
|
|
2163
|
+
ctx.fillStyle='rgba(211,221,215,.42)'; ctx.font='800 13px ui-monospace,monospace'; ctx.fillText(telemetryHeading,98,1036);
|
|
2164
|
+
ctx.fillStyle='#d9e1dc'; ctx.font='600 25px system-ui'; wrapCanvasText(ctx, replayReasonText(event),98,1080,884,36,5);
|
|
2165
|
+
ctx.fillStyle='rgba(211,221,215,.32)'; ctx.font='700 14px ui-monospace,monospace'; ctx.fillText(`${event?.connection || ''} · ${event?.protocol || event?.requestedProtocol || ''}`,98,1202);
|
|
2166
|
+
ctx.fillStyle='rgba(216,225,219,.30)'; ctx.font='700 15px system-ui'; ctx.fillText('pokertools-arena.github.io',72,1300);
|
|
2167
|
+
ctx.textAlign='right'; ctx.fillText('AI poker decision snapshot',1008,1300); ctx.textAlign='left';
|
|
2168
|
+
return canvas;
|
|
2169
|
+
}
|
|
2170
|
+
function canvasToPngBlob(canvas) { return new Promise((resolve, reject) => canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('Could not encode PNG')), 'image/png', 1)); }
|
|
2171
|
+
async function createReplayShareBlob() {
|
|
2172
|
+
if (!currentReplayEvent) throw new Error('Open a decision replay first.');
|
|
2173
|
+
return canvasToPngBlob(makeReplayShareCanvas(currentReplayEvent));
|
|
2174
|
+
}
|
|
2175
|
+
async function saveReplayImage() {
|
|
2176
|
+
try {
|
|
2177
|
+
const blob = await createReplayShareBlob(); const url = URL.createObjectURL(blob); const a = document.createElement('a');
|
|
2178
|
+
a.href = url; a.download = replayShareFilename(currentReplayEvent); document.body.append(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(url),1000);
|
|
2179
|
+
if (els.replayShareStatus) els.replayShareStatus.textContent = 'PNG saved · 1080×1350';
|
|
2180
|
+
} catch (err) { if (els.replayShareStatus) els.replayShareStatus.textContent = `Could not save image: ${summarizeError(err)}`; }
|
|
2181
|
+
}
|
|
2182
|
+
async function copyReplayImage() {
|
|
2183
|
+
try {
|
|
2184
|
+
if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined') throw new Error('Image clipboard is not supported by this browser. Use Save PNG instead.');
|
|
2185
|
+
const blob = await createReplayShareBlob(); await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
|
|
2186
|
+
if (els.replayShareStatus) els.replayShareStatus.textContent = 'PNG copied to clipboard · ready to paste.';
|
|
2187
|
+
} catch (err) { if (els.replayShareStatus) els.replayShareStatus.textContent = summarizeError(err); }
|
|
2188
|
+
}
|
|
2189
|
+
async function shareReplayImage() {
|
|
2190
|
+
try {
|
|
2191
|
+
const blob = await createReplayShareBlob(); const file = new File([blob], replayShareFilename(currentReplayEvent), { type:'image/png' });
|
|
2192
|
+
if (!navigator.share || !navigator.canShare?.({ files:[file] })) throw new Error('Native image sharing is not supported here. Use Save PNG instead.');
|
|
2193
|
+
await navigator.share({ title:'pokertools-arena decision replay', text:`${displayModelName(currentReplayEvent?.configuredModel || currentReplayEvent?.resolvedModel || '')} · ${currentReplayEvent?.action?.description || 'Decision'}`, files:[file] });
|
|
2194
|
+
if (els.replayShareStatus) els.replayShareStatus.textContent = 'Share sheet opened.';
|
|
2195
|
+
} catch (err) { if (err?.name !== 'AbortError' && els.replayShareStatus) els.replayShareStatus.textContent = summarizeError(err); }
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
function downloadText(filename, text) {
|
|
2199
|
+
const blob = new Blob([text], { type: 'application/x-ndjson' }), url = URL.createObjectURL(blob), a = document.createElement('a');
|
|
2200
|
+
a.href = url; a.download = filename; document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
/* Floating tooltips ----------------------------------------------------------
|
|
2204
|
+
Every `[data-tip]` element (the small “i” buttons) gets a single floating
|
|
2205
|
+
tooltip instead of a CSS pseudo-element. Modal dialogs clip their contents
|
|
2206
|
+
(`overflow:hidden` plus a scrollable body), so a pseudo-element tooltip near
|
|
2207
|
+
an edge is cut off. This tooltip is `position:fixed` and is attached to the
|
|
2208
|
+
topmost open <dialog>, which both escapes the modal's overflow clip and keeps
|
|
2209
|
+
it in the dialog's top layer. It flips above/below the trigger and is clamped
|
|
2210
|
+
to the viewport so it is always fully visible. */
|
|
2211
|
+
const tooltipEl = document.createElement('div');
|
|
2212
|
+
tooltipEl.className = 'ui-tooltip';
|
|
2213
|
+
tooltipEl.id = 'ui-tooltip';
|
|
2214
|
+
tooltipEl.setAttribute('role', 'tooltip');
|
|
2215
|
+
let tooltipTarget = null;
|
|
2216
|
+
|
|
2217
|
+
function tooltipHost() {
|
|
2218
|
+
const dialogs = $$('dialog[open]');
|
|
2219
|
+
return dialogs.length ? dialogs[dialogs.length - 1] : document.body;
|
|
2220
|
+
}
|
|
2221
|
+
function positionTooltip(target) {
|
|
2222
|
+
const gap = 9, margin = 8;
|
|
2223
|
+
const rect = target.getBoundingClientRect();
|
|
2224
|
+
const tipRect = tooltipEl.getBoundingClientRect();
|
|
2225
|
+
let placement = 'bottom';
|
|
2226
|
+
let top = rect.bottom + gap;
|
|
2227
|
+
if (top + tipRect.height > window.innerHeight - margin && rect.top - gap - tipRect.height >= margin) {
|
|
2228
|
+
placement = 'top';
|
|
2229
|
+
top = rect.top - gap - tipRect.height;
|
|
2230
|
+
}
|
|
2231
|
+
top = clamp(top, margin, Math.max(margin, window.innerHeight - margin - tipRect.height));
|
|
2232
|
+
let left = rect.left + rect.width / 2 - tipRect.width / 2;
|
|
2233
|
+
left = clamp(left, margin, Math.max(margin, window.innerWidth - margin - tipRect.width));
|
|
2234
|
+
tooltipEl.style.top = `${Math.round(top)}px`;
|
|
2235
|
+
tooltipEl.style.left = `${Math.round(left)}px`;
|
|
2236
|
+
tooltipEl.dataset.placement = placement;
|
|
2237
|
+
tooltipEl.style.setProperty('--tip-arrow', `${Math.round(clamp(rect.left + rect.width / 2 - left, 12, Math.max(12, tipRect.width - 12)))}px`);
|
|
2238
|
+
}
|
|
2239
|
+
function showTooltip(target) {
|
|
2240
|
+
const text = target.dataset.tip;
|
|
2241
|
+
if (!text || target === tooltipTarget) return;
|
|
2242
|
+
hideTooltip();
|
|
2243
|
+
tooltipTarget = target;
|
|
2244
|
+
tooltipEl.textContent = text;
|
|
2245
|
+
const host = tooltipHost();
|
|
2246
|
+
if (tooltipEl.parentElement !== host) host.append(tooltipEl);
|
|
2247
|
+
tooltipEl.classList.add('is-visible');
|
|
2248
|
+
positionTooltip(target);
|
|
2249
|
+
target.setAttribute('aria-describedby', 'ui-tooltip');
|
|
2250
|
+
}
|
|
2251
|
+
function hideTooltip() {
|
|
2252
|
+
if (!tooltipTarget) return;
|
|
2253
|
+
tooltipTarget.removeAttribute('aria-describedby');
|
|
2254
|
+
tooltipTarget = null;
|
|
2255
|
+
tooltipEl.classList.remove('is-visible');
|
|
2256
|
+
}
|
|
2257
|
+
const TIP_SELECTOR = '[data-tip]';
|
|
2258
|
+
const tipTargetFrom = event => (event.target instanceof Element ? event.target.closest(TIP_SELECTOR) : null);
|
|
2259
|
+
document.addEventListener('pointerover', event => { const target = tipTargetFrom(event); if (target) showTooltip(target); }, true);
|
|
2260
|
+
document.addEventListener('pointerout', event => {
|
|
2261
|
+
const target = tipTargetFrom(event);
|
|
2262
|
+
if (target && target === tooltipTarget && !(event.relatedTarget instanceof Element && target.contains(event.relatedTarget))) hideTooltip();
|
|
2263
|
+
}, true);
|
|
2264
|
+
document.addEventListener('focusin', event => { const target = tipTargetFrom(event); if (target) showTooltip(target); }, true);
|
|
2265
|
+
document.addEventListener('focusout', event => { const target = tipTargetFrom(event); if (target && target === tooltipTarget) hideTooltip(); }, true);
|
|
2266
|
+
document.addEventListener('keydown', event => { if (event.key === 'Escape') hideTooltip(); }, true);
|
|
2267
|
+
window.addEventListener('scroll', () => { if (tooltipTarget) positionTooltip(tooltipTarget); }, true);
|
|
2268
|
+
window.addEventListener('resize', hideTooltip, { passive: true });
|
|
2269
|
+
|
|
2270
|
+
els.decisionFeed?.addEventListener('click', event => {
|
|
2271
|
+
const item = event.target.closest('[data-decision-id]');
|
|
2272
|
+
if (item) openDecisionReplay(item.dataset.decisionId);
|
|
2273
|
+
});
|
|
2274
|
+
els.eventLog?.addEventListener('click', event => {
|
|
2275
|
+
const item = event.target.closest('[data-decision-id]');
|
|
2276
|
+
if (item) openDecisionReplay(item.dataset.decisionId);
|
|
2277
|
+
});
|
|
2278
|
+
els.eventLog?.addEventListener('keydown', event => {
|
|
2279
|
+
if (!['Enter',' '].includes(event.key)) return;
|
|
2280
|
+
const item = event.target.closest('[data-decision-id]');
|
|
2281
|
+
if (item) { event.preventDefault(); openDecisionReplay(item.dataset.decisionId); }
|
|
2282
|
+
});
|
|
2283
|
+
els.closeReplay?.addEventListener('click', () => els.replayDialog.close());
|
|
2284
|
+
els.saveReplayImage?.addEventListener('click', saveReplayImage);
|
|
2285
|
+
els.copyReplayImage?.addEventListener('click', copyReplayImage);
|
|
2286
|
+
els.shareReplayImage?.addEventListener('click', shareReplayImage);
|
|
2287
|
+
|
|
2288
|
+
els.soundBtn.addEventListener('click', async () => {
|
|
2289
|
+
soundEnabled = !soundEnabled;
|
|
2290
|
+
const label = $('.action-label', els.soundBtn);
|
|
2291
|
+
if (label) label.textContent = soundEnabled ? 'Sound on' : 'Sound';
|
|
2292
|
+
els.soundBtn.setAttribute('aria-pressed', String(soundEnabled));
|
|
2293
|
+
els.soundBtn.classList.toggle('active', soundEnabled);
|
|
2294
|
+
if (soundEnabled) { try { await getAudioContext()?.resume(); } catch {} playTableSound('chip'); }
|
|
2295
|
+
});
|
|
2296
|
+
|
|
2297
|
+
els.recordBtn?.addEventListener('click', () => tableRecording ? stopTableRecording() : void startTableRecording());
|
|
2298
|
+
els.testsBtn.addEventListener('click', openTests);
|
|
2299
|
+
els.closeTests.addEventListener('click', () => els.testsDialog.close());
|
|
2300
|
+
els.runTestsBtn.addEventListener('click', runDecisionSanitySuite);
|
|
2301
|
+
els.clearTestsBtn.addEventListener('click', () => {
|
|
2302
|
+
if (sanityRunAbort) { sanityRunAbort.abort(); return; }
|
|
2303
|
+
sanityResults = []; els.testsStatus.textContent = 'Results cleared.'; renderSanityResults();
|
|
2304
|
+
});
|
|
2305
|
+
els.setupBtn.addEventListener('click', openSetup);
|
|
2306
|
+
els.closeSetup.addEventListener('click', () => els.setupDialog.close());
|
|
2307
|
+
els.addConnectionBtn.addEventListener('click', () => addConnectionRow({ name: `API ${els.connectionsEditor.children.length + 1}`, kind: 'openai', baseUrl: 'https://api.openai.com/v1' }));
|
|
2308
|
+
els.pauseBtn.addEventListener('click', () => { if (!director) return; currentState?.status === 'PAUSED' ? director.resume() : director.pause(); });
|
|
2309
|
+
els.stopBtn.addEventListener('click', () => director?.stop());
|
|
2310
|
+
els.exportBtn.addEventListener('click', () => { if (!director?.events?.length) return; downloadText(`${director.config?.id || 'pokertools-arena'}.jsonl`, director.exportJsonl()); });
|
|
2311
|
+
els.seatsBtn.addEventListener('click', () => {
|
|
2312
|
+
if (director && ['RUNNING', 'PAUSED'].includes(director.status)) return;
|
|
2313
|
+
lobbyVisible = true;
|
|
2314
|
+
render(currentState || { status: 'IDLE', events: [] });
|
|
2315
|
+
});
|
|
2316
|
+
els.seatsLayer.addEventListener('click', event => {
|
|
2317
|
+
const seat = event.target.closest('[data-lobby-seat]');
|
|
2318
|
+
if (!seat || !els.seatsLayer.contains(seat)) return;
|
|
2319
|
+
const seatIndex = Number(seat.dataset.lobbySeat);
|
|
2320
|
+
if (Number.isInteger(seatIndex)) openSeatEditor(seatIndex);
|
|
2321
|
+
});
|
|
2322
|
+
|
|
2323
|
+
els.closeSeat.addEventListener('click', () => els.seatDialog.close());
|
|
2324
|
+
els.cancelSeatBtn.addEventListener('click', () => els.seatDialog.close());
|
|
2325
|
+
for (const dialog of [els.setupDialog, els.seatDialog, els.testsDialog, els.replayDialog]) {
|
|
2326
|
+
dialog.addEventListener('click', event => { if (event.target === dialog) dialog.close(); });
|
|
2327
|
+
dialog.addEventListener('close', hideTooltip);
|
|
2328
|
+
}
|
|
2329
|
+
els.seatConnection.addEventListener('change', () => { applySeatProtocolRules(); void refreshSeatModelCatalog(); });
|
|
2330
|
+
els.seatModel.addEventListener('input', applySeatProtocolRules);
|
|
2331
|
+
els.seatProtocol.addEventListener('change', applySeatProtocolRules);
|
|
2332
|
+
els.refreshModelsBtn?.addEventListener('click', () => void refreshSeatModelCatalog({ force: true }));
|
|
2333
|
+
els.removeSeatBtn.addEventListener('click', () => {
|
|
2334
|
+
if (editingSeatIndex == null || (director && ['RUNNING', 'PAUSED'].includes(director.status))) return;
|
|
2335
|
+
seatAssignments[editingSeatIndex] = null;
|
|
2336
|
+
saveSeatAssignments();
|
|
2337
|
+
els.seatDialog.close();
|
|
2338
|
+
});
|
|
2339
|
+
els.seatForm.addEventListener('submit', event => {
|
|
2340
|
+
event.preventDefault();
|
|
2341
|
+
if (editingSeatIndex == null || (director && ['RUNNING', 'PAUSED'].includes(director.status))) return;
|
|
2342
|
+
els.seatError.classList.add('hidden');
|
|
2343
|
+
try {
|
|
2344
|
+
const draft = readSeatDraft();
|
|
2345
|
+
if (!draft.name) throw new Error('Player name is required');
|
|
2346
|
+
if (!draft.connectionId || !connectionById(draft.connectionId)) throw new Error('Choose an API connection');
|
|
2347
|
+
if (!draft.model) throw new Error('Model is required');
|
|
2348
|
+
const duplicate = seatAssignments.some((p, i) => i !== editingSeatIndex && p && p.name.trim().toLowerCase() === draft.name.toLowerCase());
|
|
2349
|
+
if (duplicate) throw new Error(`Another seat already uses the name “${draft.name}”`);
|
|
2350
|
+
seatAssignments[editingSeatIndex] = draft;
|
|
2351
|
+
saveSeatAssignments();
|
|
2352
|
+
els.seatDialog.close();
|
|
2353
|
+
} catch (err) {
|
|
2354
|
+
els.seatError.textContent = summarizeError(err);
|
|
2355
|
+
els.seatError.classList.remove('hidden');
|
|
2356
|
+
}
|
|
2357
|
+
});
|
|
2358
|
+
|
|
2359
|
+
els.setupForm.addEventListener('submit', event => {
|
|
2360
|
+
event.preventDefault();
|
|
2361
|
+
els.setupError.classList.add('hidden');
|
|
2362
|
+
try {
|
|
2363
|
+
const raw = collectSetupRaw(true);
|
|
2364
|
+
for (const connection of raw.connections) parseHeaders(connection.headers);
|
|
2365
|
+
saveSetupWithoutSecrets(raw);
|
|
2366
|
+
els.setupDialog.close();
|
|
2367
|
+
render(currentState || { status: 'IDLE', events: [] });
|
|
2368
|
+
} catch (err) {
|
|
2369
|
+
els.setupError.textContent = summarizeError(err);
|
|
2370
|
+
els.setupError.classList.remove('hidden');
|
|
2371
|
+
}
|
|
2372
|
+
});
|
|
2373
|
+
|
|
2374
|
+
async function startConfiguredTournament() {
|
|
2375
|
+
if (director && ['RUNNING', 'PAUSED'].includes(director.status)) return;
|
|
2376
|
+
const raw = collectSetupRaw(true);
|
|
2377
|
+
if (raw.players.length < 2) {
|
|
2378
|
+
lobbyVisible = true;
|
|
2379
|
+
render(currentState || { status: 'IDLE', events: [] });
|
|
2380
|
+
showActionToast('Seat at least 2 models', 'fold');
|
|
2381
|
+
return;
|
|
2382
|
+
}
|
|
2383
|
+
els.startTopBtn.disabled = true;
|
|
2384
|
+
const startLabel = $('.action-label', els.startTopBtn);
|
|
2385
|
+
if (startLabel) startLabel.textContent = 'Starting…';
|
|
2386
|
+
try {
|
|
2387
|
+
if (!engineModule) await loadPokerTools();
|
|
2388
|
+
saveSetupWithoutSecrets(raw);
|
|
2389
|
+
director = new TournamentDirector({ onUpdate: render });
|
|
2390
|
+
lobbyVisible = false;
|
|
2391
|
+
lastVisualState = null;
|
|
2392
|
+
lastProcessedEventId = null;
|
|
2393
|
+
await director.start(raw);
|
|
2394
|
+
} catch (err) {
|
|
2395
|
+
lobbyVisible = true;
|
|
2396
|
+
els.setupError.textContent = summarizeError(err);
|
|
2397
|
+
els.setupError.classList.remove('hidden');
|
|
2398
|
+
openSetup({ preserveError: true });
|
|
2399
|
+
render(currentState || { status: 'IDLE', events: [] });
|
|
2400
|
+
} finally {
|
|
2401
|
+
els.startTopBtn.disabled = false;
|
|
2402
|
+
if (startLabel) startLabel.textContent = 'Start';
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
els.startTopBtn.addEventListener('click', startConfiguredTournament);
|
|
2406
|
+
|
|
2407
|
+
$$('.tab').forEach(tab => tab.addEventListener('click', () => {
|
|
2408
|
+
activeInspectorTab = tab.dataset.tab || 'live';
|
|
2409
|
+
$$('.tab').forEach(t => t.classList.toggle('active', t === tab));
|
|
2410
|
+
$$('.tab-panel').forEach(p => p.classList.toggle('active', p.id === `tab-${tab.dataset.tab}`));
|
|
2411
|
+
if (!currentState) return;
|
|
2412
|
+
if (activeInspectorTab === 'live') { renderMemo.feed = feedRenderSignature(currentState); renderFeed(currentState); }
|
|
2413
|
+
else if (activeInspectorTab === 'log') { renderMemo.events = eventsRenderSignature(currentState); renderEvents(currentState); }
|
|
2414
|
+
else if (activeInspectorTab === 'stats') { renderMemo.stats = statsRenderSignature(currentState); renderStats(currentState); }
|
|
2415
|
+
}));
|
|
2416
|
+
|
|
2417
|
+
window.addEventListener('beforeunload', event => {
|
|
2418
|
+
if (tableRecording) tableRecording.stream?.getTracks?.().forEach(t => t.stop());
|
|
2419
|
+
if (director && ['RUNNING', 'PAUSED'].includes(director.status)) { event.preventDefault(); event.returnValue = ''; }
|
|
2420
|
+
});
|
|
2421
|
+
|
|
2422
|
+
restoreSetup(); render({ status: 'IDLE', events: [] });
|
|
2423
|
+
if (pendingAutostart) {
|
|
2424
|
+
// Opt-in launcher demo: seats and connections were injected from .env, so the
|
|
2425
|
+
// tournament can start without a click. A decision budget still hard-stops it.
|
|
2426
|
+
pendingAutostart = false;
|
|
2427
|
+
setTimeout(() => { void startConfiguredTournament(); }, 600);
|
|
2428
|
+
}
|
|
2429
|
+
loadPokerTools().catch(err => {
|
|
2430
|
+
els.setupError.textContent = `PokerTools browser build failed to load: ${summarizeError(err)}. Check your internet connection or CDN access.`;
|
|
2431
|
+
els.setupError.classList.remove('hidden'); openSetup({ preserveError: true });
|
|
2432
|
+
});
|
|
2433
|
+
if (!storageGet('pokertoolsArenaBrowserSeen')) storageSet('pokertoolsArenaBrowserSeen', '1');
|
|
2434
|
+
|
|
2435
|
+
let resizeTimer = null;
|
|
2436
|
+
function relayoutForViewport() {
|
|
2437
|
+
clearTimeout(resizeTimer);
|
|
2438
|
+
resizeTimer = setTimeout(() => {
|
|
2439
|
+
if (currentState && !lobbyVisible) {
|
|
2440
|
+
layoutTableSeats();
|
|
2441
|
+
renderMemo.table = '';
|
|
2442
|
+
render(currentState, { force: true });
|
|
2443
|
+
} else {
|
|
2444
|
+
layoutTableSeats({ lobby: true });
|
|
2445
|
+
renderMemo.table = '';
|
|
2446
|
+
renderLobbyTable();
|
|
2447
|
+
}
|
|
2448
|
+
}, 70);
|
|
2449
|
+
}
|
|
2450
|
+
window.addEventListener('resize', relayoutForViewport, { passive: true });
|
|
2451
|
+
window.addEventListener('orientationchange', relayoutForViewport, { passive: true });
|
|
2452
|
+
if ('ResizeObserver' in globalThis) {
|
|
2453
|
+
const tableResizeObserver = new ResizeObserver(() => {
|
|
2454
|
+
clearTimeout(resizeTimer);
|
|
2455
|
+
resizeTimer = setTimeout(() => layoutTableSeats({ lobby: lobbyVisible }), 50);
|
|
2456
|
+
});
|
|
2457
|
+
tableResizeObserver.observe(els.pokerTable);
|
|
2458
|
+
}
|