@patterkit/core 0.10.1 → 0.11.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/dist/index.cjs CHANGED
@@ -141,10 +141,73 @@ function walkContainer(children, selector, out) {
141
141
  }
142
142
 
143
143
  // src/validate.ts
144
- function choiceLoopsBack(choice, ...targets) {
145
- const back = new Set(targets.filter((s) => typeof s === "string" && s.length > 0));
146
- const hits = (n) => n.type === "snippet" ? !!n.jump && back.has(n.jump.to) : (n.children ?? []).some(hits);
147
- return (choice.children ?? []).some(hits);
144
+ function jumpGraph(scenes, start) {
145
+ const firstBlock = /* @__PURE__ */ new Map();
146
+ const blocks = /* @__PURE__ */ new Set();
147
+ const wellFormed = scenes.filter((sc) => typeof sc?.id === "string" && Array.isArray(sc.blocks));
148
+ for (const scene of wellFormed) {
149
+ const first = scene.blocks[0];
150
+ if (typeof first?.id === "string") firstBlock.set(scene.id, first.id);
151
+ for (const b of scene.blocks) if (typeof b?.id === "string") blocks.add(b.id);
152
+ }
153
+ const resolve = (to) => blocks.has(to) ? to : firstBlock.get(to);
154
+ const next = /* @__PURE__ */ new Map();
155
+ const jumpedInto = /* @__PURE__ */ new Set();
156
+ const calledInto = /* @__PURE__ */ new Set();
157
+ for (const scene of wellFormed) {
158
+ for (const block of scene.blocks) {
159
+ if (typeof block?.id !== "string" || !Array.isArray(block.children)) continue;
160
+ const out = /* @__PURE__ */ new Set();
161
+ for (const to of jumpTargets(block.children, resolve)) {
162
+ out.add(to.block);
163
+ (to.call ? calledInto : jumpedInto).add(to.block);
164
+ }
165
+ next.set(block.id, out);
166
+ }
167
+ }
168
+ const begin = start ? start.block !== void 0 && blocks.has(start.block) ? start.block : firstBlock.get(start.scene) : void 0;
169
+ const fallbackBegin = wellFormed[0] ? firstBlock.get(wellFormed[0].id) : void 0;
170
+ if (begin ?? fallbackBegin) jumpedInto.add(begin ?? fallbackBegin);
171
+ return { next, jumpedInto, calledInto, resolve };
172
+ }
173
+ function jumpTargets(nodes, resolve) {
174
+ const out = [];
175
+ (0, import_model.walkNodes)(nodes.filter((n) => n && typeof n === "object"), (n) => {
176
+ if (n.type !== "snippet" || !n.jump || typeof n.jump.to !== "string" || n.jump.to === "END") return;
177
+ const block = resolve(n.jump.to);
178
+ if (block !== void 0) out.push({ block, call: n.jump.mode === "call" });
179
+ });
180
+ return out;
181
+ }
182
+ function leadsBack(graph, nodes, home) {
183
+ const queue = jumpTargets(nodes, graph.resolve).map((t) => t.block);
184
+ const seen = /* @__PURE__ */ new Set();
185
+ while (queue.length > 0) {
186
+ const b = queue.shift();
187
+ if (b === home) return true;
188
+ if (seen.has(b)) continue;
189
+ seen.add(b);
190
+ for (const n of graph.next.get(b) ?? []) queue.push(n);
191
+ }
192
+ return false;
193
+ }
194
+ function whatFollowsChoices(children) {
195
+ const out = /* @__PURE__ */ new Map();
196
+ const visit = (nodes, inOrder, after, once, options) => {
197
+ nodes.forEach((n, i) => {
198
+ if (!n || typeof n !== "object") return;
199
+ const inOnce = once || options && n.sticky !== true;
200
+ if (n.type !== "group") return;
201
+ const follows = inOrder ? [...nodes.slice(i + 1), ...after] : after;
202
+ if (n.selector === "choice") out.set(n.id, { follows, once });
203
+ if (Array.isArray(n.children)) visit(n.children, (n.selector ?? "run") === "run", follows, inOnce, n.selector === "choice");
204
+ });
205
+ };
206
+ visit(children, true, [], false, false);
207
+ return out;
208
+ }
209
+ function carriesOn(nodes) {
210
+ return nodes.some((n) => !isContentlessNode(n) && !(n.type === "snippet" && (n.beats?.length ?? 0) === 0 && n.jump?.to === "END"));
148
211
  }
149
212
  function isContentlessNode(node) {
150
213
  if (node.type === "snippet") return (node.beats?.length ?? 0) === 0 && !node.jump;
@@ -211,6 +274,7 @@ function validateProject(input) {
211
274
  const addressable = /* @__PURE__ */ new Set();
212
275
  const sceneGameIds = /* @__PURE__ */ new Map();
213
276
  const jumps = [];
277
+ const graph = jumpGraph(Array.isArray(scenes) ? scenes : [], project.start);
214
278
  const castNames = new Set((project.cast ?? []).map((c) => c.name));
215
279
  const qualifierIds = project.qualifiers === void 0 || Array.isArray(project.qualifiers) ? new Set((0, import_model.projectQualifiers)(project).flatMap((q) => isQualifierEntry(q) && typeof q.gameId === "string" ? [q.gameId] : [])) : null;
216
280
  const checkPad = (value, what, id) => {
@@ -361,6 +425,7 @@ function validateProject(input) {
361
425
  message: `${n.type} '${n.id}' in block '${block.id}' never runs: snippet '${n.jumper}' before it always jumps ${where2}`
362
426
  });
363
427
  }
428
+ const afterChoice = whatFollowsChoices(block.children);
364
429
  (0, import_model.walkNodes)(block.children, (node) => {
365
430
  const inBlock = `block '${block.id}'`;
366
431
  checkTags(node.tags, `${node.type} '${node.id}'`, node.id);
@@ -402,10 +467,27 @@ function validateProject(input) {
402
467
  issues.push({ code: "multiple-fallbacks", message: `choice '${node.id}' in ${inBlock} has ${fallbacks} fallback options (at most one allowed)`, id: node.id });
403
468
  }
404
469
  const hasUnconditionalEscape = (node.children ?? []).some((opt) => !opt.condition);
405
- if ((node.children?.length ?? 0) > 0 && !hasUnconditionalEscape) {
406
- issues.push({ code: "choice-can-empty", message: `choice '${node.id}' in ${inBlock} has no unconditional option or fallback - it runs dry (falls through) if every condition fails`, id: node.id, severity: "warning" });
407
- } else if (!hasSticky && fallbacks === 0 && choiceLoopsBack(node, block.id, bgid, scene.id, sgid)) {
408
- issues.push({ code: "choice-can-empty", message: `choice '${node.id}' in ${inBlock} can be re-entered but every option is once-only with no fallback - it will run dry`, id: node.id, severity: "warning" });
470
+ const { follows, once } = afterChoice.get(node.id) ?? { follows: [], once: false };
471
+ const noGuaranteedPath = (node.children?.length ?? 0) > 0 && !hasUnconditionalEscape;
472
+ const exhausts = !noGuaranteedPath && !once && !hasSticky && fallbacks === 0 && leadsBack(graph, [...node.children ?? [], ...follows], block.id);
473
+ if (noGuaranteedPath || exhausts) {
474
+ const why = noGuaranteedPath ? "has no unconditional option or fallback, so it runs dry if every condition fails" : "can be played again but every option is once-only with no fallback, so it runs dry once they're used";
475
+ const returns = graph.calledInto.has(block.id) && !graph.jumpedInto.has(block.id);
476
+ if (carriesOn(follows) || returns) {
477
+ issues.push({
478
+ code: "choice-can-empty",
479
+ severity: "info",
480
+ id: node.id,
481
+ message: `choice '${node.id}' in ${inBlock} ${why}; play then carries on ${returns && !carriesOn(follows) ? "where the block was called from" : "after it"}`
482
+ });
483
+ } else {
484
+ issues.push({
485
+ code: "choice-ends-dry",
486
+ severity: "warning",
487
+ id: node.id,
488
+ message: `choice '${node.id}' in ${inBlock} ${why}, and nothing follows it, so the dialogue would end there; add a fallback to say what happens then`
489
+ });
490
+ }
409
491
  }
410
492
  }
411
493
  return;
package/dist/index.d.cts CHANGED
@@ -26,13 +26,14 @@ interface StringifyOptions {
26
26
  declare function canonicalStringify(value: unknown, opts?: StringifyOptions): string;
27
27
 
28
28
  interface ValidationIssue {
29
- code: "duplicate-id" | "missing-id" | "malformed-node" | "missing-name" | "empty-scene" | "empty-snippet" | "empty-container" | "dangling-jump" | "jump-into-non-addressable" | "invalid-gameid" | "duplicate-gameid" | "unknown-character" | "unknown-qualifier" | "invalid-qualifier" | "invalid-pad" | "pad-overlaps-seam" | "prompt-pad-without-beat" | "qualifier-without-speaker" | "invalid-speaker-label" | "unused-speaker-label" | "invalid-temporary" | "invalid-declaration" | "invalid-status-ladder" | "invalid-locales" | "invalid-captions" | "missing-prompt" | "invalid-prompt" | "multiple-fallbacks" | "choice-can-empty" | "invalid-status-value" | "unknown-doc-class" | "invalid-tag" | "invalid-gamedata-field" | "invalid-gamedata-value" | "jump-cycle" | "unreachable-after-jump" | "no-start" | "dangling-start";
29
+ code: "duplicate-id" | "missing-id" | "malformed-node" | "missing-name" | "empty-scene" | "empty-snippet" | "empty-container" | "dangling-jump" | "jump-into-non-addressable" | "invalid-gameid" | "duplicate-gameid" | "unknown-character" | "unknown-qualifier" | "invalid-qualifier" | "invalid-pad" | "pad-overlaps-seam" | "prompt-pad-without-beat" | "qualifier-without-speaker" | "invalid-speaker-label" | "unused-speaker-label" | "invalid-temporary" | "invalid-declaration" | "invalid-status-ladder" | "invalid-locales" | "invalid-captions" | "missing-prompt" | "invalid-prompt" | "multiple-fallbacks" | "choice-can-empty" | "choice-ends-dry" | "invalid-status-value" | "unknown-doc-class" | "invalid-tag" | "invalid-gamedata-field" | "invalid-gamedata-value" | "jump-cycle" | "unreachable-after-jump" | "no-start" | "dangling-start";
30
30
  message: string;
31
31
  /** Id of the offending node/beat, where applicable. */
32
32
  id?: string;
33
33
  /** Absent means an error: the project should not be built. A warning still compiles and plays (a choice
34
- * that can run dry gathers or falls through at runtime), and every front end shows it as one. */
35
- severity?: "warning";
34
+ * that can run dry and end the dialogue), and every front end shows it as one. An `info` is a note, not a
35
+ * problem (a choice that can run dry and then carry on, which is often meant): shown, never counted. */
36
+ severity?: "warning" | "info";
36
37
  /** A quality gameData value off its field's ladder (a deleted or renamed stage, most often): the field, the
37
38
  * value, and the stages to offer in its place, in ladder order. An editor's quick fix reads it. */
38
39
  offLadder?: {
package/dist/index.d.ts CHANGED
@@ -26,13 +26,14 @@ interface StringifyOptions {
26
26
  declare function canonicalStringify(value: unknown, opts?: StringifyOptions): string;
27
27
 
28
28
  interface ValidationIssue {
29
- code: "duplicate-id" | "missing-id" | "malformed-node" | "missing-name" | "empty-scene" | "empty-snippet" | "empty-container" | "dangling-jump" | "jump-into-non-addressable" | "invalid-gameid" | "duplicate-gameid" | "unknown-character" | "unknown-qualifier" | "invalid-qualifier" | "invalid-pad" | "pad-overlaps-seam" | "prompt-pad-without-beat" | "qualifier-without-speaker" | "invalid-speaker-label" | "unused-speaker-label" | "invalid-temporary" | "invalid-declaration" | "invalid-status-ladder" | "invalid-locales" | "invalid-captions" | "missing-prompt" | "invalid-prompt" | "multiple-fallbacks" | "choice-can-empty" | "invalid-status-value" | "unknown-doc-class" | "invalid-tag" | "invalid-gamedata-field" | "invalid-gamedata-value" | "jump-cycle" | "unreachable-after-jump" | "no-start" | "dangling-start";
29
+ code: "duplicate-id" | "missing-id" | "malformed-node" | "missing-name" | "empty-scene" | "empty-snippet" | "empty-container" | "dangling-jump" | "jump-into-non-addressable" | "invalid-gameid" | "duplicate-gameid" | "unknown-character" | "unknown-qualifier" | "invalid-qualifier" | "invalid-pad" | "pad-overlaps-seam" | "prompt-pad-without-beat" | "qualifier-without-speaker" | "invalid-speaker-label" | "unused-speaker-label" | "invalid-temporary" | "invalid-declaration" | "invalid-status-ladder" | "invalid-locales" | "invalid-captions" | "missing-prompt" | "invalid-prompt" | "multiple-fallbacks" | "choice-can-empty" | "choice-ends-dry" | "invalid-status-value" | "unknown-doc-class" | "invalid-tag" | "invalid-gamedata-field" | "invalid-gamedata-value" | "jump-cycle" | "unreachable-after-jump" | "no-start" | "dangling-start";
30
30
  message: string;
31
31
  /** Id of the offending node/beat, where applicable. */
32
32
  id?: string;
33
33
  /** Absent means an error: the project should not be built. A warning still compiles and plays (a choice
34
- * that can run dry gathers or falls through at runtime), and every front end shows it as one. */
35
- severity?: "warning";
34
+ * that can run dry and end the dialogue), and every front end shows it as one. An `info` is a note, not a
35
+ * problem (a choice that can run dry and then carry on, which is often meant): shown, never counted. */
36
+ severity?: "warning" | "info";
36
37
  /** A quality gameData value off its field's ladder (a deleted or renamed stage, most often): the field, the
37
38
  * value, and the stages to offer in its place, in ladder order. An editor's quick fix reads it. */
38
39
  offLadder?: {
package/dist/index.js CHANGED
@@ -102,10 +102,73 @@ function walkContainer(children, selector, out) {
102
102
  }
103
103
 
104
104
  // src/validate.ts
105
- function choiceLoopsBack(choice, ...targets) {
106
- const back = new Set(targets.filter((s) => typeof s === "string" && s.length > 0));
107
- const hits = (n) => n.type === "snippet" ? !!n.jump && back.has(n.jump.to) : (n.children ?? []).some(hits);
108
- return (choice.children ?? []).some(hits);
105
+ function jumpGraph(scenes, start) {
106
+ const firstBlock = /* @__PURE__ */ new Map();
107
+ const blocks = /* @__PURE__ */ new Set();
108
+ const wellFormed = scenes.filter((sc) => typeof sc?.id === "string" && Array.isArray(sc.blocks));
109
+ for (const scene of wellFormed) {
110
+ const first = scene.blocks[0];
111
+ if (typeof first?.id === "string") firstBlock.set(scene.id, first.id);
112
+ for (const b of scene.blocks) if (typeof b?.id === "string") blocks.add(b.id);
113
+ }
114
+ const resolve = (to) => blocks.has(to) ? to : firstBlock.get(to);
115
+ const next = /* @__PURE__ */ new Map();
116
+ const jumpedInto = /* @__PURE__ */ new Set();
117
+ const calledInto = /* @__PURE__ */ new Set();
118
+ for (const scene of wellFormed) {
119
+ for (const block of scene.blocks) {
120
+ if (typeof block?.id !== "string" || !Array.isArray(block.children)) continue;
121
+ const out = /* @__PURE__ */ new Set();
122
+ for (const to of jumpTargets(block.children, resolve)) {
123
+ out.add(to.block);
124
+ (to.call ? calledInto : jumpedInto).add(to.block);
125
+ }
126
+ next.set(block.id, out);
127
+ }
128
+ }
129
+ const begin = start ? start.block !== void 0 && blocks.has(start.block) ? start.block : firstBlock.get(start.scene) : void 0;
130
+ const fallbackBegin = wellFormed[0] ? firstBlock.get(wellFormed[0].id) : void 0;
131
+ if (begin ?? fallbackBegin) jumpedInto.add(begin ?? fallbackBegin);
132
+ return { next, jumpedInto, calledInto, resolve };
133
+ }
134
+ function jumpTargets(nodes, resolve) {
135
+ const out = [];
136
+ walkNodes(nodes.filter((n) => n && typeof n === "object"), (n) => {
137
+ if (n.type !== "snippet" || !n.jump || typeof n.jump.to !== "string" || n.jump.to === "END") return;
138
+ const block = resolve(n.jump.to);
139
+ if (block !== void 0) out.push({ block, call: n.jump.mode === "call" });
140
+ });
141
+ return out;
142
+ }
143
+ function leadsBack(graph, nodes, home) {
144
+ const queue = jumpTargets(nodes, graph.resolve).map((t) => t.block);
145
+ const seen = /* @__PURE__ */ new Set();
146
+ while (queue.length > 0) {
147
+ const b = queue.shift();
148
+ if (b === home) return true;
149
+ if (seen.has(b)) continue;
150
+ seen.add(b);
151
+ for (const n of graph.next.get(b) ?? []) queue.push(n);
152
+ }
153
+ return false;
154
+ }
155
+ function whatFollowsChoices(children) {
156
+ const out = /* @__PURE__ */ new Map();
157
+ const visit = (nodes, inOrder, after, once, options) => {
158
+ nodes.forEach((n, i) => {
159
+ if (!n || typeof n !== "object") return;
160
+ const inOnce = once || options && n.sticky !== true;
161
+ if (n.type !== "group") return;
162
+ const follows = inOrder ? [...nodes.slice(i + 1), ...after] : after;
163
+ if (n.selector === "choice") out.set(n.id, { follows, once });
164
+ if (Array.isArray(n.children)) visit(n.children, (n.selector ?? "run") === "run", follows, inOnce, n.selector === "choice");
165
+ });
166
+ };
167
+ visit(children, true, [], false, false);
168
+ return out;
169
+ }
170
+ function carriesOn(nodes) {
171
+ return nodes.some((n) => !isContentlessNode(n) && !(n.type === "snippet" && (n.beats?.length ?? 0) === 0 && n.jump?.to === "END"));
109
172
  }
110
173
  function isContentlessNode(node) {
111
174
  if (node.type === "snippet") return (node.beats?.length ?? 0) === 0 && !node.jump;
@@ -172,6 +235,7 @@ function validateProject(input) {
172
235
  const addressable = /* @__PURE__ */ new Set();
173
236
  const sceneGameIds = /* @__PURE__ */ new Map();
174
237
  const jumps = [];
238
+ const graph = jumpGraph(Array.isArray(scenes) ? scenes : [], project.start);
175
239
  const castNames = new Set((project.cast ?? []).map((c) => c.name));
176
240
  const qualifierIds = project.qualifiers === void 0 || Array.isArray(project.qualifiers) ? new Set(projectQualifiers(project).flatMap((q) => isQualifierEntry(q) && typeof q.gameId === "string" ? [q.gameId] : [])) : null;
177
241
  const checkPad = (value, what, id) => {
@@ -322,6 +386,7 @@ function validateProject(input) {
322
386
  message: `${n.type} '${n.id}' in block '${block.id}' never runs: snippet '${n.jumper}' before it always jumps ${where2}`
323
387
  });
324
388
  }
389
+ const afterChoice = whatFollowsChoices(block.children);
325
390
  walkNodes(block.children, (node) => {
326
391
  const inBlock = `block '${block.id}'`;
327
392
  checkTags(node.tags, `${node.type} '${node.id}'`, node.id);
@@ -363,10 +428,27 @@ function validateProject(input) {
363
428
  issues.push({ code: "multiple-fallbacks", message: `choice '${node.id}' in ${inBlock} has ${fallbacks} fallback options (at most one allowed)`, id: node.id });
364
429
  }
365
430
  const hasUnconditionalEscape = (node.children ?? []).some((opt) => !opt.condition);
366
- if ((node.children?.length ?? 0) > 0 && !hasUnconditionalEscape) {
367
- issues.push({ code: "choice-can-empty", message: `choice '${node.id}' in ${inBlock} has no unconditional option or fallback - it runs dry (falls through) if every condition fails`, id: node.id, severity: "warning" });
368
- } else if (!hasSticky && fallbacks === 0 && choiceLoopsBack(node, block.id, bgid, scene.id, sgid)) {
369
- issues.push({ code: "choice-can-empty", message: `choice '${node.id}' in ${inBlock} can be re-entered but every option is once-only with no fallback - it will run dry`, id: node.id, severity: "warning" });
431
+ const { follows, once } = afterChoice.get(node.id) ?? { follows: [], once: false };
432
+ const noGuaranteedPath = (node.children?.length ?? 0) > 0 && !hasUnconditionalEscape;
433
+ const exhausts = !noGuaranteedPath && !once && !hasSticky && fallbacks === 0 && leadsBack(graph, [...node.children ?? [], ...follows], block.id);
434
+ if (noGuaranteedPath || exhausts) {
435
+ const why = noGuaranteedPath ? "has no unconditional option or fallback, so it runs dry if every condition fails" : "can be played again but every option is once-only with no fallback, so it runs dry once they're used";
436
+ const returns = graph.calledInto.has(block.id) && !graph.jumpedInto.has(block.id);
437
+ if (carriesOn(follows) || returns) {
438
+ issues.push({
439
+ code: "choice-can-empty",
440
+ severity: "info",
441
+ id: node.id,
442
+ message: `choice '${node.id}' in ${inBlock} ${why}; play then carries on ${returns && !carriesOn(follows) ? "where the block was called from" : "after it"}`
443
+ });
444
+ } else {
445
+ issues.push({
446
+ code: "choice-ends-dry",
447
+ severity: "warning",
448
+ id: node.id,
449
+ message: `choice '${node.id}' in ${inBlock} ${why}, and nothing follows it, so the dialogue would end there; add a fallback to say what happens then`
450
+ });
451
+ }
370
452
  }
371
453
  }
372
454
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patterkit/core",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "Patter core: stable id generation, readable-handle computation, canonical (de)serialisation, and the structural validator.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,7 +34,7 @@
34
34
  "build": "tsup src/index.ts --format esm,cjs --dts --clean"
35
35
  },
36
36
  "dependencies": {
37
- "@patterkit/model": "0.22.0",
37
+ "@patterkit/model": "0.23.0",
38
38
  "@wildwinter/expr": "^0.6.0",
39
39
  "@wildwinter/toolkit": "^0.4.0",
40
40
  "json5": "^2.2.3"