ravensight-playtest 0.2.3 → 0.2.5

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 CHANGED
@@ -87,6 +87,18 @@ content pack verified by sha256, disk, recommended concurrency, and the two
87
87
  optional tools. Exit code 3 when any row fails; a missing optional tool is a
88
88
  warning and never a failure, so CI does not break on it.
89
89
 
90
+ For a web game there is also a `browser` row: `check` imports Playwright,
91
+ launches a headless Chromium and closes it, which is exactly how a web run
92
+ starts. A missing browser fails the check when the linked build is a URL or you
93
+ pass `--web`. When no build is linked yet and the configured driver is the
94
+ default `playwright_web`, it is only a warning, so a Godot repo that never
95
+ changed the default is not failed over a browser it will not use. A repo linked
96
+ to a Godot project or a driver config gets no browser row. The fix is:
97
+
98
+ ```bash
99
+ npm i -D playwright && npx playwright install chromium
100
+ ```
101
+
90
102
  Past `--personas` in `POST /jobs`, requesting more than 100 personas or the same
91
103
  slug twice is refused with 400 `too_many_personas`, and a slug this game does
92
104
  not have is 400 `unknown_persona`; `run` checks the count against the same
@@ -101,6 +113,7 @@ automatically. `check` shows that same warning as a row when it applies.
101
113
  | `--modules <list>` | Price this module list, comma separated. |
102
114
  | `--personas <list>` | Price this persona list. Without it the balance row prices no personas, so the quote is lower than a real job's. |
103
115
  | `--godot-path <path>` | Where Godot is, if it is not on the PATH. |
116
+ | `--web` | Check the browser even when this repo is not linked to a web build. |
104
117
 
105
118
  ### init
106
119
 
@@ -108,9 +121,15 @@ Links this repo to a game and writes `.ravensight/config.json`. Refuses a game
108
121
  your token cannot see, so the first job does not fail with a 404. Adds
109
122
  `.ravensight/jobs/` to an existing `.gitignore`.
110
123
 
124
+ Without `--game`, one visible game is linked as is. With several, `init` lists
125
+ them numbered and asks `Which game? [1-N]` when it is running in a terminal; in
126
+ CI or a pipe it prints the list and asks you to pass `--game` instead. A token
127
+ that can see no games is told so, with the fix (approve a game for the token at
128
+ login).
129
+
111
130
  | Flag | Effect |
112
131
  |---|---|
113
- | `--game <gameId>` | The game to link. With one visible game it is optional. |
132
+ | `--game <gameId>` | The game to link. Optional with one visible game, or in a terminal. |
114
133
  | `--force` | Relink a repo that is already linked. |
115
134
 
116
135
  ### brief
@@ -130,6 +149,30 @@ warning `run` shows. A brief is required before `run`; the server answers 409
130
149
  | `--game <gameId>` | The game, when this repo is not linked. |
131
150
  | `--modules <list>`, `--personas <list>` | Price this module and persona list in the estimate it prints. |
132
151
 
152
+ #### The `brief --from` file
153
+
154
+ A JSON object of brief fields, or the same object wrapped as
155
+ `{ "fields": { ... } }`, which is how a saved brief carries them. The first four
156
+ are what makes a brief complete; the rest are optional.
157
+
158
+ | Field | Shape | Notes |
159
+ |---|---|---|
160
+ | `goals` | `[{ "goal": "...", "priority": 1 }]` | At least one. `goal` is one of `find_bugs`, `onboarding`, `balance`, `level_flow`, `narrative`, `accessibility`, `performance`. `priority` is 1 (highest) to 5. |
161
+ | `success_criteria` | string | What success looks like, one criterion per line. The aggregate report scores each line. |
162
+ | `build_notes.controls` | string | How to play: keys, buttons, the verbs a persona needs. |
163
+ | `build_notes.how_to_start` | string | Where a persona begins: the URL, the menu path, the first thing to press. |
164
+ | `design_intent_notes` | array of strings | Deliberate choices a persona must not report as bugs. |
165
+ | `known_issues` | array of strings | Issues you already know about, so they are not reported again. |
166
+
167
+ ```json
168
+ {
169
+ "goals": [{ "goal": "onboarding", "priority": 1 }, { "goal": "find_bugs", "priority": 2 }],
170
+ "success_criteria": "A new player reaches level 2\nNobody gets stuck in the menu",
171
+ "build_notes": { "controls": "Arrow keys to move, space to jump", "how_to_start": "Press Start on the title screen" },
172
+ "design_intent_notes": ["The fog on level 1 is intentional"]
173
+ }
174
+ ```
175
+
133
176
  ### profile
134
177
 
135
178
  Builds the game profile and the capability report: what your game is, what can
@@ -158,7 +201,7 @@ splitting the job in two is what gets everything played.
158
201
  | `--build-url <url>` | The web build to play. |
159
202
  | `--godot-project <dir>` | The Godot project directory. |
160
203
  | `--cli <command>` | The command that starts a text build. |
161
- | `--max-actions <n>` | Cap the actions one persona may take. |
204
+ | `--max-actions <n>` | Lower the actions one persona may take. It can only lower the persona's own patience (and the server's per run ceiling), never raise it. |
162
205
  | `--concurrency <n>` | Personas at once. Default 2; `check` recommends a number for this machine. |
163
206
  | `--upload-video` | Upload `session.webm` as well as the reports. |
164
207
  | `--upload-transcript` | Upload `transcript.jsonl` as well as the reports. |
@@ -166,10 +209,24 @@ splitting the job in two is what gets everything played.
166
209
  | `--headed`, `--headless` | Show or hide the browser. |
167
210
  | `--verbose` | Print each turn. |
168
211
 
169
- Four flags are accepted and reported as not applied by this release, rather than
170
- silently ignored: `--budget-usd` and `--quality` (the budget and the model tier
171
- are the server's, enforced at the proxy), `--offline-upload` (uploads already
172
- queue) and `--build-dir` (serve the build and pass `--build-url`).
212
+ Three flags are accepted and reported as not applied by this release, rather
213
+ than silently ignored: `--quality` (the model tier is the server's, enforced at
214
+ the proxy), `--offline-upload` (uploads already queue) and `--build-dir` (serve
215
+ the build and pass `--build-url`). There is no spending flag: each run's model
216
+ budget is set by the server from what the job was charged, and `--budget-usd` is
217
+ an unknown option (the refusal says where the budget comes from).
218
+
219
+ Each run hands its playtest identity to the game, so the game's own Ravensight
220
+ events from a playtest are marked synthetic rather than counted as a player. A
221
+ Godot game reads it from its environment. A web game's JavaScript SDK reads it
222
+ from the page: the browser driver sets `window.__RAVENSIGHT_PLAYTEST__` and
223
+ `localStorage['ravensight.playtest']` before the game's scripts run. It is never
224
+ sent as a request header, which would reach your game's own API too.
225
+
226
+ When a run reaches its model budget before the report step, nothing more is
227
+ asked of the model. The persona's notes are written to a partial `report.json`
228
+ (`partial: true`, one unreviewed finding per note with the nearest screenshot as
229
+ evidence) and uploaded, so the job's aggregate still counts them.
173
230
 
174
231
  ### fix
175
232
 
@@ -465,3 +522,29 @@ import { createClient, packs, uploadRunDir } from 'ravensight-playtest';
465
522
  ## License
466
523
 
467
524
  MIT. Copyright Reality Software Entertainment.
525
+
526
+ ## Ravensight Playtester action selection
527
+
528
+ The optional `--controller jev` mode uses the existing persona run. A screenshot
529
+ observer proposes actions, Ravensight selects one on the server, and the local
530
+ driver executes only that choice. Ambiguous controls stop the run for review.
531
+ Reports, screenshots and findings use the normal upload and review flow.
532
+
533
+ ```sh
534
+ ravensight-playtest run --driver playwright_web --controller jev --build-url https://your-game.example/ --max-actions 12 --headed
535
+ ```
536
+
537
+ Sign in, initialize the project and save its expectations brief first. The server
538
+ must advertise this controller in its content pack; otherwise the command stops
539
+ before registering a paid job. No provider key belongs in the CLI. Selection costs
540
+ share the existing persona-run budget and do not create another wallet charge.
541
+ Resume preserves the controller recorded in the job journal.
542
+
543
+ For Jump Bunny, use `https://reality-software-entertainment.github.io/poc-jump-bunny/`
544
+ and the public player label **Ravensight Playtester**. This is a local browser run,
545
+ not a hosted browser service. Human playtesting is still needed for subjective
546
+ feedback and timing-sensitive gameplay.
547
+
548
+ ## Build adapters
549
+
550
+ Browser and Godot runners share the paid playtest lifecycle with desktop, Unity/Unreal export, Android/iOS and custom JSON-RPC adapters. Native adapters require a locally configured automation server and remain experimental until validated against your build. See [adapter setup and validation boundaries](docs/ADAPTERS.md).
@@ -0,0 +1,77 @@
1
+ # Connect a game to Ravensight Playtester
2
+
3
+ Run the CLI on the machine that can access your build. Sign in, link the project with `ravensight-playtest init --game <gameId>`, and complete its expectations brief. `run` quotes the existing persona price before registration. Adapters share the same prepaid job, action limits, model budget, cancellation, reports and uploads.
4
+
5
+ | Adapter | Target | Validation |
6
+ | --- | --- | --- |
7
+ | `playwright_web` | Browser URL, including canvas/WebGL exports | Automated tests and live Jump Bunny gameplay |
8
+ | `godot_driver` | Godot project with the playtest addon | Existing addon protocol and runner tests |
9
+ | `desktop_driver` | Local W3C/Appium session configuration | Protocol tested; experimental on physical builds |
10
+ | `unity_driver`, `unreal_driver` | Desktop exports through that same W3C interface | Engine-neutral screen controls, not editor plugins; experimental |
11
+ | `android_driver`, `ios_driver` | Appium device/emulator/simulator configuration | Protocol tested; hardware/build validation still required |
12
+ | `cli_stdio` | Developer-owned JSON-RPC bridge | Real child-process protocol test |
13
+
14
+ No adapter promises every game is autonomously solvable. Fast action games, unusual controls, anti-cheat, hardware access and protected platforms may require a game-specific bridge. Console tooling and authorization remain the developer's responsibility. The runner cannot bypass platform access restrictions.
15
+
16
+ ## Browser and Godot
17
+
18
+ ```sh
19
+ ravensight-playtest run --driver playwright_web --build-url https://your-game.example/ --headed
20
+ ravensight-playtest run --driver godot_driver --godot-project ./your-game
21
+ ```
22
+
23
+ `--controller jev` enables structured action selection when the server supports it. Screen interpretation proposes controls and the selector chooses a supplied action. Uncertain decisions stop for review. Provider credentials stay on Ravensight. A completed playtest report is evidence for human review, not proof that a game is bug-free.
24
+
25
+ ## Desktop and mobile
26
+
27
+ Install and configure an Appium-compatible driver for your platform. Start its server locally. Supply `ravensight-driver.json` with a loopback endpoint and capabilities appropriate to the installed driver:
28
+
29
+ ```json
30
+ {
31
+ "endpoint": "http://127.0.0.1:4723",
32
+ "capabilities": {
33
+ "platformName": "Android",
34
+ "appium:automationName": "UiAutomator2",
35
+ "appium:deviceName": "YOUR_LOCAL_DEVICE",
36
+ "appium:app": "/absolute/path/to/game.apk"
37
+ }
38
+ }
39
+ ```
40
+
41
+ ```sh
42
+ ravensight-playtest run --driver android_driver --driver-config ./ravensight-driver.json
43
+ ```
44
+
45
+ For iOS, use `ios_driver`, `platformName: iOS`, your XCUITest driver and a provisioned device or simulator. For desktop, use the capabilities required by your installed desktop driver. Unity/Unreal exports use `unity_driver`/`unreal_driver`; no engine source is uploaded by this configuration. Configuration files are local and should not contain provider credentials. Do not expose the automation server publicly.
46
+
47
+ The runner creates a W3C session, reads page source plus PNG screenshots, and executes bounded coordinate clicks, keys, text and single-pointer swipes. It releases input after each action and closes the session when done. One persona uses the device at a time. Native adapters currently capture screenshots, not video or engine logs. Browser navigation is unavailable on these adapters. Model-visible text is limited to 12,000 characters; responses are bounded to 8 MB and commands time out after 30 seconds.
48
+
49
+ ## Custom bridge
50
+
51
+ Use this for text games or your own engine/hardware integration. `--driver-config` points to:
52
+
53
+ ```json
54
+ {
55
+ "command": "/absolute/path/to/bridge-executable",
56
+ "args": ["--game", "/absolute/path/to/build"],
57
+ "cwd": "/absolute/path/to/project"
58
+ }
59
+ ```
60
+
61
+ The configured executable is trusted local code chosen by the developer. The model cannot change it. It runs without a shell. Standard output must contain newline-delimited JSON-RPC 2.0 responses. Put diagnostics on stderr. Respond with the request ID and `result`, or an `error`:
62
+
63
+ ```json
64
+ {"jsonrpc":"2.0","id":1,"method":"launch","params":{"protocolVersion":1}}
65
+ {"jsonrpc":"2.0","id":1,"result":{"ok":true}}
66
+ ```
67
+
68
+ Required methods:
69
+
70
+ - `launch`: initialize/reset the game; return `{ "ok": true }`.
71
+ - `observe`: return `{ "text": "visible state" }`, optionally `screenshotBase64` containing PNG data.
72
+ - `act`: accept a `kind` of `key`, `type`, `click` or `wait`; return `{ "ok": true }` after execution. Apply your own game-specific legality checks. Never interpret model text as shell code.
73
+ - `screenshot`: return `{ "pngBase64": "..." }`, or a JSON-RPC error if unavailable. Text-only games can still produce text reports without screenshots.
74
+
75
+ Requests expire after 30 seconds. Oversized or malformed responses stop the bridge. The runner terminates its child when the session ends; the bridge must clean up any game processes it owns on termination. No custom protocol can establish compatibility with untested hardware by itself.
76
+
77
+ Protocol references: [W3C WebDriver](https://www.w3.org/TR/webdriver/), [Appium capabilities](https://appium.io/docs/en/3.2/guides/caps/).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ravensight-playtest",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "AI personas play your game and file reports to Ravensight.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://ravensight.io/docs/#playtest",
@@ -25,6 +25,7 @@
25
25
  "bin",
26
26
  "src",
27
27
  "addons",
28
+ "docs/ADAPTERS.md",
28
29
  "README.md",
29
30
  "LICENSE",
30
31
  "!src/**/*.test.js",
package/src/cli.js CHANGED
@@ -50,14 +50,15 @@ async function loadRunner(name, exportName) {
50
50
  * Kept in the same order as `VALUED` and `BOOLEAN` in src/run/args.js, which is
51
51
  * the list it has to agree with.
52
52
  */
53
- const RUNNER_HELP = `
53
+ export const RUNNER_HELP = `
54
54
  Run options (parsed by the runner, see src/run/args.js):
55
55
  --personas <list> comma separated persona slugs, default the pack's set
56
- --driver <name> playwright_web or godot_driver
56
+ --driver <name> playwright_web, godot_driver, desktop_driver, unity_driver, unreal_driver, android_driver, ios_driver or cli_stdio
57
+ --controller <name> standard or jev action selection (default standard)
57
58
  --build-url <url> the web build to play
58
59
  --godot-project <dir> the Godot project directory
59
- --cli <command> the command that starts a text build
60
- --max-actions <n> cap the actions one persona may take
60
+ --driver-config <file> local WebDriver or JSON-RPC bridge configuration
61
+ --max-actions <n> lower the actions one persona may take; it can only lower the persona's own patience, never raise it
61
62
  --concurrency <n> personas at once, default 2
62
63
  --upload-video upload session.webm as well as the reports
63
64
  --upload-transcript upload transcript.jsonl as well as the reports
@@ -67,7 +68,10 @@ Run options (parsed by the runner, see src/run/args.js):
67
68
  --verbose print each turn
68
69
 
69
70
  Accepted and reported as not applied by this release:
70
- --budget-usd, --quality, --offline-upload, --build-dir
71
+ --quality, --offline-upload, --build-dir
72
+
73
+ There is no spending flag: each run's model budget is set by the server from
74
+ what the job was charged.
71
75
  `;
72
76
 
73
77
  /** Flags every command shares. */
@@ -156,13 +160,15 @@ export function buildProgram() {
156
160
  .addOption(new Option('--modules <list>', 'price this module list').argParser(list))
157
161
  .addOption(new Option('--personas <list>', 'price this persona list').argParser(list))
158
162
  .option('--godot-path <path>', 'where Godot is, if it is not on the PATH')
163
+ .option('--web', 'check the browser even when this repo is not linked to a web build')
159
164
  .action(capture(slot, async options => {
160
165
  const { check } = await import('./commands/check.js');
161
166
  return check(flagsFrom(options, {
162
167
  game: options.game,
163
168
  modules: options.modules,
164
169
  personas: options.personas,
165
- godotPath: options.godotPath
170
+ godotPath: options.godotPath,
171
+ web: Boolean(options.web)
166
172
  }));
167
173
  }));
168
174
 
@@ -19,6 +19,75 @@ const row = (name, status, detail, fix = null) => ({ name, status, detail, fix }
19
19
  /** "1 persona", "3 personas". A doctor that cannot count reads like a draft. */
20
20
  const plural = (count, noun) => `${count} ${noun}${count === 1 ? '' : 's'}`;
21
21
 
22
+ /** The two commands that make the playwright_web driver work. */
23
+ export const BROWSER_FIX = 'npm i -D playwright && npx playwright install chromium';
24
+
25
+ /**
26
+ * The repo config, for deciding whether this is a web game. A config that
27
+ * cannot be read is reported by the rows that need it, not here.
28
+ *
29
+ * @param {Object} flags
30
+ * @returns {Promise<Object|null>}
31
+ */
32
+ async function configForBrowser(flags) {
33
+ if (flags.config !== undefined) return flags.config;
34
+ try {
35
+ return await loadConfig({ repoRoot: flags.repoRoot });
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * How much a missing browser matters here, or null when the row is skipped.
43
+ *
44
+ * `fail` when `--web` asks for it or the linked build is a URL: a web run
45
+ * cannot start without one. `warn` when only the configured driver says
46
+ * `playwright_web` and no build is linked yet, because that is the untouched
47
+ * default `init` writes, and a Godot developer who never changed it must not
48
+ * get exit 3 over a browser they will never launch. Skipped otherwise.
49
+ *
50
+ * @param {Object} flags
51
+ * @param {Object|null} config
52
+ * @returns {'fail'|'warn'|null}
53
+ */
54
+ export function browserSeverity(flags, config) {
55
+ if (flags.web) return 'fail';
56
+ if (!config) return null;
57
+ const kind = config.build && config.build.kind;
58
+ if (kind === 'url') return 'fail';
59
+ if (kind) return null;
60
+ return config.driver === 'playwright_web' ? 'warn' : null;
61
+ }
62
+
63
+ /**
64
+ * Import Playwright, launch headless Chromium, close it. The same three steps
65
+ * a web run starts with, so a pass here means the run will get a browser.
66
+ *
67
+ * @param {(specifier: string) => Promise<any>} [importer] test seam
68
+ * @returns {Promise<{ok: boolean, detail: string}>}
69
+ */
70
+ export async function probeBrowser(importer) {
71
+ const load = importer || (specifier => import(specifier));
72
+ let browser = null;
73
+ try {
74
+ const playwright = await load('playwright');
75
+ browser = await playwright.chromium.launch({ headless: true });
76
+ const version = typeof browser.version === 'function' ? browser.version() : '';
77
+ return { ok: true, detail: `chromium ${version}`.trim() + ' launched headless' };
78
+ } catch (error) {
79
+ return { ok: false, detail: String(error && error.message ? error.message : error).split('\n')[0] };
80
+ } finally {
81
+ if (browser) {
82
+ try {
83
+ await browser.close();
84
+ } catch {
85
+ // A browser that will not close still launched, which is what was asked.
86
+ }
87
+ }
88
+ }
89
+ }
90
+
22
91
  /**
23
92
  * Every environment check, as data.
24
93
  *
@@ -37,6 +106,7 @@ const plural = (count, noun) => `${count} ${noun}${count === 1 ? '' : 's'}`;
37
106
  * @param {string[]} [flags.modules]
38
107
  * @param {string[]} [flags.personas]
39
108
  * @param {string} [flags.repoRoot]
109
+ * @param {boolean} [flags.web] probe the browser whatever the config says
40
110
  * @param {Object} [deps]
41
111
  * @returns {Promise<{rows: Array, ok: boolean}>}
42
112
  */
@@ -243,6 +313,17 @@ export async function runChecks(flags = {}, deps = {}) {
243
313
  'needed only to re-encode a session video before upload'
244
314
  ));
245
315
 
316
+ const browserNeed = browserSeverity(flags, await configForBrowser(flags));
317
+ if (browserNeed) {
318
+ const probe = await probeBrowser(deps.importer);
319
+ rows.push(row(
320
+ 'browser',
321
+ probe.ok ? 'ok' : browserNeed,
322
+ probe.detail,
323
+ probe.ok ? null : BROWSER_FIX
324
+ ));
325
+ }
326
+
246
327
  const godot = await (deps.inspectGodot || inspectGodot)(
247
328
  { repoRoot, godotPath: flags.godotPath },
248
329
  deps
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { appendFile, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { optionsFrom } from '../run/args.js';
4
+ import { optionsFrom, unknownOptionNotes } from '../run/args.js';
5
5
  import { getDeps } from '../run/deps.js';
6
6
  import { createDriver } from '../run/drivers/index.js';
7
7
  import EXIT from '../run/exit.js';
@@ -15,7 +15,7 @@ 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, exitCodeFor, explainRegisterRefusal, formatCents, resolveSetup, resolveTarget, skillFor } from './run.js';
18
+ import { createInterruptHandler, deliver, driverOptions, exitCodeFor, explainRegisterRefusal, formatCents, resolveSetup, resolveTarget, skillFor } from './run.js';
19
19
 
20
20
  /**
21
21
  * `ravensight-playtest fix <target>`: take one finding, change the code on a
@@ -155,6 +155,7 @@ async function fix(target, flags, injected, deps, log) {
155
155
  const { options, unknown } = optionsFrom(flags);
156
156
  if (unknown.length > 0) {
157
157
  log(`I do not know these options: ${unknown.join(', ')}. Run "ravensight-playtest fix --help".`);
158
+ for (const line of unknownOptionNotes(unknown)) log(line);
158
159
  return EXIT.ENVIRONMENT;
159
160
  }
160
161
  const parsed = parseTarget(target);
@@ -506,6 +507,7 @@ async function fix(target, flags, injected, deps, log) {
506
507
  repoRoot,
507
508
  entry: verifyRun,
508
509
  pack,
510
+ brief,
509
511
  finding,
510
512
  driverName,
511
513
  target: buildTarget,
@@ -752,6 +754,7 @@ export async function runVerify({
752
754
  repoRoot,
753
755
  entry,
754
756
  pack,
757
+ brief = null,
755
758
  finding,
756
759
  driverName,
757
760
  target,
@@ -769,7 +772,7 @@ export async function runVerify({
769
772
  return null;
770
773
  }
771
774
  const headless = driverName === 'godot_driver' ? options.headless === true : options.headed !== true;
772
- const driver = await makeDriver(driverName, { headless });
775
+ const driver = await makeDriver(driverName, driverOptions({ headless, driverName, target, directory, entry, jobId }));
773
776
  const ledger = createUsageLedger();
774
777
  const heartbeat = createHeartbeat({
775
778
  api,
@@ -796,7 +799,8 @@ export async function runVerify({
796
799
  target,
797
800
  runDirectory: directory,
798
801
  skill: skillFor(pack, 'persona_playtest'),
799
- brief: verifyBrief(pack.brief || null, finding),
802
+ // The brief fetched above, as `run` does: the pack does not always carry one.
803
+ brief: verifyBrief(brief || pack.brief || null, finding),
800
804
  intentNotes: pack.intent_notes || [],
801
805
  schemas: pack.schemas || [],
802
806
  maxActions,
@@ -37,7 +37,8 @@ export async function ensureGitignore(repoRoot) {
37
37
  /**
38
38
  * `ravensight-playtest init --game <gameId>`
39
39
  *
40
- * Link this repo to a Ravensight game.
40
+ * Link this repo to a Ravensight game. Without `--game`, one visible game is
41
+ * used as is, and several are offered as a numbered list on a terminal.
41
42
  *
42
43
  * The game is checked against `whoami` before anything is written, because the
43
44
  * alternative is a config that looks right and fails on the first job with a
@@ -45,7 +46,7 @@ export async function ensureGitignore(repoRoot) {
45
46
  * authority: an unapproved game is invisible to it, by design.
46
47
  *
47
48
  * @param {{game?: string, apiUrl?: string, repoRoot?: string, force?: boolean, json?: boolean}} flags
48
- * @param {Object} [deps]
49
+ * @param {{context?: Object, choose?: Function}} [deps]
49
50
  * @returns {Promise<number>}
50
51
  */
51
52
  export async function init(flags = {}, deps = {}) {
@@ -58,15 +59,39 @@ export async function init(flags = {}, deps = {}) {
58
59
  // Every API call in this command goes through the one 401 handler, so a
59
60
  // refused credential is cleared and explained here the same way it is
60
61
  // everywhere else.
61
- return withAuthHandling(context, () => linkRepo(context, flags, repoRoot));
62
+ return withAuthHandling(context, () => linkRepo(context, flags, repoRoot, deps.choose || ui.choose));
62
63
  }
63
64
 
64
- async function linkRepo(context, flags, repoRoot) {
65
+ /**
66
+ * @param {Object} context
67
+ * @param {Object} flags
68
+ * @param {string} repoRoot
69
+ * @param {(question: string, labels: Array<string>) => Promise<number|null>} choose
70
+ * the picker; answers null when there is no terminal to ask on
71
+ */
72
+ async function linkRepo(context, flags, repoRoot, choose) {
65
73
  const whoami = await context.api.cli.whoami();
66
74
 
67
75
  const games = whoami.games || [];
68
76
  let gameId = flags.game;
69
77
 
78
+ if (!gameId && games.length === 0) {
79
+ throw new CliError(
80
+ 'This token can see no games, so there is nothing to link.',
81
+ 1,
82
+ { hint: 'Create a game in the dashboard, then run login again and approve it for this token.' }
83
+ );
84
+ }
85
+
86
+ // Never under --json: whatever reads that output is a script, not a person.
87
+ if (!gameId && games.length > 1 && !flags.json) {
88
+ // On a terminal, ask. Anywhere else (CI, a pipe) the picker answers null
89
+ // and the command falls through to the list and the --game error, which
90
+ // is what a script needs to see.
91
+ const picked = await choose(`Which game? [1-${games.length}]`, games.map(game => `${game.name || game.gameId} (${game.gameId})`));
92
+ if (Number.isInteger(picked) && games[picked]) gameId = games[picked].gameId;
93
+ }
94
+
70
95
  if (!gameId) {
71
96
  if (games.length === 1) {
72
97
  gameId = games[0].gameId;
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { mkdir } from 'node:fs/promises';
3
- import { optionsFrom } from '../run/args.js';
3
+ import { optionsFrom, unknownOptionNotes } from '../run/args.js';
4
4
  import { getDeps } from '../run/deps.js';
5
5
  import EXIT from '../run/exit.js';
6
6
  import { createHeartbeat } from '../run/heartbeat.js';
@@ -55,6 +55,7 @@ async function profile(flags, injected, deps, log) {
55
55
  const { options, unknown } = optionsFrom(flags);
56
56
  if (unknown.length > 0) {
57
57
  log(`I do not know these options: ${unknown.join(', ')}.`);
58
+ for (const line of unknownOptionNotes(unknown)) log(line);
58
59
  return EXIT.ENVIRONMENT;
59
60
  }
60
61