@table-games-engine/persistence 0.0.0-stage → 0.1.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/LICENSE.md +105 -0
- package/README.md +163 -2
- package/dist/checksum.d.ts +22 -0
- package/dist/core.d.ts +9 -0
- package/dist/core.js +239 -0
- package/dist/in-memory.d.ts +28 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +1691 -0
- package/dist/persisted-engine.d.ts +135 -0
- package/dist/replay.d.ts +64 -0
- package/dist/snapshots.d.ts +80 -0
- package/dist/sqlite/adapter.d.ts +56 -0
- package/dist/sqlite/migrations.d.ts +31 -0
- package/package.json +36 -4
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
|
+
};
|