ravensight-playtest 0.3.1 → 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/src/run/report.js CHANGED
@@ -207,6 +207,191 @@ export function normaliseEvidence(report, runDirectory = null) {
207
207
  return { report: { ...report, findings }, changed };
208
208
  }
209
209
 
210
+ /** Category words a model writes that are not in the schema, mapped to one that is, when the schema has it. */
211
+ export const CATEGORY_SYNONYMS = Object.freeze({
212
+ ui: 'ux', usability: 'ux', onboarding: 'ux', clarity: 'ux', visual: 'ux', visuals: 'ux', feedback: 'ux', navigation: 'ux', polish: 'ux', design: 'ux',
213
+ crash: 'bug', error: 'bug', defect: 'bug', broken: 'bug', functional: 'bug', functionality: 'bug', network: 'bug',
214
+ perf: 'performance', lag: 'performance', fps: 'performance', loading: 'performance',
215
+ a11y: 'accessibility',
216
+ level: 'level_design', map: 'level_design', 'level design': 'level_design', 'level-design': 'level_design',
217
+ text: 'content', story: 'content', narrative: 'content', writing: 'content', copy: 'content', audio: 'content', art: 'content',
218
+ economy: 'balance', difficulty: 'balance', progression: 'balance'
219
+ });
220
+
221
+ /** The severity an out of range one becomes. */
222
+ export const NORMALISED_SEVERITY = 'minor';
223
+
224
+ /**
225
+ * The category an unknown one becomes: `other` when the schema has it, else a
226
+ * catch-all the schema names, else `ux`. The server's schema today has no
227
+ * catch-all (bug, ux, balance, content, performance, accessibility,
228
+ * level_design), so an unknown word that no synonym places becomes `ux`, the
229
+ * category most unplaced findings turn out to be.
230
+ *
231
+ * @param {Array<string>} categories the schema's enum
232
+ * @returns {string}
233
+ */
234
+ export function catchAllCategory(categories) {
235
+ for (const candidate of ['other', 'misc', 'general', 'uncategorized']) if (categories.includes(candidate)) return candidate;
236
+ return categories.includes('ux') ? 'ux' : categories[0];
237
+ }
238
+
239
+ /**
240
+ * The enum lists and defaults of the report schema's root and of one finding,
241
+ * read from the schema itself so a pack update moves them.
242
+ *
243
+ * @param {Object} schema the playtest-report schema
244
+ * @returns {{severities: Array<string>, categories: Array<string>, root: Object, finding: Object}}
245
+ */
246
+ export function enumFields(schema) {
247
+ const props = (schema && schema.properties) || {};
248
+ const item = (props.findings && props.findings.items && props.findings.items.properties) || {};
249
+ const enumsOf = properties => Object.fromEntries(Object.entries(properties)
250
+ .filter(([, spec]) => spec && Array.isArray(spec.enum))
251
+ .map(([key, spec]) => [key, { values: spec.enum, ...(spec.default !== undefined ? { default: spec.default } : {}) }]));
252
+ return {
253
+ severities: (item.severity && item.severity.enum) || [],
254
+ categories: (item.category && item.category.enum) || [],
255
+ root: enumsOf(props),
256
+ finding: enumsOf(item)
257
+ };
258
+ }
259
+
260
+ /**
261
+ * Bring known out of range values back into the schema before upload,
262
+ * instead of losing the run to them. The server's schema stays strict; this
263
+ * only rewrites what the model's own words say plainly:
264
+ *
265
+ * - a value that differs from an allowed one only in case or spacing takes
266
+ * the allowed spelling (`Major` is `major`);
267
+ * - a severity that is a category word (`"severity": "ux"`) moves to
268
+ * `category` when the category is missing or not allowed, and the severity
269
+ * becomes `minor`; a severity that is anything else unknown becomes `minor`;
270
+ * - a category that is a severity word, with the severity out of range,
271
+ * swaps back; any other unknown category becomes a synonym the schema has,
272
+ * else `other` (or the catch-all, see {@link catchAllCategory});
273
+ * - any other enum field, at the root or on a finding, with a value the
274
+ * schema does not allow takes the schema's `default` when it has one.
275
+ *
276
+ * Every change is returned, and recorded on the finding as a runner note at
277
+ * the end of its description. The Texas round lost a run to `"severity": "ux"`
278
+ * when the model's budget ran out before its repair turn.
279
+ *
280
+ * @param {Object} report
281
+ * @param {Object} schema the playtest-report schema
282
+ * @returns {{report: Object, changes: Array<{id: string|null, field: string, from: any, to: any, note: string}>}}
283
+ */
284
+ export function normaliseEnums(report, schema) {
285
+ if (!report || typeof report !== 'object' || !schema) return { report, changes: [] };
286
+ const fields = enumFields(schema);
287
+ const changes = [];
288
+ const spelled = (value, allowed) => {
289
+ if (typeof value !== 'string') return null;
290
+ const key = value.trim().toLowerCase().replace(/[\s-]+/g, '_');
291
+ return allowed.find(entry => String(entry).toLowerCase() === key) ?? null;
292
+ };
293
+ const fixEnum = (target, key, spec, id) => {
294
+ if (!(key in target) || spec.values.includes(target[key])) return;
295
+ const respelled = spelled(target[key], spec.values);
296
+ const to = respelled !== null ? respelled : spec.default;
297
+ if (to === undefined) return;
298
+ changes.push({ id, field: key, from: target[key], to, note: `${key} ${JSON.stringify(target[key])} is not allowed, so it became ${JSON.stringify(to)}` });
299
+ target[key] = to;
300
+ };
301
+
302
+ const root = { ...report };
303
+ for (const [key, spec] of Object.entries(fields.root)) fixEnum(root, key, spec, null);
304
+
305
+ const { severities, categories } = fields;
306
+ if (Array.isArray(root.findings)) {
307
+ root.findings = root.findings.map(original => {
308
+ if (!original || typeof original !== 'object' || Array.isArray(original)) return original;
309
+ const finding = { ...original };
310
+ const id = typeof finding.id === 'string' ? finding.id : null;
311
+ const notes = [];
312
+ const record = (field, from, to, note) => {
313
+ changes.push({ id, field, from, to, note });
314
+ notes.push(note);
315
+ };
316
+ if (severities.length > 0 && categories.length > 0) {
317
+ const severityOk = severities.includes(finding.severity);
318
+ const categoryOk = categories.includes(finding.category);
319
+ const severityRespelled = severityOk ? finding.severity : spelled(finding.severity, severities);
320
+ const categoryRespelled = categoryOk ? finding.category : spelled(finding.category, categories);
321
+ const severityAsCategory = severityRespelled === null ? spelled(finding.severity, categories) : null;
322
+ const categoryAsSeverity = categoryRespelled === null ? spelled(finding.category, severities) : null;
323
+ if (severityRespelled !== null && !severityOk) record('severity', finding.severity, severityRespelled, `severity ${JSON.stringify(finding.severity)} became ${JSON.stringify(severityRespelled)}`);
324
+ if (categoryRespelled !== null && !categoryOk) record('category', finding.category, categoryRespelled, `category ${JSON.stringify(finding.category)} became ${JSON.stringify(categoryRespelled)}`);
325
+ let severity = severityRespelled;
326
+ let category = categoryRespelled;
327
+ if (severity === null && category === null && severityAsCategory !== null && categoryAsSeverity !== null) {
328
+ // The two were written in each other's slot.
329
+ record('severity', finding.severity, categoryAsSeverity, `severity ${JSON.stringify(finding.severity)} and category ${JSON.stringify(finding.category)} were swapped, so they were put back`);
330
+ record('category', finding.category, severityAsCategory, `category ${JSON.stringify(finding.category)} is a severity word, so it moved to severity`);
331
+ severity = categoryAsSeverity;
332
+ category = severityAsCategory;
333
+ }
334
+ if (severity === null && severityAsCategory !== null && category === null) {
335
+ record('category', finding.category === undefined ? null : finding.category, severityAsCategory, `severity ${JSON.stringify(finding.severity)} is a category, so it moved to category`);
336
+ category = severityAsCategory;
337
+ }
338
+ if (severity === null) {
339
+ record('severity', finding.severity === undefined ? null : finding.severity, NORMALISED_SEVERITY, `severity ${JSON.stringify(finding.severity === undefined ? null : finding.severity)} is not a severity, so it became ${NORMALISED_SEVERITY}`);
340
+ severity = NORMALISED_SEVERITY;
341
+ }
342
+ if (category === null) {
343
+ const word = typeof finding.category === 'string' ? finding.category.trim().toLowerCase() : '';
344
+ const synonym = CATEGORY_SYNONYMS[word] && categories.includes(CATEGORY_SYNONYMS[word]) ? CATEGORY_SYNONYMS[word] : null;
345
+ const to = synonym || catchAllCategory(categories);
346
+ record('category', finding.category === undefined ? null : finding.category, to, `category ${JSON.stringify(finding.category === undefined ? null : finding.category)} is not a category, so it became ${to}`);
347
+ category = to;
348
+ }
349
+ finding.severity = severity;
350
+ finding.category = category;
351
+ }
352
+ for (const [key, spec] of Object.entries(fields.finding)) {
353
+ if (key === 'severity' || key === 'category') continue;
354
+ const before = changes.length;
355
+ fixEnum(finding, key, spec, id);
356
+ if (changes.length > before) notes.push(changes[changes.length - 1].note);
357
+ }
358
+ if (notes.length > 0) {
359
+ const line = `Runner note: ${notes.join('; ')}.`;
360
+ const description = typeof finding.description === 'string' ? finding.description : '';
361
+ finding.description = description ? `${description}\n\n${line}` : line;
362
+ }
363
+ return finding;
364
+ });
365
+ }
366
+ return { report: changes.length > 0 ? root : report, changes };
367
+ }
368
+
369
+ /** How report.md lists what the runner normalised. */
370
+ export const NORMALISED_HEADING = '## Normalised by the runner (values the report schema does not allow)';
371
+
372
+ /**
373
+ * report.md agreeing with report.json about normalised values: each change is
374
+ * listed under {@link NORMALISED_HEADING}, replacing a list written before.
375
+ *
376
+ * @param {string} markdown
377
+ * @param {Array<{id: string|null, note: string}>} changes
378
+ * @returns {string}
379
+ */
380
+ export function appendNormalisedMarkdown(markdown, changes) {
381
+ if (!Array.isArray(changes) || changes.length === 0) return markdown;
382
+ let body = String(markdown || '');
383
+ const at = body.indexOf(NORMALISED_HEADING);
384
+ if (at !== -1) {
385
+ // A list written before is replaced: from its heading to the next
386
+ // second level heading, or the end.
387
+ const next = body.indexOf('\n## ', at + NORMALISED_HEADING.length);
388
+ body = body.slice(0, at) + (next === -1 ? '' : body.slice(next + 1));
389
+ }
390
+ body = body.replace(/\s+$/, '');
391
+ const lines = changes.map(change => `- ${change.id ? `${change.id}: ` : ''}${change.note}.`);
392
+ return `${body}\n\n${NORMALISED_HEADING}\n${lines.join('\n')}\n`;
393
+ }
394
+
210
395
  /**
211
396
  * Check every `screenshot` evidence ref against the files actually on disk.
212
397
  *
@@ -301,6 +486,8 @@ export function repairInstruction(details, evidenceProblems, missingSections = [
301
486
 
302
487
  export default {
303
488
  createReportValidator,
489
+ normaliseEnums,
490
+ appendNormalisedMarkdown,
304
491
  stampReport,
305
492
  checkEvidence,
306
493
  normaliseEvidence,
package/src/run/resume.js CHANGED
@@ -149,6 +149,8 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
149
149
  build: { kind: driverName === 'playwright_web' ? 'url' : 'godot_project', ref: target },
150
150
  repo: { commit: '', dirty: false },
151
151
  max_actions: (journal.options && journal.options.maxActions) ?? null,
152
+ // The same body as the first register, so a long job replays as long.
153
+ ...(journal.options?.runLength === 'long' ? { run_length: 'long' } : {}),
152
154
  pack_versions: { pack: pack.pack_version }
153
155
  }, { idempotencyKey: journal.idempotency_key });
154
156
 
@@ -165,6 +167,9 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
165
167
  ...options,
166
168
  controller: journal.options?.controller || options.controller || 'standard',
167
169
  maxActions: (journal.options && journal.options.maxActions) ?? options.maxActions,
170
+ // The journal's answer, never the flag: a job is long or standard for good.
171
+ runLength: journal.options?.runLength === 'long' ? 'long' : 'standard',
172
+ ...(Number.isInteger(journal.options?.longRunFactor) ? { longRunFactor: journal.options.longRunFactor } : {}),
168
173
  concurrency: options.concurrency ?? (journal.options && journal.options.concurrency),
169
174
  uploadVideo: options.uploadVideo ?? (journal.options && journal.options.uploadVideo),
170
175
  uploadTranscript: options.uploadTranscript ?? (journal.options && journal.options.uploadTranscript)
package/src/run/tools.js CHANGED
@@ -83,7 +83,7 @@ export const PLAY_TOOLS = Object.freeze([
83
83
  additionalProperties: false,
84
84
  properties: {
85
85
  ref: { type: 'string', description: 'playwright_web only, the preferred target: the ref shown on the element, such as e12.' },
86
- target: { type: 'string', description: 'web: button "Start" or Start Game. Godot: a node path such as /root/Main/UI/StartButton.' },
86
+ target: { type: 'string', description: 'web: button "Start" or Start Game; a whole line copied from the screen also works, and is clicked by its ref, else its @(x,y). Text that is not on the screen is refused at once. Godot: a node path such as /root/Main/UI/StartButton.' },
87
87
  x: { type: 'number' },
88
88
  y: { type: 'number' },
89
89
  thought_in_character: thought,
@@ -0,0 +1,90 @@
1
+ /**
2
+ * What a plain `run` asks for, in one place, so `brief`, `check` and `run`
3
+ * quote the same job. Kept free of runner imports: `check` and `brief` load
4
+ * this without pulling in the drivers.
5
+ */
6
+
7
+ /**
8
+ * Personas used when the developer names none. One since 0.3.2: a first run
9
+ * should cost one persona run, and a second persona is one `--personas` away.
10
+ * Spec 17 defaulted to two, which made the default run cost twice what most
11
+ * customers expected to pay for a first look.
12
+ */
13
+ export const DEFAULT_PERSONAS = Object.freeze(['curious-kid']);
14
+
15
+ /** The modules `run` registers. `aggregate_report` is free and buys the aggregate's steps. */
16
+ export const RUN_MODULES = Object.freeze(['persona_playtest', 'aggregate_report']);
17
+
18
+ /**
19
+ * Actions a sign up form alone took in customer runs (an email, a password, a
20
+ * confirm, a name, the submit, and the clicks between them), which is why a
21
+ * brief's test account matters on a short action budget.
22
+ */
23
+ export const SIGN_UP_ACTIONS = 8;
24
+
25
+ /**
26
+ * The default persona set as far as this pack has them: the defaults it
27
+ * carries, else its first persona.
28
+ *
29
+ * @param {Array<{slug: string}>} [available]
30
+ * @returns {Array<string>}
31
+ */
32
+ export function defaultPersonasFor(available = null) {
33
+ if (!Array.isArray(available)) return [...DEFAULT_PERSONAS];
34
+ const slugs = available.map(persona => persona && persona.slug).filter(Boolean);
35
+ const defaults = DEFAULT_PERSONAS.filter(slug => slugs.includes(slug));
36
+ return defaults.length > 0 ? defaults : slugs.slice(0, 1);
37
+ }
38
+
39
+ /** "1 persona", "3 personas". */
40
+ export function personaCount(count) {
41
+ return `${count} persona${count === 1 ? '' : 's'}`;
42
+ }
43
+
44
+ /**
45
+ * An estimate's breakdown in words, every unit named with its own price:
46
+ * "profile job $4.00, a persona run $1.00 each (x2)".
47
+ *
48
+ * @param {Array<{unit: string, count: number, unit_cents: number}>} breakdown
49
+ * @param {(cents: number) => string} money
50
+ * @returns {string}
51
+ */
52
+ export function describeBreakdown(breakdown, money) {
53
+ const parts = [];
54
+ for (const line of Array.isArray(breakdown) ? breakdown : []) {
55
+ if (!line || !(Number(line.count) > 0)) continue;
56
+ const price = money(line.unit_cents);
57
+ const times = Number(line.count) > 1 ? ` (x${line.count})` : '';
58
+ if (line.unit === 'playtest_run') parts.push(`a persona run ${price} each${times}`);
59
+ else if (line.unit === 'playtest_run_long') parts.push(`a long persona run ${price} each${times}`);
60
+ else if (line.unit === 'playtest_profile') parts.push(`profile job ${price}${times}`);
61
+ else if (line.unit === 'playtest_fix') parts.push(`a fix attempt ${price} each${times}`);
62
+ else parts.push(`${line.unit} ${price}${times}`);
63
+ }
64
+ return parts.join(', ');
65
+ }
66
+
67
+ /**
68
+ * The persona slugs in a list that the pack does not have.
69
+ *
70
+ * @param {Array<string>} slugs
71
+ * @param {Array<{slug: string}>} available
72
+ * @returns {Array<string>}
73
+ */
74
+ export function unknownPersonas(slugs, available) {
75
+ const known = new Set((available || []).map(persona => persona && persona.slug));
76
+ return (slugs || []).filter(slug => !known.has(slug));
77
+ }
78
+
79
+ /**
80
+ * "unknown persona "x"; the valid slugs are a, b and c. "ravensight-playtest personas" lists them."
81
+ *
82
+ * @param {Array<string>} unknown
83
+ * @param {Array<{slug: string}>} available
84
+ * @returns {string}
85
+ */
86
+ export function unknownPersonaMessage(unknown, available) {
87
+ const slugs = (available || []).map(persona => persona.slug);
88
+ const listed = slugs.length > 1 ? `${slugs.slice(0, -1).join(', ')} and ${slugs[slugs.length - 1]}` : slugs.join('');
89
+ return `unknown persona${unknown.length === 1 ? '' : 's'} ${unknown.map(slug => JSON.stringify(slug)).join(', ')}; the valid slugs are ${listed || '(none in this pack)'}. "ravensight-playtest personas" lists them.`;
90
+ }
@@ -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
+ }
@@ -100,6 +100,9 @@ export async function listStates(options = {}) {
100
100
  state
101
101
  });
102
102
  }
103
- found.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)));
103
+ // Two journals written in the same millisecond tie on updated_at; the job
104
+ // id breaks the tie (descending, like the time), so the order never
105
+ // depends on how the filesystem lists the directory.
106
+ found.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)) || String(b.job_id).localeCompare(String(a.job_id)));
104
107
  return found;
105
108
  }
package/src/ui/index.js CHANGED
@@ -184,6 +184,19 @@ export async function choose(question, labels, options = {}) {
184
184
  }
185
185
  }
186
186
 
187
+ /**
188
+ * Bold on a terminal that shows it, plain everywhere else (a pipe, CI, a
189
+ * log file, NO_COLOR), so a saved log never carries escape codes.
190
+ *
191
+ * @param {string} text
192
+ * @param {{isTTY?: boolean}} [target] the stream it will be written to
193
+ * @returns {string}
194
+ */
195
+ export function bold(text, target = stream) {
196
+ if (process.env.NO_COLOR || !(target && target.isTTY)) return String(text);
197
+ return `\u001b[1m${text}\u001b[22m`;
198
+ }
199
+
187
200
  /** Cents as dollars, the one place that formatting exists. */
188
201
  export function money(cents) {
189
202
  if (cents === null || cents === undefined) return 'unknown';
@@ -219,5 +232,6 @@ export const ui = {
219
232
  confirm,
220
233
  choose,
221
234
  money,
222
- bytes
235
+ bytes,
236
+ bold
223
237
  };