ravensight-playtest 0.3.2 → 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/README.md CHANGED
@@ -4,6 +4,11 @@ AI personas play your game, then file a report you can read, mark up and argue
4
4
  with. The personas run on your machine, against your build; Ravensight stores
5
5
  the reports and renders them.
6
6
 
7
+ Simulation Runner adds seeded headless batches for mechanics, economy and
8
+ progression checks through your own game adapter. It runs locally without
9
+ model calls and saves bounded results to the same Playtest workspace. See
10
+ [Simulation Runner](docs/SIMULATION.md) for the held candidate and setup.
11
+
7
12
  You need a Ravensight account and Node 22 or newer. You do not need an
8
13
  Anthropic API key: model calls go through Ravensight under the same token you
9
14
  log in with, so there is one credential on your machine and you can revoke it
@@ -884,3 +889,17 @@ feedback and timing-sensitive gameplay.
884
889
  ## Build adapters
885
890
 
886
891
  Browser and Godot runners share the paid playtest lifecycle with desktop, Unity/Unreal export, Android/iOS and custom JSON-RPC adapters. Native adapters require a locally configured automation server and remain experimental until validated against your build. See [adapter setup and validation boundaries](docs/ADAPTERS.md) and the [native runner starter](docs/NATIVE_RUNNER.md); `check` probes the automation server for free before a paid native run.
892
+
893
+ ## Simulation Runner
894
+
895
+ Available in held local candidate `0.4.0`; not yet published. Simulation Runner requires macOS or Linux (including WSL); native Windows is refused before purchase.
896
+
897
+ Run seeded mechanics tests through a local game adapter, compare explicit strategies and replay selected traces. A batch uses the existing standard Playtest purchase of 5 AI units from prepaid balance, confirmed against the server quote. Only the summary is uploaded; game execution and replay traces stay local. It does not automatically call a model or replace testing the player interface.
898
+
899
+ ```sh
900
+ ravensight-playtest simulate --config simulation.json --driver-config bridge.json
901
+ ravensight-playtest simulate --resume .ravensight/simulations/BATCH_ID/state.json
902
+ ravensight-playtest simulation-replay trace-00000.json --driver-config bridge.json
903
+ ```
904
+
905
+ See [Simulation Runner setup, adapter protocol and limits](docs/SIMULATION.md). The package includes `examples/simulation/economy.mjs` as a deterministic starting adapter. Local replay creates no new paid job.
@@ -0,0 +1,91 @@
1
+ # Simulation Runner
2
+
3
+ Held local candidate: `0.4.0`. This package has not been published; use a reviewed local tarball with the matching held server implementation.
4
+
5
+ Simulation Runner exercises game mechanics through a developer-owned local adapter. It can run seeded games across explicit strategies, summarize outcomes and metrics, and save selected replay traces. Use it for questions such as whether an economy supports several viable strategies or whether a progression rule traps a simulated player. It does not judge animation, interface usability, feel or subjective fun.
6
+
7
+ A batch uses the existing standard Playtest purchase: **5 AI units from prepaid balance**. The CLI obtains the current server quote and asks for confirmation before registration. One batch is one purchase, not one purchase per simulated game. It does not consume the monthly shared AI allowance. Local replay is a debugging operation with no new job or purchase. Automatic AI interpretation is not included; an agent can use the existing report tools when explicitly asked to interpret the summary.
8
+
9
+ ## Setup and run
10
+
11
+ Run Simulation Runner on macOS or Linux, including WSL. Native Windows is rejected during preflight before quoting or registering a purchase because this feature requires POSIX process-group cleanup. This restriction applies to Simulation Runner, not every Playtest adapter. The recorded real-process and 10,000-game validation was run on macOS; native Windows and WSL execution were not validated in this review.
12
+
13
+ Sign in and initialize your Ravensight project as usual. Implement the protocol below, or start with the packaged `examples/simulation/economy.mjs`. The example is deterministic and contains no networking or telemetry.
14
+
15
+ Create `bridge.json` with absolute paths for the Node executable and adapter:
16
+
17
+ ```json
18
+ {
19
+ "command": "/absolute/path/to/node",
20
+ "args": ["/absolute/path/to/economy.mjs"],
21
+ "cwd": "/absolute/path/to/game"
22
+ }
23
+ ```
24
+
25
+ The adapter is trusted local code, **not a sandbox**. No shell is used to spawn it. Account/provider credential environment variables are stripped. Paid execution injects a synthetic Playtest token and run/job IDs; preflight and replay inject an invalid token sentinel so an inherited token cannot accidentally mark the session as production.
26
+
27
+ Create `simulation.json`:
28
+
29
+ ```json
30
+ {
31
+ "version": 1,
32
+ "seed": "economy-review-1",
33
+ "runs": 100,
34
+ "max_steps": 12,
35
+ "workers": 4,
36
+ "strategies": [
37
+ { "name": "random", "policy": "random" },
38
+ { "name": "collector", "policy": "scored" },
39
+ { "name": "saver", "policy": "scripted", "actions": ["earn", "earn", "earn", "earn", "earn", "earn", "earn", "earn"] }
40
+ ]
41
+ }
42
+ ```
43
+
44
+ ```sh
45
+ ravensight-playtest simulate --config simulation.json --driver-config bridge.json
46
+ ```
47
+
48
+ A `driver_config` path inside the simulation file may replace `--driver-config`. The CLI probes the adapter before purchasing. A probe only starts a game and validates its first observation; it does not run the batch. Keep adapter initialization free of destructive side effects.
49
+
50
+ Strategy names start with a lowercase letter and contain at most 64 lowercase letters, digits, underscores or hyphens. Strategies are assigned round-robin. Each run receives a seed derived from the root seed and its zero-based run index. Worker count changes concurrency, not action selection. `random` chooses among legal action IDs deterministically. `scripted` follows supplied IDs and fails on an illegal action or exhaustion before a terminal state. `scored` chooses the highest score supplied by the game for that strategy name, with a deterministic action-ID tie break. Names such as "aggressive" have no built-in gameplay meaning.
51
+
52
+ ## Adapter protocol
53
+
54
+ Use one JSON-RPC 2.0 JSON object per line on stdout. Send diagnostics to stderr. The runner starts a fresh subprocess for each game, with one request outstanding at a time.
55
+
56
+ ```json
57
+ {"jsonrpc":"2.0","id":1,"method":"simulation.start","params":{"version":1,"seed":"derived-seed","strategy":"collector","max_steps":12}}
58
+ {"jsonrpc":"2.0","id":1,"result":{"done":false,"state_hash":"state-0","actions":[{"id":"earn","scores":{"collector":1}},{"id":"buy","scores":{"collector":3}}],"metrics":{"coins":4}}}
59
+ {"jsonrpc":"2.0","id":2,"method":"simulation.step","params":{"action":"buy"}}
60
+ {"jsonrpc":"2.0","id":2,"result":{"done":true,"state_hash":"state-1","actions":[],"metrics":{"coins":2,"inventory":1},"outcome":"collected"}}
61
+ ```
62
+
63
+ The game owns state hashing and must include every state component relevant to reproducibility. Return `done` as a boolean, a nonempty `state_hash` of at most 128 characters, and at most 256 unique action IDs of at most 128 characters. Nonterminal observations need legal actions. Optional action scores contain at most 16 finite numeric values keyed by strategy. Optional metrics contain at most 32 finite numeric values between -1e15 and 1e15 with keys of at most 80 characters. Optional outcomes are nonempty strings of at most 80 characters. Reserved names `__proto__`, `constructor` and `prototype` are rejected.
64
+
65
+ ## Limits and results
66
+
67
+ - 1 to 10,000 requested games, 1 to 10,000 actions per game, 1 to 16 workers and 1 to 16 strategies.
68
+ - At most 1,000,000 actions across a batch and at most 15 minutes of execution, subject to the registered server budget.
69
+ - At most 30 seconds per response and 256 KiB per response. Cancellation terminates the subprocess group, escalating from SIGTERM to SIGKILL.
70
+ - At most ten selected traces: the first ten run indices, not an exhaustive failure archive. Each trace is bounded to 4 MiB and 10,000 actions.
71
+ - Per-strategy summaries retain at most 32 distinct metric names and 128 outcome buckets. Additional outcome names are grouped as `other`; keep metric names stable between games.
72
+
73
+ `duration_ms` reports execution time within the batch deadline, excluding any cleanup or post-stop serialization beyond that deadline. `results.jsonl` streams one local result per requested run. `summary.json` contains completed, failed and truncated totals, total actions, terminal outcome counts and terminal metric count/min/max/mean by strategy. Metrics are run-level terminal values, not a mean across every observation. A terminal result without an outcome uses `unspecified`. Reaching `max_steps` is **truncated**, never a successful terminal outcome. Games not started because of cancellation or a batch limit count as failed, with the stop reason retained in the local report.
74
+
75
+ Only the bounded summary is uploaded to the existing Playtest job. Adapter scripts, raw states, per-game rows, replay traces and scripted action lists remain local. The public configuration hash covers the registered configuration and strategy names/policies; trace metadata also includes a separate full local configuration hash covering scripts.
76
+
77
+ ## Recovery and replay
78
+
79
+ The CLI prints a durable recovery journal path. Recover the same purchase rather than starting another batch:
80
+
81
+ ```sh
82
+ ravensight-playtest simulate --resume .ravensight/simulations/BATCH_ID/state.json
83
+ ```
84
+
85
+ Resume preserves the API host, game, configuration and purchase identity. Do not combine it with a new config or bridge argument. A changed bridge configuration is rejected. Keep the adapter build and its dependencies unchanged for reproducible recovery. After a process interruption, any restarted local attempt has its own output directory. If the summary was already saved, resume uploads that summary without executing the games again. A terminal server job is not restarted.
86
+
87
+ ```sh
88
+ ravensight-playtest simulation-replay /absolute/path/to/trace-00000.json --driver-config bridge.json
89
+ ```
90
+
91
+ Replay checks each recorded state hash, terminal flag, outcome and metrics and reports the first divergence. A matching replay verifies that recording with the current adapter; it does not prove the game is deterministic. External services, clocks, platform differences and ignored seeds can cause divergence. A trace from a max-step-truncated game only covers the recorded window, not a finished game. Size-capped or failed recordings can verify their captured prefix, reported as `matched_prefix: true` and `fully_replayed: false`. A recorded failed protocol action is retried after that prefix; `failure_reproduced` states whether the same sanitized protocol error recurred. A failed initial start has no state prefix to verify. Policy failures such as an exhausted script only support prefix replay, not recreation of the policy failure. These results never claim a fully reproduced terminal game.
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ // Trusted local adapter example. It has no networking or telemetry.
3
+ import readline from 'node:readline';
4
+ import { createHash } from 'node:crypto';
5
+ let seed, turn, coins, inventory;
6
+ function observe() {
7
+ const done = turn >= 8;
8
+ return { done, state_hash: createHash('sha256').update(JSON.stringify([seed, turn, coins, inventory])).digest('hex'),
9
+ actions: done ? [] : [{ id: 'earn', scores: { saver: 2, collector: 1 } }, ...(coins >= 2 ? [{ id: 'buy', scores: { saver: 0, collector: 3 } }] : [])],
10
+ metrics: { coins, inventory }, ...(done ? { outcome: inventory >= 2 ? 'collected' : 'saved' } : {}) };
11
+ }
12
+ for await (const line of readline.createInterface({ input: process.stdin })) {
13
+ let request;
14
+ try {
15
+ request = JSON.parse(line);
16
+ if (request.method === 'simulation.start') { seed = request.params.seed; turn = 0; inventory = 0; coins = parseInt(createHash('sha256').update(seed).digest('hex').slice(0, 4), 16) % 3; }
17
+ else if (request.method === 'simulation.step' && !observe().done && observe().actions.some(a => a.id === request.params.action)) {
18
+ turn++; if (request.params.action === 'earn') coins++; else { coins -= 2; inventory++; }
19
+ } else throw new Error('Illegal simulation request');
20
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result: observe() }) + '\n');
21
+ } catch { process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request?.id, error: { code: -32602, message: 'Invalid request' } }) + '\n'); }
22
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ravensight-playtest",
3
- "version": "0.3.2",
4
- "description": "AI personas play your game and file reports to Ravensight.",
3
+ "version": "0.4.0",
4
+ "description": "Local AI playtests and seeded game simulations with reports in Ravensight.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://ravensight.io/docs/#playtest",
7
7
  "bugs": {
@@ -12,6 +12,8 @@
12
12
  "playtest",
13
13
  "game-analytics",
14
14
  "ai-personas",
15
+ "simulation",
16
+ "game-balance",
15
17
  "ravensight"
16
18
  ],
17
19
  "type": "module",
@@ -27,6 +29,8 @@
27
29
  "addons",
28
30
  "docs/ADAPTERS.md",
29
31
  "docs/NATIVE_RUNNER.md",
32
+ "docs/SIMULATION.md",
33
+ "examples/simulation",
30
34
  "README.md",
31
35
  "LICENSE",
32
36
  "!src/**/*.test.js",
package/src/api/client.js CHANGED
@@ -194,9 +194,9 @@ export function createClient(options = {}) {
194
194
  * persona run's price; anything else leaves `run_length` out, which the
195
195
  * server reads as a standard run.
196
196
  */
197
- estimate: (gameId, { modules = [], personas = [], runLength = null } = {}) =>
197
+ estimate: (gameId, { modules = [], personas = [], runLength = null, simulation } = {}) =>
198
198
  request('POST', `${gameBase(gameId)}/estimate`, {
199
- body: { modules, personas, ...(runLength === 'long' ? { run_length: 'long' } : {}) }
199
+ body: { modules, personas, ...(runLength === 'long' ? { run_length: 'long' } : {}), ...(simulation ? { simulation } : {}) }
200
200
  }).then(answer => answer.body),
201
201
 
202
202
  /**
package/src/cli.js CHANGED
@@ -316,6 +316,24 @@ export function buildProgram() {
316
316
  return run(flagsFrom(options, { game: options.game, argv: command.args }));
317
317
  }));
318
318
 
319
+ common(program.command('simulate').description('run a paid, seeded batch against a local headless game'))
320
+ .option('--game <gameId>', 'the game, when this repo is not linked')
321
+ .option('--config <file>', 'simulation JSON: seed, runs, max_steps, workers and strategies')
322
+ .option('--driver-config <file>', 'local JSON-RPC executable configuration')
323
+ .option('--resume <file>', 'resume the same paid batch from its state.json, without another charge')
324
+ .action(capture(slot, async options => {
325
+ const { simulateCommand } = await import('./commands/simulate.js');
326
+ return simulateCommand(flagsFrom(options, { game: options.game, configFile: options.config, driverConfig: options.driverConfig, resume: options.resume }));
327
+ }));
328
+
329
+ common(program.command('simulation-replay').description('verify a recorded simulation trace locally; creates no paid job'))
330
+ .argument('<trace>', 'a selected trace.json written by simulate')
331
+ .requiredOption('--driver-config <file>', 'local JSON-RPC executable configuration')
332
+ .action(capture(slot, async (trace, options) => {
333
+ const { replayCommand } = await import('./commands/simulate.js');
334
+ return replayCommand(trace, flagsFrom(options, { driverConfig: options.driverConfig }));
335
+ }));
336
+
319
337
  return program;
320
338
  }
321
339
 
@@ -0,0 +1,298 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { createContext, resolveGameId, CLI_VERSION, ui } from '../api/index.js';
5
+ import { createHeartbeat } from '../run/heartbeat.js';
6
+ import { readBridgeConfig } from '../run/drivers/stdio.js';
7
+ import { runBatch, replayTrace, validateConfig, probeBridge } from '../simulation/index.js';
8
+ import { jobLink } from '../dashboard.js';
9
+ import { CliError } from '../errors.js';
10
+ import EXIT from '../run/exit.js';
11
+
12
+ const MODULES = ['balance_sim', 'aggregate_report'];
13
+ const TERMINAL = new Set(['succeeded', 'failed', 'canceled', 'budget_exceeded', 'skipped', 'stalled']);
14
+ const money = cents => `$${(cents / 100).toFixed(2)}`;
15
+ const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
16
+ async function loadBridge(file) {
17
+ const bridge = await readBridgeConfig(file);
18
+ return { ...bridge, cwd: bridge.cwd || path.dirname(file) };
19
+ }
20
+
21
+ export function publicConfig(config) {
22
+ return { version: config.version, seed: config.seed, runs: config.runs, max_steps: config.max_steps,
23
+ workers: config.workers, strategies: config.strategies.map(({ name, policy }) => ({ name, policy })) };
24
+ }
25
+
26
+ async function jsonFile(file, maxBytes = 64000) {
27
+ if ((await stat(file)).size > maxBytes) throw new CliError(`File exceeds ${maxBytes} bytes: ${file}`);
28
+ return JSON.parse(await readFile(file, 'utf8'));
29
+ }
30
+
31
+ async function save(file, value) {
32
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
33
+ const temporary = `${file}.${randomUUID()}.tmp`;
34
+ try {
35
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
36
+ await rename(temporary, file);
37
+ } finally { await unlink(temporary).catch(() => {}); }
38
+ }
39
+
40
+ // Exclusive per-journal execution. A dead process's lock is recoverable; a live
41
+ // process cannot have its paid run restarted underneath it by another shell.
42
+ async function acquire(file) {
43
+ const lock = `${file}.lock`;
44
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
45
+ for (let attempt = 0; attempt < 2; attempt++) {
46
+ try {
47
+ const handle = await open(lock, 'wx', 0o600);
48
+ await handle.writeFile(String(process.pid));
49
+ await handle.close();
50
+ return () => unlink(lock).catch(() => {});
51
+ } catch (error) {
52
+ if (error.code !== 'EEXIST') throw error;
53
+ const pid = Number(await readFile(lock, 'utf8'));
54
+ if (!Number.isInteger(pid) || pid < 1) throw new CliError(`Unreadable simulation lock: ${lock}`);
55
+ try { process.kill(pid, 0); }
56
+ catch (probe) {
57
+ if (probe.code === 'ESRCH' && attempt === 0) { await unlink(lock); continue; }
58
+ }
59
+ throw new CliError('This simulation journal is already in use. Wait for the other runner to stop.');
60
+ }
61
+ }
62
+ }
63
+
64
+ function assertQuote(quote) {
65
+ if (quote?.execution_mode !== 'direct_command_batch' || !quote.simulation_limits ||
66
+ !Number.isSafeInteger(quote.estimate_cents) || quote.estimate_cents <= 0 ||
67
+ quote.breakdown?.length !== 1 || quote.breakdown[0].unit !== 'playtest_run' ||
68
+ quote.breakdown[0].count !== 1 || quote.breakdown[0].unit_cents !== quote.estimate_cents) {
69
+ throw new CliError('This server did not quote a paid Simulation Runner batch. Nothing was registered or charged. Update the server and CLI together.');
70
+ }
71
+ }
72
+
73
+ /** One confirmed purchase, a local batch, then bounded evidence to the same job. */
74
+ export async function simulateCommand(flags = {}, injected = {}) {
75
+ const output = injected.ui || ui;
76
+ const repoRoot = path.resolve(flags.repoRoot || process.cwd());
77
+ let journal, file;
78
+ if (flags.resume) {
79
+ if (flags.configFile || flags.driverConfig) throw new CliError('Resume uses the original config and bridge. Do not combine --resume with --config or --driver-config.');
80
+ file = path.resolve(repoRoot, flags.resume);
81
+ journal = await jsonFile(file, 2 * 1024 * 1024);
82
+ if (journal.version !== 1 || journal.kind !== 'simulation' || !journal.idempotency_key ||
83
+ !journal.game_id || !journal.api_url || !journal.config || !journal.registration) {
84
+ throw new CliError('This is not a Simulation Runner journal.');
85
+ }
86
+ if (flags.game && flags.game !== journal.game_id) throw new CliError('Resume must use the original game.');
87
+ if (flags.apiUrl && flags.apiUrl.replace(/\/+$/, '') !== journal.api_url) throw new CliError('Resume must use the original API host.');
88
+ } else {
89
+ if (!flags.configFile) throw new CliError('Supply --config simulation.json, or --resume <state.json>.');
90
+ const configFile = path.resolve(repoRoot, flags.configFile);
91
+ const { driver_config: configDriver, ...raw } = await jsonFile(configFile);
92
+ const config = validateConfig(raw);
93
+ const driverFile = flags.driverConfig ? path.resolve(repoRoot, flags.driverConfig)
94
+ : typeof configDriver === 'string' ? path.resolve(path.dirname(configFile), configDriver) : null;
95
+ if (!driverFile) throw new CliError('Supply --driver-config bridge.json or driver_config in the simulation config.');
96
+ const bridge = await loadBridge(driverFile);
97
+ journal = { version: 1, kind: 'simulation', idempotency_key: randomUUID(), config,
98
+ bridge_file: driverFile, bridge_hash: hash(bridge), created_at: new Date().toISOString(), finished: false };
99
+ file = path.join(repoRoot, '.ravensight', 'simulations', journal.idempotency_key, 'state.json');
100
+ }
101
+ validateConfig(journal.config);
102
+ const context = await (injected.createContext || createContext)({ apiUrl: journal.api_url || flags.apiUrl, repoRoot, requireAuth: true });
103
+ if (journal.api_url && context.apiUrl.replace(/\/+$/, '') !== journal.api_url) throw new CliError('The API host changed. Resume was refused.');
104
+ const api = context.api;
105
+ const gameId = journal.game_id || resolveGameId(flags, context.config);
106
+ const release = await acquire(file);
107
+ const controller = new AbortController();
108
+ const target = injected.process || process;
109
+ const stop = () => controller.abort(new Error('Simulation canceled.'));
110
+ target.once('SIGINT', stop);
111
+ target.once('SIGTERM', stop);
112
+ let heartbeat;
113
+ const persist = () => save(file, journal);
114
+ const settledExit = state => journal.canceled || state === 'canceled' ? EXIT.CANCELED
115
+ : state !== 'succeeded' || (journal.summary && (!journal.summary.completed_runs || journal.summary.failed_runs || journal.summary.truncated_runs)) ? EXIT.FAILED : EXIT.OK;
116
+ const recover = () => output.error(`Recover this same purchase with: ravensight-playtest simulate --resume ${JSON.stringify(file)}`);
117
+ try {
118
+ if (journal.finished) {
119
+ if (flags.json) output.json({ job_id: journal.job_id, finished: true, summary: journal.summary });
120
+ else output.info(`This batch is already finished: ${jobLink(gameId, journal.job_id, context.apiUrl)}`);
121
+ return journal.exit_code ?? EXIT.OK;
122
+ }
123
+ if (journal.job_id) {
124
+ const { job } = await api.jobs.get(gameId, journal.job_id);
125
+ if (TERMINAL.has(job.state)) {
126
+ journal.finished = true;
127
+ journal.exit_code = settledExit(job.state);
128
+ await persist();
129
+ if (flags.json) output.json({ job_id: journal.job_id, finished: true, state: job.state });
130
+ else output.info(`The server already settled this batch as ${job.state}. No simulation was restarted.`);
131
+ return journal.exit_code;
132
+ }
133
+ // A completed run can await job settlement after an interrupted finish.
134
+ // Neither adapter files nor local execution are needed to settle it.
135
+ if (!journal.summary && journal.run_id) {
136
+ const { run: existing } = await api.runs.get(gameId, journal.job_id, journal.run_id);
137
+ if (TERMINAL.has(existing.state)) {
138
+ await api.jobs.finish(gameId, journal.job_id, {});
139
+ journal.finished = true; journal.exit_code = settledExit(existing.state); await persist();
140
+ if (flags.json) output.json({ job_id: journal.job_id, finished: true, state: existing.state });
141
+ else output.info(`This run already finished as ${existing.state}; its job is now settled.`);
142
+ return journal.exit_code;
143
+ }
144
+ }
145
+ if (job.cancel_requested) controller.abort(new Error('Simulation canceled on the server.'));
146
+ }
147
+ let bridge;
148
+ if (!journal.summary) {
149
+ bridge = await loadBridge(journal.bridge_file);
150
+ if (hash(bridge) !== journal.bridge_hash) throw new CliError('The bridge configuration changed. Restore it before resuming this purchase.');
151
+ if (!controller.signal.aborted) await (injected.probeBridge || probeBridge)({ bridge, seed: journal.config.seed,
152
+ strategy: journal.config.strategies[0].name, max_steps: journal.config.max_steps, signal: controller.signal });
153
+ }
154
+ if (!journal.registration) {
155
+ const simulation = publicConfig(journal.config);
156
+ const quote = await api.jobs.estimate(gameId, { modules: MODULES, personas: [], simulation });
157
+ assertQuote(quote);
158
+ output.info(`One simulation batch: ${journal.config.runs} games across ${journal.config.strategies.length} strategies, ${money(quote.estimate_cents)} total from prepaid balance.`);
159
+ output.info('Game execution and replay traces stay local. Only the bounded summary is uploaded. This is a mechanics test, not a test of the player interface.');
160
+ if (quote.ok === false) throw new CliError(`Insufficient prepaid balance (${money(quote.balance_cents)}). Nothing was charged.`, EXIT.ENVIRONMENT);
161
+ if (!flags.yes && !await (injected.confirm || output.confirm)(`Start this batch for ${money(quote.estimate_cents)}?`)) return EXIT.CANCELED;
162
+ if (controller.signal.aborted) return EXIT.CANCELED;
163
+ journal.api_url = context.apiUrl.replace(/\/+$/, '');
164
+ journal.game_id = gameId;
165
+ journal.limits = quote.simulation_limits;
166
+ journal.registration = { modules: MODULES, personas: [], driver: 'cli_stdio', simulation,
167
+ cli_version: CLI_VERSION, confirm_price_cents: quote.estimate_cents,
168
+ build: { kind: 'simulation', ref: hash(simulation) } };
169
+ // Durable BEFORE the money request. A lost register response replays the
170
+ // same idempotency key, even when no job_id ever reached this machine.
171
+ await persist();
172
+ output.info(`Recovery journal: ${file}`);
173
+ }
174
+
175
+ let registered;
176
+ try { registered = await api.jobs.register(gameId, journal.registration, { idempotencyKey: journal.idempotency_key }); }
177
+ catch (error) { recover(); throw error; }
178
+ journal.job_id = registered.job_id;
179
+ const run = registered.runs?.find(entry => entry.module === 'balance_sim');
180
+ if (!run || !run.playtest_token) {
181
+ await api.jobs.finish(gameId, journal.job_id, { state: 'failed', reason: 'Simulation run or synthetic token missing.' });
182
+ throw new CliError('The server did not create an executable simulation run. The job was stopped.');
183
+ }
184
+ journal.run_id = run.run_id;
185
+ await persist();
186
+ let current = flags.resume ? (await api.runs.get(gameId, journal.job_id, run.run_id)).run : run;
187
+ if (TERMINAL.has(current.state)) {
188
+ await api.jobs.finish(gameId, journal.job_id, {});
189
+ journal.finished = true;
190
+ journal.exit_code = settledExit(current.state);
191
+ await persist();
192
+ if (flags.json) output.json({ job_id: journal.job_id, finished: true, state: current.state });
193
+ else output.info(`This run already finished as ${current.state}; its job is now settled.`);
194
+ return journal.exit_code;
195
+ }
196
+ const transition = async state => {
197
+ if (current.state !== state) {
198
+ await api.runs.transition(gameId, journal.job_id, run.run_id, state);
199
+ current = { ...current, state };
200
+ }
201
+ };
202
+ heartbeat = (injected.createHeartbeat || createHeartbeat)({ api, gameId, jobId: journal.job_id, runId: run.run_id,
203
+ onCancel: stop, onError: error => {
204
+ if ([401, 403, 404, 409].includes(error.status)) controller.abort(new Error('The server closed this run.'));
205
+ } });
206
+ heartbeat.start();
207
+ await heartbeat.beat();
208
+ if (!journal.summary) {
209
+ if (['reporting', 'uploading'].includes(current.state)) throw new CliError('The local simulation summary is missing. Restore the journal before finishing this paid run.');
210
+ if (['queued', undefined].includes(current.state)) await transition('launching');
211
+ await transition('playing');
212
+ try {
213
+ // A crash can leave an incomplete results.jsonl. Preserve it and use a
214
+ // new directory for the deterministic restart of the same paid run.
215
+ journal.attempt_directory = path.join(path.dirname(file), `attempt-${randomUUID()}`);
216
+ await persist();
217
+ const result = await (injected.runBatch || runBatch)({ config: journal.config,
218
+ bridge: { ...bridge, batchTimeoutMs: Math.min(900000, (run.limits?.wall_seconds || run.budget?.wall_seconds_max || journal.limits?.wall_seconds_max || 900) * 1000) },
219
+ outputDir: journal.attempt_directory,
220
+ playtest: { token: run.playtest_token, runId: run.run_id, jobId: journal.job_id }, signal: controller.signal,
221
+ onProgress: progress => {
222
+ if (progress.finished_runs % 100 === 0 || progress.finished_runs === progress.requested_runs) {
223
+ output.info(`${progress.finished_runs}/${progress.requested_runs} games processed; ${progress.completed_runs} completed, ${progress.failed_runs} failed, ${progress.truncated_runs} reached a limit.`);
224
+ void heartbeat.beat({ actionsTaken: progress.total_steps, checkpointStep: progress.finished_runs });
225
+ }
226
+ } });
227
+ journal.summary = result.summary;
228
+ journal.traces = result.traces;
229
+ journal.local_report = result.report;
230
+ journal.canceled = controller.signal.aborted;
231
+ await persist();
232
+ } catch (error) {
233
+ // No usable local result exists. Complete and settle through the same
234
+ // server refund path; neither a second charge nor a local refund.
235
+ try {
236
+ await api.runs.complete(gameId, journal.job_id, run.run_id, { state: 'failed', quit_reason: 'error', reason: 'Local simulation could not produce a summary.' });
237
+ await api.jobs.finish(gameId, journal.job_id, { reason: 'Local simulation failed.' });
238
+ journal.finished = true; journal.exit_code = EXIT.FAILED; await persist();
239
+ } catch { recover(); }
240
+ throw error;
241
+ }
242
+ }
243
+ const useful = journal.summary.completed_runs > 0;
244
+ try {
245
+ if (useful) {
246
+ if (current.state !== 'uploading') await transition('reporting');
247
+ await transition('uploading');
248
+ }
249
+ await api.runs.complete(gameId, journal.job_id, run.run_id, {
250
+ state: useful ? 'succeeded' : 'failed', simulation: journal.summary,
251
+ actions_taken: journal.summary.total_steps, quit_reason: useful ? 'goal_reached' : 'error'
252
+ });
253
+ await api.jobs.finish(gameId, journal.job_id, {});
254
+ } catch (error) {
255
+ // Keep valid results for retry. A dropped complete/finish response is
256
+ // ambiguous, so do not overwrite success with failed or buy another job.
257
+ recover();
258
+ throw error;
259
+ }
260
+ journal.finished = true;
261
+ journal.exit_code = journal.canceled ? EXIT.CANCELED :
262
+ (!useful || journal.summary.failed_runs || journal.summary.truncated_runs) ? EXIT.FAILED : EXIT.OK;
263
+ await persist();
264
+ if (flags.json) output.json({ job_id: journal.job_id, summary: journal.summary, traces: journal.traces, journal: file });
265
+ else {
266
+ output.info(`Simulation report: ${jobLink(gameId, journal.job_id, context.apiUrl)}`);
267
+ output.info(`Local summary and replay traces: ${path.dirname(file)}`);
268
+ if (!useful) output.info('No game reached a terminal result. The server settles this batch without keeping its Playtest unit.');
269
+ }
270
+ return journal.exit_code;
271
+ } finally {
272
+ heartbeat?.stop();
273
+ target.removeListener('SIGINT', stop);
274
+ target.removeListener('SIGTERM', stop);
275
+ await release();
276
+ }
277
+ }
278
+
279
+ export async function replayCommand(trace, flags = {}, injected = {}) {
280
+ const output = injected.ui || ui;
281
+ const repo = path.resolve(flags.repoRoot || process.cwd());
282
+ const bridge = await loadBridge(path.resolve(repo, flags.driverConfig));
283
+ const controller = new AbortController();
284
+ const stop = () => controller.abort(new Error('Replay canceled.'));
285
+ const target = injected.process || process;
286
+ target.once('SIGINT', stop); target.once('SIGTERM', stop);
287
+ try {
288
+ const result = await (injected.replayTrace || replayTrace)({ trace: path.resolve(repo, trace), bridge, signal: controller.signal });
289
+ if (flags.json) output.json(result);
290
+ else output.info(result.matched
291
+ ? result.fully_replayed === false
292
+ ? result.failure_reproduced ? 'Replay matched the recorded prefix and reproduced the recorded failure.'
293
+ : 'Replay matched the recorded prefix only. The trace does not establish a complete playthrough.'
294
+ : 'Replay matched every recorded state, outcome and metric.'
295
+ : `Replay diverged: ${JSON.stringify(result.first_divergence)}. Check the game seed, build and external dependencies.`);
296
+ return result.matched ? EXIT.OK : EXIT.FAILED;
297
+ } finally { target.removeListener('SIGINT', stop); target.removeListener('SIGTERM', stop); }
298
+ }
@@ -0,0 +1,176 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { boundedString, bridgeOptions, observation, openBridge } from './protocol.js';
5
+ const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
6
+ const MAX_TRACE_BYTES = 4 * 1024 * 1024;
7
+ const MAX_ACTIONS = 1000000;
8
+ const compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0;
9
+ export function validateConfig(input) {
10
+ if (!input || input.version !== 1 || !boundedString(input.seed, 128)) throw new Error('Simulation config needs version 1 and a seed of 1 to 128 characters.');
11
+ for (const [key, max] of [['runs', 10000], ['max_steps', 10000], ['workers', 16]]) if (!Number.isInteger(input[key]) || input[key] < 1 || input[key] > max) throw new Error(`Invalid simulation ${key}.`);
12
+ if (!Array.isArray(input.strategies) || !input.strategies.length || input.strategies.length > 16) throw new Error('Choose 1 to 16 simulation strategies.');
13
+ const strategies = input.strategies.map(strategy => {
14
+ if (!strategy || typeof strategy.name !== 'string' || !/^[a-z][a-z0-9_-]{0,63}$/.test(strategy.name) || !['random', 'scripted', 'scored'].includes(strategy.policy)) throw new Error('Invalid simulation strategy.');
15
+ if (strategy.policy === 'scripted' && (!Array.isArray(strategy.actions) || !strategy.actions.length || strategy.actions.length > 10000 || strategy.actions.some(id => !boundedString(id, 128)))) throw new Error('Scripted strategy needs 1 to 10000 action IDs.');
16
+ return { name: strategy.name, policy: strategy.policy, ...(strategy.policy === 'scripted' ? { actions: [...strategy.actions] } : {}) };
17
+ });
18
+ if (new Set(strategies.map(s => s.name)).size !== strategies.length) throw new Error('Strategy names must be unique.');
19
+ const config = { version: 1, seed: input.seed, runs: input.runs, max_steps: input.max_steps, workers: input.workers, strategies };
20
+ if (Buffer.byteLength(JSON.stringify(config)) > 2 * 1024 * 1024) throw new Error('Simulation configuration exceeds 2 MiB.');
21
+ return config;
22
+ }
23
+ export function configHash(config) { return hash({ version: 1, seed: config.seed, runs: config.runs, max_steps: config.max_steps, workers: config.workers, strategies: config.strategies.map(({ name, policy }) => ({ name, policy })) }); }
24
+ export function deriveSeed(seed, index) { return hash([seed, index]); }
25
+ function choose(state, strategy, index, seed) {
26
+ if (strategy.policy === 'scripted') {
27
+ const id = strategy.actions[index];
28
+ if (!id) throw new Error('Scripted strategy exhausted before terminal state.');
29
+ if (!state.actions.some(action => action.id === id)) throw new Error('Scripted strategy selected an illegal action.');
30
+ return id;
31
+ }
32
+ if (strategy.policy === 'scored') {
33
+ const choices = state.actions.filter(action => Number.isFinite(action.scores?.[strategy.name]));
34
+ if (!choices.length) throw new Error('Game supplied no scores for this strategy.');
35
+ return choices.sort((a, b) => b.scores[strategy.name] - a.scores[strategy.name] || compareText(a.id, b.id))[0].id;
36
+ }
37
+ const choices = [...state.actions].sort((a, b) => compareText(a.id, b.id));
38
+ return choices[parseInt(hash([seed, index]).slice(0, 12), 16) % choices.length].id;
39
+ }
40
+ function stateRecord(state) { return { done: state.done, state_hash: state.state_hash, ...(state.outcome === undefined ? {} : { outcome: state.outcome }), ...(state.metrics === undefined ? {} : { metrics: state.metrics }) }; }
41
+ async function writeJson(file, value) { await fs.writeFile(file, JSON.stringify(value) + '\n', { flag: 'wx', mode: 0o600 }); }
42
+ export async function probeBridge({ bridge, seed = 'preflight', strategy = 'random', max_steps = 1, signal }) {
43
+ if (!boundedString(seed, 128) || !boundedString(strategy, 80) || !Number.isInteger(max_steps) || max_steps < 1 || max_steps > 10000) throw new Error('Invalid simulation probe.');
44
+ const client = openBridge(bridge, { signal });
45
+ try { return await client.request('simulation.start', { version: 1, seed, strategy, max_steps }); }
46
+ finally { await client.stop(); }
47
+ }
48
+ export async function runBatch({ config: rawConfig, bridge: rawBridge, outputDir, playtest, signal, onProgress }) {
49
+ const config = validateConfig(rawConfig); const bridge = bridgeOptions(rawBridge);
50
+ if (typeof outputDir !== 'string' || !outputDir) throw new Error('Simulation output directory is required.');
51
+ outputDir = path.resolve(outputDir); await fs.mkdir(outputDir, { recursive: true });
52
+ const resultsPath = path.join(outputDir, 'results.jsonl'); const file = await fs.open(resultsPath, 'wx', 0o600);
53
+ const started = Date.now(); const controller = new AbortController(); let stopReason = null;
54
+ const cancel = () => { stopReason ||= 'cancelled'; controller.abort(); };
55
+ signal?.addEventListener('abort', cancel, { once: true }); if (signal?.aborted) cancel();
56
+ const timer = setTimeout(() => { stopReason ||= 'batch_deadline'; controller.abort(); }, bridge.batchTimeoutMs);
57
+ let next = 0, reservedSteps = 0, writeChain = Promise.resolve(), commitTail = Promise.resolve();
58
+ const traces = [];
59
+ const summary = { version: 1, seed: config.seed, config_hash: configHash(config), requested_runs: config.runs, completed_runs: 0, failed_runs: 0, truncated_runs: 0, total_steps: 0, strategies: config.strategies.map(s => ({ name: s.name, runs: 0, completed: 0, failed: 0, truncated: 0, outcomes: {}, metrics: {} })), trace_count: 0, duration_ms: 0 };
60
+ async function execute(index, priorCommit) {
61
+ const strategy = config.strategies[index % config.strategies.length]; const seed = deriveSeed(config.seed, index);
62
+ const row = { version: 1, run_index: index, seed, strategy: strategy.name, status: 'failed', steps: 0 };
63
+ const trace = index < 10 ? { version: 1, run_index: index, seed, strategy: strategy.name, max_steps: config.max_steps, local_config_hash: hash(config), states: [], actions: [] } : null;
64
+ let traceBytes = 1024, client, pendingAction, phase = 'start';
65
+ const record = state => {
66
+ if (!trace) return;
67
+ const item = stateRecord(state); traceBytes += Buffer.byteLength(JSON.stringify(item)) + 1;
68
+ if (traceBytes > MAX_TRACE_BYTES) { trace.truncated = true; return; }
69
+ trace.states.push(item);
70
+ };
71
+ try {
72
+ if (controller.signal.aborted) throw new Error('Simulation stopped before run started.');
73
+ client = openBridge(bridge, { signal: controller.signal, playtest });
74
+ let state = await client.request('simulation.start', { version: 1, seed, strategy: strategy.name, max_steps: config.max_steps }); record(state);
75
+ while (!state.done && row.steps < config.max_steps) {
76
+ if (reservedSteps >= MAX_ACTIONS) { stopReason ||= 'action_budget'; controller.abort(); throw new Error('Batch action budget exhausted.'); }
77
+ phase = 'policy';
78
+ const action = choose(state, strategy, row.steps, seed); reservedSteps++;
79
+ pendingAction = action; phase = 'step';
80
+ state = await client.request('simulation.step', { action }); pendingAction = undefined; row.steps++; summary.total_steps++;
81
+ if (trace && !trace.truncated) { trace.actions.push(action); traceBytes += Buffer.byteLength(JSON.stringify(action)) + 1; }
82
+ record(state);
83
+ }
84
+ row.status = state.done ? 'completed' : 'truncated';
85
+ if (state.done) { row.outcome = state.outcome || 'unspecified'; row.metrics = state.metrics || {}; }
86
+ } catch (error) { row.error = error.message.slice(0, 200); if (trace) { trace.failure_phase = phase; if (pendingAction) trace.failed_action = pendingAction; } }
87
+ finally { if (client) await client.stop(); }
88
+ // Commit in run-index order, retaining at most one result per worker. This
89
+ // makes floating-point aggregate order independent of process scheduling.
90
+ await priorCommit;
91
+ const aggregate = summary.strategies[index % config.strategies.length]; aggregate.runs++;
92
+ summary[`${row.status}_runs`]++; aggregate[row.status]++;
93
+ if (row.status === 'completed') {
94
+ // Bound aggregate cardinality even if a malicious adapter invents a new
95
+ // metric/outcome name in every run. Raw per-run values remain on disk.
96
+ const outcome = Object.hasOwn(aggregate.outcomes, row.outcome) || Object.keys(aggregate.outcomes).length < 127 ? row.outcome : 'other';
97
+ aggregate.outcomes[outcome] = (Object.hasOwn(aggregate.outcomes, outcome) ? aggregate.outcomes[outcome] : 0) + 1;
98
+ for (const [key, value] of Object.entries(row.metrics)) {
99
+ if (!Object.hasOwn(aggregate.metrics, key) && Object.keys(aggregate.metrics).length >= 32) continue;
100
+ if (!Object.hasOwn(aggregate.metrics, key)) aggregate.metrics[key] = { count: 0, min: value, max: value, mean: 0 };
101
+ const metric = aggregate.metrics[key];
102
+ metric.count++; metric.min = Math.min(metric.min, value); metric.max = Math.max(metric.max, value);
103
+ // Weighted arithmetic avoids overflow from subtracting opposite extremes.
104
+ metric.mean = Math.max(metric.min, Math.min(metric.max, metric.mean * ((metric.count - 1) / metric.count) + value / metric.count));
105
+ }
106
+ }
107
+ if (trace) { if (trace.truncated) trace.actions = trace.actions.slice(0, Math.max(0, trace.states.length - 1)); trace.status = row.status; trace.error = row.error; const target = path.join(outputDir, `trace-${String(index).padStart(5, '0')}.json`); await writeJson(target, trace); traces.push(target); }
108
+ writeChain = writeChain.then(() => file.write(JSON.stringify(row) + '\n')); await writeChain;
109
+ if (onProgress) await onProgress({ finished_runs: summary.completed_runs + summary.failed_runs + summary.truncated_runs, requested_runs: config.runs, completed_runs: summary.completed_runs, failed_runs: summary.failed_runs, truncated_runs: summary.truncated_runs, total_steps: summary.total_steps });
110
+ }
111
+ try {
112
+ const workers = Array.from({ length: Math.min(config.workers, config.runs) }, async () => {
113
+ try {
114
+ while (next < config.runs) {
115
+ const index = next++; const prior = commitTail; let release;
116
+ commitTail = new Promise(resolve => { release = resolve; });
117
+ try { await execute(index, prior); } finally { release(); }
118
+ }
119
+ }
120
+ catch (error) { controller.abort(); throw error; }
121
+ });
122
+ const outcomes = await Promise.allSettled(workers);
123
+ const failure = outcomes.find(item => item.status === 'rejected');
124
+ if (failure) throw failure.reason;
125
+ }
126
+ finally { clearTimeout(timer); signal?.removeEventListener('abort', cancel); controller.abort(); try { await writeChain; } finally { await file.close(); } }
127
+ summary.trace_count = traces.length; summary.duration_ms = Math.min(bridge.batchTimeoutMs, Date.now() - started);
128
+ traces.sort();
129
+ const report = { results_path: resultsPath, stop_reason: stopReason, determinism_note: 'Replay verifies recorded observations. Reproducibility requires the game to honor its seed; this runner cannot prove determinism.' };
130
+ await writeJson(path.join(outputDir, 'summary.json'), summary);
131
+ return { summary, traces, report };
132
+ }
133
+ export async function replayTrace({ trace, bridge, signal, outputDir }) {
134
+ if (typeof trace === 'string') { if ((await fs.stat(trace)).size > MAX_TRACE_BYTES + 1024) throw new Error('Trace exceeds replay limit.'); trace = JSON.parse(await fs.readFile(trace, 'utf8')); }
135
+ const failedStart = trace?.status === 'failed' && trace?.failure_phase === 'start' && trace?.states?.length === 0;
136
+ if (!trace || trace.version !== 1 || !boundedString(trace.seed, 128) || !boundedString(trace.strategy, 80) || !Number.isInteger(trace.max_steps) || trace.max_steps < 1 || trace.max_steps > 10000 || !Array.isArray(trace.actions) || trace.actions.length > 10000 || trace.actions.some(action => !boundedString(action, 128)) || !Array.isArray(trace.states) || (!failedStart && trace.states.length !== trace.actions.length + 1) || (failedStart && trace.actions.length) || (trace.failed_action !== undefined && !boundedString(trace.failed_action, 128))) throw new Error('Trace is invalid for replay.');
137
+ if (Buffer.byteLength(JSON.stringify(trace)) > MAX_TRACE_BYTES + 1024) throw new Error('Trace exceeds replay limit.');
138
+ for (const state of trace.states) observation({ ...state, actions: state.done ? [] : [{ id: 'replay-validation' }] });
139
+ const options = bridgeOptions(bridge);
140
+ const deadline = AbortSignal.timeout(options.batchTimeoutMs);
141
+ const replaySignal = signal ? AbortSignal.any([signal, deadline]) : deadline;
142
+ const client = openBridge(options, { signal: replaySignal });
143
+ let result = { matched: true, matched_prefix: false, fully_replayed: false, failure_reproduced: null, first_divergence: null };
144
+ const start = () => client.request('simulation.start', { version: 1, seed: trace.seed, strategy: trace.strategy, max_steps: trace.max_steps });
145
+ async function reproduce(request, step) {
146
+ try {
147
+ const actual = await request(); result.matched = false; result.failure_reproduced = false;
148
+ result.first_divergence = { step, reason: 'Recorded protocol failure did not recur.', actual: stateRecord(actual) };
149
+ } catch (error) {
150
+ result.failure_reproduced = error.message === trace.error;
151
+ result.matched = result.failure_reproduced;
152
+ if (!result.matched) result.first_divergence = { step, expected_error: trace.error, actual_error: error.message };
153
+ }
154
+ }
155
+ try {
156
+ if (failedStart) await reproduce(start, 0);
157
+ else {
158
+ for (let i = 0; i < trace.states.length; i++) {
159
+ let state;
160
+ try { state = i === 0 ? await start() : await client.request('simulation.step', { action: trace.actions[i - 1] }); }
161
+ catch (error) { result.matched = false; result.first_divergence = { step: i, actual_error: error.message }; break; }
162
+ const expected = trace.states[i]; const actual = stateRecord(state);
163
+ const canonical = value => JSON.stringify([value.state_hash, value.done, value.outcome ?? null, Object.entries(value.metrics || {}).sort(([a], [b]) => compareText(a, b))]);
164
+ if (canonical(expected) !== canonical(actual)) { result.matched = false; result.first_divergence = { step: i, expected, actual }; break; }
165
+ if (i < trace.actions.length && !state.actions.some(action => action.id === trace.actions[i])) { result.matched = false; result.first_divergence = { step: i, reason: 'Recorded action is no longer legal.' }; break; }
166
+ }
167
+ if (result.matched) {
168
+ result.matched_prefix = true;
169
+ result.fully_replayed = !trace.truncated && trace.status !== 'failed';
170
+ if (trace.status === 'failed' && trace.failed_action && !trace.truncated) await reproduce(() => client.request('simulation.step', { action: trace.failed_action }), trace.states.length);
171
+ }
172
+ }
173
+ } finally { await client.stop(); }
174
+ if (outputDir) { await fs.mkdir(outputDir, { recursive: true }); const file = path.resolve(outputDir, 'replay.json'); await writeJson(file, result); result.report_path = file; }
175
+ return result;
176
+ }
@@ -0,0 +1,90 @@
1
+ import { spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+ const forbidden = new Set(['__proto__', 'constructor', 'prototype']);
4
+ export const MAX_RESPONSE = 256 * 1024;
5
+ export function boundedString(value, max) { return typeof value === 'string' && value.length > 0 && value.length <= max && !forbidden.has(value); }
6
+ export function observation(value) {
7
+ if (!value || typeof value !== 'object' || Array.isArray(value) || typeof value.done !== 'boolean' || !boundedString(value.state_hash, 128) || !Array.isArray(value.actions) || value.actions.length > 256) throw new Error('Invalid simulation observation.');
8
+ const numbers = (map, limit, maxAbsolute = Infinity) => {
9
+ if (!map || typeof map !== 'object' || Array.isArray(map) || Object.keys(map).length > limit || Object.entries(map).some(([key, n]) => !boundedString(key, 80) || typeof n !== 'number' || (!Number.isFinite(n) || Math.abs(n) > maxAbsolute))) throw new Error('Invalid simulation numeric map.');
10
+ return { ...map };
11
+ };
12
+ const actions = value.actions.map(action => {
13
+ if (!action || !boundedString(action.id, 128)) throw new Error('Invalid simulation action.');
14
+ return { id: action.id, ...(action.scores === undefined ? {} : { scores: numbers(action.scores, 16) }) };
15
+ });
16
+ if (new Set(actions.map(action => action.id)).size !== actions.length) throw new Error('Duplicate simulation action.');
17
+ if (!value.done && !actions.length) throw new Error('Nonterminal simulation needs legal actions.');
18
+ if (value.outcome !== undefined && !boundedString(value.outcome, 80)) throw new Error('Invalid simulation outcome.');
19
+ return { done: value.done, state_hash: value.state_hash, actions, ...(value.metrics === undefined ? {} : { metrics: numbers(value.metrics, 32, 1e15) }), ...(value.outcome === undefined ? {} : { outcome: value.outcome }) };
20
+ }
21
+ export function bridgeOptions(bridge) {
22
+ // Native Windows has no POSIX process-group kill. Do not buy a batch whose
23
+ // adapter descendants we cannot reliably stop; WSL uses the Linux path.
24
+ if (process.platform === 'win32') throw new Error('Simulation Runner is not supported on native Windows. Run it in WSL, Linux or macOS so cancellation can stop the adapter process group.');
25
+ if (!bridge || !path.isAbsolute(bridge.command || '') || (bridge.args !== undefined && (!Array.isArray(bridge.args) || bridge.args.length > 256 || bridge.args.some(arg => typeof arg !== 'string' || arg.length > 8192))) || (bridge.cwd !== undefined && !path.isAbsolute(bridge.cwd))) throw new Error('Bridge needs an absolute command, string arguments and optional absolute cwd.');
26
+ const timeoutMs = bridge.timeoutMs ?? 30000; const batchTimeoutMs = bridge.batchTimeoutMs ?? 900000;
27
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30000 || !Number.isInteger(batchTimeoutMs) || batchTimeoutMs < 1 || batchTimeoutMs > 900000) throw new Error('Invalid simulation deadline.');
28
+ if (bridge.env !== undefined && (!bridge.env || typeof bridge.env !== 'object' || Array.isArray(bridge.env) || Object.entries(bridge.env).some(([k, v]) => !boundedString(k, 128) || typeof v !== 'string' || v.length > 8192))) throw new Error('Invalid bridge environment.');
29
+ return { ...bridge, timeoutMs, batchTimeoutMs };
30
+ }
31
+ export function childEnvironment(extra = {}, playtest = {}) {
32
+ // Local adapters are trusted code, not a security sandbox. Avoid accidentally
33
+ // forwarding account/provider credentials or inherited synthetic identities.
34
+ const env = {};
35
+ for (const [key, value] of Object.entries({ ...process.env, ...extra })) {
36
+ if (/TOKEN|SECRET|PASSWORD|CREDENTIAL|API.?KEY|PRIVATE.?KEY|AUTH|^AWS_|^AZURE_|^GOOGLE_|^ANTHROPIC_|^OPENAI_|^NODE_OPTIONS$|^RAVENSIGHT_.*(?:RUN|JOB)/i.test(key)) continue;
37
+ env[key] = value;
38
+ }
39
+ env.RAVENSIGHT_PLAYTEST_TOKEN = typeof playtest.token === 'string' && playtest.token ? playtest.token : 'invalid-simulation-preflight-token';
40
+ env.RAVENSIGHT_PLAYTEST_RUN_ID = String(playtest.runId || 'local-simulation');
41
+ env.RAVENSIGHT_PLAYTEST_JOB_ID = String(playtest.jobId || 'local-simulation');
42
+ return env;
43
+ }
44
+ export function openBridge(raw, { signal, playtest } = {}) {
45
+ const bridge = bridgeOptions(raw);
46
+ const child = spawn(bridge.command, bridge.args || [], { cwd: bridge.cwd, shell: false, detached: process.platform !== 'win32', env: childEnvironment(bridge.env, playtest), stdio: ['pipe', 'pipe', 'pipe'] });
47
+ let buffer = Buffer.alloc(0), id = 0, pending, failure, closing;
48
+ const kill = sig => { try { if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, sig); else child.kill(sig); } catch {} };
49
+ const fail = message => { failure ||= new Error(message); if (pending) { clearTimeout(pending.timer); pending.reject(failure); pending = null; } };
50
+ const stop = () => {
51
+ if (closing) return closing;
52
+ fail('Simulation bridge stopped.'); signal?.removeEventListener('abort', abort);
53
+ kill('SIGTERM');
54
+ // Always escalate against the process group, including when child.killed is
55
+ // true or the leader exited while a descendant still owns its pipes.
56
+ closing = new Promise(resolve => setTimeout(() => { kill('SIGKILL'); child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); resolve(); }, 100));
57
+ return closing;
58
+ };
59
+ const abort = () => { fail('Simulation cancelled.'); void stop(); };
60
+ signal?.addEventListener('abort', abort, { once: true });
61
+ if (signal?.aborted) abort();
62
+ child.stderr.resume();
63
+ child.on('error', () => { fail('Simulation bridge could not start.'); void stop(); });
64
+ child.on('exit', () => { fail('Simulation bridge exited.'); });
65
+ child.stdin.on('error', () => { fail('Simulation bridge input failed.'); void stop(); });
66
+ child.stdout.on('data', chunk => {
67
+ buffer = Buffer.concat([buffer, chunk]);
68
+ if (buffer.length > MAX_RESPONSE) { fail('Simulation response exceeds 256 KB.'); void stop(); return; }
69
+ let end;
70
+ while ((end = buffer.indexOf(10)) >= 0) {
71
+ const line = buffer.subarray(0, end); buffer = buffer.subarray(end + 1);
72
+ try {
73
+ const message = JSON.parse(line.toString('utf8'), (key, value) => { if (forbidden.has(key)) throw new Error(); return value; });
74
+ if (!pending || message.jsonrpc !== '2.0' || message.id !== pending.id || message.error || !Object.hasOwn(message, 'result')) throw new Error();
75
+ const result = observation(message.result); const entry = pending; pending = null; clearTimeout(entry.timer); entry.resolve(result);
76
+ } catch { fail('Invalid simulation protocol response.'); void stop(); return; }
77
+ }
78
+ });
79
+ return {
80
+ request(method, params) {
81
+ if (failure) return Promise.reject(failure);
82
+ if (pending) return Promise.reject(new Error('Simulation request already pending.'));
83
+ return new Promise((resolve, reject) => {
84
+ const requestId = ++id;
85
+ pending = { id: requestId, resolve, reject, timer: setTimeout(() => { fail('Simulation response timed out.'); void stop(); }, bridge.timeoutMs) };
86
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: requestId, method, params }) + '\n');
87
+ });
88
+ }, stop
89
+ };
90
+ }