@storylet-studio/runtime 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/index.cjs +248 -1045
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +56 -20
- package/dist/index.d.ts +56 -20
- package/dist/index.js +183 -969
- package/dist/index.js.map +1 -1
- package/package.json +7 -2
package/dist/index.js
CHANGED
|
@@ -1,936 +1,27 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
+
BUNDLE_SCHEMAS,
|
|
7
|
+
PLACE_GROUP,
|
|
8
|
+
allTagGroups,
|
|
9
|
+
ambiguousValueAddressMessage,
|
|
10
|
+
effectiveGameId,
|
|
11
|
+
groupsOfBox,
|
|
12
|
+
isHoleRef,
|
|
13
|
+
parseHoleRef,
|
|
14
|
+
valueAddresses,
|
|
15
|
+
zoneQualifiedValueAddressMessage
|
|
16
|
+
} from "@storylet-studio/model";
|
|
17
|
+
import { SAVE_SCHEMA, SAVE_SCHEMA_V1 } from "@storylet-studio/model";
|
|
18
|
+
import { PropertyBag as StateBag, ScopeRegistry } from "@wildwinter/scoperegistry";
|
|
462
19
|
|
|
463
|
-
//
|
|
464
|
-
|
|
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
|
-
}
|
|
20
|
+
// src/prng.ts
|
|
21
|
+
import { makePrng, shuffleInPlace } from "@wildwinter/expr";
|
|
931
22
|
|
|
932
23
|
// src/engine.ts
|
|
933
|
-
var tagKey = (groupId, tagId) => `${groupId}${tagId}`;
|
|
24
|
+
var tagKey = (boxId, groupId, tagId) => `${boxId}${groupId}${tagId}`;
|
|
934
25
|
var cardIsShared = (card, deckShared) => card.shared ?? deckShared;
|
|
935
26
|
var sharedCap = (card) => card.sharedCopies ?? card.copies ?? 1;
|
|
936
27
|
var OWNER = "Storylet Engine";
|
|
@@ -975,7 +66,7 @@ function sectionsOf(p, keyOf, out) {
|
|
|
975
66
|
for (const [id, values] of Object.entries(p[kind])) if (Object.keys(values).length > 0) out[keyOf(kind, id)] = values;
|
|
976
67
|
}
|
|
977
68
|
}
|
|
978
|
-
var bagFromDecls = (decls, pathPrefix) => new
|
|
69
|
+
var bagFromDecls = (decls, pathPrefix) => new StateBag(decls, { normalise: (n) => n, pathPrefix });
|
|
979
70
|
function conditionPasses(v) {
|
|
980
71
|
if (typeof v === "boolean") return v;
|
|
981
72
|
if (typeof v === "number") return v !== 0;
|
|
@@ -987,11 +78,12 @@ var isShared = (scope, d) => d.shared ?? SCOPE_DEFAULT_SHARED[scope];
|
|
|
987
78
|
var sharedHalf = (scope, decls) => decls.filter((d) => isShared(scope, d));
|
|
988
79
|
var flowHalf = (scope, decls) => decls.filter((d) => !isShared(scope, d));
|
|
989
80
|
var OWNED_SCOPES = ["box", "deck", "hand", "value"];
|
|
81
|
+
var emptyOwnerIndex = () => ({ gameId: /* @__PURE__ */ new Map(), id: /* @__PURE__ */ new Map(), repeated: /* @__PURE__ */ new Map(), zoneQualified: /* @__PURE__ */ new Map() });
|
|
990
82
|
var emptyOwnerIndexes = () => ({
|
|
991
|
-
box:
|
|
992
|
-
deck:
|
|
993
|
-
hand:
|
|
994
|
-
value:
|
|
83
|
+
box: emptyOwnerIndex(),
|
|
84
|
+
deck: emptyOwnerIndex(),
|
|
85
|
+
hand: emptyOwnerIndex(),
|
|
86
|
+
value: emptyOwnerIndex()
|
|
995
87
|
});
|
|
996
88
|
var indexOwner = (index, entity) => {
|
|
997
89
|
const gameId = effectiveGameId(entity);
|
|
@@ -1003,6 +95,7 @@ var indexValueOwners = (index, bundle) => {
|
|
|
1003
95
|
for (const [id, segment] of addresses.print) index.gameId.set(id, segment);
|
|
1004
96
|
for (const [segment, id] of addresses.accept) index.id.set(segment, id);
|
|
1005
97
|
for (const [gameId, candidates] of addresses.repeated) index.repeated.set(gameId, candidates);
|
|
98
|
+
for (const [segment, zone] of addresses.zoneQualified) index.zoneQualified.set(segment, zone);
|
|
1006
99
|
};
|
|
1007
100
|
var handDeclsOf = (internals, hand) => {
|
|
1008
101
|
if (hand.template !== void 0) {
|
|
@@ -1014,6 +107,8 @@ var addressOf = (internals, kind, id) => `${kind}.${internals.owners[kind].gameI
|
|
|
1014
107
|
var resolveOwner = (internals, kind, segment) => {
|
|
1015
108
|
const candidates = internals.owners[kind].repeated.get(segment);
|
|
1016
109
|
if (candidates !== void 0) return { ambiguous: candidates };
|
|
110
|
+
const zone = internals.owners[kind].zoneQualified.get(segment);
|
|
111
|
+
if (zone !== void 0) return { zone };
|
|
1017
112
|
const byGameId = internals.owners[kind].id.get(segment);
|
|
1018
113
|
if (byGameId !== void 0) return { id: byGameId, legacy: false };
|
|
1019
114
|
if (internals.owners[kind].gameId.has(segment)) return { id: segment, legacy: true };
|
|
@@ -1023,6 +118,7 @@ var ownerOrThrow = (internals, kind, segment, name) => {
|
|
|
1023
118
|
const owner = resolveOwner(internals, kind, segment);
|
|
1024
119
|
if (owner === void 0) throw new Error(`no ${kind} store "${segment}"`);
|
|
1025
120
|
if ("ambiguous" in owner) throw new Error(ambiguousValueAddressMessage(segment, name, owner.ambiguous));
|
|
121
|
+
if ("zone" in owner) throw new Error(zoneQualifiedValueAddressMessage(segment, owner.zone, name));
|
|
1026
122
|
return owner;
|
|
1027
123
|
};
|
|
1028
124
|
var legacyAddressMessage = (internals, kind, segment, name) => `"${kind}.${segment}.${name}" names the ${kind} by its internal id; write "${addressOf(internals, kind, segment)}.${name}". The internal-id form is refused after the next release.`;
|
|
@@ -1040,9 +136,12 @@ var buildPartition = (internals, half) => {
|
|
|
1040
136
|
hand: new Map(b.boxes.flatMap((box) => box.hands.map(
|
|
1041
137
|
(hand) => [hand.id, bagFromDecls(half("hand", handDeclsOf(internals, hand)), at("hand", hand.id))]
|
|
1042
138
|
))),
|
|
1043
|
-
|
|
139
|
+
// Every box's tags, then the project map's zones ONCE (design/project-
|
|
140
|
+
// map-contract.md 3.3): a zone is one bag per partition, whichever boxes'
|
|
141
|
+
// hands are dealt to it.
|
|
142
|
+
value: new Map(allTagGroups(b).flatMap((group) => group.tags.map(
|
|
1044
143
|
(tag) => [tag.id, bagFromDecls(half("value", tag.properties ?? []), at("value", tag.id))]
|
|
1045
|
-
)))
|
|
144
|
+
)))
|
|
1046
145
|
};
|
|
1047
146
|
};
|
|
1048
147
|
var partitionValues = (p) => ({
|
|
@@ -1159,6 +258,68 @@ function finishReport(bundle, saved, flows, draft) {
|
|
|
1159
258
|
retypedProperties
|
|
1160
259
|
};
|
|
1161
260
|
}
|
|
261
|
+
var refuseUnreadableBundle = (bundle) => {
|
|
262
|
+
const schema = bundle.schema;
|
|
263
|
+
if (typeof schema !== "string" || !BUNDLE_SCHEMAS.includes(schema)) {
|
|
264
|
+
throw new Error(`unsupported bundle schema: ${String(schema)} (this runtime reads ${BUNDLE_SCHEMAS.join(" and ")})`);
|
|
265
|
+
}
|
|
266
|
+
const problems = [];
|
|
267
|
+
const map = bundle.map?.group;
|
|
268
|
+
const mapName = map !== void 0 ? effectiveGameId(map) : void 0;
|
|
269
|
+
if (map !== void 0 && mapName === PLACE_GROUP) {
|
|
270
|
+
problems.push(`the project map's tag group is called "${PLACE_GROUP}", which is reserved for a box's own hands`);
|
|
271
|
+
}
|
|
272
|
+
const boxTags = /* @__PURE__ */ new Map();
|
|
273
|
+
for (const box of bundle.boxes) {
|
|
274
|
+
for (const group of box.tagGroups) {
|
|
275
|
+
for (const tag of group.tags) {
|
|
276
|
+
const gameId = effectiveGameId(tag);
|
|
277
|
+
if (!boxTags.has(gameId)) boxTags.set(gameId, { box: effectiveGameId(box), group: effectiveGameId(group) });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
for (const tag of map?.tags ?? []) {
|
|
282
|
+
const zone = effectiveGameId(tag);
|
|
283
|
+
const clash = boxTags.get(zone);
|
|
284
|
+
if (clash !== void 0) {
|
|
285
|
+
problems.push(`the project map's zone "${zone}" has the name of tag "${zone}" in box "${clash.box}", group "${clash.group}", so "value.${zone}.<name>" would name two things`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
for (const box of bundle.boxes) {
|
|
289
|
+
const boxName = effectiveGameId(box);
|
|
290
|
+
if (box.usesMap === true) {
|
|
291
|
+
if (map === void 0) {
|
|
292
|
+
problems.push(`box "${boxName}" uses the project map, but the bundle has no map`);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
const twin = box.tagGroups.find((g) => effectiveGameId(g) === mapName);
|
|
296
|
+
if (twin !== void 0) {
|
|
297
|
+
problems.push(`box "${boxName}" uses the project map and declares its own tag group "${mapName}", the map's name`);
|
|
298
|
+
}
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (map === void 0) continue;
|
|
302
|
+
const names = (where) => {
|
|
303
|
+
problems.push(`box "${boxName}" is not on the project map, but ${where} names the map's tag group "${mapName}"`);
|
|
304
|
+
};
|
|
305
|
+
for (const deck of box.decks) {
|
|
306
|
+
for (const card of deck.cards) {
|
|
307
|
+
if (card.tags?.[map.id] !== void 0) names(`card "${effectiveGameId(card)}"`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
for (const template of box.handTemplates) {
|
|
311
|
+
if (template.bindings?.[map.id] !== void 0 || template.chooses?.includes(map.id) === true) {
|
|
312
|
+
names(`hand template "${effectiveGameId(template)}"`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
for (const hand of box.hands) {
|
|
316
|
+
if (hand.chosen?.[map.id] !== void 0 || hand.rule?.bindings?.[map.id] !== void 0) {
|
|
317
|
+
names(`hand "${effectiveGameId(hand)}"`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (problems.length > 0) throw new Error(`bundle refused: ${problems.join("; ")}`);
|
|
322
|
+
};
|
|
1162
323
|
var Engine = class _Engine {
|
|
1163
324
|
internals;
|
|
1164
325
|
seed;
|
|
@@ -1175,6 +336,7 @@ var Engine = class _Engine {
|
|
|
1175
336
|
* hotSwap puts this engine back exactly as it was. */
|
|
1176
337
|
sharedMounts = [];
|
|
1177
338
|
constructor(bundle, opts = {}) {
|
|
339
|
+
refuseUnreadableBundle(bundle);
|
|
1178
340
|
this.creationOptions = opts;
|
|
1179
341
|
this.seed = opts.seed ?? 0;
|
|
1180
342
|
this.onReplacedFlow = opts.onReplacedFlow;
|
|
@@ -1258,6 +420,10 @@ var Engine = class _Engine {
|
|
|
1258
420
|
indexOwner(internals.owners.hand, hand);
|
|
1259
421
|
}
|
|
1260
422
|
}
|
|
423
|
+
if (bundle.map !== void 0) {
|
|
424
|
+
internals.groupsById.set(bundle.map.group.id, { group: bundle.map.group });
|
|
425
|
+
if (bundle.map.group.required === true) internals.requiredGroups.add(bundle.map.group.id);
|
|
426
|
+
}
|
|
1261
427
|
this.initLadders();
|
|
1262
428
|
const declSet = (half) => ({
|
|
1263
429
|
story: half("story", bundle.story.properties),
|
|
@@ -1268,9 +434,9 @@ var Engine = class _Engine {
|
|
|
1268
434
|
hand: new Map(bundle.boxes.flatMap((box) => box.hands.map(
|
|
1269
435
|
(hand) => [hand.id, half("hand", handDeclsOf(internals, hand))]
|
|
1270
436
|
))),
|
|
1271
|
-
value: new Map(bundle
|
|
437
|
+
value: new Map(allTagGroups(bundle).flatMap((group) => group.tags.map(
|
|
1272
438
|
(tag) => [tag.id, half("value", tag.properties ?? [])]
|
|
1273
|
-
)))
|
|
439
|
+
)))
|
|
1274
440
|
});
|
|
1275
441
|
internals.flowDecls = declSet(flowHalf);
|
|
1276
442
|
internals.sharedDecls = declSet(sharedHalf);
|
|
@@ -1332,8 +498,8 @@ var Engine = class _Engine {
|
|
|
1332
498
|
reg.set("world", n, v, { host: true });
|
|
1333
499
|
} : void 0;
|
|
1334
500
|
} else {
|
|
1335
|
-
internals.worldSet = (n, v,
|
|
1336
|
-
reg.set("world", n, v,
|
|
501
|
+
internals.worldSet = (n, v, host) => {
|
|
502
|
+
reg.set("world", n, v, host === true ? { host: true } : void 0);
|
|
1337
503
|
};
|
|
1338
504
|
}
|
|
1339
505
|
}
|
|
@@ -1371,6 +537,7 @@ var Engine = class _Engine {
|
|
|
1371
537
|
}
|
|
1372
538
|
for (const hand of box.hands) internals.ladders.hand.set(hand.id, grab(handDeclsOf(internals, hand)));
|
|
1373
539
|
}
|
|
540
|
+
for (const tag of b.map?.group.tags ?? []) internals.ladders.value.set(tag.id, grab(tag.properties));
|
|
1374
541
|
const any = (m) => [...m.values()].some((x) => x.size > 0);
|
|
1375
542
|
internals.hasQualities = internals.ladders.world.size > 0 || internals.ladders.story.size > 0 || any(internals.ladders.box) || any(internals.ladders.deck) || any(internals.ladders.value) || any(internals.ladders.hand);
|
|
1376
543
|
}
|
|
@@ -1694,11 +861,35 @@ var Engine = class _Engine {
|
|
|
1694
861
|
return structuredClone({
|
|
1695
862
|
schema: SAVE_SCHEMA,
|
|
1696
863
|
content: this.internals.bundle.content,
|
|
1697
|
-
...this.internals.ownsRegistry ? { registry: this.
|
|
864
|
+
...this.internals.ownsRegistry ? { registry: this.registrySection() } : {},
|
|
1698
865
|
shared: { spent: [...this.spent].sort() },
|
|
1699
866
|
flows: Object.fromEntries([...this.flowsById].map(([id, flow]) => [id, flow.snapshot(false)]))
|
|
1700
867
|
});
|
|
1701
868
|
}
|
|
869
|
+
/** The registry's values in CANONICAL order, the order a load rebuilds them
|
|
870
|
+
* in: the engine-wide keys as the constructor registered them, then each
|
|
871
|
+
* flow's keys in `flows()` order (each flow's own registration order), then
|
|
872
|
+
* anything else the registry holds (values still waiting for a key), as the
|
|
873
|
+
* registry lists it. The registry itself lists keys in registration order,
|
|
874
|
+
* and a flow replaced in place (`open()` above keeps its slot in
|
|
875
|
+
* `flowsById`) re-registers its keys at the END, so `openFlow("a");
|
|
876
|
+
* openFlow("b"); openFlow("a")` saved b's keys before a's while a load
|
|
877
|
+
* rebuilt a's first: the same run, different `.storyletsave` bytes, and a
|
|
878
|
+
* save loaded and saved again no longer equal to itself. It is the
|
|
879
|
+
* 2026-08-29 rule carried into the section save@2 moved the per-flow values
|
|
880
|
+
* to (2026-10-01). Order does not matter on READ (`partitionsFromSections`
|
|
881
|
+
* sorts by key shape), so a save written in the old order loads as before. */
|
|
882
|
+
registrySection() {
|
|
883
|
+
const all = this.internals.registry.save();
|
|
884
|
+
const out = {};
|
|
885
|
+
const take = (key) => {
|
|
886
|
+
if (Object.prototype.hasOwnProperty.call(all, key) && !Object.prototype.hasOwnProperty.call(out, key)) out[key] = all[key];
|
|
887
|
+
};
|
|
888
|
+
for (const { key } of this.sharedMounts) take(key);
|
|
889
|
+
for (const flow of this.flowsById.values()) for (const key of flow.registeredKeys()) take(key);
|
|
890
|
+
for (const key of Object.keys(all)) take(key);
|
|
891
|
+
return out;
|
|
892
|
+
}
|
|
1702
893
|
/** ONE flow's blob, to park a visit that is walking away: the same shape
|
|
1703
894
|
* the envelope carries per flow, and the same shape `openFlow`'s `restore`
|
|
1704
895
|
* option takes back (design/engine-server.md 4.1). Saving the whole
|
|
@@ -2028,9 +1219,16 @@ var Flow = class {
|
|
|
2028
1219
|
/** Tag group names are box-scoped: two boxes may name a group the same way
|
|
2029
1220
|
* (schema 1 - boxes namespace their groups), so a name is only ever
|
|
2030
1221
|
* resolved inside the box being asked, never bundle-wide. Ids are
|
|
2031
|
-
* project-unique and accepted here too, still confined to the box.
|
|
1222
|
+
* project-unique and accepted here too, still confined to the box.
|
|
1223
|
+
*
|
|
1224
|
+
* A box on the project map sees ONE namespace: its own groups, then the
|
|
1225
|
+
* map's group (design/project-map-contract.md 3.1). A box that has not
|
|
1226
|
+
* opted in does not see the map's name at all, so a peek naming it there
|
|
1227
|
+
* is the ordinary unknown-group refusal. Own groups first is stated for
|
|
1228
|
+
* determinism only: a bundle that loads never has the two share a name. */
|
|
2032
1229
|
groupInBox(box, ref) {
|
|
2033
|
-
|
|
1230
|
+
const groups = groupsOfBox(this.internals.bundle, box);
|
|
1231
|
+
return groups.find((g) => effectiveGameId(g) === ref) ?? groups.find((g) => g.id === ref);
|
|
2034
1232
|
}
|
|
2035
1233
|
/** Fold one play into the indexes. O(the card's tags), not O(the log). */
|
|
2036
1234
|
indexPlay(record) {
|
|
@@ -2040,7 +1238,7 @@ var Flow = class {
|
|
|
2040
1238
|
if (!entry) return;
|
|
2041
1239
|
for (const [groupId, tagIds] of Object.entries(entry.card.tags ?? {})) {
|
|
2042
1240
|
for (const tagId of tagIds) {
|
|
2043
|
-
const key = tagKey(groupId, tagId);
|
|
1241
|
+
const key = tagKey(entry.box.id, groupId, tagId);
|
|
2044
1242
|
this.tagPlayCount.set(key, (this.tagPlayCount.get(key) ?? 0) + 1);
|
|
2045
1243
|
this.lastPlayInTag.set(key, record);
|
|
2046
1244
|
}
|
|
@@ -2056,8 +1254,10 @@ var Flow = class {
|
|
|
2056
1254
|
for (const record of this.playLog) this.indexPlay(record);
|
|
2057
1255
|
}
|
|
2058
1256
|
/** `box` is the box whose ask is being evaluated: the play-history
|
|
2059
|
-
* functions take a bare group name, so it resolves there
|
|
2060
|
-
*
|
|
1257
|
+
* functions take a bare group name, so it resolves there, and they count
|
|
1258
|
+
* only that box's own plays (the box is in the index key). That was
|
|
1259
|
+
* automatic while every group was a box's; a project-map zone is shared,
|
|
1260
|
+
* and its history is still not (design/project-map-contract.md 3.7, D7).
|
|
2061
1261
|
* History is THIS flow's: countPlayed answers "have I done this". */
|
|
2062
1262
|
/** One host per box, built once.
|
|
2063
1263
|
*
|
|
@@ -2082,7 +1282,7 @@ var Flow = class {
|
|
|
2082
1282
|
const keyOf = (group, tag) => {
|
|
2083
1283
|
const found = this.groupInBox(box, group);
|
|
2084
1284
|
const t = found?.tags.find((v) => v.gameId === tag);
|
|
2085
|
-
return found && t ? tagKey(found.id, t.id) : void 0;
|
|
1285
|
+
return found && t ? tagKey(box.id, found.id, t.id) : void 0;
|
|
2086
1286
|
};
|
|
2087
1287
|
const since = (record) => {
|
|
2088
1288
|
const entry = this.internals.cardsByGameId.get(record.card);
|
|
@@ -2289,7 +1489,7 @@ var Flow = class {
|
|
|
2289
1489
|
* is what says otherwise.
|
|
2290
1490
|
*/
|
|
2291
1491
|
bindStateGroups(box, boundTags, askNames) {
|
|
2292
|
-
for (const group of box
|
|
1492
|
+
for (const group of groupsOfBox(this.internals.bundle, box)) {
|
|
2293
1493
|
if (group.boundBy === void 0 || boundTags.has(group.id)) continue;
|
|
2294
1494
|
const ref = /^@(world|story)\.([a-z][a-z0-9_-]*)$/.exec(group.boundBy);
|
|
2295
1495
|
if (!ref) {
|
|
@@ -2948,6 +2148,7 @@ var Flow = class {
|
|
|
2948
2148
|
};
|
|
2949
2149
|
|
|
2950
2150
|
// src/describe.ts
|
|
2151
|
+
import { effectiveGameId as effectiveGameId2, groupsOfBox as groupsOfBox2, isHoleRef as isHoleRef2 } from "@storylet-studio/model";
|
|
2951
2152
|
var summarise = (decls) => decls.map((d) => ({
|
|
2952
2153
|
name: d.name,
|
|
2953
2154
|
type: d.type,
|
|
@@ -2963,14 +2164,14 @@ var handDecls = (hand, box) => {
|
|
|
2963
2164
|
}
|
|
2964
2165
|
return hand.properties ?? [];
|
|
2965
2166
|
};
|
|
2966
|
-
var movableHoles = (hand, box) => {
|
|
2167
|
+
var movableHoles = (bundle, hand, box) => {
|
|
2967
2168
|
const filled = hand.template !== void 0 ? hand.chosen : hand.rule?.bindings;
|
|
2968
2169
|
const out = [];
|
|
2969
2170
|
for (const [groupId, value] of Object.entries(filled ?? {})) {
|
|
2970
|
-
if (!
|
|
2971
|
-
const group = box.
|
|
2171
|
+
if (!isHoleRef2(value)) continue;
|
|
2172
|
+
const group = groupsOfBox2(bundle, box).find((g) => g.id === groupId);
|
|
2972
2173
|
if (group === void 0) continue;
|
|
2973
|
-
out.push({ group:
|
|
2174
|
+
out.push({ group: effectiveGameId2(group), from: value });
|
|
2974
2175
|
}
|
|
2975
2176
|
return out;
|
|
2976
2177
|
};
|
|
@@ -2988,17 +2189,18 @@ function describeBundle(bundle) {
|
|
|
2988
2189
|
];
|
|
2989
2190
|
const totals = { boxes: 0, decks: 0, cards: 0, hands: 0, templates: 0, tagGroups: 0 };
|
|
2990
2191
|
for (const box of bundle.boxes) {
|
|
2991
|
-
const boxGameId =
|
|
2192
|
+
const boxGameId = effectiveGameId2(box);
|
|
2992
2193
|
const cards = box.decks.reduce((n, deck) => n + deck.cards.length, 0);
|
|
2993
2194
|
boxes.push({
|
|
2994
2195
|
gameId: boxGameId,
|
|
2995
2196
|
...box.title !== void 0 ? { title: box.title } : {},
|
|
2197
|
+
...box.usesMap === true ? { usesMap: true } : {},
|
|
2996
2198
|
ranking: { specificity: box.ranking.specificity },
|
|
2997
2199
|
...box.turn !== void 0 ? { turn: { seconds: box.turn.seconds } } : {},
|
|
2998
2200
|
...durableCardCount(box) > 0 ? { durableCards: durableCardCount(box) } : {},
|
|
2999
2201
|
tagGroups: box.tagGroups.map((group) => ({
|
|
3000
|
-
gameId:
|
|
3001
|
-
tags: group.tags.map((tag) =>
|
|
2202
|
+
gameId: effectiveGameId2(group),
|
|
2203
|
+
tags: group.tags.map((tag) => effectiveGameId2(tag))
|
|
3002
2204
|
})),
|
|
3003
2205
|
counts: {
|
|
3004
2206
|
decks: box.decks.length,
|
|
@@ -3016,13 +2218,13 @@ function describeBundle(bundle) {
|
|
|
3016
2218
|
totals.tagGroups += box.tagGroups.length;
|
|
3017
2219
|
for (const hand of box.hands) {
|
|
3018
2220
|
const template = hand.template !== void 0 ? box.handTemplates.find((t) => t.id === hand.template) : void 0;
|
|
3019
|
-
const movable = movableHoles(hand, box);
|
|
2221
|
+
const movable = movableHoles(bundle, hand, box);
|
|
3020
2222
|
hands.push({
|
|
3021
|
-
gameId:
|
|
2223
|
+
gameId: effectiveGameId2(hand),
|
|
3022
2224
|
...hand.title !== void 0 ? { title: hand.title } : {},
|
|
3023
2225
|
box: boxGameId,
|
|
3024
2226
|
slots: handSlots(hand, box),
|
|
3025
|
-
...template !== void 0 ? { template:
|
|
2227
|
+
...template !== void 0 ? { template: effectiveGameId2(template) } : {},
|
|
3026
2228
|
...movable.length > 0 ? { movable } : {}
|
|
3027
2229
|
});
|
|
3028
2230
|
}
|
|
@@ -3037,14 +2239,23 @@ function describeBundle(bundle) {
|
|
|
3037
2239
|
});
|
|
3038
2240
|
};
|
|
3039
2241
|
push("box", boxGameId, box.properties);
|
|
3040
|
-
for (const deck of box.decks) push("deck",
|
|
3041
|
-
for (const hand of box.hands) push("hand",
|
|
2242
|
+
for (const deck of box.decks) push("deck", effectiveGameId2(deck), deck.properties);
|
|
2243
|
+
for (const hand of box.hands) push("hand", effectiveGameId2(hand), handDecls(hand, box));
|
|
3042
2244
|
for (const group of box.tagGroups) {
|
|
3043
2245
|
for (const tag of group.tags) {
|
|
3044
|
-
push("tag",
|
|
2246
|
+
push("tag", effectiveGameId2(tag), tag.properties ?? [], effectiveGameId2(group));
|
|
3045
2247
|
}
|
|
3046
2248
|
}
|
|
3047
2249
|
}
|
|
2250
|
+
const map = bundle.map;
|
|
2251
|
+
if (map !== void 0) {
|
|
2252
|
+
const group = effectiveGameId2(map.group);
|
|
2253
|
+
for (const tag of map.group.tags) {
|
|
2254
|
+
const decls = tag.properties ?? [];
|
|
2255
|
+
if (decls.length > 0) properties.push({ scope: "tag", owner: effectiveGameId2(tag), group, properties: summarise(decls) });
|
|
2256
|
+
}
|
|
2257
|
+
totals.tagGroups += 1;
|
|
2258
|
+
}
|
|
3048
2259
|
return {
|
|
3049
2260
|
identity: {
|
|
3050
2261
|
schema: bundle.schema,
|
|
@@ -3057,13 +2268,16 @@ function describeBundle(bundle) {
|
|
|
3057
2268
|
boxes,
|
|
3058
2269
|
hands,
|
|
3059
2270
|
properties,
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
2271
|
+
...map !== void 0 ? {
|
|
2272
|
+
map: {
|
|
2273
|
+
group: effectiveGameId2(map.group),
|
|
2274
|
+
tags: map.group.tags.map((tag) => effectiveGameId2(tag)),
|
|
2275
|
+
boxes: bundle.boxes.filter((box) => box.usesMap === true).map((box) => effectiveGameId2(box)),
|
|
2276
|
+
zones: map.geometry?.zones.length ?? 0,
|
|
2277
|
+
backgrounds: map.geometry?.backgrounds?.length ?? 0,
|
|
2278
|
+
sites: Object.fromEntries(Object.entries(map.geometry?.sites ?? {}).map(([box, sites]) => [box, sites.length]))
|
|
2279
|
+
}
|
|
2280
|
+
} : {}
|
|
3067
2281
|
};
|
|
3068
2282
|
}
|
|
3069
2283
|
export {
|