ravensight-playtest 0.3.0 → 0.3.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 +207 -24
- package/docs/NATIVE_RUNNER.md +2 -2
- package/package.json +2 -2
- package/src/api/client.js +9 -3
- package/src/briefLocal.js +126 -0
- package/src/cli.js +14 -3
- package/src/commands/brief.js +48 -9
- package/src/commands/check.js +187 -38
- package/src/commands/fix.js +12 -1
- package/src/commands/init.js +13 -6
- package/src/commands/personas.js +50 -0
- package/src/commands/run.js +264 -31
- package/src/run/aggregate.js +6 -3
- package/src/run/args.js +18 -1
- package/src/run/drivers/web.js +634 -83
- package/src/run/noise.js +102 -0
- package/src/run/personaLoop.js +658 -26
- package/src/run/report.js +187 -0
- package/src/run/resume.js +5 -0
- package/src/run/tools.js +20 -4
- package/src/runDefaults.js +90 -0
- package/src/state/index.js +4 -1
- package/src/ui/index.js +15 -1
package/src/commands/brief.js
CHANGED
|
@@ -7,6 +7,8 @@ import { readJson } from '../fsutil.js';
|
|
|
7
7
|
import { briefLink, openInBrowser } from '../dashboard.js';
|
|
8
8
|
import { ui } from '../ui/index.js';
|
|
9
9
|
import { CliError, ExitCode } from '../errors.js';
|
|
10
|
+
import { DEFAULT_PERSONAS, RUN_MODULES, describeBreakdown, personaCount } from '../runDefaults.js';
|
|
11
|
+
import { displayLocalBrief, loadLocalBrief, localBriefFile, saveLocalBrief, splitLocalFields } from '../briefLocal.js';
|
|
10
12
|
|
|
11
13
|
/** The goals the server's expectations-brief schema accepts, in its own order. */
|
|
12
14
|
export const GOALS = Object.freeze([
|
|
@@ -19,7 +21,6 @@ export const GOALS = Object.freeze([
|
|
|
19
21
|
'performance'
|
|
20
22
|
]);
|
|
21
23
|
|
|
22
|
-
const DEFAULT_MODULES = ['game_profile', 'persona_playtest'];
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* The four things a quick brief needs, which are exactly the four the server
|
|
@@ -79,6 +80,13 @@ function showBrief(brief, rendered) {
|
|
|
79
80
|
ui.info(rendered || '');
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
/** Print the fields kept on this machine, the password masked. */
|
|
84
|
+
function showLocalBrief(local, repoRoot) {
|
|
85
|
+
ui.blank();
|
|
86
|
+
ui.info(`On this machine only (${localBriefFile({ repoRoot })}):`);
|
|
87
|
+
ui.info(JSON.stringify(local, null, 2));
|
|
88
|
+
}
|
|
89
|
+
|
|
82
90
|
/**
|
|
83
91
|
* A question asker over a pair of streams.
|
|
84
92
|
*
|
|
@@ -212,13 +220,21 @@ async function editBrief(context, flags, deps) {
|
|
|
212
220
|
}
|
|
213
221
|
|
|
214
222
|
if (flags.show) {
|
|
223
|
+
const local = displayLocalBrief(await loadLocalBrief({ repoRoot: flags.repoRoot }));
|
|
224
|
+
const hasLocal = Object.keys(local).length > 0;
|
|
215
225
|
if (!current.brief) {
|
|
216
|
-
if (flags.json) ui.json({ brief: null });
|
|
217
|
-
else
|
|
226
|
+
if (flags.json) ui.json({ brief: null, local });
|
|
227
|
+
else {
|
|
228
|
+
ui.info('No brief yet. Run brief with no flags to write one.');
|
|
229
|
+
if (hasLocal) showLocalBrief(local, flags.repoRoot);
|
|
230
|
+
}
|
|
218
231
|
return ExitCode.OK;
|
|
219
232
|
}
|
|
220
|
-
if (flags.json) ui.json(current);
|
|
221
|
-
else
|
|
233
|
+
if (flags.json) ui.json({ ...current, local });
|
|
234
|
+
else {
|
|
235
|
+
showBrief(current.brief, current.rendered);
|
|
236
|
+
if (hasLocal) showLocalBrief(local, flags.repoRoot);
|
|
237
|
+
}
|
|
222
238
|
return ExitCode.OK;
|
|
223
239
|
}
|
|
224
240
|
|
|
@@ -226,12 +242,22 @@ async function editBrief(context, flags, deps) {
|
|
|
226
242
|
const parentVersion = current.brief ? current.brief.version : null;
|
|
227
243
|
|
|
228
244
|
let fields;
|
|
245
|
+
let localSaved = [];
|
|
229
246
|
if (flags.from) {
|
|
230
247
|
const loaded = await readJson(flags.from, null);
|
|
231
248
|
if (!loaded || typeof loaded !== 'object') {
|
|
232
249
|
throw new CliError(`${flags.from} does not contain a brief fields object.`);
|
|
233
250
|
}
|
|
234
251
|
fields = loaded.fields && typeof loaded.fields === 'object' ? loaded.fields : loaded;
|
|
252
|
+
// expected_noise and test_account stay on this machine: the server's
|
|
253
|
+
// brief schema refuses keys it does not list, and a password has no place
|
|
254
|
+
// in a record the studio shares.
|
|
255
|
+
const split = splitLocalFields(fields);
|
|
256
|
+
fields = split.server;
|
|
257
|
+
if (Object.keys(split.local).length > 0) {
|
|
258
|
+
await saveLocalBrief(split.local, { repoRoot: flags.repoRoot });
|
|
259
|
+
localSaved = Object.keys(split.local);
|
|
260
|
+
}
|
|
235
261
|
} else {
|
|
236
262
|
fields = await askQuickBrief(existingFields, deps);
|
|
237
263
|
}
|
|
@@ -260,8 +286,13 @@ async function editBrief(context, flags, deps) {
|
|
|
260
286
|
// A refused check does not undo a saved brief, and the brief is the point.
|
|
261
287
|
}
|
|
262
288
|
|
|
263
|
-
|
|
264
|
-
|
|
289
|
+
// The run `run` would start: its modules and its personas (the flag, the
|
|
290
|
+
// repo config, else the default one). Before 0.3.2 this priced a game
|
|
291
|
+
// profile with no personas and quoted $4.00 for a job nobody was about to
|
|
292
|
+
// start.
|
|
293
|
+
const estimateModules = flags.modules || (context.config && context.config.modules) || [...RUN_MODULES];
|
|
294
|
+
const configuredPersonas = flags.personas || (context.config && Array.isArray(context.config.personas) && context.config.personas.length > 0 ? context.config.personas : null);
|
|
295
|
+
const estimatePersonas = estimateModules.includes('persona_playtest') ? (configuredPersonas || [...DEFAULT_PERSONAS]) : (configuredPersonas || []);
|
|
265
296
|
let estimate = null;
|
|
266
297
|
try {
|
|
267
298
|
estimate = await api.brief.estimate(gameId, { modules: estimateModules, personas: estimatePersonas });
|
|
@@ -270,11 +301,14 @@ async function editBrief(context, flags, deps) {
|
|
|
270
301
|
}
|
|
271
302
|
|
|
272
303
|
if (flags.json) {
|
|
273
|
-
ui.json({ ...saved, estimate });
|
|
304
|
+
ui.json({ ...saved, estimate, local_saved: localSaved });
|
|
274
305
|
return ExitCode.OK;
|
|
275
306
|
}
|
|
276
307
|
|
|
277
308
|
ui.ok(`Saved brief version ${saved.brief.version} (${saved.brief.status}).`);
|
|
309
|
+
if (localSaved.length > 0) {
|
|
310
|
+
ui.info(`Kept ${localSaved.join(' and ')} in ${localBriefFile({ repoRoot: flags.repoRoot })}, on this machine only: the server's brief does not store them.`);
|
|
311
|
+
}
|
|
278
312
|
const after = saved.brief.completeness || completenessFor(saved.brief.fields);
|
|
279
313
|
if (!after.complete) {
|
|
280
314
|
ui.warn(`Still missing: ${after.missing.join(', ')}`);
|
|
@@ -283,7 +317,12 @@ async function editBrief(context, flags, deps) {
|
|
|
283
317
|
|
|
284
318
|
if (estimate) {
|
|
285
319
|
ui.blank();
|
|
286
|
-
|
|
320
|
+
const units = describeBreakdown(estimate.breakdown, ui.money);
|
|
321
|
+
const what = estimateModules.includes('persona_playtest')
|
|
322
|
+
? `A run of ${personaCount(estimatePersonas.length)} (${estimatePersonas.join(', ')})${estimateModules.includes('game_profile') ? ' with a game profile' : ''}`
|
|
323
|
+
: `A ${estimateModules.join(' plus ')} job`;
|
|
324
|
+
ui.info(`${what} would cost ${ui.money(estimate.estimate_cents)}${units ? `: ${units}` : ''}.`);
|
|
325
|
+
if (!estimateModules.includes('game_profile')) ui.info('A game profile ("ravensight-playtest profile") is a separate job with its own price.');
|
|
287
326
|
if (estimate.wall_warning) ui.warn(estimate.wall_warning);
|
|
288
327
|
ui.table(estimate.breakdown, [
|
|
289
328
|
{ key: 'unit', label: 'UNIT' },
|
package/src/commands/check.js
CHANGED
|
@@ -11,8 +11,10 @@ import { CLI_VERSION, compareVersions } from '../version.js';
|
|
|
11
11
|
import { ui } from '../ui/index.js';
|
|
12
12
|
import { ExitCode } from '../errors.js';
|
|
13
13
|
import { completenessFor } from './brief.js';
|
|
14
|
+
import { RUN_MODULES, defaultPersonasFor, describeBreakdown, personaCount, unknownPersonaMessage, unknownPersonas } from '../runDefaults.js';
|
|
14
15
|
import { WEBDRIVER_NAMES } from '../run/drivers/webdriver.js';
|
|
15
16
|
import { inspectNativeBuild, probeAutomation, platformWords } from '../run/drivers/webdriver-probe.js';
|
|
17
|
+
import { CHECK_PLAYTEST_IDENTITY, DEFAULT_VIEWPORT, WEB_OBSERVATION_MAX_CHARS, injectPlaytestIdentity, measureSnapshot, readSnapshot } from '../run/drivers/web.js';
|
|
16
18
|
import path from 'node:path';
|
|
17
19
|
|
|
18
20
|
export const NODE_MIN_MAJOR = 22;
|
|
@@ -64,21 +66,109 @@ export function browserSeverity(flags, config) {
|
|
|
64
66
|
return config.driver === 'playwright_web' ? 'warn' : null;
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
/** How long `check` waits for a web build's first screen before it warns instead. */
|
|
70
|
+
export const FIRST_SCREEN_TIMEOUT_MS = 20000;
|
|
71
|
+
|
|
72
|
+
/** How long `check` waits for a first screen's network to go quiet, which a game with a socket never does. */
|
|
73
|
+
export const FIRST_SCREEN_SETTLE_MS = 3000;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A list with this many look-alike items or more on the first screen is
|
|
77
|
+
* worth a long run: the persona sees two and a count and has to search or
|
|
78
|
+
* scroll its way to the one it wants, or, when the items share a name and the
|
|
79
|
+
* page is under the cap, reads all of them on every step.
|
|
80
|
+
*/
|
|
81
|
+
export const LONG_LIST_ITEMS = 50;
|
|
82
|
+
|
|
83
|
+
/** `18,400`. */
|
|
84
|
+
const count = value => Number(value).toLocaleString('en-US');
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Load the first screen of a web build in an already launched browser and
|
|
88
|
+
* measure it the way a persona's observation would see it.
|
|
89
|
+
*
|
|
90
|
+
* @param {any} browser
|
|
91
|
+
* @param {string} url
|
|
92
|
+
* @param {{timeoutMs?: number}} [options]
|
|
93
|
+
* @returns {Promise<{ok: true, aria: boolean, fullChars: number, shownChars: number, longestRun: number}|{ok: false, detail: string}>}
|
|
94
|
+
*/
|
|
95
|
+
export async function measureFirstScreen(browser, url, { timeoutMs = FIRST_SCREEN_TIMEOUT_MS } = {}) {
|
|
96
|
+
let page = null;
|
|
97
|
+
try {
|
|
98
|
+
if (!browser || typeof browser.newPage !== 'function') throw new Error('this Playwright cannot open a page');
|
|
99
|
+
page = await browser.newPage({ viewport: DEFAULT_VIEWPORT });
|
|
100
|
+
// Marked as a playtest before any of the game's scripts run, so a build
|
|
101
|
+
// with the Ravensight web SDK never records this load as a real player.
|
|
102
|
+
// A Playwright that cannot mark the page does not load it at all.
|
|
103
|
+
if (typeof page.addInitScript !== 'function') throw new Error('this Playwright cannot mark the page as a check, so it was not loaded');
|
|
104
|
+
await page.addInitScript(injectPlaytestIdentity, { ...CHECK_PLAYTEST_IDENTITY });
|
|
105
|
+
await page.goto(url, { waitUntil: 'load', timeout: timeoutMs });
|
|
106
|
+
if (typeof page.waitForLoadState === 'function') {
|
|
107
|
+
try {
|
|
108
|
+
await page.waitForLoadState('networkidle', { timeout: FIRST_SCREEN_SETTLE_MS });
|
|
109
|
+
} catch {
|
|
110
|
+
// A page that keeps a connection open is measured as it stands.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const snap = await readSnapshot(page);
|
|
114
|
+
return { ok: true, ...measureSnapshot(snap, { viewport: DEFAULT_VIEWPORT }) };
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return { ok: false, detail: String(error && error.message ? error.message : error).split('\n')[0] };
|
|
117
|
+
} finally {
|
|
118
|
+
if (page && typeof page.close === 'function') {
|
|
119
|
+
try {
|
|
120
|
+
await page.close();
|
|
121
|
+
} catch {
|
|
122
|
+
// The browser closes it with everything else.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Why a measured first screen is worth a long run, or an empty list when it
|
|
130
|
+
* is not: the whole compacted text over the observation cap, or a very long
|
|
131
|
+
* list of look-alike items.
|
|
132
|
+
*
|
|
133
|
+
* @param {{fullChars: number, longestRun: number}} measure
|
|
134
|
+
* @returns {string[]}
|
|
135
|
+
*/
|
|
136
|
+
export function heavyReasons(measure) {
|
|
137
|
+
const reasons = [];
|
|
138
|
+
if (measure.fullChars > WEB_OBSERVATION_MAX_CHARS) {
|
|
139
|
+
const ratio = measure.fullChars / WEB_OBSERVATION_MAX_CHARS;
|
|
140
|
+
reasons.push(`first screen is ${count(measure.fullChars)} characters, about ${ratio < 10 ? ratio.toFixed(1) : Math.round(ratio)}x the ${count(WEB_OBSERVATION_MAX_CHARS)} a persona reads per step`);
|
|
141
|
+
}
|
|
142
|
+
if (measure.longestRun >= LONG_LIST_ITEMS) {
|
|
143
|
+
reasons.push(measure.longestRunCollapsed === false
|
|
144
|
+
? `first screen has a list of ${count(measure.longestRun)} look-alike items, which a persona sees in full on every step`
|
|
145
|
+
: `first screen has a list of ${count(measure.longestRun)} look-alike items, which a persona sees as two and a count`);
|
|
146
|
+
}
|
|
147
|
+
return reasons;
|
|
148
|
+
}
|
|
149
|
+
|
|
67
150
|
/**
|
|
68
151
|
* Import Playwright, launch headless Chromium, close it. The same three steps
|
|
69
152
|
* a web run starts with, so a pass here means the run will get a browser.
|
|
70
153
|
*
|
|
154
|
+
* With a `url`, the browser also loads that first screen and measures it
|
|
155
|
+
* (see {@link measureFirstScreen}); a page that does not load is reported
|
|
156
|
+
* in `firstScreen`, never as a failed browser.
|
|
157
|
+
*
|
|
71
158
|
* @param {(specifier: string) => Promise<any>} [importer] test seam
|
|
72
|
-
* @
|
|
159
|
+
* @param {{url?: string|null, timeoutMs?: number}} [options]
|
|
160
|
+
* @returns {Promise<{ok: boolean, detail: string, firstScreen?: Object}>}
|
|
73
161
|
*/
|
|
74
|
-
export async function probeBrowser(importer) {
|
|
162
|
+
export async function probeBrowser(importer, { url = null, timeoutMs } = {}) {
|
|
75
163
|
const load = importer || (specifier => import(specifier));
|
|
76
164
|
let browser = null;
|
|
77
165
|
try {
|
|
78
166
|
const playwright = await load('playwright');
|
|
79
167
|
browser = await playwright.chromium.launch({ headless: true });
|
|
80
168
|
const version = typeof browser.version === 'function' ? browser.version() : '';
|
|
81
|
-
|
|
169
|
+
const detail = `chromium ${version}`.trim() + ' launched headless';
|
|
170
|
+
if (!url) return { ok: true, detail };
|
|
171
|
+
return { ok: true, detail, firstScreen: await measureFirstScreen(browser, url, { timeoutMs }) };
|
|
82
172
|
} catch (error) {
|
|
83
173
|
return { ok: false, detail: String(error && error.message ? error.message : error).split('\n')[0] };
|
|
84
174
|
} finally {
|
|
@@ -450,42 +540,17 @@ export async function runChecks(flags = {}, deps = {}) {
|
|
|
450
540
|
|
|
451
541
|
/* -------------------------------------------------- balance and pack */
|
|
452
542
|
|
|
543
|
+
/** The standard estimate and its request, so a heavy first screen can quote the long price beside it. */
|
|
544
|
+
let priced = null;
|
|
453
545
|
if (gameId && whoami && inScope) {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
'balance',
|
|
461
|
-
'warn',
|
|
462
|
-
`estimate ${ui.money(estimate.estimate_cents)}, balance hidden by your role`,
|
|
463
|
-
'ask an owner or admin to check the balance'
|
|
464
|
-
));
|
|
465
|
-
} else {
|
|
466
|
-
rows.push(row(
|
|
467
|
-
'balance',
|
|
468
|
-
estimate.ok ? 'pass' : 'fail',
|
|
469
|
-
`estimate ${ui.money(estimate.estimate_cents)}, available ${ui.money(estimate.balance_cents)}`,
|
|
470
|
-
estimate.ok ? null : `top up at ${estimate.topup_url}`
|
|
471
|
-
));
|
|
472
|
-
}
|
|
473
|
-
// Not a failure and not hidden by role: a job this size is still
|
|
474
|
-
// allowed, it may just outrun its wall clock, which is worth seeing
|
|
475
|
-
// here rather than only after `run` has already asked for a price.
|
|
476
|
-
if (estimate.wall_warning) {
|
|
477
|
-
rows.push(row('persona wall clock', 'warn', estimate.wall_warning));
|
|
478
|
-
}
|
|
479
|
-
} catch (error) {
|
|
480
|
-
rows.push(row(
|
|
481
|
-
'balance',
|
|
482
|
-
'warn',
|
|
483
|
-
error instanceof ApiError ? error.message : String(error && error.message)
|
|
484
|
-
));
|
|
485
|
-
}
|
|
486
|
-
|
|
546
|
+
// What `run` would price: its modules, and with no personas named (by the
|
|
547
|
+
// flag or the repo config) the default persona set. Before 0.3.2 the
|
|
548
|
+
// persona module priced no persona at all.
|
|
549
|
+
const modules = flags.modules || (config && config.modules) || [...RUN_MODULES];
|
|
550
|
+
const named = flags.personas || (config && Array.isArray(config.personas) && config.personas.length > 0 ? config.personas : null);
|
|
551
|
+
let pack = null;
|
|
487
552
|
try {
|
|
488
|
-
|
|
553
|
+
pack = await packs.get({ api: authed, gameId, modules });
|
|
489
554
|
const manifest = (await authed.packs.manifest()).body;
|
|
490
555
|
const verified = packs.verifyAgainstManifest(manifest, pack.bundle);
|
|
491
556
|
rows.push(row(
|
|
@@ -504,6 +569,48 @@ export async function runChecks(flags = {}, deps = {}) {
|
|
|
504
569
|
'the CLI cannot run without personas and schemas'
|
|
505
570
|
));
|
|
506
571
|
}
|
|
572
|
+
const available = pack && pack.bundle && Array.isArray(pack.bundle.personas) ? pack.bundle.personas : null;
|
|
573
|
+
const personas = modules.includes('persona_playtest') ? (named || defaultPersonasFor(available)) : (named || []);
|
|
574
|
+
const unknown = available ? unknownPersonas(personas, available) : [];
|
|
575
|
+
if (unknown.length > 0) {
|
|
576
|
+
rows.push(row('personas', 'fail', unknownPersonaMessage(unknown, available), 'ravensight-playtest personas lists the valid slugs'));
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (unknown.length === 0) {
|
|
580
|
+
try {
|
|
581
|
+
const estimate = await authed.jobs.estimate(gameId, { modules, personas });
|
|
582
|
+
priced = { estimate, modules, personas };
|
|
583
|
+
const units = describeBreakdown(estimate.breakdown, ui.money);
|
|
584
|
+
const summary = `estimate ${ui.money(estimate.estimate_cents)}${modules.includes('persona_playtest') ? ` for ${personaCount(personas.length)} (${personas.join(', ')})` : ''}${units ? `: ${units}` : ''}`;
|
|
585
|
+
if (estimate.balance_cents === null || estimate.balance_cents === undefined) {
|
|
586
|
+
rows.push(row(
|
|
587
|
+
'balance',
|
|
588
|
+
'warn',
|
|
589
|
+
`${summary}; balance hidden by your role`,
|
|
590
|
+
'ask an owner or admin to check the balance'
|
|
591
|
+
));
|
|
592
|
+
} else {
|
|
593
|
+
rows.push(row(
|
|
594
|
+
'balance',
|
|
595
|
+
estimate.ok ? 'pass' : 'fail',
|
|
596
|
+
`${summary}; available ${ui.money(estimate.balance_cents)}`,
|
|
597
|
+
estimate.ok ? null : `top up at ${estimate.topup_url}`
|
|
598
|
+
));
|
|
599
|
+
}
|
|
600
|
+
// Not a failure and not hidden by role: a job this size is still
|
|
601
|
+
// allowed, it may just outrun its wall clock, which is worth seeing
|
|
602
|
+
// here rather than only after `run` has already asked for a price.
|
|
603
|
+
if (estimate.wall_warning) {
|
|
604
|
+
rows.push(row('persona wall clock', 'warn', estimate.wall_warning));
|
|
605
|
+
}
|
|
606
|
+
} catch (error) {
|
|
607
|
+
rows.push(row(
|
|
608
|
+
'balance',
|
|
609
|
+
'warn',
|
|
610
|
+
error instanceof ApiError ? error.message : String(error && error.message)
|
|
611
|
+
));
|
|
612
|
+
}
|
|
613
|
+
}
|
|
507
614
|
}
|
|
508
615
|
|
|
509
616
|
/* --------------------------------------------------------- the machine */
|
|
@@ -533,13 +640,17 @@ export async function runChecks(flags = {}, deps = {}) {
|
|
|
533
640
|
|
|
534
641
|
const browserNeed = browserSeverity(flags, await configForBrowser(flags));
|
|
535
642
|
if (browserNeed) {
|
|
536
|
-
const
|
|
643
|
+
const firstScreenUrl = plan.driverName === 'playwright_web' && /^https?:\/\//i.test(String(plan.target || '')) ? plan.target : null;
|
|
644
|
+
const probe = await probeBrowser(deps.importer, { url: firstScreenUrl, timeoutMs: deps.firstScreenTimeoutMs });
|
|
537
645
|
rows.push(row(
|
|
538
646
|
'browser',
|
|
539
647
|
probe.ok ? 'pass' : browserNeed,
|
|
540
648
|
probe.detail,
|
|
541
649
|
probe.ok ? null : BROWSER_FIX
|
|
542
650
|
));
|
|
651
|
+
if (probe.firstScreen) {
|
|
652
|
+
rows.push(await firstScreenRow(probe.firstScreen, firstScreenUrl, { authed, gameId, priced }));
|
|
653
|
+
}
|
|
543
654
|
}
|
|
544
655
|
|
|
545
656
|
const godot = await (deps.inspectGodot || inspectGodot)(
|
|
@@ -587,6 +698,44 @@ export async function runChecks(flags = {}, deps = {}) {
|
|
|
587
698
|
return { rows, ok: rows.every(entry => entry.status !== 'fail'), ...facts };
|
|
588
699
|
}
|
|
589
700
|
|
|
701
|
+
/**
|
|
702
|
+
* The `first screen` row: how heavy the build's first screen is for a
|
|
703
|
+
* persona, and a suggestion to use `--long` when it is heavy. Both prices are
|
|
704
|
+
* read from the server's free estimate; nothing here is charged. A first
|
|
705
|
+
* screen that did not load is a warning, never a failure: `run` has its own
|
|
706
|
+
* health check, and the server may simply not be started yet.
|
|
707
|
+
*
|
|
708
|
+
* @param {Object} measure from {@link measureFirstScreen}
|
|
709
|
+
* @param {string} url
|
|
710
|
+
* @param {{authed: Object|null, gameId: string|null, priced: Object|null}} context
|
|
711
|
+
* @returns {Promise<Object>} a row
|
|
712
|
+
*/
|
|
713
|
+
export async function firstScreenRow(measure, url, { authed, gameId, priced }) {
|
|
714
|
+
if (!measure.ok) {
|
|
715
|
+
return row('first screen', 'warn', `could not load ${url} to measure it: ${measure.detail}`, 'start the build, then run check again to see how heavy its first screen is');
|
|
716
|
+
}
|
|
717
|
+
const reasons = heavyReasons(measure);
|
|
718
|
+
if (reasons.length === 0) {
|
|
719
|
+
return row('first screen', 'pass', `${count(measure.fullChars)} characters, within the ${count(WEB_OBSERVATION_MAX_CHARS)} a persona reads per step`);
|
|
720
|
+
}
|
|
721
|
+
let prices = 'a long run costs twice a standard run; run shows the price before any charge';
|
|
722
|
+
if (priced && priced.modules.includes('persona_playtest') && authed && gameId) {
|
|
723
|
+
try {
|
|
724
|
+
// A read like the standard estimate above: it prices, it charges nothing.
|
|
725
|
+
const long = await authed.jobs.estimate(gameId, { modules: priced.modules, personas: priced.personas, runLength: 'long' });
|
|
726
|
+
prices = `long ${ui.money(long.estimate_cents)} against standard ${ui.money(priced.estimate.estimate_cents)}`;
|
|
727
|
+
} catch {
|
|
728
|
+
// The suggestion stands without the figure.
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return row(
|
|
732
|
+
'first screen',
|
|
733
|
+
'warn',
|
|
734
|
+
`${reasons.join('; ')}. Consider --long: twice the actions, model budget and wall time per persona (${prices})`,
|
|
735
|
+
'ravensight-playtest run --long'
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
|
|
590
739
|
/**
|
|
591
740
|
* `ravensight-playtest check`
|
|
592
741
|
* @param {Object} [flags]
|
package/src/commands/fix.js
CHANGED
|
@@ -15,7 +15,8 @@ import { createRepoTools, PROFILE_TOOLS } from '../run/repoTools.js';
|
|
|
15
15
|
import { assertNoSecrets } from '../run/secretScan.js';
|
|
16
16
|
import * as stateFile from '../run/state.js';
|
|
17
17
|
import { createUsageLedger, writeUsageFile } from '../run/usage.js';
|
|
18
|
-
import { createInterruptHandler, deliver, driverOptions, exitCodeFor, explainRegisterRefusal, formatCents, resolveSetup, resolveTarget, skillFor } from './run.js';
|
|
18
|
+
import { createInterruptHandler, deliver, driverOptions, exitCodeFor, explainRegisterRefusal, formatCents, resolveSetup, resolveTarget, skillFor, usableLocalBrief } from './run.js';
|
|
19
|
+
import { loadLocalBrief } from '../briefLocal.js';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* `ravensight-playtest fix <target>`: take one finding, change the code on a
|
|
@@ -784,6 +785,15 @@ export async function runVerify({
|
|
|
784
785
|
});
|
|
785
786
|
heartbeat.start();
|
|
786
787
|
|
|
788
|
+
// The verify replay plays like `run` does: with the brief's local fields,
|
|
789
|
+
// so it can sign in with the test account and knows the expected noise.
|
|
790
|
+
let localBrief = {};
|
|
791
|
+
try {
|
|
792
|
+
localBrief = usableLocalBrief(await loadLocalBrief({ repoRoot }));
|
|
793
|
+
} catch (error) {
|
|
794
|
+
log(`verify: ${String(error && error.message)} The replay goes ahead without expected_noise and test_account.`);
|
|
795
|
+
}
|
|
796
|
+
|
|
787
797
|
let outcome;
|
|
788
798
|
try {
|
|
789
799
|
outcome = await runPersona({
|
|
@@ -801,6 +811,7 @@ export async function runVerify({
|
|
|
801
811
|
skill: skillFor(pack, 'persona_playtest'),
|
|
802
812
|
// The brief fetched above, as `run` does: the pack does not always carry one.
|
|
803
813
|
brief: verifyBrief(brief || pack.brief || null, finding),
|
|
814
|
+
localBrief,
|
|
804
815
|
intentNotes: pack.intent_notes || [],
|
|
805
816
|
schemas: pack.schemas || [],
|
|
806
817
|
maxActions,
|
package/src/commands/init.js
CHANGED
|
@@ -9,9 +9,14 @@ import { CliError, ExitCode } from '../errors.js';
|
|
|
9
9
|
|
|
10
10
|
const GITIGNORE_LINE = '.ravensight/jobs/';
|
|
11
11
|
|
|
12
|
+
/** The local brief holds a test account password, so it is never committed. */
|
|
13
|
+
const LOCAL_BRIEF_LINE = '.ravensight/brief.local.json';
|
|
14
|
+
|
|
15
|
+
const GITIGNORE_LINES = [GITIGNORE_LINE, LOCAL_BRIEF_LINE];
|
|
16
|
+
|
|
12
17
|
/**
|
|
13
|
-
* Add `.ravensight/jobs/` to the repo's
|
|
14
|
-
* already ignore
|
|
18
|
+
* Add `.ravensight/jobs/` and `.ravensight/brief.local.json` to the repo's
|
|
19
|
+
* .gitignore if it has one and does not already ignore them.
|
|
15
20
|
*
|
|
16
21
|
* Job directories hold screenshots and a video, which nobody wants in their
|
|
17
22
|
* history. Only appended when a .gitignore already exists: creating one in
|
|
@@ -29,8 +34,10 @@ export async function ensureGitignore(repoRoot) {
|
|
|
29
34
|
if (error.code === 'ENOENT') return 'absent';
|
|
30
35
|
throw error;
|
|
31
36
|
}
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
const present = new Set(text.split('\n').map(line => line.trim()));
|
|
38
|
+
const missing = GITIGNORE_LINES.filter(line => !present.has(line));
|
|
39
|
+
if (missing.length === 0) return 'present';
|
|
40
|
+
await appendFile(file, `${text.endsWith('\n') || text === '' ? '' : '\n'}${missing.join('\n')}\n`);
|
|
34
41
|
return 'added';
|
|
35
42
|
}
|
|
36
43
|
|
|
@@ -137,8 +144,8 @@ async function linkRepo(context, flags, repoRoot, choose) {
|
|
|
137
144
|
const name = (games.find(game => game.gameId === gameId) || {}).name || gameId;
|
|
138
145
|
ui.ok(`Linked this repo to ${name} (${gameId}).`);
|
|
139
146
|
ui.info(`Wrote ${paths.configFile({ repoRoot })}`);
|
|
140
|
-
if (gitignore === 'added') ui.info(`Added ${
|
|
141
|
-
if (gitignore === 'absent') ui.warn(`No .gitignore here. Add ${
|
|
147
|
+
if (gitignore === 'added') ui.info(`Added ${GITIGNORE_LINES.join(' and ')} to .gitignore`);
|
|
148
|
+
if (gitignore === 'absent') ui.warn(`No .gitignore here. Add ${GITIGNORE_LINES.join(' and ')} to whatever ignores build output.`);
|
|
142
149
|
ui.blank();
|
|
143
150
|
ui.info('Next: ravensight-playtest brief');
|
|
144
151
|
return ExitCode.OK;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createContext } from '../api/index.js';
|
|
2
|
+
import { resolveGameId } from '../config.js';
|
|
3
|
+
import { withAuthHandling } from '../auth/session.js';
|
|
4
|
+
import { packs } from '../packs/index.js';
|
|
5
|
+
import { ui } from '../ui/index.js';
|
|
6
|
+
import { ExitCode } from '../errors.js';
|
|
7
|
+
import { DEFAULT_PERSONAS, defaultPersonasFor } from '../runDefaults.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `ravensight-playtest personas`
|
|
11
|
+
*
|
|
12
|
+
* The persona slugs `--personas` accepts, from the content pack this game
|
|
13
|
+
* gets, with each one's patience (its action budget) and play style. A free
|
|
14
|
+
* read: nothing is reserved.
|
|
15
|
+
*
|
|
16
|
+
* @param {Object} [flags]
|
|
17
|
+
* @param {Object} [deps] test seam: context, packs
|
|
18
|
+
* @returns {Promise<number>}
|
|
19
|
+
*/
|
|
20
|
+
export async function personas(flags = {}, deps = {}) {
|
|
21
|
+
const context = deps.context || await createContext({ apiUrl: flags.apiUrl, repoRoot: flags.repoRoot, requireAuth: true });
|
|
22
|
+
return withAuthHandling(context, async () => {
|
|
23
|
+
const gameId = resolveGameId(flags, context.config);
|
|
24
|
+
const answer = await (deps.packs || packs).get({ api: context.api, gameId, modules: ['persona_playtest'] });
|
|
25
|
+
const bundle = answer && answer.bundle ? answer.bundle : answer;
|
|
26
|
+
const list = (bundle && Array.isArray(bundle.personas) ? bundle.personas : []).map(persona => ({
|
|
27
|
+
slug: persona.slug,
|
|
28
|
+
name: persona.name || persona.slug,
|
|
29
|
+
patience_actions: Number(persona.patience_actions) || null,
|
|
30
|
+
playstyle: persona.playstyle || null,
|
|
31
|
+
skill_level: persona.skill_level || null
|
|
32
|
+
}));
|
|
33
|
+
const defaults = defaultPersonasFor(list);
|
|
34
|
+
if (flags.json) {
|
|
35
|
+
ui.json({ personas: list, default: defaults });
|
|
36
|
+
return ExitCode.OK;
|
|
37
|
+
}
|
|
38
|
+
ui.table(list.map(entry => ({ ...entry, slug: defaults.includes(entry.slug) ? `${entry.slug} (default)` : entry.slug, patience_actions: entry.patience_actions === null ? '' : String(entry.patience_actions) })), [
|
|
39
|
+
{ key: 'slug', label: 'SLUG' },
|
|
40
|
+
{ key: 'name', label: 'NAME' },
|
|
41
|
+
{ key: 'patience_actions', label: 'ACTIONS', align: 'right' },
|
|
42
|
+
{ key: 'playstyle', label: 'PLAYSTYLE' }
|
|
43
|
+
]);
|
|
44
|
+
ui.blank();
|
|
45
|
+
ui.info(`A run with no --personas plays ${defaults.join(', ') || DEFAULT_PERSONAS.join(', ')}. Pick others with --personas <slug,slug>; each persona is one persona run.`);
|
|
46
|
+
return ExitCode.OK;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default personas;
|