@table-games-engine/nakama-adapter 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 +322 -2
- package/dist/broadcast.d.ts +27 -0
- package/dist/idempotency.d.ts +54 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +1224 -0
- package/dist/match-handler.d.ts +76 -0
- package/dist/persistence-bridge.d.ts +78 -0
- package/dist/plugins.d.ts +76 -0
- package/dist/register-plugin.d.ts +7 -0
- package/dist/registry.d.ts +84 -0
- package/dist/serialization.d.ts +135 -0
- package/dist/timeouts.d.ts +33 -0
- package/dist/types.d.ts +108 -0
- package/package.json +32 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,1224 @@
|
|
|
1
|
+
// src/serialization.ts
|
|
2
|
+
var INBOUND_KINDS = /* @__PURE__ */ new Set([
|
|
3
|
+
"action",
|
|
4
|
+
"pass",
|
|
5
|
+
"concede",
|
|
6
|
+
"choose-targets",
|
|
7
|
+
"order-triggers",
|
|
8
|
+
"admin-pause",
|
|
9
|
+
"admin-resume"
|
|
10
|
+
]);
|
|
11
|
+
function isObject(v) {
|
|
12
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
13
|
+
}
|
|
14
|
+
function isString(v) {
|
|
15
|
+
return typeof v === "string";
|
|
16
|
+
}
|
|
17
|
+
function isNumber(v) {
|
|
18
|
+
return typeof v === "number" && Number.isFinite(v);
|
|
19
|
+
}
|
|
20
|
+
function parseInbound(json) {
|
|
21
|
+
let raw;
|
|
22
|
+
try {
|
|
23
|
+
raw = JSON.parse(json);
|
|
24
|
+
} catch (e) {
|
|
25
|
+
return {
|
|
26
|
+
kind: "malformed",
|
|
27
|
+
reason: "invalid-json",
|
|
28
|
+
detail: e instanceof Error ? e.message : String(e)
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
if (!isObject(raw)) {
|
|
32
|
+
return { kind: "malformed", reason: "not-object" };
|
|
33
|
+
}
|
|
34
|
+
const kind = raw["kind"];
|
|
35
|
+
if (!isString(kind)) {
|
|
36
|
+
return { kind: "malformed", reason: "missing-kind" };
|
|
37
|
+
}
|
|
38
|
+
if (!INBOUND_KINDS.has(kind)) {
|
|
39
|
+
return { kind: "malformed", reason: "unknown-kind", detail: kind };
|
|
40
|
+
}
|
|
41
|
+
switch (kind) {
|
|
42
|
+
case "action":
|
|
43
|
+
return parseActionEnvelope(raw);
|
|
44
|
+
case "pass":
|
|
45
|
+
return parseSimpleVersionedEnvelope(raw, "pass");
|
|
46
|
+
case "concede":
|
|
47
|
+
return parseSimpleVersionedEnvelope(raw, "concede");
|
|
48
|
+
case "choose-targets":
|
|
49
|
+
return parseChooseTargetsEnvelope(raw);
|
|
50
|
+
case "order-triggers":
|
|
51
|
+
return parseOrderTriggersEnvelope(raw);
|
|
52
|
+
case "admin-pause":
|
|
53
|
+
return parseAdminPauseEnvelope(raw);
|
|
54
|
+
case "admin-resume":
|
|
55
|
+
return parseAdminResumeEnvelope(raw);
|
|
56
|
+
default:
|
|
57
|
+
return { kind: "malformed", reason: "unknown-kind", detail: kind };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function parseActionEnvelope(raw) {
|
|
61
|
+
const clientId = raw["clientId"];
|
|
62
|
+
const stateVersion = raw["stateVersion"];
|
|
63
|
+
const action = raw["action"];
|
|
64
|
+
if (!isString(clientId) || clientId.length === 0) {
|
|
65
|
+
return { kind: "malformed", reason: "invalid-fields", detail: "clientId" };
|
|
66
|
+
}
|
|
67
|
+
if (!isNumber(stateVersion)) {
|
|
68
|
+
return {
|
|
69
|
+
kind: "malformed",
|
|
70
|
+
reason: "invalid-fields",
|
|
71
|
+
detail: "stateVersion"
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (!isObject(action) || action["type"] !== "action") {
|
|
75
|
+
return { kind: "malformed", reason: "invalid-fields", detail: "action" };
|
|
76
|
+
}
|
|
77
|
+
if (!isString(action["actionTypeId"])) {
|
|
78
|
+
return {
|
|
79
|
+
kind: "malformed",
|
|
80
|
+
reason: "invalid-fields",
|
|
81
|
+
detail: "action.actionTypeId"
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
kind: "action",
|
|
86
|
+
clientId,
|
|
87
|
+
stateVersion,
|
|
88
|
+
action
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function parseSimpleVersionedEnvelope(raw, kind) {
|
|
92
|
+
const clientId = raw["clientId"];
|
|
93
|
+
const stateVersion = raw["stateVersion"];
|
|
94
|
+
if (!isString(clientId) || clientId.length === 0) {
|
|
95
|
+
return { kind: "malformed", reason: "invalid-fields", detail: "clientId" };
|
|
96
|
+
}
|
|
97
|
+
if (!isNumber(stateVersion)) {
|
|
98
|
+
return {
|
|
99
|
+
kind: "malformed",
|
|
100
|
+
reason: "invalid-fields",
|
|
101
|
+
detail: "stateVersion"
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return { kind, clientId, stateVersion };
|
|
105
|
+
}
|
|
106
|
+
function parseChooseTargetsEnvelope(raw) {
|
|
107
|
+
const clientId = raw["clientId"];
|
|
108
|
+
const stateVersion = raw["stateVersion"];
|
|
109
|
+
const action = raw["action"];
|
|
110
|
+
if (!isString(clientId) || clientId.length === 0) {
|
|
111
|
+
return { kind: "malformed", reason: "invalid-fields", detail: "clientId" };
|
|
112
|
+
}
|
|
113
|
+
if (!isNumber(stateVersion)) {
|
|
114
|
+
return {
|
|
115
|
+
kind: "malformed",
|
|
116
|
+
reason: "invalid-fields",
|
|
117
|
+
detail: "stateVersion"
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (!isObject(action) || action["type"] !== "choose_targets") {
|
|
121
|
+
return { kind: "malformed", reason: "invalid-fields", detail: "action" };
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
kind: "choose-targets",
|
|
125
|
+
clientId,
|
|
126
|
+
stateVersion,
|
|
127
|
+
action
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function parseOrderTriggersEnvelope(raw) {
|
|
131
|
+
const clientId = raw["clientId"];
|
|
132
|
+
const stateVersion = raw["stateVersion"];
|
|
133
|
+
const action = raw["action"];
|
|
134
|
+
if (!isString(clientId) || clientId.length === 0) {
|
|
135
|
+
return { kind: "malformed", reason: "invalid-fields", detail: "clientId" };
|
|
136
|
+
}
|
|
137
|
+
if (!isNumber(stateVersion)) {
|
|
138
|
+
return {
|
|
139
|
+
kind: "malformed",
|
|
140
|
+
reason: "invalid-fields",
|
|
141
|
+
detail: "stateVersion"
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (!isObject(action) || action["type"] !== "order_triggers") {
|
|
145
|
+
return { kind: "malformed", reason: "invalid-fields", detail: "action" };
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
kind: "order-triggers",
|
|
149
|
+
clientId,
|
|
150
|
+
stateVersion,
|
|
151
|
+
action
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function parseAdminPauseEnvelope(raw) {
|
|
155
|
+
const clientId = raw["clientId"];
|
|
156
|
+
if (!isString(clientId) || clientId.length === 0) {
|
|
157
|
+
return { kind: "malformed", reason: "invalid-fields", detail: "clientId" };
|
|
158
|
+
}
|
|
159
|
+
const reason = raw["reason"];
|
|
160
|
+
if (reason !== void 0 && !isString(reason)) {
|
|
161
|
+
return { kind: "malformed", reason: "invalid-fields", detail: "reason" };
|
|
162
|
+
}
|
|
163
|
+
return reason === void 0 ? { kind: "admin-pause", clientId } : { kind: "admin-pause", clientId, reason };
|
|
164
|
+
}
|
|
165
|
+
function parseAdminResumeEnvelope(raw) {
|
|
166
|
+
const clientId = raw["clientId"];
|
|
167
|
+
if (!isString(clientId) || clientId.length === 0) {
|
|
168
|
+
return { kind: "malformed", reason: "invalid-fields", detail: "clientId" };
|
|
169
|
+
}
|
|
170
|
+
return { kind: "admin-resume", clientId };
|
|
171
|
+
}
|
|
172
|
+
function serializeOutbound(env) {
|
|
173
|
+
return JSON.stringify(env);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// src/registry.ts
|
|
177
|
+
var RegistryError = class extends Error {
|
|
178
|
+
code;
|
|
179
|
+
constructor(code, message) {
|
|
180
|
+
super(message);
|
|
181
|
+
this.name = "RegistryError";
|
|
182
|
+
this.code = code;
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
var GameRegistry = class {
|
|
186
|
+
entries;
|
|
187
|
+
constructor() {
|
|
188
|
+
this.entries = /* @__PURE__ */ new Map();
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Register a game. Throws `RegistryError('duplicate')` if `gameId` is
|
|
192
|
+
* already registered.
|
|
193
|
+
*/
|
|
194
|
+
register(args) {
|
|
195
|
+
const key = args.gameId;
|
|
196
|
+
if (this.entries.has(key)) {
|
|
197
|
+
throw new RegistryError(
|
|
198
|
+
"duplicate",
|
|
199
|
+
`game already registered: ${key}`
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
const entry = {
|
|
203
|
+
gameId: args.gameId,
|
|
204
|
+
manifest: args.manifest,
|
|
205
|
+
handlers: { ...args.handlers },
|
|
206
|
+
setup: args.setup,
|
|
207
|
+
...args.winChecker !== void 0 ? { winChecker: args.winChecker } : {},
|
|
208
|
+
...args.stateBasedActions !== void 0 ? { stateBasedActions: args.stateBasedActions } : {},
|
|
209
|
+
...args.legalMoves !== void 0 ? { legalMoves: args.legalMoves } : {}
|
|
210
|
+
};
|
|
211
|
+
this.entries.set(key, entry);
|
|
212
|
+
return entry;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Look up a registered game. Returns `null` (not undefined) when not
|
|
216
|
+
* found, to make call sites read more naturally.
|
|
217
|
+
*/
|
|
218
|
+
resolve(gameId) {
|
|
219
|
+
const key = gameId;
|
|
220
|
+
return this.entries.get(key) ?? null;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Remove a game from the registry. Returns `true` if a game was actually
|
|
224
|
+
* removed; `false` if `gameId` was not registered.
|
|
225
|
+
*/
|
|
226
|
+
unregister(gameId) {
|
|
227
|
+
const key = gameId;
|
|
228
|
+
return this.entries.delete(key);
|
|
229
|
+
}
|
|
230
|
+
/** Number of registered games. Mostly useful in tests. */
|
|
231
|
+
get size() {
|
|
232
|
+
return this.entries.size;
|
|
233
|
+
}
|
|
234
|
+
/** List of registered game IDs (snapshot copy). */
|
|
235
|
+
list() {
|
|
236
|
+
return Array.from(this.entries.keys());
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
// src/register-plugin.ts
|
|
241
|
+
import { setupMatch } from "@table-games-engine/core";
|
|
242
|
+
function registerPlugin(registry, plugin) {
|
|
243
|
+
return registry.register({
|
|
244
|
+
gameId: plugin.id,
|
|
245
|
+
manifest: plugin.manifest,
|
|
246
|
+
handlers: plugin.actionHandlers,
|
|
247
|
+
// Same seed shape the TCG glue used, so existing matches replay identically.
|
|
248
|
+
setup: (matchId, playerIds, config, ctx) => setupMatch(plugin, { playerIds, config, seed: `${String(matchId)}-${ctx.now}` }),
|
|
249
|
+
...plugin.winConditionChecker ? { winChecker: plugin.winConditionChecker } : {},
|
|
250
|
+
...plugin.stateBasedActions ? { stateBasedActions: plugin.stateBasedActions } : {},
|
|
251
|
+
...plugin.legalMoves ? { legalMoves: plugin.legalMoves } : {}
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// src/idempotency.ts
|
|
256
|
+
var DEFAULT_TTL_MS = 5 * 60 * 1e3;
|
|
257
|
+
var IdempotencyCache = class {
|
|
258
|
+
buckets;
|
|
259
|
+
ttlMs;
|
|
260
|
+
now;
|
|
261
|
+
constructor(options = {}) {
|
|
262
|
+
this.buckets = /* @__PURE__ */ new Map();
|
|
263
|
+
this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
264
|
+
this.now = options.now ?? (() => Date.now());
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Look up a cached response. Prunes expired entries for the match as a
|
|
268
|
+
* side effect, so a stale entry is never returned.
|
|
269
|
+
*/
|
|
270
|
+
get(matchId, clientId) {
|
|
271
|
+
const bucket = this.buckets.get(matchId);
|
|
272
|
+
if (!bucket) return null;
|
|
273
|
+
this.pruneBucket(bucket);
|
|
274
|
+
const entry = bucket.get(clientId);
|
|
275
|
+
return entry ? entry.response : null;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Cache `response` under `(matchId, clientId)` with TTL. Replaces any
|
|
279
|
+
* existing entry for the same key.
|
|
280
|
+
*/
|
|
281
|
+
set(matchId, clientId, response) {
|
|
282
|
+
let bucket = this.buckets.get(matchId);
|
|
283
|
+
if (!bucket) {
|
|
284
|
+
bucket = /* @__PURE__ */ new Map();
|
|
285
|
+
this.buckets.set(matchId, bucket);
|
|
286
|
+
}
|
|
287
|
+
this.pruneBucket(bucket);
|
|
288
|
+
bucket.set(clientId, {
|
|
289
|
+
response,
|
|
290
|
+
expiresAt: this.now() + this.ttlMs
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Drop the entire bucket for `matchId`. Use when a match terminates so
|
|
295
|
+
* we don't hold the response payloads in memory.
|
|
296
|
+
*/
|
|
297
|
+
clear(matchId) {
|
|
298
|
+
this.buckets.delete(matchId);
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Walk every bucket and drop expired entries. Empty buckets are removed.
|
|
302
|
+
*/
|
|
303
|
+
prune() {
|
|
304
|
+
for (const [matchId, bucket] of this.buckets) {
|
|
305
|
+
this.pruneBucket(bucket);
|
|
306
|
+
if (bucket.size === 0) {
|
|
307
|
+
this.buckets.delete(matchId);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/** Total number of live entries across all buckets. Test-only helper. */
|
|
312
|
+
get size() {
|
|
313
|
+
let total = 0;
|
|
314
|
+
for (const bucket of this.buckets.values()) total += bucket.size;
|
|
315
|
+
return total;
|
|
316
|
+
}
|
|
317
|
+
pruneBucket(bucket) {
|
|
318
|
+
const cutoff = this.now();
|
|
319
|
+
for (const [clientId, entry] of bucket) {
|
|
320
|
+
if (entry.expiresAt <= cutoff) {
|
|
321
|
+
bucket.delete(clientId);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
// src/match-handler.ts
|
|
328
|
+
import { computePlayerView as computePlayerView2, createEngine, driveAction } from "@table-games-engine/core";
|
|
329
|
+
|
|
330
|
+
// src/broadcast.ts
|
|
331
|
+
import { computePlayerView } from "@table-games-engine/core";
|
|
332
|
+
var OPCODES = {
|
|
333
|
+
STATE_UPDATE: 1,
|
|
334
|
+
ACK: 2,
|
|
335
|
+
BROADCAST: 3,
|
|
336
|
+
MATCH_ENDED: 4,
|
|
337
|
+
TRANSITION: 5
|
|
338
|
+
};
|
|
339
|
+
function broadcastViews(state, manifest, dispatcher, presences, legalMovesFor) {
|
|
340
|
+
for (const presence of presences) {
|
|
341
|
+
const viewerId = presence.userId;
|
|
342
|
+
if (!state.players[viewerId]) continue;
|
|
343
|
+
const view = computePlayerView(state, manifest, viewerId);
|
|
344
|
+
const envelope = {
|
|
345
|
+
kind: "state",
|
|
346
|
+
view,
|
|
347
|
+
version: view.version,
|
|
348
|
+
...legalMovesFor ? { legalMoves: legalMovesFor(viewerId) } : {}
|
|
349
|
+
};
|
|
350
|
+
dispatcher.broadcastMessage(
|
|
351
|
+
OPCODES.STATE_UPDATE,
|
|
352
|
+
serializeOutbound(envelope),
|
|
353
|
+
[presence],
|
|
354
|
+
null,
|
|
355
|
+
true
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// src/timeouts.ts
|
|
361
|
+
function checkTimeouts(state, now, timeoutMs, priorityHeldSince) {
|
|
362
|
+
const holder = state.priority.holder;
|
|
363
|
+
if (!holder) return null;
|
|
364
|
+
if (priorityHeldSince === null) return null;
|
|
365
|
+
if (now - priorityHeldSince < timeoutMs) return null;
|
|
366
|
+
const stateVersion = state.version;
|
|
367
|
+
return {
|
|
368
|
+
playerId: holder,
|
|
369
|
+
type: "timeout",
|
|
370
|
+
data: { type: "timeout", reason: "action_timeout" },
|
|
371
|
+
clientId: `timeout-${stateVersion}`,
|
|
372
|
+
stateVersion
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// src/persistence-bridge.ts
|
|
377
|
+
import {
|
|
378
|
+
checksumState,
|
|
379
|
+
everyNTransitions
|
|
380
|
+
} from "@table-games-engine/persistence";
|
|
381
|
+
function createPersistenceBridge(args) {
|
|
382
|
+
const {
|
|
383
|
+
adapter,
|
|
384
|
+
matchId,
|
|
385
|
+
policy = everyNTransitions(50)
|
|
386
|
+
} = args;
|
|
387
|
+
let transitionsSince = 0;
|
|
388
|
+
let lastSnapshotTick = -Infinity;
|
|
389
|
+
let lastVersionPersisted = -Infinity;
|
|
390
|
+
let initialSnapshotted = false;
|
|
391
|
+
async function snapshotNow(state) {
|
|
392
|
+
await adapter.persistSnapshot(matchId, state, checksumState(state));
|
|
393
|
+
transitionsSince = 0;
|
|
394
|
+
lastSnapshotTick = state.tick;
|
|
395
|
+
}
|
|
396
|
+
return {
|
|
397
|
+
matchId,
|
|
398
|
+
adapter,
|
|
399
|
+
async persistInitialSnapshot(state) {
|
|
400
|
+
if (initialSnapshotted) return;
|
|
401
|
+
initialSnapshotted = true;
|
|
402
|
+
lastVersionPersisted = state.version;
|
|
403
|
+
await snapshotNow(state);
|
|
404
|
+
},
|
|
405
|
+
async persistTransition(transition, postState) {
|
|
406
|
+
const tv = transition.toVersion;
|
|
407
|
+
if (tv <= lastVersionPersisted) {
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
lastVersionPersisted = tv;
|
|
411
|
+
const events = transition.events;
|
|
412
|
+
if (events.length > 0) {
|
|
413
|
+
await adapter.appendEvents(matchId, transition.toVersion, events);
|
|
414
|
+
}
|
|
415
|
+
transitionsSince += 1;
|
|
416
|
+
const ticksSince = postState.tick - lastSnapshotTick;
|
|
417
|
+
if (policy.shouldSnapshot(postState, { transitionsSince, ticksSince })) {
|
|
418
|
+
await snapshotNow(postState);
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
async forceSnapshot(state) {
|
|
422
|
+
await snapshotNow(state);
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// src/plugins.ts
|
|
428
|
+
function invokePluginHook(logger, pluginName, hookName, fn) {
|
|
429
|
+
let result;
|
|
430
|
+
try {
|
|
431
|
+
result = fn();
|
|
432
|
+
} catch (e) {
|
|
433
|
+
logger.error(
|
|
434
|
+
"plugin %s hook %s threw synchronously: %s",
|
|
435
|
+
pluginName,
|
|
436
|
+
hookName,
|
|
437
|
+
e instanceof Error ? e.message : String(e)
|
|
438
|
+
);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (result && typeof result.then === "function") {
|
|
442
|
+
result.catch((e) => {
|
|
443
|
+
logger.error(
|
|
444
|
+
"plugin %s hook %s rejected: %s",
|
|
445
|
+
pluginName,
|
|
446
|
+
hookName,
|
|
447
|
+
e instanceof Error ? e.message : String(e)
|
|
448
|
+
);
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
function dispatchPluginHook(plugins, logger, hookName, invoke) {
|
|
453
|
+
if (!plugins || plugins.length === 0) return;
|
|
454
|
+
for (let i = 0; i < plugins.length; i++) {
|
|
455
|
+
const plugin = plugins[i];
|
|
456
|
+
if (typeof plugin[hookName] !== "function") {
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
const name = plugin.name ?? `plugin#${i}`;
|
|
460
|
+
invokePluginHook(logger, name, hookName, () => invoke(plugin));
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// src/match-handler.ts
|
|
465
|
+
var DEFAULT_LEASE_SECONDS = 60;
|
|
466
|
+
function buildLabel(gameId, status) {
|
|
467
|
+
return JSON.stringify({ gameId, status });
|
|
468
|
+
}
|
|
469
|
+
function bytesToString(bytes) {
|
|
470
|
+
if (typeof TextDecoder !== "undefined") {
|
|
471
|
+
try {
|
|
472
|
+
return new TextDecoder().decode(bytes);
|
|
473
|
+
} catch {
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
let out = "";
|
|
477
|
+
let i = 0;
|
|
478
|
+
const len = bytes.length;
|
|
479
|
+
while (i < len) {
|
|
480
|
+
const b1 = bytes[i++];
|
|
481
|
+
if (b1 < 128) {
|
|
482
|
+
out += String.fromCharCode(b1);
|
|
483
|
+
} else if (b1 < 192) {
|
|
484
|
+
out += String.fromCharCode(b1);
|
|
485
|
+
} else if (b1 < 224) {
|
|
486
|
+
const b2 = bytes[i++] & 63;
|
|
487
|
+
out += String.fromCharCode((b1 & 31) << 6 | b2);
|
|
488
|
+
} else if (b1 < 240) {
|
|
489
|
+
const b2 = bytes[i++] & 63;
|
|
490
|
+
const b3 = bytes[i++] & 63;
|
|
491
|
+
out += String.fromCharCode((b1 & 15) << 12 | b2 << 6 | b3);
|
|
492
|
+
} else {
|
|
493
|
+
const b2 = bytes[i++] & 63;
|
|
494
|
+
const b3 = bytes[i++] & 63;
|
|
495
|
+
const b4 = bytes[i++] & 63;
|
|
496
|
+
const cp = (b1 & 7) << 18 | b2 << 12 | b3 << 6 | b4;
|
|
497
|
+
const sub = cp - 65536;
|
|
498
|
+
out += String.fromCharCode(55296 + (sub >> 10), 56320 + (sub & 1023));
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return out;
|
|
502
|
+
}
|
|
503
|
+
function decodeInbound(data) {
|
|
504
|
+
try {
|
|
505
|
+
if (typeof data === "string") return data;
|
|
506
|
+
if (data === null || data === void 0) return "";
|
|
507
|
+
const tag = Object.prototype.toString.call(data);
|
|
508
|
+
if (tag === "[object ArrayBuffer]") {
|
|
509
|
+
return bytesToString(new Uint8Array(data));
|
|
510
|
+
}
|
|
511
|
+
if (tag === "[object Uint8Array]" || tag === "[object Int8Array]" || tag === "[object Uint8ClampedArray]") {
|
|
512
|
+
return bytesToString(data);
|
|
513
|
+
}
|
|
514
|
+
const obj = data;
|
|
515
|
+
if (obj.buffer && Object.prototype.toString.call(obj.buffer) === "[object ArrayBuffer]") {
|
|
516
|
+
return bytesToString(new Uint8Array(obj.buffer));
|
|
517
|
+
}
|
|
518
|
+
const len = typeof obj.byteLength === "number" ? obj.byteLength : typeof obj.length === "number" ? obj.length : 0;
|
|
519
|
+
if (len > 0) {
|
|
520
|
+
const buf = new Uint8Array(len);
|
|
521
|
+
for (let i = 0; i < len; i++) buf[i] = obj[i] ?? 0;
|
|
522
|
+
return bytesToString(buf);
|
|
523
|
+
}
|
|
524
|
+
} catch {
|
|
525
|
+
}
|
|
526
|
+
return "";
|
|
527
|
+
}
|
|
528
|
+
function resolveOrThrow(registry, gameId) {
|
|
529
|
+
const entry = registry.resolve(gameId);
|
|
530
|
+
if (!entry) {
|
|
531
|
+
throw new Error(`createMatchHandler: unknown gameId '${gameId}'`);
|
|
532
|
+
}
|
|
533
|
+
return entry;
|
|
534
|
+
}
|
|
535
|
+
function ackOk(clientId) {
|
|
536
|
+
return { kind: "ack", clientId, ok: true };
|
|
537
|
+
}
|
|
538
|
+
function ackErr(clientId, code, message) {
|
|
539
|
+
return {
|
|
540
|
+
kind: "ack",
|
|
541
|
+
clientId,
|
|
542
|
+
ok: false,
|
|
543
|
+
error: { code, message }
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
function buildActionInput(env, sender) {
|
|
547
|
+
switch (env.kind) {
|
|
548
|
+
case "action":
|
|
549
|
+
return {
|
|
550
|
+
playerId: sender.userId,
|
|
551
|
+
type: "action",
|
|
552
|
+
data: env.action,
|
|
553
|
+
clientId: env.clientId,
|
|
554
|
+
stateVersion: env.stateVersion
|
|
555
|
+
};
|
|
556
|
+
case "pass":
|
|
557
|
+
return {
|
|
558
|
+
playerId: sender.userId,
|
|
559
|
+
type: "pass",
|
|
560
|
+
data: { type: "pass" },
|
|
561
|
+
clientId: env.clientId,
|
|
562
|
+
stateVersion: env.stateVersion
|
|
563
|
+
};
|
|
564
|
+
case "concede":
|
|
565
|
+
return {
|
|
566
|
+
playerId: sender.userId,
|
|
567
|
+
type: "concede",
|
|
568
|
+
data: { type: "concede" },
|
|
569
|
+
clientId: env.clientId,
|
|
570
|
+
stateVersion: env.stateVersion
|
|
571
|
+
};
|
|
572
|
+
case "choose-targets":
|
|
573
|
+
return {
|
|
574
|
+
playerId: sender.userId,
|
|
575
|
+
type: "choose_targets",
|
|
576
|
+
data: env.action,
|
|
577
|
+
clientId: env.clientId,
|
|
578
|
+
stateVersion: env.stateVersion
|
|
579
|
+
};
|
|
580
|
+
case "order-triggers":
|
|
581
|
+
return {
|
|
582
|
+
playerId: sender.userId,
|
|
583
|
+
type: "order_triggers",
|
|
584
|
+
data: env.action,
|
|
585
|
+
clientId: env.clientId,
|
|
586
|
+
stateVersion: env.stateVersion
|
|
587
|
+
};
|
|
588
|
+
case "admin-pause":
|
|
589
|
+
case "admin-resume":
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
function isMalformed(env) {
|
|
594
|
+
return env.kind === "malformed";
|
|
595
|
+
}
|
|
596
|
+
function sendAck(dispatcher, ack, presence) {
|
|
597
|
+
dispatcher.broadcastMessage(
|
|
598
|
+
OPCODES.ACK,
|
|
599
|
+
serializeOutbound(ack),
|
|
600
|
+
[presence],
|
|
601
|
+
null,
|
|
602
|
+
true
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
function movesFor(s) {
|
|
606
|
+
const legalMoves = s.legalMoves;
|
|
607
|
+
if (!legalMoves) return void 0;
|
|
608
|
+
return (p) => s.engineState.result || s.engineState.priority.holder !== p ? [] : legalMoves(s.engineState, p);
|
|
609
|
+
}
|
|
610
|
+
function sendViewToPresence(state, manifest, dispatcher, presence, legalMovesFor) {
|
|
611
|
+
const viewerId = presence.userId;
|
|
612
|
+
if (!state.players[viewerId]) return;
|
|
613
|
+
const view = computePlayerView2(state, manifest, viewerId);
|
|
614
|
+
const env = {
|
|
615
|
+
kind: "state",
|
|
616
|
+
view,
|
|
617
|
+
version: view.version,
|
|
618
|
+
...legalMovesFor ? { legalMoves: legalMovesFor(viewerId) } : {}
|
|
619
|
+
};
|
|
620
|
+
dispatcher.broadcastMessage(
|
|
621
|
+
OPCODES.STATE_UPDATE,
|
|
622
|
+
serializeOutbound(env),
|
|
623
|
+
[presence],
|
|
624
|
+
null,
|
|
625
|
+
true
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
function createMatchHandler(options) {
|
|
629
|
+
const {
|
|
630
|
+
registry,
|
|
631
|
+
idempotencyCache,
|
|
632
|
+
matchIdFactory,
|
|
633
|
+
now = () => Date.now(),
|
|
634
|
+
timeout,
|
|
635
|
+
tickRate = 1,
|
|
636
|
+
adapter,
|
|
637
|
+
policy,
|
|
638
|
+
ownerNode,
|
|
639
|
+
leaseSeconds = DEFAULT_LEASE_SECONDS,
|
|
640
|
+
plugins
|
|
641
|
+
} = options;
|
|
642
|
+
const bridges = /* @__PURE__ */ new Map();
|
|
643
|
+
function bridgeFor(matchId) {
|
|
644
|
+
if (!adapter) return null;
|
|
645
|
+
const key = matchId;
|
|
646
|
+
let b = bridges.get(key);
|
|
647
|
+
if (!b) {
|
|
648
|
+
b = createPersistenceBridge({
|
|
649
|
+
adapter,
|
|
650
|
+
matchId,
|
|
651
|
+
...policy ? { policy } : {}
|
|
652
|
+
});
|
|
653
|
+
bridges.set(key, b);
|
|
654
|
+
}
|
|
655
|
+
return b;
|
|
656
|
+
}
|
|
657
|
+
function pluginCtxFor(state) {
|
|
658
|
+
return {
|
|
659
|
+
matchId: state.matchId,
|
|
660
|
+
gameId: state.gameId,
|
|
661
|
+
state: state.engineState,
|
|
662
|
+
now: now()
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
async function persistTransitions(bridge, transitions, finalState) {
|
|
666
|
+
if (!bridge || transitions.length === 0) return;
|
|
667
|
+
for (const t of transitions) {
|
|
668
|
+
await bridge.persistTransition(t, finalState);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
function matchInit(_ctx, logger, _nk, params) {
|
|
672
|
+
const gameIdParam = params["gameId"];
|
|
673
|
+
if (!gameIdParam) {
|
|
674
|
+
throw new Error('matchInit: missing required param "gameId"');
|
|
675
|
+
}
|
|
676
|
+
const gameId = gameIdParam;
|
|
677
|
+
const entry = resolveOrThrow(registry, gameId);
|
|
678
|
+
const matchIdRaw = params["matchId"] ?? (matchIdFactory ? matchIdFactory(now()) : `match-${now()}`);
|
|
679
|
+
const matchId = matchIdRaw;
|
|
680
|
+
const playerIdsParam = params["playerIds"];
|
|
681
|
+
const playerIds = playerIdsParam ? playerIdsParam.split(",") : [];
|
|
682
|
+
const config = params["config"] ? JSON.parse(params["config"]) : {};
|
|
683
|
+
const engineState = entry.setup(matchId, playerIds, config, { now: now() });
|
|
684
|
+
const state = {
|
|
685
|
+
matchId,
|
|
686
|
+
gameId,
|
|
687
|
+
engineState,
|
|
688
|
+
manifest: entry.manifest,
|
|
689
|
+
handlers: entry.handlers,
|
|
690
|
+
...entry.stateBasedActions ? { stateBasedActions: entry.stateBasedActions } : {},
|
|
691
|
+
...entry.winChecker ? { winChecker: entry.winChecker } : {},
|
|
692
|
+
...entry.legalMoves ? { legalMoves: entry.legalMoves } : {},
|
|
693
|
+
presences: {},
|
|
694
|
+
status: "lobby",
|
|
695
|
+
priorityHeldSince: null,
|
|
696
|
+
lastPriorityHolder: null
|
|
697
|
+
};
|
|
698
|
+
if (adapter) {
|
|
699
|
+
const bridge = bridgeFor(matchId);
|
|
700
|
+
if (ownerNode !== void 0) {
|
|
701
|
+
adapter.acquireOwnership(matchId, ownerNode, leaseSeconds).then((acquired) => {
|
|
702
|
+
if (!acquired) {
|
|
703
|
+
logger.error(
|
|
704
|
+
"matchInit: ownership lease denied for match=%s owner=%s",
|
|
705
|
+
String(matchId),
|
|
706
|
+
ownerNode
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
}).catch((e) => {
|
|
710
|
+
logger.error(
|
|
711
|
+
"matchInit: acquireOwnership failed: %s",
|
|
712
|
+
e instanceof Error ? e.message : String(e)
|
|
713
|
+
);
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
bridge.persistInitialSnapshot(engineState).catch((e) => {
|
|
717
|
+
logger.error(
|
|
718
|
+
"matchInit: persistInitialSnapshot failed: %s",
|
|
719
|
+
e instanceof Error ? e.message : String(e)
|
|
720
|
+
);
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
dispatchPluginHook(
|
|
724
|
+
plugins,
|
|
725
|
+
logger,
|
|
726
|
+
"onMatchCreated",
|
|
727
|
+
(p) => p.onMatchCreated(pluginCtxFor(state))
|
|
728
|
+
);
|
|
729
|
+
logger.info(
|
|
730
|
+
"matchInit: matchId=%s gameId=%s seats=%d",
|
|
731
|
+
matchId,
|
|
732
|
+
gameId,
|
|
733
|
+
playerIds.length
|
|
734
|
+
);
|
|
735
|
+
return {
|
|
736
|
+
state,
|
|
737
|
+
tickRate,
|
|
738
|
+
label: buildLabel(gameId, state.status)
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
function matchJoinAttempt(_ctx, _logger, _nk, _dispatcher, _tick, state, presence, _metadata) {
|
|
742
|
+
if (state.status === "finished") {
|
|
743
|
+
return {
|
|
744
|
+
state,
|
|
745
|
+
accept: false,
|
|
746
|
+
rejectMessage: "match already finished"
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
if (state.presences[presence.userId]) {
|
|
750
|
+
return { state, accept: true };
|
|
751
|
+
}
|
|
752
|
+
const seatsTaken = Object.keys(state.presences).length;
|
|
753
|
+
const cap = state.manifest.maxPlayers ?? Number.POSITIVE_INFINITY;
|
|
754
|
+
if (seatsTaken >= cap) {
|
|
755
|
+
return { state, accept: false, rejectMessage: "match is full" };
|
|
756
|
+
}
|
|
757
|
+
return { state, accept: true };
|
|
758
|
+
}
|
|
759
|
+
function matchJoin(_ctx, logger, _nk, dispatcher, _tick, state, presences) {
|
|
760
|
+
const nextPresences = { ...state.presences };
|
|
761
|
+
const reconnecting = [];
|
|
762
|
+
const fresh = [];
|
|
763
|
+
for (const p of presences) {
|
|
764
|
+
const existed = state.presences[p.userId] !== void 0 || state.engineState.players[p.userId] !== void 0;
|
|
765
|
+
if (existed) reconnecting.push(p);
|
|
766
|
+
else fresh.push(p);
|
|
767
|
+
nextPresences[p.userId] = p;
|
|
768
|
+
}
|
|
769
|
+
const seatsFilled = Object.keys(nextPresences).length;
|
|
770
|
+
const wasLobby = state.status === "lobby";
|
|
771
|
+
let nextStatus = state.status;
|
|
772
|
+
if (state.status === "lobby" && seatsFilled >= (state.manifest.minPlayers ?? 1)) {
|
|
773
|
+
nextStatus = "active";
|
|
774
|
+
}
|
|
775
|
+
const next = {
|
|
776
|
+
...state,
|
|
777
|
+
presences: nextPresences,
|
|
778
|
+
status: nextStatus
|
|
779
|
+
};
|
|
780
|
+
logger.info(
|
|
781
|
+
"matchJoin: matchId=%s newSeats=%d total=%d status=%s reconnects=%d",
|
|
782
|
+
next.matchId,
|
|
783
|
+
fresh.length,
|
|
784
|
+
seatsFilled,
|
|
785
|
+
next.status,
|
|
786
|
+
reconnecting.length
|
|
787
|
+
);
|
|
788
|
+
for (const p of reconnecting) {
|
|
789
|
+
sendViewToPresence(next.engineState, next.manifest, dispatcher, p, movesFor(next));
|
|
790
|
+
}
|
|
791
|
+
if (fresh.length > 0) {
|
|
792
|
+
broadcastViews(
|
|
793
|
+
next.engineState,
|
|
794
|
+
next.manifest,
|
|
795
|
+
dispatcher,
|
|
796
|
+
Object.values(next.presences),
|
|
797
|
+
movesFor(next)
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
const ctx = pluginCtxFor(next);
|
|
801
|
+
for (const p of presences) {
|
|
802
|
+
dispatchPluginHook(
|
|
803
|
+
plugins,
|
|
804
|
+
logger,
|
|
805
|
+
"onPlayerJoined",
|
|
806
|
+
(plugin) => plugin.onPlayerJoined(ctx, p)
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
if (wasLobby && next.status === "active") {
|
|
810
|
+
dispatchPluginHook(
|
|
811
|
+
plugins,
|
|
812
|
+
logger,
|
|
813
|
+
"onMatchStarted",
|
|
814
|
+
(plugin) => plugin.onMatchStarted(ctx)
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
return { state: next };
|
|
818
|
+
}
|
|
819
|
+
function matchLeave(_ctx, logger, _nk, dispatcher, _tick, state, presences) {
|
|
820
|
+
const nextPresences = { ...state.presences };
|
|
821
|
+
let priorityHolderLeft = false;
|
|
822
|
+
for (const p of presences) {
|
|
823
|
+
if (state.engineState.priority.holder === p.userId) {
|
|
824
|
+
priorityHolderLeft = true;
|
|
825
|
+
}
|
|
826
|
+
delete nextPresences[p.userId];
|
|
827
|
+
}
|
|
828
|
+
let next = { ...state, presences: nextPresences };
|
|
829
|
+
const transitions = [];
|
|
830
|
+
if (priorityHolderLeft && state.engineState.priority.holder) {
|
|
831
|
+
const holder = state.engineState.priority.holder;
|
|
832
|
+
const timeoutInput = {
|
|
833
|
+
playerId: holder,
|
|
834
|
+
type: "timeout",
|
|
835
|
+
data: { type: "timeout", reason: "leaver_timeout" },
|
|
836
|
+
clientId: `system-leave-${state.engineState.version}`,
|
|
837
|
+
stateVersion: state.engineState.version
|
|
838
|
+
};
|
|
839
|
+
try {
|
|
840
|
+
const r = driveAction(next, next.engineState, timeoutInput);
|
|
841
|
+
if (r.ok) {
|
|
842
|
+
transitions.push(...r.transitions);
|
|
843
|
+
next = { ...next, engineState: r.state };
|
|
844
|
+
} else {
|
|
845
|
+
logger.warn(
|
|
846
|
+
"matchLeave: synthetic timeout rejected: %s",
|
|
847
|
+
JSON.stringify(r.error)
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
} catch (e) {
|
|
851
|
+
logger.error("matchLeave: game logic threw: %s", e instanceof Error ? e.message : String(e));
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
const bridge = bridgeFor(next.matchId);
|
|
855
|
+
if (bridge && transitions.length > 0) {
|
|
856
|
+
persistTransitions(bridge, transitions, next.engineState).catch(
|
|
857
|
+
(e) => {
|
|
858
|
+
logger.error(
|
|
859
|
+
"matchLeave: persist failed: %s",
|
|
860
|
+
e instanceof Error ? e.message : String(e)
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
logger.info(
|
|
866
|
+
"matchLeave: matchId=%s left=%d remaining=%d",
|
|
867
|
+
next.matchId,
|
|
868
|
+
presences.length,
|
|
869
|
+
Object.keys(next.presences).length
|
|
870
|
+
);
|
|
871
|
+
broadcastViews(
|
|
872
|
+
next.engineState,
|
|
873
|
+
next.manifest,
|
|
874
|
+
dispatcher,
|
|
875
|
+
Object.values(next.presences),
|
|
876
|
+
movesFor(next)
|
|
877
|
+
);
|
|
878
|
+
const ctx = pluginCtxFor(next);
|
|
879
|
+
for (const p of presences) {
|
|
880
|
+
dispatchPluginHook(
|
|
881
|
+
plugins,
|
|
882
|
+
logger,
|
|
883
|
+
"onPlayerLeft",
|
|
884
|
+
(plugin) => plugin.onPlayerLeft(ctx, p)
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
for (const t of transitions) {
|
|
888
|
+
dispatchPluginHook(
|
|
889
|
+
plugins,
|
|
890
|
+
logger,
|
|
891
|
+
"onStateTransition",
|
|
892
|
+
(plugin) => plugin.onStateTransition(ctx, t)
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
return { state: next };
|
|
896
|
+
}
|
|
897
|
+
function matchLoop(_ctx, logger, _nk, dispatcher, _tick, state, messages) {
|
|
898
|
+
let working = state;
|
|
899
|
+
let stateChanged = false;
|
|
900
|
+
const transitions = [];
|
|
901
|
+
const playerActionTransitions = [];
|
|
902
|
+
for (const msg of messages) {
|
|
903
|
+
const sender = msg.sender;
|
|
904
|
+
const json = decodeInbound(msg.data);
|
|
905
|
+
const env = parseInbound(json);
|
|
906
|
+
if (isMalformed(env)) {
|
|
907
|
+
logger.warn(
|
|
908
|
+
"matchLoop: malformed envelope reason=%s detail=%s preview=%s",
|
|
909
|
+
env.reason,
|
|
910
|
+
String(env.detail ?? ""),
|
|
911
|
+
json.slice(0, 200)
|
|
912
|
+
);
|
|
913
|
+
const ack = ackErr(
|
|
914
|
+
"unknown",
|
|
915
|
+
"malformed",
|
|
916
|
+
env.detail ? `${env.reason}: ${env.detail}` : env.reason
|
|
917
|
+
);
|
|
918
|
+
sendAck(dispatcher, ack, sender);
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
if (env.kind === "admin-pause" || env.kind === "admin-resume") {
|
|
922
|
+
const ack = ackErr(
|
|
923
|
+
env.clientId,
|
|
924
|
+
"invalid-channel",
|
|
925
|
+
"admin envelopes must be sent via matchSignal"
|
|
926
|
+
);
|
|
927
|
+
sendAck(dispatcher, ack, sender);
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
const matchKey = working.matchId;
|
|
931
|
+
const cached = idempotencyCache.get(matchKey, env.clientId);
|
|
932
|
+
if (cached) {
|
|
933
|
+
dispatcher.broadcastMessage(
|
|
934
|
+
OPCODES.ACK,
|
|
935
|
+
serializeOutbound(cached),
|
|
936
|
+
[sender],
|
|
937
|
+
null,
|
|
938
|
+
true
|
|
939
|
+
);
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
const input = buildActionInput(env, sender);
|
|
943
|
+
if (!input) {
|
|
944
|
+
const ack = ackErr(env.clientId, "invalid", `unsupported envelope kind ${env.kind}`);
|
|
945
|
+
idempotencyCache.set(matchKey, env.clientId, ack);
|
|
946
|
+
sendAck(dispatcher, ack, sender);
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
let driven;
|
|
950
|
+
try {
|
|
951
|
+
driven = driveAction(working, working.engineState, input);
|
|
952
|
+
} catch (e) {
|
|
953
|
+
logger.error("matchLoop: game logic threw: %s", e instanceof Error ? e.message : String(e));
|
|
954
|
+
const ack = ackErr(env.clientId, "INTERNAL", "game logic error");
|
|
955
|
+
idempotencyCache.set(matchKey, env.clientId, ack);
|
|
956
|
+
sendAck(dispatcher, ack, sender);
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
let ackEnv;
|
|
960
|
+
if (driven.ok) {
|
|
961
|
+
ackEnv = ackOk(env.clientId);
|
|
962
|
+
transitions.push(...driven.transitions);
|
|
963
|
+
const first = driven.transitions[0];
|
|
964
|
+
if (first) playerActionTransitions.push({ input, transition: first });
|
|
965
|
+
working = { ...working, engineState: driven.state };
|
|
966
|
+
stateChanged = true;
|
|
967
|
+
} else {
|
|
968
|
+
const v = driven.error;
|
|
969
|
+
ackEnv = v.valid ? ackErr(env.clientId, "INTERNAL", "unknown error") : ackErr(env.clientId, v.error.code, v.error.message);
|
|
970
|
+
}
|
|
971
|
+
idempotencyCache.set(matchKey, env.clientId, ackEnv);
|
|
972
|
+
dispatcher.broadcastMessage(
|
|
973
|
+
OPCODES.ACK,
|
|
974
|
+
serializeOutbound(ackEnv),
|
|
975
|
+
[sender],
|
|
976
|
+
null,
|
|
977
|
+
true
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
if (timeout && working.status !== "finished" && !working.engineState.result) {
|
|
981
|
+
const currentHolder = working.engineState.priority.holder ?? null;
|
|
982
|
+
if (currentHolder !== working.lastPriorityHolder) {
|
|
983
|
+
working = {
|
|
984
|
+
...working,
|
|
985
|
+
lastPriorityHolder: currentHolder ?? null,
|
|
986
|
+
priorityHeldSince: now()
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
const timeoutInput = checkTimeouts(
|
|
990
|
+
working.engineState,
|
|
991
|
+
now(),
|
|
992
|
+
timeout.timeoutMs,
|
|
993
|
+
working.priorityHeldSince
|
|
994
|
+
);
|
|
995
|
+
if (timeoutInput) {
|
|
996
|
+
try {
|
|
997
|
+
const r = driveAction(working, working.engineState, timeoutInput);
|
|
998
|
+
if (r.ok) {
|
|
999
|
+
transitions.push(...r.transitions);
|
|
1000
|
+
working = {
|
|
1001
|
+
...working,
|
|
1002
|
+
engineState: r.state,
|
|
1003
|
+
priorityHeldSince: now()
|
|
1004
|
+
};
|
|
1005
|
+
stateChanged = true;
|
|
1006
|
+
} else {
|
|
1007
|
+
logger.warn(
|
|
1008
|
+
"matchLoop: timeout injection rejected: %s",
|
|
1009
|
+
JSON.stringify(r.error)
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
} catch (e) {
|
|
1013
|
+
logger.error("matchLoop: game logic threw on timeout: %s", e instanceof Error ? e.message : String(e));
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
const bridge = bridgeFor(working.matchId);
|
|
1018
|
+
if (bridge && transitions.length > 0) {
|
|
1019
|
+
persistTransitions(bridge, transitions, working.engineState).catch(
|
|
1020
|
+
(e) => {
|
|
1021
|
+
logger.error(
|
|
1022
|
+
"matchLoop: persist failed: %s",
|
|
1023
|
+
e instanceof Error ? e.message : String(e)
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
if (stateChanged) {
|
|
1029
|
+
broadcastViews(
|
|
1030
|
+
working.engineState,
|
|
1031
|
+
working.manifest,
|
|
1032
|
+
dispatcher,
|
|
1033
|
+
Object.values(working.presences),
|
|
1034
|
+
movesFor(working)
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
if (transitions.length > 0) {
|
|
1038
|
+
const ctx = pluginCtxFor(working);
|
|
1039
|
+
for (const { input, transition } of playerActionTransitions) {
|
|
1040
|
+
dispatchPluginHook(
|
|
1041
|
+
plugins,
|
|
1042
|
+
logger,
|
|
1043
|
+
"onPlayerAction",
|
|
1044
|
+
(plugin) => plugin.onPlayerAction(ctx, input, transition)
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
for (const t of transitions) {
|
|
1048
|
+
dispatchPluginHook(
|
|
1049
|
+
plugins,
|
|
1050
|
+
logger,
|
|
1051
|
+
"onStateTransition",
|
|
1052
|
+
(plugin) => plugin.onStateTransition(ctx, t)
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
const endResult = working.status === "finished" ? null : working.engineState.result ?? (() => {
|
|
1057
|
+
const c = createEngine(working.manifest, working.engineState, working.handlers).checkWinConditions();
|
|
1058
|
+
return c.ended ? c.result : null;
|
|
1059
|
+
})();
|
|
1060
|
+
if (endResult) {
|
|
1061
|
+
const ended = {
|
|
1062
|
+
kind: "match-ended",
|
|
1063
|
+
result: endResult
|
|
1064
|
+
};
|
|
1065
|
+
dispatcher.broadcastMessage(
|
|
1066
|
+
OPCODES.MATCH_ENDED,
|
|
1067
|
+
serializeOutbound(ended),
|
|
1068
|
+
null,
|
|
1069
|
+
null,
|
|
1070
|
+
true
|
|
1071
|
+
);
|
|
1072
|
+
working = { ...working, status: "finished" };
|
|
1073
|
+
const ctx = pluginCtxFor(working);
|
|
1074
|
+
dispatchPluginHook(
|
|
1075
|
+
plugins,
|
|
1076
|
+
logger,
|
|
1077
|
+
"onMatchEnded",
|
|
1078
|
+
(plugin) => plugin.onMatchEnded(ctx, endResult)
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
return { state: working };
|
|
1082
|
+
}
|
|
1083
|
+
function matchSignal(_ctx, logger, _nk, _dispatcher, _tick, state, data) {
|
|
1084
|
+
let parsed;
|
|
1085
|
+
try {
|
|
1086
|
+
const raw = JSON.parse(data);
|
|
1087
|
+
if (typeof raw === "string") {
|
|
1088
|
+
parsed = { type: raw };
|
|
1089
|
+
} else if (raw && typeof raw === "object") {
|
|
1090
|
+
parsed = raw;
|
|
1091
|
+
} else {
|
|
1092
|
+
return { state, data: JSON.stringify({ ok: false, error: "invalid" }) };
|
|
1093
|
+
}
|
|
1094
|
+
} catch {
|
|
1095
|
+
parsed = { type: data };
|
|
1096
|
+
}
|
|
1097
|
+
let next = state;
|
|
1098
|
+
switch (parsed.type) {
|
|
1099
|
+
case "pause":
|
|
1100
|
+
if (state.status === "active") {
|
|
1101
|
+
next = { ...state, status: "paused" };
|
|
1102
|
+
logger.info("matchSignal: paused matchId=%s", state.matchId);
|
|
1103
|
+
}
|
|
1104
|
+
return { state: next, data: JSON.stringify({ ok: true, status: next.status }) };
|
|
1105
|
+
case "resume":
|
|
1106
|
+
if (state.status === "paused") {
|
|
1107
|
+
next = { ...state, status: "active" };
|
|
1108
|
+
logger.info("matchSignal: resumed matchId=%s", state.matchId);
|
|
1109
|
+
}
|
|
1110
|
+
return { state: next, data: JSON.stringify({ ok: true, status: next.status }) };
|
|
1111
|
+
case "force-end":
|
|
1112
|
+
next = { ...state, status: "finished" };
|
|
1113
|
+
logger.info("matchSignal: force-end matchId=%s", state.matchId);
|
|
1114
|
+
return { state: next, data: JSON.stringify({ ok: true, status: next.status }) };
|
|
1115
|
+
case "eject": {
|
|
1116
|
+
const playerId = parsed.playerId;
|
|
1117
|
+
if (!playerId) {
|
|
1118
|
+
return {
|
|
1119
|
+
state,
|
|
1120
|
+
data: JSON.stringify({ ok: false, error: "eject: missing playerId" })
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
const ejectInput = {
|
|
1124
|
+
playerId,
|
|
1125
|
+
type: "concede",
|
|
1126
|
+
data: { type: "concede" },
|
|
1127
|
+
clientId: `system-eject-${state.engineState.version}`,
|
|
1128
|
+
stateVersion: state.engineState.version
|
|
1129
|
+
};
|
|
1130
|
+
const stateForEject = {
|
|
1131
|
+
...state.engineState,
|
|
1132
|
+
priority: {
|
|
1133
|
+
...state.engineState.priority,
|
|
1134
|
+
holder: playerId
|
|
1135
|
+
}
|
|
1136
|
+
};
|
|
1137
|
+
let r;
|
|
1138
|
+
try {
|
|
1139
|
+
r = driveAction(state, stateForEject, ejectInput);
|
|
1140
|
+
} catch (e) {
|
|
1141
|
+
logger.error("matchSignal: game logic threw on eject: %s", e instanceof Error ? e.message : String(e));
|
|
1142
|
+
return { state, data: JSON.stringify({ ok: false, error: "eject failed" }) };
|
|
1143
|
+
}
|
|
1144
|
+
if (!r.ok) {
|
|
1145
|
+
logger.warn("matchSignal: eject rejected: %s", JSON.stringify(r.error));
|
|
1146
|
+
return {
|
|
1147
|
+
state,
|
|
1148
|
+
data: JSON.stringify({ ok: false, error: "eject failed" })
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
const ejectTransitions = [...r.transitions];
|
|
1152
|
+
const prevHolder = state.engineState.priority.holder;
|
|
1153
|
+
const keepHolder = prevHolder && prevHolder !== playerId && !r.state.result && r.state.players[prevHolder]?.status !== "eliminated" && r.state.turn.turnNumber === state.engineState.turn.turnNumber;
|
|
1154
|
+
const drained = keepHolder ? { ...r.state, priority: { ...r.state.priority, holder: prevHolder } } : r.state;
|
|
1155
|
+
const newPresences = { ...state.presences };
|
|
1156
|
+
delete newPresences[playerId];
|
|
1157
|
+
next = {
|
|
1158
|
+
...state,
|
|
1159
|
+
engineState: drained,
|
|
1160
|
+
presences: newPresences
|
|
1161
|
+
};
|
|
1162
|
+
const bridge = bridgeFor(next.matchId);
|
|
1163
|
+
if (bridge) {
|
|
1164
|
+
persistTransitions(bridge, ejectTransitions, next.engineState).catch(
|
|
1165
|
+
(e) => {
|
|
1166
|
+
logger.error(
|
|
1167
|
+
"matchSignal:eject persist failed: %s",
|
|
1168
|
+
e instanceof Error ? e.message : String(e)
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
return { state: next, data: JSON.stringify({ ok: true, ejected: playerId }) };
|
|
1174
|
+
}
|
|
1175
|
+
default:
|
|
1176
|
+
return {
|
|
1177
|
+
state,
|
|
1178
|
+
data: JSON.stringify({ ok: false, error: `unknown signal: ${parsed.type}` })
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
function matchTerminate(_ctx, logger, _nk, _dispatcher, _tick, state, graceSeconds) {
|
|
1183
|
+
logger.info(
|
|
1184
|
+
"matchTerminate: matchId=%s grace=%ds",
|
|
1185
|
+
state.matchId,
|
|
1186
|
+
graceSeconds
|
|
1187
|
+
);
|
|
1188
|
+
idempotencyCache.clear(state.matchId);
|
|
1189
|
+
if (adapter && ownerNode !== void 0) {
|
|
1190
|
+
adapter.releaseOwnership(state.matchId, ownerNode).catch((e) => {
|
|
1191
|
+
logger.error(
|
|
1192
|
+
"matchTerminate: releaseOwnership failed: %s",
|
|
1193
|
+
e instanceof Error ? e.message : String(e)
|
|
1194
|
+
);
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
bridges.delete(state.matchId);
|
|
1198
|
+
return { state: { ...state, status: "finished" } };
|
|
1199
|
+
}
|
|
1200
|
+
return {
|
|
1201
|
+
matchInit,
|
|
1202
|
+
matchJoinAttempt,
|
|
1203
|
+
matchJoin,
|
|
1204
|
+
matchLeave,
|
|
1205
|
+
matchLoop,
|
|
1206
|
+
matchTerminate,
|
|
1207
|
+
matchSignal
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
export {
|
|
1211
|
+
GameRegistry,
|
|
1212
|
+
IdempotencyCache,
|
|
1213
|
+
OPCODES,
|
|
1214
|
+
RegistryError,
|
|
1215
|
+
broadcastViews,
|
|
1216
|
+
checkTimeouts,
|
|
1217
|
+
createMatchHandler,
|
|
1218
|
+
createPersistenceBridge,
|
|
1219
|
+
dispatchPluginHook,
|
|
1220
|
+
invokePluginHook,
|
|
1221
|
+
parseInbound,
|
|
1222
|
+
registerPlugin,
|
|
1223
|
+
serializeOutbound
|
|
1224
|
+
};
|