pokertools-arena 0.8.0 → 0.9.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 CHANGED
@@ -5,4 +5,4 @@ OPENAI_PLAYER1=google/gemma-4-26b-a4b-it
5
5
  OPENAI_PLAYER2=qwen/qwen3.8-flash
6
6
  OPENAI_PLAYER3=typesafe/jev-1.13
7
7
  # OPENAI_PLAYER4=...
8
- OPENAI_STARTING_STACK=10000
8
+ OPENAI_STARTING_STACK=3000
package/README.md CHANGED
@@ -17,7 +17,7 @@
17
17
  | **Package** | `npx pokertools-arena` |
18
18
  | **Runtime** | Node.js ≥ 24 for tooling; any modern browser for the app |
19
19
 
20
- > **Current release: 0.8.0.** The tab-audio recording release: table recordings now capture the current tab's own audio from `getDisplayMedia()` and rebuild video frames through a timestamp-preserving `MediaStreamTrackProcessor` → `VideoFrame` pipeline, so the exported WebM keeps audio and picture on the same capture clock at up to 2560×1440.
20
+ > **Current release: 0.9.0.** A responsive interface and configuration release: the table, inspector, logs, decision history, and dialogs now adapt cleanly from phones to wide displays. Tournament defaults are centralized, with 30 seconds per action and 3,000 starting chips.
21
21
 
22
22
  ---
23
23
 
@@ -107,6 +107,8 @@ The table **is** the player editor. A fresh launch shows empty seats around the
107
107
  3. Configure **Settings → Connections** (base URLs and keys) and **Settings → Tournament** (stack, blinds, clock, benchmark mode, architecture, representation).
108
108
  4. Press **Start**. Only configured seats enter the tournament, and seat editing locks while it runs.
109
109
 
110
+ Defaults and validation limits live in [`src/config/arena-config.js`](./src/config/arena-config.js). A host page may override any value before loading `app.js` by assigning a partial object to `window.__POKERTOOLS_ARENA_CONFIG__`; the arena deep-merges it with the documented defaults.
111
+
110
112
  ## `.env` bootstrap
111
113
 
112
114
  The launcher reads `.env` from the current working directory (falling back to the package root) and injects only supported fields into page memory. Disable with `--no-env`. A real user `.env` is never moved, printed or archived.
@@ -117,7 +119,7 @@ OPENAI_API_KEY="sk-or-v1-..."
117
119
  OPENAI_PLAYER1=google/gemma-4-26b-a4b-it
118
120
  OPENAI_PLAYER2=qwen/qwen3.8-flash
119
121
  OPENAI_PLAYER3=typesafe/jev-1.13
120
- OPENAI_STARTING_STACK=10000
122
+ OPENAI_STARTING_STACK=3000
121
123
  ```
122
124
 
123
125
  | Variable | Notes |
@@ -125,7 +127,7 @@ OPENAI_STARTING_STACK=10000
125
127
  | `OPENAI_BASE_URL` | Defaults to `https://api.openai.com/v1`; OpenRouter URLs are auto-detected. |
126
128
  | `OPENAI_API_KEY` | Optional for local/unauthenticated endpoints. |
127
129
  | `OPENAI_PLAYER1` … `OPENAI_PLAYER10` | Each value seats one model; Jev models auto-route to Decisions on OpenRouter. |
128
- | `OPENAI_STARTING_STACK` | Chip stack per seat (minimum 100, default 10000). |
130
+ | `OPENAI_STARTING_STACK` | Chip stack per seat (minimum 100, default 3000). |
129
131
 
130
132
  See [`.env.example`](./.env.example).
131
133
 
@@ -186,6 +188,7 @@ pokertools-arena/
186
188
  ├── bin/pokertools-arena.mjs local static launcher + .env bootstrap
187
189
  ├── src/ browser application source
188
190
  │ ├── index.html · app.js · styles.css
191
+ │ ├── config/arena-config.js defaults, limits, presets, host overrides
189
192
  │ ├── lib/decision-core.js canonical DOM-free decision core
190
193
  │ ├── benchmark/scenarios.js in-app sanity scenarios
191
194
  │ ├── env/arena-env.js .env placeholder injected by the launcher
@@ -79,7 +79,7 @@ function envBootstrap(values, overrides = {}) {
79
79
  };
80
80
  const autostart = truthy(flagOrEnv('OPENAI_AUTOSTART'));
81
81
  const maxDecisions = Math.max(0, Math.round(Number(flagOrEnv('OPENAI_MAX_DECISIONS') || 0) || 0));
82
- const startingStack = Math.max(100, Math.round(Number(flagOrEnv('OPENAI_STARTING_STACK') || 10_000) || 10_000));
82
+ const startingStack = Math.max(100, Math.round(Number(flagOrEnv('OPENAI_STARTING_STACK') || 3_000) || 3_000));
83
83
  return {
84
84
  source: '.env',
85
85
  settings: {
package/dist/app.js CHANGED
@@ -114139,6 +114139,89 @@ var DECISION_SANITY_SCENARIOS = Object.freeze([
114139
114139
  }
114140
114140
  ]);
114141
114141
 
114142
+ // src/config/arena-config.js
114143
+ var BASE_CONFIG = {
114144
+ table: {
114145
+ minPlayers: 2,
114146
+ maxPlayers: 10,
114147
+ startingStack: 3e3,
114148
+ smallBlind: 25,
114149
+ bigBlind: 50,
114150
+ ante: 5,
114151
+ handsPerLevel: 8,
114152
+ blindMultiplier: 1.5
114153
+ },
114154
+ timing: {
114155
+ actionSeconds: 30,
114156
+ timeBankSeconds: 30,
114157
+ lowTimeSeconds: 5,
114158
+ lowTimeFraction: 0.25,
114159
+ betweenActionsMs: 250,
114160
+ betweenHandsMs: 700
114161
+ },
114162
+ limits: {
114163
+ startingStack: { min: 100 },
114164
+ smallBlind: { min: 1 },
114165
+ bigBlind: { min: 2 },
114166
+ ante: { min: 0 },
114167
+ handsPerLevel: { min: 1 },
114168
+ blindMultiplier: { min: 1.1, max: 3, step: 0.1 },
114169
+ actionSeconds: { min: 1, max: 120 },
114170
+ timeBankSeconds: { min: 0, max: 600 },
114171
+ lowTimeSeconds: { min: 0, max: 600 },
114172
+ lowTimeFraction: { min: 0, max: 1, step: 0.05 },
114173
+ betweenActionsMs: { min: 0, max: 5e3 },
114174
+ betweenHandsMs: { min: 0, max: 1e4 }
114175
+ },
114176
+ connections: {
114177
+ defaultKind: "openai",
114178
+ presets: {
114179
+ openai: "https://api.openai.com/v1",
114180
+ openrouter: "https://openrouter.ai/api/v1",
114181
+ typesafe: "https://api.typesafe.ai/v1/systemone"
114182
+ }
114183
+ },
114184
+ history: {
114185
+ recentDecisions: 12,
114186
+ snapshotEvents: 300,
114187
+ persistedEvents: 100
114188
+ }
114189
+ };
114190
+ function mergeConfig(base, override) {
114191
+ if (!override || typeof override !== "object" || Array.isArray(override)) return { ...base };
114192
+ const merged = { ...base };
114193
+ for (const [key, value] of Object.entries(override)) {
114194
+ if (value && typeof value === "object" && !Array.isArray(value) && base[key] && typeof base[key] === "object") {
114195
+ merged[key] = mergeConfig(base[key], value);
114196
+ } else if (value !== void 0) {
114197
+ merged[key] = value;
114198
+ }
114199
+ }
114200
+ return merged;
114201
+ }
114202
+ var ARENA_CONFIG = Object.freeze(mergeConfig(BASE_CONFIG, globalThis.__POKERTOOLS_ARENA_CONFIG__));
114203
+ var TABLE_DEFAULTS = Object.freeze(ARENA_CONFIG.table);
114204
+ var TIMING_DEFAULTS = Object.freeze(ARENA_CONFIG.timing);
114205
+ var CONFIG_LIMITS = Object.freeze(ARENA_CONFIG.limits);
114206
+ var CONNECTION_PRESETS = Object.freeze(ARENA_CONFIG.connections.presets);
114207
+ var HISTORY_CONFIG = Object.freeze(ARENA_CONFIG.history);
114208
+ var FORM_DEFAULTS = Object.freeze({ ...TABLE_DEFAULTS, ...TIMING_DEFAULTS });
114209
+ function applyConfiguredFormDefaults(form) {
114210
+ for (const [name, value] of Object.entries(FORM_DEFAULTS)) {
114211
+ const input = form?.elements?.namedItem(name);
114212
+ if (!input) continue;
114213
+ input.value = String(value);
114214
+ const limits = CONFIG_LIMITS[name] || {};
114215
+ for (const attribute of ["min", "max", "step"]) {
114216
+ if (limits[attribute] != null) input.setAttribute(attribute, String(limits[attribute]));
114217
+ }
114218
+ }
114219
+ }
114220
+ function defaultConnections() {
114221
+ const kind = ARENA_CONFIG.connections.defaultKind;
114222
+ return [{ name: "API", kind, baseUrl: CONNECTION_PRESETS[kind], apiKey: "", headers: "" }];
114223
+ }
114224
+
114142
114225
  // src/lib/decision-core.js
114143
114226
  var import_evaluator = __toESM(require_dist2(), 1);
114144
114227
  var ACTION = Object.freeze({
@@ -115561,7 +115644,6 @@ var els = {
115561
115644
  handValue: $("#handValue"),
115562
115645
  levelValue: $("#levelValue"),
115563
115646
  streetLabel: $("#streetLabel"),
115564
- winnerBanner: $("#winnerBanner"),
115565
115647
  decisionPanelTitle: $("#decisionPanelTitle"),
115566
115648
  decisionEmpty: $("#decisionEmpty"),
115567
115649
  decisionCard: $("#decisionCard"),
@@ -115582,8 +115664,10 @@ var els = {
115582
115664
  decisionLabelPosition: $("#decisionLabelPosition"),
115583
115665
  decisionLabelOptions: $("#decisionLabelOptions"),
115584
115666
  decisionFeed: $("#decisionFeed"),
115667
+ decisionFeedSummary: $("#decisionFeedSummary"),
115585
115668
  eventLog: $("#eventLog"),
115586
115669
  logSummary: $("#logSummary"),
115670
+ logTabCount: $("#logTabCount"),
115587
115671
  logSearch: $("#logSearch"),
115588
115672
  logFilter: $("#logFilter"),
115589
115673
  logClear: $("#logClear"),
@@ -115653,7 +115737,7 @@ var audioContext = null;
115653
115737
  var soundMasterGain = null;
115654
115738
  var lastVisualState = null;
115655
115739
  var lastProcessedEventId = null;
115656
- var MAX_LOBBY_SEATS = 10;
115740
+ var MAX_LOBBY_SEATS = TABLE_DEFAULTS.maxPlayers;
115657
115741
  var seatAssignments = Array(MAX_LOBBY_SEATS).fill(null);
115658
115742
  var editingSeatIndex = null;
115659
115743
  var seatNameAuto = false;
@@ -115674,14 +115758,6 @@ var activeDecisionClockId = null;
115674
115758
  var tableRecording = null;
115675
115759
  var currentReplayEvent = null;
115676
115760
  var renderMemo = { status: "", table: "", decision: "", feed: "", events: "", stats: "" };
115677
- var TIMING_DEFAULTS = Object.freeze({
115678
- actionSeconds: 20,
115679
- timeBankSeconds: 30,
115680
- lowTimeSeconds: 5,
115681
- lowTimeFraction: 0.25,
115682
- betweenActionsMs: 250,
115683
- betweenHandsMs: 700
115684
- });
115685
115761
  function animationsAllowed() {
115686
115762
  return document.visibilityState === "visible" && !globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
115687
115763
  }
@@ -115932,10 +116008,6 @@ function processVisualEffects(s) {
115932
116008
  if (events.length) lastProcessedEventId = events.at(-1).id;
115933
116009
  lastVisualState = s;
115934
116010
  }
115935
- var defaultConnections = [
115936
- { name: "API", kind: "openai", baseUrl: "https://api.openai.com/v1", apiKey: "", headers: "" }
115937
- ];
115938
- var defaultPlayers = [];
115939
116011
  function injectedEnvironmentConfig() {
115940
116012
  const raw = globalThis.__POKERTOOLS_ENV__;
115941
116013
  if (!raw || typeof raw !== "object") return null;
@@ -115943,7 +116015,7 @@ function injectedEnvironmentConfig() {
115943
116015
  const players = Array.isArray(raw.players) ? raw.players.filter((p) => p && p.model) : [];
115944
116016
  if (!connections.length && !players.length) return null;
115945
116017
  const settings = raw.settings && typeof raw.settings === "object" ? {
115946
- startingStack: Math.max(100, Math.round(Number(raw.settings.startingStack) || 1e4)),
116018
+ startingStack: Math.max(CONFIG_LIMITS.startingStack.min, Math.round(Number(raw.settings.startingStack) || TABLE_DEFAULTS.startingStack)),
115947
116019
  autostart: Boolean(raw.settings.autostart),
115948
116020
  maxDecisions: Math.max(0, Math.round(Number(raw.settings.maxDecisions) || 0))
115949
116021
  } : null;
@@ -115953,7 +116025,7 @@ function injectedEnvironmentConfig() {
115953
116025
  id: String(c.id || `env-connection-${index + 1}`),
115954
116026
  name: String(c.name || "API"),
115955
116027
  kind: ["openai", "openrouter", "typesafe"].includes(c.kind) ? c.kind : "openai",
115956
- baseUrl: String(c.baseUrl || "https://api.openai.com/v1"),
116028
+ baseUrl: String(c.baseUrl || CONNECTION_PRESETS.openai),
115957
116029
  apiKey: String(c.apiKey || ""),
115958
116030
  headers: String(c.headers || "")
115959
116031
  })),
@@ -116198,7 +116270,7 @@ function buildBlindStructure(config) {
116198
116270
  let sb = Math.max(1, Math.round(config.smallBlind));
116199
116271
  let bb = Math.max(sb * 2, Math.round(config.bigBlind));
116200
116272
  let ante = Math.max(0, Math.round(config.ante || 0));
116201
- const multiplier = clamp(Number(config.blindMultiplier) || 1.5, 1.1, 3);
116273
+ const multiplier = clamp(Number(config.blindMultiplier) || TABLE_DEFAULTS.blindMultiplier, CONFIG_LIMITS.blindMultiplier.min, CONFIG_LIMITS.blindMultiplier.max);
116202
116274
  const safe = (value) => Number.isSafeInteger(value) && value >= 0;
116203
116275
  for (let i = 0; i < 60; i++) {
116204
116276
  if (!safe(sb) || !safe(bb) || !safe(ante)) break;
@@ -116215,7 +116287,7 @@ function buildBlindStructure(config) {
116215
116287
  return levels;
116216
116288
  }
116217
116289
  function normalizeConfig(input = {}) {
116218
- const players = Array.isArray(input.players) ? input.players.slice(0, 10) : [];
116290
+ const players = Array.isArray(input.players) ? input.players.slice(0, MAX_LOBBY_SEATS) : [];
116219
116291
  const connections = Array.isArray(input.connections) ? input.connections : [];
116220
116292
  if (players.length < 2) throw new Error("At least 2 players are required");
116221
116293
  if (!connections.length) throw new Error("Add at least one API connection");
@@ -116229,24 +116301,24 @@ function normalizeConfig(input = {}) {
116229
116301
  parseHeaders(c.headers);
116230
116302
  return { ...c, baseUrl: normalizeBaseUrl(c.baseUrl) };
116231
116303
  });
116232
- const smallBlind = Math.max(1, Math.round(Number(input.smallBlind) || 25));
116233
- const bigBlind = Math.max(2, Math.round(Number(input.bigBlind) || 50));
116304
+ const smallBlind = Math.max(CONFIG_LIMITS.smallBlind.min, Math.round(Number(input.smallBlind) || TABLE_DEFAULTS.smallBlind));
116305
+ const bigBlind = Math.max(CONFIG_LIMITS.bigBlind.min, Math.round(Number(input.bigBlind) || TABLE_DEFAULTS.bigBlind));
116234
116306
  if (bigBlind < smallBlind * 2) throw new Error("Big blind must be at least twice the small blind");
116235
116307
  return {
116236
116308
  id: id("tournament"),
116237
116309
  name: "pokertools-arena",
116238
- startingStack: Math.max(100, Math.round(Number(input.startingStack) || 1e4)),
116310
+ startingStack: Math.max(CONFIG_LIMITS.startingStack.min, Math.round(Number(input.startingStack) || TABLE_DEFAULTS.startingStack)),
116239
116311
  smallBlind,
116240
116312
  bigBlind,
116241
- ante: Math.max(0, Math.round(Number(input.ante) || 0)),
116242
- handsPerLevel: Math.max(1, Math.round(Number(input.handsPerLevel) || 8)),
116243
- blindMultiplier: clamp(Number(input.blindMultiplier) || 1.5, 1.1, 3),
116244
- actionSeconds: clamp(Number(input.actionSeconds) || TIMING_DEFAULTS.actionSeconds, 1, 120),
116245
- timeBankSeconds: clamp(Number(input.timeBankSeconds) || TIMING_DEFAULTS.timeBankSeconds, 0, 600),
116246
- lowTimeSeconds: clamp(Number.isFinite(Number(input.lowTimeSeconds)) ? Number(input.lowTimeSeconds) : TIMING_DEFAULTS.lowTimeSeconds, 0, 600),
116247
- lowTimeFraction: clamp(Number.isFinite(Number(input.lowTimeFraction)) ? Number(input.lowTimeFraction) : TIMING_DEFAULTS.lowTimeFraction, 0, 1),
116248
- betweenActionsMs: clamp(Number(input.betweenActionsMs) || TIMING_DEFAULTS.betweenActionsMs, 0, 5e3),
116249
- betweenHandsMs: clamp(Number(input.betweenHandsMs) || TIMING_DEFAULTS.betweenHandsMs, 0, 1e4),
116313
+ ante: Math.max(CONFIG_LIMITS.ante.min, Math.round(Number(input.ante) || TABLE_DEFAULTS.ante)),
116314
+ handsPerLevel: Math.max(CONFIG_LIMITS.handsPerLevel.min, Math.round(Number(input.handsPerLevel) || TABLE_DEFAULTS.handsPerLevel)),
116315
+ blindMultiplier: clamp(Number(input.blindMultiplier) || TABLE_DEFAULTS.blindMultiplier, CONFIG_LIMITS.blindMultiplier.min, CONFIG_LIMITS.blindMultiplier.max),
116316
+ actionSeconds: clamp(Number(input.actionSeconds) || TIMING_DEFAULTS.actionSeconds, CONFIG_LIMITS.actionSeconds.min, CONFIG_LIMITS.actionSeconds.max),
116317
+ timeBankSeconds: clamp(Number(input.timeBankSeconds) || TIMING_DEFAULTS.timeBankSeconds, CONFIG_LIMITS.timeBankSeconds.min, CONFIG_LIMITS.timeBankSeconds.max),
116318
+ lowTimeSeconds: clamp(Number.isFinite(Number(input.lowTimeSeconds)) ? Number(input.lowTimeSeconds) : TIMING_DEFAULTS.lowTimeSeconds, CONFIG_LIMITS.lowTimeSeconds.min, CONFIG_LIMITS.lowTimeSeconds.max),
116319
+ lowTimeFraction: clamp(Number.isFinite(Number(input.lowTimeFraction)) ? Number(input.lowTimeFraction) : TIMING_DEFAULTS.lowTimeFraction, CONFIG_LIMITS.lowTimeFraction.min, CONFIG_LIMITS.lowTimeFraction.max),
116320
+ betweenActionsMs: clamp(Number(input.betweenActionsMs) || TIMING_DEFAULTS.betweenActionsMs, CONFIG_LIMITS.betweenActionsMs.min, CONFIG_LIMITS.betweenActionsMs.max),
116321
+ betweenHandsMs: clamp(Number(input.betweenHandsMs) || TIMING_DEFAULTS.betweenHandsMs, CONFIG_LIMITS.betweenHandsMs.min, CONFIG_LIMITS.betweenHandsMs.max),
116250
116322
  connections: cleanConnections,
116251
116323
  benchmarkMode: input.benchmarkMode === BENCHMARK_MODES.RAW ? BENCHMARK_MODES.RAW : DEFAULT_BENCHMARK_MODE,
116252
116324
  decisionArchitecture: input.decisionArchitecture === DECISION_ARCHITECTURES.FLAT ? DECISION_ARCHITECTURES.FLAT : DEFAULT_DECISION_ARCHITECTURE,
@@ -116420,7 +116492,7 @@ var TournamentDirector = class {
116420
116492
  stats: this.stats,
116421
116493
  publicPlayerStats,
116422
116494
  timeBanks: this.timeBanks,
116423
- events: this.events.slice(-300)
116495
+ events: this.events.slice(-HISTORY_CONFIG.snapshotEvents)
116424
116496
  };
116425
116497
  }
116426
116498
  broadcast() {
@@ -116442,7 +116514,7 @@ var TournamentDirector = class {
116442
116514
  persistLightweight() {
116443
116515
  try {
116444
116516
  const state = this.snapshot();
116445
- const lightweight = { ...state, events: state.events.slice(-100), currentDecision: null };
116517
+ const lightweight = { ...state, events: state.events.slice(-HISTORY_CONFIG.persistedEvents), currentDecision: null };
116446
116518
  storageSet("pokertoolsArenaLastState", JSON.stringify(lightweight));
116447
116519
  } catch {
116448
116520
  }
@@ -116831,16 +116903,12 @@ function refreshSeatConnectionSelect(selected = null) {
116831
116903
  applySeatProtocolRules();
116832
116904
  }
116833
116905
  function connectionPresetUrl(kind) {
116834
- if (kind === "openrouter") return "https://openrouter.ai/api/v1";
116835
- if (kind === "typesafe") return "https://api.typesafe.ai/v1/systemone";
116836
- return "https://api.openai.com/v1";
116906
+ return CONNECTION_PRESETS[kind] || CONNECTION_PRESETS.openai;
116837
116907
  }
116838
116908
  function shouldReplacePresetUrl(value) {
116839
116909
  const normalized = normalizeBaseUrl(value);
116840
116910
  return !normalized || [
116841
- "https://api.openai.com/v1",
116842
- "https://openrouter.ai/api/v1",
116843
- "https://api.typesafe.ai/v1/systemone"
116911
+ ...Object.values(CONNECTION_PRESETS)
116844
116912
  ].includes(normalized);
116845
116913
  }
116846
116914
  function addConnectionRow(connection = {}) {
@@ -116864,9 +116932,9 @@ function addConnectionRow(connection = {}) {
116864
116932
  $("[data-field=kind]", row).addEventListener("change", () => {
116865
116933
  const kind = $("[data-field=kind]", row).value;
116866
116934
  const url = $("[data-field=baseUrl]", row);
116867
- if (kind === "typesafe" && (!url.value || url.value.includes("openrouter.ai"))) url.value = "https://api.typesafe.ai/v1/systemone";
116868
- if (kind === "openrouter" && (!url.value || url.value.includes("typesafe.ai"))) url.value = "https://openrouter.ai/api/v1";
116869
- if (kind === "openai" && (!url.value || url.value.includes("typesafe.ai"))) url.value = "https://api.openai.com/v1";
116935
+ if (kind === "typesafe" && (!url.value || url.value.includes("openrouter.ai"))) url.value = CONNECTION_PRESETS.typesafe;
116936
+ if (kind === "openrouter" && (!url.value || url.value.includes("typesafe.ai"))) url.value = CONNECTION_PRESETS.openrouter;
116937
+ if (kind === "openai" && (!url.value || url.value.includes("typesafe.ai"))) url.value = CONNECTION_PRESETS.openai;
116870
116938
  refreshSeatConnectionSelect();
116871
116939
  });
116872
116940
  $(".remove-connection", row).addEventListener("click", () => {
@@ -117089,7 +117157,7 @@ function saveSeatAssignments() {
117089
117157
  }
117090
117158
  function restoreSeatAssignments(rows = []) {
117091
117159
  seatAssignments = Array(MAX_LOBBY_SEATS).fill(null);
117092
- const sources = rows.length ? rows : defaultPlayers;
117160
+ const sources = rows;
117093
117161
  sources.slice(0, MAX_LOBBY_SEATS).forEach((player, index) => {
117094
117162
  const lobbySeat = clamp(Math.round(Number(player.lobbySeat ?? index)), 0, MAX_LOBBY_SEATS - 1);
117095
117163
  let connectionId = player.connectionId || "";
@@ -117159,13 +117227,14 @@ async function primeModelCapabilities() {
117159
117227
  }));
117160
117228
  }
117161
117229
  function restoreSetup() {
117230
+ applyConfiguredFormDefaults(els.setupForm);
117162
117231
  let saved = null;
117163
117232
  try {
117164
117233
  saved = JSON.parse(storageGet("pokertoolsArenaBrowserConfig"));
117165
117234
  } catch {
117166
117235
  }
117167
117236
  const injected = injectedEnvironmentConfig();
117168
- const conns = injected?.connections?.length ? injected.connections : saved?.connections?.length ? saved.connections : defaultConnections;
117237
+ const conns = injected?.connections?.length ? injected.connections : saved?.connections?.length ? saved.connections : defaultConnections();
117169
117238
  conns.forEach(addConnectionRow);
117170
117239
  restoreSeatAssignments(injected?.players?.length ? injected.players : saved?.players?.length ? saved.players : []);
117171
117240
  capabilityPrime = primeModelCapabilities();
@@ -117191,7 +117260,7 @@ function restoreSetup() {
117191
117260
  function renderLobbyIfVisible() {
117192
117261
  if (lobbyVisible && (!director || !["RUNNING", "PAUSED"].includes(director.status))) renderTable(currentState || { status: "IDLE", events: [] });
117193
117262
  }
117194
- var TABLE_MIN_PLAYERS = 2;
117263
+ var TABLE_MIN_PLAYERS = TABLE_DEFAULTS.minPlayers;
117195
117264
  function visualSeatAngle(index, count) {
117196
117265
  const safeCount = Math.max(TABLE_MIN_PLAYERS, Math.min(MAX_LOBBY_SEATS, Math.round(Number(count) || TABLE_MIN_PLAYERS)));
117197
117266
  const safeIndex = (Math.round(Number(index) || 0) % safeCount + safeCount) % safeCount;
@@ -117334,7 +117403,6 @@ function renderLobbyTable() {
117334
117403
  els.handValue.textContent = "\u2014";
117335
117404
  els.levelValue.textContent = "\u2014";
117336
117405
  els.streetLabel.textContent = "LOBBY";
117337
- els.winnerBanner.classList.add("hidden");
117338
117406
  }
117339
117407
  function renderStatus(s) {
117340
117408
  const status = s?.status || "IDLE";
@@ -117405,7 +117473,6 @@ function renderTable(s) {
117405
117473
  els.levelValue.title = `Level ${Number(table.blindLevel ?? 0) + 1}`;
117406
117474
  els.streetLabel.textContent = s.status === "FINISHED" && s.winner ? `WINNER \xB7 ${displayModelName(s.winner.model || "")}` : table.street || "\u2014";
117407
117475
  els.streetLabel.classList.toggle("winner-street", s.status === "FINISHED" && Boolean(s.winner));
117408
- els.winnerBanner.classList.add("hidden");
117409
117476
  }
117410
117477
  function clearTurnRings(keep = null) {
117411
117478
  for (const el of $$(".seat.turn-active")) if (el !== keep) el.classList.remove("turn-active");
@@ -117644,7 +117711,7 @@ function renderDecision(s) {
117644
117711
  }
117645
117712
  function renderFeed(s) {
117646
117713
  const feedArchive = eventArchive(s);
117647
- const events = feedArchive.filter((e) => e.type === "DECISION").slice(-12).reverse();
117714
+ const events = feedArchive.filter((e) => e.type === "DECISION").slice(-HISTORY_CONFIG.recentDecisions).reverse();
117648
117715
  const explanations = new Map(feedArchive.filter((e) => e.type === "SPECTATOR_EXPLANATION").map((e) => [e.decisionId, e.text]));
117649
117716
  els.decisionFeed.innerHTML = events.length ? events.map((e) => {
117650
117717
  const infra = [];
@@ -117656,6 +117723,7 @@ function renderFeed(s) {
117656
117723
  const reason = explanations.get(e.decisionId) || e.publicReason || (e.decisionMeta?.family ? "Typed decision (no text rationale)" : "No public rationale");
117657
117724
  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 || "\u2014")}</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 || "")} \xB7 ${escapeHtml(shortModel(e.resolvedModel || e.configuredModel || ""))} \xB7 ${e.primaryDecisionLatencyMs || e.latencyMs || 0}ms${infra.length ? ` \xB7 ${escapeHtml(infra.join(" \xB7 "))}` : ""}${e.error ? ` \xB7 ${escapeHtml(e.error)}` : ""}<span class="decision-replay-hint">View hand \u2197</span></div></button>`;
117658
117725
  }).join("") : '<div class="empty-state">No decisions yet.</div>';
117726
+ if (els.decisionFeedSummary) els.decisionFeedSummary.textContent = events.length ? `${events.length} most recent` : "Newest first";
117659
117727
  }
117660
117728
  function eventArchive(s = currentState) {
117661
117729
  if (director?.events?.length) return director.events;
@@ -117707,6 +117775,7 @@ function eventMatchesView(event) {
117707
117775
  function renderEvents(s) {
117708
117776
  const archive = eventArchive(s);
117709
117777
  const events = archive.filter(eventMatchesView).reverse();
117778
+ if (els.logTabCount) els.logTabCount.textContent = archive.length > 999 ? "999+" : String(archive.length);
117710
117779
  if (els.logSummary) {
117711
117780
  const suffix = events.length === archive.length ? "full run" : `${events.length} shown`;
117712
117781
  els.logSummary.textContent = `${archive.length.toLocaleString()} event${archive.length === 1 ? "" : "s"} \xB7 ${suffix}`;
@@ -117721,7 +117790,7 @@ function renderEvents(s) {
117721
117790
  const attrs = replayable ? ` type="button" data-decision-id="${escapeHtml(e.id)}" aria-label="Replay ${escapeHtml(detail || "decision")}"` : "";
117722
117791
  return `<${tag}${attrs} class="event-row event-${category}${e.error || e.type?.includes?.("ERROR") ? " has-error" : ""}">
117723
117792
  <span class="event-rail" aria-hidden="true"></span>
117724
- <span class="event-main"><span class="event-row-head"><span class="event-type">${escapeHtml(e.type)}</span><time class="event-time mono" datetime="${new Date(e.at).toISOString()}">${escapeHtml(time)}</time></span>${detail ? `<span class="event-detail">${escapeHtml(detail)}</span>` : ""}${meta ? `<span class="event-meta mono">${escapeHtml(meta)}</span>` : ""}</span>
117793
+ <span class="event-main"><span class="event-row-head"><span class="event-type">${escapeHtml(e.type.replaceAll("_", " "))}</span><time class="event-time mono" datetime="${new Date(e.at).toISOString()}">${escapeHtml(time)}</time></span>${detail ? `<span class="event-detail">${escapeHtml(detail)}</span>` : ""}${meta ? `<span class="event-meta mono">${escapeHtml(meta)}</span>` : ""}</span>
117725
117794
  ${replayable ? '<span class="event-open" aria-hidden="true">\u2197</span>' : ""}
117726
117795
  </${tag}>`;
117727
117796
  }).join("") || '<div class="empty-state log-empty">No events match these filters.</div>';
@@ -117784,6 +117853,10 @@ function statsRenderSignature(s) {
117784
117853
  }
117785
117854
  function render(s, { force = false } = {}) {
117786
117855
  currentState = s;
117856
+ if (els.logTabCount) {
117857
+ const eventCount = eventArchive(s).length;
117858
+ els.logTabCount.textContent = eventCount > 999 ? "999+" : String(eventCount);
117859
+ }
117787
117860
  const statusSig = statusRenderSignature(s);
117788
117861
  if (force || renderMemo.status !== statusSig) {
117789
117862
  renderMemo.status = statusSig;
@@ -118880,7 +118953,7 @@ els.clearTestsBtn.addEventListener("click", () => {
118880
118953
  });
118881
118954
  els.setupBtn.addEventListener("click", openSetup);
118882
118955
  els.closeSetup.addEventListener("click", () => els.setupDialog.close());
118883
- els.addConnectionBtn.addEventListener("click", () => addConnectionRow({ name: `API ${els.connectionsEditor.children.length + 1}`, kind: "openai", baseUrl: "https://api.openai.com/v1" }));
118956
+ els.addConnectionBtn.addEventListener("click", () => addConnectionRow({ name: `API ${els.connectionsEditor.children.length + 1}`, kind: ARENA_CONFIG.connections.defaultKind, baseUrl: connectionPresetUrl(ARENA_CONFIG.connections.defaultKind) }));
118884
118957
  els.pauseBtn.addEventListener("click", () => {
118885
118958
  if (!director) return;
118886
118959
  currentState?.status === "PAUSED" ? director.resume() : director.pause();
@@ -119084,7 +119157,6 @@ loadPokerTools().catch((err) => {
119084
119157
  els.setupError.classList.remove("hidden");
119085
119158
  openSetup({ preserveError: true });
119086
119159
  });
119087
- if (!storageGet("pokertoolsArenaBrowserSeen")) storageSet("pokertoolsArenaBrowserSeen", "1");
119088
119160
  var resizeTimer = null;
119089
119161
  function relayoutForViewport() {
119090
119162
  clearTimeout(resizeTimer);
package/dist/index.html CHANGED
@@ -53,7 +53,7 @@
53
53
  }
54
54
  </script>
55
55
 
56
- <link rel="stylesheet" href="./styles.css?v=0.6.6-tab-avsync-quality" />
56
+ <link rel="stylesheet" href="./styles.css?v=0.9.0" />
57
57
  </head>
58
58
  <body>
59
59
  <div class="app-shell">
@@ -63,7 +63,7 @@
63
63
  <div class="brandbar" aria-label="pokertools-arena">
64
64
  <div class="brand-mark">♠</div>
65
65
  <div class="brand-copy">
66
- <div class="brand-title">pokertools-arena</div>
66
+ <h1 class="brand-title">pokertools-arena</h1>
67
67
  </div>
68
68
  </div>
69
69
  <a id="repoLink" class="repo-link" href="https://github.com/pokertools-arena/pokertools-arena.github.io" target="_blank" rel="noopener noreferrer" title="View source on GitHub" aria-label="View source on GitHub"><svg class="repo-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg></a>
@@ -79,7 +79,7 @@
79
79
 
80
80
  <div class="tabs" role="tablist" aria-label="Tournament inspector">
81
81
  <button id="tabButton-live" class="tab active" data-tab="live" type="button" role="tab" aria-selected="true" aria-controls="tab-live">Live</button>
82
- <button id="tabButton-log" class="tab" data-tab="log" type="button" role="tab" aria-selected="false" aria-controls="tab-log" tabindex="-1">Log</button>
82
+ <button id="tabButton-log" class="tab" data-tab="log" type="button" role="tab" aria-selected="false" aria-controls="tab-log" tabindex="-1">Log <span id="logTabCount" class="tab-count">0</span></button>
83
83
  <button id="tabButton-stats" class="tab" data-tab="stats" type="button" role="tab" aria-selected="false" aria-controls="tab-stats" tabindex="-1">Stats</button>
84
84
  </div>
85
85
 
@@ -102,7 +102,7 @@
102
102
  <div id="legalActions" class="legal-actions"></div>
103
103
  </div>
104
104
  </section>
105
- <section class="panel-block decision-feed-panel"><div class="panel-title">Recent decisions</div><div id="decisionFeed" class="decision-feed"></div></section>
105
+ <section class="panel-block decision-feed-panel"><div class="panel-title-row"><div class="panel-title">Decision history</div><div id="decisionFeedSummary" class="panel-caption">Newest first</div></div><div id="decisionFeed" class="decision-feed"></div></section>
106
106
  </div>
107
107
 
108
108
  <div id="tab-log" class="tab-panel" role="tabpanel" aria-labelledby="tabButton-log" hidden>
@@ -132,7 +132,6 @@
132
132
  </div>
133
133
  </div>
134
134
 
135
- <div id="winnerBanner" class="winner-banner hidden" role="status" aria-live="polite"></div>
136
135
  <div id="pokerTable" class="poker-table">
137
136
  <div class="felt-ring"></div>
138
137
  <div id="fxLayer" class="fx-layer" aria-hidden="true"></div>
@@ -183,6 +182,7 @@
183
182
  </div>
184
183
 
185
184
  <div class="dialog-body setup-body">
185
+ <p class="dialog-intro">Configure providers, tournament pacing, and benchmark behavior. Changes are saved locally without API keys.</p>
186
186
  <section class="settings-section">
187
187
  <div class="section-head compact-section-head">
188
188
  <div class="section-title-row"><h3>Connections</h3><button class="info-button" type="button" aria-label="About API connections" data-tip="Use any browser-accessible OpenAI-compatible API. Base URL is always editable. OpenRouter is an optional preset that also enables Jev Decisions routing.">i</button></div>
@@ -197,18 +197,18 @@
197
197
  <div id="seatSummary" class="seat-summary">0 / 10 seated</div>
198
198
  </div>
199
199
  <div class="field-grid tournament-fields">
200
- <label>Starting stack<input name="startingStack" type="number" min="100" value="10000" /></label>
201
- <label>Small blind<input name="smallBlind" type="number" min="1" value="25" /></label>
202
- <label>Big blind<input name="bigBlind" type="number" min="2" value="50" /></label>
203
- <label>Ante<input name="ante" type="number" min="0" value="5" /></label>
204
- <label>Hands / level<input name="handsPerLevel" type="number" min="1" value="8" /></label>
205
- <label>Blind multiplier<input name="blindMultiplier" type="number" min="1.1" max="3" step="0.1" value="1.5" /></label>
206
- <label>Action time (s)<input name="actionSeconds" type="number" min="1" max="120" value="20" /></label>
207
- <label>Time bank (s)<input name="timeBankSeconds" type="number" min="0" max="600" value="30" /></label>
208
- <label>Low-time warning (s)<input name="lowTimeSeconds" type="number" min="0" max="600" value="5" /></label>
209
- <label>Low-time ring fraction<input name="lowTimeFraction" type="number" min="0" max="1" step="0.05" value="0.25" /></label>
210
- <label>Action delay (ms)<input name="betweenActionsMs" type="number" min="0" max="5000" value="250" /></label>
211
- <label>Hand delay (ms)<input name="betweenHandsMs" type="number" min="0" max="10000" value="700" /></label>
200
+ <label>Starting stack<input name="startingStack" type="number" /></label>
201
+ <label>Small blind<input name="smallBlind" type="number" /></label>
202
+ <label>Big blind<input name="bigBlind" type="number" /></label>
203
+ <label>Ante<input name="ante" type="number" /></label>
204
+ <label>Hands / level<input name="handsPerLevel" type="number" /></label>
205
+ <label>Blind multiplier<input name="blindMultiplier" type="number" /></label>
206
+ <label>Action time (s)<input name="actionSeconds" type="number" /></label>
207
+ <label>Time bank (s)<input name="timeBankSeconds" type="number" /></label>
208
+ <label>Low-time warning (s)<input name="lowTimeSeconds" type="number" /></label>
209
+ <label>Low-time ring fraction<input name="lowTimeFraction" type="number" /></label>
210
+ <label>Action delay (ms)<input name="betweenActionsMs" type="number" /></label>
211
+ <label>Hand delay (ms)<input name="betweenHandsMs" type="number" /></label>
212
212
  </div>
213
213
  </section>
214
214
 
@@ -391,6 +391,6 @@
391
391
  </template>
392
392
 
393
393
  <script src="./arena-env.js"></script>
394
- <script type="module" src="./app.js?v=0.6.6-tab-avsync-quality"></script>
394
+ <script type="module" src="./app.js?v=0.9.0"></script>
395
395
  </body>
396
396
  </html>