@patterkit/cli 0.5.4 → 0.6.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.
Files changed (3) hide show
  1. package/README.md +28 -0
  2. package/dist/cli.js +60 -23
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -128,6 +128,34 @@ patter play --scene scn_tavern --choices opt_work,opt_secret
128
128
  patter play --seed 42
129
129
  ```
130
130
 
131
+ ### `patter coverage [path]`
132
+
133
+ Play the story many times with random choices and count how often each beat comes
134
+ up, to find content no player can reach. The summary counts beats reached, never
135
+ reached, and rarely reached (in fewer than 5% of runs), and says how the runs
136
+ ended: reached the end, stalled at a choice with nothing to pick, or hit the step
137
+ limit. The table gives each beat's **reached** (the share of runs that played it)
138
+ and **played** (times it played in all runs), least reached first. Marks: `‼`
139
+ never reached, `?` never reached but may just need an input driver, `~` rarely
140
+ reached. Choices that ran dry (nothing to take and no fallback) are listed too.
141
+
142
+ | Option | Values | Meaning |
143
+ |--------|--------|---------|
144
+ | `--runs <n>` | integer | How many playthroughs (default 5000). |
145
+ | `--max-steps <n>` | integer | Steps before a run is stopped (default 200). |
146
+ | `--seed <n>` | integer | The same seed gives the same result. |
147
+ | `--scene <id>` / `--block <id>` | id | Start here instead of the project's start. |
148
+ | `--order <o>` | `least` / `script` | Least reached first (default), or the script's order, scene by scene. |
149
+ | `--fail-on-gap` | | Exit 1 if any beat is never reached (a CI gate). |
150
+ | `--propose` | | Print suggested `@world` input drivers instead of running. |
151
+ | `--json` | | The full report as JSON. |
152
+
153
+ ```sh
154
+ patter coverage
155
+ patter coverage --runs 10000 --seed 7 --order script
156
+ patter coverage --fail-on-gap
157
+ ```
158
+
131
159
  ### `patter resolve <query> [path]`
132
160
 
133
161
  Look up an **id**, **handle**, or **name** and report what it is and where it
package/dist/cli.js CHANGED
@@ -198055,6 +198055,10 @@ function mulberry32(seed) {
198055
198055
  const prng = makePrng(seed);
198056
198056
  return () => prng.next();
198057
198057
  }
198058
+ var RARE_REACH_PCT = 5;
198059
+ function leastReachedFirst(beats) {
198060
+ return beats.map((b, i) => ({ b, i })).sort((x, y) => x.b.reachPct - y.b.reachPct || x.b.hits - y.b.hits || x.i - y.i).map(({ b }) => b);
198061
+ }
198058
198062
  function scanExpr(node, hostTokens, refs, proposals) {
198059
198063
  const refOf = (n) => n.kind === "scopedvar" && hostTokens.has(n.scope) ? `@${n.scope}.${n.name}` : void 0;
198060
198064
  const propose = (ref, v) => (proposals.get(ref) ?? proposals.set(ref, /* @__PURE__ */ new Set()).get(ref)).add(v);
@@ -198344,6 +198348,7 @@ function* sweep(loaded, options = {}, hooks = {}) {
198344
198348
  const blocked = blockedGates(id, analysis, drivenRefs, reachedRuns);
198345
198349
  if (blocked.length) blockedBy = blocked;
198346
198350
  }
198351
+ const reachPct = executed ? reached / executed * 100 : 0;
198347
198352
  return {
198348
198353
  id,
198349
198354
  scene: m.scene,
@@ -198352,13 +198357,15 @@ function* sweep(loaded, options = {}, hooks = {}) {
198352
198357
  preview: m.preview,
198353
198358
  hits: hitCount.get(id),
198354
198359
  reachedRuns: reached,
198355
- reachPct: executed ? reached / executed * 100 : 0,
198360
+ reachPct,
198361
+ ...reached > 0 && reachPct < RARE_REACH_PCT ? { rare: true } : {},
198356
198362
  ...needsInput ? { needsInput } : {},
198357
198363
  ...blockedBy ? { blockedBy } : {}
198358
198364
  };
198359
198365
  });
198360
198366
  const neverHit = beats.filter((b) => b.reachedRuns === 0).length;
198361
198367
  const covered = beats.length - neverHit;
198368
+ const rare = beats.filter((b) => b.rare).length;
198362
198369
  const dryChoices = [...dryRuns.entries()].map(([id, r]) => ({ id, scene: choiceScene.get(id) ?? "", runs: r })).sort((a, b) => b.runs - a.runs || a.id.localeCompare(b.id));
198363
198370
  return {
198364
198371
  runs: executed,
@@ -198366,7 +198373,8 @@ function* sweep(loaded, options = {}, hooks = {}) {
198366
198373
  seed,
198367
198374
  start,
198368
198375
  beats,
198369
- totals: { beats: beats.length, covered, neverHit, coveragePct: beats.length ? covered / beats.length * 100 : 100 },
198376
+ totals: { beats: beats.length, covered, neverHit, rare, coveragePct: beats.length ? covered / beats.length * 100 : 100 },
198377
+ rareThresholdPct: RARE_REACH_PCT,
198370
198378
  termination,
198371
198379
  drivers,
198372
198380
  unwrittenInputs: [...unwrittenInputs].sort(),
@@ -198380,14 +198388,19 @@ function runCoverage(loaded, options = {}, hooks = {}) {
198380
198388
  while (!step.done) step = it.next();
198381
198389
  return step.value;
198382
198390
  }
198383
- function renderCoverageText(report, sceneName = (id) => id) {
198391
+ function renderCoverageText(report, sceneName = (id) => id, opts = {}) {
198392
+ const order = opts.order ?? "least";
198384
198393
  const out = [];
198385
198394
  const t = report.totals;
198386
198395
  const pct = (n) => `${n.toFixed(0)}%`;
198387
- out.push(`coverage: ${t.covered}/${t.beats} beats reached (${pct(t.coveragePct)})${t.neverHit ? ` - ${t.neverHit} never reached` : ""}`);
198396
+ const rareLimit = report.rareThresholdPct ?? RARE_REACH_PCT;
198397
+ const rareCount = t.rare ?? report.beats.filter((b) => b.rare).length;
198398
+ out.push(
198399
+ `coverage: ${t.covered}/${t.beats} beats reached (${pct(t.coveragePct)})` + (t.neverHit ? ` - ${t.neverHit} never reached` : "") + (rareCount ? ` - ${rareCount} rarely reached (under ${rareLimit}% of runs)` : "")
198400
+ );
198388
198401
  out.push(`${report.runs} run(s) - ${report.maxSteps} max steps - seed ${report.seed}${report.cancelled ? " - CANCELLED" : ""}`);
198389
198402
  const term = report.termination;
198390
- out.push(`runs ended: ${term.ended} reached the end, ${term.stalled} stalled, ${term.capped} hit the step cap, ${term.evalError} errored`);
198403
+ out.push(`runs ended: ${term.ended} reached the end, ${term.stalled} stalled at a choice with nothing to pick, ${term.capped} hit the step limit, ${term.evalError} errored`);
198391
198404
  if (report.drivers.length) out.push(`input drivers: ${report.drivers.map((d) => d.ref).join(", ")}`);
198392
198405
  if (report.unwrittenInputs.length) {
198393
198406
  out.push(`? = gated on an input nothing writes/drives: ${report.unwrittenInputs.join(", ")} (add a coverage driver?)`);
@@ -198399,25 +198412,43 @@ function renderCoverageText(report, sceneName = (id) => id) {
198399
198412
  out.push(` \u203C ${String(d.runs).padStart(6)} run(s) ${sceneName(d.scene)} choice '${d.id}'`);
198400
198413
  }
198401
198414
  }
198415
+ if (!report.beats.length) return out;
198416
+ const clip = (s, n = 48) => s.length > n ? `${s.slice(0, n - 1)}\u2026` : s;
198417
+ const scenes = new Set(report.beats.map((b) => b.scene));
198418
+ const heading = " reached played beat";
198419
+ const row = (b, withScene) => {
198420
+ const mark = b.reachedRuns === 0 ? b.needsInput || b.blockedBy ? "? " : "\u203C " : b.rare ? "~ " : " ";
198421
+ const label = b.character ? `${b.character}: ${clip(b.preview)}` : clip(b.preview || `(${b.kind})`);
198422
+ const where = withScene ? `[${sceneName(b.scene)}] ` : "";
198423
+ out.push(` ${mark}${pct(b.reachPct).padStart(6)} ${String(b.hits).padStart(6)} ${where}${label}`);
198424
+ for (const bg of b.blockedBy ?? []) {
198425
+ const names = bg.writers.map((w) => {
198426
+ const target = report.beats.find((x) => x.id === w);
198427
+ return target ? clip(target.preview || target.id, 28) : w;
198428
+ });
198429
+ out.push(` gated on ${bg.ref}, written only by: ${names.join(", ")} (never played either)`);
198430
+ }
198431
+ };
198432
+ out.push("");
198433
+ out.push(`reached = share of runs that played the beat at least once; played = times it played in all runs`);
198434
+ out.push(`\u203C never reached ? never reached, may need an input ~ rarely reached (under ${rareLimit}% of runs)`);
198435
+ if (order === "least") {
198436
+ out.push("");
198437
+ out.push(`least reached first${scenes.size > 1 ? ", every scene" : ""}`);
198438
+ out.push(heading);
198439
+ for (const b of leastReachedFirst(report.beats)) row(b, scenes.size > 1);
198440
+ return out;
198441
+ }
198402
198442
  const byScene = /* @__PURE__ */ new Map();
198403
198443
  for (const b of report.beats) (byScene.get(b.scene) ?? byScene.set(b.scene, []).get(b.scene)).push(b);
198404
- const clip = (s, n = 48) => s.length > n ? `${s.slice(0, n - 1)}\u2026` : s;
198405
198444
  for (const [scene, beats] of byScene) {
198406
198445
  const dead = beats.filter((b) => b.reachedRuns === 0).length;
198446
+ const rare = beats.filter((b) => b.rare).length;
198447
+ const notes = [dead ? `${dead} never reached` : "", rare ? `${rare} rarely reached` : ""].filter(Boolean).join(", ");
198407
198448
  out.push("");
198408
- out.push(`${sceneName(scene)}${dead ? ` (${dead} never reached)` : ""}`);
198409
- for (const b of beats) {
198410
- const mark = b.reachedRuns === 0 ? b.needsInput || b.blockedBy ? "? " : "\u203C " : " ";
198411
- const label = b.character ? `${b.character}: ${clip(b.preview)}` : clip(b.preview || `(${b.kind})`);
198412
- out.push(` ${mark}${b.reachPct.toFixed(0).padStart(3)}% ${String(b.hits).padStart(6)} ${label}`);
198413
- for (const bg of b.blockedBy ?? []) {
198414
- const names = bg.writers.map((w) => {
198415
- const target = report.beats.find((x) => x.id === w);
198416
- return target ? clip(target.preview || target.id, 28) : w;
198417
- });
198418
- out.push(` gated on ${bg.ref}, written only by: ${names.join(", ")} (never played either)`);
198419
- }
198420
- }
198449
+ out.push(`${sceneName(scene)}${notes ? ` (${notes})` : ""}`);
198450
+ out.push(heading);
198451
+ for (const b of beats) row(b, false);
198421
198452
  }
198422
198453
  return out;
198423
198454
  }
@@ -241787,7 +241818,7 @@ function writeTextFiles(files, encoding = "utf8") {
241787
241818
  // package.json
241788
241819
  var package_default = {
241789
241820
  name: "@patterkit/cli",
241790
- version: "0.5.4",
241821
+ version: "0.6.0",
241791
241822
  description: "The `patter` CLI: validate, format, export, and play a Patter project.",
241792
241823
  type: "module",
241793
241824
  license: "MIT",
@@ -241817,7 +241848,7 @@ var package_default = {
241817
241848
  },
241818
241849
  dependencies: {
241819
241850
  "@patterkit/core": "0.3.0",
241820
- "@patterkit/ops": "0.10.3",
241851
+ "@patterkit/ops": "0.11.0",
241821
241852
  "@wildwinter/simple-vc-lib": "^0.4.1"
241822
241853
  }
241823
241854
  };
@@ -241857,6 +241888,7 @@ Usage:
241857
241888
  [--scene id] [--block id] [--choices a,b,c] [--seed N]
241858
241889
  patter coverage [path] Narrative coverage: random playthroughs, find never-reached content
241859
241890
  [--runs N] [--max-steps M] [--seed S] [--scene id] [--block id]
241891
+ [--order least|script] (least reached first, the default, or the script's own order)
241860
241892
  [--json] [--fail-on-gap] (--fail-on-gap exits 1 if any beat is never reached)
241861
241893
  [--propose] (print auto-proposed @world input drivers instead of running)
241862
241894
  patter resolve <query> [path] Find a line by id, Game ID, or name: shows where it lives + what it says
@@ -241892,7 +241924,7 @@ var FLAGS = {
241892
241924
  export: { boolean: ["ids", "source-debug"], valued: ["o"] },
241893
241925
  "export-html": { boolean: [], valued: ["o"] },
241894
241926
  play: { boolean: [], valued: ["scene", "block", "choices", "seed"] },
241895
- coverage: { boolean: ["json", "fail-on-gap", "propose"], valued: ["runs", "max-steps", "seed", "scene", "block"] },
241927
+ coverage: { boolean: ["json", "fail-on-gap", "propose"], valued: ["runs", "max-steps", "seed", "scene", "block", "order"] },
241896
241928
  resolve: { boolean: [], valued: [] },
241897
241929
  usage: { boolean: ["json"], valued: [] },
241898
241930
  report: { boolean: ["json"], valued: ["xlsx"] },
@@ -242150,6 +242182,11 @@ ${count} issue(s)${advisory}`);
242150
242182
  }
242151
242183
  numbers[flag] = n;
242152
242184
  }
242185
+ const order = flags.order ?? "least";
242186
+ if (order !== "least" && order !== "script") {
242187
+ console.error(`coverage: --order '${order}' is not least or script`);
242188
+ return 2;
242189
+ }
242153
242190
  const loaded = loadProject(positionals[0] ?? ".");
242154
242191
  if (flags.propose === true) {
242155
242192
  const drivers = proposeCoverageDrivers(loaded);
@@ -242168,7 +242205,7 @@ ${count} issue(s)${advisory}`);
242168
242205
  if (flags.json === true) console.log(JSON.stringify(report));
242169
242206
  else {
242170
242207
  const nameOf = (id) => loaded.scenes.find((s) => s.id === id)?.name ?? id;
242171
- for (const line2 of renderCoverageText(report, nameOf)) console.log(line2);
242208
+ for (const line2 of renderCoverageText(report, nameOf, { order })) console.log(line2);
242172
242209
  }
242173
242210
  return flags["fail-on-gap"] === true && report.totals.neverHit > 0 ? 1 : 0;
242174
242211
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patterkit/cli",
3
- "version": "0.5.4",
3
+ "version": "0.6.0",
4
4
  "description": "The `patter` CLI: validate, format, export, and play a Patter project.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@patterkit/core": "0.3.0",
33
- "@patterkit/ops": "0.10.3",
33
+ "@patterkit/ops": "0.11.0",
34
34
  "@wildwinter/simple-vc-lib": "^0.4.1"
35
35
  }
36
36
  }