@patterkit/core 0.3.1 → 0.4.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/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # @patterkit/core
2
+
3
+ The low-level building blocks shared by Patter's editor, command line tool, and CI: ids, readable handles,
4
+ reading and writing the source files, and the structural validator.
5
+
6
+ Most people won't need to install this directly. To play Patter dialogue in a game or web page, use
7
+ [`@patterkit/runtime`](https://www.npmjs.com/package/@patterkit/runtime); to work with Patter projects from
8
+ a terminal or a build script, use [`@patterkit/cli`](https://www.npmjs.com/package/@patterkit/cli).
9
+
10
+ ## What's in it
11
+
12
+ - **Ids and handles**: `newId` for stable node ids, and `slug`, `hash4`, and `hash32` for the readable
13
+ handles and content hashes built from them.
14
+ - **Reading and writing source files**: `parseSource` and `canonicalStringify`, which write a project's
15
+ files in one canonical form so version control shows only real changes.
16
+ - **The structural validator**: `validateProject` checks a project and its scenes hang together (ids,
17
+ references, and structure) and returns a list of issues.
18
+ - **Scene scaffolding**: `planProject` and `planScene` plan a stub project or scene that another tool can
19
+ hand to a writer.
20
+
21
+ ```sh
22
+ npm install @patterkit/core
23
+ ```
24
+
25
+ ## Part of Patter
26
+
27
+ [Patter](https://patterkit.dev/) is a branching dialogue format with its own editor (Patterpad), command line
28
+ tool (`patter`), and runtimes for JavaScript, Unity, Unreal, and Godot. This package is one layer of the
29
+ JavaScript tooling, and the other `@patterkit` packages build on it. [How it fits
30
+ together](https://patterkit.dev/architecture/) shows where each piece sits, and the source is in the
31
+ [patter repository](https://github.com/patterkit/patter).
32
+
33
+ ## Licence
34
+
35
+ MIT
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 = [];
@@ -243,9 +296,9 @@ function validateProject(input) {
243
296
  }
244
297
  const hasUnconditionalEscape = (node.children ?? []).some((opt) => !opt.condition);
245
298
  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 });
299
+ 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
300
  } 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 });
301
+ 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
302
  }
250
303
  }
251
304
  return;
@@ -254,6 +307,10 @@ function validateProject(input) {
254
307
  for (const beat of node.beats ?? []) {
255
308
  seeId(beat.id, "beat", `snippet '${node.id}'`);
256
309
  checkTags(beat.tags, `beat '${beat.id}'`, beat.id);
310
+ const kind = beat.kind;
311
+ if (kind !== "line" && kind !== "text" && kind !== "gameEvent") {
312
+ issues.push({ code: "malformed-node", message: `beat '${beat.id}' has an unknown kind '${kind}' (line, text, or gameEvent)`, id: beat.id });
313
+ }
257
314
  if (beat.kind === "line" && beat.character && !castNames.has(beat.character)) {
258
315
  issues.push({
259
316
  code: "unknown-character",
@@ -267,6 +324,7 @@ function validateProject(input) {
267
324
  }
268
325
  }
269
326
  if (input.authoring) validateAuthoring(input.authoring, project, allIds, issues);
327
+ issues.push(...contentlessJumpCycles(scenes));
270
328
  for (const d of jumps) {
271
329
  if (d.to === "END") continue;
272
330
  if (!allIds.has(d.to)) {
@@ -476,11 +534,12 @@ function defaultMatchesType(value, decl) {
476
534
  }
477
535
 
478
536
  // src/scene-scaffold.ts
537
+ var import_model4 = require("@patterkit/model");
479
538
  var trimSlash = (p) => p.replace(/\/+$/, "");
480
539
  function planProject(opts) {
481
540
  const locale = opts.locale ?? "en";
482
541
  const project = {
483
- schema: "patter/project@0",
542
+ schema: import_model4.PROJECT_SCHEMA,
484
543
  project: { id: (0, import_toolkit.newId)("proj"), name: opts.name },
485
544
  locales: { default: locale, all: [locale] },
486
545
  voiced: true
@@ -532,10 +591,10 @@ function planScene(target, scaffold) {
532
591
  gameId: scaffold.gameId,
533
592
  blocks: [{ id: (0, import_toolkit.newId)("blk"), type: "block", name: "Main", children }]
534
593
  };
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/");
594
+ const flow = { schema: import_model4.FLOW_SCHEMA, scene };
595
+ const locale = { schema: import_model4.STRINGS_SCHEMA, scene: sceneId, locale: target.locale, default: true, strings };
596
+ const flowDir = trimSlash((0, import_model4.projectLayout)(target).flow);
597
+ const stringsDir = trimSlash((0, import_model4.projectLayout)(target).strings);
539
598
  return {
540
599
  sceneId,
541
600
  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 = [];
@@ -205,9 +258,9 @@ function validateProject(input) {
205
258
  }
206
259
  const hasUnconditionalEscape = (node.children ?? []).some((opt) => !opt.condition);
207
260
  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 });
261
+ 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
262
  } 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 });
263
+ 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
264
  }
212
265
  }
213
266
  return;
@@ -216,6 +269,10 @@ function validateProject(input) {
216
269
  for (const beat of node.beats ?? []) {
217
270
  seeId(beat.id, "beat", `snippet '${node.id}'`);
218
271
  checkTags(beat.tags, `beat '${beat.id}'`, beat.id);
272
+ const kind = beat.kind;
273
+ if (kind !== "line" && kind !== "text" && kind !== "gameEvent") {
274
+ issues.push({ code: "malformed-node", message: `beat '${beat.id}' has an unknown kind '${kind}' (line, text, or gameEvent)`, id: beat.id });
275
+ }
219
276
  if (beat.kind === "line" && beat.character && !castNames.has(beat.character)) {
220
277
  issues.push({
221
278
  code: "unknown-character",
@@ -229,6 +286,7 @@ function validateProject(input) {
229
286
  }
230
287
  }
231
288
  if (input.authoring) validateAuthoring(input.authoring, project, allIds, issues);
289
+ issues.push(...contentlessJumpCycles(scenes));
232
290
  for (const d of jumps) {
233
291
  if (d.to === "END") continue;
234
292
  if (!allIds.has(d.to)) {
@@ -438,11 +496,12 @@ function defaultMatchesType(value, decl) {
438
496
  }
439
497
 
440
498
  // src/scene-scaffold.ts
499
+ import { projectLayout, FLOW_SCHEMA, PROJECT_SCHEMA, STRINGS_SCHEMA } from "@patterkit/model";
441
500
  var trimSlash = (p) => p.replace(/\/+$/, "");
442
501
  function planProject(opts) {
443
502
  const locale = opts.locale ?? "en";
444
503
  const project = {
445
- schema: "patter/project@0",
504
+ schema: PROJECT_SCHEMA,
446
505
  project: { id: newId("proj"), name: opts.name },
447
506
  locales: { default: locale, all: [locale] },
448
507
  voiced: true
@@ -494,10 +553,10 @@ function planScene(target, scaffold) {
494
553
  gameId: scaffold.gameId,
495
554
  blocks: [{ id: newId("blk"), type: "block", name: "Main", children }]
496
555
  };
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/");
556
+ const flow = { schema: FLOW_SCHEMA, scene };
557
+ const locale = { schema: STRINGS_SCHEMA, scene: sceneId, locale: target.locale, default: true, strings };
558
+ const flowDir = trimSlash(projectLayout(target).flow);
559
+ const stringsDir = trimSlash(projectLayout(target).strings);
501
560
  return {
502
561
  sceneId,
503
562
  writes: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patterkit/core",
3
- "version": "0.3.1",
3
+ "version": "0.4.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.8.0",
37
+ "@patterkit/model": "0.10.0",
38
38
  "@wildwinter/toolkit": "^0.1.0",
39
39
  "json5": "^2.2.3"
40
40
  }