ravensight-playtest 0.7.0 → 0.7.2

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,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { mkdir, open, access, rename } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { CliError } from '../errors.js';
4
+ import { CliError, ExitCode } from '../errors.js';
5
5
  import { ApiError } from '../api/errors.js';
6
6
  import { stableStringify } from '../fsutil.js';
7
7
 
@@ -140,7 +140,7 @@ export function hostedQuoteLines(offer) {
140
140
  ...q.localBreakdown.map(row => `${row.count} ${row.unit} at ${money(row.unitCents)} each: ${money(row.count * row.unitCents)}.`),
141
141
  ...q.computeBreakdown.map(row => `${row.controller} hosted compute: ${money(row.priceCents)}.`),
142
142
  `Total ${money(q.totalPriceCents)}; already reserved ${money(q.alreadyReservedCents)}; due now ${money(q.dueNowCents)} from prepaid funds. No top-up is performed.`,
143
- `Video: ${r.includeVideo ? 'included' : 'off'}; transcript: ${r.includeTranscript ? 'included' : 'off'}.`,
143
+ `Video: ${r.includeVideo ? 'included' : 'off'}. Hosted runs save no transcript.`,
144
144
  `Customer failure: ${q.customerFailure}; cancellation after start: ${q.cancelAfterStart}. Price ${q.priceVersion}; refund policy ${q.policyVersion}.`,
145
145
  `Quote expires ${new Date(q.expiresAt).toISOString()}.`];
146
146
  }
@@ -157,8 +157,42 @@ export async function runHostedQuote({ filename, api, apiUrl, repoRoot, confirm,
157
157
  if (profile.driver !== offer.request.driver || funding?.eligible === false || offer.quote.funding?.eligible === false) fail('The server no longer advertises this profile or sufficient prepaid funding. Nothing was charged.');
158
158
  await onPrepared(offer);
159
159
  if (typeof confirm !== 'function') fail('The full hosted quote must be confirmed before admission.');
160
- if (!await confirm(`Pay ${money(offer.quote.dueNowCents)} now (${money(offer.quote.totalPriceCents)} total) for the quoted Standard then Jev runs and the evidence options shown?`)) return { state: 'declined', walletMutationRequested: false };
160
+ if (!await confirm(confirmQuestion(offer))) return { state: 'declined', walletMutationRequested: false };
161
+ return admitHostedOffer({ offer, api, apiUrl, repoRoot, now });
162
+ }
163
+
164
+ export const confirmQuestion = offer => `Pay ${money(offer.quote.dueNowCents)} now (${money(offer.quote.totalPriceCents)} total) for the quoted Standard then Jev runs and the evidence options shown?`;
165
+
166
+ // Refusals the server makes before any charge: the quote or consent was not
167
+ // accepted, funding or capacity said no, or the quote is gone. The original
168
+ // intent is kept on disk all the same; only the message differs.
169
+ const DEFINITE_REFUSALS = new Set(['hosted_funding_required', 'quote_not_accepted', 'not_found', 'recorded_unavailable',
170
+ 'recorded_setup_required', 'hosted_global_capacity', 'too_many_active_hosted_runs', 'hosted_daily_capacity',
171
+ 'hosted_build_too_large', 'hosted_build_safety_hold']);
172
+
173
+ export function hostedRefusalMessage(error) {
174
+ const code = error?.code;
175
+ if (code === 'hosted_funding_required') {
176
+ const f = error.body?.funding;
177
+ const topUp = Number.isSafeInteger(f?.requiredTopUpCents) && f.requiredTopUpCents > 0 ? ` Add at least ${money(f.requiredTopUpCents)} of prepaid funds, then request a new quote.` : ' Add prepaid funds, then request a new quote.';
178
+ return `The server refused the run for funding. Nothing was charged.${topUp}`;
179
+ }
180
+ if (code === 'quote_not_accepted') return 'The server did not accept this consent for the quote. Nothing was charged; request a new quote.';
181
+ if (['hosted_global_capacity', 'too_many_active_hosted_runs'].includes(code)) return 'Hosted capacity is busy. Nothing was charged. Wait for an existing run to finish, then retry. Adding funds does not increase capacity.';
182
+ if (code === 'hosted_daily_capacity') return 'Today\'s hosted run allowance is used up. Nothing was charged. Try again after the daily limit resets.';
183
+ if (code === 'recorded_setup_required') return `${error.message || 'Complete the playtest brief and choose an available persona.'} Nothing was charged.`;
184
+ if (code === 'not_found') return 'The quote is no longer available. Nothing was charged; request a new quote.';
185
+ if (code === 'hosted_build_too_large') return 'This build is too large to play on a hosted machine: hosted playtests play builds up to 300 MiB unpacked. Make the build smaller and upload it again. Nothing was charged.';
186
+ if (code === 'hosted_build_safety_hold') return 'This build was stopped for safety during a hosted playtest and cannot run again until an admin reviews and clears it in the dashboard. Nothing was charged.';
187
+ return 'Hosted playtests are not available right now. Nothing was charged.';
188
+ }
189
+
190
+ // A saved durable intent is never retried. The journal is fsynced before the
191
+ // only wallet-mutating POST and the server receives a stable key for the plan.
192
+ export async function admitHostedOffer({ offer, api, apiUrl, repoRoot, now = Date.now }) {
161
193
  validateHostedOffer(offer, apiUrl, now());
194
+ const identity = digest({ apiUrl: offer.apiUrl, gameId: offer.gameId, planHash: offer.quote.planHash });
195
+ const journal = path.join(repoRoot, '.ravensight', 'hosted-runs', `${identity}.json`);
162
196
  const state = { schema: 'ravensight-hosted-admission-journal/v1', apiUrl: offer.apiUrl, gameId: offer.gameId,
163
197
  quoteId: offer.quote.quoteId, planHash: offer.quote.planHash, consent: offer.consent,
164
198
  idempotencyKey: `hosted-${offer.quote.planHash}`, phase: 'admission_unknown', createdAt: new Date(now()).toISOString() };
@@ -186,6 +220,101 @@ export async function runHostedQuote({ filename, api, apiUrl, repoRoot, confirm,
186
220
  return { state: 'charged_blocked', ...ack, journal, replayAuthorized: false, gameplayProven: false };
187
221
  } catch (error) {
188
222
  if (error instanceof ApiError && error.status === 401) throw error;
223
+ if (error instanceof ApiError && DEFINITE_REFUSALS.has(error.code) && error.status >= 400 && error.status < 600) {
224
+ throw new CliError(hostedRefusalMessage(error), ExitCode.ENVIRONMENT);
225
+ }
189
226
  fail(`The original admission outcome needs reconciliation. Preserve ${journal}; no retry or replacement admission was requested.`);
190
227
  }
191
228
  }
229
+
230
+ const ELIGIBLE_BUILD_STATE = 'graphical_static_validated';
231
+
232
+ /**
233
+ * The builds a recorded hosted run can use: static-checked, unexpired and on a
234
+ * profile the server currently advertises as ready. Newest first, the same
235
+ * filter the dashboard's recorded comparison form applies.
236
+ */
237
+ export function eligibleHostedBuilds(builds, profiles, now = Date.now()) {
238
+ const rows = Array.isArray(builds) ? builds : [];
239
+ return rows.filter(build => build && build.kind === 'recorded_graphical' && build.state === ELIGIBLE_BUILD_STATE
240
+ && id(build.buildId) && !(Date.parse(build.expiresAt) <= now)
241
+ && profiles.some(p => p.profileVersion === build.profileVersion && p.format === build.format))
242
+ .sort((a, b) => (Date.parse(b.createdAt) || 0) - (Date.parse(a.createdAt) || 0));
243
+ }
244
+
245
+ /**
246
+ * Quote a recorded Standard then Jev run without a saved quote file: pick the
247
+ * build (the newest eligible one unless named), ask for a quote, check it, and
248
+ * hand back the same offer shape `hosted quote` saves. Charges nothing.
249
+ */
250
+ export async function prepareHostedRun({ api, apiUrl, gameId, buildId, persona, includeVideo = false, includeTranscript = false, standardProvider, now = Date.now }) {
251
+ if (!id(gameId)) fail('Choose a game. Nothing was charged.');
252
+ const answer = await api.request('GET', `${base(gameId)}/run-capabilities`, { idempotent: true });
253
+ const recorded = answer.body?.recorded;
254
+ if (!recorded?.available || recorded.intakeEnabled !== true || !Array.isArray(recorded.profiles)) {
255
+ fail('Hosted playtests are not available for this game right now. Nothing was charged.');
256
+ }
257
+ const listed = await api.request('GET', `${base(gameId)}/builds`, { idempotent: true });
258
+ const eligible = eligibleHostedBuilds(listed.body?.builds, recorded.profiles, now());
259
+ let build;
260
+ if (buildId) {
261
+ build = eligible.find(row => row.buildId === buildId);
262
+ if (!build) fail(`Build ${buildId} is not ready for a hosted run: it has to pass its static archive check on a ready profile and not have expired. Nothing was charged.`);
263
+ } else {
264
+ build = eligible[0];
265
+ if (!build) fail('No build is ready for a hosted run. Upload one with "build upload" and wait for its static archive check. Nothing was charged.');
266
+ }
267
+ const { profile, funding } = await available(api, gameId, build.profileVersion, standardProvider);
268
+ const request = requestFor({ buildId: build.buildId, profileVersion: build.profileVersion, persona, standardProvider, includeVideo, includeTranscript }, profile);
269
+ const response = await api.request('POST', `${base(gameId)}/quotes`, { body: request, idempotent: false });
270
+ if (response.status !== 200) fail('The server did not return a hosted quote. Nothing was charged.');
271
+ const quote = response.body, consent = hostedConsent(quote, request, now());
272
+ const value = { apiUrl: origin(apiUrl), gameId, request, quote, consent };
273
+ const offer = { schema: 'ravensight-hosted-recorded-quote/v1', ...value, integrity: digest(value) };
274
+ return { offer, build, funding: quote.funding || funding || null };
275
+ }
276
+
277
+ /** True when the server says the balance does not meet this run's funding requirement. */
278
+ export function fundingShortfall(quote, funding) {
279
+ const f = quote?.funding || funding;
280
+ if (f && f.eligible === false) return { requiredTopUpCents: Number.isSafeInteger(f.requiredTopUpCents) ? f.requiredTopUpCents : null };
281
+ return null;
282
+ }
283
+
284
+ /** Plain statements a person accepts with the price. Each is something the server enforces or the quote states. */
285
+ export function hostedConsentLines(offer) {
286
+ const q = offer.quote;
287
+ return [
288
+ 'One persona plays the same build twice, once with Standard and once with Jev, on isolated hosted machines. These are AI playtests, not human feedback.',
289
+ 'The game runs without network access. Shared capacity, daily limits and the quoted runtime bounds still apply; funding does not guarantee an immediate start.',
290
+ `If the build fails, hosted compute is ${q.customerFailure === 'refund' ? 'refunded' : 'kept'}. Canceling after a confirmed start ${q.cancelAfterStart === 'refund' ? 'refunds' : 'keeps'} that run's compute charge. Unstarted work is refunded during settlement.`,
291
+ 'The build\'s validation hold is separate and is returned after safe cleanup. It does not reduce this price.',
292
+ ...(Number.isSafeInteger(q.minimumBalanceCents) ? [`Balance needed to start: ${money(q.minimumBalanceCents)}. Only the total above is charged.`] : [])
293
+ ];
294
+ }
295
+
296
+ const admissionPattern = /^ha_[a-f0-9-]{36}$/;
297
+
298
+ /** Admission, job and runs for one hosted run. A job or runs not readable yet reads as absent. */
299
+ export async function readHostedRun({ api, gameId, admissionId }) {
300
+ if (!admissionPattern.test(admissionId || '')) fail('Name a hosted run id (ha_...), as "hosted run" printed it.');
301
+ const admission = (await api.request('GET', `${base(gameId)}/runs/${encodeURIComponent(admissionId)}`, { idempotent: true })).body;
302
+ if (!admission || admission.admissionId !== admissionId) fail('The server answered for a different hosted run.');
303
+ let job = null, runs = [];
304
+ if (admission.jobId) {
305
+ const jobPath = `/api/v1/games/${encodeURIComponent(gameId)}/playtest/jobs/${encodeURIComponent(admission.jobId)}`;
306
+ const soft = async promise => { try { return (await promise).body; } catch (error) { if (error instanceof ApiError && error.status === 404) return null; throw error; } };
307
+ const [jobBody, runsBody] = await Promise.all([
308
+ soft(api.request('GET', jobPath, { idempotent: true })),
309
+ soft(api.request('GET', `${jobPath}/runs`, { idempotent: true }))
310
+ ]);
311
+ job = jobBody?.job || null;
312
+ runs = Array.isArray(runsBody?.runs) ? runsBody.runs : [];
313
+ }
314
+ return { admission, job, runs };
315
+ }
316
+
317
+ export async function cancelHostedRun({ api, gameId, admissionId }) {
318
+ if (!admissionPattern.test(admissionId || '')) fail('Name a hosted run id (ha_...), as "hosted run" printed it.');
319
+ return (await api.request('POST', `${base(gameId)}/runs/${encodeURIComponent(admissionId)}/cancel`, { body: {}, idempotent: false })).body;
320
+ }
@@ -0,0 +1,108 @@
1
+ // Customer-facing progress for a hosted admission plus its ordinary Playtest job.
2
+ //
3
+ // Mirrors dashboard/src/lib/hostedPlaytestStatus.js in the platform repo
4
+ // (game_tracker), so the CLI and the dashboard use the same phases and the same
5
+ // words. Change both together. The admission owns the provider and payment
6
+ // lifecycle; the job and its runs own gameplay, report capture and aggregation.
7
+
8
+ const FAILED = new Set(['failed', 'budget_exceeded', 'interrupted', 'stalled', 'refused']);
9
+ const CANCELED = new Set(['canceled']);
10
+ const boundedReason = value => String(value || '').replace(/\s+/g, ' ').trim().slice(0, 240);
11
+
12
+ export const HOSTED_PHASES = Object.freeze(['queued', 'provisioning', 'running', 'capturing', 'analyzing', 'report-ready', 'failed', 'canceled']);
13
+ export const TERMINAL_PHASES = Object.freeze(['report-ready', 'failed', 'canceled']);
14
+
15
+ /**
16
+ * @param {object} admission GET /hosted/runs/:admissionId
17
+ * @param {object|null} [job] GET /jobs/:jobId `job`, or null when not readable yet
18
+ * @param {Array<object>} [runs] GET /jobs/:jobId/runs `runs`
19
+ * @param {number} [now]
20
+ */
21
+ export function hostedPlaytestStatus(admission, job = null, runs = [], now = Date.now()) {
22
+ if (!admission) return null;
23
+ const runRows = Array.isArray(runs) ? runs : [];
24
+ const jobState = job?.state || '';
25
+ const admissionState = admission.executionState || admission.state || '';
26
+ const cancelRequested = admission.cancelRequested === true || job?.cancel_requested === true;
27
+ const aggregate = runRows.find(row => row.module === 'aggregate_report');
28
+ const plays = runRows.filter(row => row.module !== 'aggregate_report');
29
+
30
+ let phase = 'queued';
31
+ let label = 'Queued';
32
+ let detail = 'Waiting for hosted capacity.';
33
+
34
+ if (CANCELED.has(jobState) || CANCELED.has(admissionState)) {
35
+ phase = 'canceled'; label = 'Canceled'; detail = 'The hosted run was canceled.';
36
+ } else if (FAILED.has(jobState) || FAILED.has(admissionState) || job?.summary_report === 'failed') {
37
+ phase = 'failed'; label = 'Failed'; detail = 'The hosted run stopped before a report was ready.';
38
+ } else if (job?.summary_report === 'ready' || admissionState === 'recorded_closed') {
39
+ phase = 'report-ready'; label = 'Report ready'; detail = 'The hosted run and report are complete.';
40
+ } else if (['aggregating', 'uploading'].includes(jobState) || ['reporting', 'uploading'].includes(aggregate?.state)) {
41
+ phase = 'analyzing'; label = 'Analyzing'; detail = 'Combining the completed playtests into the comparison report.';
42
+ } else if (plays.some(row => ['reporting', 'uploading'].includes(row.state))) {
43
+ phase = 'capturing'; label = 'Capturing results'; detail = 'Saving the playtest report, evidence and requested media.';
44
+ } else if (plays.some(row => row.state === 'playing') || jobState === 'running') {
45
+ phase = 'running'; label = 'Running'; detail = 'The hosted game session is in progress.';
46
+ } else if (plays.some(row => row.state === 'launching') || jobState === 'registered' || admissionState === 'dispatched') {
47
+ phase = 'provisioning'; label = 'Provisioning'; detail = 'Preparing isolated hosted compute for this run.';
48
+ }
49
+
50
+ const startedAt = Date.parse(job?.created_at || admission.createdAt || '');
51
+ const finishedAt = Date.parse(job?.finished_at || '');
52
+ const elapsedSeconds = Number.isFinite(startedAt)
53
+ ? Math.max(0, Math.floor(((Number.isFinite(finishedAt) ? finishedAt : Number(now)) - startedAt) / 1000))
54
+ : null;
55
+ const failure = phase === 'failed'
56
+ ? boundedReason(job?.state_reason || admission.failureReason || admission.stopReason)
57
+ : '';
58
+
59
+ return {
60
+ phase,
61
+ label,
62
+ detail,
63
+ elapsedSeconds,
64
+ failure,
65
+ cancelRequested,
66
+ settled: admission.settled === true,
67
+ // A terminal job can still be settling provider cleanup and refunds. The
68
+ // admission is the authoritative signal that a new paid run is safe.
69
+ terminal: admission.settled === true && TERMINAL_PHASES.includes(phase)
70
+ };
71
+ }
72
+
73
+ const MODE_NAMES = Object.freeze({ standard: 'Standard', jev: 'Jev' });
74
+
75
+ /**
76
+ * One row per hosted mode (Standard, Jev), from the job's persona runs. A mode
77
+ * whose run the server has not created yet reads as not started.
78
+ * @param {Array<object>} runs
79
+ * @param {Array<string>} [controllers]
80
+ */
81
+ export function modeOutcomes(runs = [], controllers = ['standard', 'jev']) {
82
+ const plays = (Array.isArray(runs) ? runs : []).filter(row => row.module !== 'aggregate_report');
83
+ return controllers.map(controller => {
84
+ const run = plays.find(row => row.controller === controller) || null;
85
+ const report = run?.report || null;
86
+ return {
87
+ mode: controller,
88
+ name: MODE_NAMES[controller] || controller,
89
+ runId: run?.run_id || null,
90
+ state: run?.state || 'not_started',
91
+ reason: run?.state_reason ? boundedReason(run.state_reason) : null,
92
+ reportValid: report ? report.valid === true : false,
93
+ findings: report && Number.isSafeInteger(report.findings_count) ? report.findings_count : null,
94
+ wouldContinue: report && typeof report.would_continue === 'boolean' ? report.would_continue : null,
95
+ recording: run?.recording?.status || null
96
+ };
97
+ });
98
+ }
99
+
100
+ /** "Standard: completed, 4 findings, video saved." */
101
+ export function modeLine(row) {
102
+ const parts = [row.state.replace(/_/g, ' ')];
103
+ if (row.findings !== null) parts.push(`${row.findings} finding${row.findings === 1 ? '' : 's'}`);
104
+ if (row.wouldContinue !== null) parts.push(row.wouldContinue ? 'would keep playing' : 'would stop playing');
105
+ if (row.recording) parts.push(`video ${row.recording}`);
106
+ if (row.reason) parts.push(row.reason);
107
+ return `${row.name}: ${parts.join(', ')}.`;
108
+ }
@@ -232,11 +232,12 @@ export async function judgeClusters({ client, jobId, runId, clusters, ledger = n
232
232
  }
233
233
  } catch (error) {
234
234
  // A dedup that could not be judged is not worth failing a job over: the
235
- // local clustering is the fallback and it is deterministic. A budget
236
- // refusal in particular has to reach the caller though, because it
237
- // decides the exit code.
238
- if (error instanceof ProxyError && (error.isBudget || error.isClosed)) throw error;
239
- log(`dedup pass skipped: ${String(error && error.message)}`);
235
+ // local clustering is the fallback and it is deterministic. That includes
236
+ // a budget refusal: the aggregate is the job's report and always comes
237
+ // back, and the exit code is decided by the persona runs, not by this
238
+ // pass. Only a closed job stops it.
239
+ if (error instanceof ProxyError && error.isClosed) throw error;
240
+ log(`dedup pass skipped${error instanceof ProxyError && error.isBudget ? ' (the aggregate run is out of model budget; the local clustering stands)' : ''}: ${String(error && error.message)}`);
240
241
  }
241
242
  return verdicts;
242
243
  }
@@ -393,6 +394,7 @@ export async function buildAggregate({
393
394
  persona: run.persona,
394
395
  state: run.state,
395
396
  report: run.report,
397
+ markdown: typeof run.markdown === 'string' ? run.markdown : null,
396
398
  // A finding the server dropped for want of evidence is marked on disk
397
399
  // after delivery (./dropped.js) and kept out of the aggregate, so the
398
400
  // job report counts what the dashboard counts.
@@ -456,6 +458,7 @@ export async function buildAggregate({
456
458
  dropped_for: 'no evidence'
457
459
  })),
458
460
  capability_notes: capabilityNotes.length > 0 ? capabilityNotes : ['No capability limits noted for this job.'],
461
+ persona_voices: inputs.map(run => personaVoice(run)),
459
462
  usage: ledger ? aggregateUsage(ledger) : { usd: 0, by_model: {} }
460
463
  };
461
464
 
@@ -540,11 +543,11 @@ export async function judgeAggregate({ client, jobId, runId, ranked, inputs, bri
540
543
  if (ledger) ledger.record({ model: answer.model, usage: answer.usage, ms: Date.now() - askedAt });
541
544
  return parseJsonObject(answer.text);
542
545
  } catch (error) {
543
- // A budget or cancel refusal decides the caller's exit code and has to
544
- // reach it. Anything else leaves the locally ranked aggregate standing,
545
- // which is still the report a developer can read.
546
- if (error instanceof ProxyError && (error.isBudget || error.isClosed)) throw error;
547
- log(`aggregate synthesis skipped: ${String(error && error.message)}`);
546
+ // A cancel refusal reaches the caller. Anything else, a budget refusal
547
+ // included, leaves the locally ranked aggregate standing, which is still
548
+ // the report a developer can read: the aggregate always comes back.
549
+ if (error instanceof ProxyError && error.isClosed) throw error;
550
+ log(`aggregate synthesis skipped${error instanceof ProxyError && error.isBudget ? ' (the aggregate run is out of model budget; the local ranking stands)' : ''}: ${String(error && error.message)}`);
548
551
  return null;
549
552
  }
550
553
  }
@@ -665,6 +668,14 @@ export function renderAggregateMarkdown(json) {
665
668
  lines.push(`| ${run.persona} | ${run.actions_taken} | ${run.quit_reason} | ${sentiment.fun ?? '-'} | ${sentiment.clarity ?? '-'} | ${sentiment.frustration ?? '-'} | ${run.would_continue === null || run.would_continue === undefined ? '-' : run.would_continue ? 'yes' : 'no'} |`);
666
669
  }
667
670
  lines.push('');
671
+ lines.push('## How the personas felt');
672
+ const voices = Array.isArray(json.persona_voices) ? json.persona_voices : [];
673
+ if (voices.length === 0) lines.push('No persona report was written, so there is nothing in their own words.');
674
+ for (const voice of voices) {
675
+ lines.push(`### ${voice.persona}${voice.written_from_log ? ' (written from the run log)' : ''}`);
676
+ lines.push(voice.text ? voice.text.split('\n').map(line => `> ${line}`).join('\n') : '> This run\'s report has no in-character section.');
677
+ lines.push('');
678
+ }
668
679
  lines.push('## Top issues (ranked)');
669
680
  if (json.issues.length === 0) lines.push('None.');
670
681
  for (const issue of json.issues) {
@@ -703,6 +714,54 @@ export function renderAggregateMarkdown(json) {
703
714
  return fillPlaceholders(lines.join('\n'), { generatedAt: json.generated_at, game: json.game_id });
704
715
  }
705
716
 
717
+ /** The most characters of one persona's in-character section the aggregate quotes. */
718
+ export const MAX_VOICE_CHARS = 1200;
719
+
720
+ /**
721
+ * One persona's in-character section, from its report.md: the section under
722
+ * "In-character experience", the one a report written from the run log
723
+ * carries too, cut to MAX_VOICE_CHARS at a paragraph or sentence where it can.
724
+ *
725
+ * @param {{runId: string, persona: string, markdown: string|null}} run
726
+ * @returns {{run_id: string, persona: string, written_from_log: boolean, text: string|null}}
727
+ */
728
+ export function personaVoice(run) {
729
+ const section = inCharacterSection(run && run.markdown);
730
+ return {
731
+ run_id: run.runId,
732
+ persona: run.persona,
733
+ written_from_log: Boolean(section && section.fromLog),
734
+ text: section ? clipVoice(section.text) : null
735
+ };
736
+ }
737
+
738
+ /**
739
+ * The body of report.md's in-character section, or null when it has none.
740
+ *
741
+ * @param {string|null} markdown
742
+ * @returns {{text: string, fromLog: boolean}|null}
743
+ */
744
+ export function inCharacterSection(markdown) {
745
+ if (typeof markdown !== 'string' || !markdown) return null;
746
+ const lines = markdown.split('\n');
747
+ const start = lines.findIndex(line => /^##\s+In-character experience\b/i.test(line.trim()));
748
+ if (start === -1) return null;
749
+ const rest = lines.slice(start + 1);
750
+ const end = rest.findIndex(line => /^#{1,2}\s/.test(line));
751
+ const text = (end === -1 ? rest : rest.slice(0, end)).join('\n').trim();
752
+ if (!text) return null;
753
+ return { text, fromLog: /written from the run log/i.test(lines[start]) };
754
+ }
755
+
756
+ function clipVoice(text) {
757
+ if (text.length <= MAX_VOICE_CHARS) return text;
758
+ const cut = text.slice(0, MAX_VOICE_CHARS);
759
+ const paragraph = cut.lastIndexOf('\n\n');
760
+ const sentence = cut.lastIndexOf('. ');
761
+ const at = paragraph > MAX_VOICE_CHARS / 2 ? paragraph : sentence > MAX_VOICE_CHARS / 2 ? sentence + 1 : cut.length;
762
+ return `${cut.slice(0, at).trim()} [...]`;
763
+ }
764
+
706
765
  /**
707
766
  * Write both aggregate files into the job directory.
708
767
  *
@@ -0,0 +1,102 @@
1
+ import { OUTPUT_WEIGHT } from './usage.js';
2
+
3
+ /**
4
+ * Keeping enough of a run's model budget back to write its report.
5
+ *
6
+ * The proxy reserves each call's pessimistic projection before sending it:
7
+ * the whole request body at the full input rate (characters / 3.5) plus the
8
+ * output ceiling at the output rate. A call whose projection does not fit
9
+ * what is left of the run's budget is refused with 402 before anything is
10
+ * sent. The server's own warning comes at 80 percent of the budget, counting
11
+ * the call being made, and in the October 26 runs it never came: one act
12
+ * turn's projection jumped the run from under 80 percent to a refusal, and the
13
+ * report that followed did not fit either.
14
+ *
15
+ * So before every act turn the loop asks this module whether, after that
16
+ * turn, the report call would still be admitted. Both projections are sized
17
+ * from the requests themselves (the act turn about to be sent and the report
18
+ * prompt as it would be built now), in input-token units, and priced at what
19
+ * one unit has actually cost this run (the server's spend over the ledger's
20
+ * weighted tokens, ./usage.js). No price is held here.
21
+ */
22
+
23
+ /** Characters per input token, the proxy's own projection rule. */
24
+ export const CHARS_PER_INPUT_TOKEN = 3.5;
25
+
26
+ /**
27
+ * Headroom on the report's projection: the prompt grows by the notes and
28
+ * observations of the turn still to be played, and a persona that escalates
29
+ * may write it on a dearer tier than the one the unit price was learned on.
30
+ */
31
+ export const REPORT_RESERVE_MARGIN = 1.3;
32
+
33
+ /** Headroom on what the next act turn will really cost, over the dearest recent one. */
34
+ export const ACT_COST_MARGIN = 1.5;
35
+
36
+ /** A flat share of the run's budget never planned against. */
37
+ export const FLAT_MARGIN_RATIO = 0.03;
38
+
39
+ /**
40
+ * The proxy's projection of a request, in input-token units.
41
+ *
42
+ * @param {number} bodyChars the request's JSON length
43
+ * @param {number} maxTokens its output ceiling
44
+ * @returns {number}
45
+ */
46
+ export function projectionUnits(bodyChars, maxTokens) {
47
+ return Math.max(0, Number(bodyChars) || 0) / CHARS_PER_INPUT_TOKEN + Math.max(0, Number(maxTokens) || 0) * OUTPUT_WEIGHT;
48
+ }
49
+
50
+ /**
51
+ * Whether play has to stop now so the report still fits.
52
+ *
53
+ * @param {Object} args
54
+ * @param {number|null} args.usdMax the run's model budget, from register
55
+ * @param {{usd: number, usdPerUnit: number}|null} args.spend the ledger's estimate
56
+ * @param {number} args.actChars the next act request's JSON length
57
+ * @param {number} args.actMaxTokens
58
+ * @param {number} args.actCostUnits what an act turn has really cost, at most, lately
59
+ * @param {number} args.reportChars the report request's JSON length if it were sent now
60
+ * @param {number} args.reportMaxTokens
61
+ * @returns {{stop: boolean, known: boolean, figures: Object}}
62
+ */
63
+ export function reserveDecision({ usdMax, spend, actChars, actMaxTokens, actCostUnits, reportChars, reportMaxTokens }) {
64
+ const max = Number(usdMax);
65
+ if (!Number.isFinite(max) || max <= 0 || !spend || !Number.isFinite(spend.usdPerUnit) || spend.usdPerUnit <= 0) {
66
+ return { stop: false, known: false, figures: {} };
67
+ }
68
+ const price = spend.usdPerUnit;
69
+ const actProjected = price * projectionUnits(actChars, actMaxTokens);
70
+ const actCost = price * Math.max(0, Number(actCostUnits) || 0) * ACT_COST_MARGIN;
71
+ const reportProjected = price * projectionUnits(reportChars, reportMaxTokens) * REPORT_RESERVE_MARGIN;
72
+ const flat = max * FLAT_MARGIN_RATIO;
73
+ // The act turn itself has to be admitted, and after it the report has to be.
74
+ const actFits = spend.usd + actProjected <= max;
75
+ const reportFitsAfter = spend.usd + actCost + reportProjected + flat <= max;
76
+ const round = value => Math.round(value * 1e6) / 1e6;
77
+ return {
78
+ stop: !actFits || !reportFitsAfter,
79
+ known: true,
80
+ figures: {
81
+ usd_max: round(max),
82
+ usd_spent: round(spend.usd),
83
+ act_projected_usd: round(actProjected),
84
+ act_cost_usd: round(actCost),
85
+ report_reserve_usd: round(reportProjected),
86
+ margin_usd: round(flat)
87
+ }
88
+ };
89
+ }
90
+
91
+ /**
92
+ * A registered run's model budget in USD: `limits.model_usd`, or the older
93
+ * `budget_usd` beside it. Null when the server sent neither, which leaves the
94
+ * reserve off and the server's own warning in charge.
95
+ *
96
+ * @param {Object} entry a run from the register answer
97
+ * @returns {number|null}
98
+ */
99
+ export function runModelBudgetUsd(entry) {
100
+ const value = Number(entry && ((entry.limits && entry.limits.model_usd) ?? entry.budget_usd));
101
+ return Number.isFinite(value) && value > 0 ? value : null;
102
+ }
@@ -1,6 +1,7 @@
1
1
  import { mkdir, rename, stat, readdir, rm, chmod } from 'node:fs/promises';
2
2
  import { recordingLimits, recordingViewport, recordingDirectory, recordingMetadata, saveRecording, probeVideo } from './recording.js';
3
3
  import path from 'node:path';
4
+ import { gameRefusal, runnerGone } from '../gameRefusal.js';
4
5
  import {
5
6
  DriverError,
6
7
  STALE_REF_MESSAGE,
@@ -31,6 +32,18 @@ import {
31
32
  /** Default recording and viewport size. Kept at 720p to match the upload guidance. */
32
33
  export const DEFAULT_VIEWPORT = Object.freeze({ width: 1280, height: 720 });
33
34
 
35
+ /**
36
+ * The frame a canvas observation sends the model: JPEG at this quality, at the
37
+ * viewport's own size. The model is billed for an image by its pixels, so the
38
+ * format changes nothing there and the coordinates a persona reads off it stay
39
+ * the page's own. What it changes is the proxy's reservation, which prices the
40
+ * request body's characters as text: a 1280x720 PNG of a busy canvas is
41
+ * 300 to 900 KB of base64, a projection of up to half a standard run's model
42
+ * budget for one act turn, where the same frame as JPEG at 70 is 60 to 150 KB.
43
+ * Screenshots saved as evidence stay PNG.
44
+ */
45
+ export const OBSERVATION_FRAME = Object.freeze({ type: 'jpeg', quality: 70, mediaType: 'image/jpeg' });
46
+
34
47
  /** A `wait` action is clamped to this, so one turn cannot burn the wall clock. */
35
48
  export const MAX_WAIT_MS = 10000;
36
49
 
@@ -1349,7 +1362,7 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1349
1362
  const snap = await snapshotText();
1350
1363
  const after = { text: hashText(snap, shownText(snap)) };
1351
1364
  if (String(snap.text).trim().length < THIN_OBSERVATION_CHARS) {
1352
- const frame = await current.screenshot({ type: 'png', timeout: ACTION_TIMEOUT_MS });
1365
+ const frame = await current.screenshot({ type: OBSERVATION_FRAME.type, quality: OBSERVATION_FRAME.quality, timeout: ACTION_TIMEOUT_MS });
1353
1366
  after.screenshotBase64 = frame.toString('base64');
1354
1367
  }
1355
1368
  return observationHash(after);
@@ -1579,9 +1592,9 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1579
1592
  // is the only way to play them. Spend an image only then.
1580
1593
  if (String(text).trim().length < THIN_OBSERVATION_CHARS) {
1581
1594
  try {
1582
- const buffer = await current.screenshot({ type: 'png', timeout: ACTION_TIMEOUT_MS });
1595
+ const buffer = await current.screenshot({ type: OBSERVATION_FRAME.type, quality: OBSERVATION_FRAME.quality, timeout: ACTION_TIMEOUT_MS });
1583
1596
  observation.screenshotBase64 = buffer.toString('base64');
1584
- observation.screenshotMediaType = 'image/png';
1597
+ observation.screenshotMediaType = OBSERVATION_FRAME.mediaType;
1585
1598
  } catch {
1586
1599
  // No image and thin text is still an observation; the persona will
1587
1600
  // read it as a blank screen, which is what it is.
@@ -1679,6 +1692,16 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1679
1692
  if (/Timeout \d+ms exceeded/i.test(text) && intercepted) {
1680
1693
  return { ok: false, changed: false, error: `timed out: ${intercepted.slice(0, 160)} intercepts pointer events over the target`, driverError: false };
1681
1694
  }
1695
+ // A target the page shows as disabled, hidden or not editable is the
1696
+ // game refusing the action, which is what a player sees: evidence,
1697
+ // never a driver error. The resolved element is named so the report
1698
+ // step can cite it.
1699
+ const refusal = /Timeout \d+ms exceeded/i.test(text) ? gameRefusal(text) : null;
1700
+ if (refusal) {
1701
+ return { ok: false, changed: false, error: `the game did not accept this action: the target is ${refusal.state} (${refusal.element})`, driverError: false };
1702
+ }
1703
+ // The browser or page closed under the runner: the runner's failure.
1704
+ if (runnerGone(text)) return { ok: false, changed: false, error: text, driverError: true };
1682
1705
  // A timeout with the page unchanged while resolving a click or typing
1683
1706
  // target means the target was never found or never actionable: the
1684
1707
  // driver's failure, not the game's. Never for navigate: a game that
@@ -0,0 +1,77 @@
1
+ import { homedir } from 'node:os';
2
+ import { redactSecrets } from './secretScan.js';
3
+
4
+ /** The server stores a run's reason as `stateReason` and the dashboard shows it. */
5
+ export const MAX_REASON_CHARS = 400;
6
+
7
+ /**
8
+ * Token shapes the upload scan does not cover, because they are not refused
9
+ * there: any `gt_` or `sk-` token and a bearer header value. A reason is
10
+ * diagnostics, so these are replaced rather than refused.
11
+ */
12
+ const EXTRA_PATTERNS = [
13
+ { type: 'bearer_token', regex: /\bbearer\s+[A-Za-z0-9._~+/=-]{4,2048}/gi },
14
+ { type: 'ravensight_token', regex: /\bgt_[A-Za-z0-9_-]{4,256}/g },
15
+ { type: 'api_key', regex: /\bsk-[A-Za-z0-9_-]{4,256}/g }
16
+ ];
17
+
18
+ /** A home directory in a path: macOS, Linux and Windows. */
19
+ const HOME_PATHS = [
20
+ /\/Users\/[^/\s'"`]+/g,
21
+ /\/home\/[^/\s'"`]+/g,
22
+ /\b[A-Za-z]:[\\/]+Users[\\/]+[^\\/\s'"`]+/gi
23
+ ];
24
+
25
+ function escapeRegExp(text) {
26
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
27
+ }
28
+
29
+ /**
30
+ * The text with token-like strings and absolute home paths taken out, safe to
31
+ * send to the server as a run's reason.
32
+ *
33
+ * @param {string} text
34
+ * @param {{home?: string}} [options]
35
+ * @returns {string}
36
+ */
37
+ export function redactReason(text, { home = homedir() } = {}) {
38
+ let out = redactSecrets(text);
39
+ for (const { type, regex } of EXTRA_PATTERNS) out = out.replace(regex, `[redacted ${type}]`);
40
+ if (home && home.length > 1) out = out.replace(new RegExp(escapeRegExp(home), 'g'), '~');
41
+ for (const pattern of HOME_PATHS) out = out.replace(pattern, '~');
42
+ return out;
43
+ }
44
+
45
+ /**
46
+ * The reason a failed run is reported with: the detail it already has, or else
47
+ * the caught error's message, redacted and cut to 400 characters. Never empty.
48
+ *
49
+ * @param {unknown} error
50
+ * @param {{detail?: string, fallback?: string, home?: string}} [options]
51
+ * @returns {string}
52
+ */
53
+ export function failureReason(error, { detail = '', fallback = 'the run stopped on an error', home } = {}) {
54
+ const message = error && typeof error === 'object' && 'message' in error ? error.message : error;
55
+ const raw = String(detail || '').trim() || (message == null ? '' : String(message).trim());
56
+ const text = redactReason(raw, home === undefined ? {} : { home }).trim().slice(0, MAX_REASON_CHARS);
57
+ return text || fallback;
58
+ }
59
+
60
+ /**
61
+ * The reason a run whose game or browser could not start is reported with:
62
+ * what failed, the redacted message, and what to do about it. The message is
63
+ * cut first, so the instruction always survives the 400 character limit.
64
+ *
65
+ * @param {string} driverName
66
+ * @param {unknown} error
67
+ * @param {{detail?: string, browserFix?: string, home?: string}} [options]
68
+ * @returns {string}
69
+ */
70
+ export function driverStartReason(driverName, error, { detail = '', browserFix = 'npx playwright install chromium', home } = {}) {
71
+ const browser = driverName === 'playwright_web';
72
+ const what = browser ? 'the browser could not start' : driverName === 'godot_driver' ? 'Godot could not start' : `the ${driverName} driver could not start`;
73
+ const todo = browser ? `run ${browserFix}` : 'run "ravensight-playtest check" to see what is missing';
74
+ const room = MAX_REASON_CHARS - what.length - todo.length - 4;
75
+ const message = failureReason(error, { detail, fallback: 'no error message', ...(home === undefined ? {} : { home }) }).slice(0, Math.max(0, room)).trim();
76
+ return `${what}: ${message}; ${todo}`;
77
+ }