ravensight-playtest 0.6.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.
- package/README.md +111 -2
- package/package.json +1 -1
- package/src/api/client.js +28 -0
- package/src/auth/scopes.js +36 -0
- package/src/cli.js +101 -8
- package/src/commands/check.js +5 -1
- package/src/commands/fakeCore.js +5 -1
- package/src/commands/fix.js +71 -2
- package/src/commands/hosted.js +143 -2
- package/src/commands/lists.js +206 -0
- package/src/commands/profile.js +2 -1
- package/src/commands/punch.js +116 -0
- package/src/commands/run.js +86 -5
- package/src/commands/tape.js +74 -0
- package/src/hosted/recordedRun.js +132 -3
- package/src/hosted/status.js +108 -0
- package/src/run/aggregate.js +69 -10
- package/src/run/budgetReserve.js +102 -0
- package/src/run/drivers/web.js +26 -3
- package/src/run/failureReason.js +77 -0
- package/src/run/gameRefusal.js +57 -0
- package/src/run/personaLoop.js +255 -59
- package/src/run/regenerate.js +4 -1
- package/src/run/report.js +54 -0
- package/src/run/usage.js +54 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { ui } from '../ui/index.js';
|
|
2
|
+
import { ExitCode } from '../errors.js';
|
|
3
|
+
import { guarded, printHints, printList, requireItemId, requireStatus, requireText, withList } from './lists.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `ravensight-playtest tape`: blue tape (Labs beta).
|
|
7
|
+
*
|
|
8
|
+
* Small first-impression annoyances, captured in one line while they are
|
|
9
|
+
* fresh. Free. An item that turns out to matter is promoted to the punch list,
|
|
10
|
+
* which closes the tape item and keeps its source.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const LIST = 'tape';
|
|
14
|
+
|
|
15
|
+
/** `tape "<text>"` */
|
|
16
|
+
export async function tapeAdd(text, flags = {}, deps = {}) {
|
|
17
|
+
const value = requireText(text);
|
|
18
|
+
return withList(flags, deps, async ({ api, gameId }) => {
|
|
19
|
+
const answer = await guarded(() => api.labs.lists.add(gameId, LIST, { text: value }), { list: LIST });
|
|
20
|
+
if (flags.json) {
|
|
21
|
+
ui.json(answer);
|
|
22
|
+
return ExitCode.OK;
|
|
23
|
+
}
|
|
24
|
+
const item = answer && answer.item ? answer.item : {};
|
|
25
|
+
if (answer && answer.existing) ui.info(`Already taped: ${item.item_id}, ${item.text}`);
|
|
26
|
+
else ui.ok(`Taped ${item.item_id}: ${item.text}`);
|
|
27
|
+
if (printHints(item) > 0) ui.info(' These hints follow the rules of Simplified Technical English. They never block an item.');
|
|
28
|
+
return ExitCode.OK;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `tape list [--status open|done|dropped|all]` */
|
|
33
|
+
export async function tapeList(flags = {}, deps = {}) {
|
|
34
|
+
const status = requireStatus(flags.status);
|
|
35
|
+
return withList(flags, deps, async ({ api, gameId }) => {
|
|
36
|
+
const answer = await guarded(() => api.labs.lists.list(gameId, LIST, { status }), { list: LIST });
|
|
37
|
+
if (flags.json) {
|
|
38
|
+
ui.json(answer);
|
|
39
|
+
return ExitCode.OK;
|
|
40
|
+
}
|
|
41
|
+
printList(answer, LIST, status);
|
|
42
|
+
return ExitCode.OK;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `tape promote <li_id>` */
|
|
47
|
+
export async function tapePromote(itemId, flags = {}, deps = {}) {
|
|
48
|
+
const id = requireItemId(itemId);
|
|
49
|
+
return withList(flags, deps, async ({ api, gameId }) => {
|
|
50
|
+
const answer = await guarded(() => api.labs.lists.promote(gameId, id), { list: LIST, itemId: id });
|
|
51
|
+
if (flags.json) {
|
|
52
|
+
ui.json(answer);
|
|
53
|
+
return ExitCode.OK;
|
|
54
|
+
}
|
|
55
|
+
const promoted = answer && answer.promoted ? answer.promoted : {};
|
|
56
|
+
ui.ok(`Promoted ${id} to the punch list as ${promoted.item_id}: ${promoted.text}`);
|
|
57
|
+
return ExitCode.OK;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `tape drop <li_id>` */
|
|
62
|
+
export async function tapeDrop(itemId, flags = {}, deps = {}) {
|
|
63
|
+
const id = requireItemId(itemId);
|
|
64
|
+
return withList(flags, deps, async ({ api, gameId }) => {
|
|
65
|
+
const answer = await guarded(() => api.labs.lists.update(gameId, LIST, id, { status: 'dropped' }), { list: LIST, itemId: id });
|
|
66
|
+
if (flags.json) {
|
|
67
|
+
ui.json(answer);
|
|
68
|
+
return ExitCode.OK;
|
|
69
|
+
}
|
|
70
|
+
const text = answer && answer.item && answer.item.text ? `: ${answer.item.text}` : '';
|
|
71
|
+
ui.ok(`Dropped ${id}${text}`);
|
|
72
|
+
return ExitCode.OK;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
@@ -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'}
|
|
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(
|
|
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
|
+
}
|
package/src/run/aggregate.js
CHANGED
|
@@ -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.
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
if (error instanceof ProxyError &&
|
|
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
|
+
}
|
package/src/run/drivers/web.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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 =
|
|
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
|