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.
- package/README.md +77 -2
- package/docs/SIMULATION.md +91 -0
- package/docs/godot-driver.md +310 -0
- package/examples/simulation/economy.mjs +22 -0
- package/package.json +7 -2
- package/src/api/client.js +2 -2
- package/src/cli.js +21 -1
- package/src/commands/run.js +42 -13
- package/src/commands/simulate.js +298 -0
- package/src/run/actionSelector.js +3 -2
- package/src/run/aggregate.js +4 -1
- package/src/run/args.js +2 -0
- package/src/run/comparison.js +32 -0
- package/src/run/controllers.js +17 -0
- package/src/run/drivers/driver.js +5 -5
- package/src/run/drivers/godot.js +131 -39
- package/src/run/drivers/recording.js +148 -0
- package/src/run/drivers/web.js +128 -66
- package/src/run/personaLoop.js +17 -10
- package/src/run/resume.js +16 -5
- package/src/simulation/index.js +176 -0
- package/src/simulation/protocol.js +90 -0
- package/src/upload/index.js +4 -2
- package/src/upload/recording.js +56 -0
package/src/commands/run.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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.
|
package/src/run/aggregate.js
CHANGED
|
@@ -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
|
-
|
|
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()
|
|
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`
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|