@storylet-studio/runtime 0.8.0 → 0.8.2

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.js CHANGED
@@ -1,933 +1,20 @@
1
- // ../../../expr/packages/expr/src/ast.ts
2
- function deserialiseAst(node) {
3
- switch (node[0]) {
4
- case "b":
5
- return { kind: "bool", value: node[1] };
6
- case "n":
7
- return { kind: "number", value: node[1] };
8
- case "s":
9
- return { kind: "string", value: node[1] };
10
- case "sv":
11
- return { kind: "scopedvar", scope: node[1], name: node[2] };
12
- case "u":
13
- return { kind: "unary", op: node[1], operand: deserialiseAst(node[2]) };
14
- case "bin":
15
- return { kind: "binary", op: node[1], left: deserialiseAst(node[2]), right: deserialiseAst(node[3]) };
16
- case "call": {
17
- const args = node.slice(2).map(deserialiseAst);
18
- return { kind: "call", name: node[1], args };
19
- }
20
- case "fd":
21
- return { kind: "flagdelta", sign: node[1], name: node[2] };
22
- }
23
- }
24
-
25
- // ../../../expr/packages/expr/src/evaluate.ts
26
- var EvalError = class extends Error {
27
- constructor(message) {
28
- super(message);
29
- this.name = "EvalError";
30
- }
31
- };
32
- function evaluate(node, ctx, dialect) {
33
- const missingPolicy = new Map(
34
- dialect.scopes.map((s) => [s.token, s.missing ?? "false"])
35
- );
36
- const rec = (n) => {
37
- switch (n.kind) {
38
- case "bool":
39
- return n.value;
40
- case "number":
41
- return n.value;
42
- case "string":
43
- return n.value;
44
- case "scopedvar": {
45
- const scope = ctx.scopes[n.scope];
46
- if (scope === void 0) {
47
- return false;
48
- }
49
- const val = typeof scope.get === "function" ? scope.get(n.name) : scope[n.name];
50
- if (val === void 0) {
51
- if (missingPolicy.get(n.scope) === "throw") {
52
- throw new EvalError(`@${n.scope}.${n.name} is not declared on the current ${n.scope}.`);
53
- }
54
- return false;
55
- }
56
- return val;
57
- }
58
- case "call": {
59
- if (n.name === "advance" && !dialect.functions[n.name]) {
60
- const arg = n.args[0];
61
- if (n.args.length !== 1 || arg === void 0) {
62
- throw new EvalError(`advance() takes exactly 1 argument, got ${n.args.length}`);
63
- }
64
- const ladder = ladderOf(arg, ctx);
65
- if (ladder === void 0) {
66
- throw new EvalError("advance() needs a quality reference (@scope.name of a quality property)");
67
- }
68
- const current = stageIndex(rec(arg), ladder, "advance");
69
- return ladder[Math.min(current + 1, ladder.length - 1)];
70
- }
71
- const def = dialect.functions[n.name];
72
- if (!def) throw new EvalError(`unknown function '${n.name}'`);
73
- return def.eval(n.args, { evaluate: rec, ctx });
74
- }
75
- case "flagdelta":
76
- throw new EvalError("flagdelta node is only valid as an argument to a flag-delta function");
77
- case "unary": {
78
- if (n.op === "not") {
79
- const val2 = rec(n.operand);
80
- if (typeof val2 !== "boolean") throw new EvalError(`'not' requires a boolean operand, got ${typeof val2}`);
81
- return !val2;
82
- }
83
- const val = rec(n.operand);
84
- if (typeof val !== "number") throw new EvalError(`unary '-' requires a numeric operand, got ${typeof val}`);
85
- return -val;
86
- }
87
- case "binary": {
88
- if (n.op === "and") {
89
- const l = rec(n.left);
90
- if (typeof l !== "boolean") throw new EvalError(`'and' requires boolean operands, left is ${typeof l}`);
91
- if (!l) return false;
92
- const r = rec(n.right);
93
- if (typeof r !== "boolean") throw new EvalError(`'and' requires boolean operands, right is ${typeof r}`);
94
- return r;
95
- }
96
- if (n.op === "or") {
97
- const l = rec(n.left);
98
- if (typeof l !== "boolean") throw new EvalError(`'or' requires boolean operands, left is ${typeof l}`);
99
- if (l) return true;
100
- const r = rec(n.right);
101
- if (typeof r !== "boolean") throw new EvalError(`'or' requires boolean operands, right is ${typeof r}`);
102
- return r;
103
- }
104
- const left = rec(n.left);
105
- const right = rec(n.right);
106
- const lLadder = ladderOf(n.left, ctx);
107
- const rLadder = ladderOf(n.right, ctx);
108
- const ladder = lLadder ?? rLadder;
109
- if (ladder !== void 0) {
110
- if (lLadder && rLadder && !sameLadder(lLadder, rLadder)) {
111
- if (n.op === ">" || n.op === ">=" || n.op === "<" || n.op === "<=") {
112
- throw new EvalError(`'${n.op}' compares two different qualities, whose stage orders are unrelated`);
113
- }
114
- }
115
- switch (n.op) {
116
- case ">":
117
- return stageIndex(left, ladder, ">") > stageIndex(right, ladder, ">");
118
- case ">=":
119
- return stageIndex(left, ladder, ">=") >= stageIndex(right, ladder, ">=");
120
- case "<":
121
- return stageIndex(left, ladder, "<") < stageIndex(right, ladder, "<");
122
- case "<=":
123
- return stageIndex(left, ladder, "<=") <= stageIndex(right, ladder, "<=");
124
- case "+":
125
- case "-":
126
- case "*":
127
- case "/":
128
- throw new EvalError(`'${n.op}' cannot be applied to a quality - a stage is a position, not a number; use advance() to move it`);
129
- default:
130
- break;
131
- }
132
- }
133
- switch (n.op) {
134
- case "==":
135
- return valueEquals(left, right);
136
- case "!=":
137
- return !valueEquals(left, right);
138
- case ">":
139
- assertNumbers(left, right, ">");
140
- return left > right;
141
- case ">=":
142
- assertNumbers(left, right, ">=");
143
- return left >= right;
144
- case "<":
145
- assertNumbers(left, right, "<");
146
- return left < right;
147
- case "<=":
148
- assertNumbers(left, right, "<=");
149
- return left <= right;
150
- case "+":
151
- if (typeof left === "number" && typeof right === "number") return left + right;
152
- if (typeof left === "string" && typeof right === "string") return left + right;
153
- throw new EvalError(`'+' requires two numbers or two strings, got ${typeof left} and ${typeof right}`);
154
- case "-":
155
- assertNumbers(left, right, "-");
156
- return left - right;
157
- case "*":
158
- assertNumbers(left, right, "*");
159
- return left * right;
160
- case "/":
161
- assertNumbers(left, right, "/");
162
- if (right === 0) throw new EvalError("division by zero");
163
- return left / right;
164
- }
165
- }
166
- }
167
- };
168
- return rec(node);
169
- }
170
- function valueEquals(a, b) {
171
- if (Array.isArray(a) || Array.isArray(b)) {
172
- if (!Array.isArray(a) || !Array.isArray(b)) return false;
173
- if (a.length !== b.length) return false;
174
- const x = [...a].sort();
175
- const y = [...b].sort();
176
- for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return false;
177
- return true;
178
- }
179
- return a === b;
180
- }
181
- function assertNumbers(l, r, op) {
182
- if (typeof l !== "number" || typeof r !== "number") {
183
- throw new EvalError(`'${op}' requires numeric operands, got ${typeof l} and ${typeof r}`);
184
- }
185
- }
186
- function ladderOf(node, ctx) {
187
- if (node.kind !== "scopedvar" || ctx.qualities === void 0) return void 0;
188
- return ctx.qualities(node.scope, node.name);
189
- }
190
- function stageIndex(value, ladder, op) {
191
- if (typeof value !== "string") {
192
- throw new EvalError(`'${op}' on a quality compares stages, got ${typeof value}`);
193
- }
194
- const i = ladder.indexOf(value);
195
- if (i < 0) throw new EvalError(`"${value}" is not a stage of this quality (stages: ${ladder.join(", ")})`);
196
- return i;
197
- }
198
- var sameLadder = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
199
-
200
- // ../../../expr/packages/expr/src/prng.ts
201
- function toUint32(seed) {
202
- if (Number.isNaN(seed) || !Number.isFinite(seed)) return 0;
203
- const modded = Math.trunc(seed) % 4294967296;
204
- return modded < 0 ? modded + 4294967296 : modded;
205
- }
206
- function makePrng(seed) {
207
- let s = toUint32(seed);
208
- return {
209
- next() {
210
- s = s + 1831565813 >>> 0;
211
- let t = Math.imul(s ^ s >>> 15, 1 | s);
212
- t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
213
- return ((t ^ t >>> 14) >>> 0) / 4294967296;
214
- },
215
- state() {
216
- return s;
217
- }
218
- };
219
- }
220
- function shuffleInPlace(arr, prng) {
221
- for (let i = arr.length - 1; i > 0; i--) {
222
- const j = Math.floor(prng.next() * (i + 1));
223
- [arr[i], arr[j]] = [arr[j], arr[i]];
224
- }
225
- }
226
-
227
- // ../../../expr/packages/expr-specificity/src/index.ts
228
- var CHECK_FLAGS_COUNTING_CALL = {
229
- name: "check_flags",
230
- count: (node) => Math.max(1, node.args.length - 1)
231
- };
232
- var DEFAULT_COUNTING_CALLS = [CHECK_FLAGS_COUNTING_CALL];
233
- function matchedSpecificity(node, evalTruthy, opts) {
234
- const countingCalls = opts?.countingCalls ?? DEFAULT_COUNTING_CALLS;
235
- return walk(node, opts?.want ?? true, evalTruthy, countingCalls);
236
- }
237
- function walk(node, want, evalTruthy, countingCalls) {
238
- if (node.kind === "binary" && (node.op === "and" || node.op === "or")) {
239
- const l = walk(node.left, want, evalTruthy, countingCalls);
240
- const r = walk(node.right, want, evalTruthy, countingCalls);
241
- const behaveAsAnd = node.op === "and" === want;
242
- if (behaveAsAnd) return l > 0 && r > 0 ? l + r : 0;
243
- return Math.max(l, r);
244
- }
245
- if (node.kind === "unary" && node.op === "not") {
246
- return walk(node.operand, !want, evalTruthy, countingCalls);
247
- }
248
- if (node.kind === "call") {
249
- const rule = countingCalls.find((c) => c.name === node.name);
250
- if (rule) {
251
- const operands = rule.count(node);
252
- const holds = evalTruthy(node);
253
- if (want) return holds ? operands : 0;
254
- return holds ? 0 : 1;
255
- }
256
- }
257
- return evalTruthy(node) === want ? 1 : 0;
258
- }
259
-
260
- // ../dialect/src/engine-scopes.ts
261
- var ENGINE_SCOPES = [
262
- { token: "patter", engine: "Patterplay", means: "Patter's shared globals" },
263
- { token: "story", engine: "Storylet Engine", means: "the Storylet Engine's shared @story properties" }
264
- ];
265
-
266
- // ../dialect/src/index.ts
267
- var NEVER_PLAYED = 9999;
268
- var host = (h) => h.ctx.host ?? {};
269
- var stringArg = (fn, args, h, i) => {
270
- const v = h.evaluate(args[i]);
271
- if (typeof v !== "string" || v === "") {
272
- throw new EvalError(`${fn}() argument ${i + 1} must be a non-empty string`);
273
- }
274
- return v;
275
- };
276
- var flagsArg = (fn, args, h) => {
277
- if (args.length === 0) {
278
- throw new EvalError(`${fn}() requires at least one argument (the flags property)`);
279
- }
280
- const v = h.evaluate(args[0]);
281
- if (Array.isArray(v)) return v;
282
- if (v === false) return [];
283
- throw new EvalError(`${fn}() first argument must be a flags property`);
284
- };
285
- var OWN_SCOPES = ["story", "world", "box", "deck", "hand"];
286
- var EXTERNAL_SCOPES = ENGINE_SCOPES.map((s) => s.token).filter((t) => !OWN_SCOPES.includes(t));
287
- var storyletsDialect = {
288
- // A missing property in a PRESENT scope is always an error: every property
289
- // is declared with a default, so absence means a publish bug, a drifted
290
- // save, or a foreign scope the host never fed (schema 6.2). The same holds
291
- // for another engine's scope: `@patter.glod` is a typo, and it says so the
292
- // first time the card is evaluated rather than quietly reading false.
293
- scopes: [
294
- ...OWN_SCOPES.map((token) => ({ token, missing: "throw" })),
295
- ...EXTERNAL_SCOPES.map((token) => ({ token, missing: "throw" }))
296
- ],
297
- defaultScope: "story",
298
- functions: {
299
- random: {
300
- minArgs: 2,
301
- maxArgs: 2,
302
- returnType: "number",
303
- eval(args, h) {
304
- if (args.length !== 2) throw new EvalError("random(a, b) requires exactly 2 arguments");
305
- const nextRandom = host(h).nextRandom;
306
- if (!nextRandom) throw new EvalError("random() called without a PRNG in context");
307
- const a = h.evaluate(args[0]);
308
- const b = h.evaluate(args[1]);
309
- if (typeof a !== "number" || typeof b !== "number") {
310
- throw new EvalError("random(a, b) arguments must be numbers");
311
- }
312
- if (!Number.isInteger(a) || !Number.isInteger(b)) {
313
- throw new EvalError("random(a, b) arguments must be integers");
314
- }
315
- const lo = Math.min(a, b);
316
- const hi = Math.max(a, b);
317
- return Math.floor(nextRandom() * (hi - lo + 1)) + lo;
318
- }
319
- },
320
- check_flags: {
321
- minArgs: 1,
322
- returnType: "boolean",
323
- flagDeltaArgs: true,
324
- eval(args, h) {
325
- const flags = flagsArg("check_flags", args, h);
326
- for (let i = 1; i < args.length; i++) {
327
- const arg = args[i];
328
- if (arg.kind !== "flagdelta") {
329
- throw new EvalError("check_flags() flag args must be +flagName or -flagName");
330
- }
331
- if (arg.sign === "+" ? !flags.includes(arg.name) : flags.includes(arg.name)) {
332
- return false;
333
- }
334
- }
335
- return true;
336
- }
337
- },
338
- set_flags: {
339
- minArgs: 1,
340
- returnType: "flags",
341
- flagDeltaArgs: true,
342
- eval(args, h) {
343
- const result = [...flagsArg("set_flags", args, h)];
344
- for (let i = 1; i < args.length; i++) {
345
- const arg = args[i];
346
- if (arg.kind !== "flagdelta") {
347
- throw new EvalError("set_flags() flag args must be +flagName or -flagName");
348
- }
349
- if (arg.sign === "+") {
350
- if (!result.includes(arg.name)) result.push(arg.name);
351
- } else {
352
- const idx = result.indexOf(arg.name);
353
- if (idx >= 0) result.splice(idx, 1);
354
- }
355
- }
356
- return result.sort();
357
- }
358
- },
359
- count_played: {
360
- minArgs: 1,
361
- maxArgs: 1,
362
- returnType: "number",
363
- eval(args, h) {
364
- const card = stringArg("count_played", args, h, 0);
365
- const fn = host(h).countPlayed;
366
- if (!fn) throw new EvalError("count_played() called without a play log in context");
367
- return fn(card);
368
- }
369
- },
370
- turns_since_played: {
371
- minArgs: 1,
372
- maxArgs: 1,
373
- returnType: "number",
374
- eval(args, h) {
375
- const card = stringArg("turns_since_played", args, h, 0);
376
- const fn = host(h).turnsSincePlayed;
377
- if (!fn) throw new EvalError("turns_since_played() called without a play log in context");
378
- return fn(card);
379
- }
380
- },
381
- count_played_in: {
382
- minArgs: 2,
383
- maxArgs: 2,
384
- returnType: "number",
385
- eval(args, h) {
386
- const dimension = stringArg("count_played_in", args, h, 0);
387
- const value = stringArg("count_played_in", args, h, 1);
388
- const fn = host(h).countPlayedIn;
389
- if (!fn) throw new EvalError("count_played_in() called without a play log in context");
390
- return fn(dimension, value);
391
- }
392
- },
393
- turns_since_played_in: {
394
- minArgs: 2,
395
- maxArgs: 2,
396
- returnType: "number",
397
- eval(args, h) {
398
- const dimension = stringArg("turns_since_played_in", args, h, 0);
399
- const value = stringArg("turns_since_played_in", args, h, 1);
400
- const fn = host(h).turnsSincePlayedIn;
401
- if (!fn) throw new EvalError("turns_since_played_in() called without a play log in context");
402
- return fn(dimension, value);
403
- }
404
- }
405
- }
406
- };
407
-
408
- // ../model/src/index.ts
409
- function gameIdify(text) {
410
- return text.toLowerCase().replace(/['’]/g, "").replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
411
- }
412
- function effectiveGameId(entity) {
413
- const pinned = entity.gameId?.trim();
414
- if (pinned) return pinned;
415
- const fromTitle = entity.title ? gameIdify(entity.title) : "";
416
- return fromTitle || entity.id;
417
- }
418
- function valueAddresses(bundle) {
419
- const tags = [];
420
- for (const box of bundle.boxes) {
421
- const boxGameId = effectiveGameId(box);
422
- for (const group of box.tagGroups) {
423
- for (const tag of group.tags) {
424
- const gameId = effectiveGameId(tag);
425
- tags.push({ id: tag.id, gameId, qualified: `${boxGameId}/${gameId}` });
426
- }
427
- }
428
- }
429
- const forms = /* @__PURE__ */ new Map();
430
- for (const tag of tags) {
431
- const list = forms.get(tag.gameId) ?? [];
432
- if (!list.includes(tag.qualified)) list.push(tag.qualified);
433
- forms.set(tag.gameId, list);
434
- }
435
- const print = /* @__PURE__ */ new Map();
436
- const accept = /* @__PURE__ */ new Map();
437
- const repeated = /* @__PURE__ */ new Map();
438
- for (const tag of tags) {
439
- const candidates = forms.get(tag.gameId) ?? [tag.qualified];
440
- const ambiguous = candidates.length > 1;
441
- print.set(tag.id, ambiguous ? tag.qualified : tag.gameId);
442
- if (!accept.has(tag.qualified)) accept.set(tag.qualified, tag.id);
443
- if (!ambiguous && !accept.has(tag.gameId)) accept.set(tag.gameId, tag.id);
444
- if (ambiguous) repeated.set(tag.gameId, candidates);
445
- }
446
- return { print, accept, repeated };
447
- }
448
- function ambiguousValueAddressMessage(segment, name, candidates) {
449
- const forms = candidates.map((q) => `"value.${q}.${name}"`);
450
- const list = forms.length <= 1 ? forms[0] ?? "" : `${forms.slice(0, -1).join(", ")} or ${forms[forms.length - 1]}`;
451
- return `"value.${segment}.${name}" names a tag in ${candidates.length} boxes; write ${list}`;
452
- }
453
- var PLACE_GROUP = "place";
454
- var HOLE_REF = /^@(hand|world|story)\.([a-z][a-z0-9_-]*)$/;
455
- var isHoleRef = (value) => value.startsWith("@");
456
- var parseHoleRef = (value) => {
457
- const m = HOLE_REF.exec(value);
458
- return m === null ? void 0 : { scope: m[1], name: m[2] };
459
- };
460
- var SAVE_SCHEMA = "storylets/save@2";
461
- var SAVE_SCHEMA_V1 = "storylets/save@1";
1
+ // src/engine.ts
2
+ import { deserialiseAst, evaluate } from "@wildwinter/expr";
3
+ import { matchedSpecificity } from "@wildwinter/expr-specificity";
4
+ import { storyletsDialect, NEVER_PLAYED } from "@storylet-studio/dialect";
5
+ import {
6
+ PLACE_GROUP,
7
+ ambiguousValueAddressMessage,
8
+ effectiveGameId,
9
+ isHoleRef,
10
+ parseHoleRef,
11
+ valueAddresses
12
+ } from "@storylet-studio/model";
13
+ import { SAVE_SCHEMA, SAVE_SCHEMA_V1 } from "@storylet-studio/model";
14
+ import { PropertyBag as StateBag, ScopeRegistry } from "@wildwinter/scoperegistry";
462
15
 
463
- // ../../../expr/packages/scoperegistry/src/index.ts
464
- var PropertyBag = class _PropertyBag {
465
- /** The live values record (stable identity across reseed, so an
466
- * EvalContext built over it stays valid). Read-path for evaluation;
467
- * writes go through `set` so the firing rule applies. */
468
- values = {};
469
- decls = /* @__PURE__ */ new Map();
470
- subscribers = /* @__PURE__ */ new Set();
471
- auditors = /* @__PURE__ */ new Set();
472
- /** Name normalisation policy: lowercase by default (the registry's
473
- * long-standing contract); a product whose names are case-significant
474
- * passes identity. */
475
- norm;
476
- /** The address prefix this bag's rows carry, separator included (`@`,
477
- * `@scene.`, `world.`, `deck.<id>.`). Empty means a row's path is its name. */
478
- pathPrefix;
479
- constructor(declarations = [], opts) {
480
- this.norm = opts?.normalise ?? ((n) => n.toLowerCase());
481
- this.pathPrefix = opts?.pathPrefix ?? "";
482
- this.seed(declarations);
483
- }
484
- seed(declarations) {
485
- for (const d of declarations) {
486
- const name = this.norm(d.name);
487
- this.decls.set(name, d);
488
- this.values[name] = structuredClone(d.default ?? defaultFor(d));
489
- }
490
- }
491
- get(name) {
492
- return this.values[this.norm(name)];
493
- }
494
- /** A name as this bag keys it: its normalisation policy applied. The registry
495
- * uses it to key quality ladders and the validation schema the bag's own way,
496
- * so a case-significant (identity) bag is not quietly folded to lower case
497
- * one layer up. */
498
- normalise(name) {
499
- return this.norm(name);
500
- }
501
- /** Write a property. Engine writes (the default) notify subscribers;
502
- * pass `silent: true` for a host write, which reaches only the audit
503
- * hook. Throws on a read-only property unless the caller says it is the
504
- * HOST (`host: true`), for whom `writable: false` was never a rule - it is
505
- * the story's promise, not the game's. `silent` and `host` are separate on
506
- * purpose: one is about who hears the write, the other about who may make
507
- * it. Returns the change. */
508
- set(name, value, opts) {
509
- const n = this.norm(name);
510
- if (!opts?.host && this.decls.get(n)?.writable === false) throw new Error(`'${name}' is read-only`);
511
- const change = {
512
- name: n,
513
- prev: this.values[n],
514
- next: value,
515
- silent: opts?.silent ?? false,
516
- reason: opts?.reason
517
- };
518
- this.values[n] = value;
519
- for (const audit of this.auditors) audit(change);
520
- if (!change.silent) for (const fn of this.subscribers) fn(change);
521
- return change;
522
- }
523
- /** Notified of engine (non-silent) writes. Returns the unsubscribe. */
524
- subscribe(fn) {
525
- this.subscribers.add(fn);
526
- return () => this.subscribers.delete(fn);
527
- }
528
- /** Notified of EVERY write, silent or not. Returns the unsubscribe. */
529
- onAudit(fn) {
530
- this.auditors.add(fn);
531
- return () => this.auditors.delete(fn);
532
- }
533
- /** Examiner rows: the declared surface only (stray values are storage,
534
- * not surface). */
535
- rows() {
536
- return [...this.decls.entries()].map(([name, d]) => rowFor(d, this.get(name), void 0, name, this.pathPrefix));
537
- }
538
- declarations() {
539
- return [...this.decls.values()];
540
- }
541
- /** The one sanctioned copy door: values deep-copied, declarations
542
- * duplicated, the normalisation policy carried, subscriptions NOT
543
- * carried. */
544
- clone() {
545
- const c = new _PropertyBag([], { normalise: this.norm, pathPrefix: this.pathPrefix });
546
- c.decls = new Map(this.decls);
547
- Object.assign(c.values, structuredClone(this.values));
548
- return c;
549
- }
550
- /** Clear and re-seed from new declarations, in place (the values record
551
- * keeps its identity, so contexts built over it stay valid). */
552
- reseed(declarations) {
553
- for (const k of Object.keys(this.values)) delete this.values[k];
554
- this.decls.clear();
555
- this.seed(declarations);
556
- }
557
- /** Bare values, ready to embed in a product's save. */
558
- save() {
559
- return structuredClone(this.values);
560
- }
561
- /** Lay saved values over the current ones (call after a fresh seed:
562
- * orphans land as strays, new declarations keep their defaults; the
563
- * product decides whether to prune). Does not fire events. */
564
- load(values) {
565
- for (const [k, v] of Object.entries(values)) this.values[this.norm(k)] = v;
566
- }
567
- };
568
- function rowFor(d, value, writable, name, pathPrefix = "") {
569
- const rowName = name ?? d.name.toLowerCase();
570
- return {
571
- name: rowName,
572
- path: pathPrefix + rowName,
573
- type: d.type,
574
- value,
575
- default: d.default ?? defaultFor(d),
576
- ...d.values !== void 0 ? { values: d.values } : {},
577
- // `stages` was added to the row so an examiner could offer a quality's ladder
578
- // instead of a free-text box, and then never populated here: every quality row
579
- // this function built came out without one. Fixed 2026-09-02.
580
- ...d.stages !== void 0 ? { stages: d.stages } : {},
581
- writable: writable ?? d.writable ?? true
582
- };
583
- }
584
- var lowerCase = (name) => name.toLowerCase();
585
- var SAVE_FRAGMENT_VERSION = 1;
586
- var ScopeRegistry = class {
587
- scopes = /* @__PURE__ */ new Map();
588
- /** Values loaded for keys nobody has registered yet, waiting to be claimed. */
589
- parked = /* @__PURE__ */ new Map();
590
- rev = 0;
591
- /**
592
- * A counter that moves whenever a scope is registered or removed, and at no
593
- * other time: it starts at 0 and each registration or removal adds 1. Values
594
- * changing does not move it. A caller that caches a context built by
595
- * `toEvalContext()` rebuilds it when this moves, because the context's set of
596
- * scopes is fixed when it is built while the values it reads stay live.
597
- */
598
- get revision() {
599
- return this.rev;
600
- }
601
- /**
602
- * Register a scope this registry **owns and stores**. Its bag is seeded from
603
- * each declaration's `default` (or a type default). Owned scopes are
604
- * type-checked (declarations) and serialized by `save`/`load`.
605
- *
606
- * The third argument may be the path prefix alone (the pre-0.7 form) or an
607
- * options object.
608
- */
609
- defineOwned(token, declarations, opts) {
610
- const o = typeof opts === "string" ? { pathPrefix: opts } : opts ?? {};
611
- const bag = new PropertyBag(declarations, {
612
- pathPrefix: o.pathPrefix ?? `${token}.`,
613
- ...o.normalise ? { normalise: o.normalise } : {}
614
- });
615
- return this.mountOwned(token, bag, o.owner !== void 0 ? { owner: o.owner } : void 0);
616
- }
617
- /**
618
- * Attach an EXISTING bag as an owned scope: an engine (or a host) holds the
619
- * bag and this registry reads, writes, lists and saves it like its own.
620
- *
621
- * If values were loaded for this key before anyone registered it, the bag
622
- * claims them now: laid over its seeded defaults by the bag's own `load` rule.
623
- */
624
- mountOwned(token, bag, opts) {
625
- this.assertFree(token, opts?.owner);
626
- this.scopes.set(token, { kind: "owned", bag, ...opts?.owner !== void 0 ? { owner: opts.owner } : {} });
627
- this.rev++;
628
- const waiting = this.parked.get(token);
629
- if (waiting) {
630
- bag.load(waiting);
631
- this.parked.delete(token);
632
- }
633
- return this;
634
- }
635
- /**
636
- * Unregister a scope. With `{ keep: true }` an owned scope's values are parked
637
- * and handed back when the same key is next registered, which is how a live
638
- * reload hands an engine's state to its replacement. Throws on an unknown key.
639
- */
640
- remove(token, opts) {
641
- const e = this.scopes.get(token);
642
- if (!e) throw new Error(`unknown scope '@${token}'`);
643
- if (opts?.keep && e.kind === "owned") this.parked.set(token, e.bag.save());
644
- this.scopes.delete(token);
645
- this.rev++;
646
- return this;
647
- }
648
- /**
649
- * Drop parked values nobody claimed. Parked values are kept in the next save by
650
- * default, so nothing loaded is lost to a flow or deck that simply has not
651
- * reopened yet; a game that knows they are dead drops them here.
652
- *
653
- * With a `prefix`, only keys starting with it are dropped: an engine resetting
654
- * itself drops its own instance keys (`my-engine/`) and leaves every other
655
- * engine's alone.
656
- */
657
- discardParked(prefix) {
658
- if (prefix === void 0) this.parked.clear();
659
- else for (const key of [...this.parked.keys()]) if (key.startsWith(prefix)) this.parked.delete(key);
660
- return this;
661
- }
662
- /** An owned scope's bag (subscribe, audit, rows live there). */
663
- ownedBag(token) {
664
- const e = this.scopes.get(token);
665
- if (!e || e.kind !== "owned") throw new Error(`'@${token}' is not an owned scope`);
666
- return e.bag;
667
- }
668
- /**
669
- * Re-initialise an existing **owned** scope's bag from new declarations,
670
- * clearing its current values. For scope-local state that resets on a context
671
- * change (e.g. entering a new scene / site / deck) without disturbing other
672
- * scopes. Mutates the bag in place, so an `EvalContext` already built from this
673
- * registry stays valid.
674
- */
675
- reseedOwned(token, declarations) {
676
- this.ownedBag(token).reseed(declarations);
677
- return this;
678
- }
679
- /**
680
- * Register a **foreign** scope backed by a host `{ get, set? }` resolver. The
681
- * values live in the host/other engine and are never stored or saved here.
682
- * `declarations` (optional, e.g. imported from a `scopeRegistrySpec`) are used
683
- * only for validation; omit them for an opaque scope.
684
- */
685
- defineForeign(token, resolver, declarations = [], opts = true) {
686
- const o = typeof opts === "boolean" ? { writable: opts } : opts;
687
- this.assertFree(token, o.owner);
688
- const norm = o.normalise ?? lowerCase;
689
- const decls = /* @__PURE__ */ new Map();
690
- for (const d of declarations) decls.set(norm(d.name), d);
691
- this.scopes.set(token, {
692
- kind: "foreign",
693
- resolver,
694
- decls,
695
- scopeWritable: o.writable ?? true,
696
- norm,
697
- ...o.owner !== void 0 ? { owner: o.owner } : {}
698
- });
699
- this.rev++;
700
- return this;
701
- }
702
- has(token) {
703
- return this.scopes.has(token);
704
- }
705
- /** Read a property; undefined if the scope or property is not present. */
706
- get(scope, name) {
707
- const e = this.scopes.get(scope);
708
- if (!e) return void 0;
709
- return e.kind === "owned" ? e.bag.get(name) : e.resolver.get(e.norm(name));
710
- }
711
- /** Write a property (an ENGINE write: the bag's subscribers fire; use
712
- * the bag directly for silent host writes). Throws on an unknown scope.
713
- *
714
- * `writable: false` is the STORY's promise, so a story write is refused and
715
- * a HOST write is not: pass `{ host: true }` from a host's own surface (its
716
- * `setProperty`, its tooling, a coverage driver) and never from the path an
717
- * outcome or effect takes. A foreign scope whose resolver has no `set` is
718
- * refused for everyone, host included - that is not a rule to bypass, it is
719
- * a game that gave no way to write. */
720
- set(scope, name, value, opts) {
721
- const e = this.scopes.get(scope);
722
- if (!e) throw new Error(`unknown scope '@${scope}'`);
723
- if (e.kind === "owned") {
724
- try {
725
- e.bag.set(name, value, opts?.host ? { host: true } : void 0);
726
- } catch {
727
- throw new Error(`'@${scope}.${name}' is read-only`);
728
- }
729
- return;
730
- }
731
- const n = e.norm(name);
732
- if (!e.resolver.set) throw new Error(`'@${scope}.${name}' is read-only`);
733
- if (!opts?.host && !this.foreignWritable(e, n)) throw new Error(`'@${scope}.${name}' is read-only`);
734
- e.resolver.set(n, value);
735
- }
736
- foreignWritable(e, name) {
737
- if (!e.resolver.set) return false;
738
- return e.decls.get(name)?.writable ?? e.scopeWritable;
739
- }
740
- /** Examiner rows across every scope with a declared surface: owned bags
741
- * first, then declared foreign scopes (values read through, writability
742
- * reflecting the resolver). Opaque foreign scopes are not listed. */
743
- listProperties() {
744
- const out = [];
745
- for (const [token, e] of this.scopes) {
746
- const owner = e.owner !== void 0 ? { owner: e.owner } : {};
747
- if (e.kind === "owned") {
748
- for (const row of e.bag.rows()) out.push({ scope: token, ...owner, ...row });
749
- } else {
750
- for (const [n, d] of e.decls) {
751
- out.push({
752
- scope: token,
753
- ...owner,
754
- ...rowFor(d, e.resolver.get(n), this.foreignWritable(e, n), n, `${token}.`)
755
- });
756
- }
757
- }
758
- }
759
- return out;
760
- }
761
- /**
762
- * Build the `EvalContext` expr's `evaluate` consumes: owned scopes as static
763
- * bags, foreign scopes as their resolvers. `host` carries dialect-function
764
- * callbacks (PRNG, tag lookups) and is passed through untouched.
765
- */
766
- toEvalContext(host2, opts) {
767
- const view = this.view(opts?.aliases);
768
- const scopes = {};
769
- for (const [token, e] of view) scopes[token] = e.kind === "owned" ? e.bag.values : e.resolver;
770
- const qualities = this.qualityLadders(view);
771
- return qualities.size === 0 ? { scopes, host: host2 } : {
772
- scopes,
773
- host: host2,
774
- qualities: (scope, name) => {
775
- const e = view.get(scope);
776
- return e ? qualities.get(scope)?.get(normOf(e)(name)) : void 0;
777
- }
778
- };
779
- }
780
- /**
781
- * The scopes an expression sees: every registered key under its own token,
782
- * then each alias token pointing at its key's entry (an alias shadows a key of
783
- * the same name). Keys an engine uses for instance bags (`engine/flow-2/...`)
784
- * are not valid expression tokens, so they are present but unreachable.
785
- */
786
- view(aliases) {
787
- const out = new Map(this.scopes);
788
- for (const [token, key] of Object.entries(aliases ?? {})) {
789
- const e = this.scopes.get(key);
790
- if (!e) throw new Error(`alias '@${token}' names '${key}', which is not registered`);
791
- out.set(token, e);
792
- }
793
- return out;
794
- }
795
- /** Every quality declaration's ladder, keyed scope token then name (the
796
- * scope's own normalisation). */
797
- qualityLadders(view) {
798
- const out = /* @__PURE__ */ new Map();
799
- for (const [token, e] of view) {
800
- for (const [n, d] of declsOf(e)) {
801
- if (d.type !== "quality" || d.stages === void 0) continue;
802
- let m = out.get(token);
803
- if (!m) {
804
- m = /* @__PURE__ */ new Map();
805
- out.set(token, m);
806
- }
807
- m.set(n, d.stages);
808
- }
809
- }
810
- return out;
811
- }
812
- /**
813
- * Build the `ExpressionSchema` expr's validator consumes. Scopes with no
814
- * declarations are **omitted** (opaque - references into them are not flagged);
815
- * declared scopes contribute their property types for validation. Aliases
816
- * apply as they do to `toEvalContext`, so a condition written against `@scene`
817
- * validates against the instance bag the engine names.
818
- */
819
- toSchema(opts) {
820
- const properties = /* @__PURE__ */ new Map();
821
- for (const [token, e] of this.view(opts?.aliases)) {
822
- const decls = declsOf(e);
823
- if (decls.length === 0) continue;
824
- const m = /* @__PURE__ */ new Map();
825
- for (const [n, d] of decls) m.set(n, {
826
- type: d.type,
827
- enumValues: d.values,
828
- ...d.stages !== void 0 ? { stages: d.stages } : {}
829
- });
830
- properties.set(token, m);
831
- }
832
- return { properties };
833
- }
834
- /** Serialize **owned** scopes (foreign scopes are the game's, and the game
835
- * saves them), as bare bags keyed by token, plus any values still parked, so
836
- * a save taken before every engine has re-registered loses nothing. The
837
- * registry knows nothing about game saves: a game embeds this in its own. */
838
- save() {
839
- const out = {};
840
- for (const [token, e] of this.scopes) if (e.kind === "owned") out[token] = e.bag.save();
841
- for (const [token, vals] of this.parked) out[token] = structuredClone(vals);
842
- return out;
843
- }
844
- /**
845
- * Restore from a `save` blob. An owned scope lays its section over its current
846
- * values (the bag's `load` rule). A section for a key nobody has registered
847
- * yet is PARKED and handed over when that key registers, so a game can load
848
- * its registry before its engines have reopened their flows or decks. A
849
- * section for a foreign scope is ignored: those values are the game's.
850
- *
851
- * A load replaces whatever was parked before it: it is a whole restore, and
852
- * residue from an earlier load must not leak into this one.
853
- *
854
- * Changed in 0.7.0: sections for unregistered keys used to be dropped.
855
- */
856
- load(blob, opts) {
857
- if (!opts?.keepParked) this.parked.clear();
858
- for (const [token, vals] of Object.entries(blob)) {
859
- const e = this.scopes.get(token);
860
- if (e?.kind === "owned") e.bag.load(vals);
861
- else if (!e) this.parked.set(token, structuredClone(vals));
862
- }
863
- }
864
- /**
865
- * `save()` wrapped with a version stamp.
866
- *
867
- * @deprecated Versioning belongs to the save that embeds the values; no
868
- * engine ever called this. Embed `save()` in your own versioned save.
869
- * Removed at the next breaking release.
870
- */
871
- saveFragment() {
872
- return { version: SAVE_FRAGMENT_VERSION, scopes: this.save() };
873
- }
874
- /**
875
- * Restore from a versioned fragment; an unsupported version throws.
876
- *
877
- * @deprecated See `saveFragment`. Removed at the next breaking release.
878
- */
879
- loadFragment(fragment) {
880
- if (fragment.version !== SAVE_FRAGMENT_VERSION) {
881
- throw new Error(`unsupported owned-state fragment version ${fragment.version} (supported: ${SAVE_FRAGMENT_VERSION})`);
882
- }
883
- this.load(fragment.scopes);
884
- }
885
- /**
886
- * A token is taken once. There is no reserved-token list: a clash surfaces
887
- * here, the moment a game combines its engines, which is the only moment
888
- * anyone knows which engines are present. With owners recorded the error says
889
- * whose token it already is.
890
- */
891
- assertFree(token, owner) {
892
- const e = this.scopes.get(token);
893
- if (!e) return;
894
- const by = e.owner !== void 0 ? ` by ${e.owner}` : "";
895
- const wants = owner !== void 0 ? ` (wanted by ${owner})` : "";
896
- throw new Error(`scope '@${token}' is already registered${by}${wants}`);
897
- }
898
- };
899
- function declsOf(e) {
900
- if (e.kind === "foreign") return [...e.decls.entries()];
901
- return e.bag.declarations().map((d) => [e.bag.normalise(d.name), d]);
902
- }
903
- function normOf(e) {
904
- return e.kind === "foreign" ? e.norm : (n) => e.bag.normalise(n);
905
- }
906
- function defaultFor(d) {
907
- if (d.default !== void 0) return d.default;
908
- switch (d.type) {
909
- case "boolean":
910
- return false;
911
- case "number":
912
- return 0;
913
- case "string":
914
- return "";
915
- case "enum":
916
- return d.values?.[0] ?? "";
917
- case "flags":
918
- return [];
919
- // A quality starts at the first rung of its ladder.
920
- case "quality":
921
- return d.stages?.[0] ?? "";
922
- // Unreachable for a well-typed declaration, and deliberately present anyway: a bundle
923
- // is DATA, and a hand-edited or newer-than-this-build one can carry a type string the
924
- // union does not have. Falling off the switch would seed `undefined`, which is not a
925
- // ScalarValue and travels a long way before it fails. Patterplay's copy of this had the
926
- // guard and this one did not, which is the drift you only find by removing a duplicate.
927
- default:
928
- return false;
929
- }
930
- }
16
+ // src/prng.ts
17
+ import { makePrng, shuffleInPlace } from "@wildwinter/expr";
931
18
 
932
19
  // src/engine.ts
933
20
  var tagKey = (groupId, tagId) => `${groupId}${tagId}`;
@@ -975,7 +62,7 @@ function sectionsOf(p, keyOf, out) {
975
62
  for (const [id, values] of Object.entries(p[kind])) if (Object.keys(values).length > 0) out[keyOf(kind, id)] = values;
976
63
  }
977
64
  }
978
- var bagFromDecls = (decls, pathPrefix) => new PropertyBag(decls, { normalise: (n) => n, pathPrefix });
65
+ var bagFromDecls = (decls, pathPrefix) => new StateBag(decls, { normalise: (n) => n, pathPrefix });
979
66
  function conditionPasses(v) {
980
67
  if (typeof v === "boolean") return v;
981
68
  if (typeof v === "number") return v !== 0;
@@ -1332,8 +419,8 @@ var Engine = class _Engine {
1332
419
  reg.set("world", n, v, { host: true });
1333
420
  } : void 0;
1334
421
  } else {
1335
- internals.worldSet = (n, v, host2) => {
1336
- reg.set("world", n, v, host2 === true ? { host: true } : void 0);
422
+ internals.worldSet = (n, v, host) => {
423
+ reg.set("world", n, v, host === true ? { host: true } : void 0);
1337
424
  };
1338
425
  }
1339
426
  }
@@ -2948,6 +2035,7 @@ var Flow = class {
2948
2035
  };
2949
2036
 
2950
2037
  // src/describe.ts
2038
+ import { effectiveGameId as effectiveGameId2, isHoleRef as isHoleRef2 } from "@storylet-studio/model";
2951
2039
  var summarise = (decls) => decls.map((d) => ({
2952
2040
  name: d.name,
2953
2041
  type: d.type,
@@ -2967,10 +2055,10 @@ var movableHoles = (hand, box) => {
2967
2055
  const filled = hand.template !== void 0 ? hand.chosen : hand.rule?.bindings;
2968
2056
  const out = [];
2969
2057
  for (const [groupId, value] of Object.entries(filled ?? {})) {
2970
- if (!isHoleRef(value)) continue;
2058
+ if (!isHoleRef2(value)) continue;
2971
2059
  const group = box.tagGroups.find((g) => g.id === groupId);
2972
2060
  if (group === void 0) continue;
2973
- out.push({ group: effectiveGameId(group), from: value });
2061
+ out.push({ group: effectiveGameId2(group), from: value });
2974
2062
  }
2975
2063
  return out;
2976
2064
  };
@@ -2988,7 +2076,7 @@ function describeBundle(bundle) {
2988
2076
  ];
2989
2077
  const totals = { boxes: 0, decks: 0, cards: 0, hands: 0, templates: 0, tagGroups: 0 };
2990
2078
  for (const box of bundle.boxes) {
2991
- const boxGameId = effectiveGameId(box);
2079
+ const boxGameId = effectiveGameId2(box);
2992
2080
  const cards = box.decks.reduce((n, deck) => n + deck.cards.length, 0);
2993
2081
  boxes.push({
2994
2082
  gameId: boxGameId,
@@ -2997,8 +2085,8 @@ function describeBundle(bundle) {
2997
2085
  ...box.turn !== void 0 ? { turn: { seconds: box.turn.seconds } } : {},
2998
2086
  ...durableCardCount(box) > 0 ? { durableCards: durableCardCount(box) } : {},
2999
2087
  tagGroups: box.tagGroups.map((group) => ({
3000
- gameId: effectiveGameId(group),
3001
- tags: group.tags.map((tag) => effectiveGameId(tag))
2088
+ gameId: effectiveGameId2(group),
2089
+ tags: group.tags.map((tag) => effectiveGameId2(tag))
3002
2090
  })),
3003
2091
  counts: {
3004
2092
  decks: box.decks.length,
@@ -3018,11 +2106,11 @@ function describeBundle(bundle) {
3018
2106
  const template = hand.template !== void 0 ? box.handTemplates.find((t) => t.id === hand.template) : void 0;
3019
2107
  const movable = movableHoles(hand, box);
3020
2108
  hands.push({
3021
- gameId: effectiveGameId(hand),
2109
+ gameId: effectiveGameId2(hand),
3022
2110
  ...hand.title !== void 0 ? { title: hand.title } : {},
3023
2111
  box: boxGameId,
3024
2112
  slots: handSlots(hand, box),
3025
- ...template !== void 0 ? { template: effectiveGameId(template) } : {},
2113
+ ...template !== void 0 ? { template: effectiveGameId2(template) } : {},
3026
2114
  ...movable.length > 0 ? { movable } : {}
3027
2115
  });
3028
2116
  }
@@ -3037,11 +2125,11 @@ function describeBundle(bundle) {
3037
2125
  });
3038
2126
  };
3039
2127
  push("box", boxGameId, box.properties);
3040
- for (const deck of box.decks) push("deck", effectiveGameId(deck), deck.properties);
3041
- for (const hand of box.hands) push("hand", effectiveGameId(hand), handDecls(hand, box));
2128
+ for (const deck of box.decks) push("deck", effectiveGameId2(deck), deck.properties);
2129
+ for (const hand of box.hands) push("hand", effectiveGameId2(hand), handDecls(hand, box));
3042
2130
  for (const group of box.tagGroups) {
3043
2131
  for (const tag of group.tags) {
3044
- push("tag", effectiveGameId(tag), tag.properties ?? [], effectiveGameId(group));
2132
+ push("tag", effectiveGameId2(tag), tag.properties ?? [], effectiveGameId2(group));
3045
2133
  }
3046
2134
  }
3047
2135
  }