@patterkit/core 0.3.2 → 0.4.1

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
@@ -21,12 +21,12 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  canonicalStringify: () => canonicalStringify,
24
- effectiveGameId: () => import_model4.effectiveGameId,
25
- gameIdify: () => import_model4.gameIdify,
24
+ effectiveGameId: () => import_model5.effectiveGameId,
25
+ gameIdify: () => import_model5.gameIdify,
26
26
  hash32: () => hash32,
27
27
  hash4: () => hash4,
28
28
  humanizeNodeRefs: () => humanizeNodeRefs,
29
- isValidGameId: () => import_model4.isValidGameId,
29
+ isValidGameId: () => import_model5.isValidGameId,
30
30
  newId: () => import_toolkit.newId,
31
31
  parseSource: () => import_toolkit3.parseSource,
32
32
  planProject: () => planProject,
@@ -53,7 +53,7 @@ function hash32(input) {
53
53
  }
54
54
 
55
55
  // src/index.ts
56
- var import_model4 = require("@patterkit/model");
56
+ var import_model5 = require("@patterkit/model");
57
57
 
58
58
  // src/noderefs.ts
59
59
  var VISIT_FN_RE = /\b(patter_visits|patter_seen|visits|seen)\s*\(\s*(?:(['"])(.*?)\2|([A-Za-z_]\w*))\s*\)/g;
@@ -106,6 +106,59 @@ function isContentlessNode(node) {
106
106
  if (node.prompt) return false;
107
107
  return Array.isArray(node.children) && node.children.every(isContentlessNode);
108
108
  }
109
+ function contentlessJumpCycles(scenes) {
110
+ const firstBlock = /* @__PURE__ */ new Map();
111
+ for (const scene of scenes) {
112
+ const first = Array.isArray(scene.blocks) ? scene.blocks[0] : void 0;
113
+ if (typeof scene.id === "string" && typeof first?.id === "string") firstBlock.set(scene.id, first.id);
114
+ }
115
+ const leads = (children) => {
116
+ if (!Array.isArray(children)) return void 0;
117
+ for (const n of children) {
118
+ if (!n || typeof n !== "object" || n.condition) return void 0;
119
+ if (n.type === "snippet") {
120
+ if ((n.beats?.length ?? 0) > 0) return void 0;
121
+ if (!n.jump) continue;
122
+ if (n.jump.to === "END") return void 0;
123
+ const to = firstBlock.get(n.jump.to) ?? n.jump.to;
124
+ return { via: n.id, to };
125
+ }
126
+ if (n.type !== "group" || n.prompt || n.selector !== void 0 && n.selector !== "run") return void 0;
127
+ const inner = leads(n.children);
128
+ if (inner !== "through") return inner;
129
+ }
130
+ return "through";
131
+ };
132
+ const next = /* @__PURE__ */ new Map();
133
+ for (const scene of scenes) {
134
+ for (const block of Array.isArray(scene.blocks) ? scene.blocks : []) {
135
+ const l = leads(block.children);
136
+ if (l && l !== "through" && typeof block.id === "string") next.set(block.id, l);
137
+ }
138
+ }
139
+ const issues = [];
140
+ const settled = /* @__PURE__ */ new Set();
141
+ for (const startId of next.keys()) {
142
+ const path = [];
143
+ let at = startId;
144
+ while (at !== void 0 && !settled.has(at) && !path.includes(at)) {
145
+ path.push(at);
146
+ at = next.get(at)?.to;
147
+ }
148
+ if (at !== void 0 && path.includes(at)) {
149
+ const loop = path.slice(path.indexOf(at));
150
+ const via = loop.map((b) => next.get(b).via);
151
+ issues.push({
152
+ code: "jump-cycle",
153
+ severity: "warning",
154
+ id: via[0],
155
+ message: `${via.length === 1 ? `snippet '${via[0]}' jumps` : `snippets ${via.map((v) => `'${v}'`).join(", ")} jump`} round a loop with nothing to deliver (blocks ${[...loop, at].map((b) => `'${b}'`).join(" -> ")}); play reaching it never gets out`
156
+ });
157
+ }
158
+ for (const b of path) settled.add(b);
159
+ }
160
+ return issues;
161
+ }
109
162
  function validateProject(input) {
110
163
  const { project, scenes } = input;
111
164
  const issues = [];
@@ -121,6 +174,7 @@ function validateProject(input) {
121
174
  }
122
175
  }
123
176
  };
177
+ const addressOf = (node) => (0, import_model2.effectiveGameId)({ gameId: node.gameId, name: typeof node.name === "string" ? node.name : "" });
124
178
  const seeId = (id, kind, where) => {
125
179
  if (typeof id !== "string" || id.length === 0) {
126
180
  issues.push({ code: "missing-id", message: `${kind} in ${where} has no id` });
@@ -145,7 +199,7 @@ function validateProject(input) {
145
199
  if (scene.gameId && !(0, import_model2.isValidGameId)(scene.gameId)) {
146
200
  issues.push({ code: "invalid-gameid", message: `${where} address '${scene.gameId}' is invalid (lowercase letters, digits, hyphens; no leading/trailing hyphen)`, id: scene.id });
147
201
  }
148
- const sgid = (0, import_model2.effectiveGameId)(scene);
202
+ const sgid = addressOf(scene);
149
203
  if (sgid) {
150
204
  const prev = sceneGameIds.get(sgid);
151
205
  if (prev && prev !== scene.id) issues.push({ code: "duplicate-gameid", message: `${where} address '${sgid}' is already used by scene '${prev}'`, id: scene.id });
@@ -180,7 +234,7 @@ function validateProject(input) {
180
234
  if (block.gameId && !(0, import_model2.isValidGameId)(block.gameId)) {
181
235
  issues.push({ code: "invalid-gameid", message: `block '${block.id}' address '${block.gameId}' is invalid (lowercase letters, digits, hyphens; no leading/trailing hyphen)`, id: block.id });
182
236
  }
183
- const bgid = (0, import_model2.effectiveGameId)(block);
237
+ const bgid = addressOf(block);
184
238
  if (bgid) {
185
239
  const prev = blockGameIds.get(bgid);
186
240
  if (prev && prev !== block.id) issues.push({ code: "duplicate-gameid", message: `block '${block.id}' address '${bgid}' is already used by block '${prev}' in ${where}`, id: block.id });
@@ -243,9 +297,9 @@ function validateProject(input) {
243
297
  }
244
298
  const hasUnconditionalEscape = (node.children ?? []).some((opt) => !opt.condition);
245
299
  if ((node.children?.length ?? 0) > 0 && !hasUnconditionalEscape) {
246
- 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 });
300
+ 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" });
247
301
  } else if (!hasSticky && fallbacks === 0 && choiceLoopsBack(node, block.id, bgid, scene.id, sgid)) {
248
- 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 });
302
+ 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" });
249
303
  }
250
304
  }
251
305
  return;
@@ -254,6 +308,10 @@ function validateProject(input) {
254
308
  for (const beat of node.beats ?? []) {
255
309
  seeId(beat.id, "beat", `snippet '${node.id}'`);
256
310
  checkTags(beat.tags, `beat '${beat.id}'`, beat.id);
311
+ const kind = beat.kind;
312
+ if (kind !== "line" && kind !== "text" && kind !== "gameEvent") {
313
+ issues.push({ code: "malformed-node", message: `beat '${beat.id}' has an unknown kind '${kind}' (line, text, or gameEvent)`, id: beat.id });
314
+ }
257
315
  if (beat.kind === "line" && beat.character && !castNames.has(beat.character)) {
258
316
  issues.push({
259
317
  code: "unknown-character",
@@ -267,6 +325,7 @@ function validateProject(input) {
267
325
  }
268
326
  }
269
327
  if (input.authoring) validateAuthoring(input.authoring, project, allIds, issues);
328
+ issues.push(...contentlessJumpCycles(scenes));
270
329
  for (const d of jumps) {
271
330
  if (d.to === "END") continue;
272
331
  if (!allIds.has(d.to)) {
@@ -476,11 +535,12 @@ function defaultMatchesType(value, decl) {
476
535
  }
477
536
 
478
537
  // src/scene-scaffold.ts
538
+ var import_model4 = require("@patterkit/model");
479
539
  var trimSlash = (p) => p.replace(/\/+$/, "");
480
540
  function planProject(opts) {
481
541
  const locale = opts.locale ?? "en";
482
542
  const project = {
483
- schema: "patter/project@0",
543
+ schema: import_model4.PROJECT_SCHEMA,
484
544
  project: { id: (0, import_toolkit.newId)("proj"), name: opts.name },
485
545
  locales: { default: locale, all: [locale] },
486
546
  voiced: true
@@ -532,10 +592,10 @@ function planScene(target, scaffold) {
532
592
  gameId: scaffold.gameId,
533
593
  blocks: [{ id: (0, import_toolkit.newId)("blk"), type: "block", name: "Main", children }]
534
594
  };
535
- const flow = { schema: "patter/flow@0", scene };
536
- const locale = { schema: "patter/strings@0", scene: sceneId, locale: target.locale, default: true, strings };
537
- const flowDir = trimSlash(target.layout?.flow ?? "scenes/");
538
- const stringsDir = trimSlash(target.layout?.strings ?? "loc/");
595
+ const flow = { schema: import_model4.FLOW_SCHEMA, scene };
596
+ const locale = { schema: import_model4.STRINGS_SCHEMA, scene: sceneId, locale: target.locale, default: true, strings };
597
+ const flowDir = trimSlash((0, import_model4.projectLayout)(target).flow);
598
+ const stringsDir = trimSlash((0, import_model4.projectLayout)(target).strings);
539
599
  return {
540
600
  sceneId,
541
601
  writes: [
package/dist/index.d.cts CHANGED
@@ -26,10 +26,13 @@ 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" | "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";
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" | "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" | "jump-cycle";
30
30
  message: string;
31
31
  /** Id of the offending node/beat, where applicable. */
32
32
  id?: string;
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";
33
36
  }
34
37
  interface ProjectInput {
35
38
  project: ProjectFile;
package/dist/index.d.ts CHANGED
@@ -26,10 +26,13 @@ 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" | "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";
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" | "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" | "jump-cycle";
30
30
  message: string;
31
31
  /** Id of the offending node/beat, where applicable. */
32
32
  id?: string;
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";
33
36
  }
34
37
  interface ProjectInput {
35
38
  project: ProjectFile;
package/dist/index.js CHANGED
@@ -68,6 +68,59 @@ function isContentlessNode(node) {
68
68
  if (node.prompt) return false;
69
69
  return Array.isArray(node.children) && node.children.every(isContentlessNode);
70
70
  }
71
+ function contentlessJumpCycles(scenes) {
72
+ const firstBlock = /* @__PURE__ */ new Map();
73
+ for (const scene of scenes) {
74
+ const first = Array.isArray(scene.blocks) ? scene.blocks[0] : void 0;
75
+ if (typeof scene.id === "string" && typeof first?.id === "string") firstBlock.set(scene.id, first.id);
76
+ }
77
+ const leads = (children) => {
78
+ if (!Array.isArray(children)) return void 0;
79
+ for (const n of children) {
80
+ if (!n || typeof n !== "object" || n.condition) return void 0;
81
+ if (n.type === "snippet") {
82
+ if ((n.beats?.length ?? 0) > 0) return void 0;
83
+ if (!n.jump) continue;
84
+ if (n.jump.to === "END") return void 0;
85
+ const to = firstBlock.get(n.jump.to) ?? n.jump.to;
86
+ return { via: n.id, to };
87
+ }
88
+ if (n.type !== "group" || n.prompt || n.selector !== void 0 && n.selector !== "run") return void 0;
89
+ const inner = leads(n.children);
90
+ if (inner !== "through") return inner;
91
+ }
92
+ return "through";
93
+ };
94
+ const next = /* @__PURE__ */ new Map();
95
+ for (const scene of scenes) {
96
+ for (const block of Array.isArray(scene.blocks) ? scene.blocks : []) {
97
+ const l = leads(block.children);
98
+ if (l && l !== "through" && typeof block.id === "string") next.set(block.id, l);
99
+ }
100
+ }
101
+ const issues = [];
102
+ const settled = /* @__PURE__ */ new Set();
103
+ for (const startId of next.keys()) {
104
+ const path = [];
105
+ let at = startId;
106
+ while (at !== void 0 && !settled.has(at) && !path.includes(at)) {
107
+ path.push(at);
108
+ at = next.get(at)?.to;
109
+ }
110
+ if (at !== void 0 && path.includes(at)) {
111
+ const loop = path.slice(path.indexOf(at));
112
+ const via = loop.map((b) => next.get(b).via);
113
+ issues.push({
114
+ code: "jump-cycle",
115
+ severity: "warning",
116
+ id: via[0],
117
+ message: `${via.length === 1 ? `snippet '${via[0]}' jumps` : `snippets ${via.map((v) => `'${v}'`).join(", ")} jump`} round a loop with nothing to deliver (blocks ${[...loop, at].map((b) => `'${b}'`).join(" -> ")}); play reaching it never gets out`
118
+ });
119
+ }
120
+ for (const b of path) settled.add(b);
121
+ }
122
+ return issues;
123
+ }
71
124
  function validateProject(input) {
72
125
  const { project, scenes } = input;
73
126
  const issues = [];
@@ -83,6 +136,7 @@ function validateProject(input) {
83
136
  }
84
137
  }
85
138
  };
139
+ const addressOf = (node) => effectiveGameId({ gameId: node.gameId, name: typeof node.name === "string" ? node.name : "" });
86
140
  const seeId = (id, kind, where) => {
87
141
  if (typeof id !== "string" || id.length === 0) {
88
142
  issues.push({ code: "missing-id", message: `${kind} in ${where} has no id` });
@@ -107,7 +161,7 @@ function validateProject(input) {
107
161
  if (scene.gameId && !isValidGameId(scene.gameId)) {
108
162
  issues.push({ code: "invalid-gameid", message: `${where} address '${scene.gameId}' is invalid (lowercase letters, digits, hyphens; no leading/trailing hyphen)`, id: scene.id });
109
163
  }
110
- const sgid = effectiveGameId(scene);
164
+ const sgid = addressOf(scene);
111
165
  if (sgid) {
112
166
  const prev = sceneGameIds.get(sgid);
113
167
  if (prev && prev !== scene.id) issues.push({ code: "duplicate-gameid", message: `${where} address '${sgid}' is already used by scene '${prev}'`, id: scene.id });
@@ -142,7 +196,7 @@ function validateProject(input) {
142
196
  if (block.gameId && !isValidGameId(block.gameId)) {
143
197
  issues.push({ code: "invalid-gameid", message: `block '${block.id}' address '${block.gameId}' is invalid (lowercase letters, digits, hyphens; no leading/trailing hyphen)`, id: block.id });
144
198
  }
145
- const bgid = effectiveGameId(block);
199
+ const bgid = addressOf(block);
146
200
  if (bgid) {
147
201
  const prev = blockGameIds.get(bgid);
148
202
  if (prev && prev !== block.id) issues.push({ code: "duplicate-gameid", message: `block '${block.id}' address '${bgid}' is already used by block '${prev}' in ${where}`, id: block.id });
@@ -205,9 +259,9 @@ function validateProject(input) {
205
259
  }
206
260
  const hasUnconditionalEscape = (node.children ?? []).some((opt) => !opt.condition);
207
261
  if ((node.children?.length ?? 0) > 0 && !hasUnconditionalEscape) {
208
- 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 });
262
+ 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" });
209
263
  } else if (!hasSticky && fallbacks === 0 && choiceLoopsBack(node, block.id, bgid, scene.id, sgid)) {
210
- 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 });
264
+ 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" });
211
265
  }
212
266
  }
213
267
  return;
@@ -216,6 +270,10 @@ function validateProject(input) {
216
270
  for (const beat of node.beats ?? []) {
217
271
  seeId(beat.id, "beat", `snippet '${node.id}'`);
218
272
  checkTags(beat.tags, `beat '${beat.id}'`, beat.id);
273
+ const kind = beat.kind;
274
+ if (kind !== "line" && kind !== "text" && kind !== "gameEvent") {
275
+ issues.push({ code: "malformed-node", message: `beat '${beat.id}' has an unknown kind '${kind}' (line, text, or gameEvent)`, id: beat.id });
276
+ }
219
277
  if (beat.kind === "line" && beat.character && !castNames.has(beat.character)) {
220
278
  issues.push({
221
279
  code: "unknown-character",
@@ -229,6 +287,7 @@ function validateProject(input) {
229
287
  }
230
288
  }
231
289
  if (input.authoring) validateAuthoring(input.authoring, project, allIds, issues);
290
+ issues.push(...contentlessJumpCycles(scenes));
232
291
  for (const d of jumps) {
233
292
  if (d.to === "END") continue;
234
293
  if (!allIds.has(d.to)) {
@@ -438,11 +497,12 @@ function defaultMatchesType(value, decl) {
438
497
  }
439
498
 
440
499
  // src/scene-scaffold.ts
500
+ import { projectLayout, FLOW_SCHEMA, PROJECT_SCHEMA, STRINGS_SCHEMA } from "@patterkit/model";
441
501
  var trimSlash = (p) => p.replace(/\/+$/, "");
442
502
  function planProject(opts) {
443
503
  const locale = opts.locale ?? "en";
444
504
  const project = {
445
- schema: "patter/project@0",
505
+ schema: PROJECT_SCHEMA,
446
506
  project: { id: newId("proj"), name: opts.name },
447
507
  locales: { default: locale, all: [locale] },
448
508
  voiced: true
@@ -494,10 +554,10 @@ function planScene(target, scaffold) {
494
554
  gameId: scaffold.gameId,
495
555
  blocks: [{ id: newId("blk"), type: "block", name: "Main", children }]
496
556
  };
497
- const flow = { schema: "patter/flow@0", scene };
498
- const locale = { schema: "patter/strings@0", scene: sceneId, locale: target.locale, default: true, strings };
499
- const flowDir = trimSlash(target.layout?.flow ?? "scenes/");
500
- const stringsDir = trimSlash(target.layout?.strings ?? "loc/");
557
+ const flow = { schema: FLOW_SCHEMA, scene };
558
+ const locale = { schema: STRINGS_SCHEMA, scene: sceneId, locale: target.locale, default: true, strings };
559
+ const flowDir = trimSlash(projectLayout(target).flow);
560
+ const stringsDir = trimSlash(projectLayout(target).strings);
501
561
  return {
502
562
  sceneId,
503
563
  writes: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patterkit/core",
3
- "version": "0.3.2",
3
+ "version": "0.4.1",
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.9.0",
37
+ "@patterkit/model": "0.10.0",
38
38
  "@wildwinter/toolkit": "^0.1.0",
39
39
  "json5": "^2.2.3"
40
40
  }