ravensight-playtest 0.3.2 → 0.5.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.
@@ -1,8 +1,10 @@
1
+ import { recordingForCompletion, recordingCompletionBody } from '../upload/recording.js';
1
2
  import { randomUUID } from 'node:crypto';
2
3
  import { mkdir, readFile, stat } from 'node:fs/promises';
3
4
  import path from 'node:path';
4
5
  import { buildAggregate, writeAggregate } from '../run/aggregate.js';
5
6
  import { optionsFrom, unknownOptionNotes } from '../run/args.js';
7
+ import { controllerPlan, quoteMatchesPlan, runsMatchPlan } from '../run/controllers.js';
6
8
  import { getDeps } from '../run/deps.js';
7
9
  import { createDriver } from '../run/drivers/index.js';
8
10
  import EXIT from '../run/exit.js';
@@ -94,10 +96,13 @@ async function run(flags, injected, deps, log) {
94
96
  if (setup.code !== undefined) return setup.code;
95
97
  const { context, gameId, repoRoot, apiUrl, cliVersion, api } = setup;
96
98
 
97
- if (options.controller && !['standard', 'jev'].includes(options.controller)) { log('Choose --controller standard or --controller jev.'); return EXIT.ENVIRONMENT; }
99
+ let controllers;
100
+ try { controllers = controllerPlan(options); } catch (error) { log(error.message); return EXIT.ENVIRONMENT; }
101
+ options.controllers = controllers;
98
102
  const keyModifier = resolveKeyModifier(options.keyModifier);
99
103
  if (keyModifier.error) { log(keyModifier.error); return EXIT.ENVIRONMENT; }
100
104
  const driverName = options.driver || (context.config && context.config.driver) || 'playwright_web';
105
+ if (controllers?.length > 1 && !['playwright_web', 'godot_driver'].includes(driverName)) { log('Comparisons currently require a web or Godot build. Nothing was charged.'); return EXIT.ENVIRONMENT; }
101
106
  const target = resolveTarget(options, context.config, driverName);
102
107
  if (!target) {
103
108
  log('Point the run at a build: --build-url for a web build, --godot-project for Godot, or --driver-config for a native or text bridge.');
@@ -147,7 +152,7 @@ async function run(flags, injected, deps, log) {
147
152
  }
148
153
  const modules = [...RUN_MODULES];
149
154
  const pack = await deps.packs.get({ api, gameId, modules });
150
- if (options.controller === 'jev' && !pack.routing?.action_controllers?.includes('jev')) { log('This server does not currently support the requested action controller. No job was registered.'); return EXIT.ENVIRONMENT; }
155
+ if (controllers && (!pack.routing?.controller_plans || controllers.some(controller => !pack.routing?.action_controllers?.includes(controller)))) { log('This server does not currently support the requested controller plan. No job was registered or charged.'); return EXIT.ENVIRONMENT; }
151
156
  const personaSlugs = resolvePersonas(options, context.config, pack);
152
157
  const unknownSlugs = unknownPersonas(personaSlugs, pack.personas || []);
153
158
  if (unknownSlugs.length > 0) {
@@ -161,7 +166,7 @@ async function run(flags, injected, deps, log) {
161
166
  // fetch, nothing charged. `pack.routing` is the clientRouting subset, and an
162
167
  // older cached pack without `max_personas` skips the check rather than
163
168
  // refusing on a field that does not exist yet.
164
- const personaRefusal = personaCountRefusal(personaSlugs.length, pack.routing && pack.routing.max_personas);
169
+ const personaRefusal = personaCountRefusal(personaSlugs.length * (controllers?.length || 1), pack.routing && pack.routing.max_personas);
165
170
  if (personaRefusal) {
166
171
  log(personaRefusal);
167
172
  return EXIT.ENVIRONMENT;
@@ -192,7 +197,7 @@ async function run(flags, injected, deps, log) {
192
197
  const runLength = options.runLength === 'long' ? 'long' : 'standard';
193
198
  let estimate;
194
199
  try {
195
- estimate = await api.jobs.estimate(gameId, { modules, personas: personaSlugs, runLength });
200
+ estimate = await api.jobs.estimate(gameId, { modules, personas: personaSlugs, runLength, ...(controllers ? { controllers, driver: driverName } : {}) });
196
201
  } catch (error) {
197
202
  if (error && error.code === 'invalid_estimate') {
198
203
  log(`The server refused to price this job: ${String(error.message || 'invalid estimate')}${runLength === 'long' ? ' This server may not price long runs yet; run without --long.' : ''} Nothing was registered or charged.`);
@@ -200,6 +205,9 @@ async function run(flags, injected, deps, log) {
200
205
  }
201
206
  throw error;
202
207
  }
208
+ if (!quoteMatchesPlan(estimate, controllers, personaSlugs)) { log('The server did not quote every requested controller run. Nothing was registered or charged.'); return EXIT.ENVIRONMENT; }
209
+ if (controllers) log(`Controllers: ${controllers.join(' + ')}. ${personas.length * controllers.length} independent playthrough(s); each run is included in the total quote.`);
210
+ if (controllers?.length > 1) log('Each controller gets a fresh game process or browser session. Server-side game progress and randomness may differ; comparison is evidence, not proof of identical starting state.');
203
211
  // A long run is its own unit on the server. An older server that ignores
204
212
  // run_length prices a standard run, and confirming that would buy a run
205
213
  // the CLI then plays as long.
@@ -245,6 +253,7 @@ async function run(flags, injected, deps, log) {
245
253
  registered = await api.jobs.register(gameId, {
246
254
  modules,
247
255
  personas: personaSlugs,
256
+ ...(controllers ? { controllers } : {}),
248
257
  driver: driverName,
249
258
  cli_version: cliVersion,
250
259
  confirm_price_cents: estimate.estimate_cents,
@@ -280,6 +289,8 @@ async function run(flags, injected, deps, log) {
280
289
  runLength,
281
290
  ...(runLength === 'long' ? { longRunFactor: options.longRunFactor } : {}),
282
291
  controller: options.controller || 'standard',
292
+ ...(controllers ? { controllers } : {}),
293
+ recordVideo: Boolean(options.recordVideo || options.uploadVideo),
283
294
  concurrency: options.concurrency ?? DEFAULT_CONCURRENCY,
284
295
  uploadVideo: Boolean(options.uploadVideo),
285
296
  uploadTranscript: Boolean(options.uploadTranscript)
@@ -291,6 +302,10 @@ async function run(flags, injected, deps, log) {
291
302
  }
292
303
  }
293
304
  await stateFile.save(repoRoot, journal);
305
+ if (!runsMatchPlan(registered.runs || [], controllers, personaSlugs)) {
306
+ log(`The registered controller plan does not match the quote. Job ${jobId} is saved for reconciliation; no playthrough or model call was started.`);
307
+ return EXIT.FAILED;
308
+ }
294
309
  log(`job ${jobId}${registered.replayed ? ' (replayed)' : ''}: ${formatCents(registered.price && registered.price.price_cents)} charged. ${directory}`);
295
310
 
296
311
  return playJob({
@@ -451,7 +466,7 @@ export async function playJob({
451
466
 
452
467
  const results = [];
453
468
  const queue = personaRuns.filter(entry => !stateFile.isRunDone(journal.runs[entry.run_id]));
454
- const concurrency = WEBDRIVER_NAMES.includes(driverName) ? 1 : Math.max(1, Math.min(Number(options.concurrency) || DEFAULT_CONCURRENCY, queue.length || 1));
469
+ const concurrency = (options.controllers?.length > 1 || WEBDRIVER_NAMES.includes(driverName)) ? 1 : Math.max(1, Math.min(Number(options.concurrency) || DEFAULT_CONCURRENCY, queue.length || 1));
455
470
 
456
471
  /**
457
472
  * Runs the process is in the middle of. A Ctrl-C has to move these to
@@ -462,7 +477,8 @@ export async function playJob({
462
477
  * back out of, which is exactly what it is for.
463
478
  */
464
479
  const inFlight = new Map();
465
- const interrupt = createInterruptHandler({ api, gameId, jobId, repoRoot, journal, inFlight, log, injected });
480
+ const abort = new AbortController();
481
+ const interrupt = createInterruptHandler({ api, gameId, jobId, repoRoot, journal, inFlight, log, injected, onInterrupt: () => abort.abort() });
466
482
 
467
483
  try {
468
484
  await Promise.all(Array.from({ length: concurrency }, async () => {
@@ -483,7 +499,7 @@ export async function playJob({
483
499
  pack,
484
500
  driverName,
485
501
  target,
486
- options,
502
+ options: { ...options, signal: abort.signal },
487
503
  journal,
488
504
  inFlight,
489
505
  makeDriver,
@@ -633,7 +649,8 @@ export async function playPersonaRun({
633
649
  try {
634
650
  if (inFlight) inFlight.set(runId, { state: 'launching' });
635
651
  outcome = await runPersona({
636
- controller: options.controller || 'standard',
652
+ controller: entry.controller || options.controller || 'standard',
653
+ signal: options.signal,
637
654
  client,
638
655
  api,
639
656
  gameId,
@@ -654,7 +671,7 @@ export async function playPersonaRun({
654
671
  maxActions,
655
672
  heartbeat,
656
673
  ledger,
657
- recordVideo: Boolean(options.uploadVideo),
674
+ recordVideo: Boolean(options.recordVideo || options.uploadVideo),
658
675
  // Written the moment the warning arrives, not after the run: a run that
659
676
  // then fails still says in state.json that it was warned.
660
677
  onBudgetWarning: async () => {
@@ -668,6 +685,10 @@ export async function playPersonaRun({
668
685
  });
669
686
  } catch (error) {
670
687
  heartbeat.stop();
688
+ if (options.signal?.aborted) {
689
+ await stateFile.patchRun(repoRoot, journal, runId, { state: 'interrupted', completed: false, actions_taken: error.outcome?.actionsTaken || 0 });
690
+ return { runId, persona: persona.slug, state: 'interrupted', error };
691
+ }
671
692
  if (inFlight) inFlight.delete(runId);
672
693
  const failed = error.outcome || { state: 'failed', quitReason: 'error', actionsTaken: 0, report: null, reportLint: [], reachedState: 'playing' };
673
694
  await stateFile.patchRun(repoRoot, journal, runId, {
@@ -726,6 +747,10 @@ export async function playPersonaRun({
726
747
  return { runId, persona: persona.slug, state: failed.state, quitReason: failed.quitReason, error };
727
748
  }
728
749
  heartbeat.stop();
750
+ if (options.signal?.aborted) {
751
+ await stateFile.patchRun(repoRoot, journal, runId, { state: 'interrupted', completed: false, actions_taken: outcome.actionsTaken, report_written: Boolean(outcome.report) });
752
+ return { runId, persona: persona.slug, state: 'interrupted' };
753
+ }
729
754
  if (inFlight) inFlight.delete(runId);
730
755
 
731
756
  await stateFile.patchRun(repoRoot, journal, runId, {
@@ -804,6 +829,9 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
804
829
  // The run card's duration. The server lane accepts `duration`; an older
805
830
  // server ignores a key it does not know.
806
831
  if (outcome.duration) body.duration = durationBody(outcome.duration);
832
+ // Keep the same bounded opt-in status in the older-server partial-report fallback.
833
+ const recording = options.uploadVideo === true ? recordingForCompletion(outcome.recording) : null;
834
+ Object.assign(body, await recordingCompletionBody(directory, recording ? { recording } : {}, options.uploadVideo === true));
807
835
  if (outcome.report) body.report = outcome.report;
808
836
  // `report_md` is what the server lints for its four required sections. Not
809
837
  // sending it means `report_lint` is silently empty on every run, which reads
@@ -1135,7 +1163,7 @@ async function publishAggregate({ deps, api, gameId, jobId, repoRoot, directory,
1135
1163
  *
1136
1164
  * @returns {{signalled: boolean, settle: () => Promise<void>, release: () => void}}
1137
1165
  */
1138
- export function createInterruptHandler({ api, gameId, jobId, repoRoot, journal, inFlight, log, message = null, injected = {} }) {
1166
+ export function createInterruptHandler({ api, gameId, jobId, repoRoot, journal, inFlight, log, message = null, injected = {}, onInterrupt = () => {} }) {
1139
1167
  const signals = injected.signals || ['SIGINT', 'SIGTERM'];
1140
1168
  const target = injected.process || process;
1141
1169
  const state = { signalled: false, signal: null };
@@ -1144,6 +1172,7 @@ export function createInterruptHandler({ api, gameId, jobId, repoRoot, journal,
1144
1172
  if (state.signalled) return;
1145
1173
  state.signalled = true;
1146
1174
  state.signal = signal;
1175
+ onInterrupt(signal);
1147
1176
  log(`\n${signal}: ${message || `finishing the turn in flight, then stopping. Nothing is lost; "ravensight-playtest resume ${jobId}" carries on.`}`);
1148
1177
  // Hand the signal back to the default behaviour, so a second press kills
1149
1178
  // the process rather than being swallowed by a handler that is now done.
@@ -1285,15 +1314,15 @@ export async function loadRunReports(repoRoot, jobId, personaRuns, journal = nul
1285
1314
  // summary must not count findings the dashboard does not have.
1286
1315
  const recorded = journal && journal.runs ? journal.runs[entry.run_id] : null;
1287
1316
  if (recorded && recorded.report_refused === true) {
1288
- out.push({ runId: entry.run_id, persona: entry.persona, state: 'failed', report: null, reportRefused: true });
1317
+ out.push({ runId: entry.run_id, persona: entry.persona, ...(entry.controller ? { controller: entry.controller } : {}), state: 'failed', report: null, reportRefused: true });
1289
1318
  continue;
1290
1319
  }
1291
1320
  const file = path.join(runDirFor(repoRoot, jobId, entry.run_id), RUN_FILES.reportJson);
1292
1321
  try {
1293
1322
  const report = JSON.parse(await readFile(file, 'utf8'));
1294
- out.push({ runId: entry.run_id, persona: entry.persona, state: report && report.partial === true ? 'budget_exceeded' : 'succeeded', report });
1323
+ out.push({ runId: entry.run_id, persona: entry.persona, ...(entry.controller ? { controller: entry.controller } : {}), state: recorded?.state || entry.state || (report && report.partial === true ? 'budget_exceeded' : 'succeeded'), report });
1295
1324
  } catch {
1296
- out.push({ runId: entry.run_id, persona: entry.persona, state: 'failed', report: null });
1325
+ out.push({ runId: entry.run_id, persona: entry.persona, ...(entry.controller ? { controller: entry.controller } : {}), state: 'failed', report: null });
1297
1326
  }
1298
1327
  }
1299
1328
  return out;
@@ -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
+ }
@@ -1,5 +1,5 @@
1
1
  import { ProxyError, STEPS } from './model.js';
2
- export const CONTROLLERS = ['standard', 'jev'];
2
+ export { CONTROLLERS } from './controllers.js';
3
3
  export const JEV_CONTROLLER_PROMPT = '\nRavensight Playtester action-selection mode: describe the currently visible screen briefly in text, including each proposed target and its screenshot coordinates, then propose one to three ALTERNATIVE next actions as tool calls. Only one will be executed after a separate selector chooses. These are alternatives, never a sequential plan. Use only observed controls. For a canvas game without accessible DOM controls, include a target label describing the visible control AND screenshot x/y coordinates; the driver uses those coordinates. Never invent selectors such as textbox or button names. When the brief identifies a field as a public player label, use Ravensight Playtester. Credentials or private access codes require human review. When the game is loading or transitioning, propose a bounded wait tool call. Do not place secrets in proposals. After their tool results, observe the resulting state before proposing again.';
4
4
  export function describeAction(tool) {
5
5
  const input = tool.input || {};
@@ -14,7 +14,7 @@ export function describeAction(tool) {
14
14
  };
15
15
  return String(descriptions[tool.name] || `${tool.name}: ${JSON.stringify(input)}`).slice(0, 300);
16
16
  }
17
- export async function selectAction({ api, jobId, runId, persona, observation, toolUses, recentActions = [], intentNotes = [] }) {
17
+ export async function selectAction({ api, jobId, runId, persona, observation, toolUses, controller = 'jev', recentActions = [], intentNotes = [] }) {
18
18
  const proposals = toolUses.slice(0, 8);
19
19
  // Observation does not input anything into the game. Keep it in the existing
20
20
  // bounded driver loop; Jev selects game inputs, not whether a frame may load.
@@ -22,6 +22,7 @@ export async function selectAction({ api, jobId, runId, persona, observation, to
22
22
  return { status: 'selected', toolId: proposals[0].id, source: 'observation' };
23
23
  }
24
24
  const body = {
25
+ controller,
25
26
  objective: ('Play the game according to the supplied persona, exploring visible controls and gathering evidence. Developer scope: ' + intentNotes.map(note => note.text || '').join(' ')).slice(0, 1000),
26
27
  // The observer already applies the full persona. The selector needs its identity,
27
28
  // current visible state and proposed inputs, not a second personality prompt.
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { compareControllers, comparisonMarkdown } from './comparison.js';
2
3
  import { fillPlaceholders } from './placeholders.js';
3
4
  import { writeFile } from 'node:fs/promises';
4
5
  import path from 'node:path';
@@ -458,7 +459,9 @@ export async function buildAggregate({
458
459
  usage: ledger ? aggregateUsage(ledger) : { usd: 0, by_model: {} }
459
460
  };
460
461
 
461
- return { json, markdown: renderAggregateMarkdown(json) };
462
+ const comparison = compareControllers(runs);
463
+ if (comparison) json.controller_comparison = comparison;
464
+ return { json, markdown: renderAggregateMarkdown(json) + comparisonMarkdown(comparison) };
462
465
  }
463
466
 
464
467
  /**
package/src/run/args.js CHANGED
@@ -16,6 +16,7 @@ const VALUED = new Map([
16
16
  ['--driver', 'driver'],
17
17
  ['--driver-config', 'driverConfig'],
18
18
  ['--controller', 'controller'],
19
+ ['--compare-with', 'compareWith'],
19
20
  ['--build-url', 'buildUrl'],
20
21
  ['--build-dir', 'buildDir'],
21
22
  ['--godot-project', 'godotProject'],
@@ -29,6 +30,7 @@ const VALUED = new Map([
29
30
  /** Options that are just present or absent. */
30
31
  const BOOLEAN = new Map([
31
32
  ['--upload-video', 'uploadVideo'],
33
+ ['--record-video', 'recordVideo'],
32
34
  ['--upload-transcript', 'uploadTranscript'],
33
35
  ['--dry-upload', 'dryUpload'],
34
36
  ['--offline-upload', 'offlineUpload'],
@@ -0,0 +1,32 @@
1
+ // Deterministic comparison of the reports already paid for. No new model call.
2
+ export function compareControllers(runs) {
3
+ const planned = runs.filter(run => run.controller);
4
+ const controllers = [...new Set(planned.map(run => run.controller))];
5
+ if (controllers.length < 2) return null;
6
+ const rows = planned.map(run => ({
7
+ run_id: run.runId, persona: run.persona, controller: run.controller,
8
+ state: run.state, report_available: Boolean(run.report),
9
+ partial: !run.report || run.report.partial === true || run.state !== 'succeeded',
10
+ actions_taken: run.report?.actions_taken ?? null,
11
+ }));
12
+ const groups = new Map();
13
+ for (const run of planned) for (const finding of run.report?.findings || []) {
14
+ if (finding.dropped || !Array.isArray(finding.evidence) || !finding.evidence.length) continue;
15
+ const key = JSON.stringify([run.persona, finding.category || '', String(finding.title || '').trim().toLowerCase().replace(/\s+/g, ' ')]);
16
+ if (!groups.has(key)) groups.set(key, { title: finding.title, persona: run.persona, category: finding.category, occurrences: [] });
17
+ groups.get(key).occurrences.push({ run_id: run.runId, controller: run.controller, evidence: finding.evidence });
18
+ }
19
+ const findings = [...groups.values()].map(group => ({ ...group,
20
+ match: new Set(group.occurrences.map(row => row.controller)).size > 1 ? 'reported_by_multiple_controllers' : 'reported_by_one_controller' }));
21
+ return { schema_version: '1.0', controllers, status: rows.some(row => row.partial) ? 'inconclusive' : 'reports_complete',
22
+ limits: ['Controllers use separate game sessions. Server saves and random state are not proven identical.', 'Matches use the same persona, category and exact normalized title. Agreement does not verify a bug; absence does not prove a pass.'], runs: rows, findings };
23
+ }
24
+ export function comparisonMarkdown(comparison) {
25
+ if (!comparison) return '';
26
+ const safe = value => String(value ?? '').replace(/[\r\n|]/g, ' ');
27
+ return '\n\n## Controller comparison\n\n' + comparison.limits.join(' ') + '\n\n' +
28
+ (comparison.status === 'inconclusive' ? 'At least one playthrough is missing or partial. This comparison is inconclusive.\n\n' : '') +
29
+ '| Controller | Persona | Run | Report |\n| --- | --- | --- | --- |\n' +
30
+ comparison.runs.map(row => `| ${safe(row.controller)} | ${safe(row.persona)} | ${safe(row.run_id)} | ${row.partial ? 'Incomplete' : 'Complete'} |`).join('\n') +
31
+ '\n\n' + comparison.findings.map(group => `- ${safe(group.title)}: ${[...new Set(group.occurrences.map(row => row.controller))].join(', ')} (${group.occurrences.map(row => safe(row.run_id)).join(', ')}).`).join('\n');
32
+ }
@@ -0,0 +1,17 @@
1
+ export const CONTROLLERS = ['standard', 'jev', 'rune'];
2
+ export function controllerPlan(options = {}) {
3
+ if (options.compareWith && options.controller) throw new Error('Use --controller for one playthrough or --compare-with for a comparison.');
4
+ if (!options.compareWith && !options.controller) return null;
5
+ const plan = options.compareWith ? ['standard', ...String(options.compareWith).split(',')] : [options.controller];
6
+ if (plan.length > 3 || plan.some(x => !CONTROLLERS.includes(x)) || new Set(plan).size !== plan.length) throw new Error('Choose standard, jev or rune once each. --compare-with jev runs standard and Jev separately.');
7
+ return plan;
8
+ }
9
+ export function quoteMatchesPlan(quote, plan, personas) {
10
+ return !plan || (Array.isArray(quote.controllers) && JSON.stringify(quote.controllers) === JSON.stringify(plan) && quote.persona_run_count === plan.length * personas.length);
11
+ }
12
+ export function runsMatchPlan(runs, plan, personas) {
13
+ if (!plan) return true;
14
+ const actual = runs.filter(run => run.module === 'persona_playtest').map(run => `${run.persona}/${run.controller}`).sort();
15
+ const expected = personas.flatMap(persona => plan.map(controller => `${persona}/${controller}`)).sort();
16
+ return JSON.stringify(actual) === JSON.stringify(expected);
17
+ }
@@ -18,7 +18,7 @@ import { createHash } from 'node:crypto';
18
18
  * observe() -> Observation
19
19
  * act(action) -> ActResult
20
20
  * screenshot(name, opts) -> { path, relativePath }
21
- * stop() -> { videoPath?: string|null, framesDir?: string }
21
+ * stop({ reason }) -> { videoPath?: string|null, recording?: Object }
22
22
  * ```
23
23
  *
24
24
  * Optional, used when present:
@@ -37,10 +37,10 @@ import { createHash } from 'node:crypto';
37
37
  * description in ../tools.js says so, because the model is the one
38
38
  * choosing.
39
39
  * - **`stop()` may return no video.** `playwright_web` hands back a
40
- * `session.webm` from Playwright's own recorder. `godot_driver` returns
41
- * `videoPath: null` and leaves a `frames/` sequence, which the CLI
42
- * stitches only when ffmpeg is present. That is what keeps ffmpeg
43
- * optional, and it means "no video" is a normal outcome, not a failure.
40
+ * `session.webm` from Playwright's own recorder. `godot_driver` encodes
41
+ * timestamped viewport frames to `session.mp4` with optional local ffmpeg.
42
+ * `recording.status` distinguishes complete, partial, unsupported and failed
43
+ * capture; absence of an encoder or rendered viewport never masquerades as video.
44
44
  *
45
45
  * `Observation.scene`, `.scenes` and `.scripts` are how a finding from a
46
46
  * running Godot game can cite a `res://` path; the web driver leaves them