ravensight-playtest 0.3.0 → 0.3.2
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 +207 -24
- package/docs/NATIVE_RUNNER.md +2 -2
- package/package.json +2 -2
- package/src/api/client.js +9 -3
- package/src/briefLocal.js +126 -0
- package/src/cli.js +14 -3
- package/src/commands/brief.js +48 -9
- package/src/commands/check.js +187 -38
- package/src/commands/fix.js +12 -1
- package/src/commands/init.js +13 -6
- package/src/commands/personas.js +50 -0
- package/src/commands/run.js +264 -31
- package/src/run/aggregate.js +6 -3
- package/src/run/args.js +18 -1
- package/src/run/drivers/web.js +634 -83
- package/src/run/noise.js +102 -0
- package/src/run/personaLoop.js +658 -26
- package/src/run/report.js +187 -0
- package/src/run/resume.js +5 -0
- package/src/run/tools.js +20 -4
- package/src/runDefaults.js +90 -0
- package/src/state/index.js +4 -1
- package/src/ui/index.js +15 -1
package/src/run/report.js
CHANGED
|
@@ -207,6 +207,191 @@ export function normaliseEvidence(report, runDirectory = null) {
|
|
|
207
207
|
return { report: { ...report, findings }, changed };
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
/** Category words a model writes that are not in the schema, mapped to one that is, when the schema has it. */
|
|
211
|
+
export const CATEGORY_SYNONYMS = Object.freeze({
|
|
212
|
+
ui: 'ux', usability: 'ux', onboarding: 'ux', clarity: 'ux', visual: 'ux', visuals: 'ux', feedback: 'ux', navigation: 'ux', polish: 'ux', design: 'ux',
|
|
213
|
+
crash: 'bug', error: 'bug', defect: 'bug', broken: 'bug', functional: 'bug', functionality: 'bug', network: 'bug',
|
|
214
|
+
perf: 'performance', lag: 'performance', fps: 'performance', loading: 'performance',
|
|
215
|
+
a11y: 'accessibility',
|
|
216
|
+
level: 'level_design', map: 'level_design', 'level design': 'level_design', 'level-design': 'level_design',
|
|
217
|
+
text: 'content', story: 'content', narrative: 'content', writing: 'content', copy: 'content', audio: 'content', art: 'content',
|
|
218
|
+
economy: 'balance', difficulty: 'balance', progression: 'balance'
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
/** The severity an out of range one becomes. */
|
|
222
|
+
export const NORMALISED_SEVERITY = 'minor';
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The category an unknown one becomes: `other` when the schema has it, else a
|
|
226
|
+
* catch-all the schema names, else `ux`. The server's schema today has no
|
|
227
|
+
* catch-all (bug, ux, balance, content, performance, accessibility,
|
|
228
|
+
* level_design), so an unknown word that no synonym places becomes `ux`, the
|
|
229
|
+
* category most unplaced findings turn out to be.
|
|
230
|
+
*
|
|
231
|
+
* @param {Array<string>} categories the schema's enum
|
|
232
|
+
* @returns {string}
|
|
233
|
+
*/
|
|
234
|
+
export function catchAllCategory(categories) {
|
|
235
|
+
for (const candidate of ['other', 'misc', 'general', 'uncategorized']) if (categories.includes(candidate)) return candidate;
|
|
236
|
+
return categories.includes('ux') ? 'ux' : categories[0];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The enum lists and defaults of the report schema's root and of one finding,
|
|
241
|
+
* read from the schema itself so a pack update moves them.
|
|
242
|
+
*
|
|
243
|
+
* @param {Object} schema the playtest-report schema
|
|
244
|
+
* @returns {{severities: Array<string>, categories: Array<string>, root: Object, finding: Object}}
|
|
245
|
+
*/
|
|
246
|
+
export function enumFields(schema) {
|
|
247
|
+
const props = (schema && schema.properties) || {};
|
|
248
|
+
const item = (props.findings && props.findings.items && props.findings.items.properties) || {};
|
|
249
|
+
const enumsOf = properties => Object.fromEntries(Object.entries(properties)
|
|
250
|
+
.filter(([, spec]) => spec && Array.isArray(spec.enum))
|
|
251
|
+
.map(([key, spec]) => [key, { values: spec.enum, ...(spec.default !== undefined ? { default: spec.default } : {}) }]));
|
|
252
|
+
return {
|
|
253
|
+
severities: (item.severity && item.severity.enum) || [],
|
|
254
|
+
categories: (item.category && item.category.enum) || [],
|
|
255
|
+
root: enumsOf(props),
|
|
256
|
+
finding: enumsOf(item)
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Bring known out of range values back into the schema before upload,
|
|
262
|
+
* instead of losing the run to them. The server's schema stays strict; this
|
|
263
|
+
* only rewrites what the model's own words say plainly:
|
|
264
|
+
*
|
|
265
|
+
* - a value that differs from an allowed one only in case or spacing takes
|
|
266
|
+
* the allowed spelling (`Major` is `major`);
|
|
267
|
+
* - a severity that is a category word (`"severity": "ux"`) moves to
|
|
268
|
+
* `category` when the category is missing or not allowed, and the severity
|
|
269
|
+
* becomes `minor`; a severity that is anything else unknown becomes `minor`;
|
|
270
|
+
* - a category that is a severity word, with the severity out of range,
|
|
271
|
+
* swaps back; any other unknown category becomes a synonym the schema has,
|
|
272
|
+
* else `other` (or the catch-all, see {@link catchAllCategory});
|
|
273
|
+
* - any other enum field, at the root or on a finding, with a value the
|
|
274
|
+
* schema does not allow takes the schema's `default` when it has one.
|
|
275
|
+
*
|
|
276
|
+
* Every change is returned, and recorded on the finding as a runner note at
|
|
277
|
+
* the end of its description. The Texas round lost a run to `"severity": "ux"`
|
|
278
|
+
* when the model's budget ran out before its repair turn.
|
|
279
|
+
*
|
|
280
|
+
* @param {Object} report
|
|
281
|
+
* @param {Object} schema the playtest-report schema
|
|
282
|
+
* @returns {{report: Object, changes: Array<{id: string|null, field: string, from: any, to: any, note: string}>}}
|
|
283
|
+
*/
|
|
284
|
+
export function normaliseEnums(report, schema) {
|
|
285
|
+
if (!report || typeof report !== 'object' || !schema) return { report, changes: [] };
|
|
286
|
+
const fields = enumFields(schema);
|
|
287
|
+
const changes = [];
|
|
288
|
+
const spelled = (value, allowed) => {
|
|
289
|
+
if (typeof value !== 'string') return null;
|
|
290
|
+
const key = value.trim().toLowerCase().replace(/[\s-]+/g, '_');
|
|
291
|
+
return allowed.find(entry => String(entry).toLowerCase() === key) ?? null;
|
|
292
|
+
};
|
|
293
|
+
const fixEnum = (target, key, spec, id) => {
|
|
294
|
+
if (!(key in target) || spec.values.includes(target[key])) return;
|
|
295
|
+
const respelled = spelled(target[key], spec.values);
|
|
296
|
+
const to = respelled !== null ? respelled : spec.default;
|
|
297
|
+
if (to === undefined) return;
|
|
298
|
+
changes.push({ id, field: key, from: target[key], to, note: `${key} ${JSON.stringify(target[key])} is not allowed, so it became ${JSON.stringify(to)}` });
|
|
299
|
+
target[key] = to;
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const root = { ...report };
|
|
303
|
+
for (const [key, spec] of Object.entries(fields.root)) fixEnum(root, key, spec, null);
|
|
304
|
+
|
|
305
|
+
const { severities, categories } = fields;
|
|
306
|
+
if (Array.isArray(root.findings)) {
|
|
307
|
+
root.findings = root.findings.map(original => {
|
|
308
|
+
if (!original || typeof original !== 'object' || Array.isArray(original)) return original;
|
|
309
|
+
const finding = { ...original };
|
|
310
|
+
const id = typeof finding.id === 'string' ? finding.id : null;
|
|
311
|
+
const notes = [];
|
|
312
|
+
const record = (field, from, to, note) => {
|
|
313
|
+
changes.push({ id, field, from, to, note });
|
|
314
|
+
notes.push(note);
|
|
315
|
+
};
|
|
316
|
+
if (severities.length > 0 && categories.length > 0) {
|
|
317
|
+
const severityOk = severities.includes(finding.severity);
|
|
318
|
+
const categoryOk = categories.includes(finding.category);
|
|
319
|
+
const severityRespelled = severityOk ? finding.severity : spelled(finding.severity, severities);
|
|
320
|
+
const categoryRespelled = categoryOk ? finding.category : spelled(finding.category, categories);
|
|
321
|
+
const severityAsCategory = severityRespelled === null ? spelled(finding.severity, categories) : null;
|
|
322
|
+
const categoryAsSeverity = categoryRespelled === null ? spelled(finding.category, severities) : null;
|
|
323
|
+
if (severityRespelled !== null && !severityOk) record('severity', finding.severity, severityRespelled, `severity ${JSON.stringify(finding.severity)} became ${JSON.stringify(severityRespelled)}`);
|
|
324
|
+
if (categoryRespelled !== null && !categoryOk) record('category', finding.category, categoryRespelled, `category ${JSON.stringify(finding.category)} became ${JSON.stringify(categoryRespelled)}`);
|
|
325
|
+
let severity = severityRespelled;
|
|
326
|
+
let category = categoryRespelled;
|
|
327
|
+
if (severity === null && category === null && severityAsCategory !== null && categoryAsSeverity !== null) {
|
|
328
|
+
// The two were written in each other's slot.
|
|
329
|
+
record('severity', finding.severity, categoryAsSeverity, `severity ${JSON.stringify(finding.severity)} and category ${JSON.stringify(finding.category)} were swapped, so they were put back`);
|
|
330
|
+
record('category', finding.category, severityAsCategory, `category ${JSON.stringify(finding.category)} is a severity word, so it moved to severity`);
|
|
331
|
+
severity = categoryAsSeverity;
|
|
332
|
+
category = severityAsCategory;
|
|
333
|
+
}
|
|
334
|
+
if (severity === null && severityAsCategory !== null && category === null) {
|
|
335
|
+
record('category', finding.category === undefined ? null : finding.category, severityAsCategory, `severity ${JSON.stringify(finding.severity)} is a category, so it moved to category`);
|
|
336
|
+
category = severityAsCategory;
|
|
337
|
+
}
|
|
338
|
+
if (severity === null) {
|
|
339
|
+
record('severity', finding.severity === undefined ? null : finding.severity, NORMALISED_SEVERITY, `severity ${JSON.stringify(finding.severity === undefined ? null : finding.severity)} is not a severity, so it became ${NORMALISED_SEVERITY}`);
|
|
340
|
+
severity = NORMALISED_SEVERITY;
|
|
341
|
+
}
|
|
342
|
+
if (category === null) {
|
|
343
|
+
const word = typeof finding.category === 'string' ? finding.category.trim().toLowerCase() : '';
|
|
344
|
+
const synonym = CATEGORY_SYNONYMS[word] && categories.includes(CATEGORY_SYNONYMS[word]) ? CATEGORY_SYNONYMS[word] : null;
|
|
345
|
+
const to = synonym || catchAllCategory(categories);
|
|
346
|
+
record('category', finding.category === undefined ? null : finding.category, to, `category ${JSON.stringify(finding.category === undefined ? null : finding.category)} is not a category, so it became ${to}`);
|
|
347
|
+
category = to;
|
|
348
|
+
}
|
|
349
|
+
finding.severity = severity;
|
|
350
|
+
finding.category = category;
|
|
351
|
+
}
|
|
352
|
+
for (const [key, spec] of Object.entries(fields.finding)) {
|
|
353
|
+
if (key === 'severity' || key === 'category') continue;
|
|
354
|
+
const before = changes.length;
|
|
355
|
+
fixEnum(finding, key, spec, id);
|
|
356
|
+
if (changes.length > before) notes.push(changes[changes.length - 1].note);
|
|
357
|
+
}
|
|
358
|
+
if (notes.length > 0) {
|
|
359
|
+
const line = `Runner note: ${notes.join('; ')}.`;
|
|
360
|
+
const description = typeof finding.description === 'string' ? finding.description : '';
|
|
361
|
+
finding.description = description ? `${description}\n\n${line}` : line;
|
|
362
|
+
}
|
|
363
|
+
return finding;
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
return { report: changes.length > 0 ? root : report, changes };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** How report.md lists what the runner normalised. */
|
|
370
|
+
export const NORMALISED_HEADING = '## Normalised by the runner (values the report schema does not allow)';
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* report.md agreeing with report.json about normalised values: each change is
|
|
374
|
+
* listed under {@link NORMALISED_HEADING}, replacing a list written before.
|
|
375
|
+
*
|
|
376
|
+
* @param {string} markdown
|
|
377
|
+
* @param {Array<{id: string|null, note: string}>} changes
|
|
378
|
+
* @returns {string}
|
|
379
|
+
*/
|
|
380
|
+
export function appendNormalisedMarkdown(markdown, changes) {
|
|
381
|
+
if (!Array.isArray(changes) || changes.length === 0) return markdown;
|
|
382
|
+
let body = String(markdown || '');
|
|
383
|
+
const at = body.indexOf(NORMALISED_HEADING);
|
|
384
|
+
if (at !== -1) {
|
|
385
|
+
// A list written before is replaced: from its heading to the next
|
|
386
|
+
// second level heading, or the end.
|
|
387
|
+
const next = body.indexOf('\n## ', at + NORMALISED_HEADING.length);
|
|
388
|
+
body = body.slice(0, at) + (next === -1 ? '' : body.slice(next + 1));
|
|
389
|
+
}
|
|
390
|
+
body = body.replace(/\s+$/, '');
|
|
391
|
+
const lines = changes.map(change => `- ${change.id ? `${change.id}: ` : ''}${change.note}.`);
|
|
392
|
+
return `${body}\n\n${NORMALISED_HEADING}\n${lines.join('\n')}\n`;
|
|
393
|
+
}
|
|
394
|
+
|
|
210
395
|
/**
|
|
211
396
|
* Check every `screenshot` evidence ref against the files actually on disk.
|
|
212
397
|
*
|
|
@@ -301,6 +486,8 @@ export function repairInstruction(details, evidenceProblems, missingSections = [
|
|
|
301
486
|
|
|
302
487
|
export default {
|
|
303
488
|
createReportValidator,
|
|
489
|
+
normaliseEnums,
|
|
490
|
+
appendNormalisedMarkdown,
|
|
304
491
|
stampReport,
|
|
305
492
|
checkEvidence,
|
|
306
493
|
normaliseEvidence,
|
package/src/run/resume.js
CHANGED
|
@@ -149,6 +149,8 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
|
|
|
149
149
|
build: { kind: driverName === 'playwright_web' ? 'url' : 'godot_project', ref: target },
|
|
150
150
|
repo: { commit: '', dirty: false },
|
|
151
151
|
max_actions: (journal.options && journal.options.maxActions) ?? null,
|
|
152
|
+
// The same body as the first register, so a long job replays as long.
|
|
153
|
+
...(journal.options?.runLength === 'long' ? { run_length: 'long' } : {}),
|
|
152
154
|
pack_versions: { pack: pack.pack_version }
|
|
153
155
|
}, { idempotencyKey: journal.idempotency_key });
|
|
154
156
|
|
|
@@ -165,6 +167,9 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
|
|
|
165
167
|
...options,
|
|
166
168
|
controller: journal.options?.controller || options.controller || 'standard',
|
|
167
169
|
maxActions: (journal.options && journal.options.maxActions) ?? options.maxActions,
|
|
170
|
+
// The journal's answer, never the flag: a job is long or standard for good.
|
|
171
|
+
runLength: journal.options?.runLength === 'long' ? 'long' : 'standard',
|
|
172
|
+
...(Number.isInteger(journal.options?.longRunFactor) ? { longRunFactor: journal.options.longRunFactor } : {}),
|
|
168
173
|
concurrency: options.concurrency ?? (journal.options && journal.options.concurrency),
|
|
169
174
|
uploadVideo: options.uploadVideo ?? (journal.options && journal.options.uploadVideo),
|
|
170
175
|
uploadTranscript: options.uploadTranscript ?? (journal.options && journal.options.uploadTranscript)
|
package/src/run/tools.js
CHANGED
|
@@ -68,7 +68,7 @@ const note = {
|
|
|
68
68
|
export const PLAY_TOOLS = Object.freeze([
|
|
69
69
|
{
|
|
70
70
|
name: 'snapshot',
|
|
71
|
-
description: 'Look at the current screen. Does not spend an action. Use it after anything that might have changed the game state.',
|
|
71
|
+
description: 'Look at the current screen. Does not spend an action. Use it after anything that might have changed the game state. The result also repeats this run\'s identity (the email and display name to use whenever a form asks for one).',
|
|
72
72
|
input_schema: {
|
|
73
73
|
type: 'object',
|
|
74
74
|
additionalProperties: false,
|
|
@@ -83,7 +83,7 @@ export const PLAY_TOOLS = Object.freeze([
|
|
|
83
83
|
additionalProperties: false,
|
|
84
84
|
properties: {
|
|
85
85
|
ref: { type: 'string', description: 'playwright_web only, the preferred target: the ref shown on the element, such as e12.' },
|
|
86
|
-
target: { type: 'string', description: 'web: button "Start" or Start Game. Godot: a node path such as /root/Main/UI/StartButton.' },
|
|
86
|
+
target: { type: 'string', description: 'web: button "Start" or Start Game; a whole line copied from the screen also works, and is clicked by its ref, else its @(x,y). Text that is not on the screen is refused at once. Godot: a node path such as /root/Main/UI/StartButton.' },
|
|
87
87
|
x: { type: 'number' },
|
|
88
88
|
y: { type: 'number' },
|
|
89
89
|
thought_in_character: thought,
|
|
@@ -234,7 +234,7 @@ export function actionFor(toolUse) {
|
|
|
234
234
|
* screen, so a ref in this call was read off a screen that is gone
|
|
235
235
|
* @returns {Promise<{result: Object, observation: Object|null, spentAction: boolean, changed: boolean, error: string|null}>}
|
|
236
236
|
*/
|
|
237
|
-
export async function executeTool({ driver, toolUse, onNote, onScreenshot, staleRefs = false }) {
|
|
237
|
+
export async function executeTool({ driver, toolUse, onNote, onScreenshot, staleRefs = false, identity = null }) {
|
|
238
238
|
const input = toolUse.input || {};
|
|
239
239
|
if (toolUse.malformed) {
|
|
240
240
|
return {
|
|
@@ -272,8 +272,13 @@ export async function executeTool({ driver, toolUse, onNote, onScreenshot, stale
|
|
|
272
272
|
|
|
273
273
|
if (toolUse.name === 'snapshot') {
|
|
274
274
|
const observation = await driver.observe();
|
|
275
|
+
const result = observationResult(toolUse.id, observation);
|
|
276
|
+
// The run's identity rides on the one tool a persona calls to look
|
|
277
|
+
// around: the act step's tool allowlist has no room for a tool of its own.
|
|
278
|
+
const line = identityLine(identity);
|
|
279
|
+
if (line) result.content.push({ type: 'text', text: line });
|
|
275
280
|
return {
|
|
276
|
-
result
|
|
281
|
+
result,
|
|
277
282
|
observation,
|
|
278
283
|
spentAction: false,
|
|
279
284
|
changed: false,
|
|
@@ -351,6 +356,17 @@ export function observationResult(id, observation, prefix = '', isError = false)
|
|
|
351
356
|
return { type: 'tool_result', tool_use_id: id, content, ...(isError ? { is_error: true } : {}) };
|
|
352
357
|
}
|
|
353
358
|
|
|
359
|
+
/**
|
|
360
|
+
* The run's identity as one line of a snapshot result, or '' with none.
|
|
361
|
+
*
|
|
362
|
+
* @param {{email?: string, displayName?: string}|null} identity
|
|
363
|
+
* @returns {string}
|
|
364
|
+
*/
|
|
365
|
+
export function identityLine(identity) {
|
|
366
|
+
if (!identity || !identity.email) return '';
|
|
367
|
+
return `run identity: email ${identity.email}, display name ${identity.displayName}. Use these whenever a form asks for an email or a name.`;
|
|
368
|
+
}
|
|
369
|
+
|
|
354
370
|
/**
|
|
355
371
|
* @param {string} id
|
|
356
372
|
* @param {string} text
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a plain `run` asks for, in one place, so `brief`, `check` and `run`
|
|
3
|
+
* quote the same job. Kept free of runner imports: `check` and `brief` load
|
|
4
|
+
* this without pulling in the drivers.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Personas used when the developer names none. One since 0.3.2: a first run
|
|
9
|
+
* should cost one persona run, and a second persona is one `--personas` away.
|
|
10
|
+
* Spec 17 defaulted to two, which made the default run cost twice what most
|
|
11
|
+
* customers expected to pay for a first look.
|
|
12
|
+
*/
|
|
13
|
+
export const DEFAULT_PERSONAS = Object.freeze(['curious-kid']);
|
|
14
|
+
|
|
15
|
+
/** The modules `run` registers. `aggregate_report` is free and buys the aggregate's steps. */
|
|
16
|
+
export const RUN_MODULES = Object.freeze(['persona_playtest', 'aggregate_report']);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Actions a sign up form alone took in customer runs (an email, a password, a
|
|
20
|
+
* confirm, a name, the submit, and the clicks between them), which is why a
|
|
21
|
+
* brief's test account matters on a short action budget.
|
|
22
|
+
*/
|
|
23
|
+
export const SIGN_UP_ACTIONS = 8;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The default persona set as far as this pack has them: the defaults it
|
|
27
|
+
* carries, else its first persona.
|
|
28
|
+
*
|
|
29
|
+
* @param {Array<{slug: string}>} [available]
|
|
30
|
+
* @returns {Array<string>}
|
|
31
|
+
*/
|
|
32
|
+
export function defaultPersonasFor(available = null) {
|
|
33
|
+
if (!Array.isArray(available)) return [...DEFAULT_PERSONAS];
|
|
34
|
+
const slugs = available.map(persona => persona && persona.slug).filter(Boolean);
|
|
35
|
+
const defaults = DEFAULT_PERSONAS.filter(slug => slugs.includes(slug));
|
|
36
|
+
return defaults.length > 0 ? defaults : slugs.slice(0, 1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** "1 persona", "3 personas". */
|
|
40
|
+
export function personaCount(count) {
|
|
41
|
+
return `${count} persona${count === 1 ? '' : 's'}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* An estimate's breakdown in words, every unit named with its own price:
|
|
46
|
+
* "profile job $4.00, a persona run $1.00 each (x2)".
|
|
47
|
+
*
|
|
48
|
+
* @param {Array<{unit: string, count: number, unit_cents: number}>} breakdown
|
|
49
|
+
* @param {(cents: number) => string} money
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
export function describeBreakdown(breakdown, money) {
|
|
53
|
+
const parts = [];
|
|
54
|
+
for (const line of Array.isArray(breakdown) ? breakdown : []) {
|
|
55
|
+
if (!line || !(Number(line.count) > 0)) continue;
|
|
56
|
+
const price = money(line.unit_cents);
|
|
57
|
+
const times = Number(line.count) > 1 ? ` (x${line.count})` : '';
|
|
58
|
+
if (line.unit === 'playtest_run') parts.push(`a persona run ${price} each${times}`);
|
|
59
|
+
else if (line.unit === 'playtest_run_long') parts.push(`a long persona run ${price} each${times}`);
|
|
60
|
+
else if (line.unit === 'playtest_profile') parts.push(`profile job ${price}${times}`);
|
|
61
|
+
else if (line.unit === 'playtest_fix') parts.push(`a fix attempt ${price} each${times}`);
|
|
62
|
+
else parts.push(`${line.unit} ${price}${times}`);
|
|
63
|
+
}
|
|
64
|
+
return parts.join(', ');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The persona slugs in a list that the pack does not have.
|
|
69
|
+
*
|
|
70
|
+
* @param {Array<string>} slugs
|
|
71
|
+
* @param {Array<{slug: string}>} available
|
|
72
|
+
* @returns {Array<string>}
|
|
73
|
+
*/
|
|
74
|
+
export function unknownPersonas(slugs, available) {
|
|
75
|
+
const known = new Set((available || []).map(persona => persona && persona.slug));
|
|
76
|
+
return (slugs || []).filter(slug => !known.has(slug));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* "unknown persona "x"; the valid slugs are a, b and c. "ravensight-playtest personas" lists them."
|
|
81
|
+
*
|
|
82
|
+
* @param {Array<string>} unknown
|
|
83
|
+
* @param {Array<{slug: string}>} available
|
|
84
|
+
* @returns {string}
|
|
85
|
+
*/
|
|
86
|
+
export function unknownPersonaMessage(unknown, available) {
|
|
87
|
+
const slugs = (available || []).map(persona => persona.slug);
|
|
88
|
+
const listed = slugs.length > 1 ? `${slugs.slice(0, -1).join(', ')} and ${slugs[slugs.length - 1]}` : slugs.join('');
|
|
89
|
+
return `unknown persona${unknown.length === 1 ? '' : 's'} ${unknown.map(slug => JSON.stringify(slug)).join(', ')}; the valid slugs are ${listed || '(none in this pack)'}. "ravensight-playtest personas" lists them.`;
|
|
90
|
+
}
|
package/src/state/index.js
CHANGED
|
@@ -100,6 +100,9 @@ export async function listStates(options = {}) {
|
|
|
100
100
|
state
|
|
101
101
|
});
|
|
102
102
|
}
|
|
103
|
-
|
|
103
|
+
// Two journals written in the same millisecond tie on updated_at; the job
|
|
104
|
+
// id breaks the tie (descending, like the time), so the order never
|
|
105
|
+
// depends on how the filesystem lists the directory.
|
|
106
|
+
found.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)) || String(b.job_id).localeCompare(String(a.job_id)));
|
|
104
107
|
return found;
|
|
105
108
|
}
|
package/src/ui/index.js
CHANGED
|
@@ -184,6 +184,19 @@ export async function choose(question, labels, options = {}) {
|
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Bold on a terminal that shows it, plain everywhere else (a pipe, CI, a
|
|
189
|
+
* log file, NO_COLOR), so a saved log never carries escape codes.
|
|
190
|
+
*
|
|
191
|
+
* @param {string} text
|
|
192
|
+
* @param {{isTTY?: boolean}} [target] the stream it will be written to
|
|
193
|
+
* @returns {string}
|
|
194
|
+
*/
|
|
195
|
+
export function bold(text, target = stream) {
|
|
196
|
+
if (process.env.NO_COLOR || !(target && target.isTTY)) return String(text);
|
|
197
|
+
return `\u001b[1m${text}\u001b[22m`;
|
|
198
|
+
}
|
|
199
|
+
|
|
187
200
|
/** Cents as dollars, the one place that formatting exists. */
|
|
188
201
|
export function money(cents) {
|
|
189
202
|
if (cents === null || cents === undefined) return 'unknown';
|
|
@@ -219,5 +232,6 @@ export const ui = {
|
|
|
219
232
|
confirm,
|
|
220
233
|
choose,
|
|
221
234
|
money,
|
|
222
|
-
bytes
|
|
235
|
+
bytes,
|
|
236
|
+
bold
|
|
223
237
|
};
|