ravensight-playtest 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +380 -0
  3. package/addons/ravensight_driver/driver.gd +836 -0
  4. package/addons/ravensight_driver/export_plugin.gd +51 -0
  5. package/addons/ravensight_driver/plugin.cfg +7 -0
  6. package/addons/ravensight_driver/plugin.gd +36 -0
  7. package/bin/ravensight-playtest.js +31 -0
  8. package/package.json +45 -0
  9. package/src/api/README.md +500 -0
  10. package/src/api/client.js +340 -0
  11. package/src/api/errors.js +115 -0
  12. package/src/api/http.js +194 -0
  13. package/src/api/index.js +107 -0
  14. package/src/auth/deviceCode.js +79 -0
  15. package/src/auth/keychain.js +159 -0
  16. package/src/auth/session.js +128 -0
  17. package/src/cli.js +335 -0
  18. package/src/commands/brief.js +303 -0
  19. package/src/commands/check.js +318 -0
  20. package/src/commands/fakeCore.js +379 -0
  21. package/src/commands/init.js +120 -0
  22. package/src/commands/login.js +90 -0
  23. package/src/commands/logout.js +70 -0
  24. package/src/commands/open.js +125 -0
  25. package/src/commands/profile.js +262 -0
  26. package/src/commands/resume.js +156 -0
  27. package/src/commands/run.js +1015 -0
  28. package/src/commands/upload.js +137 -0
  29. package/src/config.js +100 -0
  30. package/src/dashboard.js +97 -0
  31. package/src/detect.js +77 -0
  32. package/src/errors.js +44 -0
  33. package/src/fsutil.js +77 -0
  34. package/src/godot.js +85 -0
  35. package/src/packs/index.js +191 -0
  36. package/src/paths.js +129 -0
  37. package/src/run/aggregate.js +658 -0
  38. package/src/run/args.js +111 -0
  39. package/src/run/context.js +181 -0
  40. package/src/run/deps.js +184 -0
  41. package/src/run/drivers/driver.js +183 -0
  42. package/src/run/drivers/godot-observation.js +138 -0
  43. package/src/run/drivers/godot-project.js +475 -0
  44. package/src/run/drivers/godot-rpc.js +225 -0
  45. package/src/run/drivers/godot.js +587 -0
  46. package/src/run/drivers/index.js +52 -0
  47. package/src/run/drivers/web.js +385 -0
  48. package/src/run/exit.js +21 -0
  49. package/src/run/heartbeat.js +131 -0
  50. package/src/run/index.js +31 -0
  51. package/src/run/json.js +56 -0
  52. package/src/run/model.js +384 -0
  53. package/src/run/paths.js +88 -0
  54. package/src/run/personaLoop.js +871 -0
  55. package/src/run/profile.js +214 -0
  56. package/src/run/regenerate.js +149 -0
  57. package/src/run/repoTools.js +286 -0
  58. package/src/run/report.js +222 -0
  59. package/src/run/resume.js +272 -0
  60. package/src/run/secretScan.js +171 -0
  61. package/src/run/state.js +198 -0
  62. package/src/run/synthetic.js +206 -0
  63. package/src/run/tools.js +344 -0
  64. package/src/run/transcript.js +93 -0
  65. package/src/run/usage.js +115 -0
  66. package/src/state/index.js +105 -0
  67. package/src/states.js +104 -0
  68. package/src/ui/index.js +195 -0
  69. package/src/upload/allowlist.js +116 -0
  70. package/src/upload/index.js +467 -0
  71. package/src/upload/queue.js +114 -0
  72. package/src/version.js +63 -0
package/src/cli.js ADDED
@@ -0,0 +1,335 @@
1
+ import { Command, Option } from 'commander';
2
+ import { CLI_VERSION } from './version.js';
3
+ import { ui } from './ui/index.js';
4
+ import { CliError, ExitCode } from './errors.js';
5
+ import { ApiError } from './api/errors.js';
6
+
7
+ /**
8
+ * The command line surface.
9
+ *
10
+ * Every command is loaded lazily, inside its action, for two reasons that both
11
+ * matter. Startup stays fast, because `npx ravensight-playtest check` should not
12
+ * pay to parse the whole runner: `src/run/index.js` reaches the model client,
13
+ * both drivers and the schema validator, and no other command needs any of
14
+ * them. And a build that is missing the runner (a partial install, or a tarball
15
+ * that lost a directory) fails with a sentence rather than a module resolution
16
+ * stack trace at import time.
17
+ */
18
+
19
+ /** Where each runner backed command lives, for the message below. */
20
+ const RUNNER_COMMANDS = {
21
+ run: 'src/run/index.js',
22
+ profile: 'src/run/index.js'
23
+ };
24
+
25
+ async function loadRunner(name, exportName) {
26
+ try {
27
+ const mod = await import('./run/index.js');
28
+ if (typeof mod[exportName] === 'function') return mod[exportName];
29
+ } catch {
30
+ // fall through to the message below
31
+ }
32
+ throw new CliError(
33
+ `The ${name} command is not available in this build.`,
34
+ 1,
35
+ { hint: `It lives in ${RUNNER_COMMANDS[name]}, which this install does not have. Reinstall the package.` }
36
+ );
37
+ }
38
+
39
+ /**
40
+ * The run specific flags, for `--help`.
41
+ *
42
+ * `run` and `profile` use `allowUnknownOption()` so that everything commander
43
+ * does not recognise reaches `src/run/args.js`, which is the right place for
44
+ * flags about driving a game. The cost is that commander knows nothing about
45
+ * them and so prints none of them, which would leave the two most important
46
+ * commands documenting only the four shared flags. This block is the fix, and it
47
+ * is text rather than options so it cannot change what gets parsed.
48
+ *
49
+ * Kept in the same order as `VALUED` and `BOOLEAN` in src/run/args.js, which is
50
+ * the list it has to agree with.
51
+ */
52
+ const RUNNER_HELP = `
53
+ Run options (parsed by the runner, see src/run/args.js):
54
+ --personas <list> comma separated persona slugs, default the pack's set
55
+ --driver <name> playwright_web or godot_driver
56
+ --build-url <url> the web build to play
57
+ --godot-project <dir> the Godot project directory
58
+ --cli <command> the command that starts a text build
59
+ --max-actions <n> cap the actions one persona may take
60
+ --concurrency <n> personas at once, default 2
61
+ --upload-video upload session.webm as well as the reports
62
+ --upload-transcript upload transcript.jsonl as well as the reports
63
+ --dry-upload list every file and byte that would be sent, send nothing
64
+ --headed show the browser
65
+ --headless hide it
66
+ --verbose print each turn
67
+
68
+ Accepted and reported as not applied by this release:
69
+ --budget-usd, --quality, --offline-upload, --build-dir
70
+ `;
71
+
72
+ /** Flags every command shares. */
73
+ function common(command) {
74
+ return command
75
+ .option('--api-url <url>', 'Ravensight API base URL (default from RAVENSIGHT_API_URL)')
76
+ .option('--repo <dir>', 'the repo to work in (default the current directory)')
77
+ .option('--json', 'machine readable output')
78
+ .option('-y, --yes', 'answer every confirmation with yes');
79
+ }
80
+
81
+ /**
82
+ * Wrap an action so its exit code survives commander, which discards whatever an
83
+ * action handler returns. The slot is per program instance rather than module
84
+ * scope, so two programs in one process (which is what the tests do) cannot read
85
+ * each other's result.
86
+ * @param {{code: number}} slot
87
+ * @param {Function} handler
88
+ */
89
+ function capture(slot, handler) {
90
+ return async (...args) => {
91
+ const code = await handler(...args);
92
+ slot.code = typeof code === 'number' ? code : ExitCode.OK;
93
+ };
94
+ }
95
+
96
+ /** Normalize commander's options into the flag shape the command modules take. */
97
+ function flagsFrom(options, extra = {}) {
98
+ const flags = {
99
+ apiUrl: options.apiUrl,
100
+ repoRoot: options.repo,
101
+ json: Boolean(options.json),
102
+ yes: Boolean(options.yes),
103
+ ...extra
104
+ };
105
+ if (flags.json) ui.setQuiet(true);
106
+ return flags;
107
+ }
108
+
109
+ const list = value => value.split(',').map(part => part.trim()).filter(Boolean);
110
+
111
+ /**
112
+ * Build the program. Exported rather than executed so a test can parse an
113
+ * argument vector without spawning a process or exiting it.
114
+ * @returns {Command}
115
+ */
116
+ export function buildProgram() {
117
+ const program = new Command();
118
+ const slot = { code: ExitCode.OK };
119
+ program.exitCodeSlot = slot;
120
+ program
121
+ .name('ravensight-playtest')
122
+ .description('AI personas play your game and file reports to Ravensight.')
123
+ .version(CLI_VERSION)
124
+ .showHelpAfterError()
125
+ .enablePositionalOptions();
126
+
127
+ common(program.command('login').description('log in to Ravensight with a device code'))
128
+ .option('--no-browser', 'do not try to open a browser')
129
+ .action(capture(slot, async options => {
130
+ const { login } = await import('./commands/login.js');
131
+ return login(flagsFrom(options, { noBrowser: options.browser === false }));
132
+ }));
133
+
134
+ common(program.command('logout').description('revoke this machine\'s token and forget it'))
135
+ .action(capture(slot, async options => {
136
+ const { logout } = await import('./commands/logout.js');
137
+ return logout(flagsFrom(options));
138
+ }));
139
+
140
+ common(program.command('check').description('check this machine, this repo and this account'))
141
+ .option('--game <gameId>', 'check against this game instead of the linked one')
142
+ .addOption(new Option('--modules <list>', 'price this module list').argParser(list))
143
+ .addOption(new Option('--personas <list>', 'price this persona list').argParser(list))
144
+ .option('--godot-path <path>', 'where Godot is, if it is not on the PATH')
145
+ .action(capture(slot, async options => {
146
+ const { check } = await import('./commands/check.js');
147
+ return check(flagsFrom(options, {
148
+ game: options.game,
149
+ modules: options.modules,
150
+ personas: options.personas,
151
+ godotPath: options.godotPath
152
+ }));
153
+ }));
154
+
155
+ common(program.command('init').description('link this repo to a Ravensight game'))
156
+ .option('--game <gameId>', 'the game to link')
157
+ .option('--force', 'relink a repo that is already linked')
158
+ .action(capture(slot, async options => {
159
+ const { init } = await import('./commands/init.js');
160
+ return init(flagsFrom(options, { game: options.game, force: Boolean(options.force) }));
161
+ }));
162
+
163
+ common(program.command('brief').description('write or read the expectations brief'))
164
+ .option('--show', 'print the current brief and stop')
165
+ .option('--open', 'open the brief form in the dashboard')
166
+ .option('--draft', 'save as a draft even when it looks complete')
167
+ .option('--from <file>', 'read the fields from a JSON file instead of asking')
168
+ .option('--game <gameId>', 'the game, when this repo is not linked')
169
+ .addOption(new Option('--modules <list>', 'price this module list').argParser(list))
170
+ .addOption(new Option('--personas <list>', 'price this persona list').argParser(list))
171
+ .action(capture(slot, async options => {
172
+ const { brief } = await import('./commands/brief.js');
173
+ return brief(flagsFrom(options, {
174
+ show: Boolean(options.show),
175
+ open: Boolean(options.open),
176
+ draft: Boolean(options.draft),
177
+ from: options.from,
178
+ game: options.game,
179
+ modules: options.modules,
180
+ personas: options.personas
181
+ }));
182
+ }));
183
+
184
+ common(program.command('upload').description('finish the uploads a job still owes'))
185
+ .argument('<jobId>')
186
+ .option('--game <gameId>', 'the game, when the job journal is missing')
187
+ .option('--dry-upload', 'list what would be sent and send nothing')
188
+ .option('--video', 'include the session video')
189
+ .option('--transcript', 'include the transcript')
190
+ .action(capture(slot, async (jobId, options) => {
191
+ const { upload } = await import('./commands/upload.js');
192
+ return upload(jobId, flagsFrom(options, {
193
+ game: options.game,
194
+ dryRun: Boolean(options.dryUpload),
195
+ video: options.video,
196
+ transcript: options.transcript
197
+ }));
198
+ }));
199
+
200
+ common(program.command('resume').description('carry on with a job this repo started'))
201
+ .argument('[jobId]')
202
+ .option('--game <gameId>', 'the game, when the job journal is missing')
203
+ .option('--status', 'report and stop, without restarting anything')
204
+ .option('--restart-interrupted', 'replay an interrupted run instead of finalising it from its transcript')
205
+ .action(capture(slot, async (jobId, options) => {
206
+ const { resume } = await import('./commands/resume.js');
207
+ return resume(jobId, flagsFrom(options, {
208
+ game: options.game,
209
+ status: Boolean(options.status),
210
+ // Left undefined rather than false when it was not typed, because the
211
+ // runner merges these over its own argv parse and flags win there.
212
+ restartInterrupted: options.restartInterrupted ? true : undefined
213
+ }));
214
+ }));
215
+
216
+ common(program.command('review').description('open the post job review form'))
217
+ .argument('[jobId]')
218
+ .option('--game <gameId>', 'the game, when this repo is not linked')
219
+ .action(capture(slot, async (jobId, options) => {
220
+ const { review } = await import('./commands/open.js');
221
+ return review(jobId, flagsFrom(options, { game: options.game }));
222
+ }));
223
+
224
+ common(program.command('open').description('open a job or the playtest dashboard'))
225
+ .argument('[jobId]')
226
+ .option('--game <gameId>', 'the game, when this repo is not linked')
227
+ .action(capture(slot, async (jobId, options) => {
228
+ const { open } = await import('./commands/open.js');
229
+ return open(jobId, flagsFrom(options, { game: options.game }));
230
+ }));
231
+
232
+ common(program.command('profile').description('build the game profile and capability report'))
233
+ .option('--game <gameId>', 'the game, when this repo is not linked')
234
+ .allowUnknownOption()
235
+ // Unknown options' values land in command.args; without this commander
236
+ // counts them as excess positional arguments and refuses the command.
237
+ .allowExcessArguments()
238
+ .addHelpText('after', RUNNER_HELP)
239
+ .action(capture(slot, async (options, command) => {
240
+ const profile = await loadRunner('profile', 'profileCommand');
241
+ return profile(flagsFrom(options, { game: options.game, argv: command.args }));
242
+ }));
243
+
244
+ common(program.command('run').description('run a playtest'))
245
+ .option('--game <gameId>', 'the game, when this repo is not linked')
246
+ .allowUnknownOption()
247
+ // Unknown options' values land in command.args; without this commander
248
+ // counts them as excess positional arguments and refuses the command.
249
+ .allowExcessArguments()
250
+ .addHelpText('after', RUNNER_HELP)
251
+ .action(capture(slot, async (options, command) => {
252
+ const run = await loadRunner('run', 'runCommand');
253
+ return run(flagsFrom(options, { game: options.game, argv: command.args }));
254
+ }));
255
+
256
+ return program;
257
+ }
258
+
259
+ /**
260
+ * Parse and dispatch, answering an exit code rather than calling `process.exit`.
261
+ *
262
+ * An `ApiError` is reported by its message and its validation pointers, which is
263
+ * what the server wrote them for: a developer who sees
264
+ * `/build_notes/controls: required` can fix it, and a developer who sees
265
+ * `400 Bad Request` cannot.
266
+ *
267
+ * @param {string[]} argv
268
+ * @returns {Promise<number>}
269
+ */
270
+ export async function main(argv = process.argv, options = {}) {
271
+ const program = buildProgram();
272
+ program.exitOverride();
273
+ // Commander writes its own help and usage errors straight to the process
274
+ // streams. Routing them through `options.write` lets a test drive the whole
275
+ // program without printing a page of help into the test log.
276
+ if (options.write) {
277
+ program.configureOutput({ writeOut: options.write, writeErr: options.write });
278
+ }
279
+ try {
280
+ await program.parseAsync(argv);
281
+ return program.exitCodeSlot.code;
282
+ } catch (error) {
283
+ // commander throws for --help and --version, which are successes.
284
+ if (error && error.code === 'commander.helpDisplayed') return ExitCode.OK;
285
+ if (error && error.code === 'commander.version') return ExitCode.OK;
286
+ if (error && error.code === 'commander.help') return ExitCode.OK;
287
+ if (error && typeof error.exitCode === 'number' && String(error.code || '').startsWith('commander.')) {
288
+ return error.exitCode;
289
+ }
290
+ return report(error);
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Turn a thrown thing into a printed message and an exit code.
296
+ * @param {unknown} error
297
+ * @returns {number}
298
+ */
299
+ export function report(error) {
300
+ if (error instanceof CliError) {
301
+ ui.error(error.message);
302
+ if (error.hint) ui.info(error.hint);
303
+ return error.exitCode;
304
+ }
305
+ if (error instanceof ApiError) {
306
+ ui.error(error.message);
307
+ const details = error.formatDetails();
308
+ if (details) ui.info(details);
309
+
310
+ // An ingest key on a playtest route is 403 `ingest_key_not_allowed`, which
311
+ // reads like a permissions problem and is not one: it is the wrong kind of
312
+ // credential entirely, and no amount of granting roles will fix it.
313
+ if (error.code === 'ingest_key_not_allowed') {
314
+ ui.info('That is a gt_live_ ingest key, which game clients use to send events.');
315
+ ui.info('This CLI needs a gt_cli_ token: run ravensight-playtest login.');
316
+ return ExitCode.AUTH;
317
+ }
318
+ if (error.status === 401 || error.status === 403) {
319
+ ui.info('Run ravensight-playtest login, or check the token scopes.');
320
+ return ExitCode.AUTH;
321
+ }
322
+ // A 404 under /api/v1/games/<gameId>/ is the server's way of saying "not
323
+ // yours, or not there", deliberately indistinguishable so an outsider
324
+ // cannot probe for a game's existence. That means the message alone can
325
+ // never tell a developer which of the two it was, so name both.
326
+ if (error.status === 404 && /\/api\/v1\/games\//.test(error.path || '')) {
327
+ ui.info('That game, job or run is not visible to this token.');
328
+ ui.info('Run ravensight-playtest check to confirm the game is linked, or log in again.');
329
+ return ExitCode.FAILURE;
330
+ }
331
+ return ExitCode.FAILURE;
332
+ }
333
+ ui.error(error && error.stack ? error.stack : String(error));
334
+ return ExitCode.FAILURE;
335
+ }
@@ -0,0 +1,303 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { createContext } from '../api/index.js';
3
+ import { ApiError } from '../api/errors.js';
4
+ import { resolveGameId } from '../config.js';
5
+ import { withAuthHandling } from '../auth/session.js';
6
+ import { readJson } from '../fsutil.js';
7
+ import { briefLink, openInBrowser } from '../dashboard.js';
8
+ import { ui } from '../ui/index.js';
9
+ import { CliError, ExitCode } from '../errors.js';
10
+
11
+ /** The goals the server's expectations-brief schema accepts, in its own order. */
12
+ export const GOALS = Object.freeze([
13
+ 'find_bugs',
14
+ 'onboarding',
15
+ 'balance',
16
+ 'level_flow',
17
+ 'narrative',
18
+ 'accessibility',
19
+ 'performance'
20
+ ]);
21
+
22
+ const DEFAULT_MODULES = ['game_profile', 'persona_playtest'];
23
+
24
+ /**
25
+ * The four things a quick brief needs, which are exactly the four the server
26
+ * calls a complete brief: at least one goal, what success looks like, how the
27
+ * game is controlled, and where a persona starts.
28
+ *
29
+ * Duplicated from the server's `completenessFor` on purpose, so the CLI can
30
+ * tell a developer what is missing without a round trip. The server still
31
+ * decides: `POST /brief/check` records its own verdict, and `PUT /brief` with
32
+ * `status: 'complete'` validates against the schema.
33
+ *
34
+ * @param {Object} fields
35
+ * @returns {{complete: boolean, missing: string[]}}
36
+ */
37
+ export function completenessFor(fields = {}) {
38
+ const buildNotes = (fields && fields.build_notes) || {};
39
+ const missing = [];
40
+ const filled = value => typeof value === 'string' && value.trim().length > 0;
41
+ if (!Array.isArray(fields.goals) || fields.goals.length < 1) missing.push('/goals');
42
+ if (!filled(fields.success_criteria)) missing.push('/success_criteria');
43
+ if (!filled(buildNotes.controls)) missing.push('/build_notes/controls');
44
+ if (!filled(buildNotes.how_to_start)) missing.push('/build_notes/how_to_start');
45
+ return { complete: missing.length === 0, missing };
46
+ }
47
+
48
+ /**
49
+ * Parse a goals answer: a comma separated list of names or numbers from the
50
+ * menu, in priority order. The order is the priority, which is one fewer
51
+ * question than asking for a number per goal and is what a developer means when
52
+ * they list them.
53
+ * @param {string} answer
54
+ * @returns {Array<{goal: string, priority: number}>}
55
+ */
56
+ export function parseGoals(answer) {
57
+ const wanted = String(answer || '')
58
+ .split(',')
59
+ .map(part => part.trim().toLowerCase())
60
+ .filter(Boolean);
61
+ const goals = [];
62
+ for (const entry of wanted) {
63
+ const byIndex = Number.isInteger(Number(entry)) ? GOALS[Number(entry) - 1] : undefined;
64
+ const name = byIndex || (GOALS.includes(entry) ? entry : undefined);
65
+ if (!name || goals.some(goal => goal.goal === name)) continue;
66
+ goals.push({ goal: name, priority: Math.min(goals.length + 1, 5) });
67
+ }
68
+ return goals;
69
+ }
70
+
71
+ /** Print a brief the way a person reads one. */
72
+ function showBrief(brief, rendered) {
73
+ ui.info(`Brief version ${brief.version}, ${brief.status}`);
74
+ const completeness = brief.completeness || completenessFor(brief.fields);
75
+ if (!completeness.complete) {
76
+ ui.warn(`Still missing: ${completeness.missing.join(', ')}`);
77
+ }
78
+ ui.blank();
79
+ ui.info(rendered || '');
80
+ }
81
+
82
+ /**
83
+ * A question asker over a pair of streams.
84
+ *
85
+ * One readline interface for the whole form, not one per question: Node's
86
+ * readline consumes everything already buffered on its input when it reads the
87
+ * first line, so a second interface over the same stdin loses whatever the
88
+ * first one buffered. That is also why `askQuickBrief` takes an `ask` function
89
+ * rather than building the interface itself, which is what makes the form
90
+ * testable without a terminal.
91
+ *
92
+ * @param {{input?: object, output?: object}} [io]
93
+ * @returns {{ask: (question: string) => Promise<string>, close: () => void}}
94
+ */
95
+ export function createPrompter(io = {}) {
96
+ const rl = createInterface({
97
+ input: io.input || process.stdin,
98
+ output: io.output || process.stdout
99
+ });
100
+ return {
101
+ ask: question => rl.question(question),
102
+ close: () => rl.close()
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Ask the four quick brief questions, seeded with whatever is already there so
108
+ * an edit is an edit and not a retype. An empty answer keeps the current value.
109
+ *
110
+ * @param {Object} current - the existing `fields`, or {}
111
+ * @param {{ask?: Function, input?: object, output?: object}} [io]
112
+ * @returns {Promise<Object>} the new `fields`
113
+ */
114
+ export async function askQuickBrief(current = {}, io = {}) {
115
+ const prompter = io.ask ? { ask: io.ask, close: () => {} } : createPrompter(io);
116
+ const keep = (answer, existing) => (String(answer || '').trim() ? String(answer).trim() : existing);
117
+
118
+ try {
119
+ ui.info('Goals, most important first. Pick from:');
120
+ GOALS.forEach((goal, index) => ui.info(` ${index + 1}. ${goal}`));
121
+ const currentGoals = Array.isArray(current.goals) ? current.goals : [];
122
+ const goalsAnswer = await prompter.ask(
123
+ `Goals${currentGoals.length ? ` [${currentGoals.map(g => g.goal).join(',')}]` : ''}: `
124
+ );
125
+ const goals = String(goalsAnswer || '').trim() ? parseGoals(goalsAnswer) : currentGoals;
126
+
127
+ const success = keep(
128
+ await prompter.ask(`What does success look like?${current.success_criteria ? ' [keep]' : ''} `),
129
+ current.success_criteria || ''
130
+ );
131
+
132
+ const buildNotes = current.build_notes || {};
133
+ const controls = keep(
134
+ await prompter.ask(`How is it controlled?${buildNotes.controls ? ' [keep]' : ''} `),
135
+ buildNotes.controls || ''
136
+ );
137
+ const howToStart = keep(
138
+ await prompter.ask(`Where does a player start?${buildNotes.how_to_start ? ' [keep]' : ''} `),
139
+ buildNotes.how_to_start || ''
140
+ );
141
+
142
+ return {
143
+ ...current,
144
+ goals,
145
+ success_criteria: success,
146
+ build_notes: { ...buildNotes, controls, how_to_start: howToStart }
147
+ };
148
+ } finally {
149
+ prompter.close();
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Save a brief, handling a stale parent version.
155
+ *
156
+ * Brief versions are immutable and every save writes n+1 against the version it
157
+ * was based on. A 409 `stale_version` means somebody else (usually the same
158
+ * developer, in the dashboard) saved in between. The answer carries
159
+ * `current_version`, so the fix is to say what happened and retry against that
160
+ * version rather than to silently overwrite their edit.
161
+ *
162
+ * @param {Object} options
163
+ * @returns {Promise<Object>} `{ brief, rendered }`
164
+ */
165
+ export async function saveBrief({ api, gameId, fields, status, parentVersion, onStale }) {
166
+ try {
167
+ return await api.brief.put(gameId, { fields, status, parentVersion });
168
+ } catch (error) {
169
+ if (!(error instanceof ApiError) || error.code !== 'stale_version') throw error;
170
+ const current = error.body && error.body.current_version;
171
+ if (onStale) {
172
+ const retry = await onStale(current);
173
+ if (!retry) throw new CliError('The brief changed while you were editing it.', 1, {
174
+ hint: 'Run brief again to start from the current version.'
175
+ });
176
+ }
177
+ return api.brief.put(gameId, { fields, status, parentVersion: current });
178
+ }
179
+ }
180
+
181
+ /**
182
+ * `ravensight-playtest brief`
183
+ *
184
+ * Show, edit or open the expectations brief. Editing is the default, because a
185
+ * job on a game with no complete brief is refused with 409 `brief_required` and
186
+ * this is the command that fixes that.
187
+ *
188
+ * @param {Object} [flags]
189
+ * @param {Object} [deps]
190
+ * @returns {Promise<number>}
191
+ */
192
+ export async function brief(flags = {}, deps = {}) {
193
+ const context = deps.context || await createContext({
194
+ apiUrl: flags.apiUrl,
195
+ repoRoot: flags.repoRoot,
196
+ requireAuth: true
197
+ });
198
+ return withAuthHandling(context, () => editBrief(context, flags, deps));
199
+ }
200
+
201
+ async function editBrief(context, flags, deps) {
202
+ const gameId = resolveGameId(flags, context.config);
203
+ const api = context.api;
204
+
205
+ const current = await api.brief.get(gameId);
206
+
207
+ if (flags.open) {
208
+ const link = briefLink(gameId, context.apiUrl);
209
+ ui.info(link);
210
+ await openInBrowser(link, deps);
211
+ return ExitCode.OK;
212
+ }
213
+
214
+ if (flags.show) {
215
+ if (!current.brief) {
216
+ if (flags.json) ui.json({ brief: null });
217
+ else ui.info('No brief yet. Run brief with no flags to write one.');
218
+ return ExitCode.OK;
219
+ }
220
+ if (flags.json) ui.json(current);
221
+ else showBrief(current.brief, current.rendered);
222
+ return ExitCode.OK;
223
+ }
224
+
225
+ const existingFields = current.brief ? current.brief.fields : {};
226
+ const parentVersion = current.brief ? current.brief.version : null;
227
+
228
+ let fields;
229
+ if (flags.from) {
230
+ const loaded = await readJson(flags.from, null);
231
+ if (!loaded || typeof loaded !== 'object') {
232
+ throw new CliError(`${flags.from} does not contain a brief fields object.`);
233
+ }
234
+ fields = loaded.fields && typeof loaded.fields === 'object' ? loaded.fields : loaded;
235
+ } else {
236
+ fields = await askQuickBrief(existingFields, deps);
237
+ }
238
+
239
+ const local = completenessFor(fields);
240
+ const status = flags.draft ? 'draft' : local.complete ? 'complete' : 'draft';
241
+
242
+ const saved = await saveBrief({
243
+ api,
244
+ gameId,
245
+ fields,
246
+ status,
247
+ parentVersion,
248
+ onStale: async version => {
249
+ ui.warn(`The brief moved to version ${version} while you were editing.`);
250
+ return ui.confirm('Save on top of that version?', { yes: flags.yes });
251
+ }
252
+ });
253
+
254
+ // Tell the server what the CLI thinks completeness is. The server keeps its
255
+ // own verdict; this is the signal spec 18 uses to see where developers get
256
+ // stuck filling the form in.
257
+ try {
258
+ await api.brief.check(gameId, completenessFor(saved.brief.fields));
259
+ } catch {
260
+ // A refused check does not undo a saved brief, and the brief is the point.
261
+ }
262
+
263
+ const estimateModules = flags.modules || (context.config && context.config.modules) || DEFAULT_MODULES;
264
+ const estimatePersonas = flags.personas || (context.config && context.config.personas) || [];
265
+ let estimate = null;
266
+ try {
267
+ estimate = await api.brief.estimate(gameId, { modules: estimateModules, personas: estimatePersonas });
268
+ } catch (error) {
269
+ if (!(error instanceof ApiError)) throw error;
270
+ }
271
+
272
+ if (flags.json) {
273
+ ui.json({ ...saved, estimate });
274
+ return ExitCode.OK;
275
+ }
276
+
277
+ ui.ok(`Saved brief version ${saved.brief.version} (${saved.brief.status}).`);
278
+ const after = saved.brief.completeness || completenessFor(saved.brief.fields);
279
+ if (!after.complete) {
280
+ ui.warn(`Still missing: ${after.missing.join(', ')}`);
281
+ ui.info('A persona playtest job needs a complete brief, so fill those in before you run one.');
282
+ }
283
+
284
+ if (estimate) {
285
+ ui.blank();
286
+ ui.info(`A ${estimateModules.join(' plus ')} job would cost ${ui.money(estimate.estimate_cents)}.`);
287
+ if (estimate.wall_warning) ui.warn(estimate.wall_warning);
288
+ ui.table(estimate.breakdown, [
289
+ { key: 'unit', label: 'UNIT' },
290
+ { key: 'count', label: 'COUNT', align: 'right' },
291
+ { key: 'unit_cents', label: 'CENTS EACH', align: 'right' }
292
+ ]);
293
+ if (estimate.balance_cents === null || estimate.balance_cents === undefined) {
294
+ ui.info('Your role cannot see the balance, so ask an owner whether it covers that.');
295
+ } else if (estimate.ok) {
296
+ ui.ok(`Available balance ${ui.money(estimate.balance_cents)}, which covers it.`);
297
+ } else {
298
+ ui.warn(`Available balance ${ui.money(estimate.balance_cents)}, which does not cover it.`);
299
+ ui.info(`Top up at ${estimate.topup_url}`);
300
+ }
301
+ }
302
+ return ExitCode.OK;
303
+ }