@table-games-engine/persistence 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1691 @@
1
+ // src/checksum.ts
2
+ var FNV_OFFSET = 2166136261;
3
+ var FNV_PRIME = 16777619;
4
+ function fnv1a32(input) {
5
+ let h = FNV_OFFSET;
6
+ for (let i = 0; i < input.length; i++) {
7
+ h ^= input.charCodeAt(i);
8
+ h = Math.imul(h, FNV_PRIME);
9
+ }
10
+ return h >>> 0;
11
+ }
12
+ function canonicalize(value) {
13
+ if (value === null) return "null";
14
+ if (value === void 0) return "null";
15
+ const t = typeof value;
16
+ if (t === "number") {
17
+ if (!Number.isFinite(value)) return "null";
18
+ return JSON.stringify(value);
19
+ }
20
+ if (t === "string" || t === "boolean") return JSON.stringify(value);
21
+ if (Array.isArray(value)) {
22
+ return "[" + value.map((v) => canonicalize(v)).join(",") + "]";
23
+ }
24
+ if (t === "object") {
25
+ const obj = value;
26
+ const keys = Object.keys(obj).sort();
27
+ const parts = [];
28
+ for (const k of keys) {
29
+ const v = obj[k];
30
+ if (v === void 0) continue;
31
+ parts.push(JSON.stringify(k) + ":" + canonicalize(v));
32
+ }
33
+ return "{" + parts.join(",") + "}";
34
+ }
35
+ return "null";
36
+ }
37
+ function checksumState(state) {
38
+ const canonical = canonicalize(state);
39
+ const h = fnv1a32(canonical);
40
+ return h.toString(16).padStart(8, "0");
41
+ }
42
+
43
+ // src/in-memory.ts
44
+ var InMemoryAdapter = class {
45
+ matches = /* @__PURE__ */ new Map();
46
+ now;
47
+ closed = false;
48
+ constructor(now = () => Date.now()) {
49
+ this.now = now;
50
+ }
51
+ getOrCreate(matchId) {
52
+ let data = this.matches.get(matchId);
53
+ if (!data) {
54
+ data = {
55
+ snapshots: [],
56
+ events: [],
57
+ lastEventVersion: -1,
58
+ ownership: null
59
+ };
60
+ this.matches.set(matchId, data);
61
+ }
62
+ return data;
63
+ }
64
+ persistSnapshot(matchId, state, checksum) {
65
+ const data = this.getOrCreate(matchId);
66
+ const version = state.version;
67
+ const existing = data.snapshots.find((s) => s.version === version);
68
+ if (existing && existing.checksum !== checksum) {
69
+ return Promise.reject(
70
+ new Error(
71
+ `InMemoryAdapter: snapshot integrity violation for match ${String(
72
+ matchId
73
+ )} at version ${String(version)}: existing checksum ${existing.checksum} != new checksum ${checksum}`
74
+ )
75
+ );
76
+ }
77
+ if (!existing) {
78
+ data.snapshots.push({ version, state, checksum });
79
+ }
80
+ return Promise.resolve();
81
+ }
82
+ loadSnapshot(matchId, atVersion) {
83
+ const data = this.matches.get(matchId);
84
+ if (!data || data.snapshots.length === 0) return Promise.resolve(null);
85
+ let chosen = null;
86
+ if (atVersion === void 0) {
87
+ let bestIdx = -1;
88
+ let bestVersion = -Infinity;
89
+ for (let i = 0; i < data.snapshots.length; i++) {
90
+ const s = data.snapshots[i];
91
+ const v = s.version;
92
+ if (v >= bestVersion) {
93
+ bestVersion = v;
94
+ bestIdx = i;
95
+ }
96
+ }
97
+ chosen = bestIdx >= 0 ? data.snapshots[bestIdx] : null;
98
+ } else {
99
+ const target = atVersion;
100
+ let bestVersion = -Infinity;
101
+ for (const s of data.snapshots) {
102
+ const v = s.version;
103
+ if (v <= target && v >= bestVersion) {
104
+ bestVersion = v;
105
+ chosen = s;
106
+ }
107
+ }
108
+ }
109
+ if (!chosen) return Promise.resolve(null);
110
+ const computed = checksumState(chosen.state);
111
+ if (computed !== chosen.checksum) {
112
+ return Promise.reject(
113
+ new Error(
114
+ `InMemoryAdapter: checksum mismatch on load for match ${String(
115
+ matchId
116
+ )} at version ${String(chosen.version)}: stored ${chosen.checksum}, computed ${computed}`
117
+ )
118
+ );
119
+ }
120
+ return Promise.resolve(chosen.state);
121
+ }
122
+ appendEvents(matchId, transitionVersion, events) {
123
+ const data = this.getOrCreate(matchId);
124
+ const v = transitionVersion;
125
+ if (v <= data.lastEventVersion) {
126
+ return Promise.reject(
127
+ new Error(
128
+ `InMemoryAdapter: appendEvents version regression for match ${String(
129
+ matchId
130
+ )}: got ${v}, last was ${data.lastEventVersion}`
131
+ )
132
+ );
133
+ }
134
+ data.events.push({ version: transitionVersion, events });
135
+ data.lastEventVersion = v;
136
+ return Promise.resolve();
137
+ }
138
+ loadEvents(matchId, fromVersion, toVersion) {
139
+ const data = this.matches.get(matchId);
140
+ if (!data) return Promise.resolve([]);
141
+ const from = fromVersion;
142
+ const to = toVersion;
143
+ const out = [];
144
+ for (const batch of data.events) {
145
+ const v = batch.version;
146
+ if (v > from && v <= to) {
147
+ out.push(...batch.events);
148
+ }
149
+ }
150
+ return Promise.resolve(out);
151
+ }
152
+ acquireOwnership(matchId, ownerNode, leaseSeconds) {
153
+ const data = this.getOrCreate(matchId);
154
+ const now = this.now();
155
+ const current = data.ownership;
156
+ const expired = current === null || current.expiresAt <= now;
157
+ if (!expired && current.owner !== ownerNode) {
158
+ return Promise.resolve(false);
159
+ }
160
+ data.ownership = {
161
+ owner: ownerNode,
162
+ expiresAt: now + leaseSeconds * 1e3
163
+ };
164
+ return Promise.resolve(true);
165
+ }
166
+ releaseOwnership(matchId, ownerNode) {
167
+ const data = this.matches.get(matchId);
168
+ if (!data) return Promise.resolve();
169
+ if (data.ownership && data.ownership.owner === ownerNode) {
170
+ data.ownership = null;
171
+ }
172
+ return Promise.resolve();
173
+ }
174
+ close() {
175
+ this.closed = true;
176
+ void this.closed;
177
+ return Promise.resolve();
178
+ }
179
+ };
180
+
181
+ // src/sqlite/adapter.ts
182
+ import Database from "better-sqlite3";
183
+
184
+ // src/sqlite/migrations.ts
185
+ import { readFileSync } from "node:fs";
186
+ import { dirname, join } from "node:path";
187
+ import { fileURLToPath } from "node:url";
188
+ var CURRENT_SCHEMA_VERSION = 1;
189
+ var SCHEMA_VERSION_KEY = "schema_version";
190
+ function schemaPath() {
191
+ const here = dirname(fileURLToPath(import.meta.url));
192
+ return join(here, "schema.sql");
193
+ }
194
+ function applyMigrations(db) {
195
+ const ddl = readFileSync(schemaPath(), "utf8");
196
+ const tx = db.transaction(() => {
197
+ db.exec(ddl);
198
+ const existing = getSchemaVersion(db);
199
+ if (existing < CURRENT_SCHEMA_VERSION) {
200
+ setSchemaVersion(db, CURRENT_SCHEMA_VERSION);
201
+ }
202
+ });
203
+ tx();
204
+ }
205
+ function getSchemaVersion(db) {
206
+ const meta = db.prepare(
207
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='_meta'"
208
+ ).get();
209
+ if (!meta) return 0;
210
+ const row = db.prepare("SELECT value FROM _meta WHERE key = ?").get(SCHEMA_VERSION_KEY);
211
+ if (!row) return 0;
212
+ const n = Number.parseInt(row.value, 10);
213
+ return Number.isFinite(n) ? n : 0;
214
+ }
215
+ function setSchemaVersion(db, n) {
216
+ db.prepare(
217
+ "INSERT INTO _meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value"
218
+ ).run(SCHEMA_VERSION_KEY, String(n));
219
+ }
220
+
221
+ // src/sqlite/adapter.ts
222
+ var SqliteAdapter = class {
223
+ db;
224
+ now;
225
+ // Prepared statements — created once and reused.
226
+ stmtInsertSnapshot;
227
+ stmtSelectSnapshotChecksum;
228
+ stmtSelectLatestSnapshot;
229
+ stmtSelectSnapshotAtVersion;
230
+ stmtMaxEventVersion;
231
+ stmtInsertEvent;
232
+ stmtSelectEvents;
233
+ stmtSelectOwnership;
234
+ stmtUpsertOwnership;
235
+ stmtDeleteOwnership;
236
+ constructor(path, opts = {}) {
237
+ this.db = new Database(path);
238
+ this.db.pragma("journal_mode = WAL");
239
+ this.db.pragma("foreign_keys = ON");
240
+ applyMigrations(this.db);
241
+ this.now = opts.now ?? (() => Date.now());
242
+ this.stmtInsertSnapshot = this.db.prepare(
243
+ "INSERT OR IGNORE INTO snapshots(match_id, version, state_json, checksum, taken_at) VALUES (?, ?, ?, ?, ?)"
244
+ );
245
+ this.stmtSelectSnapshotChecksum = this.db.prepare(
246
+ "SELECT checksum FROM snapshots WHERE match_id = ? AND version = ?"
247
+ );
248
+ this.stmtSelectLatestSnapshot = this.db.prepare(
249
+ "SELECT state_json, checksum, version FROM snapshots WHERE match_id = ? ORDER BY version DESC LIMIT 1"
250
+ );
251
+ this.stmtSelectSnapshotAtVersion = this.db.prepare(
252
+ "SELECT state_json, checksum, version FROM snapshots WHERE match_id = ? AND version <= ? ORDER BY version DESC LIMIT 1"
253
+ );
254
+ this.stmtMaxEventVersion = this.db.prepare(
255
+ "SELECT MAX(version) AS c FROM events WHERE match_id = ?"
256
+ );
257
+ this.stmtInsertEvent = this.db.prepare(
258
+ "INSERT INTO events(match_id, version, idx, event_json) VALUES (?, ?, ?, ?)"
259
+ );
260
+ this.stmtSelectEvents = this.db.prepare(
261
+ "SELECT event_json FROM events WHERE match_id = ? AND version > ? AND version <= ? ORDER BY version, idx"
262
+ );
263
+ this.stmtSelectOwnership = this.db.prepare(
264
+ "SELECT owner_node, expires_at FROM ownership WHERE match_id = ?"
265
+ );
266
+ this.stmtUpsertOwnership = this.db.prepare(
267
+ "INSERT OR REPLACE INTO ownership(match_id, owner_node, expires_at) VALUES (?, ?, ?)"
268
+ );
269
+ this.stmtDeleteOwnership = this.db.prepare(
270
+ "DELETE FROM ownership WHERE match_id = ? AND owner_node = ?"
271
+ );
272
+ }
273
+ // ---------------------------------------------------------------------------
274
+ // Snapshots
275
+ // ---------------------------------------------------------------------------
276
+ persistSnapshot(matchId, state, checksum) {
277
+ const version = state.version;
278
+ const json = JSON.stringify(state);
279
+ const takenAt = this.now();
280
+ try {
281
+ const tx = this.db.transaction(() => {
282
+ const result = this.stmtInsertSnapshot.run(
283
+ matchId,
284
+ version,
285
+ json,
286
+ checksum,
287
+ takenAt
288
+ );
289
+ if (result.changes === 0) {
290
+ const existing = this.stmtSelectSnapshotChecksum.get(
291
+ matchId,
292
+ version
293
+ );
294
+ if (existing && existing.checksum !== checksum) {
295
+ throw new Error(
296
+ `SqliteAdapter: snapshot integrity violation for match ${String(
297
+ matchId
298
+ )} at version ${version}: existing checksum ${existing.checksum} != new checksum ${checksum}`
299
+ );
300
+ }
301
+ }
302
+ });
303
+ tx.immediate();
304
+ } catch (err2) {
305
+ return Promise.reject(err2);
306
+ }
307
+ return Promise.resolve();
308
+ }
309
+ loadSnapshot(matchId, atVersion) {
310
+ let row;
311
+ if (atVersion === void 0) {
312
+ row = this.stmtSelectLatestSnapshot.get(matchId);
313
+ } else {
314
+ row = this.stmtSelectSnapshotAtVersion.get(
315
+ matchId,
316
+ atVersion
317
+ );
318
+ }
319
+ if (!row) return Promise.resolve(null);
320
+ let state;
321
+ try {
322
+ state = JSON.parse(row.state_json);
323
+ } catch (err2) {
324
+ return Promise.reject(
325
+ new Error(
326
+ `SqliteAdapter: failed to parse stored state for match ${String(
327
+ matchId
328
+ )} at version ${row.version}: ${err2.message}`
329
+ )
330
+ );
331
+ }
332
+ const computed = checksumState(state);
333
+ if (computed !== row.checksum) {
334
+ return Promise.reject(
335
+ new Error(
336
+ `SqliteAdapter: checksum mismatch on load for match ${String(
337
+ matchId
338
+ )} at version ${row.version}: stored ${row.checksum}, computed ${computed}`
339
+ )
340
+ );
341
+ }
342
+ return Promise.resolve(state);
343
+ }
344
+ // ---------------------------------------------------------------------------
345
+ // Events
346
+ // ---------------------------------------------------------------------------
347
+ appendEvents(matchId, transitionVersion, events) {
348
+ if (events.length === 0) return Promise.resolve();
349
+ const v = transitionVersion;
350
+ try {
351
+ const tx = this.db.transaction(() => {
352
+ const max = this.stmtMaxEventVersion.get(
353
+ matchId
354
+ );
355
+ const existing = max.c ?? -1;
356
+ if (existing >= 0 && v <= existing) {
357
+ throw new Error(
358
+ `SqliteAdapter: appendEvents version regression for match ${String(
359
+ matchId
360
+ )}: got ${v}, last was ${existing}`
361
+ );
362
+ }
363
+ for (let i = 0; i < events.length; i++) {
364
+ const ev = events[i];
365
+ this.stmtInsertEvent.run(
366
+ matchId,
367
+ v,
368
+ i,
369
+ JSON.stringify(ev)
370
+ );
371
+ }
372
+ });
373
+ tx.immediate();
374
+ } catch (err2) {
375
+ return Promise.reject(err2);
376
+ }
377
+ return Promise.resolve();
378
+ }
379
+ loadEvents(matchId, fromVersion, toVersion) {
380
+ const rows = this.stmtSelectEvents.all(
381
+ matchId,
382
+ fromVersion,
383
+ toVersion
384
+ );
385
+ const out = rows.map(
386
+ (r) => JSON.parse(r.event_json)
387
+ );
388
+ return Promise.resolve(out);
389
+ }
390
+ // ---------------------------------------------------------------------------
391
+ // Ownership / leasing
392
+ // ---------------------------------------------------------------------------
393
+ acquireOwnership(matchId, ownerNode, leaseSeconds) {
394
+ const now = this.now();
395
+ let acquired = false;
396
+ try {
397
+ const tx = this.db.transaction(() => {
398
+ const current = this.stmtSelectOwnership.get(
399
+ matchId
400
+ );
401
+ const expired = !current || current.expires_at <= now;
402
+ if (!expired && current.owner_node !== ownerNode) {
403
+ acquired = false;
404
+ return;
405
+ }
406
+ this.stmtUpsertOwnership.run(
407
+ matchId,
408
+ ownerNode,
409
+ now + leaseSeconds * 1e3
410
+ );
411
+ acquired = true;
412
+ });
413
+ tx.immediate();
414
+ } catch (err2) {
415
+ return Promise.reject(err2);
416
+ }
417
+ return Promise.resolve(acquired);
418
+ }
419
+ releaseOwnership(matchId, ownerNode) {
420
+ this.stmtDeleteOwnership.run(matchId, ownerNode);
421
+ return Promise.resolve();
422
+ }
423
+ // ---------------------------------------------------------------------------
424
+ // Lifecycle
425
+ // ---------------------------------------------------------------------------
426
+ /**
427
+ * Close the underlying database. Subsequent calls into adapter methods will
428
+ * throw with the better-sqlite3 native error ("The database connection is
429
+ * not open"); we do not wrap that to keep the contract minimal.
430
+ *
431
+ * Idempotent: calling `close()` twice is a no-op (better-sqlite3 tolerates
432
+ * the second close cleanly when the handle is already released).
433
+ */
434
+ close() {
435
+ if (this.db.open) {
436
+ this.db.close();
437
+ }
438
+ return Promise.resolve();
439
+ }
440
+ };
441
+
442
+ // src/replay.ts
443
+ function replayMatch(adapter, matchId, toVersion) {
444
+ return adapter.loadSnapshot(matchId, toVersion);
445
+ }
446
+ function loadEventsBetween(adapter, matchId, fromVersion, toVersion) {
447
+ return adapter.loadEvents(matchId, fromVersion, toVersion);
448
+ }
449
+
450
+ // src/snapshots.ts
451
+ function everyNTransitions(n) {
452
+ if (!Number.isInteger(n) || n < 1) {
453
+ throw new Error(
454
+ `everyNTransitions: n must be a positive integer (got ${String(n)})`
455
+ );
456
+ }
457
+ return {
458
+ shouldSnapshot(_state, sinceLast) {
459
+ return sinceLast.transitionsSince >= n;
460
+ }
461
+ };
462
+ }
463
+ function everyTTicks(t) {
464
+ if (!Number.isInteger(t) || t < 1) {
465
+ throw new Error(
466
+ `everyTTicks: t must be a positive integer (got ${String(t)})`
467
+ );
468
+ }
469
+ return {
470
+ shouldSnapshot(_state, sinceLast) {
471
+ return sinceLast.ticksSince >= t;
472
+ }
473
+ };
474
+ }
475
+ function always() {
476
+ return {
477
+ shouldSnapshot() {
478
+ return true;
479
+ }
480
+ };
481
+ }
482
+ function never() {
483
+ return {
484
+ shouldSnapshot() {
485
+ return false;
486
+ }
487
+ };
488
+ }
489
+ function combine(...policies) {
490
+ if (policies.length === 0) return never();
491
+ return {
492
+ shouldSnapshot(state, sinceLast) {
493
+ for (const p of policies) {
494
+ if (p.shouldSnapshot(state, sinceLast)) return true;
495
+ }
496
+ return false;
497
+ }
498
+ };
499
+ }
500
+
501
+ // ../core/src/types/primitives.ts
502
+ function ok(value) {
503
+ return { ok: true, value };
504
+ }
505
+ function err(error) {
506
+ return { ok: false, error };
507
+ }
508
+
509
+ // ../core/src/engine/rng.ts
510
+ function xorshift32(s) {
511
+ let x = s | 0;
512
+ x ^= x << 13;
513
+ x ^= x >>> 17;
514
+ x ^= x << 5;
515
+ return x >>> 0;
516
+ }
517
+ function nextRandom(state) {
518
+ const s = state[0] ?? 1;
519
+ const next = xorshift32(s) || 1;
520
+ return {
521
+ value: next / 4294967296,
522
+ // unsigned-32 / 2^32 -> [0, 1)
523
+ newRngState: [next],
524
+ consumed: 1
525
+ };
526
+ }
527
+ function randomInt(state, min, max) {
528
+ if (min > max) throw new Error(`randomInt: min ${min} > max ${max}`);
529
+ const r = nextRandom(state);
530
+ const span = max - min + 1;
531
+ return {
532
+ value: min + Math.floor(r.value * span),
533
+ newRngState: r.newRngState,
534
+ consumed: 1
535
+ };
536
+ }
537
+ function randomPick(state, array) {
538
+ if (array.length === 0) {
539
+ return { value: void 0, newRngState: state, consumed: 0 };
540
+ }
541
+ const r = randomInt(state, 0, array.length - 1);
542
+ return { value: array[r.value], newRngState: r.newRngState, consumed: 1 };
543
+ }
544
+ function randomShuffle(state, array) {
545
+ const out = [...array];
546
+ let s = state;
547
+ let consumed = 0;
548
+ for (let i = out.length - 1; i > 0; i--) {
549
+ const r = randomInt(s, 0, i);
550
+ [out[i], out[r.value]] = [out[r.value], out[i]];
551
+ s = r.newRngState;
552
+ consumed += 1;
553
+ }
554
+ return { value: out, newRngState: s, consumed };
555
+ }
556
+
557
+ // ../core/src/engine/context.ts
558
+ function createContext(manifest, state) {
559
+ const idCounter = { n: 0 };
560
+ const ctx = {
561
+ manifest,
562
+ state,
563
+ tick: state.tick,
564
+ // === Deterministic RNG ===
565
+ random() {
566
+ return nextRandom(state.rng.state);
567
+ },
568
+ randomInt(min, max) {
569
+ return randomInt(state.rng.state, min, max);
570
+ },
571
+ randomPick(array) {
572
+ return randomPick(state.rng.state, array);
573
+ },
574
+ randomShuffle(array) {
575
+ return randomShuffle(state.rng.state, array);
576
+ },
577
+ // === State Queries ===
578
+ getPlayer(playerId) {
579
+ const player = state.players[playerId];
580
+ if (!player) return err("NOT_FOUND");
581
+ if (player.status === "eliminated") return err("ELIMINATED");
582
+ return ok({ player });
583
+ },
584
+ getActivePlayers() {
585
+ return Object.values(state.players).filter((p) => p.status !== "eliminated").map((p) => p.playerId);
586
+ },
587
+ getObject(objectId) {
588
+ const object = state.objects[objectId];
589
+ if (!object) return err("NOT_FOUND");
590
+ return ok({ object });
591
+ },
592
+ getObjectsInZone(zoneId) {
593
+ const zone = state.zones[zoneId];
594
+ if (!zone) return [];
595
+ const out = [];
596
+ for (const id of zone.objects) {
597
+ const obj = state.objects[id];
598
+ if (obj) out.push(obj);
599
+ }
600
+ return out;
601
+ },
602
+ findObjects(filter) {
603
+ const all = Object.values(state.objects);
604
+ return all.filter((o) => {
605
+ if (filter.zoneId && o.zoneId !== filter.zoneId) return false;
606
+ if (filter.controller && o.controller !== filter.controller)
607
+ return false;
608
+ if (filter.owner && o.owner !== filter.owner) return false;
609
+ if (filter.typeId && o.typeId !== filter.typeId) return false;
610
+ if (filter.tags && !filter.tags.every((t) => o.tags.includes(t)))
611
+ return false;
612
+ if (filter.custom && !filter.custom(o)) return false;
613
+ return true;
614
+ });
615
+ },
616
+ getZone(zoneId) {
617
+ const zone = state.zones[zoneId];
618
+ if (!zone) return err("NOT_FOUND");
619
+ return ok({ zone });
620
+ },
621
+ getPlayerZone(playerId, zoneType) {
622
+ const player = state.players[playerId];
623
+ if (!player) return err("NOT_FOUND");
624
+ const zoneId = player.zones[zoneType];
625
+ if (!zoneId) return err("NOT_FOUND");
626
+ const zone = state.zones[zoneId];
627
+ if (!zone) return err("NOT_FOUND");
628
+ return ok({ zone });
629
+ },
630
+ // === Condition Evaluation (manifest-wired in Task 14) ===
631
+ evaluateCondition(_condition) {
632
+ return true;
633
+ },
634
+ validateTargets(_frameId) {
635
+ return true;
636
+ },
637
+ // === Stack Queries ===
638
+ getCurrentFrame() {
639
+ const frames = state.stack.frames;
640
+ return frames[frames.length - 1];
641
+ },
642
+ getStack() {
643
+ return state.stack.frames;
644
+ },
645
+ isStackEmpty() {
646
+ return state.stack.frames.length === 0;
647
+ },
648
+ // === Priority Queries ===
649
+ getPriorityHolder() {
650
+ return state.priority.holder;
651
+ },
652
+ getActivePlayer() {
653
+ return state.turn.activePlayer;
654
+ },
655
+ getLegalActions(_playerId) {
656
+ return state.priority.window.legalActionTypes;
657
+ },
658
+ // === Effect Execution (manifest-wired in Task 14) ===
659
+ prepareEffect(_effect, _context) {
660
+ return { mutations: [], events: [], childEffects: [] };
661
+ },
662
+ // === ID Generation ===
663
+ generateId(prefix) {
664
+ idCounter.n += 1;
665
+ return `${prefix}-${state.version}-${idCounter.n}`;
666
+ }
667
+ };
668
+ return ctx;
669
+ }
670
+
671
+ // ../core/src/engine/frames.ts
672
+ function deriveFrameId(version, clientId) {
673
+ return `frame-${version}-${clientId}`;
674
+ }
675
+ function rawTargetsToSelections(targets) {
676
+ const out = {};
677
+ for (const [name, raw] of Object.entries(targets)) {
678
+ const r = raw;
679
+ if (r.type === "player" && typeof r.playerId === "string") {
680
+ out[name] = { type: "player", playerId: r.playerId };
681
+ } else if (r.type === "object" && typeof r.objectId === "string") {
682
+ out[name] = { type: "object", objectId: r.objectId };
683
+ } else if (r.type === "zone" && typeof r.zoneId === "string") {
684
+ out[name] = { type: "zone", zoneId: r.zoneId };
685
+ } else if (r.type === "stack-frame" && typeof r.frameId === "string") {
686
+ out[name] = { type: "stack-frame", frameId: r.frameId };
687
+ }
688
+ }
689
+ return out;
690
+ }
691
+ function buildActionFrame(args) {
692
+ const speed = args.def.speed ?? "instant";
693
+ return {
694
+ frameId: deriveFrameId(args.version, args.clientId),
695
+ frameType: "action",
696
+ actionTypeId: args.def.id,
697
+ actor: args.actor,
698
+ speed,
699
+ createdAt: args.tick,
700
+ status: "pending",
701
+ source: { type: "player", playerId: args.actor },
702
+ targets: {
703
+ targets: rawTargetsToSelections(args.data.targets),
704
+ valid: true
705
+ },
706
+ payload: args.data.payload,
707
+ paidCosts: [],
708
+ children: [],
709
+ parentFrame: null
710
+ };
711
+ }
712
+
713
+ // ../core/src/engine/validate.ts
714
+ function fail(code, message, details) {
715
+ const error = details ? { code, message, details } : { code, message };
716
+ return { valid: false, error };
717
+ }
718
+ function validateAction(state, manifest, input) {
719
+ if (state.priority.holder !== input.playerId) {
720
+ return fail(
721
+ "NOT_PRIORITY_HOLDER",
722
+ `${input.playerId} does not have priority`
723
+ );
724
+ }
725
+ if (input.type === "pass" || input.data.type === "pass") {
726
+ if (!state.priority.window.canPass) {
727
+ return fail("ILLEGAL_IN_WINDOW", "cannot pass right now");
728
+ }
729
+ return {
730
+ valid: true,
731
+ frame: {
732
+ frameId: "pass-noop",
733
+ frameType: "action",
734
+ actionTypeId: "__pass",
735
+ actor: input.playerId,
736
+ speed: "instant",
737
+ createdAt: state.tick,
738
+ status: "resolved",
739
+ source: { type: "player", playerId: input.playerId },
740
+ targets: { targets: {}, valid: true },
741
+ payload: {},
742
+ paidCosts: [],
743
+ children: [],
744
+ parentFrame: null
745
+ }
746
+ };
747
+ }
748
+ if (input.type === "concede" || input.data.type === "concede") {
749
+ return {
750
+ valid: true,
751
+ frame: {
752
+ frameId: "concede-noop",
753
+ frameType: "action",
754
+ actionTypeId: "__concede",
755
+ actor: input.playerId,
756
+ speed: "instant",
757
+ createdAt: state.tick,
758
+ status: "resolved",
759
+ source: { type: "player", playerId: input.playerId },
760
+ targets: { targets: {}, valid: true },
761
+ payload: {},
762
+ paidCosts: [],
763
+ children: [],
764
+ parentFrame: null
765
+ }
766
+ };
767
+ }
768
+ if (input.type === "timeout" || input.data.type === "timeout") {
769
+ return {
770
+ valid: true,
771
+ frame: {
772
+ frameId: "timeout-noop",
773
+ frameType: "action",
774
+ actionTypeId: "__timeout",
775
+ actor: input.playerId,
776
+ speed: "instant",
777
+ createdAt: state.tick,
778
+ status: "resolved",
779
+ source: { type: "player", playerId: input.playerId },
780
+ targets: { targets: {}, valid: true },
781
+ payload: {},
782
+ paidCosts: [],
783
+ children: [],
784
+ parentFrame: null
785
+ }
786
+ };
787
+ }
788
+ if (input.type !== "action" || input.data.type !== "action") {
789
+ return fail(
790
+ "ILLEGAL_IN_WINDOW",
791
+ `input type ${input.type} not supported by validateAction`
792
+ );
793
+ }
794
+ const data = input.data;
795
+ const actions = manifest.actions?.actions ?? {};
796
+ const def = actions[data.actionTypeId];
797
+ if (!def) {
798
+ return fail("INVALID_ACTION_TYPE", `unknown action ${data.actionTypeId}`);
799
+ }
800
+ const window = state.priority.window;
801
+ if (!window.legalActionTypes.includes(data.actionTypeId)) {
802
+ return fail(
803
+ "ILLEGAL_IN_WINDOW",
804
+ `${data.actionTypeId} not legal in current window`
805
+ );
806
+ }
807
+ const speed = def.speed ?? "instant";
808
+ if (window.speedRestriction === "instant-only" && speed === "sorcery") {
809
+ return fail(
810
+ "ILLEGAL_IN_WINDOW",
811
+ "sorcery-speed action not allowed; window is instant-only"
812
+ );
813
+ }
814
+ if (window.speedRestriction === "sorcery-only" && speed === "instant") {
815
+ return fail(
816
+ "ILLEGAL_IN_WINDOW",
817
+ "instant-speed action not allowed; window is sorcery-only"
818
+ );
819
+ }
820
+ if (window.speedRestriction === "none") {
821
+ return fail(
822
+ "ILLEGAL_IN_WINDOW",
823
+ "no player actions allowed in current window"
824
+ );
825
+ }
826
+ const frame = buildActionFrame({
827
+ actor: input.playerId,
828
+ def,
829
+ data,
830
+ tick: state.tick,
831
+ version: state.version,
832
+ clientId: input.clientId
833
+ });
834
+ return { valid: true, frame };
835
+ }
836
+
837
+ // ../core/src/engine/reducer.ts
838
+ function applyMutation(state, mutation) {
839
+ switch (mutation.domain) {
840
+ case "stack":
841
+ return applyStack(state, mutation.operation);
842
+ case "zone":
843
+ return applyZone(state, mutation.operation);
844
+ case "object":
845
+ return applyObject(state, mutation.operation);
846
+ case "player":
847
+ return applyPlayer(state, mutation.operation);
848
+ case "phase":
849
+ return applyPhase(state, mutation.operation);
850
+ case "turn":
851
+ return applyTurn(state, mutation.operation);
852
+ case "shared":
853
+ return applyShared(state, mutation.operation);
854
+ case "rng":
855
+ return applyRng(state, mutation.operation);
856
+ default: {
857
+ const exhaustive = mutation;
858
+ throw new Error(`Unknown mutation domain: ${JSON.stringify(exhaustive)}`);
859
+ }
860
+ }
861
+ }
862
+ function applyStack(state, op) {
863
+ switch (op.type) {
864
+ case "push":
865
+ return {
866
+ ...state,
867
+ stack: { ...state.stack, frames: [...state.stack.frames, op.frame] }
868
+ };
869
+ case "pop": {
870
+ const frames = state.stack.frames.slice(0, -1);
871
+ return { ...state, stack: { ...state.stack, frames } };
872
+ }
873
+ case "update-frame": {
874
+ const frames = state.stack.frames.map(
875
+ (f) => f.frameId === op.frameId ? { ...f, ...op.updates } : f
876
+ );
877
+ return { ...state, stack: { ...state.stack, frames } };
878
+ }
879
+ case "clear":
880
+ return { ...state, stack: { ...state.stack, frames: [] } };
881
+ default: {
882
+ const exhaustive = op;
883
+ throw new Error(`Unknown stack op: ${JSON.stringify(exhaustive)}`);
884
+ }
885
+ }
886
+ }
887
+ function applyZone(state, op) {
888
+ const zones = { ...state.zones };
889
+ const get = (id) => zones[id];
890
+ switch (op.type) {
891
+ case "add-object": {
892
+ const z = get(op.zoneId);
893
+ if (!z) throw new Error(`zone not found: ${op.zoneId}`);
894
+ const objects = op.position == null ? [...z.objects, op.objectId] : [
895
+ ...z.objects.slice(0, op.position),
896
+ op.objectId,
897
+ ...z.objects.slice(op.position)
898
+ ];
899
+ zones[op.zoneId] = { ...z, objects };
900
+ return { ...state, zones };
901
+ }
902
+ case "remove-object": {
903
+ const z = get(op.zoneId);
904
+ if (!z) throw new Error(`zone not found: ${op.zoneId}`);
905
+ zones[op.zoneId] = {
906
+ ...z,
907
+ objects: z.objects.filter((o) => o !== op.objectId)
908
+ };
909
+ return { ...state, zones };
910
+ }
911
+ case "move-object": {
912
+ const from = get(op.fromZone);
913
+ const to = get(op.toZone);
914
+ if (!from || !to) throw new Error("zone not found in move");
915
+ zones[op.fromZone] = {
916
+ ...from,
917
+ objects: from.objects.filter((o) => o !== op.objectId)
918
+ };
919
+ const toObjects = op.position == null ? [...to.objects, op.objectId] : [
920
+ ...to.objects.slice(0, op.position),
921
+ op.objectId,
922
+ ...to.objects.slice(op.position)
923
+ ];
924
+ zones[op.toZone] = { ...to, objects: toObjects };
925
+ return { ...state, zones };
926
+ }
927
+ case "reorder": {
928
+ const z = get(op.zoneId);
929
+ if (!z) throw new Error(`zone not found: ${op.zoneId}`);
930
+ zones[op.zoneId] = { ...z, objects: [...op.newOrder] };
931
+ return { ...state, zones };
932
+ }
933
+ case "shuffle":
934
+ return state;
935
+ default: {
936
+ const exhaustive = op;
937
+ throw new Error(`Unknown zone op: ${JSON.stringify(exhaustive)}`);
938
+ }
939
+ }
940
+ }
941
+ function applyObject(state, op) {
942
+ const objects = { ...state.objects };
943
+ switch (op.type) {
944
+ case "create":
945
+ objects[op.object.objectId] = op.object;
946
+ return { ...state, objects };
947
+ case "destroy": {
948
+ const next = { ...objects };
949
+ delete next[op.objectId];
950
+ return { ...state, objects: next };
951
+ }
952
+ case "update": {
953
+ const o = objects[op.objectId];
954
+ if (!o) return state;
955
+ objects[op.objectId] = { ...o, ...op.updates };
956
+ return { ...state, objects };
957
+ }
958
+ case "set-counter": {
959
+ const o = objects[op.objectId];
960
+ if (!o) return state;
961
+ objects[op.objectId] = {
962
+ ...o,
963
+ counters: { ...o.counters, [op.counter]: op.value }
964
+ };
965
+ return { ...state, objects };
966
+ }
967
+ case "add-modification": {
968
+ const o = objects[op.objectId];
969
+ if (!o) return state;
970
+ objects[op.objectId] = {
971
+ ...o,
972
+ modifications: [
973
+ ...o.modifications,
974
+ op.modification
975
+ ]
976
+ };
977
+ return { ...state, objects };
978
+ }
979
+ case "remove-modification": {
980
+ const o = objects[op.objectId];
981
+ if (!o) return state;
982
+ objects[op.objectId] = {
983
+ ...o,
984
+ modifications: o.modifications.filter((m) => m.id !== op.modificationId)
985
+ };
986
+ return { ...state, objects };
987
+ }
988
+ case "tap": {
989
+ const o = objects[op.objectId];
990
+ if (!o) return state;
991
+ const tags = o.tags.includes("tapped") ? o.tags : [...o.tags, "tapped"];
992
+ objects[op.objectId] = { ...o, tapped: true, tags };
993
+ return { ...state, objects };
994
+ }
995
+ case "untap": {
996
+ const o = objects[op.objectId];
997
+ if (!o) return state;
998
+ objects[op.objectId] = {
999
+ ...o,
1000
+ tapped: false,
1001
+ tags: o.tags.filter((t) => t !== "tapped")
1002
+ };
1003
+ return { ...state, objects };
1004
+ }
1005
+ case "flip": {
1006
+ const o = objects[op.objectId];
1007
+ if (!o) return state;
1008
+ objects[op.objectId] = { ...o, faceState: op.faceState };
1009
+ return { ...state, objects };
1010
+ }
1011
+ default: {
1012
+ const exhaustive = op;
1013
+ throw new Error(`Unknown object op: ${JSON.stringify(exhaustive)}`);
1014
+ }
1015
+ }
1016
+ }
1017
+ function applyPlayer(state, op) {
1018
+ const players = { ...state.players };
1019
+ const p = players[op.playerId];
1020
+ if (!p) return state;
1021
+ switch (op.type) {
1022
+ case "set-resource":
1023
+ players[op.playerId] = {
1024
+ ...p,
1025
+ resources: { ...p.resources, [op.resource]: op.value }
1026
+ };
1027
+ return { ...state, players };
1028
+ case "add-resource":
1029
+ players[op.playerId] = {
1030
+ ...p,
1031
+ resources: {
1032
+ ...p.resources,
1033
+ [op.resource]: (p.resources[op.resource] ?? 0) + op.delta
1034
+ }
1035
+ };
1036
+ return { ...state, players };
1037
+ case "set-counter":
1038
+ players[op.playerId] = {
1039
+ ...p,
1040
+ counters: { ...p.counters, [op.counter]: op.value }
1041
+ };
1042
+ return { ...state, players };
1043
+ case "set-flag":
1044
+ players[op.playerId] = {
1045
+ ...p,
1046
+ flags: { ...p.flags, [op.flag]: op.value }
1047
+ };
1048
+ return { ...state, players };
1049
+ case "set-status":
1050
+ players[op.playerId] = {
1051
+ ...p,
1052
+ status: op.status
1053
+ };
1054
+ return { ...state, players };
1055
+ case "update-custom":
1056
+ players[op.playerId] = {
1057
+ ...p,
1058
+ custom: { ...p.custom, [op.path]: op.value }
1059
+ };
1060
+ return { ...state, players };
1061
+ default: {
1062
+ const exhaustive = op;
1063
+ throw new Error(`Unknown player op: ${JSON.stringify(exhaustive)}`);
1064
+ }
1065
+ }
1066
+ }
1067
+ function applyPhase(state, op) {
1068
+ switch (op.type) {
1069
+ case "enter": {
1070
+ const phaseId = op.phaseId;
1071
+ const visited = state.phase.visitedThisTurn.includes(phaseId) ? state.phase.visitedThisTurn : [...state.phase.visitedThisTurn, phaseId];
1072
+ return {
1073
+ ...state,
1074
+ phase: {
1075
+ ...state.phase,
1076
+ current: phaseId,
1077
+ enteredAt: state.tick,
1078
+ visitedThisTurn: visited
1079
+ }
1080
+ };
1081
+ }
1082
+ case "exit":
1083
+ return state;
1084
+ case "set-visited":
1085
+ return {
1086
+ ...state,
1087
+ phase: {
1088
+ ...state.phase,
1089
+ visitedThisTurn: [...op.phases]
1090
+ }
1091
+ };
1092
+ default: {
1093
+ const exhaustive = op;
1094
+ throw new Error(`Unknown phase op: ${JSON.stringify(exhaustive)}`);
1095
+ }
1096
+ }
1097
+ }
1098
+ function applyTurn(state, op) {
1099
+ const { turn } = state;
1100
+ switch (op.type) {
1101
+ case "advance": {
1102
+ const idx = turn.turnOrder.indexOf(turn.activePlayer);
1103
+ const len = Math.max(turn.turnOrder.length, 1);
1104
+ const nextIdx = idx === -1 ? turn.activePlayerIndex % len : (idx + 1) % len;
1105
+ const next = turn.turnOrder[nextIdx] ?? turn.activePlayer;
1106
+ return {
1107
+ ...state,
1108
+ turn: {
1109
+ ...turn,
1110
+ turnNumber: turn.turnNumber + 1,
1111
+ activePlayer: next,
1112
+ activePlayerIndex: nextIdx
1113
+ }
1114
+ };
1115
+ }
1116
+ case "set-active-player": {
1117
+ const idx = turn.turnOrder.indexOf(op.playerId);
1118
+ return {
1119
+ ...state,
1120
+ turn: {
1121
+ ...turn,
1122
+ activePlayer: op.playerId,
1123
+ activePlayerIndex: idx === -1 ? turn.activePlayerIndex : idx
1124
+ }
1125
+ };
1126
+ }
1127
+ case "add-extra-turn": {
1128
+ const extra = { player: op.playerId, reason: op.reason };
1129
+ return { ...state, turn: { ...turn, extraTurns: [...turn.extraTurns, extra] } };
1130
+ }
1131
+ case "eliminate-player": {
1132
+ const newOrder = turn.turnOrder.filter((p) => p !== op.playerId);
1133
+ const eliminated = turn.eliminatedPlayers.includes(op.playerId) ? turn.eliminatedPlayers : [...turn.eliminatedPlayers, op.playerId];
1134
+ return {
1135
+ ...state,
1136
+ turn: { ...turn, turnOrder: newOrder, eliminatedPlayers: eliminated }
1137
+ };
1138
+ }
1139
+ case "set-turn-order":
1140
+ return {
1141
+ ...state,
1142
+ turn: { ...turn, turnOrder: [...op.order] }
1143
+ };
1144
+ default: {
1145
+ const exhaustive = op;
1146
+ throw new Error(`Unknown turn op: ${JSON.stringify(exhaustive)}`);
1147
+ }
1148
+ }
1149
+ }
1150
+ function applyShared(state, op) {
1151
+ switch (op.type) {
1152
+ case "set":
1153
+ return {
1154
+ ...state,
1155
+ shared: {
1156
+ ...state.shared,
1157
+ [op.path]: op.value
1158
+ }
1159
+ };
1160
+ case "merge":
1161
+ return {
1162
+ ...state,
1163
+ shared: {
1164
+ ...state.shared,
1165
+ ...op.updates
1166
+ }
1167
+ };
1168
+ default: {
1169
+ const exhaustive = op;
1170
+ throw new Error(`Unknown shared op: ${JSON.stringify(exhaustive)}`);
1171
+ }
1172
+ }
1173
+ }
1174
+ function applyRng(state, op) {
1175
+ switch (op.type) {
1176
+ case "advance":
1177
+ return {
1178
+ ...state,
1179
+ rng: { ...state.rng, counter: state.rng.counter + op.count }
1180
+ };
1181
+ case "set-state":
1182
+ return {
1183
+ ...state,
1184
+ rng: { ...state.rng, state: [...op.state] }
1185
+ };
1186
+ default: {
1187
+ const exhaustive = op;
1188
+ throw new Error(`Unknown rng op: ${JSON.stringify(exhaustive)}`);
1189
+ }
1190
+ }
1191
+ }
1192
+
1193
+ // ../core/src/engine/transition.ts
1194
+ function inc(v) {
1195
+ return v + 1;
1196
+ }
1197
+ function incTick(t) {
1198
+ return t + 1;
1199
+ }
1200
+ function deriveTransitionId(version) {
1201
+ return `tx-${version}`;
1202
+ }
1203
+ function applyTransition(state, mutations, events, cause, type = "state-based") {
1204
+ let next = state;
1205
+ for (const m of mutations) next = applyMutation(next, m);
1206
+ const fromVersion = state.version;
1207
+ const toVersion = inc(fromVersion);
1208
+ const newTick = incTick(state.tick);
1209
+ const newState = {
1210
+ ...next,
1211
+ version: toVersion,
1212
+ tick: newTick,
1213
+ recentEvents: [...next.recentEvents, ...events]
1214
+ };
1215
+ const transition = {
1216
+ transitionId: deriveTransitionId(toVersion),
1217
+ type,
1218
+ fromVersion,
1219
+ toVersion,
1220
+ timestamp: newTick,
1221
+ cause,
1222
+ mutations: [...mutations],
1223
+ events: [...events]
1224
+ };
1225
+ return { newState, transition };
1226
+ }
1227
+
1228
+ // ../core/src/engine/stack.ts
1229
+ function peek(stack) {
1230
+ return stack.frames[stack.frames.length - 1];
1231
+ }
1232
+
1233
+ // ../core/src/engine/resolve.ts
1234
+ function resolveTopFrame(state, manifest, handlers) {
1235
+ const top = peek(state.stack);
1236
+ if (!top) return { kind: "stack-empty" };
1237
+ if (!top.targets.valid) {
1238
+ return { kind: "fizzled", frameId: top.frameId, reason: "targets-illegal" };
1239
+ }
1240
+ const key = top.frameType === "action" || top.frameType === "reaction" ? top.actionTypeId : top.frameType === "trigger" ? top.triggerId : top.effectType;
1241
+ const handler = handlers[key];
1242
+ if (!handler) return { kind: "no-handler", actionTypeId: key };
1243
+ const ctx = createContext(manifest, state);
1244
+ const result = handler(ctx, top);
1245
+ return { kind: "resolved", frameId: top.frameId, result };
1246
+ }
1247
+
1248
+ // ../core/src/engine/lifecycle.ts
1249
+ function advanceTurn(state) {
1250
+ return applyMutation(state, { domain: "turn", operation: { type: "advance" } });
1251
+ }
1252
+ function advancePhase(state, manifest) {
1253
+ const currentPhaseId = state.phase.current;
1254
+ const transitions = manifest.phases?.transitions;
1255
+ if (!transitions || transitions.length === 0) return state;
1256
+ const candidates = transitions.filter((t) => t.from === currentPhaseId).slice().sort((a, b) => b.priority - a.priority);
1257
+ const next = candidates[0]?.to;
1258
+ if (!next) return state;
1259
+ return applyMutation(state, {
1260
+ domain: "phase",
1261
+ operation: { type: "enter", phaseId: next }
1262
+ });
1263
+ }
1264
+ function checkWin(state) {
1265
+ const allPlayers = Object.values(state.players);
1266
+ const active = allPlayers.filter((p) => p.status !== "eliminated");
1267
+ if (active.length > 1) {
1268
+ return { ended: false };
1269
+ }
1270
+ const winner = active[0];
1271
+ const rankings = [];
1272
+ if (winner) {
1273
+ rankings.push({ playerId: winner.playerId, rank: 1 });
1274
+ }
1275
+ let nextRank = winner ? 2 : 1;
1276
+ for (const p of allPlayers) {
1277
+ if (winner && p.playerId === winner.playerId) continue;
1278
+ rankings.push({ playerId: p.playerId, rank: nextRank });
1279
+ nextRank += 1;
1280
+ }
1281
+ const result = {
1282
+ outcome: winner ? "win" : "draw",
1283
+ winners: winner ? [winner.playerId] : [],
1284
+ rankings,
1285
+ endedAt: state.tick
1286
+ };
1287
+ return { ended: true, result };
1288
+ }
1289
+
1290
+ // ../core/src/engine/window.ts
1291
+ function computeWindow(state, manifest, _holder) {
1292
+ const phaseId = state.phase.current;
1293
+ const phase = manifest.phases.phases[phaseId];
1294
+ const phaseActions = phase?.legalActions ?? [];
1295
+ const top = state.stack.frames[state.stack.frames.length - 1];
1296
+ const respondingTo = top ? top.frameId : null;
1297
+ const type = respondingTo ? "response" : "main";
1298
+ return {
1299
+ type,
1300
+ legalActionTypes: phaseActions,
1301
+ speedRestriction: respondingTo ? "instant-only" : "any",
1302
+ respondingTo,
1303
+ restrictions: [],
1304
+ canPass: true,
1305
+ requiredAction: null
1306
+ };
1307
+ }
1308
+
1309
+ // ../core/src/engine/triggers.ts
1310
+ function resolveController(def, state) {
1311
+ const raw = def.controller;
1312
+ if (raw === "active-player" || raw === "source-controller" || raw === "all" || raw == null) {
1313
+ return state.turn.activePlayer;
1314
+ }
1315
+ return raw;
1316
+ }
1317
+ function eventMatches(def, event) {
1318
+ const pattern = def.event;
1319
+ if (!pattern || typeof pattern.eventType !== "string") return false;
1320
+ if (pattern.eventType !== event.type) return false;
1321
+ const filters = pattern.filters;
1322
+ if (filters) {
1323
+ for (const key of Object.keys(filters)) {
1324
+ if (event.data[key] !== filters[key]) return false;
1325
+ }
1326
+ }
1327
+ return true;
1328
+ }
1329
+ function buildTriggerFrame(def, event, state) {
1330
+ const controller = resolveController(def, state);
1331
+ const frameId = `trigger-${state.version}-${def.id}-${event.eventId}`;
1332
+ const optional = def.optional ?? false;
1333
+ return {
1334
+ frameId,
1335
+ frameType: "trigger",
1336
+ triggerId: def.id,
1337
+ controller,
1338
+ triggeringEvent: {
1339
+ eventType: event.type,
1340
+ eventData: event.data,
1341
+ timestamp: event.timestamp
1342
+ },
1343
+ triggerSource: null,
1344
+ optional,
1345
+ createdAt: event.timestamp,
1346
+ status: "pending",
1347
+ source: { type: "trigger", triggerId: def.id, controller },
1348
+ targets: { targets: {}, valid: true },
1349
+ payload: {},
1350
+ paidCosts: [],
1351
+ children: [],
1352
+ parentFrame: null
1353
+ };
1354
+ }
1355
+ function scanTriggers(state, manifest, recentEvents) {
1356
+ const catalog = manifest.triggers?.triggers ?? {};
1357
+ const defs = Object.values(catalog);
1358
+ const out = [];
1359
+ for (const event of recentEvents) {
1360
+ for (const def of defs) {
1361
+ if (eventMatches(def, event)) {
1362
+ out.push(buildTriggerFrame(def, event, state));
1363
+ }
1364
+ }
1365
+ }
1366
+ return orderApnap(out, state.turn.activePlayer);
1367
+ }
1368
+ function orderApnap(frames, activePlayer) {
1369
+ const ap = [];
1370
+ const nap = [];
1371
+ for (const f of frames) {
1372
+ if (f.controller === activePlayer) ap.push(f);
1373
+ else nap.push(f);
1374
+ }
1375
+ return [...ap, ...nap];
1376
+ }
1377
+
1378
+ // ../core/src/engine/operations.ts
1379
+ function refreshWindow(state, manifest) {
1380
+ const holder = state.priority.holder ?? state.turn.activePlayer;
1381
+ const window = computeWindow(state, manifest, holder);
1382
+ return {
1383
+ ...state,
1384
+ priority: { ...state.priority, window }
1385
+ };
1386
+ }
1387
+ function isSentinelFrameId(id) {
1388
+ if (id.startsWith("pass-noop")) return "pass";
1389
+ if (id.startsWith("concede-noop")) return "concede";
1390
+ if (id.startsWith("timeout-noop")) return "timeout";
1391
+ return null;
1392
+ }
1393
+ function createEngine(manifest, initialState, handlers) {
1394
+ let state = initialState;
1395
+ function setState(next) {
1396
+ state = next;
1397
+ }
1398
+ return {
1399
+ getState: () => state,
1400
+ applyAction(input) {
1401
+ const refreshed = refreshWindow(state, manifest);
1402
+ setState(refreshed);
1403
+ const validation = validateAction(refreshed, manifest, input);
1404
+ if (!validation.valid) return err(validation);
1405
+ const frame = validation.frame;
1406
+ const sentinel = isSentinelFrameId(frame.frameId);
1407
+ if (sentinel === "pass") {
1408
+ const newPriority = {
1409
+ ...refreshed.priority,
1410
+ passed: refreshed.priority.passed.includes(input.playerId) ? refreshed.priority.passed : [...refreshed.priority.passed, input.playerId],
1411
+ consecutivePasses: refreshed.priority.consecutivePasses + 1
1412
+ };
1413
+ const { newState: newState2, transition: transition2 } = applyTransition(
1414
+ { ...refreshed, priority: newPriority },
1415
+ [],
1416
+ [],
1417
+ { type: "player-action", playerId: input.playerId, input },
1418
+ "priority-passed"
1419
+ );
1420
+ setState(newState2);
1421
+ return ok(transition2);
1422
+ }
1423
+ if (sentinel === "concede" || sentinel === "timeout") {
1424
+ const mutations = [
1425
+ {
1426
+ domain: "player",
1427
+ operation: { type: "set-status", playerId: input.playerId, status: "eliminated" }
1428
+ },
1429
+ {
1430
+ domain: "turn",
1431
+ operation: { type: "eliminate-player", playerId: input.playerId }
1432
+ }
1433
+ ];
1434
+ const { newState: newState2, transition: transition2 } = applyTransition(
1435
+ refreshed,
1436
+ mutations,
1437
+ [],
1438
+ { type: "player-action", playerId: input.playerId, input },
1439
+ "player-eliminated"
1440
+ );
1441
+ setState(newState2);
1442
+ return ok(transition2);
1443
+ }
1444
+ const { newState, transition } = applyTransition(
1445
+ refreshed,
1446
+ [{ domain: "stack", operation: { type: "push", frame } }],
1447
+ [],
1448
+ { type: "player-action", playerId: input.playerId, input },
1449
+ "action-pushed"
1450
+ );
1451
+ setState(newState);
1452
+ return ok(transition);
1453
+ },
1454
+ resolveTopFrame() {
1455
+ const outcome = resolveTopFrame(state, manifest, handlers);
1456
+ switch (outcome.kind) {
1457
+ case "stack-empty":
1458
+ return err("rule-violation");
1459
+ case "no-handler":
1460
+ return err("rule-violation");
1461
+ case "fizzled": {
1462
+ const { newState, transition } = applyTransition(
1463
+ state,
1464
+ [{ domain: "stack", operation: { type: "pop" } }],
1465
+ [],
1466
+ { type: "stack-resolution", frameId: outcome.frameId },
1467
+ "action-countered"
1468
+ );
1469
+ setState(newState);
1470
+ return ok(transition);
1471
+ }
1472
+ case "resolved": {
1473
+ const r = outcome.result;
1474
+ const childPushes = r.newFrames.map((f) => ({
1475
+ domain: "stack",
1476
+ operation: { type: "push", frame: f }
1477
+ }));
1478
+ const mutations = [
1479
+ { domain: "stack", operation: { type: "pop" } },
1480
+ ...r.mutations,
1481
+ ...childPushes
1482
+ ];
1483
+ const { newState, transition } = applyTransition(
1484
+ state,
1485
+ mutations,
1486
+ r.events,
1487
+ { type: "stack-resolution", frameId: outcome.frameId },
1488
+ "action-resolved"
1489
+ );
1490
+ setState(newState);
1491
+ return ok(transition);
1492
+ }
1493
+ }
1494
+ },
1495
+ processTriggers() {
1496
+ const triggers = scanTriggers(state, manifest, state.recentEvents);
1497
+ const mutations = triggers.map((f) => ({
1498
+ domain: "stack",
1499
+ operation: { type: "push", frame: f }
1500
+ }));
1501
+ const { newState, transition } = applyTransition(
1502
+ state,
1503
+ mutations,
1504
+ [],
1505
+ { type: "system", reason: "process-triggers" },
1506
+ "trigger-fired"
1507
+ );
1508
+ setState(newState);
1509
+ return ok(transition);
1510
+ },
1511
+ checkStateBasedActions() {
1512
+ const { newState, transition } = applyTransition(
1513
+ state,
1514
+ [],
1515
+ [],
1516
+ { type: "state-based-action" },
1517
+ "state-based"
1518
+ );
1519
+ setState(newState);
1520
+ return ok(transition);
1521
+ },
1522
+ advancePhase() {
1523
+ const next = advancePhase(state, manifest);
1524
+ if (next === state) return err("CANNOT_ADVANCE");
1525
+ const nextPhaseId = next.phase.current;
1526
+ const { newState, transition } = applyTransition(
1527
+ state,
1528
+ [{ domain: "phase", operation: { type: "enter", phaseId: nextPhaseId } }],
1529
+ [],
1530
+ { type: "phase-exit" },
1531
+ "phase-changed"
1532
+ );
1533
+ setState(newState);
1534
+ return ok(transition);
1535
+ },
1536
+ advanceTurn() {
1537
+ const next = advanceTurn(state);
1538
+ if (next === state) return err("CANNOT_ADVANCE");
1539
+ const newActive = next.turn.activePlayer;
1540
+ const { newState, transition } = applyTransition(
1541
+ state,
1542
+ [
1543
+ { domain: "turn", operation: { type: "advance" } },
1544
+ // Reset priority to the new active player so the next applyAction
1545
+ // sees a consistent holder.
1546
+ {
1547
+ domain: "turn",
1548
+ operation: { type: "set-active-player", playerId: newActive }
1549
+ }
1550
+ ],
1551
+ [],
1552
+ { type: "system", reason: "advance-turn" },
1553
+ "turn-changed"
1554
+ );
1555
+ const withPriority = {
1556
+ ...newState,
1557
+ priority: { ...newState.priority, holder: newActive, passed: [], consecutivePasses: 0 }
1558
+ };
1559
+ setState(withPriority);
1560
+ return ok(transition);
1561
+ },
1562
+ checkWinConditions() {
1563
+ return checkWin(state);
1564
+ }
1565
+ };
1566
+ }
1567
+
1568
+ // src/persisted-engine.ts
1569
+ var DEFAULT_LEASE_SECONDS = 60;
1570
+ async function createPersistedEngine(args) {
1571
+ const {
1572
+ manifest,
1573
+ initialState,
1574
+ handlers,
1575
+ adapter,
1576
+ matchId,
1577
+ policy = everyNTransitions(50),
1578
+ ownerNode,
1579
+ leaseSeconds = DEFAULT_LEASE_SECONDS
1580
+ } = args;
1581
+ if (ownerNode !== void 0) {
1582
+ const acquired = await adapter.acquireOwnership(
1583
+ matchId,
1584
+ ownerNode,
1585
+ leaseSeconds
1586
+ );
1587
+ if (!acquired) {
1588
+ throw new Error(
1589
+ `createPersistedEngine: ownership lease for match ${String(
1590
+ matchId
1591
+ )} is held by another node; ${String(ownerNode)} could not acquire`
1592
+ );
1593
+ }
1594
+ }
1595
+ await adapter.persistSnapshot(
1596
+ matchId,
1597
+ initialState,
1598
+ checksumState(initialState)
1599
+ );
1600
+ const engine = createEngine(manifest, initialState, handlers);
1601
+ let transitionsSince = 0;
1602
+ let lastSnapshotTick = initialState.tick;
1603
+ let lastVersionPersisted = initialState.version;
1604
+ async function persistTransition(transition) {
1605
+ const tv = transition.toVersion;
1606
+ if (tv <= lastVersionPersisted) {
1607
+ return;
1608
+ }
1609
+ lastVersionPersisted = tv;
1610
+ const events = transition.events;
1611
+ if (events.length > 0) {
1612
+ await adapter.appendEvents(matchId, transition.toVersion, events);
1613
+ }
1614
+ transitionsSince += 1;
1615
+ const state = engine.getState();
1616
+ const ticksSince = state.tick - lastSnapshotTick;
1617
+ if (policy.shouldSnapshot(state, { transitionsSince, ticksSince })) {
1618
+ await snapshotNow(state);
1619
+ }
1620
+ }
1621
+ async function snapshotNow(state) {
1622
+ await adapter.persistSnapshot(matchId, state, checksumState(state));
1623
+ transitionsSince = 0;
1624
+ lastSnapshotTick = state.tick;
1625
+ }
1626
+ let releasedOwnership = false;
1627
+ async function releaseOwnership() {
1628
+ if (ownerNode === void 0) return;
1629
+ if (releasedOwnership) return;
1630
+ releasedOwnership = true;
1631
+ await adapter.releaseOwnership(matchId, ownerNode);
1632
+ }
1633
+ return {
1634
+ getState: () => engine.getState(),
1635
+ getMatchId: () => matchId,
1636
+ async applyAction(input) {
1637
+ const r = engine.applyAction(input);
1638
+ if (r.ok) await persistTransition(r.value);
1639
+ return r;
1640
+ },
1641
+ async resolveTopFrame() {
1642
+ const r = engine.resolveTopFrame();
1643
+ if (r.ok) await persistTransition(r.value);
1644
+ return r;
1645
+ },
1646
+ async processTriggers() {
1647
+ const r = engine.processTriggers();
1648
+ if (r.ok) await persistTransition(r.value);
1649
+ return r;
1650
+ },
1651
+ async checkStateBasedActions() {
1652
+ const r = engine.checkStateBasedActions();
1653
+ if (r.ok) await persistTransition(r.value);
1654
+ return r;
1655
+ },
1656
+ async advancePhase() {
1657
+ const r = engine.advancePhase();
1658
+ if (r.ok) await persistTransition(r.value);
1659
+ return r;
1660
+ },
1661
+ async advanceTurn() {
1662
+ const r = engine.advanceTurn();
1663
+ if (r.ok) await persistTransition(r.value);
1664
+ return r;
1665
+ },
1666
+ checkWinConditions() {
1667
+ return engine.checkWinConditions();
1668
+ },
1669
+ async forceSnapshot() {
1670
+ await snapshotNow(engine.getState());
1671
+ },
1672
+ releaseOwnership,
1673
+ async close() {
1674
+ await releaseOwnership();
1675
+ await adapter.close();
1676
+ }
1677
+ };
1678
+ }
1679
+ export {
1680
+ InMemoryAdapter,
1681
+ SqliteAdapter,
1682
+ always,
1683
+ checksumState,
1684
+ combine,
1685
+ createPersistedEngine,
1686
+ everyNTransitions,
1687
+ everyTTicks,
1688
+ loadEventsBetween,
1689
+ never,
1690
+ replayMatch
1691
+ };