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 +70 -0
- package/package.json +1 -1
- package/src/api/client.js +28 -0
- package/src/cli.js +68 -0
- package/src/commands/fix.js +69 -2
- package/src/commands/lists.js +206 -0
- package/src/commands/punch.js +116 -0
- package/src/commands/tape.js +74 -0
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
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')
|
package/src/commands/fix.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
+
}
|