@storylet-studio/runtime 0.2.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 +16 -0
- package/dist/index.cjs +1972 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +656 -0
- package/dist/index.d.ts +656 -0
- package/dist/index.js +1941 -0
- package/dist/index.js.map +1 -0
- package/dist/storyletengine.min.js +2 -0
- package/dist/storyletengine.min.js.map +1 -0
- package/package.json +36 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1941 @@
|
|
|
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/index.ts
|
|
261
|
+
var NEVER_PLAYED = 9999;
|
|
262
|
+
var host = (h) => h.ctx.host ?? {};
|
|
263
|
+
var stringArg = (fn, args, h, i) => {
|
|
264
|
+
const v = h.evaluate(args[i]);
|
|
265
|
+
if (typeof v !== "string" || v === "") {
|
|
266
|
+
throw new EvalError(`${fn}() argument ${i + 1} must be a non-empty string`);
|
|
267
|
+
}
|
|
268
|
+
return v;
|
|
269
|
+
};
|
|
270
|
+
var flagsArg = (fn, args, h) => {
|
|
271
|
+
if (args.length === 0) {
|
|
272
|
+
throw new EvalError(`${fn}() requires at least one argument (the flags property)`);
|
|
273
|
+
}
|
|
274
|
+
const v = h.evaluate(args[0]);
|
|
275
|
+
if (Array.isArray(v)) return v;
|
|
276
|
+
if (v === false) return [];
|
|
277
|
+
throw new EvalError(`${fn}() first argument must be a flags property`);
|
|
278
|
+
};
|
|
279
|
+
var storyletsDialect = {
|
|
280
|
+
// A missing property in a PRESENT scope is always an error: every property
|
|
281
|
+
// is declared with a default, so absence means a publish bug, a drifted
|
|
282
|
+
// save, or a foreign scope the host never fed (schema 6.2).
|
|
283
|
+
scopes: [
|
|
284
|
+
{ token: "story", missing: "throw" },
|
|
285
|
+
{ token: "world", missing: "throw" },
|
|
286
|
+
{ token: "box", missing: "throw" },
|
|
287
|
+
{ token: "deck", missing: "throw" },
|
|
288
|
+
{ token: "hand", missing: "throw" }
|
|
289
|
+
],
|
|
290
|
+
defaultScope: "story",
|
|
291
|
+
functions: {
|
|
292
|
+
random: {
|
|
293
|
+
minArgs: 2,
|
|
294
|
+
maxArgs: 2,
|
|
295
|
+
returnType: "number",
|
|
296
|
+
eval(args, h) {
|
|
297
|
+
if (args.length !== 2) throw new EvalError("random(a, b) requires exactly 2 arguments");
|
|
298
|
+
const nextRandom = host(h).nextRandom;
|
|
299
|
+
if (!nextRandom) throw new EvalError("random() called without a PRNG in context");
|
|
300
|
+
const a = h.evaluate(args[0]);
|
|
301
|
+
const b = h.evaluate(args[1]);
|
|
302
|
+
if (typeof a !== "number" || typeof b !== "number") {
|
|
303
|
+
throw new EvalError("random(a, b) arguments must be numbers");
|
|
304
|
+
}
|
|
305
|
+
if (!Number.isInteger(a) || !Number.isInteger(b)) {
|
|
306
|
+
throw new EvalError("random(a, b) arguments must be integers");
|
|
307
|
+
}
|
|
308
|
+
const lo = Math.min(a, b);
|
|
309
|
+
const hi = Math.max(a, b);
|
|
310
|
+
return Math.floor(nextRandom() * (hi - lo + 1)) + lo;
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
check_flags: {
|
|
314
|
+
minArgs: 1,
|
|
315
|
+
returnType: "boolean",
|
|
316
|
+
flagDeltaArgs: true,
|
|
317
|
+
eval(args, h) {
|
|
318
|
+
const flags = flagsArg("check_flags", args, h);
|
|
319
|
+
for (let i = 1; i < args.length; i++) {
|
|
320
|
+
const arg = args[i];
|
|
321
|
+
if (arg.kind !== "flagdelta") {
|
|
322
|
+
throw new EvalError("check_flags() flag args must be +flagName or -flagName");
|
|
323
|
+
}
|
|
324
|
+
if (arg.sign === "+" ? !flags.includes(arg.name) : flags.includes(arg.name)) {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
set_flags: {
|
|
332
|
+
minArgs: 1,
|
|
333
|
+
returnType: "flags",
|
|
334
|
+
flagDeltaArgs: true,
|
|
335
|
+
eval(args, h) {
|
|
336
|
+
const result = [...flagsArg("set_flags", args, h)];
|
|
337
|
+
for (let i = 1; i < args.length; i++) {
|
|
338
|
+
const arg = args[i];
|
|
339
|
+
if (arg.kind !== "flagdelta") {
|
|
340
|
+
throw new EvalError("set_flags() flag args must be +flagName or -flagName");
|
|
341
|
+
}
|
|
342
|
+
if (arg.sign === "+") {
|
|
343
|
+
if (!result.includes(arg.name)) result.push(arg.name);
|
|
344
|
+
} else {
|
|
345
|
+
const idx = result.indexOf(arg.name);
|
|
346
|
+
if (idx >= 0) result.splice(idx, 1);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return result.sort();
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
count_played: {
|
|
353
|
+
minArgs: 1,
|
|
354
|
+
maxArgs: 1,
|
|
355
|
+
returnType: "number",
|
|
356
|
+
eval(args, h) {
|
|
357
|
+
const card = stringArg("count_played", args, h, 0);
|
|
358
|
+
const fn = host(h).countPlayed;
|
|
359
|
+
if (!fn) throw new EvalError("count_played() called without a play log in context");
|
|
360
|
+
return fn(card);
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
turns_since_played: {
|
|
364
|
+
minArgs: 1,
|
|
365
|
+
maxArgs: 1,
|
|
366
|
+
returnType: "number",
|
|
367
|
+
eval(args, h) {
|
|
368
|
+
const card = stringArg("turns_since_played", args, h, 0);
|
|
369
|
+
const fn = host(h).turnsSincePlayed;
|
|
370
|
+
if (!fn) throw new EvalError("turns_since_played() called without a play log in context");
|
|
371
|
+
return fn(card);
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
count_played_in: {
|
|
375
|
+
minArgs: 2,
|
|
376
|
+
maxArgs: 2,
|
|
377
|
+
returnType: "number",
|
|
378
|
+
eval(args, h) {
|
|
379
|
+
const dimension = stringArg("count_played_in", args, h, 0);
|
|
380
|
+
const value = stringArg("count_played_in", args, h, 1);
|
|
381
|
+
const fn = host(h).countPlayedIn;
|
|
382
|
+
if (!fn) throw new EvalError("count_played_in() called without a play log in context");
|
|
383
|
+
return fn(dimension, value);
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
turns_since_played_in: {
|
|
387
|
+
minArgs: 2,
|
|
388
|
+
maxArgs: 2,
|
|
389
|
+
returnType: "number",
|
|
390
|
+
eval(args, h) {
|
|
391
|
+
const dimension = stringArg("turns_since_played_in", args, h, 0);
|
|
392
|
+
const value = stringArg("turns_since_played_in", args, h, 1);
|
|
393
|
+
const fn = host(h).turnsSincePlayedIn;
|
|
394
|
+
if (!fn) throw new EvalError("turns_since_played_in() called without a play log in context");
|
|
395
|
+
return fn(dimension, value);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
// ../model/src/index.ts
|
|
402
|
+
function gameIdify(text) {
|
|
403
|
+
return text.toLowerCase().replace(/['’]/g, "").replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
|
|
404
|
+
}
|
|
405
|
+
function effectiveGameId(entity) {
|
|
406
|
+
const pinned = entity.gameId?.trim();
|
|
407
|
+
if (pinned) return pinned;
|
|
408
|
+
const fromTitle = entity.title ? gameIdify(entity.title) : "";
|
|
409
|
+
return fromTitle || entity.id;
|
|
410
|
+
}
|
|
411
|
+
var PLACE_GROUP = "place";
|
|
412
|
+
|
|
413
|
+
// ../../../expr/packages/scoperegistry/src/index.ts
|
|
414
|
+
var PropertyBag = class _PropertyBag {
|
|
415
|
+
/** The live values record (stable identity across reseed, so an
|
|
416
|
+
* EvalContext built over it stays valid). Read-path for evaluation;
|
|
417
|
+
* writes go through `set` so the firing rule applies. */
|
|
418
|
+
values = {};
|
|
419
|
+
decls = /* @__PURE__ */ new Map();
|
|
420
|
+
subscribers = /* @__PURE__ */ new Set();
|
|
421
|
+
auditors = /* @__PURE__ */ new Set();
|
|
422
|
+
/** Name normalisation policy: lowercase by default (the registry's
|
|
423
|
+
* long-standing contract); a product whose names are case-significant
|
|
424
|
+
* passes identity. */
|
|
425
|
+
norm;
|
|
426
|
+
constructor(declarations = [], opts) {
|
|
427
|
+
this.norm = opts?.normalise ?? ((n) => n.toLowerCase());
|
|
428
|
+
this.seed(declarations);
|
|
429
|
+
}
|
|
430
|
+
seed(declarations) {
|
|
431
|
+
for (const d of declarations) {
|
|
432
|
+
const name = this.norm(d.name);
|
|
433
|
+
this.decls.set(name, d);
|
|
434
|
+
this.values[name] = structuredClone(d.default ?? defaultFor(d));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
get(name) {
|
|
438
|
+
return this.values[this.norm(name)];
|
|
439
|
+
}
|
|
440
|
+
/** Write a property. Engine writes (the default) notify subscribers;
|
|
441
|
+
* pass `silent: true` for a host write, which reaches only the audit
|
|
442
|
+
* hook. Throws on a read-only property. Returns the change. */
|
|
443
|
+
set(name, value, opts) {
|
|
444
|
+
const n = this.norm(name);
|
|
445
|
+
if (this.decls.get(n)?.writable === false) throw new Error(`'${name}' is read-only`);
|
|
446
|
+
const change = {
|
|
447
|
+
name: n,
|
|
448
|
+
prev: this.values[n],
|
|
449
|
+
next: value,
|
|
450
|
+
silent: opts?.silent ?? false,
|
|
451
|
+
reason: opts?.reason
|
|
452
|
+
};
|
|
453
|
+
this.values[n] = value;
|
|
454
|
+
for (const audit of this.auditors) audit(change);
|
|
455
|
+
if (!change.silent) for (const fn of this.subscribers) fn(change);
|
|
456
|
+
return change;
|
|
457
|
+
}
|
|
458
|
+
/** Notified of engine (non-silent) writes. Returns the unsubscribe. */
|
|
459
|
+
subscribe(fn) {
|
|
460
|
+
this.subscribers.add(fn);
|
|
461
|
+
return () => this.subscribers.delete(fn);
|
|
462
|
+
}
|
|
463
|
+
/** Notified of EVERY write, silent or not. Returns the unsubscribe. */
|
|
464
|
+
onAudit(fn) {
|
|
465
|
+
this.auditors.add(fn);
|
|
466
|
+
return () => this.auditors.delete(fn);
|
|
467
|
+
}
|
|
468
|
+
/** Examiner rows: the declared surface only (stray values are storage,
|
|
469
|
+
* not surface). */
|
|
470
|
+
rows() {
|
|
471
|
+
return [...this.decls.entries()].map(([name, d]) => rowFor(d, this.get(name), void 0, name));
|
|
472
|
+
}
|
|
473
|
+
declarations() {
|
|
474
|
+
return [...this.decls.values()];
|
|
475
|
+
}
|
|
476
|
+
/** The one sanctioned copy door: values deep-copied, declarations
|
|
477
|
+
* duplicated, the normalisation policy carried, subscriptions NOT
|
|
478
|
+
* carried. */
|
|
479
|
+
clone() {
|
|
480
|
+
const c = new _PropertyBag([], { normalise: this.norm });
|
|
481
|
+
c.decls = new Map(this.decls);
|
|
482
|
+
Object.assign(c.values, structuredClone(this.values));
|
|
483
|
+
return c;
|
|
484
|
+
}
|
|
485
|
+
/** Clear and re-seed from new declarations, in place (the values record
|
|
486
|
+
* keeps its identity, so contexts built over it stay valid). */
|
|
487
|
+
reseed(declarations) {
|
|
488
|
+
for (const k of Object.keys(this.values)) delete this.values[k];
|
|
489
|
+
this.decls.clear();
|
|
490
|
+
this.seed(declarations);
|
|
491
|
+
}
|
|
492
|
+
/** Bare values, ready to embed in a product's save. */
|
|
493
|
+
save() {
|
|
494
|
+
return structuredClone(this.values);
|
|
495
|
+
}
|
|
496
|
+
/** Lay saved values over the current ones (call after a fresh seed:
|
|
497
|
+
* orphans land as strays, new declarations keep their defaults; the
|
|
498
|
+
* product decides whether to prune). Does not fire events. */
|
|
499
|
+
load(values) {
|
|
500
|
+
for (const [k, v] of Object.entries(values)) this.values[this.norm(k)] = v;
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
function rowFor(d, value, writable, name) {
|
|
504
|
+
return {
|
|
505
|
+
name: name ?? d.name.toLowerCase(),
|
|
506
|
+
type: d.type,
|
|
507
|
+
value,
|
|
508
|
+
default: d.default ?? defaultFor(d),
|
|
509
|
+
...d.values !== void 0 ? { values: d.values } : {},
|
|
510
|
+
writable: writable ?? d.writable ?? true
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
function defaultFor(d) {
|
|
514
|
+
if (d.default !== void 0) return d.default;
|
|
515
|
+
switch (d.type) {
|
|
516
|
+
case "boolean":
|
|
517
|
+
return false;
|
|
518
|
+
case "number":
|
|
519
|
+
return 0;
|
|
520
|
+
case "string":
|
|
521
|
+
return "";
|
|
522
|
+
case "enum":
|
|
523
|
+
return d.values?.[0] ?? "";
|
|
524
|
+
case "flags":
|
|
525
|
+
return [];
|
|
526
|
+
// A quality starts at the first rung of its ladder.
|
|
527
|
+
case "quality":
|
|
528
|
+
return d.stages?.[0] ?? "";
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// src/engine.ts
|
|
533
|
+
var tagKey = (groupId, tagId) => `${groupId}${tagId}`;
|
|
534
|
+
var cardIsShared = (card, deckShared) => card.shared ?? deckShared;
|
|
535
|
+
var sharedCap = (card) => card.sharedCopies ?? card.copies ?? 1;
|
|
536
|
+
var bagFromDecls = (decls) => new PropertyBag(decls, { normalise: (n) => n });
|
|
537
|
+
function conditionPasses(v) {
|
|
538
|
+
if (typeof v === "boolean") return v;
|
|
539
|
+
if (typeof v === "number") return v !== 0;
|
|
540
|
+
if (typeof v === "string") return v !== "";
|
|
541
|
+
return v.length > 0;
|
|
542
|
+
}
|
|
543
|
+
var SCOPE_DEFAULT_SHARED = { story: true, box: false, deck: false, hand: false, value: false };
|
|
544
|
+
var isShared = (scope, d) => d.shared ?? SCOPE_DEFAULT_SHARED[scope];
|
|
545
|
+
var sharedHalf = (scope, decls) => decls.filter((d) => isShared(scope, d));
|
|
546
|
+
var flowHalf = (scope, decls) => decls.filter((d) => !isShared(scope, d));
|
|
547
|
+
var handDeclsOf = (internals, hand) => {
|
|
548
|
+
if (hand.template !== void 0) {
|
|
549
|
+
return internals.templatesById.get(hand.template)?.properties ?? internals.bundle.boxes.flatMap((b) => b.handTemplates).find((t) => t.id === hand.template)?.properties ?? [];
|
|
550
|
+
}
|
|
551
|
+
return hand.properties ?? [];
|
|
552
|
+
};
|
|
553
|
+
var buildPartition = (internals, half) => {
|
|
554
|
+
const b = internals.bundle;
|
|
555
|
+
return {
|
|
556
|
+
story: bagFromDecls(half("story", b.story.properties)),
|
|
557
|
+
box: new Map(b.boxes.map((box) => [box.id, bagFromDecls(half("box", box.properties))])),
|
|
558
|
+
deck: new Map(b.boxes.flatMap((box) => box.decks.map(
|
|
559
|
+
(deck) => [deck.id, bagFromDecls(half("deck", deck.properties))]
|
|
560
|
+
))),
|
|
561
|
+
// A template instance inherits the template's property declarations;
|
|
562
|
+
// a standalone hand declares its own (schema 2.6).
|
|
563
|
+
hand: new Map(b.boxes.flatMap((box) => box.hands.map(
|
|
564
|
+
(hand) => [hand.id, bagFromDecls(half("hand", handDeclsOf(internals, hand)))]
|
|
565
|
+
))),
|
|
566
|
+
value: new Map(b.boxes.flatMap((box) => box.tagGroups.flatMap((group) => group.tags.map(
|
|
567
|
+
(tag) => [tag.id, bagFromDecls(half("value", tag.properties ?? []))]
|
|
568
|
+
))))
|
|
569
|
+
};
|
|
570
|
+
};
|
|
571
|
+
var partitionValues = (p) => ({
|
|
572
|
+
story: p.story.values,
|
|
573
|
+
box: Object.fromEntries([...p.box].map(([id, bag]) => [id, bag.values])),
|
|
574
|
+
deck: Object.fromEntries([...p.deck].map(([id, bag]) => [id, bag.values])),
|
|
575
|
+
hand: Object.fromEntries([...p.hand].map(([id, bag]) => [id, bag.values])),
|
|
576
|
+
value: Object.fromEntries([...p.value].map(([id, bag]) => [id, bag.values]))
|
|
577
|
+
});
|
|
578
|
+
var loadPartition = (p, values) => {
|
|
579
|
+
p.story.load(values?.story ?? {});
|
|
580
|
+
for (const kind of ["box", "deck", "hand", "value"]) {
|
|
581
|
+
for (const [id, bag] of Object.entries(values?.[kind] ?? {})) {
|
|
582
|
+
p[kind].get(id)?.load(bag);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
var Engine = class {
|
|
587
|
+
internals;
|
|
588
|
+
seed;
|
|
589
|
+
flowsById = /* @__PURE__ */ new Map();
|
|
590
|
+
engineTraceHandlers = /* @__PURE__ */ new Set();
|
|
591
|
+
/** The host's @world binding, if the engine was built with one: it
|
|
592
|
+
* outlives reset/loadGame (the host's container is the host's). The
|
|
593
|
+
* self-backed resolver is rebuilt instead. */
|
|
594
|
+
hostWorld;
|
|
595
|
+
constructor(bundle, opts = {}) {
|
|
596
|
+
this.seed = opts.seed ?? 0;
|
|
597
|
+
if (opts.world !== void 0) this.hostWorld = opts.world;
|
|
598
|
+
const internals = {
|
|
599
|
+
bundle,
|
|
600
|
+
...opts.log ? { logCap: typeof opts.log === "object" ? opts.log.cap ?? 1e3 : 1e3 } : {},
|
|
601
|
+
cardsById: /* @__PURE__ */ new Map(),
|
|
602
|
+
cardsByGameId: /* @__PURE__ */ new Map(),
|
|
603
|
+
boxesByGameId: /* @__PURE__ */ new Map(),
|
|
604
|
+
boxesById: /* @__PURE__ */ new Map(),
|
|
605
|
+
handsById: /* @__PURE__ */ new Map(),
|
|
606
|
+
handsByGameId: /* @__PURE__ */ new Map(),
|
|
607
|
+
templatesById: /* @__PURE__ */ new Map(),
|
|
608
|
+
groupsById: /* @__PURE__ */ new Map(),
|
|
609
|
+
requiredGroups: /* @__PURE__ */ new Set(),
|
|
610
|
+
nodeCache: /* @__PURE__ */ new WeakMap(),
|
|
611
|
+
ladders: { world: /* @__PURE__ */ new Map(), story: /* @__PURE__ */ new Map(), box: /* @__PURE__ */ new Map(), deck: /* @__PURE__ */ new Map(), value: /* @__PURE__ */ new Map(), hand: /* @__PURE__ */ new Map() },
|
|
612
|
+
hasQualities: false,
|
|
613
|
+
hasShared: false,
|
|
614
|
+
flowDecls: { story: [], box: /* @__PURE__ */ new Map(), deck: /* @__PURE__ */ new Map(), hand: /* @__PURE__ */ new Map(), value: /* @__PURE__ */ new Map() },
|
|
615
|
+
shared: void 0,
|
|
616
|
+
worldResolver: void 0,
|
|
617
|
+
emitEngine: (flow, event, turn) => {
|
|
618
|
+
if (this.internals.logCap !== void 0) {
|
|
619
|
+
this.engineLog.push({ ...event, flow, seq: this.engineSeq++, ...turn !== void 0 ? { turn } : {} });
|
|
620
|
+
if (this.engineLog.length > this.internals.logCap) {
|
|
621
|
+
this.engineLog.splice(0, this.engineLog.length - this.internals.logCap);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
for (const h of this.engineTraceHandlers) h(flow, event);
|
|
625
|
+
},
|
|
626
|
+
engineTracing: () => this.engineTraceHandlers.size > 0
|
|
627
|
+
};
|
|
628
|
+
this.internals = internals;
|
|
629
|
+
for (const box of bundle.boxes) {
|
|
630
|
+
internals.boxesById.set(box.id, box);
|
|
631
|
+
internals.boxesByGameId.set(effectiveGameId(box), box);
|
|
632
|
+
for (const group of box.tagGroups) {
|
|
633
|
+
internals.groupsById.set(group.id, { group, box });
|
|
634
|
+
if (group.required === true) internals.requiredGroups.add(group.id);
|
|
635
|
+
}
|
|
636
|
+
for (const deck of box.decks) {
|
|
637
|
+
if (deck.shared === true) internals.hasShared = true;
|
|
638
|
+
for (const card of deck.cards) {
|
|
639
|
+
const entry = { card, deck, box };
|
|
640
|
+
internals.cardsById.set(card.id, entry);
|
|
641
|
+
internals.cardsByGameId.set(effectiveGameId(card), entry);
|
|
642
|
+
if (card.shared === true) internals.hasShared = true;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
for (const template of box.handTemplates) {
|
|
646
|
+
internals.templatesById.set(template.id, template);
|
|
647
|
+
}
|
|
648
|
+
for (const hand of box.hands) {
|
|
649
|
+
internals.handsById.set(hand.id, { hand, box });
|
|
650
|
+
internals.handsByGameId.set(effectiveGameId(hand), { hand, box });
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
this.initLadders();
|
|
654
|
+
internals.flowDecls = {
|
|
655
|
+
story: flowHalf("story", bundle.story.properties),
|
|
656
|
+
box: new Map(bundle.boxes.map((box) => [box.id, flowHalf("box", box.properties)])),
|
|
657
|
+
deck: new Map(bundle.boxes.flatMap((box) => box.decks.map(
|
|
658
|
+
(deck) => [deck.id, flowHalf("deck", deck.properties)]
|
|
659
|
+
))),
|
|
660
|
+
hand: new Map(bundle.boxes.flatMap((box) => box.hands.map(
|
|
661
|
+
(hand) => [hand.id, flowHalf("hand", handDeclsOf(internals, hand))]
|
|
662
|
+
))),
|
|
663
|
+
value: new Map(bundle.boxes.flatMap((box) => box.tagGroups.flatMap((group) => group.tags.map(
|
|
664
|
+
(tag) => [tag.id, flowHalf("value", tag.properties ?? [])]
|
|
665
|
+
))))
|
|
666
|
+
};
|
|
667
|
+
this.initShared(this.hostWorld);
|
|
668
|
+
}
|
|
669
|
+
/** Build the shared stores and the @world seam. `hostWorld` sticks for the
|
|
670
|
+
* engine's lifetime; reset/loadGame rebuild the shared bags around it. */
|
|
671
|
+
initShared(hostWorld) {
|
|
672
|
+
const internals = this.internals;
|
|
673
|
+
internals.shared = buildPartition(internals, sharedHalf);
|
|
674
|
+
if (hostWorld !== void 0) {
|
|
675
|
+
internals.worldResolver = hostWorld;
|
|
676
|
+
} else {
|
|
677
|
+
const bag = bagFromDecls(internals.bundle.world.properties);
|
|
678
|
+
internals.worldResolver = {
|
|
679
|
+
get: (n) => bag.get(n),
|
|
680
|
+
set: (n, v) => {
|
|
681
|
+
bag.set(n, v);
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
/** Quality ladders by scope for the eval channel (design/quality.md):
|
|
687
|
+
* world/story keyed by name; box/deck/value keyed by owner id then name.
|
|
688
|
+
* Built once - a bundle's declarations never change. Ladders are
|
|
689
|
+
* declaration-level, so the sharing flag does not touch them. */
|
|
690
|
+
initLadders() {
|
|
691
|
+
const internals = this.internals;
|
|
692
|
+
const grab = (decls) => {
|
|
693
|
+
const m = /* @__PURE__ */ new Map();
|
|
694
|
+
for (const d of decls ?? []) if (d.type === "quality" && d.stages !== void 0) m.set(d.name, d.stages);
|
|
695
|
+
return m;
|
|
696
|
+
};
|
|
697
|
+
const b = internals.bundle;
|
|
698
|
+
internals.ladders.world = grab(b.world.properties);
|
|
699
|
+
internals.ladders.story = grab(b.story.properties);
|
|
700
|
+
for (const box of b.boxes) {
|
|
701
|
+
internals.ladders.box.set(box.id, grab(box.properties));
|
|
702
|
+
for (const deck of box.decks) internals.ladders.deck.set(deck.id, grab(deck.properties));
|
|
703
|
+
for (const group of box.tagGroups) {
|
|
704
|
+
for (const tag of group.tags) internals.ladders.value.set(tag.id, grab(tag.properties));
|
|
705
|
+
}
|
|
706
|
+
for (const hand of box.hands) internals.ladders.hand.set(hand.id, grab(handDeclsOf(internals, hand)));
|
|
707
|
+
}
|
|
708
|
+
const any = (m) => [...m.values()].some((x) => x.size > 0);
|
|
709
|
+
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);
|
|
710
|
+
}
|
|
711
|
+
// --- flow management (Patter's surface, name for name) ----------------------
|
|
712
|
+
/** Open (or REPLACE) the named flow. An existing id's flow is closed
|
|
713
|
+
* first - re-opening a name is a reset of that name's whole per-flow
|
|
714
|
+
* state; shared state is untouched. There is no default flow: "main" is
|
|
715
|
+
* a caller convention, not an engine rule. */
|
|
716
|
+
openFlow(id, opts = {}) {
|
|
717
|
+
this.flowsById.get(id)?.markClosed();
|
|
718
|
+
const flow = new Flow(this, this.internals, id, opts.seed ?? this.seed);
|
|
719
|
+
this.flowsById.set(id, flow);
|
|
720
|
+
return flow;
|
|
721
|
+
}
|
|
722
|
+
getFlow(id) {
|
|
723
|
+
return this.flowsById.get(id);
|
|
724
|
+
}
|
|
725
|
+
/** Every live flow, open order. */
|
|
726
|
+
flows() {
|
|
727
|
+
return [...this.flowsById.values()];
|
|
728
|
+
}
|
|
729
|
+
/** Close the named flow: its handle goes INERT (every verb throws). A
|
|
730
|
+
* dropped-but-held flow must not keep writing shared state (Patter's
|
|
731
|
+
* stale-handle lesson). Unknown ids are a quiet no-op, like closing a
|
|
732
|
+
* closed door. */
|
|
733
|
+
closeFlow(id) {
|
|
734
|
+
const flow = this.flowsById.get(id);
|
|
735
|
+
if (!flow) return;
|
|
736
|
+
this.flowsById.delete(id);
|
|
737
|
+
flow.markClosed();
|
|
738
|
+
}
|
|
739
|
+
/** @internal - Flow.close() routes here so both doors agree. */
|
|
740
|
+
dropFlow(id, flow) {
|
|
741
|
+
if (this.flowsById.get(id) === flow) this.flowsById.delete(id);
|
|
742
|
+
}
|
|
743
|
+
/** Close every flow and reseed the shared state to its defaults (the
|
|
744
|
+
* self-backed @world included; a host-bound @world is the host's and is
|
|
745
|
+
* not touched). */
|
|
746
|
+
reset() {
|
|
747
|
+
this.engineLog = [];
|
|
748
|
+
for (const flow of this.flowsById.values()) flow.markClosed();
|
|
749
|
+
this.flowsById.clear();
|
|
750
|
+
this.spent.clear();
|
|
751
|
+
this.initShared(this.hostWorld);
|
|
752
|
+
}
|
|
753
|
+
// --- shared scarcity (design/shared-scarcity.md) -----------------------------
|
|
754
|
+
/** Cards a shared `redraw: "never"` has taken out of the world, by card id.
|
|
755
|
+
* The claim ledger is DERIVED from live boards and so needs no storage;
|
|
756
|
+
* this one is durable, so it rides the save's shared half. */
|
|
757
|
+
spent = /* @__PURE__ */ new Set();
|
|
758
|
+
/** @internal */
|
|
759
|
+
isTaken(cardId) {
|
|
760
|
+
return this.spent.has(cardId);
|
|
761
|
+
}
|
|
762
|
+
/** @internal */
|
|
763
|
+
markTaken(cardId) {
|
|
764
|
+
this.spent.add(cardId);
|
|
765
|
+
}
|
|
766
|
+
// --- the run's log (design/shared-scarcity.md 8.2) ---------------------------
|
|
767
|
+
/** Every flow's events in one ordered stream, each tagged with its flow.
|
|
768
|
+
* Opt in with the same `log` option the flow logs use; capped the same way.
|
|
769
|
+
*
|
|
770
|
+
* This exists because a flow's own log cannot answer the question a run
|
|
771
|
+
* raises: when a story action in ANOTHER flow moves shared state, your
|
|
772
|
+
* flow's log says nothing and your value simply changes. Reading a run
|
|
773
|
+
* needs one stream that says who did what, and merging the per-flow logs by
|
|
774
|
+
* hand is a thing every host would otherwise have to write. */
|
|
775
|
+
engineLog = [];
|
|
776
|
+
engineSeq = 0;
|
|
777
|
+
log() {
|
|
778
|
+
return this.engineLog;
|
|
779
|
+
}
|
|
780
|
+
clearLog() {
|
|
781
|
+
this.engineLog = [];
|
|
782
|
+
}
|
|
783
|
+
/** @internal - shared claims across every LIVE flow, card id -> holders.
|
|
784
|
+
* Derived, which is what makes closeFlow and the openFlow replace release
|
|
785
|
+
* what a flow was holding: its board leaves the map with it. */
|
|
786
|
+
sharedClaims() {
|
|
787
|
+
const counts = /* @__PURE__ */ new Map();
|
|
788
|
+
for (const flow of this.flowsById.values()) {
|
|
789
|
+
for (const id of flow.heldCardIds()) counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
790
|
+
}
|
|
791
|
+
return counts;
|
|
792
|
+
}
|
|
793
|
+
// --- engine-level state access ----------------------------------------------
|
|
794
|
+
/**
|
|
795
|
+
* Read shared state by path: "world.x", "story.gold" (when shared),
|
|
796
|
+
* "box.b_x.heat" (when shared). A ref that resolves PER-FLOW throws,
|
|
797
|
+
* naming the fix - silently answering with some flow's copy (or a junk
|
|
798
|
+
* default) was the bug Patter's engine.getProperty guard exists to stop.
|
|
799
|
+
*/
|
|
800
|
+
getProperty(path) {
|
|
801
|
+
const found = this.resolveShared(path);
|
|
802
|
+
const value = found.kind === "world" ? this.internals.worldResolver.get(found.name) : found.bag.get(found.name);
|
|
803
|
+
if (value === void 0) throw new Error(`no property at "${path}"`);
|
|
804
|
+
return value;
|
|
805
|
+
}
|
|
806
|
+
setProperty(path, value) {
|
|
807
|
+
const found = this.resolveShared(path);
|
|
808
|
+
if (found.kind === "world") {
|
|
809
|
+
if (!this.internals.worldResolver.set) {
|
|
810
|
+
throw new Error(`@world is read-only here: the host bound no write`);
|
|
811
|
+
}
|
|
812
|
+
this.internals.worldResolver.set(found.name, value);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
found.bag.set(found.name, value, { silent: true, reason: "host setProperty" });
|
|
816
|
+
}
|
|
817
|
+
resolveShared(path) {
|
|
818
|
+
const parts = path.split(".");
|
|
819
|
+
const perFlow = () => {
|
|
820
|
+
throw new Error(`"${path}" is per-flow state - read it on a Flow, not the Engine`);
|
|
821
|
+
};
|
|
822
|
+
if (parts.length === 2 && parts[0] === "world") return { kind: "world", name: parts[1] };
|
|
823
|
+
if (parts.length === 2 && parts[0] === "story") {
|
|
824
|
+
const name = parts[1];
|
|
825
|
+
if (this.internals.shared.story.get(name) !== void 0) return { kind: "bag", bag: this.internals.shared.story, name };
|
|
826
|
+
if (this.internals.flowDecls.story.some((d) => d.name === name)) perFlow();
|
|
827
|
+
throw new Error(`no property at "${path}"`);
|
|
828
|
+
}
|
|
829
|
+
if (parts.length === 3 && (parts[0] === "box" || parts[0] === "deck" || parts[0] === "hand" || parts[0] === "value")) {
|
|
830
|
+
const kind = parts[0];
|
|
831
|
+
const [, id, name] = parts;
|
|
832
|
+
const bag = this.internals.shared[kind].get(id);
|
|
833
|
+
if (bag !== void 0 && bag.get(name) !== void 0) return { kind: "bag", bag, name };
|
|
834
|
+
if (this.internals.flowDecls[kind].get(id)?.some((d) => d.name === name)) perFlow();
|
|
835
|
+
if (bag === void 0 && !this.internals.flowDecls[kind].has(id)) throw new Error(`no ${kind} store "${id}"`);
|
|
836
|
+
throw new Error(`no property at "${path}"`);
|
|
837
|
+
}
|
|
838
|
+
throw new Error(`bad property path "${path}"`);
|
|
839
|
+
}
|
|
840
|
+
/** The shared surface as examiner rows: @world (read through the
|
|
841
|
+
* resolver) then the shared partitions. Per-flow rows live on each Flow. */
|
|
842
|
+
listProperties() {
|
|
843
|
+
const out = [];
|
|
844
|
+
for (const d of this.internals.bundle.world.properties) {
|
|
845
|
+
const value = this.internals.worldResolver.get(d.name);
|
|
846
|
+
out.push({
|
|
847
|
+
path: `world.${d.name}`,
|
|
848
|
+
name: d.name,
|
|
849
|
+
type: d.type,
|
|
850
|
+
value: value ?? d.default,
|
|
851
|
+
default: d.default,
|
|
852
|
+
...d.values !== void 0 ? { values: d.values } : {},
|
|
853
|
+
...d.stages !== void 0 ? { stages: d.stages } : {}
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
const add = (prefix, bag) => {
|
|
857
|
+
for (const row of bag.rows()) out.push({ path: `${prefix}.${row.name}`, ...row });
|
|
858
|
+
};
|
|
859
|
+
add("story", this.internals.shared.story);
|
|
860
|
+
for (const [id, bag] of this.internals.shared.box) add(`box.${id}`, bag);
|
|
861
|
+
for (const [id, bag] of this.internals.shared.deck) add(`deck.${id}`, bag);
|
|
862
|
+
for (const [id, bag] of this.internals.shared.hand) add(`hand.${id}`, bag);
|
|
863
|
+
for (const [id, bag] of this.internals.shared.value) add(`value.${id}`, bag);
|
|
864
|
+
return out;
|
|
865
|
+
}
|
|
866
|
+
/** The SHARED kernel bags with their store path prefixes (the state
|
|
867
|
+
* logger's mount surface). The @world container is the host's own bag -
|
|
868
|
+
* the host mounts it itself. */
|
|
869
|
+
listBags() {
|
|
870
|
+
const mounts = [{ prefix: "story", bag: this.internals.shared.story }];
|
|
871
|
+
for (const kind of ["box", "deck", "hand", "value"]) {
|
|
872
|
+
for (const [id, bag] of this.internals.shared[kind]) mounts.push({ prefix: `${kind}.${id}`, bag });
|
|
873
|
+
}
|
|
874
|
+
return mounts;
|
|
875
|
+
}
|
|
876
|
+
/** Every flow's trace, one stream, each event tagged with its flow id. */
|
|
877
|
+
subscribeTrace(handler) {
|
|
878
|
+
this.engineTraceHandlers.add(handler);
|
|
879
|
+
return () => this.engineTraceHandlers.delete(handler);
|
|
880
|
+
}
|
|
881
|
+
// --- persistence (schema 4) -------------------------------------------------
|
|
882
|
+
/** The whole engine, one envelope: the shared partitions once, then
|
|
883
|
+
* every live flow keyed by its id. @world is NEVER here - the host
|
|
884
|
+
* saves its container, each engine saves its own envelope. */
|
|
885
|
+
saveGame() {
|
|
886
|
+
return structuredClone({
|
|
887
|
+
schema: "storylets/save@1",
|
|
888
|
+
content: this.internals.bundle.content,
|
|
889
|
+
shared: { props: partitionValues(this.internals.shared), spent: [...this.spent].sort() },
|
|
890
|
+
flows: Object.fromEntries([...this.flowsById].map(([id, flow]) => [id, flow.snapshot()]))
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
/** Restore: shared state once, then every flow REBUILT from its blob.
|
|
894
|
+
* Handles held from before the load are closed and inert (Patter's
|
|
895
|
+
* rule); take fresh ones from getFlow()/flows(). */
|
|
896
|
+
loadGame(envelope) {
|
|
897
|
+
if (envelope.content.project !== this.internals.bundle.content.project) {
|
|
898
|
+
throw new Error(`save is for project "${envelope.content.project}", bundle is "${this.internals.bundle.content.project}"`);
|
|
899
|
+
}
|
|
900
|
+
const env = structuredClone(envelope);
|
|
901
|
+
this.reset();
|
|
902
|
+
loadPartition(this.internals.shared, env.shared.props);
|
|
903
|
+
for (const id of env.shared.spent ?? []) this.spent.add(id);
|
|
904
|
+
for (const [id, saved] of Object.entries(env.flows ?? {})) {
|
|
905
|
+
this.openFlow(id).restore(saved);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
};
|
|
909
|
+
var Flow = class {
|
|
910
|
+
id;
|
|
911
|
+
engine;
|
|
912
|
+
internals;
|
|
913
|
+
closed = false;
|
|
914
|
+
prng;
|
|
915
|
+
/** Per-box turn counters, keyed by box id (schema 3.4) - per flow. */
|
|
916
|
+
turnCounts = /* @__PURE__ */ new Map();
|
|
917
|
+
cooldowns = {};
|
|
918
|
+
/** The board: hand contents (card ids, dealt order), keyed by hand id. */
|
|
919
|
+
boardContents = /* @__PURE__ */ new Map();
|
|
920
|
+
playLog = [];
|
|
921
|
+
// --- play-history indexes -------------------------------------------------
|
|
922
|
+
// The four play-history host functions used to SCAN playLog on every call,
|
|
923
|
+
// and they are called once per candidate card per ask, so dealing was
|
|
924
|
+
// O(candidates x playLog): a shipped game got measurably slower the longer
|
|
925
|
+
// somebody played it, which is the failure mode nobody meets in testing.
|
|
926
|
+
// Measured before the change, 2000 cards: a box with no history condition
|
|
927
|
+
// held flat at 0.3ms while `count_played` went 0.8ms -> 27.9ms as the log
|
|
928
|
+
// reached 4000 plays.
|
|
929
|
+
//
|
|
930
|
+
// These are a pure summary of `playLog`, maintained where it is appended and
|
|
931
|
+
// rebuilt where it is replaced, so they cannot drift from it. They are NOT
|
|
932
|
+
// saved: `playLog` is the record, this is a derivation, and rebuilding on
|
|
933
|
+
// load keeps the save format untouched.
|
|
934
|
+
//
|
|
935
|
+
// The tag keys are the played card's OWN (groupId, tagId) pairs, which is
|
|
936
|
+
// what makes the box-local rule survive: a group name resolves inside the
|
|
937
|
+
// ASKING box, so a card in another box carries different ids and cannot
|
|
938
|
+
// match, exactly as the per-record `inTag` check used to decide.
|
|
939
|
+
playCount = /* @__PURE__ */ new Map();
|
|
940
|
+
lastPlayOf = /* @__PURE__ */ new Map();
|
|
941
|
+
tagPlayCount = /* @__PURE__ */ new Map();
|
|
942
|
+
lastPlayInTag = /* @__PURE__ */ new Map();
|
|
943
|
+
/** The per-flow property partitions (the not-shared halves). */
|
|
944
|
+
stores;
|
|
945
|
+
traceHandlers = /* @__PURE__ */ new Set();
|
|
946
|
+
logEntries = [];
|
|
947
|
+
logSeq = 0;
|
|
948
|
+
/** Merged read view per scope, built once (bags are stable for the
|
|
949
|
+
* flow's life): the flow's own bag first, the shared bag behind it.
|
|
950
|
+
* Names are disjoint (shared XOR per-flow by declaration), so "first"
|
|
951
|
+
* is routing, not shadowing. */
|
|
952
|
+
storyReader;
|
|
953
|
+
boxReaders = /* @__PURE__ */ new Map();
|
|
954
|
+
deckReaders = /* @__PURE__ */ new Map();
|
|
955
|
+
/** @internal - built by Engine.openFlow / Engine.loadGame only. */
|
|
956
|
+
constructor(engine, internals, id, seed) {
|
|
957
|
+
this.engine = engine;
|
|
958
|
+
this.internals = internals;
|
|
959
|
+
this.id = id;
|
|
960
|
+
this.prng = makePrng(seed);
|
|
961
|
+
this.stores = buildPartition(internals, flowHalf);
|
|
962
|
+
for (const box of internals.bundle.boxes) {
|
|
963
|
+
this.turnCounts.set(box.id, 0);
|
|
964
|
+
for (const hand of box.hands) this.boardContents.set(hand.id, []);
|
|
965
|
+
}
|
|
966
|
+
const pair = (own, shared) => ({
|
|
967
|
+
get: (n) => own?.get(n) ?? shared?.get(n)
|
|
968
|
+
});
|
|
969
|
+
this.storyReader = pair(this.stores.story, internals.shared.story);
|
|
970
|
+
for (const box of internals.bundle.boxes) {
|
|
971
|
+
this.boxReaders.set(box.id, pair(this.stores.box.get(box.id), internals.shared.box.get(box.id)));
|
|
972
|
+
for (const deck of box.decks) {
|
|
973
|
+
this.deckReaders.set(deck.id, pair(this.stores.deck.get(deck.id), internals.shared.deck.get(deck.id)));
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
// --- lifetime ----------------------------------------------------------------
|
|
978
|
+
get isClosed() {
|
|
979
|
+
return this.closed;
|
|
980
|
+
}
|
|
981
|
+
/** Close this flow: the handle goes inert, every verb throws. */
|
|
982
|
+
close() {
|
|
983
|
+
if (this.closed) return;
|
|
984
|
+
this.engine.dropFlow(this.id, this);
|
|
985
|
+
this.markClosed();
|
|
986
|
+
}
|
|
987
|
+
/** @internal */
|
|
988
|
+
markClosed() {
|
|
989
|
+
this.closed = true;
|
|
990
|
+
}
|
|
991
|
+
assertOpen() {
|
|
992
|
+
if (this.closed) throw new Error(`flow "${this.id}" is closed`);
|
|
993
|
+
}
|
|
994
|
+
/** A box's current turn (schema 3.4), on THIS flow's clock. */
|
|
995
|
+
turn(boxRef) {
|
|
996
|
+
this.assertOpen();
|
|
997
|
+
const box = this.internals.boxesByGameId.get(boxRef) ?? this.internals.boxesById.get(boxRef);
|
|
998
|
+
if (!box) throw new Error(`unknown box "${boxRef}"`);
|
|
999
|
+
return this.turnCounts.get(box.id) ?? 0;
|
|
1000
|
+
}
|
|
1001
|
+
/** Subscribe to this flow's deal/play trace (schema 5). Returns the
|
|
1002
|
+
* unsubscribe. With no subscribers anywhere the flow does no trace work. */
|
|
1003
|
+
subscribeTrace(handler) {
|
|
1004
|
+
this.traceHandlers.add(handler);
|
|
1005
|
+
return () => this.traceHandlers.delete(handler);
|
|
1006
|
+
}
|
|
1007
|
+
get tracing() {
|
|
1008
|
+
return this.traceHandlers.size > 0 || this.internals.logCap !== void 0 || this.internals.engineTracing();
|
|
1009
|
+
}
|
|
1010
|
+
emit(event, turn) {
|
|
1011
|
+
if (this.internals.logCap !== void 0) {
|
|
1012
|
+
this.logEntries.push({ ...event, seq: this.logSeq++, ...turn !== void 0 ? { turn } : {} });
|
|
1013
|
+
if (this.logEntries.length > this.internals.logCap) this.logEntries.splice(0, this.logEntries.length - this.internals.logCap);
|
|
1014
|
+
}
|
|
1015
|
+
for (const handler of this.traceHandlers) handler(event);
|
|
1016
|
+
this.internals.emitEngine(this.id, event, turn);
|
|
1017
|
+
}
|
|
1018
|
+
/** The retained flow log (opt-in via the Engine's `log`), oldest first,
|
|
1019
|
+
* capped. The introspection seam for hosts and tools; the durable play
|
|
1020
|
+
* history in a save stays `playLog` (schema 4) - the log is a
|
|
1021
|
+
* flow-lifetime utility and is NOT saved. */
|
|
1022
|
+
log() {
|
|
1023
|
+
return this.logEntries;
|
|
1024
|
+
}
|
|
1025
|
+
/** Empty the retained log; `seq` keeps counting, so ordering across a
|
|
1026
|
+
* clear stays meaningful. */
|
|
1027
|
+
clearLog() {
|
|
1028
|
+
this.logEntries = [];
|
|
1029
|
+
}
|
|
1030
|
+
// --- expression plumbing ----------------------------------------------------
|
|
1031
|
+
node(expr) {
|
|
1032
|
+
let node = this.internals.nodeCache.get(expr);
|
|
1033
|
+
if (!node) {
|
|
1034
|
+
node = deserialiseAst(expr.ast);
|
|
1035
|
+
this.internals.nodeCache.set(expr, node);
|
|
1036
|
+
}
|
|
1037
|
+
return node;
|
|
1038
|
+
}
|
|
1039
|
+
/** Tag group names are box-scoped: two boxes may name a group the same way
|
|
1040
|
+
* (schema 1 - boxes namespace their groups), so a name is only ever
|
|
1041
|
+
* resolved inside the box being asked, never bundle-wide. Ids are
|
|
1042
|
+
* project-unique and accepted here too, still confined to the box. */
|
|
1043
|
+
groupInBox(box, ref) {
|
|
1044
|
+
return box.tagGroups.find((g) => effectiveGameId(g) === ref) ?? box.tagGroups.find((g) => g.id === ref);
|
|
1045
|
+
}
|
|
1046
|
+
/** Fold one play into the indexes. O(the card's tags), not O(the log). */
|
|
1047
|
+
indexPlay(record) {
|
|
1048
|
+
this.playCount.set(record.card, (this.playCount.get(record.card) ?? 0) + 1);
|
|
1049
|
+
this.lastPlayOf.set(record.card, record);
|
|
1050
|
+
const entry = this.internals.cardsByGameId.get(record.card);
|
|
1051
|
+
if (!entry) return;
|
|
1052
|
+
for (const [groupId, tagIds] of Object.entries(entry.card.tags ?? {})) {
|
|
1053
|
+
for (const tagId of tagIds) {
|
|
1054
|
+
const key = tagKey(groupId, tagId);
|
|
1055
|
+
this.tagPlayCount.set(key, (this.tagPlayCount.get(key) ?? 0) + 1);
|
|
1056
|
+
this.lastPlayInTag.set(key, record);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
/** Rebuild the indexes from the log. Called wherever `playLog` is REPLACED
|
|
1061
|
+
* rather than appended to, which is `restore` alone. */
|
|
1062
|
+
rebuildPlayIndex() {
|
|
1063
|
+
this.playCount.clear();
|
|
1064
|
+
this.lastPlayOf.clear();
|
|
1065
|
+
this.tagPlayCount.clear();
|
|
1066
|
+
this.lastPlayInTag.clear();
|
|
1067
|
+
for (const record of this.playLog) this.indexPlay(record);
|
|
1068
|
+
}
|
|
1069
|
+
/** `box` is the box whose ask is being evaluated: the play-history
|
|
1070
|
+
* functions take a bare group name, so it resolves there (a card's tags
|
|
1071
|
+
* reference its own box's group, which keeps the counts box-local).
|
|
1072
|
+
* History is THIS flow's: countPlayed answers "have I done this". */
|
|
1073
|
+
/** One host per box, built once.
|
|
1074
|
+
*
|
|
1075
|
+
* The closures below read `this.playCount`, `this.turnCounts` and the rest
|
|
1076
|
+
* LIVE, so a cached host answers with current state - which is what makes
|
|
1077
|
+
* caching safe rather than a snapshot bug. Unreal did this from the start
|
|
1078
|
+
* (`hostsByBox_`, built at flow construction) and the other three rebuilt a
|
|
1079
|
+
* host, and its closures, on every `evalCtx` call: once per deck per ask,
|
|
1080
|
+
* and once per surviving card in the eviction pass. Structural divergence
|
|
1081
|
+
* in one port AND the allocation the audit flagged, so the other three
|
|
1082
|
+
* copied it (2026-08-29). Lazy rather than eager, so a bundle's unvisited
|
|
1083
|
+
* boxes cost nothing. */
|
|
1084
|
+
hostsByBox = /* @__PURE__ */ new Map();
|
|
1085
|
+
host(box) {
|
|
1086
|
+
const cached = this.hostsByBox.get(box.id);
|
|
1087
|
+
if (cached !== void 0) return cached;
|
|
1088
|
+
const made = this.makeHost(box);
|
|
1089
|
+
this.hostsByBox.set(box.id, made);
|
|
1090
|
+
return made;
|
|
1091
|
+
}
|
|
1092
|
+
makeHost(box) {
|
|
1093
|
+
const keyOf = (group, tag) => {
|
|
1094
|
+
const found = this.groupInBox(box, group);
|
|
1095
|
+
const t = found?.tags.find((v) => v.gameId === tag);
|
|
1096
|
+
return found && t ? tagKey(found.id, t.id) : void 0;
|
|
1097
|
+
};
|
|
1098
|
+
const since = (record) => {
|
|
1099
|
+
const entry = this.internals.cardsByGameId.get(record.card);
|
|
1100
|
+
if (!entry) return NEVER_PLAYED;
|
|
1101
|
+
return (this.turnCounts.get(entry.box.id) ?? 0) - record.turn;
|
|
1102
|
+
};
|
|
1103
|
+
return {
|
|
1104
|
+
nextRandom: () => this.prng.next(),
|
|
1105
|
+
countPlayed: (card) => this.playCount.get(card) ?? 0,
|
|
1106
|
+
turnsSincePlayed: (card) => {
|
|
1107
|
+
const last = this.lastPlayOf.get(card);
|
|
1108
|
+
return last ? since(last) : NEVER_PLAYED;
|
|
1109
|
+
},
|
|
1110
|
+
countPlayedIn: (group, tag) => {
|
|
1111
|
+
const key = keyOf(group, tag);
|
|
1112
|
+
return key === void 0 ? 0 : this.tagPlayCount.get(key) ?? 0;
|
|
1113
|
+
},
|
|
1114
|
+
turnsSincePlayedIn: (group, tag) => {
|
|
1115
|
+
const key = keyOf(group, tag);
|
|
1116
|
+
const last = key === void 0 ? void 0 : this.lastPlayInTag.get(key);
|
|
1117
|
+
return last ? since(last) : NEVER_PLAYED;
|
|
1118
|
+
}
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
/** The evaluation environment (schema 3.1/6.2): @box/@deck resolve to the
|
|
1122
|
+
* card under evaluation; in hand-condition contexts @deck is an empty bag,
|
|
1123
|
+
* so any reference is an eval error (missing-policy throw). Every scope
|
|
1124
|
+
* is the flow's MERGED view - its own copies over the shared values,
|
|
1125
|
+
* names disjoint - and @world reads through the engine's resolver. */
|
|
1126
|
+
evalCtx(box, deck, handEnv) {
|
|
1127
|
+
return {
|
|
1128
|
+
scopes: {
|
|
1129
|
+
world: this.internals.worldResolver,
|
|
1130
|
+
story: this.storyReader,
|
|
1131
|
+
box: this.boxReaders.get(box.id) ?? {},
|
|
1132
|
+
deck: deck ? this.deckReaders.get(deck.id) ?? {} : {},
|
|
1133
|
+
hand: handEnv.bag
|
|
1134
|
+
},
|
|
1135
|
+
host: this.host(box),
|
|
1136
|
+
// The quality channel, answering for THIS ask's box and deck. Only wired
|
|
1137
|
+
// when a quality exists, so a bundle without one evaluates byte-
|
|
1138
|
+
// identically to before the feature.
|
|
1139
|
+
...this.internals.hasQualities ? {
|
|
1140
|
+
qualities: (scope, name) => scope === "world" ? this.internals.ladders.world.get(name) : scope === "story" ? this.internals.ladders.story.get(name) : scope === "box" ? this.internals.ladders.box.get(box.id)?.get(name) : scope === "deck" && deck ? this.internals.ladders.deck.get(deck.id)?.get(name) : scope === "hand" ? this.handLadder(handEnv, name) : void 0
|
|
1141
|
+
} : {}
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
/** The ladder behind one composed @hand name, or undefined when the name is
|
|
1145
|
+
* not a quality (or came from criteria, which are tag NAMES, never state). */
|
|
1146
|
+
handLadder(handEnv, name) {
|
|
1147
|
+
const source = handEnv.sources.get(name);
|
|
1148
|
+
if (!source) return void 0;
|
|
1149
|
+
return source.kind === "value" ? this.internals.ladders.value.get(source.id)?.get(name) : source.kind === "hand" ? this.internals.ladders.hand.get(source.id)?.get(name) : void 0;
|
|
1150
|
+
}
|
|
1151
|
+
eval(expr, ctx) {
|
|
1152
|
+
return evaluate(this.node(expr), ctx, storyletsDialect);
|
|
1153
|
+
}
|
|
1154
|
+
passes(expr, ctx, where) {
|
|
1155
|
+
if (!expr) return true;
|
|
1156
|
+
try {
|
|
1157
|
+
return conditionPasses(this.eval(expr, ctx));
|
|
1158
|
+
} catch (e) {
|
|
1159
|
+
if (this.tracing) {
|
|
1160
|
+
this.emit({ type: "diagnostic", where: where ?? "condition", message: e instanceof Error ? e.message : String(e) });
|
|
1161
|
+
}
|
|
1162
|
+
return false;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
// --- resolving asks (schema 2.6 + 3.6) -----------------------------------------
|
|
1166
|
+
tagByGameId(group, gameId) {
|
|
1167
|
+
return group.tags.find((t) => t.gameId === gameId);
|
|
1168
|
+
}
|
|
1169
|
+
/** A deal's ask: the hand's template bindings + chosen tags, or its rule's
|
|
1170
|
+
* bindings, plus the implicit home binding (schema 2.4). */
|
|
1171
|
+
askForHand(hand, box) {
|
|
1172
|
+
const boundTags = /* @__PURE__ */ new Map();
|
|
1173
|
+
const askNames = {};
|
|
1174
|
+
let condition;
|
|
1175
|
+
if (hand.template !== void 0) {
|
|
1176
|
+
const template = this.internals.templatesById.get(hand.template);
|
|
1177
|
+
if (!template) throw new Error(`hand "${effectiveGameId(hand)}": unknown template "${hand.template}"`);
|
|
1178
|
+
for (const [groupId, tagId] of Object.entries(template.bindings ?? {})) {
|
|
1179
|
+
boundTags.set(groupId, tagId);
|
|
1180
|
+
}
|
|
1181
|
+
for (const [groupId, tagId] of Object.entries(hand.chosen ?? {})) {
|
|
1182
|
+
boundTags.set(groupId, tagId);
|
|
1183
|
+
const found = this.internals.groupsById.get(groupId);
|
|
1184
|
+
const tag = found?.group.tags.find((t) => t.id === tagId);
|
|
1185
|
+
if (found && tag) askNames[effectiveGameId(found.group)] = effectiveGameId(tag);
|
|
1186
|
+
}
|
|
1187
|
+
condition = template.condition;
|
|
1188
|
+
} else {
|
|
1189
|
+
for (const [groupId, tagId] of Object.entries(hand.rule?.bindings ?? {})) {
|
|
1190
|
+
boundTags.set(groupId, tagId);
|
|
1191
|
+
const found = this.internals.groupsById.get(groupId);
|
|
1192
|
+
const tag = found?.group.tags.find((t) => t.id === tagId);
|
|
1193
|
+
if (found && tag) askNames[effectiveGameId(found.group)] = effectiveGameId(tag);
|
|
1194
|
+
}
|
|
1195
|
+
condition = hand.rule?.condition;
|
|
1196
|
+
}
|
|
1197
|
+
boundTags.set(PLACE_GROUP, hand.id);
|
|
1198
|
+
this.bindStateGroups(box, boundTags, askNames);
|
|
1199
|
+
return { box, hand, ...condition !== void 0 ? { condition } : {}, boundTags, askNames };
|
|
1200
|
+
}
|
|
1201
|
+
/** A peek's ask: raw criteria ({group gameId: tag gameId}), bindings only,
|
|
1202
|
+
* no condition slot (schema 3.1; the boundary, Reboot 4). */
|
|
1203
|
+
askForPeek(box, criteria) {
|
|
1204
|
+
const boundTags = /* @__PURE__ */ new Map();
|
|
1205
|
+
const askNames = {};
|
|
1206
|
+
for (const [groupRef, tagRef] of Object.entries(criteria)) {
|
|
1207
|
+
if (groupRef === PLACE_GROUP) {
|
|
1208
|
+
const hand = this.internals.handsByGameId.get(tagRef) ?? this.internals.handsById.get(tagRef);
|
|
1209
|
+
if (!hand) throw new Error(`peek: unknown hand "${tagRef}" in home criteria`);
|
|
1210
|
+
boundTags.set(PLACE_GROUP, hand.hand.id);
|
|
1211
|
+
continue;
|
|
1212
|
+
}
|
|
1213
|
+
const found = this.groupInBox(box, groupRef);
|
|
1214
|
+
if (!found) {
|
|
1215
|
+
throw new Error(`peek: unknown tag group "${groupRef}" in box "${effectiveGameId(box)}"`);
|
|
1216
|
+
}
|
|
1217
|
+
const tag = this.tagByGameId(found, tagRef) ?? found.tags.find((t) => t.id === tagRef);
|
|
1218
|
+
if (!tag) throw new Error(`peek: unknown tag "${tagRef}" in group "${effectiveGameId(found)}"`);
|
|
1219
|
+
boundTags.set(found.id, tag.id);
|
|
1220
|
+
askNames[effectiveGameId(found)] = effectiveGameId(tag);
|
|
1221
|
+
}
|
|
1222
|
+
this.bindStateGroups(box, boundTags, askNames);
|
|
1223
|
+
return { box, boundTags, askNames };
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* Bind every `boundBy` group in the box from the property it names.
|
|
1227
|
+
*
|
|
1228
|
+
* The gap this closes: only a hand could bind a group, and `deal` takes no
|
|
1229
|
+
* criteria, so an axis driven by state (an act, a chapter) had nowhere to
|
|
1230
|
+
* gate. Runs AFTER the hand's own bindings and never overwrites one: an
|
|
1231
|
+
* explicit binding is a deliberate act and beats a default.
|
|
1232
|
+
*
|
|
1233
|
+
* A value naming no tag in the group leaves the group UNBOUND rather than
|
|
1234
|
+
* matching nothing. Unbound is a wildcard, so the ask still deals; a silent
|
|
1235
|
+
* empty hand would look like content that does not exist, and the diagnostic
|
|
1236
|
+
* is what says otherwise.
|
|
1237
|
+
*/
|
|
1238
|
+
bindStateGroups(box, boundTags, askNames) {
|
|
1239
|
+
for (const group of box.tagGroups) {
|
|
1240
|
+
if (group.boundBy === void 0 || boundTags.has(group.id)) continue;
|
|
1241
|
+
const ref = /^@(world|story)\.([a-z][a-z0-9_-]*)$/.exec(group.boundBy);
|
|
1242
|
+
if (!ref) {
|
|
1243
|
+
this.emit({ type: "diagnostic", where: `tag group ${effectiveGameId(group)}`, message: `boundBy "${group.boundBy}" is not a @world or @story property reference` });
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
let value;
|
|
1247
|
+
try {
|
|
1248
|
+
value = this.getProperty(`${ref[1]}.${ref[2]}`);
|
|
1249
|
+
} catch {
|
|
1250
|
+
this.emit({ type: "diagnostic", where: `tag group ${effectiveGameId(group)}`, message: `boundBy "${group.boundBy}" names a property that is not declared` });
|
|
1251
|
+
continue;
|
|
1252
|
+
}
|
|
1253
|
+
const wanted = typeof value === "string" ? value : String(value);
|
|
1254
|
+
const tag = group.tags.find((t) => effectiveGameId(t) === wanted);
|
|
1255
|
+
if (!tag) {
|
|
1256
|
+
this.emit({ type: "diagnostic", where: `tag group ${effectiveGameId(group)}`, message: `${group.boundBy} is "${wanted}", which is not one of its tags` });
|
|
1257
|
+
continue;
|
|
1258
|
+
}
|
|
1259
|
+
boundTags.set(group.id, tag.id);
|
|
1260
|
+
askNames[effectiveGameId(group)] = effectiveGameId(tag);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
// --- @hand composition (schema 3.6) -------------------------------------------
|
|
1264
|
+
/** A store's full value view for one owner: the shared half under the
|
|
1265
|
+
* flow's half. Names are disjoint, so the spread is routing, not
|
|
1266
|
+
* shadowing. */
|
|
1267
|
+
valuesOf(kind, id) {
|
|
1268
|
+
return {
|
|
1269
|
+
...this.internals.shared[kind].get(id)?.values ?? {},
|
|
1270
|
+
...this.stores[kind].get(id)?.values ?? {}
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
buildHandEnv(ask) {
|
|
1274
|
+
const bag = {};
|
|
1275
|
+
const sources = /* @__PURE__ */ new Map();
|
|
1276
|
+
for (const [groupId, tagId] of ask.boundTags) {
|
|
1277
|
+
if (groupId === PLACE_GROUP) continue;
|
|
1278
|
+
for (const [name, value] of Object.entries(this.valuesOf("value", tagId))) {
|
|
1279
|
+
bag[name] = value;
|
|
1280
|
+
sources.set(name, { kind: "value", id: tagId });
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
if (ask.hand) {
|
|
1284
|
+
for (const [name, value] of Object.entries(this.valuesOf("hand", ask.hand.id))) {
|
|
1285
|
+
bag[name] = value;
|
|
1286
|
+
sources.set(name, { kind: "hand", id: ask.hand.id });
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
for (const [name, value] of Object.entries(ask.askNames)) {
|
|
1290
|
+
bag[name] = value;
|
|
1291
|
+
sources.set(name, { kind: "criteria" });
|
|
1292
|
+
}
|
|
1293
|
+
return { bag, sources, boundTags: ask.boundTags };
|
|
1294
|
+
}
|
|
1295
|
+
// --- the ask (schema 3.1 + 3.2) ------------------------------------------------
|
|
1296
|
+
/** The claims ledger, derived from THIS flow's board: card id -> holding
|
|
1297
|
+
* hands (schema 3.5). Claims are per flow - another flow holding the
|
|
1298
|
+
* card is another playthrough, not a rival hand. */
|
|
1299
|
+
claims() {
|
|
1300
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1301
|
+
for (const contents of this.boardContents.values()) {
|
|
1302
|
+
for (const id of contents) counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
1303
|
+
}
|
|
1304
|
+
return counts;
|
|
1305
|
+
}
|
|
1306
|
+
/** @internal - every card id on THIS flow's board, one entry per holding
|
|
1307
|
+
* hand. The engine sums these across live flows for the shared ledger. */
|
|
1308
|
+
heldCardIds() {
|
|
1309
|
+
return [...this.boardContents.values()].flat();
|
|
1310
|
+
}
|
|
1311
|
+
copiesOf(card) {
|
|
1312
|
+
return card.copies ?? 1;
|
|
1313
|
+
}
|
|
1314
|
+
/** The claims step (schema 3.1 step 6) for one card, as a verdict or null
|
|
1315
|
+
* for "available". Two caps apply to a shared card and they are different
|
|
1316
|
+
* statements, so they get different verdicts: `copies` is your own board
|
|
1317
|
+
* filling up, `sharedCopies` is somebody else already holding it, and a
|
|
1318
|
+
* participant told "claimed" about a card sitting on another person's
|
|
1319
|
+
* table would read it as an engine fault (design/shared-scarcity 9.3.1).
|
|
1320
|
+
*
|
|
1321
|
+
* `mine` counts this flow's holdings, `world` every live flow's. */
|
|
1322
|
+
claimVerdict(card, shared, mine, world) {
|
|
1323
|
+
const id = card.id;
|
|
1324
|
+
if ((mine.get(id) ?? 0) >= this.copiesOf(card)) return "claimed";
|
|
1325
|
+
if (shared && (world.get(id) ?? 0) >= sharedCap(card)) return "claimed-elsewhere";
|
|
1326
|
+
return null;
|
|
1327
|
+
}
|
|
1328
|
+
/** Tag matching (schema 3.1 step 3): for every bound group the card lists
|
|
1329
|
+
* the bound tag or omits the group (wildcard); the home group inverts -
|
|
1330
|
+
* a homed card requires a matching home binding (schema 2.4). */
|
|
1331
|
+
tagsMatch(card, boundTags) {
|
|
1332
|
+
const home = card.tags?.[PLACE_GROUP];
|
|
1333
|
+
if (home !== void 0 && home.length > 0) {
|
|
1334
|
+
const bound = boundTags.get(PLACE_GROUP);
|
|
1335
|
+
if (bound === void 0 || !home.includes(bound)) return false;
|
|
1336
|
+
}
|
|
1337
|
+
for (const [groupId, tagId] of boundTags) {
|
|
1338
|
+
if (groupId === PLACE_GROUP) continue;
|
|
1339
|
+
const tags = card.tags?.[groupId];
|
|
1340
|
+
if (tags === void 0) {
|
|
1341
|
+
if (this.internals.requiredGroups.has(groupId)) return false;
|
|
1342
|
+
continue;
|
|
1343
|
+
}
|
|
1344
|
+
if (!tags.includes(tagId)) return false;
|
|
1345
|
+
}
|
|
1346
|
+
return true;
|
|
1347
|
+
}
|
|
1348
|
+
/** Run one ask: availability filter then ranking. `claimed` decides the
|
|
1349
|
+
* claims step (step 6) per card, returning the verdict that refused it or
|
|
1350
|
+
* null for available. `trace` (when a subscriber exists) collects the
|
|
1351
|
+
* per-card verdicts. */
|
|
1352
|
+
runAsk(ask, claimed, trace) {
|
|
1353
|
+
const { box } = ask;
|
|
1354
|
+
const handEnv = this.buildHandEnv(ask);
|
|
1355
|
+
const verdict = (id, v) => {
|
|
1356
|
+
trace?.push({ id, verdict: v });
|
|
1357
|
+
};
|
|
1358
|
+
if (!this.passes(ask.condition, this.evalCtx(box, void 0, handEnv), `hand ${ask.hand ? effectiveGameId(ask.hand) : ""} condition`)) {
|
|
1359
|
+
return { ordered: [], handEnv };
|
|
1360
|
+
}
|
|
1361
|
+
const gateOk = /* @__PURE__ */ new Map();
|
|
1362
|
+
for (const deck of box.decks) {
|
|
1363
|
+
gateOk.set(deck.id, this.passes(deck.condition, this.evalCtx(box, deck, handEnv), `deck ${deck.gameId} gate`));
|
|
1364
|
+
}
|
|
1365
|
+
const turn = this.turnCounts.get(box.id) ?? 0;
|
|
1366
|
+
const scored = [];
|
|
1367
|
+
for (const deck of box.decks) {
|
|
1368
|
+
const deckCtx = this.evalCtx(box, deck, handEnv);
|
|
1369
|
+
const deckShared = deck.shared ?? false;
|
|
1370
|
+
for (const card of deck.cards) {
|
|
1371
|
+
const shared = cardIsShared(card, deckShared);
|
|
1372
|
+
if (!gateOk.get(deck.id)) {
|
|
1373
|
+
verdict(card.id, "deck-gate");
|
|
1374
|
+
continue;
|
|
1375
|
+
}
|
|
1376
|
+
if (shared && this.engine.isTaken(card.id)) {
|
|
1377
|
+
verdict(card.id, "taken");
|
|
1378
|
+
continue;
|
|
1379
|
+
}
|
|
1380
|
+
if ((this.cooldowns[card.id] ?? 0) > turn) {
|
|
1381
|
+
verdict(card.id, "cooldown");
|
|
1382
|
+
continue;
|
|
1383
|
+
}
|
|
1384
|
+
if (!this.tagsMatch(card, handEnv.boundTags)) {
|
|
1385
|
+
verdict(card.id, "tags");
|
|
1386
|
+
continue;
|
|
1387
|
+
}
|
|
1388
|
+
if (card.condition && !this.passes(
|
|
1389
|
+
card.condition,
|
|
1390
|
+
deckCtx,
|
|
1391
|
+
this.tracing ? `card ${card.gameId} condition` : void 0
|
|
1392
|
+
)) {
|
|
1393
|
+
verdict(card.id, "condition");
|
|
1394
|
+
continue;
|
|
1395
|
+
}
|
|
1396
|
+
const refused = claimed(card, shared);
|
|
1397
|
+
if (refused) {
|
|
1398
|
+
verdict(card.id, refused);
|
|
1399
|
+
continue;
|
|
1400
|
+
}
|
|
1401
|
+
let priority;
|
|
1402
|
+
if (typeof card.priority === "number") {
|
|
1403
|
+
priority = card.priority;
|
|
1404
|
+
} else {
|
|
1405
|
+
try {
|
|
1406
|
+
const v = this.eval(card.priority, deckCtx);
|
|
1407
|
+
if (typeof v !== "number") {
|
|
1408
|
+
verdict(card.id, "priority");
|
|
1409
|
+
continue;
|
|
1410
|
+
}
|
|
1411
|
+
priority = v;
|
|
1412
|
+
} catch (e) {
|
|
1413
|
+
if (this.tracing) {
|
|
1414
|
+
this.emit({ type: "diagnostic", where: `card ${card.gameId} priority`, message: e instanceof Error ? e.message : String(e) });
|
|
1415
|
+
}
|
|
1416
|
+
verdict(card.id, "priority");
|
|
1417
|
+
continue;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
let spec = 0;
|
|
1421
|
+
if (box.ranking.specificity && card.condition) {
|
|
1422
|
+
const node = this.node(card.condition);
|
|
1423
|
+
spec = matchedSpecificity(node, (n) => {
|
|
1424
|
+
try {
|
|
1425
|
+
return conditionPasses(evaluate(n, deckCtx, storyletsDialect));
|
|
1426
|
+
} catch {
|
|
1427
|
+
return false;
|
|
1428
|
+
}
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
scored.push({ entry: { card, deck, box }, priority, spec });
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
scored.sort((a, b) => b.priority - a.priority || b.spec - a.spec);
|
|
1435
|
+
let i = 0;
|
|
1436
|
+
while (i < scored.length) {
|
|
1437
|
+
let j = i + 1;
|
|
1438
|
+
while (j < scored.length && scored[j].priority === scored[i].priority && scored[j].spec === scored[i].spec) j++;
|
|
1439
|
+
if (j - i > 1) {
|
|
1440
|
+
const run = scored.slice(i, j);
|
|
1441
|
+
shuffleInPlace(run, this.prng);
|
|
1442
|
+
for (let k = 0; k < run.length; k++) scored[i + k] = run[k];
|
|
1443
|
+
}
|
|
1444
|
+
i = j;
|
|
1445
|
+
}
|
|
1446
|
+
for (const s of scored) {
|
|
1447
|
+
trace?.push({ id: s.entry.card.id, verdict: "dealt", priority: s.priority, specificity: s.spec });
|
|
1448
|
+
}
|
|
1449
|
+
return { ordered: scored.map((s) => s.entry), handEnv };
|
|
1450
|
+
}
|
|
1451
|
+
/** Flip eligible-but-not-taken trace entries to "capped". */
|
|
1452
|
+
capTrace(trace, taken) {
|
|
1453
|
+
for (const entry of trace) {
|
|
1454
|
+
if (entry.verdict === "dealt" && !taken.has(entry.id)) entry.verdict = "capped";
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
view(entry) {
|
|
1458
|
+
const { card } = entry;
|
|
1459
|
+
return {
|
|
1460
|
+
id: card.id,
|
|
1461
|
+
gameId: effectiveGameId(card),
|
|
1462
|
+
...card.title !== void 0 ? { title: card.title } : {},
|
|
1463
|
+
...card.purpose !== void 0 ? { purpose: card.purpose } : {},
|
|
1464
|
+
...card.fields !== void 0 ? { fields: card.fields } : {}
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
handCapacity(hand) {
|
|
1468
|
+
if (hand.slots !== void 0) return hand.slots;
|
|
1469
|
+
const declared = hand.template !== void 0 ? this.internals.templatesById.get(hand.template)?.slots : hand.rule?.slots;
|
|
1470
|
+
return declared === void 0 || declared === "unbounded" ? Infinity : declared;
|
|
1471
|
+
}
|
|
1472
|
+
resolveHand(ref) {
|
|
1473
|
+
const found = this.internals.handsByGameId.get(ref) ?? this.internals.handsById.get(ref);
|
|
1474
|
+
if (!found) throw new Error(`unknown hand "${ref}"`);
|
|
1475
|
+
return found;
|
|
1476
|
+
}
|
|
1477
|
+
// --- host surface (schema 5) ---------------------------------------------------
|
|
1478
|
+
/** Look at the top of the stock through raw tag criteria (schema 3.1):
|
|
1479
|
+
* claims respected, nothing registered, nothing left behind but the
|
|
1480
|
+
* trace line. You can never play a card you only peeked. */
|
|
1481
|
+
peek(boxRef, criteria = {}, n) {
|
|
1482
|
+
this.assertOpen();
|
|
1483
|
+
const box = this.internals.boxesByGameId.get(boxRef) ?? this.internals.boxesById.get(boxRef);
|
|
1484
|
+
if (!box) throw new Error(`unknown box "${boxRef}"`);
|
|
1485
|
+
const ask = this.askForPeek(box, criteria);
|
|
1486
|
+
const claimCounts = this.claims();
|
|
1487
|
+
const worldClaims = this.internals.hasShared ? this.engine.sharedClaims() : /* @__PURE__ */ new Map();
|
|
1488
|
+
const trace = this.tracing ? [] : void 0;
|
|
1489
|
+
const { ordered } = this.runAsk(ask, (card, shared) => this.claimVerdict(card, shared, claimCounts, worldClaims), trace);
|
|
1490
|
+
const listed = n === void 0 ? ordered : ordered.slice(0, Math.max(n, 0));
|
|
1491
|
+
if (trace) {
|
|
1492
|
+
this.capTrace(trace, new Set(listed.map((e) => e.card.id)));
|
|
1493
|
+
this.emit({ type: "peek", box: effectiveGameId(box), criteria, cards: trace }, this.turnCounts.get(box.id) ?? 0);
|
|
1494
|
+
}
|
|
1495
|
+
return { box: effectiveGameId(box), cards: listed.map((e) => this.view(e)) };
|
|
1496
|
+
}
|
|
1497
|
+
/** Refresh one hand (schema 3.5); returns its new shape. */
|
|
1498
|
+
deal(handRef) {
|
|
1499
|
+
this.assertOpen();
|
|
1500
|
+
const { hand } = this.resolveHand(handRef);
|
|
1501
|
+
return this.dealMany([handRef])[effectiveGameId(hand)] ?? [];
|
|
1502
|
+
}
|
|
1503
|
+
/** Re-deal several / all hands (schema 3.5): seeded hand-order shuffle
|
|
1504
|
+
* (fairness), evict, seed the ledger from survivors, fill in order.
|
|
1505
|
+
* Returns the dealt slice - the new contents of exactly the hands this
|
|
1506
|
+
* call dealt, keyed by hand gameId (board() stays the whole-board read). */
|
|
1507
|
+
dealMany(handRefs) {
|
|
1508
|
+
this.assertOpen();
|
|
1509
|
+
const dealt = (handRefs ?? [...this.internals.handsById.keys()].sort()).map((ref) => this.resolveHand(ref));
|
|
1510
|
+
shuffleInPlace(dealt, this.prng);
|
|
1511
|
+
for (const { hand, box } of dealt) {
|
|
1512
|
+
const ask = this.askForHand(hand, box);
|
|
1513
|
+
const handEnv = this.buildHandEnv(ask);
|
|
1514
|
+
const conditionOk = this.passes(ask.condition, this.evalCtx(box, void 0, handEnv));
|
|
1515
|
+
const gateOk = /* @__PURE__ */ new Map();
|
|
1516
|
+
for (const deck of box.decks) {
|
|
1517
|
+
gateOk.set(deck.id, this.passes(deck.condition, this.evalCtx(box, deck, handEnv)));
|
|
1518
|
+
}
|
|
1519
|
+
const turn = this.turnCounts.get(box.id) ?? 0;
|
|
1520
|
+
const evicted = [];
|
|
1521
|
+
const evict = (cardId, reason) => {
|
|
1522
|
+
evicted.push({ card: cardId, reason });
|
|
1523
|
+
return false;
|
|
1524
|
+
};
|
|
1525
|
+
const survivors = (this.boardContents.get(hand.id) ?? []).filter((cardId) => {
|
|
1526
|
+
if (!conditionOk) return evict(cardId, "hand-condition");
|
|
1527
|
+
const entry = this.internals.cardsById.get(cardId);
|
|
1528
|
+
if (!entry) return evict(cardId, "vanished");
|
|
1529
|
+
if (!gateOk.get(entry.deck.id)) return evict(cardId, "deck-gate");
|
|
1530
|
+
if ((this.cooldowns[cardId] ?? 0) > turn) return evict(cardId, "cooldown");
|
|
1531
|
+
if (!this.tagsMatch(entry.card, handEnv.boundTags)) return evict(cardId, "tags");
|
|
1532
|
+
if (!this.passes(entry.card.condition, this.evalCtx(box, entry.deck, handEnv), `card ${entry.card.gameId} condition`)) {
|
|
1533
|
+
return evict(cardId, "condition");
|
|
1534
|
+
}
|
|
1535
|
+
return true;
|
|
1536
|
+
});
|
|
1537
|
+
this.boardContents.set(hand.id, survivors);
|
|
1538
|
+
if (this.tracing) {
|
|
1539
|
+
for (const e of evicted) this.emit({ type: "evict", hand: hand.id, card: e.card, reason: e.reason }, turn);
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
const claimCounts = this.claims();
|
|
1543
|
+
const worldClaims = this.internals.hasShared ? this.engine.sharedClaims() : /* @__PURE__ */ new Map();
|
|
1544
|
+
for (const { hand, box } of dealt) {
|
|
1545
|
+
const contents = this.boardContents.get(hand.id) ?? [];
|
|
1546
|
+
const free = this.handCapacity(hand) - contents.length;
|
|
1547
|
+
if (free <= 0) continue;
|
|
1548
|
+
const ask = this.askForHand(hand, box);
|
|
1549
|
+
const own = new Set(contents);
|
|
1550
|
+
const trace = this.tracing ? [] : void 0;
|
|
1551
|
+
const { ordered } = this.runAsk(
|
|
1552
|
+
ask,
|
|
1553
|
+
(card, shared) => own.has(card.id) ? "claimed" : this.claimVerdict(card, shared, claimCounts, worldClaims),
|
|
1554
|
+
trace
|
|
1555
|
+
);
|
|
1556
|
+
const added = ordered.slice(0, free).map((e) => e.card.id);
|
|
1557
|
+
this.boardContents.set(hand.id, [...contents, ...added]);
|
|
1558
|
+
for (const id of added) {
|
|
1559
|
+
claimCounts.set(id, (claimCounts.get(id) ?? 0) + 1);
|
|
1560
|
+
worldClaims.set(id, (worldClaims.get(id) ?? 0) + 1);
|
|
1561
|
+
}
|
|
1562
|
+
if (trace) {
|
|
1563
|
+
this.capTrace(trace, new Set(added));
|
|
1564
|
+
this.emit({ type: "deal", hand: effectiveGameId(hand), cards: trace }, this.turnCounts.get(box.id) ?? 0);
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
return Object.fromEntries(dealt.map(({ hand }) => [
|
|
1568
|
+
effectiveGameId(hand),
|
|
1569
|
+
(this.boardContents.get(hand.id) ?? []).map((id) => this.view(this.internals.cardsById.get(id)))
|
|
1570
|
+
]));
|
|
1571
|
+
}
|
|
1572
|
+
/** The board: current hand contents, in dealt order, keyed by hand gameId
|
|
1573
|
+
* (schema 5). Read it for what is out; peek the stock for what could
|
|
1574
|
+
* come.
|
|
1575
|
+
*
|
|
1576
|
+
* `boxRef` (a box gameId or id) narrows the read to that box's hands, in
|
|
1577
|
+
* the same shape and the same order: "give me the barks hands" is a
|
|
1578
|
+
* common host query, and boxes are how a game separates its storylet
|
|
1579
|
+
* systems, so the grouping belongs here rather than in every host. An
|
|
1580
|
+
* unknown box throws, as it does on turn() and peek(). */
|
|
1581
|
+
board(boxRef) {
|
|
1582
|
+
this.assertOpen();
|
|
1583
|
+
let keep;
|
|
1584
|
+
if (boxRef !== void 0) {
|
|
1585
|
+
const box = this.internals.boxesByGameId.get(boxRef) ?? this.internals.boxesById.get(boxRef);
|
|
1586
|
+
if (!box) throw new Error(`unknown box "${boxRef}"`);
|
|
1587
|
+
keep = box.id;
|
|
1588
|
+
}
|
|
1589
|
+
return Object.fromEntries([...this.boardContents.entries()].filter(([handId]) => keep === void 0 || this.internals.handsById.get(handId).box.id === keep).map(([handId, ids]) => [
|
|
1590
|
+
effectiveGameId(this.internals.handsById.get(handId).hand),
|
|
1591
|
+
ids.map((id) => this.view(this.internals.cardsById.get(id)))
|
|
1592
|
+
]));
|
|
1593
|
+
}
|
|
1594
|
+
/** Resolve a played/inspected card within a hand on the board. */
|
|
1595
|
+
resolveDealt(cardId, handRef) {
|
|
1596
|
+
const entry = this.internals.cardsById.get(cardId) ?? this.internals.cardsByGameId.get(cardId);
|
|
1597
|
+
if (!entry) throw new Error(`unknown card "${cardId}"`);
|
|
1598
|
+
const { hand, box } = this.resolveHand(handRef);
|
|
1599
|
+
if (!(this.boardContents.get(hand.id) ?? []).includes(entry.card.id)) {
|
|
1600
|
+
throw new Error(`card "${effectiveGameId(entry.card)}" is not dealt to hand "${effectiveGameId(hand)}"`);
|
|
1601
|
+
}
|
|
1602
|
+
return { entry, ask: this.askForHand(hand, box) };
|
|
1603
|
+
}
|
|
1604
|
+
/** Outcome availability, evaluated against CURRENT state on every ask
|
|
1605
|
+
* (schema 3.1/5) - never a deal-time snapshot. */
|
|
1606
|
+
outcomes(cardId, from) {
|
|
1607
|
+
this.assertOpen();
|
|
1608
|
+
const { entry, ask } = this.resolveDealt(cardId, from);
|
|
1609
|
+
const ctx = this.evalCtx(entry.box, entry.deck, this.buildHandEnv(ask));
|
|
1610
|
+
return entry.card.outcomes.map((o) => ({
|
|
1611
|
+
id: o.id,
|
|
1612
|
+
gameId: effectiveGameId(o),
|
|
1613
|
+
...o.title !== void 0 ? { title: o.title } : {},
|
|
1614
|
+
...o.purpose !== void 0 ? { purpose: o.purpose } : {},
|
|
1615
|
+
available: this.passes(o.condition, ctx)
|
|
1616
|
+
}));
|
|
1617
|
+
}
|
|
1618
|
+
/** Apply an outcome (schema 3.7): the card must sit in a hand on the
|
|
1619
|
+
* board (you never play a card from inside the deck). Throws before any
|
|
1620
|
+
* mutation on a gated-shut outcome or a bad write target. */
|
|
1621
|
+
play(cardId, outcomeGameId, from, opts = {}) {
|
|
1622
|
+
this.assertOpen();
|
|
1623
|
+
const { entry, ask } = this.resolveDealt(cardId, from);
|
|
1624
|
+
const outcome = entry.card.outcomes.find((o) => effectiveGameId(o) === outcomeGameId);
|
|
1625
|
+
if (!outcome) throw new Error(`card "${effectiveGameId(entry.card)}" has no outcome "${outcomeGameId}"`);
|
|
1626
|
+
const handEnv = this.buildHandEnv(ask);
|
|
1627
|
+
const ctx = this.evalCtx(entry.box, entry.deck, handEnv);
|
|
1628
|
+
if (!this.passes(outcome.condition, ctx)) {
|
|
1629
|
+
throw new Error(`outcome "${outcomeGameId}" on "${effectiveGameId(entry.card)}" is gated shut`);
|
|
1630
|
+
}
|
|
1631
|
+
const newTurn = (this.turnCounts.get(entry.box.id) ?? 0) + (opts.advanceTurns ?? this.internals.bundle.settings.playAdvancesTurns);
|
|
1632
|
+
const writes = [];
|
|
1633
|
+
for (const [target, expr] of Object.entries(outcome.changes)) {
|
|
1634
|
+
writes.push({ target, value: this.eval(expr, ctx) });
|
|
1635
|
+
}
|
|
1636
|
+
for (const { target, value } of writes) {
|
|
1637
|
+
const { path, prev } = this.applyWrite(target, value, entry, handEnv);
|
|
1638
|
+
if (this.tracing) this.emit({ type: "write", target, path, value, ...prev !== void 0 ? { prev } : {} }, newTurn);
|
|
1639
|
+
}
|
|
1640
|
+
const record = { card: effectiveGameId(entry.card), outcome: effectiveGameId(outcome), turn: newTurn };
|
|
1641
|
+
this.playLog.push(record);
|
|
1642
|
+
this.indexPlay(record);
|
|
1643
|
+
if (entry.card.redraw === "never") {
|
|
1644
|
+
if (cardIsShared(entry.card, entry.deck.shared ?? false)) this.engine.markTaken(entry.card.id);
|
|
1645
|
+
else this.cooldowns[entry.card.id] = Number.MAX_SAFE_INTEGER;
|
|
1646
|
+
} else if (typeof entry.card.redraw === "number") {
|
|
1647
|
+
this.cooldowns[entry.card.id] = newTurn + entry.card.redraw;
|
|
1648
|
+
}
|
|
1649
|
+
const handId = ask.hand.id;
|
|
1650
|
+
this.boardContents.set(
|
|
1651
|
+
handId,
|
|
1652
|
+
(this.boardContents.get(handId) ?? []).filter((id) => id !== entry.card.id)
|
|
1653
|
+
);
|
|
1654
|
+
this.turnCounts.set(entry.box.id, newTurn);
|
|
1655
|
+
if (this.tracing) this.emit({ type: "play", card: entry.card.id, outcome: effectiveGameId(outcome), turn: newTurn }, newTurn);
|
|
1656
|
+
}
|
|
1657
|
+
/** Land one change in whichever partition declares the name: the flow's
|
|
1658
|
+
* bag when the property is per-flow, the shared bag when it is shared -
|
|
1659
|
+
* the union/partition invariant made executable. */
|
|
1660
|
+
landIn(kind, id, name, value, path) {
|
|
1661
|
+
const own = kind === "story" ? this.stores.story : id !== void 0 ? this.stores[kind].get(id) : void 0;
|
|
1662
|
+
const shared = kind === "story" ? this.internals.shared.story : id !== void 0 ? this.internals.shared[kind].get(id) : void 0;
|
|
1663
|
+
const bag = own !== void 0 && own.get(name) !== void 0 ? own : shared !== void 0 && shared.get(name) !== void 0 ? shared : void 0;
|
|
1664
|
+
if (bag === void 0) throw new Error(`no property at "${path}"`);
|
|
1665
|
+
const change = bag.set(name, value);
|
|
1666
|
+
return { path, ...change.prev !== void 0 ? { prev: change.prev } : {} };
|
|
1667
|
+
}
|
|
1668
|
+
/** Land one change; returns the resolved store path (for the trace) and
|
|
1669
|
+
* the value it replaced (for the log's "0 -> 1" reading). */
|
|
1670
|
+
applyWrite(target, value, entry, handEnv) {
|
|
1671
|
+
const match = /^@([a-z]+)\.([A-Za-z_][A-Za-z0-9_-]*)$/.exec(target);
|
|
1672
|
+
if (!match) throw new Error(`bad change target "${target}"`);
|
|
1673
|
+
const [, scope, name] = match;
|
|
1674
|
+
switch (scope) {
|
|
1675
|
+
case "world": {
|
|
1676
|
+
const resolver = this.internals.worldResolver;
|
|
1677
|
+
if (!resolver.set) throw new Error(`@world.${name} cannot be written: the host bound @world read-only`);
|
|
1678
|
+
const prev = resolver.get(name);
|
|
1679
|
+
resolver.set(name, value);
|
|
1680
|
+
return { path: `world.${name}`, ...prev !== void 0 ? { prev } : {} };
|
|
1681
|
+
}
|
|
1682
|
+
case "story":
|
|
1683
|
+
return this.landIn("story", void 0, name, value, `story.${name}`);
|
|
1684
|
+
case "box":
|
|
1685
|
+
return this.landIn("box", entry.box.id, name, value, `box.${entry.box.id}.${name}`);
|
|
1686
|
+
case "deck":
|
|
1687
|
+
return this.landIn("deck", entry.deck.id, name, value, `deck.${entry.deck.id}.${name}`);
|
|
1688
|
+
case "hand": {
|
|
1689
|
+
const source = handEnv.sources.get(name);
|
|
1690
|
+
if (!source) throw new Error(`@hand.${name} is not composed in this ask`);
|
|
1691
|
+
if (source.kind === "criteria") throw new Error(`@hand.${name} is a chosen tag / criteria name and cannot be written`);
|
|
1692
|
+
return this.landIn(source.kind, source.id, name, value, `${source.kind}.${source.id}.${name}`);
|
|
1693
|
+
}
|
|
1694
|
+
default:
|
|
1695
|
+
throw new Error(`bad change target scope "@${scope}"`);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
/** Advance one box's clock (schema 3.4): a turn is one draw-from-stock
|
|
1699
|
+
* session for that box, on THIS flow's clock. */
|
|
1700
|
+
advanceTurns(boxRef, n = 1) {
|
|
1701
|
+
this.assertOpen();
|
|
1702
|
+
const box = this.internals.boxesByGameId.get(boxRef) ?? this.internals.boxesById.get(boxRef);
|
|
1703
|
+
if (!box) throw new Error(`unknown box "${boxRef}"`);
|
|
1704
|
+
const next = (this.turnCounts.get(box.id) ?? 0) + n;
|
|
1705
|
+
this.turnCounts.set(box.id, next);
|
|
1706
|
+
if (this.tracing) this.emit({ type: "turns", box: effectiveGameId(box), turn: next }, next);
|
|
1707
|
+
}
|
|
1708
|
+
// --- state access (host surface + test tooling) ---------------------------------
|
|
1709
|
+
/** Every box, bundle order: identity + this flow's clock (the enumeration
|
|
1710
|
+
* surface examiners key their turns sections on; parity member). */
|
|
1711
|
+
listBoxes() {
|
|
1712
|
+
this.assertOpen();
|
|
1713
|
+
return this.internals.bundle.boxes.map((b) => ({
|
|
1714
|
+
id: b.id,
|
|
1715
|
+
gameId: effectiveGameId(b),
|
|
1716
|
+
...b.title !== void 0 ? { title: b.title } : {},
|
|
1717
|
+
turn: this.turnCounts.get(b.id) ?? 0
|
|
1718
|
+
}));
|
|
1719
|
+
}
|
|
1720
|
+
/** THIS flow's kernel bags with their store path prefixes (the state
|
|
1721
|
+
* logger's mount surface; parity member). The shared bags are the
|
|
1722
|
+
* Engine's listBags; the flows are rebuilt by loadGame, so consumers
|
|
1723
|
+
* re-enumerate after a load. */
|
|
1724
|
+
listBags() {
|
|
1725
|
+
this.assertOpen();
|
|
1726
|
+
const mounts = [{ prefix: "story", bag: this.stores.story }];
|
|
1727
|
+
for (const kind of ["box", "deck", "hand", "value"]) {
|
|
1728
|
+
for (const [id, bag] of this.stores[kind]) mounts.push({ prefix: `${kind}.${id}`, bag });
|
|
1729
|
+
}
|
|
1730
|
+
return mounts;
|
|
1731
|
+
}
|
|
1732
|
+
/** The flow's FULL merged view as examiner rows (the property examiner /
|
|
1733
|
+
* editor surface, parity across all runtimes): @world read through the
|
|
1734
|
+
* resolver, then per scope the shared values and this flow's own.
|
|
1735
|
+
* Bundle order: world, story, then per-box / per-deck / per-hand /
|
|
1736
|
+
* per-tag stores. */
|
|
1737
|
+
listProperties() {
|
|
1738
|
+
this.assertOpen();
|
|
1739
|
+
const out = [];
|
|
1740
|
+
for (const d of this.internals.bundle.world.properties) {
|
|
1741
|
+
const value = this.internals.worldResolver.get(d.name);
|
|
1742
|
+
out.push({
|
|
1743
|
+
path: `world.${d.name}`,
|
|
1744
|
+
name: d.name,
|
|
1745
|
+
type: d.type,
|
|
1746
|
+
value: value ?? d.default,
|
|
1747
|
+
default: d.default,
|
|
1748
|
+
...d.values !== void 0 ? { values: d.values } : {},
|
|
1749
|
+
...d.stages !== void 0 ? { stages: d.stages } : {}
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
const add = (prefix, shared, own) => {
|
|
1753
|
+
for (const bag of [shared, own]) {
|
|
1754
|
+
if (bag === void 0) continue;
|
|
1755
|
+
for (const row of bag.rows()) out.push({ path: `${prefix}.${row.name}`, ...row });
|
|
1756
|
+
}
|
|
1757
|
+
};
|
|
1758
|
+
add("story", this.internals.shared.story, this.stores.story);
|
|
1759
|
+
for (const kind of ["box", "deck", "hand", "value"]) {
|
|
1760
|
+
const ids = /* @__PURE__ */ new Set([...this.internals.shared[kind].keys(), ...this.stores[kind].keys()]);
|
|
1761
|
+
for (const id of ids) add(`${kind}.${id}`, this.internals.shared[kind].get(id), this.stores[kind].get(id));
|
|
1762
|
+
}
|
|
1763
|
+
return out;
|
|
1764
|
+
}
|
|
1765
|
+
/** Read by path: "world.x", "story.gold", "value.v_docks.danger",
|
|
1766
|
+
* "box.b_x.heat", "deck.k_main.n", "hand.h_board.owner" - the flow's
|
|
1767
|
+
* merged view, routed by the declaration's sharing. */
|
|
1768
|
+
getProperty(path) {
|
|
1769
|
+
this.assertOpen();
|
|
1770
|
+
const found = this.resolvePath(path);
|
|
1771
|
+
const value = found.kind === "world" ? this.internals.worldResolver.get(found.name) : found.own?.get(found.name) ?? found.shared?.get(found.name);
|
|
1772
|
+
if (value === void 0) throw new Error(`no property at "${path}"`);
|
|
1773
|
+
return value;
|
|
1774
|
+
}
|
|
1775
|
+
setProperty(path, value) {
|
|
1776
|
+
this.assertOpen();
|
|
1777
|
+
const found = this.resolvePath(path);
|
|
1778
|
+
if (found.kind === "world") {
|
|
1779
|
+
if (!this.internals.worldResolver.set) throw new Error(`@world is read-only here: the host bound no write`);
|
|
1780
|
+
this.internals.worldResolver.set(found.name, value);
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
const bag = found.own !== void 0 && found.own.get(found.name) !== void 0 ? found.own : found.shared !== void 0 && found.shared.get(found.name) !== void 0 ? found.shared : void 0;
|
|
1784
|
+
if (bag === void 0) throw new Error(`no property at "${path}"`);
|
|
1785
|
+
bag.set(found.name, value, { silent: true, reason: "host setProperty" });
|
|
1786
|
+
}
|
|
1787
|
+
resolvePath(path) {
|
|
1788
|
+
const parts = path.split(".");
|
|
1789
|
+
if (parts.length === 2 && parts[0] === "world") return { kind: "world", name: parts[1] };
|
|
1790
|
+
if (parts.length === 2 && parts[0] === "story") {
|
|
1791
|
+
return { kind: "bag", own: this.stores.story, shared: this.internals.shared.story, name: parts[1] };
|
|
1792
|
+
}
|
|
1793
|
+
if (parts.length === 3 && (parts[0] === "box" || parts[0] === "deck" || parts[0] === "hand" || parts[0] === "value")) {
|
|
1794
|
+
const kind = parts[0];
|
|
1795
|
+
const own = this.stores[kind].get(parts[1]);
|
|
1796
|
+
const shared = this.internals.shared[kind].get(parts[1]);
|
|
1797
|
+
if (own === void 0 && shared === void 0) throw new Error(`no ${parts[0]} store "${parts[1]}"`);
|
|
1798
|
+
return { kind: "bag", ...own !== void 0 ? { own } : {}, ...shared !== void 0 ? { shared } : {}, name: parts[2] };
|
|
1799
|
+
}
|
|
1800
|
+
throw new Error(`bad property path "${path}"`);
|
|
1801
|
+
}
|
|
1802
|
+
// --- persistence (schema 4) -------------------------------------------------
|
|
1803
|
+
/** @internal - this flow's blob inside the engine's envelope. */
|
|
1804
|
+
snapshot() {
|
|
1805
|
+
return {
|
|
1806
|
+
props: partitionValues(this.stores),
|
|
1807
|
+
turns: Object.fromEntries(this.turnCounts),
|
|
1808
|
+
prng: this.prng.state(),
|
|
1809
|
+
cooldowns: this.cooldowns,
|
|
1810
|
+
board: Object.fromEntries(this.boardContents),
|
|
1811
|
+
playLog: this.playLog
|
|
1812
|
+
};
|
|
1813
|
+
}
|
|
1814
|
+
/** @internal - restore a freshly opened flow from its blob (loadGame).
|
|
1815
|
+
* Orphaned keys (deleted entities) drop; new declarations keep defaults. */
|
|
1816
|
+
restore(saved) {
|
|
1817
|
+
loadPartition(this.stores, saved.props);
|
|
1818
|
+
this.turnCounts = new Map(this.internals.bundle.boxes.map((b) => [b.id, 0]));
|
|
1819
|
+
for (const [boxId, turn] of Object.entries(saved.turns ?? {})) {
|
|
1820
|
+
if (this.turnCounts.has(boxId)) this.turnCounts.set(boxId, turn);
|
|
1821
|
+
}
|
|
1822
|
+
this.prng = makePrng(saved.prng);
|
|
1823
|
+
this.cooldowns = saved.cooldowns ?? {};
|
|
1824
|
+
this.playLog = saved.playLog ?? [];
|
|
1825
|
+
this.rebuildPlayIndex();
|
|
1826
|
+
this.boardContents = new Map(Object.entries(saved.board ?? {}).filter(([handId]) => this.internals.handsById.has(handId)).map(([handId, ids]) => [handId, ids.filter((id) => this.internals.cardsById.has(id))]));
|
|
1827
|
+
for (const handId of this.internals.handsById.keys()) {
|
|
1828
|
+
if (!this.boardContents.has(handId)) this.boardContents.set(handId, []);
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
};
|
|
1832
|
+
|
|
1833
|
+
// src/describe.ts
|
|
1834
|
+
var summarise = (decls) => decls.map((d) => ({
|
|
1835
|
+
name: d.name,
|
|
1836
|
+
type: d.type,
|
|
1837
|
+
default: d.default,
|
|
1838
|
+
...d.values !== void 0 ? { values: d.values } : {},
|
|
1839
|
+
...d.purpose !== void 0 ? { purpose: d.purpose } : {}
|
|
1840
|
+
}));
|
|
1841
|
+
var handDecls = (hand, box) => {
|
|
1842
|
+
if (hand.template !== void 0) {
|
|
1843
|
+
return box.handTemplates.find((t) => t.id === hand.template)?.properties ?? [];
|
|
1844
|
+
}
|
|
1845
|
+
return hand.properties ?? [];
|
|
1846
|
+
};
|
|
1847
|
+
var handSlots = (hand, box) => {
|
|
1848
|
+
if (hand.slots !== void 0) return hand.slots;
|
|
1849
|
+
const declared = hand.template !== void 0 ? box.handTemplates.find((t) => t.id === hand.template)?.slots : hand.rule?.slots;
|
|
1850
|
+
return declared === void 0 ? "unbounded" : declared;
|
|
1851
|
+
};
|
|
1852
|
+
function describeBundle(bundle) {
|
|
1853
|
+
const boxes = [];
|
|
1854
|
+
const hands = [];
|
|
1855
|
+
const properties = [
|
|
1856
|
+
{ scope: "world", owner: "", properties: summarise(bundle.world.properties) },
|
|
1857
|
+
{ scope: "story", owner: "", properties: summarise(bundle.story.properties) }
|
|
1858
|
+
];
|
|
1859
|
+
const totals = { boxes: 0, decks: 0, cards: 0, hands: 0, templates: 0, tagGroups: 0 };
|
|
1860
|
+
for (const box of bundle.boxes) {
|
|
1861
|
+
const boxGameId = effectiveGameId(box);
|
|
1862
|
+
const cards = box.decks.reduce((n, deck) => n + deck.cards.length, 0);
|
|
1863
|
+
boxes.push({
|
|
1864
|
+
gameId: boxGameId,
|
|
1865
|
+
...box.title !== void 0 ? { title: box.title } : {},
|
|
1866
|
+
ranking: { specificity: box.ranking.specificity },
|
|
1867
|
+
tagGroups: box.tagGroups.map((group) => ({
|
|
1868
|
+
gameId: effectiveGameId(group),
|
|
1869
|
+
tags: group.tags.map((tag) => effectiveGameId(tag))
|
|
1870
|
+
})),
|
|
1871
|
+
counts: {
|
|
1872
|
+
decks: box.decks.length,
|
|
1873
|
+
cards,
|
|
1874
|
+
hands: box.hands.length,
|
|
1875
|
+
templates: box.handTemplates.length,
|
|
1876
|
+
tagGroups: box.tagGroups.length
|
|
1877
|
+
}
|
|
1878
|
+
});
|
|
1879
|
+
totals.boxes += 1;
|
|
1880
|
+
totals.decks += box.decks.length;
|
|
1881
|
+
totals.cards += cards;
|
|
1882
|
+
totals.hands += box.hands.length;
|
|
1883
|
+
totals.templates += box.handTemplates.length;
|
|
1884
|
+
totals.tagGroups += box.tagGroups.length;
|
|
1885
|
+
for (const hand of box.hands) {
|
|
1886
|
+
const template = hand.template !== void 0 ? box.handTemplates.find((t) => t.id === hand.template) : void 0;
|
|
1887
|
+
hands.push({
|
|
1888
|
+
gameId: effectiveGameId(hand),
|
|
1889
|
+
...hand.title !== void 0 ? { title: hand.title } : {},
|
|
1890
|
+
box: boxGameId,
|
|
1891
|
+
slots: handSlots(hand, box),
|
|
1892
|
+
...template !== void 0 ? { template: effectiveGameId(template) } : {}
|
|
1893
|
+
});
|
|
1894
|
+
}
|
|
1895
|
+
const push = (scope, owner, decls, group) => {
|
|
1896
|
+
if (decls.length === 0) return;
|
|
1897
|
+
properties.push({
|
|
1898
|
+
scope,
|
|
1899
|
+
owner,
|
|
1900
|
+
box: boxGameId,
|
|
1901
|
+
...group !== void 0 ? { group } : {},
|
|
1902
|
+
properties: summarise(decls)
|
|
1903
|
+
});
|
|
1904
|
+
};
|
|
1905
|
+
push("box", boxGameId, box.properties);
|
|
1906
|
+
for (const deck of box.decks) push("deck", effectiveGameId(deck), deck.properties);
|
|
1907
|
+
for (const hand of box.hands) push("hand", effectiveGameId(hand), handDecls(hand, box));
|
|
1908
|
+
for (const group of box.tagGroups) {
|
|
1909
|
+
for (const tag of group.tags) {
|
|
1910
|
+
push("tag", effectiveGameId(tag), tag.properties ?? [], effectiveGameId(group));
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
return {
|
|
1915
|
+
identity: {
|
|
1916
|
+
schema: bundle.schema,
|
|
1917
|
+
project: bundle.content.project,
|
|
1918
|
+
version: bundle.content.version,
|
|
1919
|
+
hash: bundle.content.hash,
|
|
1920
|
+
metadata: bundle.metadata
|
|
1921
|
+
},
|
|
1922
|
+
totals,
|
|
1923
|
+
boxes,
|
|
1924
|
+
hands,
|
|
1925
|
+
properties,
|
|
1926
|
+
maps: (bundle.maps ?? []).map((map) => ({
|
|
1927
|
+
box: map.box,
|
|
1928
|
+
group: map.group,
|
|
1929
|
+
zones: map.zones.length,
|
|
1930
|
+
backgrounds: map.backgrounds?.length ?? 0
|
|
1931
|
+
}))
|
|
1932
|
+
};
|
|
1933
|
+
}
|
|
1934
|
+
export {
|
|
1935
|
+
Engine,
|
|
1936
|
+
Flow,
|
|
1937
|
+
describeBundle,
|
|
1938
|
+
makePrng,
|
|
1939
|
+
shuffleInPlace
|
|
1940
|
+
};
|
|
1941
|
+
//# sourceMappingURL=index.js.map
|