ravensight-playtest 0.3.1 → 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/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
@@ -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,
@@ -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
+ }
@@ -100,6 +100,9 @@ export async function listStates(options = {}) {
100
100
  state
101
101
  });
102
102
  }
103
- found.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)));
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
  };