ravensight-playtest 0.6.0 → 0.7.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/README.md CHANGED
@@ -623,6 +623,14 @@ ravensight-playtest fix fnd_7g3k --no-verify
623
623
  The target is either `<jobId>/<label>`, where the label is the one the report
624
624
  gives the finding, or a bare finding id.
625
625
 
626
+ It can also be a punch list or blue tape item, `li_...`, when the item came
627
+ from a finding or a fresh-eyes finding.
628
+ The fix then runs exactly as it does for that finding, at the same price. After
629
+ the job registers, the job id is recorded on the item. The item stays open
630
+ until you close it. An item that did not come from a finding is refused before
631
+ anything is charged; `feature` is the tool for that item. See
632
+ [Punch list and blue tape](#punch-list-and-blue-tape-labs-beta).
633
+
626
634
  | Flag | Effect |
627
635
  |---|---|
628
636
  | `--game <gameId>` | The game, when this repo is not linked. |
@@ -1209,6 +1217,68 @@ During a feature job the model reads a list of those measurements, never the fil
1209
1217
 
1210
1218
  The report says what was wired in, what the project uses, what is unused and which placeholders are left for you to supply. The names are in `assets-report.md` in the run directory, which stays on your machine.
1211
1219
 
1220
+ ## Punch list and blue tape (Labs beta)
1221
+
1222
+ Adding, closing, promoting and drafting need Ravensight Labs on for the game.
1223
+ The list commands still read when Labs is off; they show an empty list then.
1224
+ The dashboard shows the same two lists.
1225
+
1226
+ The punch list holds what has to be finished before handover. You close the
1227
+ items one at a time. Blue tape holds small annoyances a new player meets in the
1228
+ first minutes. Capture them fast, then promote the ones that matter to the
1229
+ punch list.
1230
+
1231
+ ```sh
1232
+ ravensight-playtest punch list # open items; --status open|done|dropped|all
1233
+ ravensight-playtest punch add "Make the jump button respond on the first press."
1234
+ ravensight-playtest punch done li_9f2c
1235
+ ravensight-playtest punch drop li_9f2c
1236
+ ravensight-playtest punch draft # one AI unit; prints proposals, saves nothing
1237
+ ravensight-playtest punch draft --accept # one AI unit; adds every proposal
1238
+
1239
+ ravensight-playtest tape "The title music is too loud."
1240
+ ravensight-playtest tape list
1241
+ ravensight-playtest tape promote li_4a1b # moves it to the punch list
1242
+ ravensight-playtest tape drop li_4a1b
1243
+
1244
+ ravensight-playtest fix li_9f2c # an item that came from a finding
1245
+ ```
1246
+
1247
+ Reading, adding, closing and promoting are free. `punch draft` proposes items
1248
+ from the game's findings and specifications. It costs one AI unit, and nothing
1249
+ when there is nothing to propose. Without `--accept` it saves nothing. It asks
1250
+ before it spends, so pass `--yes` in a script. A list holds at most 500 open
1251
+ items.
1252
+
1253
+ Each added item gets a free plain-language check, printed as hints under it:
1254
+ long sentences, two instructions in one sentence, passive voice, and words with
1255
+ a simpler choice. The hints follow the rules of Simplified Technical English.
1256
+ They never block an item.
1257
+
1258
+ No command here closes an item for you, including `fix li_...`.
1259
+
1260
+ | Flag | Effect |
1261
+ |---|---|
1262
+ | `--game <gameId>` | The game, when this repo is not linked. |
1263
+ | `--status <status>` | `list` only: `open` (the default), `done`, `dropped` or `all`. |
1264
+ | `--accept` | `punch draft` only: add every proposal to the punch list. |
1265
+ | `--json` | Print the server's answer as JSON. |
1266
+
1267
+ ### Fresh eyes
1268
+
1269
+ A fresh-eyes playtest is a persona run with the `fresh-eyes` persona: a player
1270
+ in their first session who reports only small friction. When the job finishes,
1271
+ each of its findings becomes an open blue tape item.
1272
+
1273
+ ```sh
1274
+ ravensight-playtest personas # check that fresh-eyes is listed for your game
1275
+ ravensight-playtest run --personas fresh-eyes
1276
+ ```
1277
+
1278
+ It is priced as any persona run is. Personas come from the content pack the
1279
+ server sends, so `fresh-eyes` works once the server lists it. No CLI upgrade is
1280
+ needed for it.
1281
+
1212
1282
  ## 3D proof of concept (Labs, Alpha)
1213
1283
 
1214
1284
  Labs beta is Godot 2D. There is one 3D template, for trying Build a feature and Validate changes on a 3D project. That is the whole of the Alpha: `create` does not make a 3D game from a brief and refuses to try, and how well a real model builds 3D has not been measured. It has no switch of its own, needing the same Labs opt-in and capability switches, and no price of its own: the jobs cost what they cost on a 2D project.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ravensight-playtest",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Local AI playtests and seeded game simulations with reports in Ravensight.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://ravensight.io/docs/#playtest",
package/src/api/client.js CHANGED
@@ -14,6 +14,15 @@ function gameBase(gameId) {
14
14
  return `/api/v1/games/${encodeURIComponent(gameId)}/playtest`;
15
15
  }
16
16
 
17
+ /** `/api/v1/games/:gameId/labs/lists/:list`, for the punch list and blue tape. */
18
+ function labsListBase(gameId, list) {
19
+ if (typeof gameId !== 'string' || gameId.length === 0) {
20
+ throw new TypeError('gameId is required');
21
+ }
22
+ if (list !== 'punch' && list !== 'tape') throw new TypeError('list must be punch or tape');
23
+ return `/api/v1/games/${encodeURIComponent(gameId)}/labs/lists/${list}`;
24
+ }
25
+
17
26
  /**
18
27
  * The Ravensight playtest API, as one object with one namespace per surface.
19
28
  *
@@ -407,6 +416,25 @@ export function createClient(options = {}) {
407
416
  * free. Editing and approving are the dashboard's, on purpose: this token
408
417
  * cannot do either.
409
418
  */
419
+ /**
420
+ * The punch list and blue tape. `list` is `punch` or `tape`. Reads and
421
+ * plain writes are free; `draft` is one AI unit and saves nothing.
422
+ */
423
+ lists: {
424
+ list: (gameId, list, { status } = {}) =>
425
+ get(labsListBase(gameId, list), { query: status ? { status } : undefined }),
426
+ get: (gameId, list, itemId) =>
427
+ get(`${labsListBase(gameId, list)}/items/${encodeURIComponent(itemId)}`),
428
+ add: (gameId, list, body) =>
429
+ request('POST', labsListBase(gameId, list), { body }).then(answer => answer.body),
430
+ update: (gameId, list, itemId, body) =>
431
+ request('PATCH', `${labsListBase(gameId, list)}/items/${encodeURIComponent(itemId)}`, { body }).then(answer => answer.body),
432
+ promote: (gameId, itemId) =>
433
+ request('POST', `${labsListBase(gameId, 'tape')}/items/${encodeURIComponent(itemId)}/promote`, { body: {} }).then(answer => answer.body),
434
+ draft: gameId =>
435
+ request('POST', `${labsListBase(gameId, 'punch')}/draft`, { body: {} }).then(answer => answer.body)
436
+ },
437
+
410
438
  featureSpecs: {
411
439
  create: (gameId, brief, extra = {}) =>
412
440
  request('POST', `/api/v1/games/${encodeURIComponent(gameId)}/labs/feature-specs`, { body: { brief, ...extra } }).then(answer => answer.body),
package/src/cli.js CHANGED
@@ -537,6 +537,74 @@ export function buildProgram() {
537
537
  }));
538
538
  }));
539
539
 
540
+ // Labs lists. Each subcommand takes the shared flags itself, so `--json` and
541
+ // `--game` work after the subcommand name, where people type them.
542
+ const listFlags = command => common(command).option('--game <gameId>', 'the game, when this repo is not linked');
543
+ const statusOption = () => new Option('--status <status>', 'which items to show').choices(['open', 'done', 'dropped', 'all']).default('open');
544
+
545
+ const punch = common(program.command('punch')
546
+ .description('Labs beta: the punch list, what has to be finished before handover, closed one item at a time'));
547
+ listFlags(punch.command('list').description('show the punch list; free'))
548
+ .addOption(statusOption())
549
+ .action(capture(slot, async options => {
550
+ const { punchList } = await import('./commands/punch.js');
551
+ return punchList(flagsFrom(options, { game: options.game, status: options.status }));
552
+ }));
553
+ listFlags(punch.command('add').description('add one item; free, with plain-language hints'))
554
+ .argument('<text>', 'the item, in quotes')
555
+ .action(capture(slot, async (text, options) => {
556
+ const { punchAdd } = await import('./commands/punch.js');
557
+ return punchAdd(text, flagsFrom(options, { game: options.game }));
558
+ }));
559
+ listFlags(punch.command('done').description('close an item as done; free'))
560
+ .argument('<itemId>', 'the item id, li_...')
561
+ .action(capture(slot, async (itemId, options) => {
562
+ const { punchClose } = await import('./commands/punch.js');
563
+ return punchClose(itemId, 'done', flagsFrom(options, { game: options.game }));
564
+ }));
565
+ listFlags(punch.command('drop').description('close an item as dropped; free'))
566
+ .argument('<itemId>', 'the item id, li_...')
567
+ .action(capture(slot, async (itemId, options) => {
568
+ const { punchClose } = await import('./commands/punch.js');
569
+ return punchClose(itemId, 'dropped', flagsFrom(options, { game: options.game }));
570
+ }));
571
+ listFlags(punch.command('draft').description('propose items from this game\'s findings and specs; one AI unit, saves nothing without --accept'))
572
+ .option('--accept', 'add every proposal to the punch list')
573
+ .action(capture(slot, async options => {
574
+ const { punchDraft } = await import('./commands/punch.js');
575
+ return punchDraft(flagsFrom(options, { game: options.game, accept: Boolean(options.accept) }));
576
+ }));
577
+
578
+ const tape = listFlags(program.command('tape')
579
+ .description('Labs beta: blue tape, small first-impression annoyances; tape "<text>" captures one'))
580
+ .argument('[text]', 'the annoyance, in quotes')
581
+ .action(capture(slot, async (text, options, command) => {
582
+ if (!text) {
583
+ command.outputHelp();
584
+ return ExitCode.FAILURE;
585
+ }
586
+ const { tapeAdd } = await import('./commands/tape.js');
587
+ return tapeAdd(text, flagsFrom(options, { game: options.game }));
588
+ }));
589
+ listFlags(tape.command('list').description('show blue tape; free'))
590
+ .addOption(statusOption())
591
+ .action(capture(slot, async options => {
592
+ const { tapeList } = await import('./commands/tape.js');
593
+ return tapeList(flagsFrom(options, { game: options.game, status: options.status }));
594
+ }));
595
+ listFlags(tape.command('promote').description('move an item to the punch list; free'))
596
+ .argument('<itemId>', 'the item id, li_...')
597
+ .action(capture(slot, async (itemId, options) => {
598
+ const { tapePromote } = await import('./commands/tape.js');
599
+ return tapePromote(itemId, flagsFrom(options, { game: options.game }));
600
+ }));
601
+ listFlags(tape.command('drop').description('drop an item; free'))
602
+ .argument('<itemId>', 'the item id, li_...')
603
+ .action(capture(slot, async (itemId, options) => {
604
+ const { tapeDrop } = await import('./commands/tape.js');
605
+ return tapeDrop(itemId, flagsFrom(options, { game: options.game }));
606
+ }));
607
+
540
608
  common(program.command('simulation-replay').description('verify a recorded simulation trace locally; creates no paid job'))
541
609
  .argument('<trace>', 'a selected trace.json written by simulate')
542
610
  .requiredOption('--driver-config <file>', 'local JSON-RPC executable configuration')
@@ -29,6 +29,8 @@ import * as stateFile from '../run/state.js';
29
29
  import { createUsageLedger, writeUsageFile } from '../run/usage.js';
30
30
  import { createInterruptHandler, deliver, driverOptions, exitCodeFor, explainRegisterRefusal, formatCents, godotRefusal, resolveSetup, resolveTarget, skillFor, usableLocalBrief, startBalanceLine, startBalanceShort } from './run.js';
31
31
  import { loadLocalBrief } from '../briefLocal.js';
32
+ import { findListItem } from './lists.js';
33
+ import { jobLink } from '../dashboard.js';
32
34
 
33
35
  const execFileAsync = promisify(execFile);
34
36
 
@@ -212,9 +214,9 @@ async function fix(target, flags, injected, deps, log) {
212
214
  for (const line of unknownOptionNotes(unknown)) log(line);
213
215
  return EXIT.ENVIRONMENT;
214
216
  }
215
- const parsed = parseTarget(target);
217
+ let parsed = parseTarget(target);
216
218
  if (!parsed) {
217
- log('Name the finding to fix: either <jobId>/<label>, like pt_job_1/F002, or a finding id.');
219
+ log('Name the finding to fix: either <jobId>/<label>, like pt_job_1/F002, a finding id, or a punch list item, li_...');
218
220
  return EXIT.ENVIRONMENT;
219
221
  }
220
222
 
@@ -222,6 +224,20 @@ async function fix(target, flags, injected, deps, log) {
222
224
  if (setup.code !== undefined) return setup.code;
223
225
  const { context, api, gameId, apiUrl, repoRoot, cliVersion } = setup;
224
226
 
227
+ // A punch list item that came from a finding is fixed as that finding. The
228
+ // item only lends its finding's id, and later records the job; it is never closed here.
229
+ let listItem = null;
230
+ if (isListItemTarget(target)) {
231
+ const resolved = await resolveListItemTarget(api, gameId, String(target).trim());
232
+ if (resolved.refusal) {
233
+ log(resolved.refusal);
234
+ return EXIT.ENVIRONMENT;
235
+ }
236
+ listItem = resolved;
237
+ parsed = parseTarget(resolved.item.source.ref);
238
+ log(`${resolved.item.item_id} came from ${resolved.item.source.kind === 'fresh_eyes' ? 'fresh-eyes finding' : 'finding'} ${resolved.item.source.ref}, so this fixes that finding.`);
239
+ }
240
+
225
241
  const wantsVerify = options.verify !== false;
226
242
  const driverName = options.driver || (context.config && context.config.driver) || 'playwright_web';
227
243
  const buildTarget = resolveTarget(options, context.config, driverName, repoRoot);
@@ -477,6 +493,7 @@ async function fix(target, flags, injected, deps, log) {
477
493
  // the runs, because the charge has already happened: a developer who cannot
478
494
  // name the job they paid for cannot ask anybody about it either.
479
495
  log(`job ${jobId}: ${formatCents(registered.price && registered.price.price_cents)} charged. ${jobDir(repoRoot, jobId)}`);
496
+ if (listItem) await recordFixJob({ api, gameId, listItem, jobId, link: (registered.links && registered.links.dashboard) || jobLink(gameId, jobId, apiUrl), log });
480
497
 
481
498
  if (!fixRun) {
482
499
  log('The server registered the job without a fix run, so there is nothing to spend a model turn from.');
@@ -931,6 +948,56 @@ async function fix(target, flags, injected, deps, log) {
931
948
  * @param {{jobId: string|null, label: string|null, findingId: string|null}} target
932
949
  * @returns {Promise<Object|null>}
933
950
  */
951
+ /** The list item sources whose ref is a finding a fix can replay. */
952
+ export const FIXABLE_SOURCES = Object.freeze(['finding', 'fresh_eyes']);
953
+
954
+ /** Whether a fix target names a Labs list item rather than a finding. */
955
+ export function isListItemTarget(target) {
956
+ return String(target || '').trim().startsWith('li_');
957
+ }
958
+
959
+ /**
960
+ * Find a list item and check it came from a finding. Only a finding has the
961
+ * persona, the repro steps and the replay a fix needs; anything else is new
962
+ * work, which is what `feature` builds.
963
+ *
964
+ * @param {Object} api
965
+ * @param {string} gameId
966
+ * @param {string} itemId
967
+ * @returns {Promise<{item: Object, list: string}|{refusal: string}>}
968
+ */
969
+ export async function resolveListItemTarget(api, gameId, itemId) {
970
+ if (!api.labs || !api.labs.lists) {
971
+ return { refusal: 'This server has no punch list. Fix the finding by its id instead.' };
972
+ }
973
+ const found = await findListItem(api, gameId, itemId);
974
+ if (!found) {
975
+ return { refusal: `No item ${itemId} is on this game's punch list or blue tape. Run "ravensight-playtest punch list --status all" to see the ids.` };
976
+ }
977
+ const source = found.item.source || {};
978
+ // A fresh-eyes item's ref is a finding's targetId too.
979
+ if (!FIXABLE_SOURCES.includes(source.kind) || !source.ref) {
980
+ return {
981
+ refusal: `${itemId} did not come from a finding, so fix has nothing to replay. Use "ravensight-playtest feature" for that item. Nothing was charged.`
982
+ };
983
+ }
984
+ return found;
985
+ }
986
+
987
+ /**
988
+ * Record the fix job on the list item. Best effort: the job is already paid
989
+ * for, and a list that cannot be written must not stop the fix.
990
+ */
991
+ async function recordFixJob({ api, gameId, listItem, jobId, link, log }) {
992
+ const itemId = listItem.item.item_id;
993
+ try {
994
+ await api.labs.lists.update(gameId, listItem.list, itemId, { fix_job_id: jobId });
995
+ log(`Recorded job ${jobId} on ${itemId}: ${link}. The item stays open until you close it.`);
996
+ } catch (error) {
997
+ log(`warning: could not record job ${jobId} on ${itemId} (${error && error.message ? error.message : error}). The fix carries on.`);
998
+ }
999
+ }
1000
+
934
1001
  export async function resolveFinding(api, gameId, target) {
935
1002
  const answer = await api.jobs.findings(gameId, target.jobId ? { job: target.jobId } : {});
936
1003
  const list = (answer && answer.findings) || [];
@@ -0,0 +1,206 @@
1
+ import { createContext } from '../api/index.js';
2
+ import { ApiError } from '../api/errors.js';
3
+ import { resolveGameId } from '../config.js';
4
+ import { withAuthHandling } from '../auth/session.js';
5
+ import { CliError, ExitCode } from '../errors.js';
6
+ import { ui } from '../ui/index.js';
7
+
8
+ /**
9
+ * What `punch` and `tape` share: the game, the client, the refusals in plain
10
+ * words, and how an item is printed.
11
+ *
12
+ * The punch list holds what has to be finished before handover, closed one at
13
+ * a time. Blue tape holds small first-impression annoyances, captured fast and
14
+ * promoted to the punch list when they matter. Both live on the server under
15
+ * the game's Labs lists, so the dashboard and this CLI read the same items.
16
+ */
17
+
18
+ export const LIST_NAMES = Object.freeze({ punch: 'punch list', tape: 'blue tape list' });
19
+ export const STATUSES = Object.freeze(['open', 'done', 'dropped', 'all']);
20
+ export const ITEM_ID = /^li_[A-Za-z0-9_-]+$/;
21
+ export const LIMIT = 500;
22
+
23
+ /**
24
+ * Run `fn` with the client and the game this repo is linked to (or `--game`).
25
+ * @param {Object} flags
26
+ * @param {Object} deps test seam: context
27
+ * @param {(setup: {api: Object, gameId: string}) => Promise<number>} fn
28
+ * @returns {Promise<number>}
29
+ */
30
+ export async function withList(flags, deps, fn) {
31
+ const context = deps.context || await createContext({ apiUrl: flags.apiUrl, repoRoot: flags.repoRoot, requireAuth: true });
32
+ return withAuthHandling(context, async () => {
33
+ const gameId = resolveGameId(flags, context.config);
34
+ return fn({ api: context.api, gameId });
35
+ });
36
+ }
37
+
38
+ /**
39
+ * A refusal from the lists routes as a sentence a person can act on. Anything
40
+ * this does not recognise is handed back unchanged for `report` to print.
41
+ *
42
+ * @param {unknown} error
43
+ * @param {{list: string, itemId?: string|null}} where
44
+ * @returns {unknown}
45
+ */
46
+ export function listError(error, { list, itemId = null }) {
47
+ if (!(error instanceof ApiError) && !(error && typeof error.status === 'number' && typeof error.code === 'string')) return error;
48
+ const name = LIST_NAMES[list] || 'list';
49
+ if (error.code === 'labs_not_enabled') {
50
+ return new CliError(
51
+ `The ${name} is part of Ravensight Labs (beta). Labs is not on for this game.`,
52
+ ExitCode.ENVIRONMENT,
53
+ { hint: 'Turn Labs on from the Labs page in the dashboard. Nothing was changed.' }
54
+ );
55
+ }
56
+ if (error.code === 'item_closed') {
57
+ return new CliError(`${itemId || 'That item'} is closed. Reopen it first, then promote it.`, ExitCode.FAILURE);
58
+ }
59
+ if (error.code === 'quota_exceeded') {
60
+ return new CliError('This account has no AI units left. Add funds, then try again. Nothing was charged.', ExitCode.FAILURE);
61
+ }
62
+ if (error.code === 'labs_list_attempts') {
63
+ return new CliError('Too many drafts came back empty this hour. Try again later. Nothing was charged.', ExitCode.FAILURE);
64
+ }
65
+ if (error.code === 'ai_unavailable') {
66
+ return new CliError('The AI service is not available right now. Try again later. Nothing was charged.', ExitCode.FAILURE);
67
+ }
68
+ if (error.code === 'list_full') {
69
+ return new CliError(
70
+ `The ${name} already has ${LIMIT} open items. Close or drop some, then add this one.`,
71
+ ExitCode.FAILURE
72
+ );
73
+ }
74
+ if (error.status === 404) {
75
+ if (itemId) {
76
+ return new CliError(
77
+ `No item ${itemId} is on this game's ${name}.`,
78
+ ExitCode.FAILURE,
79
+ { hint: `Run "ravensight-playtest ${list} list --status all" to see the item ids.` }
80
+ );
81
+ }
82
+ return new CliError(
83
+ `This game has no ${name} that this token can see.`,
84
+ ExitCode.FAILURE,
85
+ { hint: 'Run "ravensight-playtest check" to confirm the game is linked. An older server has no lists yet.' }
86
+ );
87
+ }
88
+ return error;
89
+ }
90
+
91
+ /**
92
+ * Call the server and turn its refusals into sentences.
93
+ * @template T
94
+ * @param {() => Promise<T>} call
95
+ * @param {{list: string, itemId?: string|null}} where
96
+ * @returns {Promise<T>}
97
+ */
98
+ export async function guarded(call, where) {
99
+ try {
100
+ return await call();
101
+ } catch (error) {
102
+ throw listError(error, where);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Refuse an id that cannot be an item before asking the server about it.
108
+ * @param {string} itemId
109
+ */
110
+ export function requireItemId(itemId) {
111
+ const value = String(itemId || '').trim();
112
+ if (!ITEM_ID.test(value)) {
113
+ throw new CliError(`"${value}" is not an item id. Item ids start with li_.`, ExitCode.FAILURE, {
114
+ hint: 'Run "ravensight-playtest punch list" or "ravensight-playtest tape list" to see them.'
115
+ });
116
+ }
117
+ return value;
118
+ }
119
+
120
+ /**
121
+ * Refuse empty text here rather than spend a round trip on it.
122
+ * @param {string} text
123
+ */
124
+ export function requireText(text) {
125
+ const value = String(text || '').trim();
126
+ if (!value) throw new CliError('Say what the item is, in quotes.', ExitCode.FAILURE);
127
+ return value;
128
+ }
129
+
130
+ /** @param {string|undefined} status */
131
+ export function requireStatus(status) {
132
+ if (status === undefined || status === null) return 'open';
133
+ if (!STATUSES.includes(status)) {
134
+ throw new CliError(`--status takes ${STATUSES.join(', ')}.`, ExitCode.FAILURE);
135
+ }
136
+ return status;
137
+ }
138
+
139
+ /** Where an item came from, in a few words. */
140
+ export function sourceWords(source) {
141
+ if (!source || !source.kind || source.kind === 'manual') return 'manual';
142
+ const kind = String(source.kind).replace(/_/g, ' ');
143
+ return source.ref ? `${kind} ${source.ref}` : kind;
144
+ }
145
+
146
+ /** The plain-language hints under an item. A hint, never a block. */
147
+ export function printHints(item) {
148
+ const issues = item && item.plain_check && Array.isArray(item.plain_check.issues) ? item.plain_check.issues : [];
149
+ for (const issue of issues) {
150
+ if (issue && issue.message) ui.info(` hint: ${issue.message}`);
151
+ }
152
+ return issues.length;
153
+ }
154
+
155
+ /**
156
+ * Print a list answer as a table, or say it is empty.
157
+ * @param {Object} answer `{ items, counts }`
158
+ * @param {string} list
159
+ * @param {string} status
160
+ */
161
+ export function printList(answer, list, status) {
162
+ const items = answer && Array.isArray(answer.items) ? answer.items : [];
163
+ const name = LIST_NAMES[list];
164
+ if (items.length === 0) {
165
+ ui.info(status === 'all' ? `The ${name} is empty.` : `The ${name} has no ${status} items.`);
166
+ } else {
167
+ ui.table(items.map(item => ({
168
+ item_id: item.item_id,
169
+ status: item.status,
170
+ text: item.text,
171
+ source: sourceWords(item.source) + (item.promoted_to ? `, now ${item.promoted_to}` : '') + (item.fix_job_id ? `, fix ${item.fix_job_id}` : '')
172
+ })), [
173
+ { key: 'item_id', label: 'ITEM' },
174
+ { key: 'status', label: 'STATUS' },
175
+ { key: 'text', label: 'TEXT' },
176
+ { key: 'source', label: 'SOURCE' }
177
+ ]);
178
+ }
179
+ const counts = answer && answer.counts;
180
+ if (counts) {
181
+ ui.blank();
182
+ ui.info(`${counts.open ?? 0} open, ${counts.done ?? 0} done, ${counts.dropped ?? 0} dropped.`);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Find one item by id, for `fix li_...`. The punch list first, because that is
188
+ * where a finding to fix usually sits, then blue tape on a 404.
189
+ *
190
+ * @param {Object} api
191
+ * @param {string} gameId
192
+ * @param {string} itemId
193
+ * @returns {Promise<{item: Object, list: string}|null>}
194
+ */
195
+ export async function findListItem(api, gameId, itemId) {
196
+ for (const list of ['punch', 'tape']) {
197
+ try {
198
+ const answer = await api.labs.lists.get(gameId, list, itemId);
199
+ if (answer && answer.item) return { item: answer.item, list };
200
+ } catch (error) {
201
+ if (error && error.status === 404) continue;
202
+ throw listError(error, { list, itemId });
203
+ }
204
+ }
205
+ return null;
206
+ }
@@ -0,0 +1,116 @@
1
+ import { ui } from '../ui/index.js';
2
+ import { ExitCode } from '../errors.js';
3
+ import { guarded, printHints, printList, requireItemId, requireStatus, requireText, sourceWords, withList } from './lists.js';
4
+
5
+ /**
6
+ * `ravensight-playtest punch`: the punch list (Labs beta).
7
+ *
8
+ * What has to be finished before handover, closed one item at a time. Reading,
9
+ * adding and closing are free. `draft` asks for proposals from the game's
10
+ * findings and specs, which is one AI unit, and saves nothing unless
11
+ * `--accept` is passed. Nothing here closes an item on its own: `fix li_...`
12
+ * records the fix job on the item and leaves closing it to a person.
13
+ */
14
+
15
+ const LIST = 'punch';
16
+
17
+ /** `punch list [--status open|done|dropped|all]` */
18
+ export async function punchList(flags = {}, deps = {}) {
19
+ const status = requireStatus(flags.status);
20
+ return withList(flags, deps, async ({ api, gameId }) => {
21
+ const answer = await guarded(() => api.labs.lists.list(gameId, LIST, { status }), { list: LIST });
22
+ if (flags.json) {
23
+ ui.json(answer);
24
+ return ExitCode.OK;
25
+ }
26
+ printList(answer, LIST, status);
27
+ return ExitCode.OK;
28
+ });
29
+ }
30
+
31
+ /** `punch add "<text>"` */
32
+ export async function punchAdd(text, flags = {}, deps = {}) {
33
+ const value = requireText(text);
34
+ return withList(flags, deps, async ({ api, gameId }) => {
35
+ const answer = await guarded(() => api.labs.lists.add(gameId, LIST, { text: value }), { list: LIST });
36
+ if (flags.json) {
37
+ ui.json(answer);
38
+ return ExitCode.OK;
39
+ }
40
+ printAdded(answer);
41
+ return ExitCode.OK;
42
+ });
43
+ }
44
+
45
+ /** `punch done <li_id>` and `punch drop <li_id>` */
46
+ export async function punchClose(itemId, status, flags = {}, deps = {}) {
47
+ const id = requireItemId(itemId);
48
+ return withList(flags, deps, async ({ api, gameId }) => {
49
+ const answer = await guarded(() => api.labs.lists.update(gameId, LIST, id, { status }), { list: LIST, itemId: id });
50
+ if (flags.json) {
51
+ ui.json(answer);
52
+ return ExitCode.OK;
53
+ }
54
+ const text = answer && answer.item && answer.item.text ? `: ${answer.item.text}` : '';
55
+ ui.ok(`${status === 'done' ? 'Done' : 'Dropped'} ${id}${text}`);
56
+ return ExitCode.OK;
57
+ });
58
+ }
59
+
60
+ /** `punch draft [--accept]` */
61
+ export async function punchDraft(flags = {}, deps = {}) {
62
+ return withList(flags, deps, async ({ api, gameId }) => {
63
+ if (!flags.json) {
64
+ ui.info('Drafting a punch list is one AI unit. It is not charged when there is nothing to propose.');
65
+ ui.info(flags.accept ? 'Every proposal will be added to the punch list.' : 'Nothing is saved. Pass --accept to add the proposals.');
66
+ }
67
+ const confirm = deps.confirm || ui.confirm;
68
+ if (!flags.yes && !(await confirm('Draft the punch list?', { yes: flags.yes }))) {
69
+ ui.info('Nothing was charged. Pass --yes to draft without a terminal.');
70
+ return ExitCode.CANCELED;
71
+ }
72
+ const answer = await guarded(() => api.labs.lists.draft(gameId), { list: LIST });
73
+ const proposals = answer && Array.isArray(answer.proposals) ? answer.proposals : [];
74
+ const added = [];
75
+ if (flags.accept) {
76
+ try {
77
+ for (const proposal of proposals) {
78
+ const body = proposal.source ? { text: proposal.text, source: proposal.source } : { text: proposal.text };
79
+ added.push(await guarded(() => api.labs.lists.add(gameId, LIST, body), { list: LIST }));
80
+ }
81
+ } catch (error) {
82
+ // Say what was saved before the refusal, so nobody adds it twice.
83
+ if (!flags.json) for (const saved of added) printAdded(saved);
84
+ throw error;
85
+ }
86
+ }
87
+ if (flags.json) {
88
+ ui.json({ ...answer, ...(flags.accept ? { added } : {}) });
89
+ return ExitCode.OK;
90
+ }
91
+ if (proposals.length === 0) {
92
+ ui.info('No proposals, so nothing was charged.');
93
+ return ExitCode.OK;
94
+ }
95
+ const units = Number(answer.units) || 0;
96
+ ui.info(`${proposals.length} proposal${proposals.length === 1 ? '' : 's'}, ${units} AI unit${units === 1 ? '' : 's'} charged:`);
97
+ if (flags.accept) {
98
+ for (const saved of added) printAdded(saved);
99
+ return ExitCode.OK;
100
+ }
101
+ proposals.forEach((proposal, index) => {
102
+ ui.info(` ${index + 1}. ${proposal.text} (${sourceWords(proposal.source)})`);
103
+ });
104
+ ui.blank();
105
+ ui.info('Nothing was saved. Add the ones you want with "ravensight-playtest punch add".');
106
+ return ExitCode.OK;
107
+ });
108
+ }
109
+
110
+ /** One added item, with its plain-language hints under it. */
111
+ export function printAdded(answer) {
112
+ const item = answer && answer.item ? answer.item : {};
113
+ if (answer && answer.existing) ui.info(`Already on the list: ${item.item_id}, ${item.text}`);
114
+ else ui.ok(`Added ${item.item_id}: ${item.text}`);
115
+ if (printHints(item) > 0) ui.info(' These hints follow the rules of Simplified Technical English. They never block an item.');
116
+ }
@@ -0,0 +1,74 @@
1
+ import { ui } from '../ui/index.js';
2
+ import { ExitCode } from '../errors.js';
3
+ import { guarded, printHints, printList, requireItemId, requireStatus, requireText, withList } from './lists.js';
4
+
5
+ /**
6
+ * `ravensight-playtest tape`: blue tape (Labs beta).
7
+ *
8
+ * Small first-impression annoyances, captured in one line while they are
9
+ * fresh. Free. An item that turns out to matter is promoted to the punch list,
10
+ * which closes the tape item and keeps its source.
11
+ */
12
+
13
+ const LIST = 'tape';
14
+
15
+ /** `tape "<text>"` */
16
+ export async function tapeAdd(text, flags = {}, deps = {}) {
17
+ const value = requireText(text);
18
+ return withList(flags, deps, async ({ api, gameId }) => {
19
+ const answer = await guarded(() => api.labs.lists.add(gameId, LIST, { text: value }), { list: LIST });
20
+ if (flags.json) {
21
+ ui.json(answer);
22
+ return ExitCode.OK;
23
+ }
24
+ const item = answer && answer.item ? answer.item : {};
25
+ if (answer && answer.existing) ui.info(`Already taped: ${item.item_id}, ${item.text}`);
26
+ else ui.ok(`Taped ${item.item_id}: ${item.text}`);
27
+ if (printHints(item) > 0) ui.info(' These hints follow the rules of Simplified Technical English. They never block an item.');
28
+ return ExitCode.OK;
29
+ });
30
+ }
31
+
32
+ /** `tape list [--status open|done|dropped|all]` */
33
+ export async function tapeList(flags = {}, deps = {}) {
34
+ const status = requireStatus(flags.status);
35
+ return withList(flags, deps, async ({ api, gameId }) => {
36
+ const answer = await guarded(() => api.labs.lists.list(gameId, LIST, { status }), { list: LIST });
37
+ if (flags.json) {
38
+ ui.json(answer);
39
+ return ExitCode.OK;
40
+ }
41
+ printList(answer, LIST, status);
42
+ return ExitCode.OK;
43
+ });
44
+ }
45
+
46
+ /** `tape promote <li_id>` */
47
+ export async function tapePromote(itemId, flags = {}, deps = {}) {
48
+ const id = requireItemId(itemId);
49
+ return withList(flags, deps, async ({ api, gameId }) => {
50
+ const answer = await guarded(() => api.labs.lists.promote(gameId, id), { list: LIST, itemId: id });
51
+ if (flags.json) {
52
+ ui.json(answer);
53
+ return ExitCode.OK;
54
+ }
55
+ const promoted = answer && answer.promoted ? answer.promoted : {};
56
+ ui.ok(`Promoted ${id} to the punch list as ${promoted.item_id}: ${promoted.text}`);
57
+ return ExitCode.OK;
58
+ });
59
+ }
60
+
61
+ /** `tape drop <li_id>` */
62
+ export async function tapeDrop(itemId, flags = {}, deps = {}) {
63
+ const id = requireItemId(itemId);
64
+ return withList(flags, deps, async ({ api, gameId }) => {
65
+ const answer = await guarded(() => api.labs.lists.update(gameId, LIST, id, { status: 'dropped' }), { list: LIST, itemId: id });
66
+ if (flags.json) {
67
+ ui.json(answer);
68
+ return ExitCode.OK;
69
+ }
70
+ const text = answer && answer.item && answer.item.text ? `: ${answer.item.text}` : '';
71
+ ui.ok(`Dropped ${id}${text}`);
72
+ return ExitCode.OK;
73
+ });
74
+ }