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 ADDED
@@ -0,0 +1,8 @@
1
+ # Optional local/CDP real-model bootstrap. Never commit real keys.
2
+ OPENAI_BASE_URL=https://openrouter.ai/api/v1
3
+ OPENAI_API_KEY="sk-or-v1-..."
4
+ OPENAI_PLAYER1=google/gemma-4-26b-a4b-it
5
+ OPENAI_PLAYER2=qwen/qwen3.8-flash
6
+ OPENAI_PLAYER3=typesafe/jev-1.13
7
+ # OPENAI_PLAYER4=...
8
+ OPENAI_STARTING_STACK=10000
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Art
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,229 @@
1
+ # pokertools-arena
2
+
3
+ **A browser-first AI poker benchmark.** Seat Jev and OpenAI-compatible models at the same no-limit Texas Hold'em table, watch every card and decision as a spectator, and let the tournament run autonomously until one model wins.
4
+
5
+ [![CI](https://github.com/pokertools-arena/pokertools-arena.github.io/actions/workflows/ci.yml/badge.svg)](https://github.com/pokertools-arena/pokertools-arena.github.io/actions/workflows/ci.yml)
6
+ [![Deploy](https://github.com/pokertools-arena/pokertools-arena.github.io/actions/workflows/pages.yml/badge.svg)](https://github.com/pokertools-arena/pokertools-arena.github.io/actions/workflows/pages.yml)
7
+ [![npm](https://img.shields.io/npm/v/pokertools-arena.svg)](https://www.npmjs.com/package/pokertools-arena)
8
+ [![license: MIT](https://img.shields.io/badge/license-MIT-6e9875.svg)](./LICENSE)
9
+ [![node: >=24](https://img.shields.io/badge/node-%3E%3D24-6e9875.svg)](./package.json)
10
+
11
+ [![pokertools-arena social preview](src/assets/og-image.png)](https://pokertools-arena.github.io/)
12
+
13
+ | | |
14
+ | --- | --- |
15
+ | **Live app** | <https://pokertools-arena.github.io/> |
16
+ | **Source** | <https://github.com/pokertools-arena/pokertools-arena.github.io> |
17
+ | **Package** | `npx pokertools-arena` |
18
+ | **Runtime** | Node.js ≥ 24 for tooling; any modern browser for the app |
19
+
20
+ > **Current release: 0.4.0.** This is the methodology and reproducibility release: a paired fixed-state corpus, separated family/sizing correctness, fragmentation-invariance metrics with confidence intervals, standardized counters, and a reorganized repository with one source of truth per report.
21
+
22
+ ---
23
+
24
+ ## Contents
25
+
26
+ - [Why pokertools-arena](#why-pokertools-arena)
27
+ - [Features](#features)
28
+ - [Quick start](#quick-start)
29
+ - [Configure the table](#configure-the-table)
30
+ - [`.env` bootstrap](#env-bootstrap)
31
+ - [Benchmark modes](#benchmark-modes)
32
+ - [Fairness](#fairness)
33
+ - [Methodology](#methodology)
34
+ - [Testing](#testing)
35
+ - [Repository layout](#repository-layout)
36
+ - [Documentation](#documentation)
37
+ - [Privacy and security](#privacy-and-security)
38
+ - [License](#license)
39
+
40
+ ---
41
+
42
+ ## Why pokertools-arena
43
+
44
+ Text-model benchmarks are usually static question sets. Poker is a better stress test: adversarial, sequential, incomplete-information and unforgiving of bad reasoning. `pokertools-arena` turns that into an observable, reproducible benchmark.
45
+
46
+ - **Same information for every seat.** Poker legality, hole-card masking and public context are generated by the PokerTools engine, not by prompts.
47
+ - **Any OpenAI-compatible endpoint.** Hosted APIs or a local gateway; OpenRouter and direct Jev are optional presets.
48
+ - **Nothing to deploy.** The benchmark is fully client-side. There is no application server, database or backend API.
49
+ - **Everything is inspectable.** Watch live, replay any decision, export logs and replay PNGs.
50
+
51
+ Models only select from arena-generated legal action families and sizes, and every chosen move is re-validated by the engine before it is applied.
52
+
53
+ ## Features
54
+
55
+ | Area | What you get |
56
+ | --- | --- |
57
+ | **Table** | 2–10 seats, no-limit Hold'em tournaments, rising blinds, antes, time banks, elimination and podium flow |
58
+ | **Connections** | Generic OpenAI-compatible base URL, OpenRouter preset, TypeSafe System One preset |
59
+ | **Protocols** | Tool call, strict JSON Schema, prompt JSON, Jev Decisions, native Jev |
60
+ | **Fairness** | One canonical `DecisionState` per decision, fail-closed hole-card masking, deterministic legal actions |
61
+ | **Hierarchy** | Two-stage family → size decisions shared by every model and adapter |
62
+ | **Observability** | Live decision panel, typed telemetry, latency/error counters, JSONL export |
63
+ | **Replay** | Per-decision snapshots with hero cards, board, stacks, public history, legal menu |
64
+ | **Sharing** | Local 1080×1350 PNG replay cards, table-only WebM recording, native share sheet |
65
+ | **Benchmarks** | In-app sanity suite plus offline and real-API diagnostics with a paired fixed-state corpus |
66
+
67
+ ## Quick start
68
+
69
+ Prerequisites: **Node.js ≥ 24**, a **modern browser** with Web Crypto (Chromium for table-only recording), and an OpenAI-compatible endpoint.
70
+
71
+ ### Option A — npm launcher (recommended)
72
+
73
+ ```bash
74
+ npx pokertools-arena
75
+ ```
76
+
77
+ The CLI starts a tiny local static server and opens the browser. It exists only to avoid `file://` restrictions. Poker state, orchestration, model calls, logs and API keys stay in the browser.
78
+
79
+ | Flag | Default | Purpose |
80
+ | --- | --- | --- |
81
+ | `--port <n>` | `4173` | Port to serve on (auto-increments if busy). |
82
+ | `--host <addr>` | `127.0.0.1` | Interface to bind. |
83
+ | `--chrome` | off | Open Google Chrome specifically. |
84
+ | `--no-open` | off | Do not launch a browser. |
85
+ | `--no-env` | off | Ignore `.env` bootstrap. |
86
+ | `--help`, `-h` | — | Print usage. |
87
+
88
+ ### Option B — single HTML file
89
+
90
+ ```bash
91
+ npm install
92
+ npm run build
93
+ ```
94
+
95
+ Then open `dist/pokertools-arena.html`. The build bundles the PokerTools browser engine locally; no runtime CDN is used.
96
+
97
+ ### Option C — GitHub Pages
98
+
99
+ The canonical deployment is <https://pokertools-arena.github.io/>. Enable **Settings → Pages → Source: GitHub Actions** once; `.github/workflows/pages.yml` builds and deploys `dist/` on every push to `main`.
100
+
101
+ ## Configure the table
102
+
103
+ The table **is** the player editor. A fresh launch shows empty seats around the felt.
104
+
105
+ 1. Click a seat to add or edit a model.
106
+ 2. Choose a connection, enter a model ID and pick a protocol.
107
+ 3. Configure **Settings → Connections** (base URLs and keys) and **Settings → Tournament** (stack, blinds, clock, benchmark mode, architecture, representation).
108
+ 4. Press **Start**. Only configured seats enter the tournament, and seat editing locks while it runs.
109
+
110
+ ## `.env` bootstrap
111
+
112
+ 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.
113
+
114
+ ```dotenv
115
+ OPENAI_BASE_URL=https://openrouter.ai/api/v1
116
+ OPENAI_API_KEY="sk-or-v1-..."
117
+ OPENAI_PLAYER1=google/gemma-4-26b-a4b-it
118
+ OPENAI_PLAYER2=qwen/qwen3.8-flash
119
+ OPENAI_PLAYER3=typesafe/jev-1.13
120
+ OPENAI_STARTING_STACK=10000
121
+ ```
122
+
123
+ | Variable | Notes |
124
+ | --- | --- |
125
+ | `OPENAI_BASE_URL` | Defaults to `https://api.openai.com/v1`; OpenRouter URLs are auto-detected. |
126
+ | `OPENAI_API_KEY` | Optional for local/unauthenticated endpoints. |
127
+ | `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). |
129
+
130
+ See [`.env.example`](./.env.example).
131
+
132
+ ## Benchmark modes
133
+
134
+ Two tournament-wide settings keep every model on equal footing:
135
+
136
+ - **Strategy** (default): every seat receives a deterministic `heroHand` classification from `@pokertools/evaluator`.
137
+ - **Raw cognition**: no `heroHand`; each model infers hand strength from raw cards.
138
+
139
+ They are **separate benchmark tracks**. Public reports never aggregate them into a single model score.
140
+
141
+ **Decision architecture** is also tournament-wide. `hierarchical` (default) asks for an action family, then a validated size for `BET`/`RAISE`. `flat` is retained only as a diagnostic baseline. **Representation** is `canonical_json`, `compact_json` or `markdown`; all seats receive identical semantics.
142
+
143
+ ## Fairness
144
+
145
+ Every seat receives the same canonical information policy: its own hole cards, the public board/pot/stacks/positions/current bets, the same deterministic legal actions, current-hand public history, last eight public hands, and identical deterministic public opponent statistics. Opponent hole cards, other models' reasoning, prior Jev probabilities and provider metadata are never exposed.
146
+
147
+ Player masking is **fail-closed**. All-in players count as remaining until a completed hand eliminates them.
148
+
149
+ The full contract, action-family rules, sizing buckets, shared clock and fairness assertions are documented in [`docs/architecture/CONTEXT.md`](./docs/architecture/CONTEXT.md).
150
+
151
+ ## Methodology
152
+
153
+ The benchmark methodology is built around a **paired fixed-state corpus**:
154
+
155
+ - the exact same immutable state is used for flat and hierarchical, for every model, mode and representation;
156
+ - execution is deterministically interleaved with a recorded `experimentSeed`;
157
+ - **family accuracy** and **sizing accuracy (conditional on a correct family)** are reported separately from **final-action accuracy**;
158
+ - behavioral proportions carry sample size and Wilson 95% confidence intervals;
159
+ - **`fragmentation_flip_rate`** measures how often the broad family changes when only the number of same-family size choices changes;
160
+ - counters are standardized (`pokerDecisions`, `familyModelCalls`, `sizingModelCalls`, `totalModelCalls`, `httpRequests`, `httpRetries`, …) and one machine-readable summary is the single source of truth for every report.
161
+
162
+ > A hierarchical aggressive poker decision is **1 poker decision**, **2 model calls** and **2+ HTTP requests** if retries occur. Aggression is not a goal; the goal is symmetric representation, reproducibility, correctness and interpretable statistics.
163
+
164
+ Real tournament A/B is **end-to-end behavioral validation**, not a controlled architecture comparison, because actions change future states.
165
+
166
+ ## Testing
167
+
168
+ ```bash
169
+ npm test # build + release checks + offline unit + integration
170
+ npm run test:unit # corpus, hierarchy/fairness, methodology, sizing, paired, archive, report
171
+ npm run test:integration # @pokertools/engine/browser + launcher .env bootstrap
172
+ npm run test:diagnostics:dry
173
+ npm run test:real # real API paired corpus + diagnostics (uses .env)
174
+ npm run test:tournament-ab # real flat-vs-hierarchical tournament (uses .env)
175
+ npm run report:diagnostics # build logs/release-<version>/summary.json + summary.md
176
+ npm run release:archive # source archive without secrets/logs/dist
177
+ ```
178
+
179
+ `npm test` is fully offline apart from dependency installation. Real-API diagnostics are manual or secret-gated in CI. See [`tests/README.md`](./tests/README.md).
180
+
181
+ ## Repository layout
182
+
183
+ ```text
184
+ pokertools-arena/
185
+ ├── README.md · LICENSE · package.json · build.mjs
186
+ ├── bin/pokertools-arena.mjs local static launcher + .env bootstrap
187
+ ├── src/ browser application source
188
+ │ ├── index.html · app.js · styles.css
189
+ │ ├── lib/decision-core.js canonical DOM-free decision core
190
+ │ ├── benchmark/scenarios.js in-app sanity scenarios
191
+ │ ├── env/arena-env.js .env placeholder injected by the launcher
192
+ │ ├── shims/crypto.cjs Web Crypto shim for the PokerTools browser entry
193
+ │ └── assets/ favicon and social image
194
+ ├── tools/
195
+ │ ├── diagnostics/ corpus, harness, stats, counters, paired, report
196
+ │ └── release/archive.mjs source archive builder
197
+ ├── tests/
198
+ │ ├── unit/ · integration/ · real/ · analysis/
199
+ │ └── release-check.mjs
200
+ ├── docs/
201
+ │ ├── architecture/CONTEXT.md
202
+ │ ├── releases/RELEASE_NOTES.md
203
+ │ ├── diagnostics/SUMMARY.md + summary.json
204
+ │ ├── verification/0.3.x.md
205
+ │ └── legal/THIRD_PARTY_NOTICES.md
206
+ └── .github/workflows/ CI, GitHub Pages, npm publish, manual real diagnostics
207
+ ```
208
+
209
+ Generated and gitignored: `dist/`, `pokertools-arena.html`, `logs/`, `node_modules/`.
210
+
211
+ ## Documentation
212
+
213
+ - Architecture and invariants → [`docs/architecture/CONTEXT.md`](./docs/architecture/CONTEXT.md)
214
+ - Chronological release history → [`docs/releases/RELEASE_NOTES.md`](./docs/releases/RELEASE_NOTES.md)
215
+ - Benchmark methodology and results → [`docs/diagnostics/SUMMARY.md`](./docs/diagnostics/SUMMARY.md)
216
+ - Machine-readable release summary → [`docs/diagnostics/summary.json`](./docs/diagnostics/summary.json)
217
+ - Test suites → [`tests/README.md`](./tests/README.md)
218
+ - Third-party notices → [`docs/legal/THIRD_PARTY_NOTICES.md`](./docs/legal/THIRD_PARTY_NOTICES.md)
219
+
220
+ ## Privacy and security
221
+
222
+ - API keys live only in current page memory and are excluded from saved setup state, tournament snapshots, exported logs and release archives.
223
+ - The launcher serves the `.env` payload only to loopback requests whenever it contains a configured API key.
224
+ - Models never receive hidden information and never construct engine commands.
225
+ - Replay images and recordings are produced locally; nothing is uploaded.
226
+
227
+ ## License
228
+
229
+ MIT — see [`LICENSE`](./LICENSE).
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env node
2
+ import { createServer } from 'node:http';
3
+ import { readFile, stat } from 'node:fs/promises';
4
+ import { extname, dirname, join, normalize, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { spawn } from 'node:child_process';
7
+
8
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
9
+ const root = join(packageRoot, 'dist');
10
+ const args = process.argv.slice(2);
11
+ const valueAfter = (flag, fallback) => {
12
+ const index = args.indexOf(flag);
13
+ return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
14
+ };
15
+ const host = valueAfter('--host', '127.0.0.1');
16
+ const requestedPortRaw = valueAfter('--port', '4173');
17
+ const requestedPort = requestedPortRaw === '0' ? 0 : (Number(requestedPortRaw) || 4173);
18
+ const shouldOpen = !args.includes('--no-open');
19
+ const help = args.includes('--help') || args.includes('-h');
20
+ const useEnv = !args.includes('--no-env');
21
+ const useChrome = args.includes('--chrome');
22
+ const envOverrides = {
23
+ OPENAI_STARTING_STACK: valueAfter('--starting-stack', null),
24
+ OPENAI_MAX_DECISIONS: valueAfter('--max-decisions', null),
25
+ OPENAI_AUTOSTART: args.includes('--autostart') ? '1' : null,
26
+ };
27
+
28
+ function parseDotEnv(text) {
29
+ const out = {};
30
+ for (const rawLine of String(text || '').split(/\r?\n/)) {
31
+ let line = rawLine.trim();
32
+ if (!line || line.startsWith('#')) continue;
33
+ if (line.startsWith('export ')) line = line.slice(7).trim();
34
+ const eq = line.indexOf('=');
35
+ if (eq <= 0) continue;
36
+ const key = line.slice(0, eq).trim();
37
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
38
+ let value = line.slice(eq + 1).trim();
39
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
40
+ const quote = value[0];
41
+ value = value.slice(1, -1);
42
+ if (quote === '"') value = value.replace(/\n/g, '\n').replace(/\r/g, '\r').replace(/\t/g, '\t').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
43
+ } else {
44
+ value = value.replace(/\s+#.*$/, '').trim();
45
+ }
46
+ out[key] = value;
47
+ }
48
+ return out;
49
+ }
50
+
51
+ async function loadArenaEnv() {
52
+ if (!useEnv) return { source: null, values: {} };
53
+ const candidates = [resolve(process.cwd(), '.env'), join(packageRoot, '.env')];
54
+ for (const candidate of [...new Set(candidates)]) {
55
+ try {
56
+ const values = parseDotEnv(await readFile(candidate, 'utf8'));
57
+ return { source: candidate, values };
58
+ } catch {}
59
+ }
60
+ return { source: null, values: {} };
61
+ }
62
+
63
+ function envBootstrap(values, overrides = {}) {
64
+ const baseUrl = String(values.OPENAI_BASE_URL || process.env.OPENAI_BASE_URL || '').trim();
65
+ const apiKey = String(values.OPENAI_API_KEY || process.env.OPENAI_API_KEY || '').trim();
66
+ const models = [];
67
+ for (let i = 1; i <= 10; i++) {
68
+ const model = String(values[`OPENAI_PLAYER${i}`] || process.env[`OPENAI_PLAYER${i}`] || '').trim();
69
+ if (model) models.push({ index: i, model });
70
+ }
71
+ if (!baseUrl && !apiKey && !models.length) return null;
72
+ const resolvedBase = baseUrl || 'https://api.openai.com/v1';
73
+ const isOpenRouter = /(^|\.)openrouter\.ai(?=\/|$)/i.test(new URL(resolvedBase).hostname);
74
+ const connectionId = 'env-connection';
75
+ const truthy = value => /^(1|true|yes|on)$/i.test(String(value ?? '').trim());
76
+ const flagOrEnv = flag => {
77
+ if (overrides[flag] !== undefined && overrides[flag] !== null && overrides[flag] !== '') return overrides[flag];
78
+ return values[flag] || process.env[flag];
79
+ };
80
+ const autostart = truthy(flagOrEnv('OPENAI_AUTOSTART'));
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));
83
+ return {
84
+ source: '.env',
85
+ settings: {
86
+ // Default chip stack for every model seat. Override with --starting-stack
87
+ // or OPENAI_STARTING_STACK.
88
+ startingStack,
89
+ // Opt-in launcher demo/benchmark controls. Autostart is off unless the
90
+ // launcher explicitly enables it; maxDecisions hard-stops the tournament.
91
+ autostart,
92
+ maxDecisions,
93
+ },
94
+ connections: [{
95
+ id: connectionId,
96
+ name: isOpenRouter ? 'OpenRouter' : 'OpenAI-compatible',
97
+ kind: isOpenRouter ? 'openrouter' : 'openai',
98
+ baseUrl: resolvedBase,
99
+ apiKey,
100
+ headers: '',
101
+ }],
102
+ players: models.map(({ index, model }, order) => ({
103
+ lobbySeat: Math.max(0, index - 1),
104
+ name: `Player ${index}`,
105
+ connectionId,
106
+ model,
107
+ protocol: isOpenRouter && /(^|\/)typesafe\/jev-/i.test(model.replace(/^~/, '')) ? 'jev_decisions' : 'tool',
108
+ provider: '',
109
+ })),
110
+ };
111
+ }
112
+
113
+ const loadedEnv = await loadArenaEnv();
114
+ const arenaEnv = envBootstrap(loadedEnv.values, envOverrides);
115
+
116
+ if (help) {
117
+ console.log(`pokertools-arena\n\nUsage:\n npx pokertools-arena [--port 4173] [--host 127.0.0.1] [--chrome] [--no-open] [--no-env] [--autostart] [--starting-stack N] [--max-decisions N]\n\nThe launcher serves the prebuilt static benchmark locally and opens it in your browser so you can watch the tournament. --chrome opens Google Chrome specifically. By default it also reads .env from the current directory and preloads OPENAI_BASE_URL / OPENAI_API_KEY / OPENAI_PLAYER1..10 into page memory. --autostart starts the tournament once seats are loaded, and --max-decisions hard-stops it after N poker decisions. --starting-stack and --max-decisions override .env values. Use --no-env to disable the .env bootstrap.`);
118
+ process.exit(0);
119
+ }
120
+
121
+ const mime = {
122
+ '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8',
123
+ '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon'
124
+ };
125
+
126
+ function isLoopbackRequest(req) {
127
+ const address = String(req.socket?.remoteAddress || '');
128
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
129
+ }
130
+
131
+ function safePath(urlPath) {
132
+ const clean = decodeURIComponent((urlPath || '/').split('?')[0]);
133
+ const target = clean === '/' ? '/index.html' : clean;
134
+ const resolved = normalize(join(root, target));
135
+ return resolved.startsWith(root) ? resolved : null;
136
+ }
137
+
138
+ const server = createServer(async (req, res) => {
139
+ try {
140
+ const urlPath = (req.url || '/').split('?')[0];
141
+ if (urlPath === '/arena-env.js') {
142
+ const exposedEnv = arenaEnv && (isLoopbackRequest(req) || !arenaEnv.connections?.some(c => c.apiKey)) ? arenaEnv : null;
143
+ const payload = `window.__POKERTOOLS_ENV__ = ${JSON.stringify(exposedEnv)};\n`;
144
+ res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store' });
145
+ res.end(payload);
146
+ return;
147
+ }
148
+ const file = safePath(req.url);
149
+ if (!file) throw new Error('Invalid path');
150
+ const info = await stat(file);
151
+ if (!info.isFile()) throw new Error('Not a file');
152
+ const body = await readFile(file);
153
+ res.writeHead(200, {
154
+ 'Content-Type': mime[extname(file)] || 'application/octet-stream',
155
+ 'Cache-Control': 'no-store',
156
+ 'Cross-Origin-Opener-Policy': 'same-origin-allow-popups'
157
+ });
158
+ res.end(body);
159
+ } catch {
160
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
161
+ res.end('Not found');
162
+ }
163
+ });
164
+
165
+ function openBrowser(url) {
166
+ const platform = process.platform;
167
+ let command, commandArgs;
168
+ if (useChrome) {
169
+ if (platform === 'darwin') { command = 'open'; commandArgs = ['-a', 'Google Chrome', url]; }
170
+ else if (platform === 'win32') { command = 'cmd'; commandArgs = ['/c', 'start', '', 'chrome', url]; }
171
+ else { command = 'google-chrome'; commandArgs = [url]; }
172
+ } else if (platform === 'darwin') { command = 'open'; commandArgs = [url]; }
173
+ else if (platform === 'win32') { command = 'cmd'; commandArgs = ['/c', 'start', '', url]; }
174
+ else { command = 'xdg-open'; commandArgs = [url]; }
175
+ try { spawn(command, commandArgs, { detached: true, stdio: 'ignore' }).unref(); } catch {}
176
+ }
177
+
178
+ function listen(port) {
179
+ server.once('error', error => {
180
+ if (error.code === 'EADDRINUSE' && port < requestedPort + 20) return listen(port + 1);
181
+ console.error(`pokertools-arena: ${error.message}`);
182
+ process.exit(1);
183
+ });
184
+ server.listen(port, host, () => {
185
+ const address = server.address();
186
+ const actualPort = typeof address === 'object' && address ? address.port : port;
187
+ const url = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${actualPort}/`;
188
+ console.log(`pokertools-arena running at ${url}`);
189
+ if (arenaEnv) console.log(`Loaded .env bootstrap${loadedEnv.source ? ` from ${loadedEnv.source}` : ''}: ${arenaEnv.players.length} player(s), ${arenaEnv.connections[0].baseUrl}`);
190
+ else if (useEnv) console.log('No supported .env bootstrap values found; using browser-saved/manual setup.');
191
+ else console.log('.env bootstrap disabled by --no-env.');
192
+ console.log('Press Ctrl+C to stop the local launcher.');
193
+ if (shouldOpen) openBrowser(url);
194
+ });
195
+ }
196
+
197
+ listen(requestedPort);
package/dist/.nojekyll ADDED
File without changes