@patterkit/play-helpers 0.2.1 → 0.3.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
@@ -23,6 +23,7 @@ __export(index_exports, {
23
23
  SAVE_SCHEMA: () => SAVE_SCHEMA,
24
24
  applyLiveBundle: () => applyLiveBundle,
25
25
  createAudioResolver: () => createAudioResolver,
26
+ createBundleInspector: () => createBundleInspector,
26
27
  createDebugLink: () => createDebugLink,
27
28
  createPropertyInspector: () => createPropertyInspector,
28
29
  createStateLogger: () => createStateLogger,
@@ -263,8 +264,8 @@ function sameValue(a, b) {
263
264
  function createPropertyInspector(engine, opts = {}) {
264
265
  const doc = opts.container?.ownerDocument ?? document;
265
266
  injectStyle(doc);
266
- const el = doc.createElement("div");
267
- el.className = "pp-insp";
267
+ const el2 = doc.createElement("div");
268
+ el2.className = "pp-insp";
268
269
  const heading = doc.createElement("h4");
269
270
  heading.textContent = opts.title ?? "Runtime state";
270
271
  const list = doc.createElement("div");
@@ -303,15 +304,15 @@ function createPropertyInspector(engine, opts = {}) {
303
304
  input.click();
304
305
  });
305
306
  io.append(saveBtn, loadBtn);
306
- el.append(heading, io, list);
307
+ el2.append(heading, io, list);
307
308
  const rowRefreshers = [];
308
- const buildRow = (row) => {
309
+ const buildRow = (row2) => {
309
310
  const r = doc.createElement("div");
310
311
  r.className = "pp-insp-row";
311
312
  const label = doc.createElement("span");
312
313
  label.className = "pp-insp-ref";
313
- label.textContent = row.ref;
314
- label.title = row.ref;
314
+ label.textContent = row2.ref;
315
+ label.title = row2.ref;
315
316
  const ctl = doc.createElement("div");
316
317
  ctl.className = "pp-insp-ctl";
317
318
  const reset = doc.createElement("button");
@@ -323,37 +324,37 @@ function createPropertyInspector(engine, opts = {}) {
323
324
  list.appendChild(r);
324
325
  let read;
325
326
  const commit = (v) => {
326
- engine.setProperty(row.ref, v);
327
+ engine.setProperty(row2.ref, v);
327
328
  syncReset();
328
329
  };
329
330
  const focused = (node) => doc.activeElement === node;
330
331
  function syncReset() {
331
- reset.disabled = sameValue(engine.getProperty(row.ref), row.default);
332
+ reset.disabled = sameValue(engine.getProperty(row2.ref), row2.default);
332
333
  }
333
334
  reset.addEventListener("click", () => {
334
- engine.setProperty(row.ref, row.default);
335
+ engine.setProperty(row2.ref, row2.default);
335
336
  read();
336
337
  syncReset();
337
338
  });
338
- if (row.type === "boolean") {
339
+ if (row2.type === "boolean") {
339
340
  const cb = doc.createElement("input");
340
341
  cb.type = "checkbox";
341
342
  cb.addEventListener("change", () => commit(cb.checked));
342
343
  ctl.appendChild(cb);
343
344
  read = () => {
344
- if (!focused(cb)) cb.checked = engine.getProperty(row.ref) === true;
345
+ if (!focused(cb)) cb.checked = engine.getProperty(row2.ref) === true;
345
346
  };
346
- } else if (row.type === "number") {
347
+ } else if (row2.type === "number") {
347
348
  const inp = doc.createElement("input");
348
349
  inp.type = "number";
349
350
  inp.addEventListener("change", () => commit(Number(inp.value)));
350
351
  ctl.appendChild(inp);
351
352
  read = () => {
352
- if (!focused(inp)) inp.value = String(engine.getProperty(row.ref) ?? "");
353
+ if (!focused(inp)) inp.value = String(engine.getProperty(row2.ref) ?? "");
353
354
  };
354
- } else if (row.type === "enum") {
355
+ } else if (row2.type === "enum") {
355
356
  const sel = doc.createElement("select");
356
- for (const v of row.values ?? []) {
357
+ for (const v of row2.values ?? []) {
357
358
  const o = doc.createElement("option");
358
359
  o.value = v;
359
360
  o.textContent = v;
@@ -362,9 +363,9 @@ function createPropertyInspector(engine, opts = {}) {
362
363
  sel.addEventListener("change", () => commit(sel.value));
363
364
  ctl.appendChild(sel);
364
365
  read = () => {
365
- if (!focused(sel)) sel.value = String(engine.getProperty(row.ref) ?? "");
366
+ if (!focused(sel)) sel.value = String(engine.getProperty(row2.ref) ?? "");
366
367
  };
367
- } else if (row.type === "flags") {
368
+ } else if (row2.type === "flags") {
368
369
  const inp = doc.createElement("input");
369
370
  inp.type = "text";
370
371
  inp.placeholder = "comma, separated, flags";
@@ -372,7 +373,7 @@ function createPropertyInspector(engine, opts = {}) {
372
373
  ctl.appendChild(inp);
373
374
  read = () => {
374
375
  if (!focused(inp)) {
375
- const v = engine.getProperty(row.ref);
376
+ const v = engine.getProperty(row2.ref);
376
377
  inp.value = Array.isArray(v) ? v.join(", ") : "";
377
378
  }
378
379
  };
@@ -382,7 +383,7 @@ function createPropertyInspector(engine, opts = {}) {
382
383
  inp.addEventListener("change", () => commit(inp.value));
383
384
  ctl.appendChild(inp);
384
385
  read = () => {
385
- if (!focused(inp)) inp.value = String(engine.getProperty(row.ref) ?? "");
386
+ if (!focused(inp)) inp.value = String(engine.getProperty(row2.ref) ?? "");
386
387
  };
387
388
  }
388
389
  read();
@@ -399,21 +400,165 @@ function createPropertyInspector(engine, opts = {}) {
399
400
  empty.textContent = "No @patter properties.";
400
401
  list.appendChild(empty);
401
402
  } else {
402
- for (const row of props) buildRow(row);
403
+ for (const row2 of props) buildRow(row2);
403
404
  }
404
405
  const refresh = () => {
405
406
  for (const fn of rowRefreshers) fn();
406
407
  };
407
- opts.container?.appendChild(el);
408
+ opts.container?.appendChild(el2);
408
409
  const pollMs = opts.pollMs ?? 250;
409
410
  let timer;
410
411
  if (pollMs > 0) timer = setInterval(refresh, pollMs);
411
412
  return {
412
- el,
413
+ el: el2,
413
414
  refresh,
414
415
  destroy() {
415
416
  if (timer !== void 0) clearInterval(timer);
416
- el.remove();
417
+ el2.remove();
418
+ }
419
+ };
420
+ }
421
+
422
+ // src/bundle-inspector.ts
423
+ var import_runtime2 = require("@patterkit/runtime");
424
+ var STYLE_ID2 = "pp-bundle-style";
425
+ var CSS2 = `
426
+ .pp-bundle{font:13px/1.4 ui-sans-serif,system-ui,sans-serif;color:#15201e;background:#f4efe6;border:1px solid #cfc7b8;border-radius:10px;padding:.6rem .7rem;max-width:26rem;box-shadow:0 6px 20px rgba(21,32,30,.12)}
427
+ .pp-bundle h4{margin:0 0 .4rem;font:600 .72rem/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.08em;text-transform:uppercase;color:#5c6b62}
428
+ .pp-bundle details{border-top:1px solid #e3dccf;padding:.3rem 0}
429
+ .pp-bundle details:first-of-type{border-top:0}
430
+ .pp-bundle summary{cursor:pointer;font-weight:600;color:#214f4b;list-style:none}
431
+ .pp-bundle summary::-webkit-details-marker{display:none}
432
+ .pp-bundle summary::before{content:"\\25B8";display:inline-block;width:1rem;color:#8a9691}
433
+ .pp-bundle details[open]>summary::before{content:"\\25BE"}
434
+ .pp-bundle-count{color:#8a9691;font-weight:400}
435
+ .pp-bundle-row{display:flex;gap:.4rem;margin:.16rem 0 .16rem 1rem}
436
+ .pp-bundle-key{flex:0 0 8rem;color:#5c6b62}
437
+ .pp-bundle-val,.pp-bundle-addr{font-family:ui-monospace,monospace;font-size:.78rem;color:#214f4b;word-break:break-all}
438
+ .pp-bundle-sub{margin-left:2rem}
439
+ .pp-bundle-empty{color:#8a9691;font-style:italic;margin-left:1rem}
440
+ .pp-bundle-warn{color:#8a3a2f;font-weight:600}
441
+ .pp-bundle-tag{font-size:.7rem;color:#5c6b62;border:1px solid #cfc7b8;border-radius:5px;padding:0 .25rem;margin-left:.3rem}
442
+ `;
443
+ function injectStyle2(doc) {
444
+ if (doc.getElementById(STYLE_ID2)) return;
445
+ const s = doc.createElement("style");
446
+ s.id = STYLE_ID2;
447
+ s.textContent = CSS2;
448
+ (doc.head ?? doc.documentElement).appendChild(s);
449
+ }
450
+ var el = (doc, tag, cls, text) => {
451
+ const e = doc.createElement(tag);
452
+ if (cls) e.className = cls;
453
+ if (text !== void 0) e.textContent = text;
454
+ return e;
455
+ };
456
+ function row(doc, key, value, valueClass = "pp-bundle-val") {
457
+ const r = el(doc, "div", "pp-bundle-row");
458
+ r.append(el(doc, "span", "pp-bundle-key", key), el(doc, "span", valueClass, value));
459
+ return r;
460
+ }
461
+ function propertyRow(doc, p) {
462
+ const r = el(doc, "div", "pp-bundle-row");
463
+ r.append(el(doc, "span", "pp-bundle-key pp-bundle-addr", p.name));
464
+ const meta = el(doc, "span", "pp-bundle-val", p.type);
465
+ if (!p.hasDefault) meta.append(el(doc, "span", "pp-bundle-tag", "no default"));
466
+ r.append(meta);
467
+ return r;
468
+ }
469
+ function section(doc, name, label, count, open) {
470
+ const d = doc.createElement("details");
471
+ d.dataset["section"] = name;
472
+ d.open = open;
473
+ const s = doc.createElement("summary");
474
+ s.textContent = label;
475
+ if (count !== null) s.append(el(doc, "span", "pp-bundle-count", ` ${count}`));
476
+ d.append(s);
477
+ return d;
478
+ }
479
+ function createBundleInspector(bundle, opts = {}) {
480
+ const doc = opts.container?.ownerDocument ?? document;
481
+ injectStyle2(doc);
482
+ const d = (0, import_runtime2.describeBundle)(bundle);
483
+ const open = new Set(opts.open ?? ["identity", "addresses"]);
484
+ const root = el(doc, "div", "pp-bundle");
485
+ root.append(el(doc, "h4", void 0, opts.title ?? "Bundle"));
486
+ const id = section(doc, "identity", d.identity.project || "(unnamed project)", null, open.has("identity"));
487
+ if (d.identity.version) id.append(row(doc, "version", d.identity.version));
488
+ id.append(row(doc, "schema", d.identity.schema));
489
+ id.append(row(doc, "locales", `${d.identity.defaultLocale}${d.identity.locales.length > 1 ? ` (+${d.identity.locales.length - 1})` : ""}`));
490
+ id.append(row(doc, "strings", d.identity.localisation));
491
+ if (d.identity.voiced) id.append(row(doc, "voiced", "yes"));
492
+ if (d.identity.hash) id.append(row(doc, "hash", d.identity.hash));
493
+ if (d.identity.structureHash) id.append(row(doc, "structure", d.identity.structureHash));
494
+ if (d.identity.sourceDebug) {
495
+ id.append(row(doc, "build", "SOURCE DEBUG - not shippable", "pp-bundle-val pp-bundle-warn"));
496
+ }
497
+ root.append(id);
498
+ const addr = section(doc, "addresses", "Addresses", d.addresses.length, open.has("addresses"));
499
+ if (!d.addresses.length) addr.append(el(doc, "div", "pp-bundle-empty", "no scenes"));
500
+ for (const a of d.addresses) {
501
+ const r = el(doc, "div", "pp-bundle-row");
502
+ r.append(el(doc, "span", "pp-bundle-key pp-bundle-addr", a.gameId), el(doc, "span", "pp-bundle-val", a.name));
503
+ addr.append(r);
504
+ for (const b of a.blocks) {
505
+ const br = el(doc, "div", "pp-bundle-row pp-bundle-sub");
506
+ br.append(el(doc, "span", "pp-bundle-key pp-bundle-addr", b.gameId), el(doc, "span", "pp-bundle-val", b.name));
507
+ addr.append(br);
508
+ }
509
+ }
510
+ root.append(addr);
511
+ const hostCount = d.hostScopes.reduce((n, s) => n + s.properties.length, 0);
512
+ const host = section(doc, "hostScopes", "Host properties", hostCount, open.has("hostScopes"));
513
+ if (!d.hostScopes.length) host.append(el(doc, "div", "pp-bundle-empty", "the game supplies nothing"));
514
+ for (const s of d.hostScopes) {
515
+ const head = el(doc, "div", "pp-bundle-row");
516
+ head.append(el(doc, "span", "pp-bundle-key pp-bundle-addr", `@${s.token}`));
517
+ const meta = el(doc, "span", "pp-bundle-val", s.opaque ? "any name, unchecked" : `${s.properties.length} declared`);
518
+ if (!s.writable) meta.append(el(doc, "span", "pp-bundle-tag", "read-only"));
519
+ head.append(meta);
520
+ host.append(head);
521
+ for (const p of s.properties) {
522
+ const pr = propertyRow(doc, p);
523
+ pr.classList.add("pp-bundle-sub");
524
+ host.append(pr);
525
+ }
526
+ }
527
+ root.append(host);
528
+ const ownedCount = d.properties.patter.length + d.properties.scene.reduce((n, s) => n + s.properties.length, 0);
529
+ const owned = section(doc, "properties", "Story properties", ownedCount, open.has("properties"));
530
+ if (!ownedCount) owned.append(el(doc, "div", "pp-bundle-empty", "none declared"));
531
+ for (const p of d.properties.patter) owned.append(propertyRow(doc, p));
532
+ for (const s of d.properties.scene) {
533
+ owned.append(row(doc, `@scene`, s.gameId, "pp-bundle-val pp-bundle-addr"));
534
+ for (const p of s.properties) {
535
+ const pr = propertyRow(doc, p);
536
+ pr.classList.add("pp-bundle-sub");
537
+ owned.append(pr);
538
+ }
539
+ }
540
+ root.append(owned);
541
+ if (d.gameData.length) {
542
+ const gd2 = section(doc, "gameData", "Game data", d.gameData.reduce((n, g) => n + g.fields.length, 0), open.has("gameData"));
543
+ for (const g of d.gameData) {
544
+ gd2.append(row(doc, "on", g.kind));
545
+ for (const f of g.fields) {
546
+ const fr = el(doc, "div", "pp-bundle-row pp-bundle-sub");
547
+ fr.append(el(doc, "span", "pp-bundle-key pp-bundle-addr", f.name), el(doc, "span", "pp-bundle-val", f.type));
548
+ gd2.append(fr);
549
+ }
550
+ }
551
+ root.append(gd2);
552
+ }
553
+ const counts = section(doc, "counts", "Counts", null, open.has("counts"));
554
+ for (const [key, value] of Object.entries(d.counts)) counts.append(row(doc, key, String(value)));
555
+ root.append(counts);
556
+ opts.container?.append(root);
557
+ return {
558
+ el: root,
559
+ description: d,
560
+ destroy() {
561
+ root.remove();
417
562
  }
418
563
  };
419
564
  }
@@ -436,6 +581,7 @@ function createAudioResolver(manifestJson, basePath) {
436
581
  SAVE_SCHEMA,
437
582
  applyLiveBundle,
438
583
  createAudioResolver,
584
+ createBundleInspector,
439
585
  createDebugLink,
440
586
  createPropertyInspector,
441
587
  createStateLogger,
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { SaveGame, Engine, StepResult, Bundle } from '@patterkit/runtime';
1
+ import { SaveGame, Engine, StepResult, Bundle as Bundle$1, BundleDescription } from '@patterkit/runtime';
2
2
 
3
3
  declare const SAVE_SCHEMA = "patter/save@0";
4
4
  interface SaveEnvelope {
@@ -107,7 +107,7 @@ interface LiveBundleResult {
107
107
  /** The engine to keep using: the same instance for a "text" swap, a replacement for "structure". */
108
108
  engine: Engine;
109
109
  /** The parsed pushed bundle - hold on to it for the next apply's comparison. */
110
- bundle: Bundle;
110
+ bundle: Bundle$1;
111
111
  /** Which tier applied: "text" (strings-only, nothing restarted) or "structure" (full hot swap). */
112
112
  kind: "text" | "structure";
113
113
  }
@@ -117,7 +117,7 @@ interface LiveBundleResult {
117
117
  * unparseable JSON - a structural swap's edge cases are absorbed by the §9.8 drift policy inside
118
118
  * `hotSwap`, which never throws for ordinary edits.
119
119
  */
120
- declare function applyLiveBundle(engine: Engine, current: Bundle, data: string): LiveBundleResult;
120
+ declare function applyLiveBundle(engine: Engine, current: Bundle$1, data: string): LiveBundleResult;
121
121
 
122
122
  interface PropertyInspectorOptions {
123
123
  /** Where to mount the panel. If omitted, append the returned `el` yourself. */
@@ -137,6 +137,341 @@ interface PropertyInspector {
137
137
  }
138
138
  declare function createPropertyInspector(engine: Engine, opts?: PropertyInspectorOptions): PropertyInspector;
139
139
 
140
+ type BinaryOp = "==" | "!=" | ">" | ">=" | "<" | "<=" | "+" | "-" | "*" | "/" | "and" | "or";
141
+ type UnaryOp = "not" | "neg";
142
+ type AstNode = ["b", boolean] | ["n", number] | ["s", string] | ["sv", string, string] | ["u", UnaryOp, AstNode] | ["bin", BinaryOp, AstNode, AstNode] | ["call", string, ...AstNode[]] | ["fd", "+" | "-", string];
143
+
144
+ type ScalarValue = boolean | number | string | string[];
145
+ /** Developer-defined host metadata (spec §17). Opaque to Patter. */
146
+ type GameData = Record<string, unknown>;
147
+ /** A scene/block id, or the reserved "END". */
148
+ type JumpTarget = string;
149
+ interface Jump {
150
+ to: JumpTarget;
151
+ /** "jump" (one-way, default) or "call" (jump-and-return via the flow callstack). */
152
+ mode?: "jump" | "call";
153
+ }
154
+ interface LineBeat {
155
+ id: string;
156
+ kind: "line";
157
+ /** Speaker; must be a member of the project cast (validated). */
158
+ character?: string;
159
+ /** Performance direction, language-neutral (never localised). */
160
+ direction?: string;
161
+ gameData?: GameData;
162
+ /** Author-defined freeform tags (#215): a cross-cutting label layer that travels to the runtime.
163
+ * At runtime a beat's tags are the UNION of its own and every ancestor's (scene → block → group(s) →
164
+ * snippet → beat). Each tag is letters/digits/symbols with NO comma and NO whitespace; deduped. */
165
+ tags?: string[];
166
+ }
167
+ /**
168
+ * Authorial voice / narration - speaker-less prose (spec §2). Never voiced; its
169
+ * localised text always permits inline `{@name}` interpolation (spec §16). This
170
+ * is the on-screen-text role (e.g. "A door slams!") - distinct from a game-event
171
+ * beat, which is a pure engine instruction with no localised content.
172
+ */
173
+ interface TextBeat {
174
+ id: string;
175
+ kind: "text";
176
+ gameData?: GameData;
177
+ /** Author tags (#215). See LineBeat.tags. */
178
+ tags?: string[];
179
+ }
180
+ /**
181
+ * A GAME EVENT (spec §2): a pure engine instruction with no player-facing text - just `gameData` the host
182
+ * reads when the beat plays (comments / docs attach via the authoring file by id). Named "game event"
183
+ * rather than "action" because a screenplay's "action" is prose, which is our text beat. Player-facing
184
+ * words are a line or text beat instead.
185
+ */
186
+ interface GameEventBeat {
187
+ id: string;
188
+ kind: "gameEvent";
189
+ gameData?: GameData;
190
+ /** Author tags (#215). See LineBeat.tags. */
191
+ tags?: string[];
192
+ }
193
+ type Beat = LineBeat | TextBeat | GameEventBeat;
194
+ type Selector = "run" | "branch" | "sequence" | "choice";
195
+ /** How a `sequence` walks its children. `specificity` = **Best match**: pick the eligible
196
+ * child whose condition most specifically fits the current state (the most atomic constraints
197
+ * actively holding it true); equally-specific ties break by the seeded shuffle. A child with no
198
+ * condition scores zero, so it acts as the filler that wins only when nothing more specific is
199
+ * eligible. Composes with `exhaust`: `repeat` re-scores every visit (re-pickable, the Best-match
200
+ * default), `once` uses each pick up so the group slides down to the filler (graceful degradation). */
201
+ type SelectorOrder = "sequential" | "shuffle" | "specificity";
202
+ /** What a `sequence` does after one full pass through its children. */
203
+ type SelectorExhaust = "once" | "repeat" | "stick";
204
+ /**
205
+ * `sequence` selector config (spec §4). One stateful picker with two orthogonal
206
+ * axes subsumes Ink's stopping / cycle / once / shuffle and their combinations.
207
+ * Defaults: `order: "sequential"`, `exhaust: "once"`. `shuffle` draws without
208
+ * replacement and never repeats a line back-to-back (built in).
209
+ */
210
+ interface SequenceOptions {
211
+ order?: SelectorOrder;
212
+ exhaust?: SelectorExhaust;
213
+ }
214
+ /** An option's prompt beat (spec §5): a single line | text beat - the choice text. */
215
+ type PromptBeat = LineBeat | TextBeat;
216
+ type PropertyType = "boolean" | "number" | "string" | "flags" | "enum";
217
+ interface PropertyDecl {
218
+ name: string;
219
+ type: PropertyType;
220
+ default?: ScalarValue;
221
+ /**
222
+ * The orthogonal *sharing* axis (spec §7): is this property's value shared across
223
+ * all flows (one world value) or kept per-flow? It does NOT change the reference
224
+ * syntax - sharing is set here, on the declaration, not by a different scope token.
225
+ * The default depends on the scope it is declared in: a **global** property
226
+ * (project `properties` -> `@patter`) defaults to **shared**; a **scene-local**
227
+ * property (scene `sceneProps` -> `@scene`) defaults to **not shared** (per-flow).
228
+ */
229
+ shared?: boolean;
230
+ /**
231
+ * Persistence axis, for **scene-local (`@scene`) properties** (spec §7). Default
232
+ * `false`: the value PERSISTS across scene re-entries (like every other property).
233
+ * `true`: the value is **reseeded to its default on every scene entry** - "fresh
234
+ * each playthrough" (Ink's `temp`). Orthogonal to `shared`. Ignored on global
235
+ * (`@patter`) properties, which always persist for the life of the piece.
236
+ */
237
+ temporary?: boolean;
238
+ /** For enum / flags. */
239
+ values?: string[];
240
+ /** Free-text author note documenting what this property is for (authoring only; shown as a hint). */
241
+ purpose?: string;
242
+ }
243
+ /**
244
+ * A property of a host / world scope (`@world`, `@game`, ...). The same shape the
245
+ * `@wildwinter/scoperegistry` `scopeRegistrySpec` uses, declared structurally here
246
+ * so the model stays free of a runtime dependency. `default` seeds the standalone
247
+ * runtime's self-backed bag (see `HostScopeSpec`); `writable: false` makes the
248
+ * property read-only to the story (validated at compile time).
249
+ */
250
+ interface HostScopeDecl {
251
+ name: string;
252
+ type: PropertyType;
253
+ values?: string[];
254
+ default?: ScalarValue;
255
+ writable?: boolean;
256
+ /** Free-text author note documenting the property (authoring only; shown as a hint). */
257
+ purpose?: string;
258
+ }
259
+ /** One scope (`token`) in a project's host-scope registry. */
260
+ interface HostScopeSpec {
261
+ /** The scope token after `@` (e.g. `"world"`). Must not collide with Patter's own
262
+ * `patter` / `scene` / `flow`. */
263
+ token: string;
264
+ /** Scope-level read/write default for its declarations (default true). */
265
+ writable?: boolean;
266
+ /** Property declarations; omit for an opaque scope (any name, unchecked). */
267
+ declarations?: HostScopeDecl[];
268
+ }
269
+ /**
270
+ * A project's host-scope registry: the `scopeRegistrySpec` it OWNS (spec
271
+ * design/scope-registry.md §6). Makes `@world` (and any host scope) first-class
272
+ * in a standalone project: the compiler validates references into it, the runtime
273
+ * self-backs it from declaration defaults when no host resolver claims the token,
274
+ * and coverage drives its values. Structurally identical to scoperegistry's
275
+ * `ScopeRegistrySpec` so it threads straight through the compiler.
276
+ */
277
+ interface HostScopeRegistry {
278
+ version: number;
279
+ scopes: HostScopeSpec[];
280
+ }
281
+ /** A character's grammatical gender, for localisation. Translators need it to inflect the speaker's own
282
+ * lines in gendered languages (adjectives, participles, pronouns), which the source text alone often
283
+ * cannot tell them. Absent means "not specified". Authoring-only: it never reaches the runtime bundle,
284
+ * but it IS carried into the localisation handoff formats as translator context (spec §14).
285
+ *
286
+ * Free text, not a closed set: real languages need more than three genders (common/utrum, animate/
287
+ * inanimate, and so on) and translators name them differently. `COMMON_GENDERS` seeds the editor's
288
+ * auto-suggest so the everyday values stay spelled consistently; anything else is still valid. */
289
+ type GrammaticalGender = string;
290
+ interface CastMember {
291
+ /** Canonical speaker name (matched by a beat's `character`); language-neutral key. */
292
+ name: string;
293
+ /** Localised player-facing name - a localisation id (project-level strings). */
294
+ displayName?: string;
295
+ /** Grammatical gender for translators (see `GrammaticalGender`); absent = not specified. */
296
+ gender?: GrammaticalGender;
297
+ /** Free-text production notes about the character (casting, voice, intent) - authoring only. */
298
+ notes?: string;
299
+ /** The voice actor cast for this character, if known - surfaced in the VO script export (spec §16).
300
+ * Authoring-only (not shipped in the runtime bundle). */
301
+ actor?: string;
302
+ gameData?: GameData;
303
+ }
304
+ /** The cast as it reaches a compiled bundle: the player-facing fields only. `notes`, `actor` and
305
+ * `gender` are authoring / production / translation context, and the compiler drops them, so a shipped
306
+ * game never carries a real person's name or a writer's private notes. The compiler copies the shipping
307
+ * fields across explicitly (an allow-list), which is what actually keeps a new authoring field out of
308
+ * the bundle; this type states the resulting contract for anyone reading a bundle. */
309
+ type BundleCastMember = Omit<CastMember, "notes" | "actor" | "gender">;
310
+ /** A node TYPE that can carry author-defined gameData fields (the gameData schema, project-level).
311
+ * Beat kinds map straight through: dialogue = `line`, narration = `text`, game event = `gameEvent`. */
312
+ type GameDataNodeKind = "scene" | "block" | "snippet" | "line" | "text" | "gameEvent";
313
+ /** The value type of a gameData field - the property-type vocabulary plus a multiline-text variant.
314
+ * Drives the inspector's editor widget (text / textarea / number / toggle / enum dropdown). */
315
+ type GameDataFieldType = "text" | "multiline" | "number" | "boolean" | "enum";
316
+ /** One author-defined custom field on a node type (host-integration metadata, NOT expression state). */
317
+ interface GameDataField {
318
+ /** Field key - also the key a node stores its value under in `gameData` (values are name-keyed). */
319
+ name: string;
320
+ type: GameDataFieldType;
321
+ /** The value used when a node sets nothing. Storage is SPARSE - nodes hold only their overrides, and
322
+ * a reader falls back to this default (so changing it here propagates to every node that didn't set it). */
323
+ default?: ScalarValue;
324
+ /** Allowed values when `type` is "enum". */
325
+ values?: string[];
326
+ /** Free-text description of what the field is for - shown as a rollover hint in the inspector. */
327
+ purpose?: string;
328
+ }
329
+ /** Author-defined gameData fields, grouped by the node type they attach to (project-level schema). */
330
+ type GameDataFields = Partial<Record<GameDataNodeKind, GameDataField[]>>;
331
+ /** A compiled expression envelope: canonical source + pre-derived tagged-tuple AST. */
332
+ interface Expression {
333
+ src: string;
334
+ ast: AstNode;
335
+ }
336
+ type CompiledEffect = {
337
+ kind: "set";
338
+ target: string;
339
+ value: Expression;
340
+ };
341
+ interface CompiledSnippet {
342
+ id: string;
343
+ type: "snippet";
344
+ condition?: Expression;
345
+ beats?: Beat[];
346
+ onEnter?: CompiledEffect[];
347
+ onExit?: CompiledEffect[];
348
+ gameData?: GameData;
349
+ tags?: string[];
350
+ jump?: Jump;
351
+ secretUntilEligible?: boolean;
352
+ /** Option-position: repeatable (spec §5). Default false = once-only. */
353
+ sticky?: boolean;
354
+ /** Option-position: the choice's fallback, auto-followed when last (spec §5). */
355
+ fallback?: boolean;
356
+ }
357
+ interface CompiledGroup {
358
+ id: string;
359
+ type: "group";
360
+ condition?: Expression;
361
+ /** Default (omitted) = `"run"`. */
362
+ selector?: Selector;
363
+ /** Selector cursor shared across flows (default false = per-flow). */
364
+ shared?: boolean;
365
+ options?: SequenceOptions;
366
+ children: Array<CompiledGroup | CompiledSnippet>;
367
+ gameData?: GameData;
368
+ tags?: string[];
369
+ /** Option-position fields (spec §5) - only when a direct child of a `choice`. */
370
+ prompt?: PromptBeat;
371
+ secretUntilEligible?: boolean;
372
+ /** Repeatable (spec §5). Default false = once-only. */
373
+ sticky?: boolean;
374
+ /** The choice's fallback, auto-followed when last (spec §5). */
375
+ fallback?: boolean;
376
+ }
377
+ interface CompiledBlock {
378
+ id: string;
379
+ type: "block";
380
+ name: string;
381
+ /** Host-facing address (spec §6); the runtime resolves it to `id`. Absent = derived from `name`. */
382
+ gameId?: string;
383
+ children: Array<CompiledGroup | CompiledSnippet>;
384
+ gameData?: GameData;
385
+ tags?: string[];
386
+ }
387
+ interface CompiledScene {
388
+ id: string;
389
+ type: "scene";
390
+ name: string;
391
+ /** Host-facing address (spec §6); the runtime resolves it to `id`. Absent = derived from `name`. */
392
+ gameId?: string;
393
+ gameData?: GameData;
394
+ tags?: string[];
395
+ onEntry?: CompiledEffect[];
396
+ sceneProps?: PropertyDecl[];
397
+ blocks: CompiledBlock[];
398
+ }
399
+ interface Bundle {
400
+ schema: string;
401
+ /** `hash` fingerprints the WHOLE bundle (binds saves, gates staleness); `structureHash` is the same
402
+ * fingerprint with the string tables left out, so same structureHash + a different hash = a
403
+ * text-only edit, safe to hot-swap in place (live bundle refresh). */
404
+ content: {
405
+ project: string;
406
+ version?: string;
407
+ hash?: string;
408
+ structureHash?: string;
409
+ };
410
+ voiced: boolean;
411
+ locales: {
412
+ default: string;
413
+ included: string[];
414
+ };
415
+ /** Player-facing cast only: the compiler strips notes / actor / gender (see `BundleCastMember`). */
416
+ cast?: BundleCastMember[];
417
+ properties?: PropertyDecl[];
418
+ /** Host / world scope declarations, baked from the project so the runtime can self-back a declared
419
+ * scope (`@world`, ...) when no host resolver claims its token. Absent = no host scopes. */
420
+ scopeRegistry?: HostScopeRegistry;
421
+ gameDataFields?: GameDataFields;
422
+ scenes: Record<string, CompiledScene>;
423
+ /** locale -> (beatId -> text). In "embedded" localisation this carries every included locale; in "ids"
424
+ * it is EMPTY (the runtime emits beat IDs), unless `localisation.sourceDebug` embedded the source locale
425
+ * for debug playback. `content.hash` is computed over the FULL strings regardless, so the staleness gate
426
+ * is unaffected. */
427
+ strings: Record<string, Record<string, string>>;
428
+ /** How strings ship + resolve (spec §11). Absent = "embedded" (back-compat default): the runtime resolves
429
+ * `strings` per locale. "ids": the runtime emits beat IDs and the game localises them itself; `sourceDebug`
430
+ * means the source locale is embedded purely for debug playback and the runtime should flag the build as
431
+ * not shippable. */
432
+ localisation?: {
433
+ mode: "embedded" | "ids";
434
+ sourceDebug?: boolean;
435
+ };
436
+ /** Closed-caption delimiters baked from the project (#214). Absent = the default `(` / `)`; the
437
+ * runtime strips spans between them from line text when a game disables captions. */
438
+ closedCaptions?: CaptionDelimiters;
439
+ }
440
+ /** Closed-caption configuration (#214). `open`/`close` wrap a caption cue inside a dialogue line (both
441
+ * non-empty; they MAY be the same token, e.g. `*…*`). `character` names a cast member whose lines are a
442
+ * pure caption: when captions are off, ALL of that character's dialogue (and its speaker label) is
443
+ * omitted - delimiters or not - leaving a silent line that still fires (so audio plays). Absent / empty
444
+ * `character` resolves to the default `SFX` (you "disable" it simply by never using that speaker). */
445
+ interface CaptionDelimiters {
446
+ open: string;
447
+ close: string;
448
+ character?: string;
449
+ }
450
+
451
+ interface BundleInspectorOptions {
452
+ /** Where to mount the panel. If omitted, append the returned `el` yourself. */
453
+ container?: HTMLElement;
454
+ /** Panel heading. Default "Bundle". */
455
+ title?: string;
456
+ /** Sections open on mount. Default: identity and addresses, the two an
457
+ * integrator reaches for first; the rest are a click away. */
458
+ open?: BundleSection[];
459
+ }
460
+ type BundleSection = "identity" | "addresses" | "hostScopes" | "properties" | "gameData" | "counts";
461
+ interface BundleInspector {
462
+ /** The panel root (already inside `container` if you passed one). */
463
+ readonly el: HTMLElement;
464
+ /** The description the panel is showing, for a caller that wants the data too. */
465
+ readonly description: BundleDescription;
466
+ /** Remove the panel from the DOM. */
467
+ destroy(): void;
468
+ }
469
+ /**
470
+ * Build a read-only panel describing a compiled bundle: what it is, what game
471
+ * code may call on it, and what the host must supply.
472
+ */
473
+ declare function createBundleInspector(bundle: Bundle, opts?: BundleInspectorOptions): BundleInspector;
474
+
140
475
  interface AudioResolver {
141
476
  /** The full path/URL of a beat's winning audio take, or null when it has none. */
142
477
  resolve(beatId: string): string | null;
@@ -148,4 +483,4 @@ interface AudioResolver {
148
483
  */
149
484
  declare function createAudioResolver(manifestJson: string, basePath: string): AudioResolver;
150
485
 
151
- export { type AudioResolver, type DebugLink, type DebugLinkOptions, type DebugSocketLike, type LiveBundleResult, type PropertyInspector, type PropertyInspectorOptions, type PropertyValue, SAVE_SCHEMA, type SaveEnvelope, type StateChange, type StateLogger, type StateLoggerOptions, type StateSnapshot, type StateValue, applyLiveBundle, createAudioResolver, createDebugLink, createPropertyInspector, createStateLogger, deserializeState, diffState, getProperty, loadState, saveState, serializeState, setProperties, setProperty, snapshotState };
486
+ export { type AudioResolver, type BundleInspector, type BundleInspectorOptions, type BundleSection, type DebugLink, type DebugLinkOptions, type DebugSocketLike, type LiveBundleResult, type PropertyInspector, type PropertyInspectorOptions, type PropertyValue, SAVE_SCHEMA, type SaveEnvelope, type StateChange, type StateLogger, type StateLoggerOptions, type StateSnapshot, type StateValue, applyLiveBundle, createAudioResolver, createBundleInspector, createDebugLink, createPropertyInspector, createStateLogger, deserializeState, diffState, getProperty, loadState, saveState, serializeState, setProperties, setProperty, snapshotState };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { SaveGame, Engine, StepResult, Bundle } from '@patterkit/runtime';
1
+ import { SaveGame, Engine, StepResult, Bundle as Bundle$1, BundleDescription } from '@patterkit/runtime';
2
2
 
3
3
  declare const SAVE_SCHEMA = "patter/save@0";
4
4
  interface SaveEnvelope {
@@ -107,7 +107,7 @@ interface LiveBundleResult {
107
107
  /** The engine to keep using: the same instance for a "text" swap, a replacement for "structure". */
108
108
  engine: Engine;
109
109
  /** The parsed pushed bundle - hold on to it for the next apply's comparison. */
110
- bundle: Bundle;
110
+ bundle: Bundle$1;
111
111
  /** Which tier applied: "text" (strings-only, nothing restarted) or "structure" (full hot swap). */
112
112
  kind: "text" | "structure";
113
113
  }
@@ -117,7 +117,7 @@ interface LiveBundleResult {
117
117
  * unparseable JSON - a structural swap's edge cases are absorbed by the §9.8 drift policy inside
118
118
  * `hotSwap`, which never throws for ordinary edits.
119
119
  */
120
- declare function applyLiveBundle(engine: Engine, current: Bundle, data: string): LiveBundleResult;
120
+ declare function applyLiveBundle(engine: Engine, current: Bundle$1, data: string): LiveBundleResult;
121
121
 
122
122
  interface PropertyInspectorOptions {
123
123
  /** Where to mount the panel. If omitted, append the returned `el` yourself. */
@@ -137,6 +137,341 @@ interface PropertyInspector {
137
137
  }
138
138
  declare function createPropertyInspector(engine: Engine, opts?: PropertyInspectorOptions): PropertyInspector;
139
139
 
140
+ type BinaryOp = "==" | "!=" | ">" | ">=" | "<" | "<=" | "+" | "-" | "*" | "/" | "and" | "or";
141
+ type UnaryOp = "not" | "neg";
142
+ type AstNode = ["b", boolean] | ["n", number] | ["s", string] | ["sv", string, string] | ["u", UnaryOp, AstNode] | ["bin", BinaryOp, AstNode, AstNode] | ["call", string, ...AstNode[]] | ["fd", "+" | "-", string];
143
+
144
+ type ScalarValue = boolean | number | string | string[];
145
+ /** Developer-defined host metadata (spec §17). Opaque to Patter. */
146
+ type GameData = Record<string, unknown>;
147
+ /** A scene/block id, or the reserved "END". */
148
+ type JumpTarget = string;
149
+ interface Jump {
150
+ to: JumpTarget;
151
+ /** "jump" (one-way, default) or "call" (jump-and-return via the flow callstack). */
152
+ mode?: "jump" | "call";
153
+ }
154
+ interface LineBeat {
155
+ id: string;
156
+ kind: "line";
157
+ /** Speaker; must be a member of the project cast (validated). */
158
+ character?: string;
159
+ /** Performance direction, language-neutral (never localised). */
160
+ direction?: string;
161
+ gameData?: GameData;
162
+ /** Author-defined freeform tags (#215): a cross-cutting label layer that travels to the runtime.
163
+ * At runtime a beat's tags are the UNION of its own and every ancestor's (scene → block → group(s) →
164
+ * snippet → beat). Each tag is letters/digits/symbols with NO comma and NO whitespace; deduped. */
165
+ tags?: string[];
166
+ }
167
+ /**
168
+ * Authorial voice / narration - speaker-less prose (spec §2). Never voiced; its
169
+ * localised text always permits inline `{@name}` interpolation (spec §16). This
170
+ * is the on-screen-text role (e.g. "A door slams!") - distinct from a game-event
171
+ * beat, which is a pure engine instruction with no localised content.
172
+ */
173
+ interface TextBeat {
174
+ id: string;
175
+ kind: "text";
176
+ gameData?: GameData;
177
+ /** Author tags (#215). See LineBeat.tags. */
178
+ tags?: string[];
179
+ }
180
+ /**
181
+ * A GAME EVENT (spec §2): a pure engine instruction with no player-facing text - just `gameData` the host
182
+ * reads when the beat plays (comments / docs attach via the authoring file by id). Named "game event"
183
+ * rather than "action" because a screenplay's "action" is prose, which is our text beat. Player-facing
184
+ * words are a line or text beat instead.
185
+ */
186
+ interface GameEventBeat {
187
+ id: string;
188
+ kind: "gameEvent";
189
+ gameData?: GameData;
190
+ /** Author tags (#215). See LineBeat.tags. */
191
+ tags?: string[];
192
+ }
193
+ type Beat = LineBeat | TextBeat | GameEventBeat;
194
+ type Selector = "run" | "branch" | "sequence" | "choice";
195
+ /** How a `sequence` walks its children. `specificity` = **Best match**: pick the eligible
196
+ * child whose condition most specifically fits the current state (the most atomic constraints
197
+ * actively holding it true); equally-specific ties break by the seeded shuffle. A child with no
198
+ * condition scores zero, so it acts as the filler that wins only when nothing more specific is
199
+ * eligible. Composes with `exhaust`: `repeat` re-scores every visit (re-pickable, the Best-match
200
+ * default), `once` uses each pick up so the group slides down to the filler (graceful degradation). */
201
+ type SelectorOrder = "sequential" | "shuffle" | "specificity";
202
+ /** What a `sequence` does after one full pass through its children. */
203
+ type SelectorExhaust = "once" | "repeat" | "stick";
204
+ /**
205
+ * `sequence` selector config (spec §4). One stateful picker with two orthogonal
206
+ * axes subsumes Ink's stopping / cycle / once / shuffle and their combinations.
207
+ * Defaults: `order: "sequential"`, `exhaust: "once"`. `shuffle` draws without
208
+ * replacement and never repeats a line back-to-back (built in).
209
+ */
210
+ interface SequenceOptions {
211
+ order?: SelectorOrder;
212
+ exhaust?: SelectorExhaust;
213
+ }
214
+ /** An option's prompt beat (spec §5): a single line | text beat - the choice text. */
215
+ type PromptBeat = LineBeat | TextBeat;
216
+ type PropertyType = "boolean" | "number" | "string" | "flags" | "enum";
217
+ interface PropertyDecl {
218
+ name: string;
219
+ type: PropertyType;
220
+ default?: ScalarValue;
221
+ /**
222
+ * The orthogonal *sharing* axis (spec §7): is this property's value shared across
223
+ * all flows (one world value) or kept per-flow? It does NOT change the reference
224
+ * syntax - sharing is set here, on the declaration, not by a different scope token.
225
+ * The default depends on the scope it is declared in: a **global** property
226
+ * (project `properties` -> `@patter`) defaults to **shared**; a **scene-local**
227
+ * property (scene `sceneProps` -> `@scene`) defaults to **not shared** (per-flow).
228
+ */
229
+ shared?: boolean;
230
+ /**
231
+ * Persistence axis, for **scene-local (`@scene`) properties** (spec §7). Default
232
+ * `false`: the value PERSISTS across scene re-entries (like every other property).
233
+ * `true`: the value is **reseeded to its default on every scene entry** - "fresh
234
+ * each playthrough" (Ink's `temp`). Orthogonal to `shared`. Ignored on global
235
+ * (`@patter`) properties, which always persist for the life of the piece.
236
+ */
237
+ temporary?: boolean;
238
+ /** For enum / flags. */
239
+ values?: string[];
240
+ /** Free-text author note documenting what this property is for (authoring only; shown as a hint). */
241
+ purpose?: string;
242
+ }
243
+ /**
244
+ * A property of a host / world scope (`@world`, `@game`, ...). The same shape the
245
+ * `@wildwinter/scoperegistry` `scopeRegistrySpec` uses, declared structurally here
246
+ * so the model stays free of a runtime dependency. `default` seeds the standalone
247
+ * runtime's self-backed bag (see `HostScopeSpec`); `writable: false` makes the
248
+ * property read-only to the story (validated at compile time).
249
+ */
250
+ interface HostScopeDecl {
251
+ name: string;
252
+ type: PropertyType;
253
+ values?: string[];
254
+ default?: ScalarValue;
255
+ writable?: boolean;
256
+ /** Free-text author note documenting the property (authoring only; shown as a hint). */
257
+ purpose?: string;
258
+ }
259
+ /** One scope (`token`) in a project's host-scope registry. */
260
+ interface HostScopeSpec {
261
+ /** The scope token after `@` (e.g. `"world"`). Must not collide with Patter's own
262
+ * `patter` / `scene` / `flow`. */
263
+ token: string;
264
+ /** Scope-level read/write default for its declarations (default true). */
265
+ writable?: boolean;
266
+ /** Property declarations; omit for an opaque scope (any name, unchecked). */
267
+ declarations?: HostScopeDecl[];
268
+ }
269
+ /**
270
+ * A project's host-scope registry: the `scopeRegistrySpec` it OWNS (spec
271
+ * design/scope-registry.md §6). Makes `@world` (and any host scope) first-class
272
+ * in a standalone project: the compiler validates references into it, the runtime
273
+ * self-backs it from declaration defaults when no host resolver claims the token,
274
+ * and coverage drives its values. Structurally identical to scoperegistry's
275
+ * `ScopeRegistrySpec` so it threads straight through the compiler.
276
+ */
277
+ interface HostScopeRegistry {
278
+ version: number;
279
+ scopes: HostScopeSpec[];
280
+ }
281
+ /** A character's grammatical gender, for localisation. Translators need it to inflect the speaker's own
282
+ * lines in gendered languages (adjectives, participles, pronouns), which the source text alone often
283
+ * cannot tell them. Absent means "not specified". Authoring-only: it never reaches the runtime bundle,
284
+ * but it IS carried into the localisation handoff formats as translator context (spec §14).
285
+ *
286
+ * Free text, not a closed set: real languages need more than three genders (common/utrum, animate/
287
+ * inanimate, and so on) and translators name them differently. `COMMON_GENDERS` seeds the editor's
288
+ * auto-suggest so the everyday values stay spelled consistently; anything else is still valid. */
289
+ type GrammaticalGender = string;
290
+ interface CastMember {
291
+ /** Canonical speaker name (matched by a beat's `character`); language-neutral key. */
292
+ name: string;
293
+ /** Localised player-facing name - a localisation id (project-level strings). */
294
+ displayName?: string;
295
+ /** Grammatical gender for translators (see `GrammaticalGender`); absent = not specified. */
296
+ gender?: GrammaticalGender;
297
+ /** Free-text production notes about the character (casting, voice, intent) - authoring only. */
298
+ notes?: string;
299
+ /** The voice actor cast for this character, if known - surfaced in the VO script export (spec §16).
300
+ * Authoring-only (not shipped in the runtime bundle). */
301
+ actor?: string;
302
+ gameData?: GameData;
303
+ }
304
+ /** The cast as it reaches a compiled bundle: the player-facing fields only. `notes`, `actor` and
305
+ * `gender` are authoring / production / translation context, and the compiler drops them, so a shipped
306
+ * game never carries a real person's name or a writer's private notes. The compiler copies the shipping
307
+ * fields across explicitly (an allow-list), which is what actually keeps a new authoring field out of
308
+ * the bundle; this type states the resulting contract for anyone reading a bundle. */
309
+ type BundleCastMember = Omit<CastMember, "notes" | "actor" | "gender">;
310
+ /** A node TYPE that can carry author-defined gameData fields (the gameData schema, project-level).
311
+ * Beat kinds map straight through: dialogue = `line`, narration = `text`, game event = `gameEvent`. */
312
+ type GameDataNodeKind = "scene" | "block" | "snippet" | "line" | "text" | "gameEvent";
313
+ /** The value type of a gameData field - the property-type vocabulary plus a multiline-text variant.
314
+ * Drives the inspector's editor widget (text / textarea / number / toggle / enum dropdown). */
315
+ type GameDataFieldType = "text" | "multiline" | "number" | "boolean" | "enum";
316
+ /** One author-defined custom field on a node type (host-integration metadata, NOT expression state). */
317
+ interface GameDataField {
318
+ /** Field key - also the key a node stores its value under in `gameData` (values are name-keyed). */
319
+ name: string;
320
+ type: GameDataFieldType;
321
+ /** The value used when a node sets nothing. Storage is SPARSE - nodes hold only their overrides, and
322
+ * a reader falls back to this default (so changing it here propagates to every node that didn't set it). */
323
+ default?: ScalarValue;
324
+ /** Allowed values when `type` is "enum". */
325
+ values?: string[];
326
+ /** Free-text description of what the field is for - shown as a rollover hint in the inspector. */
327
+ purpose?: string;
328
+ }
329
+ /** Author-defined gameData fields, grouped by the node type they attach to (project-level schema). */
330
+ type GameDataFields = Partial<Record<GameDataNodeKind, GameDataField[]>>;
331
+ /** A compiled expression envelope: canonical source + pre-derived tagged-tuple AST. */
332
+ interface Expression {
333
+ src: string;
334
+ ast: AstNode;
335
+ }
336
+ type CompiledEffect = {
337
+ kind: "set";
338
+ target: string;
339
+ value: Expression;
340
+ };
341
+ interface CompiledSnippet {
342
+ id: string;
343
+ type: "snippet";
344
+ condition?: Expression;
345
+ beats?: Beat[];
346
+ onEnter?: CompiledEffect[];
347
+ onExit?: CompiledEffect[];
348
+ gameData?: GameData;
349
+ tags?: string[];
350
+ jump?: Jump;
351
+ secretUntilEligible?: boolean;
352
+ /** Option-position: repeatable (spec §5). Default false = once-only. */
353
+ sticky?: boolean;
354
+ /** Option-position: the choice's fallback, auto-followed when last (spec §5). */
355
+ fallback?: boolean;
356
+ }
357
+ interface CompiledGroup {
358
+ id: string;
359
+ type: "group";
360
+ condition?: Expression;
361
+ /** Default (omitted) = `"run"`. */
362
+ selector?: Selector;
363
+ /** Selector cursor shared across flows (default false = per-flow). */
364
+ shared?: boolean;
365
+ options?: SequenceOptions;
366
+ children: Array<CompiledGroup | CompiledSnippet>;
367
+ gameData?: GameData;
368
+ tags?: string[];
369
+ /** Option-position fields (spec §5) - only when a direct child of a `choice`. */
370
+ prompt?: PromptBeat;
371
+ secretUntilEligible?: boolean;
372
+ /** Repeatable (spec §5). Default false = once-only. */
373
+ sticky?: boolean;
374
+ /** The choice's fallback, auto-followed when last (spec §5). */
375
+ fallback?: boolean;
376
+ }
377
+ interface CompiledBlock {
378
+ id: string;
379
+ type: "block";
380
+ name: string;
381
+ /** Host-facing address (spec §6); the runtime resolves it to `id`. Absent = derived from `name`. */
382
+ gameId?: string;
383
+ children: Array<CompiledGroup | CompiledSnippet>;
384
+ gameData?: GameData;
385
+ tags?: string[];
386
+ }
387
+ interface CompiledScene {
388
+ id: string;
389
+ type: "scene";
390
+ name: string;
391
+ /** Host-facing address (spec §6); the runtime resolves it to `id`. Absent = derived from `name`. */
392
+ gameId?: string;
393
+ gameData?: GameData;
394
+ tags?: string[];
395
+ onEntry?: CompiledEffect[];
396
+ sceneProps?: PropertyDecl[];
397
+ blocks: CompiledBlock[];
398
+ }
399
+ interface Bundle {
400
+ schema: string;
401
+ /** `hash` fingerprints the WHOLE bundle (binds saves, gates staleness); `structureHash` is the same
402
+ * fingerprint with the string tables left out, so same structureHash + a different hash = a
403
+ * text-only edit, safe to hot-swap in place (live bundle refresh). */
404
+ content: {
405
+ project: string;
406
+ version?: string;
407
+ hash?: string;
408
+ structureHash?: string;
409
+ };
410
+ voiced: boolean;
411
+ locales: {
412
+ default: string;
413
+ included: string[];
414
+ };
415
+ /** Player-facing cast only: the compiler strips notes / actor / gender (see `BundleCastMember`). */
416
+ cast?: BundleCastMember[];
417
+ properties?: PropertyDecl[];
418
+ /** Host / world scope declarations, baked from the project so the runtime can self-back a declared
419
+ * scope (`@world`, ...) when no host resolver claims its token. Absent = no host scopes. */
420
+ scopeRegistry?: HostScopeRegistry;
421
+ gameDataFields?: GameDataFields;
422
+ scenes: Record<string, CompiledScene>;
423
+ /** locale -> (beatId -> text). In "embedded" localisation this carries every included locale; in "ids"
424
+ * it is EMPTY (the runtime emits beat IDs), unless `localisation.sourceDebug` embedded the source locale
425
+ * for debug playback. `content.hash` is computed over the FULL strings regardless, so the staleness gate
426
+ * is unaffected. */
427
+ strings: Record<string, Record<string, string>>;
428
+ /** How strings ship + resolve (spec §11). Absent = "embedded" (back-compat default): the runtime resolves
429
+ * `strings` per locale. "ids": the runtime emits beat IDs and the game localises them itself; `sourceDebug`
430
+ * means the source locale is embedded purely for debug playback and the runtime should flag the build as
431
+ * not shippable. */
432
+ localisation?: {
433
+ mode: "embedded" | "ids";
434
+ sourceDebug?: boolean;
435
+ };
436
+ /** Closed-caption delimiters baked from the project (#214). Absent = the default `(` / `)`; the
437
+ * runtime strips spans between them from line text when a game disables captions. */
438
+ closedCaptions?: CaptionDelimiters;
439
+ }
440
+ /** Closed-caption configuration (#214). `open`/`close` wrap a caption cue inside a dialogue line (both
441
+ * non-empty; they MAY be the same token, e.g. `*…*`). `character` names a cast member whose lines are a
442
+ * pure caption: when captions are off, ALL of that character's dialogue (and its speaker label) is
443
+ * omitted - delimiters or not - leaving a silent line that still fires (so audio plays). Absent / empty
444
+ * `character` resolves to the default `SFX` (you "disable" it simply by never using that speaker). */
445
+ interface CaptionDelimiters {
446
+ open: string;
447
+ close: string;
448
+ character?: string;
449
+ }
450
+
451
+ interface BundleInspectorOptions {
452
+ /** Where to mount the panel. If omitted, append the returned `el` yourself. */
453
+ container?: HTMLElement;
454
+ /** Panel heading. Default "Bundle". */
455
+ title?: string;
456
+ /** Sections open on mount. Default: identity and addresses, the two an
457
+ * integrator reaches for first; the rest are a click away. */
458
+ open?: BundleSection[];
459
+ }
460
+ type BundleSection = "identity" | "addresses" | "hostScopes" | "properties" | "gameData" | "counts";
461
+ interface BundleInspector {
462
+ /** The panel root (already inside `container` if you passed one). */
463
+ readonly el: HTMLElement;
464
+ /** The description the panel is showing, for a caller that wants the data too. */
465
+ readonly description: BundleDescription;
466
+ /** Remove the panel from the DOM. */
467
+ destroy(): void;
468
+ }
469
+ /**
470
+ * Build a read-only panel describing a compiled bundle: what it is, what game
471
+ * code may call on it, and what the host must supply.
472
+ */
473
+ declare function createBundleInspector(bundle: Bundle, opts?: BundleInspectorOptions): BundleInspector;
474
+
140
475
  interface AudioResolver {
141
476
  /** The full path/URL of a beat's winning audio take, or null when it has none. */
142
477
  resolve(beatId: string): string | null;
@@ -148,4 +483,4 @@ interface AudioResolver {
148
483
  */
149
484
  declare function createAudioResolver(manifestJson: string, basePath: string): AudioResolver;
150
485
 
151
- export { type AudioResolver, type DebugLink, type DebugLinkOptions, type DebugSocketLike, type LiveBundleResult, type PropertyInspector, type PropertyInspectorOptions, type PropertyValue, SAVE_SCHEMA, type SaveEnvelope, type StateChange, type StateLogger, type StateLoggerOptions, type StateSnapshot, type StateValue, applyLiveBundle, createAudioResolver, createDebugLink, createPropertyInspector, createStateLogger, deserializeState, diffState, getProperty, loadState, saveState, serializeState, setProperties, setProperty, snapshotState };
486
+ export { type AudioResolver, type BundleInspector, type BundleInspectorOptions, type BundleSection, type DebugLink, type DebugLinkOptions, type DebugSocketLike, type LiveBundleResult, type PropertyInspector, type PropertyInspectorOptions, type PropertyValue, SAVE_SCHEMA, type SaveEnvelope, type StateChange, type StateLogger, type StateLoggerOptions, type StateSnapshot, type StateValue, applyLiveBundle, createAudioResolver, createBundleInspector, createDebugLink, createPropertyInspector, createStateLogger, deserializeState, diffState, getProperty, loadState, saveState, serializeState, setProperties, setProperty, snapshotState };
package/dist/index.js CHANGED
@@ -223,8 +223,8 @@ function sameValue(a, b) {
223
223
  function createPropertyInspector(engine, opts = {}) {
224
224
  const doc = opts.container?.ownerDocument ?? document;
225
225
  injectStyle(doc);
226
- const el = doc.createElement("div");
227
- el.className = "pp-insp";
226
+ const el2 = doc.createElement("div");
227
+ el2.className = "pp-insp";
228
228
  const heading = doc.createElement("h4");
229
229
  heading.textContent = opts.title ?? "Runtime state";
230
230
  const list = doc.createElement("div");
@@ -263,15 +263,15 @@ function createPropertyInspector(engine, opts = {}) {
263
263
  input.click();
264
264
  });
265
265
  io.append(saveBtn, loadBtn);
266
- el.append(heading, io, list);
266
+ el2.append(heading, io, list);
267
267
  const rowRefreshers = [];
268
- const buildRow = (row) => {
268
+ const buildRow = (row2) => {
269
269
  const r = doc.createElement("div");
270
270
  r.className = "pp-insp-row";
271
271
  const label = doc.createElement("span");
272
272
  label.className = "pp-insp-ref";
273
- label.textContent = row.ref;
274
- label.title = row.ref;
273
+ label.textContent = row2.ref;
274
+ label.title = row2.ref;
275
275
  const ctl = doc.createElement("div");
276
276
  ctl.className = "pp-insp-ctl";
277
277
  const reset = doc.createElement("button");
@@ -283,37 +283,37 @@ function createPropertyInspector(engine, opts = {}) {
283
283
  list.appendChild(r);
284
284
  let read;
285
285
  const commit = (v) => {
286
- engine.setProperty(row.ref, v);
286
+ engine.setProperty(row2.ref, v);
287
287
  syncReset();
288
288
  };
289
289
  const focused = (node) => doc.activeElement === node;
290
290
  function syncReset() {
291
- reset.disabled = sameValue(engine.getProperty(row.ref), row.default);
291
+ reset.disabled = sameValue(engine.getProperty(row2.ref), row2.default);
292
292
  }
293
293
  reset.addEventListener("click", () => {
294
- engine.setProperty(row.ref, row.default);
294
+ engine.setProperty(row2.ref, row2.default);
295
295
  read();
296
296
  syncReset();
297
297
  });
298
- if (row.type === "boolean") {
298
+ if (row2.type === "boolean") {
299
299
  const cb = doc.createElement("input");
300
300
  cb.type = "checkbox";
301
301
  cb.addEventListener("change", () => commit(cb.checked));
302
302
  ctl.appendChild(cb);
303
303
  read = () => {
304
- if (!focused(cb)) cb.checked = engine.getProperty(row.ref) === true;
304
+ if (!focused(cb)) cb.checked = engine.getProperty(row2.ref) === true;
305
305
  };
306
- } else if (row.type === "number") {
306
+ } else if (row2.type === "number") {
307
307
  const inp = doc.createElement("input");
308
308
  inp.type = "number";
309
309
  inp.addEventListener("change", () => commit(Number(inp.value)));
310
310
  ctl.appendChild(inp);
311
311
  read = () => {
312
- if (!focused(inp)) inp.value = String(engine.getProperty(row.ref) ?? "");
312
+ if (!focused(inp)) inp.value = String(engine.getProperty(row2.ref) ?? "");
313
313
  };
314
- } else if (row.type === "enum") {
314
+ } else if (row2.type === "enum") {
315
315
  const sel = doc.createElement("select");
316
- for (const v of row.values ?? []) {
316
+ for (const v of row2.values ?? []) {
317
317
  const o = doc.createElement("option");
318
318
  o.value = v;
319
319
  o.textContent = v;
@@ -322,9 +322,9 @@ function createPropertyInspector(engine, opts = {}) {
322
322
  sel.addEventListener("change", () => commit(sel.value));
323
323
  ctl.appendChild(sel);
324
324
  read = () => {
325
- if (!focused(sel)) sel.value = String(engine.getProperty(row.ref) ?? "");
325
+ if (!focused(sel)) sel.value = String(engine.getProperty(row2.ref) ?? "");
326
326
  };
327
- } else if (row.type === "flags") {
327
+ } else if (row2.type === "flags") {
328
328
  const inp = doc.createElement("input");
329
329
  inp.type = "text";
330
330
  inp.placeholder = "comma, separated, flags";
@@ -332,7 +332,7 @@ function createPropertyInspector(engine, opts = {}) {
332
332
  ctl.appendChild(inp);
333
333
  read = () => {
334
334
  if (!focused(inp)) {
335
- const v = engine.getProperty(row.ref);
335
+ const v = engine.getProperty(row2.ref);
336
336
  inp.value = Array.isArray(v) ? v.join(", ") : "";
337
337
  }
338
338
  };
@@ -342,7 +342,7 @@ function createPropertyInspector(engine, opts = {}) {
342
342
  inp.addEventListener("change", () => commit(inp.value));
343
343
  ctl.appendChild(inp);
344
344
  read = () => {
345
- if (!focused(inp)) inp.value = String(engine.getProperty(row.ref) ?? "");
345
+ if (!focused(inp)) inp.value = String(engine.getProperty(row2.ref) ?? "");
346
346
  };
347
347
  }
348
348
  read();
@@ -359,21 +359,165 @@ function createPropertyInspector(engine, opts = {}) {
359
359
  empty.textContent = "No @patter properties.";
360
360
  list.appendChild(empty);
361
361
  } else {
362
- for (const row of props) buildRow(row);
362
+ for (const row2 of props) buildRow(row2);
363
363
  }
364
364
  const refresh = () => {
365
365
  for (const fn of rowRefreshers) fn();
366
366
  };
367
- opts.container?.appendChild(el);
367
+ opts.container?.appendChild(el2);
368
368
  const pollMs = opts.pollMs ?? 250;
369
369
  let timer;
370
370
  if (pollMs > 0) timer = setInterval(refresh, pollMs);
371
371
  return {
372
- el,
372
+ el: el2,
373
373
  refresh,
374
374
  destroy() {
375
375
  if (timer !== void 0) clearInterval(timer);
376
- el.remove();
376
+ el2.remove();
377
+ }
378
+ };
379
+ }
380
+
381
+ // src/bundle-inspector.ts
382
+ import { describeBundle } from "@patterkit/runtime";
383
+ var STYLE_ID2 = "pp-bundle-style";
384
+ var CSS2 = `
385
+ .pp-bundle{font:13px/1.4 ui-sans-serif,system-ui,sans-serif;color:#15201e;background:#f4efe6;border:1px solid #cfc7b8;border-radius:10px;padding:.6rem .7rem;max-width:26rem;box-shadow:0 6px 20px rgba(21,32,30,.12)}
386
+ .pp-bundle h4{margin:0 0 .4rem;font:600 .72rem/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.08em;text-transform:uppercase;color:#5c6b62}
387
+ .pp-bundle details{border-top:1px solid #e3dccf;padding:.3rem 0}
388
+ .pp-bundle details:first-of-type{border-top:0}
389
+ .pp-bundle summary{cursor:pointer;font-weight:600;color:#214f4b;list-style:none}
390
+ .pp-bundle summary::-webkit-details-marker{display:none}
391
+ .pp-bundle summary::before{content:"\\25B8";display:inline-block;width:1rem;color:#8a9691}
392
+ .pp-bundle details[open]>summary::before{content:"\\25BE"}
393
+ .pp-bundle-count{color:#8a9691;font-weight:400}
394
+ .pp-bundle-row{display:flex;gap:.4rem;margin:.16rem 0 .16rem 1rem}
395
+ .pp-bundle-key{flex:0 0 8rem;color:#5c6b62}
396
+ .pp-bundle-val,.pp-bundle-addr{font-family:ui-monospace,monospace;font-size:.78rem;color:#214f4b;word-break:break-all}
397
+ .pp-bundle-sub{margin-left:2rem}
398
+ .pp-bundle-empty{color:#8a9691;font-style:italic;margin-left:1rem}
399
+ .pp-bundle-warn{color:#8a3a2f;font-weight:600}
400
+ .pp-bundle-tag{font-size:.7rem;color:#5c6b62;border:1px solid #cfc7b8;border-radius:5px;padding:0 .25rem;margin-left:.3rem}
401
+ `;
402
+ function injectStyle2(doc) {
403
+ if (doc.getElementById(STYLE_ID2)) return;
404
+ const s = doc.createElement("style");
405
+ s.id = STYLE_ID2;
406
+ s.textContent = CSS2;
407
+ (doc.head ?? doc.documentElement).appendChild(s);
408
+ }
409
+ var el = (doc, tag, cls, text) => {
410
+ const e = doc.createElement(tag);
411
+ if (cls) e.className = cls;
412
+ if (text !== void 0) e.textContent = text;
413
+ return e;
414
+ };
415
+ function row(doc, key, value, valueClass = "pp-bundle-val") {
416
+ const r = el(doc, "div", "pp-bundle-row");
417
+ r.append(el(doc, "span", "pp-bundle-key", key), el(doc, "span", valueClass, value));
418
+ return r;
419
+ }
420
+ function propertyRow(doc, p) {
421
+ const r = el(doc, "div", "pp-bundle-row");
422
+ r.append(el(doc, "span", "pp-bundle-key pp-bundle-addr", p.name));
423
+ const meta = el(doc, "span", "pp-bundle-val", p.type);
424
+ if (!p.hasDefault) meta.append(el(doc, "span", "pp-bundle-tag", "no default"));
425
+ r.append(meta);
426
+ return r;
427
+ }
428
+ function section(doc, name, label, count, open) {
429
+ const d = doc.createElement("details");
430
+ d.dataset["section"] = name;
431
+ d.open = open;
432
+ const s = doc.createElement("summary");
433
+ s.textContent = label;
434
+ if (count !== null) s.append(el(doc, "span", "pp-bundle-count", ` ${count}`));
435
+ d.append(s);
436
+ return d;
437
+ }
438
+ function createBundleInspector(bundle, opts = {}) {
439
+ const doc = opts.container?.ownerDocument ?? document;
440
+ injectStyle2(doc);
441
+ const d = describeBundle(bundle);
442
+ const open = new Set(opts.open ?? ["identity", "addresses"]);
443
+ const root = el(doc, "div", "pp-bundle");
444
+ root.append(el(doc, "h4", void 0, opts.title ?? "Bundle"));
445
+ const id = section(doc, "identity", d.identity.project || "(unnamed project)", null, open.has("identity"));
446
+ if (d.identity.version) id.append(row(doc, "version", d.identity.version));
447
+ id.append(row(doc, "schema", d.identity.schema));
448
+ id.append(row(doc, "locales", `${d.identity.defaultLocale}${d.identity.locales.length > 1 ? ` (+${d.identity.locales.length - 1})` : ""}`));
449
+ id.append(row(doc, "strings", d.identity.localisation));
450
+ if (d.identity.voiced) id.append(row(doc, "voiced", "yes"));
451
+ if (d.identity.hash) id.append(row(doc, "hash", d.identity.hash));
452
+ if (d.identity.structureHash) id.append(row(doc, "structure", d.identity.structureHash));
453
+ if (d.identity.sourceDebug) {
454
+ id.append(row(doc, "build", "SOURCE DEBUG - not shippable", "pp-bundle-val pp-bundle-warn"));
455
+ }
456
+ root.append(id);
457
+ const addr = section(doc, "addresses", "Addresses", d.addresses.length, open.has("addresses"));
458
+ if (!d.addresses.length) addr.append(el(doc, "div", "pp-bundle-empty", "no scenes"));
459
+ for (const a of d.addresses) {
460
+ const r = el(doc, "div", "pp-bundle-row");
461
+ r.append(el(doc, "span", "pp-bundle-key pp-bundle-addr", a.gameId), el(doc, "span", "pp-bundle-val", a.name));
462
+ addr.append(r);
463
+ for (const b of a.blocks) {
464
+ const br = el(doc, "div", "pp-bundle-row pp-bundle-sub");
465
+ br.append(el(doc, "span", "pp-bundle-key pp-bundle-addr", b.gameId), el(doc, "span", "pp-bundle-val", b.name));
466
+ addr.append(br);
467
+ }
468
+ }
469
+ root.append(addr);
470
+ const hostCount = d.hostScopes.reduce((n, s) => n + s.properties.length, 0);
471
+ const host = section(doc, "hostScopes", "Host properties", hostCount, open.has("hostScopes"));
472
+ if (!d.hostScopes.length) host.append(el(doc, "div", "pp-bundle-empty", "the game supplies nothing"));
473
+ for (const s of d.hostScopes) {
474
+ const head = el(doc, "div", "pp-bundle-row");
475
+ head.append(el(doc, "span", "pp-bundle-key pp-bundle-addr", `@${s.token}`));
476
+ const meta = el(doc, "span", "pp-bundle-val", s.opaque ? "any name, unchecked" : `${s.properties.length} declared`);
477
+ if (!s.writable) meta.append(el(doc, "span", "pp-bundle-tag", "read-only"));
478
+ head.append(meta);
479
+ host.append(head);
480
+ for (const p of s.properties) {
481
+ const pr = propertyRow(doc, p);
482
+ pr.classList.add("pp-bundle-sub");
483
+ host.append(pr);
484
+ }
485
+ }
486
+ root.append(host);
487
+ const ownedCount = d.properties.patter.length + d.properties.scene.reduce((n, s) => n + s.properties.length, 0);
488
+ const owned = section(doc, "properties", "Story properties", ownedCount, open.has("properties"));
489
+ if (!ownedCount) owned.append(el(doc, "div", "pp-bundle-empty", "none declared"));
490
+ for (const p of d.properties.patter) owned.append(propertyRow(doc, p));
491
+ for (const s of d.properties.scene) {
492
+ owned.append(row(doc, `@scene`, s.gameId, "pp-bundle-val pp-bundle-addr"));
493
+ for (const p of s.properties) {
494
+ const pr = propertyRow(doc, p);
495
+ pr.classList.add("pp-bundle-sub");
496
+ owned.append(pr);
497
+ }
498
+ }
499
+ root.append(owned);
500
+ if (d.gameData.length) {
501
+ const gd2 = section(doc, "gameData", "Game data", d.gameData.reduce((n, g) => n + g.fields.length, 0), open.has("gameData"));
502
+ for (const g of d.gameData) {
503
+ gd2.append(row(doc, "on", g.kind));
504
+ for (const f of g.fields) {
505
+ const fr = el(doc, "div", "pp-bundle-row pp-bundle-sub");
506
+ fr.append(el(doc, "span", "pp-bundle-key pp-bundle-addr", f.name), el(doc, "span", "pp-bundle-val", f.type));
507
+ gd2.append(fr);
508
+ }
509
+ }
510
+ root.append(gd2);
511
+ }
512
+ const counts = section(doc, "counts", "Counts", null, open.has("counts"));
513
+ for (const [key, value] of Object.entries(d.counts)) counts.append(row(doc, key, String(value)));
514
+ root.append(counts);
515
+ opts.container?.append(root);
516
+ return {
517
+ el: root,
518
+ description: d,
519
+ destroy() {
520
+ root.remove();
377
521
  }
378
522
  };
379
523
  }
@@ -395,6 +539,7 @@ export {
395
539
  SAVE_SCHEMA,
396
540
  applyLiveBundle,
397
541
  createAudioResolver,
542
+ createBundleInspector,
398
543
  createDebugLink,
399
544
  createPropertyInspector,
400
545
  createStateLogger,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patterkit/play-helpers",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Thin game-integration helpers around @patterkit/runtime: save/load serialisation, runtime property setters, a state logger, the Patterpad Live Link client + hot reload, a property inspector, and audio resolution. The Patterplay JS companion.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,6 +34,6 @@
34
34
  "build": "tsup src/index.ts --format esm,cjs --dts --clean"
35
35
  },
36
36
  "dependencies": {
37
- "@patterkit/runtime": "0.4.1"
37
+ "@patterkit/runtime": "0.4.3"
38
38
  }
39
39
  }