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.
- package/LICENSE +21 -0
- package/README.md +380 -0
- package/addons/ravensight_driver/driver.gd +836 -0
- package/addons/ravensight_driver/export_plugin.gd +51 -0
- package/addons/ravensight_driver/plugin.cfg +7 -0
- package/addons/ravensight_driver/plugin.gd +36 -0
- package/bin/ravensight-playtest.js +31 -0
- package/package.json +45 -0
- package/src/api/README.md +500 -0
- package/src/api/client.js +340 -0
- package/src/api/errors.js +115 -0
- package/src/api/http.js +194 -0
- package/src/api/index.js +107 -0
- package/src/auth/deviceCode.js +79 -0
- package/src/auth/keychain.js +159 -0
- package/src/auth/session.js +128 -0
- package/src/cli.js +335 -0
- package/src/commands/brief.js +303 -0
- package/src/commands/check.js +318 -0
- package/src/commands/fakeCore.js +379 -0
- package/src/commands/init.js +120 -0
- package/src/commands/login.js +90 -0
- package/src/commands/logout.js +70 -0
- package/src/commands/open.js +125 -0
- package/src/commands/profile.js +262 -0
- package/src/commands/resume.js +156 -0
- package/src/commands/run.js +1015 -0
- package/src/commands/upload.js +137 -0
- package/src/config.js +100 -0
- package/src/dashboard.js +97 -0
- package/src/detect.js +77 -0
- package/src/errors.js +44 -0
- package/src/fsutil.js +77 -0
- package/src/godot.js +85 -0
- package/src/packs/index.js +191 -0
- package/src/paths.js +129 -0
- package/src/run/aggregate.js +658 -0
- package/src/run/args.js +111 -0
- package/src/run/context.js +181 -0
- package/src/run/deps.js +184 -0
- package/src/run/drivers/driver.js +183 -0
- package/src/run/drivers/godot-observation.js +138 -0
- package/src/run/drivers/godot-project.js +475 -0
- package/src/run/drivers/godot-rpc.js +225 -0
- package/src/run/drivers/godot.js +587 -0
- package/src/run/drivers/index.js +52 -0
- package/src/run/drivers/web.js +385 -0
- package/src/run/exit.js +21 -0
- package/src/run/heartbeat.js +131 -0
- package/src/run/index.js +31 -0
- package/src/run/json.js +56 -0
- package/src/run/model.js +384 -0
- package/src/run/paths.js +88 -0
- package/src/run/personaLoop.js +871 -0
- package/src/run/profile.js +214 -0
- package/src/run/regenerate.js +149 -0
- package/src/run/repoTools.js +286 -0
- package/src/run/report.js +222 -0
- package/src/run/resume.js +272 -0
- package/src/run/secretScan.js +171 -0
- package/src/run/state.js +198 -0
- package/src/run/synthetic.js +206 -0
- package/src/run/tools.js +344 -0
- package/src/run/transcript.js +93 -0
- package/src/run/usage.js +115 -0
- package/src/state/index.js +105 -0
- package/src/states.js +104 -0
- package/src/ui/index.js +195 -0
- package/src/upload/allowlist.js +116 -0
- package/src/upload/index.js +467 -0
- package/src/upload/queue.js +114 -0
- 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
|
+
}
|