@joystyk/rooms 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +147 -2
  3. package/dist/canonical.d.ts +10 -0
  4. package/dist/canonical.js +30 -0
  5. package/dist/canonical.js.map +1 -0
  6. package/dist/core.d.ts +3 -0
  7. package/dist/core.js +1879 -0
  8. package/dist/core.js.map +1 -0
  9. package/dist/index.d.ts +6 -0
  10. package/dist/index.js +7 -0
  11. package/dist/names.d.ts +19 -0
  12. package/dist/names.js +28 -0
  13. package/dist/names.js.map +1 -0
  14. package/dist/node/host.d.ts +67 -0
  15. package/dist/node/host.js +323 -0
  16. package/dist/node/host.js.map +1 -0
  17. package/dist/node/index.d.ts +6 -0
  18. package/dist/node/index.js +7 -0
  19. package/dist/node/jobs.d.ts +48 -0
  20. package/dist/node/jobs.js +176 -0
  21. package/dist/node/jobs.js.map +1 -0
  22. package/dist/node/registry.d.ts +32 -0
  23. package/dist/node/registry.js +43 -0
  24. package/dist/node/registry.js.map +1 -0
  25. package/dist/node/server.d.ts +59 -0
  26. package/dist/node/server.js +577 -0
  27. package/dist/node/server.js.map +1 -0
  28. package/dist/node/store.d.ts +53 -0
  29. package/dist/node/store.js +245 -0
  30. package/dist/node/store.js.map +1 -0
  31. package/dist/node/support.d.ts +67 -0
  32. package/dist/node/support.js +126 -0
  33. package/dist/node/support.js.map +1 -0
  34. package/dist/policy.d.ts +15 -0
  35. package/dist/policy.js +41 -0
  36. package/dist/policy.js.map +1 -0
  37. package/dist/protocol/index.d.ts +43 -0
  38. package/dist/protocol/index.js +48 -0
  39. package/dist/protocol/index.js.map +1 -0
  40. package/dist/protocol/schemas.d.ts +792 -0
  41. package/dist/protocol/schemas.js +332 -0
  42. package/dist/protocol/schemas.js.map +1 -0
  43. package/dist/protocol/types.d.ts +279 -0
  44. package/dist/protocol/types.js +1 -0
  45. package/dist/record.d.ts +173 -0
  46. package/dist/record.js +181 -0
  47. package/dist/record.js.map +1 -0
  48. package/dist/types.d.ts +374 -0
  49. package/dist/types.js +15 -0
  50. package/dist/types.js.map +1 -0
  51. package/package.json +56 -11
package/dist/core.js ADDED
@@ -0,0 +1,1879 @@
1
+ import { canonicalJson, fingerprint } from "./canonical.js";
2
+ import { admitName, nameReplacedMessage } from "./names.js";
3
+ import { policyProblem } from "./policy.js";
4
+ import "./protocol/schemas.js";
5
+ import { RejectionCode, gameRejection } from "./protocol/index.js";
6
+ import { DEFAULT_LIMITS } from "./types.js";
7
+ import { err, ok } from "@joystyk/contracts";
8
+ import { SFC32, forkRng, restoreRng } from "@joystyk/kit";
9
+ //#region src/core.ts
10
+ /** Binds the core to one engine at one rules version. */
11
+ var createRoomCore = (options) => {
12
+ const { engine, pin } = options;
13
+ const limits = {
14
+ ...DEFAULT_LIMITS,
15
+ ...options.limits
16
+ };
17
+ if (pin.rulesVersion !== engine.rulesVersion) throw new Error(`The pin's rules version ${pin.rulesVersion} differs from the engine's ${engine.rulesVersion}.`);
18
+ const defaultLevel = () => engine.ai?.levels[0] ?? null;
19
+ const displayName = (seat) => seat.claim?.name ?? (seat.controller === "ai" ? `Bot ${seat.id + 1}` : `Seat ${seat.id + 1}`);
20
+ const seatInfo = (seat) => seat.controller === "ai" && seat.aiLevel !== null ? {
21
+ id: seat.id,
22
+ name: displayName(seat),
23
+ controller: "ai",
24
+ aiLevel: seat.aiLevel
25
+ } : {
26
+ id: seat.id,
27
+ name: displayName(seat),
28
+ controller: seat.controller
29
+ };
30
+ const publicSeat = (seat) => ({
31
+ id: seat.id,
32
+ name: displayName(seat),
33
+ controller: seat.controller,
34
+ aiLevel: seat.controller === "ai" ? seat.aiLevel : null,
35
+ claimed: seat.claim !== null,
36
+ ready: seat.ready
37
+ });
38
+ const keysOf = (state, seats) => Array.from({ length: seats }, (_, seat) => canonicalJson(engine.decisionKey ? engine.decisionKey(state, seat) : engine.viewFor(state, seat)));
39
+ const settingsOf = (record) => ({
40
+ decisionMs: record.policy.decisionMs,
41
+ bank: record.policy.bank ?? null,
42
+ aiLevel: record.aiLevel
43
+ });
44
+ /** The offer as clients see it: for a rematch, every computer seat has accepted too. */
45
+ const publicOffer = (record) => {
46
+ const offer = record.offer ?? null;
47
+ if (offer === null) return null;
48
+ const computers = offer.kind === "rematch" ? record.seats.filter((s) => s.controller === "ai" && !offer.accepted.includes(s.id)).map((s) => s.id) : [];
49
+ return {
50
+ ...offer,
51
+ accepted: [...offer.accepted, ...computers]
52
+ };
53
+ };
54
+ const restoreAi = (state) => state.algorithm === SFC32 ? restoreRng(state) : options.rng(state);
55
+ /** The child stream for one decision of `seat`: fork number `seat` of the AI stream. */
56
+ const aiDecisionSeed = (state, seat) => {
57
+ const parent = restoreAi(state);
58
+ for (let i = 0; i < seat; i += 1) forkRng(parent);
59
+ return forkRng(parent).save();
60
+ };
61
+ /** The AI stream after an accepted AI move: one fork per seat, past every child it handed out. */
62
+ const advanceAi = (state, seats) => {
63
+ const parent = restoreAi(state);
64
+ for (let i = 0; i < seats; i += 1) forkRng(parent);
65
+ return parent.save();
66
+ };
67
+ /** A new match on `seats`, or the engine's reason it can't start. */
68
+ const newMatch = (record, seats, seeds, revision, now) => {
69
+ const infos = seats.map(seatInfo);
70
+ const rng = options.rng(seeds.engine);
71
+ const init = engine.init(record.config, infos, rng);
72
+ if (!init.ok) return init;
73
+ const state = init.value;
74
+ return ok({
75
+ number: (record.match?.number ?? 0) + 1,
76
+ game: pin,
77
+ config: record.config,
78
+ seats: infos,
79
+ seed: seeds.engine,
80
+ state,
81
+ rng: rng.save(),
82
+ aiRng: seeds.ai,
83
+ decisionRevision: seats.map(() => revision),
84
+ clocks: [],
85
+ ...record.policy.bank ? { banks: seats.map(() => record.policy.bank?.initialMs ?? 0) } : {},
86
+ jobs: [],
87
+ presentation: null,
88
+ log: [],
89
+ outcome: engine.outcome(state),
90
+ startedAt: now
91
+ });
92
+ };
93
+ const create = (params, now) => {
94
+ const problem = policyProblem(params.policy);
95
+ if (problem !== null) return err({
96
+ code: "bad-policy",
97
+ message: problem
98
+ });
99
+ const immediate = params.policy.start === "immediate";
100
+ const creator = admitName(params.creator.name, {
101
+ room: params.code,
102
+ epoch: 0,
103
+ revision: 0,
104
+ seat: 0
105
+ }).name;
106
+ const seats = Array.from({ length: params.policy.seats.max }, (_, id) => {
107
+ if (id === 0) return {
108
+ id,
109
+ claim: {
110
+ tokenHash: params.creator.tokenHash,
111
+ name: creator
112
+ },
113
+ controller: "human",
114
+ aiLevel: null,
115
+ ready: false
116
+ };
117
+ return immediate ? {
118
+ id,
119
+ claim: null,
120
+ controller: "ai",
121
+ aiLevel: defaultLevel(),
122
+ ready: false
123
+ } : {
124
+ id,
125
+ claim: null,
126
+ controller: "human",
127
+ aiLevel: null,
128
+ ready: false
129
+ };
130
+ });
131
+ const record = {
132
+ format: 1,
133
+ code: params.code,
134
+ epoch: 0,
135
+ writes: 0,
136
+ policy: params.policy,
137
+ phase: "lobby",
138
+ revision: 0,
139
+ game: pin,
140
+ config: params.config,
141
+ aiLevel: defaultLevel(),
142
+ seats,
143
+ host: 0,
144
+ match: null,
145
+ receipts: [],
146
+ createdAt: now,
147
+ updatedAt: now
148
+ };
149
+ if (!immediate) return ok(record);
150
+ const initFailed = (error) => {
151
+ const reference = `init-${params.code}-${now}`;
152
+ return err({
153
+ code: "init-failed",
154
+ message: `The game could not start. Reference: ${reference}.`,
155
+ diagnostic: {
156
+ reference,
157
+ error
158
+ }
159
+ });
160
+ };
161
+ let match;
162
+ try {
163
+ match = newMatch(record, seats, params.seeds, 1, now);
164
+ } catch (error) {
165
+ return initFailed(String(error));
166
+ }
167
+ if (!match.ok) return initFailed(match.error.message);
168
+ return ok({
169
+ ...record,
170
+ revision: 1,
171
+ match: match.value,
172
+ phase: match.value.outcome === null ? "playing" : "finished"
173
+ });
174
+ };
175
+ const load = (record) => ({
176
+ record,
177
+ members: /* @__PURE__ */ new Map(),
178
+ absentSince: /* @__PURE__ */ new Map(),
179
+ jobStartedAt: /* @__PURE__ */ new Map(),
180
+ aiInFlight: /* @__PURE__ */ new Map(),
181
+ aiRetry: /* @__PURE__ */ new Map(),
182
+ alarmAt: null,
183
+ closed: false
184
+ });
185
+ /** One step's working state. Everything here is local to a single `step` call. */
186
+ class Tx {
187
+ record;
188
+ members;
189
+ absentSince;
190
+ jobStartedAt;
191
+ aiInFlight;
192
+ aiRetry;
193
+ /** The seat whose own command produced the input being applied (bank increments). */
194
+ endedBy = null;
195
+ now;
196
+ start;
197
+ dirty = false;
198
+ closed;
199
+ inputs = [];
200
+ beats = [];
201
+ presentationChanged = false;
202
+ holdUntil;
203
+ membershipChanged = false;
204
+ snapshotAll = false;
205
+ snapshotTo = /* @__PURE__ */ new Set();
206
+ early = [];
207
+ receipts = [];
208
+ jobs = [];
209
+ logs = [];
210
+ autoInputs = 0;
211
+ aiLoopLogged = false;
212
+ keys = null;
213
+ constructor(room, now) {
214
+ this.start = room;
215
+ this.record = room.record;
216
+ this.members = new Map(room.members);
217
+ this.absentSince = new Map(room.absentSince);
218
+ this.jobStartedAt = new Map(room.jobStartedAt);
219
+ this.aiInFlight = new Map(room.aiInFlight);
220
+ this.aiRetry = new Map(room.aiRetry);
221
+ this.closed = room.closed;
222
+ this.now = now;
223
+ this.holdUntil = now;
224
+ }
225
+ log(level, event, fields = {}) {
226
+ this.logs.push({
227
+ type: "log",
228
+ level,
229
+ event,
230
+ fields: {
231
+ room: this.record.code,
232
+ ...fields
233
+ }
234
+ });
235
+ }
236
+ set(record) {
237
+ this.record = record;
238
+ this.dirty = true;
239
+ }
240
+ setMatch(match) {
241
+ this.set({
242
+ ...this.record,
243
+ match
244
+ });
245
+ }
246
+ bump() {
247
+ this.set({
248
+ ...this.record,
249
+ revision: this.record.revision + 1
250
+ });
251
+ }
252
+ setSeat(seat) {
253
+ this.set({
254
+ ...this.record,
255
+ seats: this.record.seats.map((s) => s.id === seat.id ? seat : s)
256
+ });
257
+ }
258
+ seat(id) {
259
+ return this.record.seats[id];
260
+ }
261
+ hasMember(seat) {
262
+ for (const member of this.members.values()) if (member.seat === seat) return true;
263
+ return false;
264
+ }
265
+ connsOf(seat) {
266
+ return [...this.members].filter(([, m]) => m.seat === seat).map(([conn]) => conn);
267
+ }
268
+ /** No human seat has a connection: the table is paused, so no AI move, clock or offer runs (section 3.5 rule 8, D-58). */
269
+ frozen() {
270
+ for (const member of this.members.values()) if (member.seat !== null && this.seat(member.seat)?.controller === "human") return false;
271
+ return true;
272
+ }
273
+ playing() {
274
+ return this.record.phase === "playing" && this.record.match !== null;
275
+ }
276
+ send(to, message, bucket = this.early) {
277
+ if (to.length > 0) bucket.push({
278
+ type: "send",
279
+ to,
280
+ message
281
+ });
282
+ }
283
+ drop(conn, reason) {
284
+ this.members.delete(conn);
285
+ this.snapshotTo.delete(conn);
286
+ this.early.push({
287
+ type: "drop",
288
+ conn,
289
+ reason
290
+ });
291
+ this.membershipChanged = true;
292
+ }
293
+ receipt(conn, id, result) {
294
+ const message = result.status === "accepted" ? {
295
+ v: 1,
296
+ t: "receipt",
297
+ id,
298
+ result
299
+ } : {
300
+ v: 1,
301
+ t: "receipt",
302
+ id,
303
+ result: {
304
+ ...result,
305
+ revision: this.record.revision
306
+ }
307
+ };
308
+ this.send([conn], message, this.receipts);
309
+ }
310
+ reject(conn, id, rejection, seat) {
311
+ this.receipt(conn, id, {
312
+ status: "rejected",
313
+ ...rejection
314
+ });
315
+ if (rejection.code === RejectionCode.stale) {
316
+ this.snapshotTo.add(conn);
317
+ this.log("info", "stale_action", { seat });
318
+ } else this.log("info", "action_rejected", {
319
+ code: rejection.code,
320
+ seat
321
+ });
322
+ }
323
+ /** Each seat's decision key for the current state, cached until the state changes. */
324
+ decisionKeys() {
325
+ if (this.keys !== null) return this.keys;
326
+ const match = this.record.match;
327
+ if (match === null) return [];
328
+ this.keys = keysOf(match.state, match.seats.length);
329
+ return this.keys;
330
+ }
331
+ /** Applies one engine input. On success the revision rises by one and the input is logged. */
332
+ apply(input) {
333
+ const match = this.record.match;
334
+ if (match === null || this.record.phase !== "playing") return err({
335
+ code: RejectionCode.wrongPhase,
336
+ message: "No match is in progress."
337
+ });
338
+ const before = this.decisionKeys();
339
+ const rng = options.rng(match.rng);
340
+ let result;
341
+ try {
342
+ result = engine.apply(match.state, input, rng);
343
+ } catch (error) {
344
+ this.log("error", "engine_error", {
345
+ input: input.kind,
346
+ error: String(error)
347
+ });
348
+ return err({
349
+ code: RejectionCode.engineError,
350
+ message: "The game hit an error and ignored that move."
351
+ });
352
+ }
353
+ if (!result.ok) return err({
354
+ code: gameRejection(result.error.code),
355
+ message: result.error.message
356
+ });
357
+ const transition = result.value;
358
+ const revision = this.record.revision + 1;
359
+ const state = transition.state;
360
+ const after = keysOf(state, match.seats.length);
361
+ this.keys = after;
362
+ const decisionRevision = match.decisionRevision.map((r, seat) => before[seat] === after[seat] ? r : revision);
363
+ const events = transition.beats.map((beat) => beat.event);
364
+ const log = [...match.log, ...events].slice(-limits.logEvents);
365
+ const requested = (transition.jobs ?? []).map((request) => ({
366
+ request,
367
+ revision
368
+ }));
369
+ for (const { request } of requested) {
370
+ const spec = engine.jobs?.[request.kind];
371
+ this.jobStartedAt.set(request.id, this.now);
372
+ this.jobs.push({
373
+ type: "start-job",
374
+ jobId: request.id,
375
+ kind: request.kind,
376
+ payload: request.payload,
377
+ timeoutMs: spec?.timeoutMs ?? 0
378
+ });
379
+ this.log("info", "job_started", {
380
+ kind: request.kind,
381
+ job: request.id
382
+ });
383
+ }
384
+ let outcome = engine.outcome(state);
385
+ if (outcome !== null) outcome = {
386
+ ...outcome,
387
+ elapsedMs: Math.max(0, this.now - match.startedAt)
388
+ };
389
+ this.set({
390
+ ...this.record,
391
+ revision,
392
+ phase: outcome === null ? "playing" : "finished",
393
+ match: {
394
+ ...match,
395
+ state,
396
+ rng: rng.save(),
397
+ decisionRevision,
398
+ log,
399
+ jobs: [...match.jobs, ...requested],
400
+ outcome
401
+ }
402
+ });
403
+ this.beats.push(...transition.beats);
404
+ const logged = input.kind === "job" && input.outcome.ok ? {
405
+ kind: "job",
406
+ jobId: input.jobId,
407
+ outcome: {
408
+ ok: true,
409
+ value: input.outcome.value
410
+ }
411
+ } : input;
412
+ this.inputs.push({
413
+ match: match.number,
414
+ revision,
415
+ at: this.now,
416
+ input: logged
417
+ });
418
+ const hold = engine.holdMs ? engine.holdMs(transition, match.presentation) : 0;
419
+ this.holdUntil = this.now + Math.max(0, hold);
420
+ if (outcome !== null) this.log("info", "match_finished", {
421
+ result: outcome.result,
422
+ match: match.number,
423
+ ms: outcome.elapsedMs ?? 0
424
+ });
425
+ this.endedBy = input.kind === "command" ? input.seat : null;
426
+ this.recomputeClocks();
427
+ this.endedBy = null;
428
+ return ok(transition);
429
+ }
430
+ /** Hands a seat to the AI or back to a human, telling the engine through a `seat` input. */
431
+ setController(id, controller) {
432
+ const seat = this.seat(id);
433
+ if (seat === void 0 || seat.controller === controller) return;
434
+ const next = {
435
+ ...seat,
436
+ controller,
437
+ aiLevel: controller === "ai" ? seat.aiLevel ?? this.record.aiLevel ?? defaultLevel() : seat.aiLevel
438
+ };
439
+ this.setSeat(next);
440
+ const before = this.record.revision;
441
+ if (this.playing()) {
442
+ const change = controller === "ai" && next.aiLevel !== null ? {
443
+ type: "controller",
444
+ controller,
445
+ name: displayName(next),
446
+ aiLevel: next.aiLevel
447
+ } : {
448
+ type: "controller",
449
+ controller,
450
+ name: displayName(next)
451
+ };
452
+ const result = this.apply({
453
+ kind: "seat",
454
+ seat: id,
455
+ change
456
+ });
457
+ if (!result.ok) this.log("warn", "seat_input_rejected", {
458
+ seat: id,
459
+ code: result.error.code
460
+ });
461
+ }
462
+ if (this.record.revision === before) this.bump();
463
+ this.log("info", "seat_handover", {
464
+ seat: id,
465
+ to: controller
466
+ });
467
+ }
468
+ /**
469
+ * Ends the clocks whose decision is over and starts clocks for new decisions (section
470
+ * 3.7). In bank mode an ended clock charges the seat's bank for the time it ran, and adds
471
+ * the increment when the seat's own command ended it.
472
+ */
473
+ recomputeClocks() {
474
+ this.syncPause();
475
+ const match = this.record.match;
476
+ if (match === null) return;
477
+ const bank = this.record.policy.bank ?? null;
478
+ const frozen = this.frozen();
479
+ const next = [];
480
+ if (this.record.phase === "playing") for (const seat of new Set(engine.pending(match.state))) {
481
+ if (this.seat(seat)?.controller !== "human") continue;
482
+ const decisionRevision = match.decisionRevision[seat] ?? 0;
483
+ const existing = match.clocks.find((c) => c.seat === seat && c.decisionRevision === decisionRevision);
484
+ if (existing !== void 0) {
485
+ next.push(existing);
486
+ continue;
487
+ }
488
+ if (frozen) continue;
489
+ const startsAt = Math.max(this.now, this.holdUntil);
490
+ if (bank !== null) {
491
+ const remaining = match.banks?.[seat] ?? bank.initialMs;
492
+ const ms = engine.decisionMs ? engine.decisionMs(match.state, seat, remaining) : remaining;
493
+ if (ms === null) continue;
494
+ next.push({
495
+ seat,
496
+ at: startsAt + ms,
497
+ decisionRevision,
498
+ startsAt,
499
+ onBank: ms === remaining
500
+ });
501
+ } else {
502
+ const ms = engine.decisionMs ? engine.decisionMs(match.state, seat, this.record.policy.decisionMs) : this.record.policy.decisionMs;
503
+ if (ms === null) continue;
504
+ next.push({
505
+ seat,
506
+ at: startsAt + ms,
507
+ decisionRevision
508
+ });
509
+ }
510
+ }
511
+ let banks = match.banks;
512
+ if (bank !== null && banks !== void 0) {
513
+ const charged = [...banks];
514
+ const until = match.pausedAt ?? this.now;
515
+ for (const ended of match.clocks) {
516
+ if (next.includes(ended) || ended.onBank !== true) continue;
517
+ const ran = Math.max(0, until - (ended.startsAt ?? until));
518
+ charged[ended.seat] = Math.max(0, (charged[ended.seat] ?? 0) - ran) + (this.endedBy === ended.seat ? bank.incrementMs : 0);
519
+ }
520
+ banks = charged;
521
+ }
522
+ const clocksChanged = canonicalJson(next) !== canonicalJson(match.clocks);
523
+ const banksChanged = banks !== match.banks && canonicalJson(banks ?? []) !== canonicalJson(match.banks ?? []);
524
+ if (clocksChanged || banksChanged) this.setMatch({
525
+ ...match,
526
+ clocks: next,
527
+ ...banks === void 0 ? {} : { banks }
528
+ });
529
+ }
530
+ /** A timed-out bank clock empties the bank before its timeout input applies. */
531
+ emptyBank(clock) {
532
+ const match = this.record.match;
533
+ if (match?.banks === void 0 || clock.onBank !== true) return;
534
+ this.setMatch({
535
+ ...match,
536
+ banks: match.banks.map((b, seat) => seat === clock.seat ? 0 : b)
537
+ });
538
+ }
539
+ /**
540
+ * Starts or ends the table's pause to match who is connected (D-58). While no player is
541
+ * connected nothing runs; when one returns, every clock's deadline, a bank clock's start and
542
+ * an end offer's deadline move on by the length of the pause, so nobody is charged for it.
543
+ */
544
+ syncPause() {
545
+ const match = this.record.match;
546
+ if (match === null) return;
547
+ if (this.frozen()) {
548
+ if (match.pausedAt !== void 0 || this.record.phase !== "playing") return;
549
+ this.setMatch({
550
+ ...match,
551
+ pausedAt: this.now
552
+ });
553
+ this.log("info", "table_paused", {});
554
+ return;
555
+ }
556
+ if (match.pausedAt === void 0) return;
557
+ const { pausedAt, ...rest } = match;
558
+ const shift = Math.max(0, this.now - pausedAt);
559
+ const clocks = match.clocks.map((c) => c.startsAt === void 0 ? {
560
+ ...c,
561
+ at: c.at + shift
562
+ } : {
563
+ ...c,
564
+ at: c.at + shift,
565
+ startsAt: c.startsAt + shift
566
+ });
567
+ this.setMatch({
568
+ ...rest,
569
+ clocks
570
+ });
571
+ const offer = this.record.offer ?? null;
572
+ if (offer?.status === "open" && offer.endsAt !== null) this.set({
573
+ ...this.record,
574
+ offer: {
575
+ ...offer,
576
+ endsAt: offer.endsAt + shift
577
+ }
578
+ });
579
+ for (const [seat, since] of this.absentSince) this.absentSince.set(seat, since + shift);
580
+ this.log("info", "table_resumed", { pausedMs: shift });
581
+ }
582
+ /** Ends the match early with no result, as an offer to end it asked (D-61). */
583
+ endMatch(how) {
584
+ const match = this.record.match;
585
+ if (match === null || this.record.phase !== "playing") return;
586
+ for (const job of match.jobs) this.jobStartedAt.delete(job.request.id);
587
+ this.aiInFlight.clear();
588
+ this.aiRetry.clear();
589
+ const outcome = {
590
+ result: "abandoned",
591
+ placements: [],
592
+ summary: how === "agreed" ? "The players agreed to end the match early." : "The match ended early: nobody declined the offer to end it.",
593
+ elapsedMs: Math.max(0, this.now - match.startedAt)
594
+ };
595
+ this.set({
596
+ ...this.record,
597
+ revision: this.record.revision + 1,
598
+ phase: "finished",
599
+ offer: null,
600
+ match: {
601
+ ...match,
602
+ jobs: [],
603
+ outcome
604
+ }
605
+ });
606
+ this.log("info", "match_ended_early", {
607
+ how,
608
+ match: match.number
609
+ });
610
+ this.recomputeClocks();
611
+ }
612
+ /** After a restart: at least `resumeGraceMs` for every clock; a bank clock's start moves too, so the wait isn't charged. */
613
+ extendClocks() {
614
+ const match = this.record.match;
615
+ if (match === null) return;
616
+ const floor = this.now + limits.resumeGraceMs;
617
+ if (!match.clocks.some((c) => c.at < floor)) return;
618
+ const clocks = match.clocks.map((c) => {
619
+ if (c.at >= floor) return c;
620
+ const shift = floor - c.at;
621
+ return c.startsAt === void 0 ? {
622
+ ...c,
623
+ at: floor
624
+ } : {
625
+ ...c,
626
+ at: floor,
627
+ startsAt: c.startsAt + shift
628
+ };
629
+ });
630
+ this.setMatch({
631
+ ...match,
632
+ clocks
633
+ });
634
+ }
635
+ budgetLeft() {
636
+ if (this.autoInputs < limits.aiInputsPerStep) return true;
637
+ if (!this.aiLoopLogged) {
638
+ this.aiLoopLogged = true;
639
+ this.log("error", "ai_loop", { inputs: this.autoInputs });
640
+ }
641
+ return false;
642
+ }
643
+ completeJob(jobId, outcome) {
644
+ const match = this.record.match;
645
+ if (match === null) return;
646
+ this.jobStartedAt.delete(jobId);
647
+ const presentation = outcome.ok && outcome.presentation !== void 0 ? outcome.presentation : match.presentation;
648
+ if (presentation !== match.presentation) this.presentationChanged = true;
649
+ this.setMatch({
650
+ ...match,
651
+ jobs: match.jobs.filter((j) => j.request.id !== jobId),
652
+ presentation
653
+ });
654
+ const stripped = outcome.ok ? {
655
+ ok: true,
656
+ value: outcome.value
657
+ } : outcome;
658
+ this.log("info", "job_finished", {
659
+ job: jobId,
660
+ ok: outcome.ok
661
+ });
662
+ const result = this.apply({
663
+ kind: "job",
664
+ jobId,
665
+ outcome: stripped
666
+ });
667
+ if (!result.ok) this.log("error", "job_input_rejected", {
668
+ job: jobId,
669
+ code: result.error.code
670
+ });
671
+ }
672
+ /** Absence deadlines for seats, or null when the seat has none. */
673
+ absentDue(seat) {
674
+ if (seat.controller !== "human" || this.hasMember(seat.id)) return null;
675
+ const since = this.absentSince.get(seat.id);
676
+ if (since !== void 0) return since + this.record.policy.graceMs;
677
+ return seat.claim === null ? Number.NEGATIVE_INFINITY : null;
678
+ }
679
+ hostDue() {
680
+ const host = this.record.host;
681
+ if (host === null || this.hasMember(host)) return null;
682
+ if (this.hostCandidate() === null) return null;
683
+ return (this.absentSince.get(host) ?? Number.NEGATIVE_INFINITY) + this.record.policy.graceMs;
684
+ }
685
+ /** The connected human in the lowest-numbered seat, other than the host. */
686
+ hostCandidate(exclude = this.record.host) {
687
+ for (const seat of this.record.seats) if (seat.id !== exclude && seat.claim !== null && seat.controller === "human" && this.hasMember(seat.id)) return seat.id;
688
+ return null;
689
+ }
690
+ /** Applies the first due deadline. True when something happened. */
691
+ applyOneDue() {
692
+ const match = this.record.match;
693
+ const now = this.now;
694
+ if (match !== null && this.record.phase === "playing") {
695
+ for (const job of match.jobs) {
696
+ const started = this.jobStartedAt.get(job.request.id);
697
+ const spec = engine.jobs?.[job.request.kind];
698
+ if (started !== void 0 && spec !== void 0 && started + spec.timeoutMs <= now) {
699
+ this.log("warn", "job_timeout", { job: job.request.id });
700
+ this.completeJob(job.request.id, {
701
+ ok: false,
702
+ reason: "timeout"
703
+ });
704
+ return true;
705
+ }
706
+ }
707
+ if (!this.frozen()) {
708
+ const due = [...match.clocks].filter((c) => c.at <= now).sort((a, b) => a.at - b.at || a.seat - b.seat)[0];
709
+ if (due !== void 0) {
710
+ this.autoInputs += 1;
711
+ this.emptyBank(due);
712
+ const result = this.apply({
713
+ kind: "timeout",
714
+ seat: due.seat
715
+ });
716
+ if (!result.ok) {
717
+ this.log("error", "timeout_rejected", {
718
+ seat: due.seat,
719
+ code: result.error.code
720
+ });
721
+ const current = this.record.match;
722
+ if (current !== null) this.setMatch({
723
+ ...current,
724
+ clocks: current.clocks.filter((c) => c !== due)
725
+ });
726
+ this.recomputeClocks();
727
+ }
728
+ return true;
729
+ }
730
+ const offer = this.record.offer ?? null;
731
+ if (offer?.status === "open" && offer.endsAt !== null && offer.endsAt <= now) {
732
+ this.autoInputs += 1;
733
+ this.endMatch("unopposed");
734
+ return true;
735
+ }
736
+ const pending = new Set(engine.pending(match.state));
737
+ for (const seat of this.record.seats) {
738
+ const at = this.absentDue(seat);
739
+ if (at === null || at > now) continue;
740
+ if (this.record.policy.absent === "ai" && seat.claim !== null) {
741
+ this.autoInputs += 1;
742
+ this.setController(seat.id, "ai");
743
+ return true;
744
+ }
745
+ if (this.record.policy.absent === "timeout" && pending.has(seat.id)) {
746
+ this.autoInputs += 1;
747
+ const result = this.apply({
748
+ kind: "timeout",
749
+ seat: seat.id
750
+ });
751
+ if (!result.ok) {
752
+ this.log("error", "timeout_rejected", {
753
+ seat: seat.id,
754
+ code: result.error.code
755
+ });
756
+ return false;
757
+ }
758
+ return true;
759
+ }
760
+ }
761
+ }
762
+ }
763
+ const hostAt = this.hostDue();
764
+ if (hostAt !== null && hostAt <= now) {
765
+ const next = this.hostCandidate();
766
+ if (next !== null) {
767
+ this.set({
768
+ ...this.record,
769
+ host: next,
770
+ revision: this.record.revision + 1
771
+ });
772
+ this.log("info", "host_transferred", { seat: next });
773
+ return true;
774
+ }
775
+ }
776
+ return false;
777
+ }
778
+ /**
779
+ * Plays one runner-driven AI move (rule 7). True when something happened. A heavy AI
780
+ * never chooses here: the core asks the pool with `start-ai`, once per decision.
781
+ */
782
+ playOneAi() {
783
+ const match = this.record.match;
784
+ const ai = engine.ai;
785
+ if (ai === void 0 || match === null || !this.playing() || match.jobs.length > 0 || this.frozen()) return false;
786
+ const seats = engine.pending(match.state).filter((s) => this.seat(s)?.controller === "ai");
787
+ if (options.heavyAi === true) {
788
+ for (const seat of seats) {
789
+ const decisionRevision = match.decisionRevision[seat] ?? 0;
790
+ if (this.aiInFlight.get(seat) === decisionRevision) continue;
791
+ const retry = this.aiRetry.get(seat);
792
+ if (retry?.decisionRevision === decisionRevision && retry.at > this.now) continue;
793
+ this.aiInFlight.set(seat, decisionRevision);
794
+ const level = this.seat(seat)?.aiLevel ?? ai.levels[0] ?? "";
795
+ this.jobs.push({
796
+ type: "start-ai",
797
+ seat,
798
+ decisionRevision,
799
+ view: engine.viewFor(match.state, seat),
800
+ level,
801
+ rng: aiDecisionSeed(match.aiRng, seat)
802
+ });
803
+ this.log("info", "ai_started", { seat });
804
+ }
805
+ return false;
806
+ }
807
+ const seat = seats[0];
808
+ if (seat === void 0) return false;
809
+ const level = this.seat(seat)?.aiLevel ?? ai.levels[0] ?? "";
810
+ const child = restoreRng(aiDecisionSeed(match.aiRng, seat));
811
+ this.autoInputs += 1;
812
+ let command;
813
+ try {
814
+ command = ai.choose(engine.viewFor(match.state, seat), seat, level, child);
815
+ } catch (error) {
816
+ this.log("error", "engine_error", {
817
+ seat,
818
+ input: "ai",
819
+ error: String(error)
820
+ });
821
+ return this.aiFallback(seat);
822
+ }
823
+ return this.applyAi(seat, command);
824
+ }
825
+ /** Applies an AI's command; the AI stream moves on only when it is accepted. */
826
+ applyAi(seat, command) {
827
+ const result = this.apply({
828
+ kind: "command",
829
+ seat,
830
+ command
831
+ });
832
+ if (result.ok) {
833
+ const match = this.record.match;
834
+ if (match !== null) this.setMatch({
835
+ ...match,
836
+ aiRng: advanceAi(match.aiRng, match.seats.length)
837
+ });
838
+ return true;
839
+ }
840
+ this.log("error", "ai_illegal_move", {
841
+ seat,
842
+ code: result.error.code
843
+ });
844
+ return this.aiFallback(seat);
845
+ }
846
+ aiFallback(seat) {
847
+ const result = this.apply({
848
+ kind: "timeout",
849
+ seat
850
+ });
851
+ if (result.ok) return true;
852
+ this.log("error", "timeout_rejected", {
853
+ seat,
854
+ code: result.error.code
855
+ });
856
+ return false;
857
+ }
858
+ /** Deadlines, then AI moves, until nothing more is due or the loop bound is reached. */
859
+ settle() {
860
+ for (;;) {
861
+ if (!this.budgetLeft()) return;
862
+ if (this.applyOneDue()) continue;
863
+ if (!this.budgetLeft()) return;
864
+ if (this.playOneAi()) continue;
865
+ return;
866
+ }
867
+ }
868
+ alarm() {
869
+ const candidates = [];
870
+ const match = this.record.match;
871
+ if (match !== null && this.record.phase === "playing") {
872
+ for (const job of match.jobs) {
873
+ const started = this.jobStartedAt.get(job.request.id);
874
+ const spec = engine.jobs?.[job.request.kind];
875
+ if (started !== void 0 && spec !== void 0) candidates.push(started + spec.timeoutMs);
876
+ }
877
+ if (!this.frozen()) {
878
+ for (const clock of match.clocks) candidates.push(clock.at);
879
+ const offer = this.record.offer ?? null;
880
+ if (offer?.status === "open" && offer.endsAt !== null) candidates.push(offer.endsAt);
881
+ const pending = new Set(engine.pending(match.state));
882
+ for (const [seat, retry] of this.aiRetry) if (pending.has(seat) && this.seat(seat)?.controller === "ai" && match.jobs.length === 0 && (match.decisionRevision[seat] ?? 0) === retry.decisionRevision) candidates.push(retry.at);
883
+ for (const seat of this.record.seats) {
884
+ const at = this.absentDue(seat);
885
+ if (at === null || !Number.isFinite(at)) continue;
886
+ if (this.record.policy.absent === "ai" && seat.claim !== null) candidates.push(at);
887
+ if (this.record.policy.absent === "timeout" && pending.has(seat.id)) candidates.push(at);
888
+ }
889
+ }
890
+ }
891
+ const hostAt = this.hostDue();
892
+ if (hostAt !== null && Number.isFinite(hostAt)) candidates.push(hostAt);
893
+ if (candidates.length === 0) return null;
894
+ const at = Math.min(...candidates);
895
+ return at <= this.now ? this.now + 1e3 : at;
896
+ }
897
+ presence() {
898
+ let spectators = 0;
899
+ for (const m of this.members.values()) if (m.seat === null) spectators += 1;
900
+ return {
901
+ connected: this.record.seats.map((s) => this.hasMember(s.id)),
902
+ clocks: this.seatClocks(),
903
+ paused: this.record.phase === "playing" && this.frozen(),
904
+ spectators
905
+ };
906
+ }
907
+ /**
908
+ * Clocks as clients show them: running per-move clocks, or every seat's bank. On a paused
909
+ * table (D-58) each clock reads as it stood when the pause began, and says it is paused.
910
+ */
911
+ seatClocks() {
912
+ const match = this.record.match;
913
+ if (match === null || this.record.phase !== "playing") return [];
914
+ const frozen = this.frozen();
915
+ const at = frozen ? match.pausedAt ?? this.now : this.now;
916
+ const paused = frozen ? { paused: true } : {};
917
+ const bank = this.record.policy.bank ?? null;
918
+ if (bank === null) return match.clocks.map((c) => ({
919
+ seat: c.seat,
920
+ remainingMs: Math.max(0, c.at - at),
921
+ running: !frozen,
922
+ ...paused
923
+ }));
924
+ return match.seats.map(({ id: seat }) => {
925
+ const left = match.banks?.[seat] ?? bank.initialMs;
926
+ const clock = match.clocks.find((c) => c.seat === seat);
927
+ if (clock === void 0) return {
928
+ seat,
929
+ remainingMs: left,
930
+ running: false
931
+ };
932
+ const startsAt = clock.startsAt ?? at;
933
+ return {
934
+ seat,
935
+ remainingMs: clock.onBank === true ? Math.max(0, left - Math.max(0, at - startsAt)) : Math.max(0, clock.at - at),
936
+ running: !frozen && at >= startsAt,
937
+ ...paused
938
+ };
939
+ });
940
+ }
941
+ views = /* @__PURE__ */ new Map();
942
+ view(viewer) {
943
+ if (this.views.has(viewer)) return this.views.get(viewer) ?? null;
944
+ const match = this.record.match;
945
+ const view = match === null ? null : engine.viewFor(match.state, viewer);
946
+ this.views.set(viewer, view);
947
+ return view;
948
+ }
949
+ snapshot(viewer, presence) {
950
+ const match = this.record.match;
951
+ const history = [];
952
+ for (const event of match?.log ?? []) {
953
+ const redacted = engine.eventFor(event, viewer);
954
+ if (redacted !== null) history.push(redacted);
955
+ }
956
+ return {
957
+ v: 1,
958
+ t: "snapshot",
959
+ epoch: this.record.epoch,
960
+ revision: this.record.revision,
961
+ phase: this.record.phase,
962
+ seat: viewer,
963
+ seats: this.record.seats.map(publicSeat),
964
+ host: this.record.host,
965
+ game: this.record.game,
966
+ settings: settingsOf(this.record),
967
+ view: this.view(viewer),
968
+ history,
969
+ pending: match !== null && this.record.phase === "playing" ? engine.pending(match.state) : [],
970
+ outcome: match?.outcome ?? null,
971
+ presentation: match?.presentation ?? null,
972
+ offer: publicOffer(this.record),
973
+ presence,
974
+ serverTime: this.now
975
+ };
976
+ }
977
+ welcome(conn, seat, issueToken) {
978
+ const message = {
979
+ v: 1,
980
+ t: "welcome",
981
+ room: this.record.code,
982
+ seat,
983
+ host: this.record.host,
984
+ game: this.record.game,
985
+ protocol: 1
986
+ };
987
+ this.early.push({
988
+ type: "welcome",
989
+ conn,
990
+ message,
991
+ issueToken
992
+ });
993
+ }
994
+ finish() {
995
+ const start = this.start.record;
996
+ const effects = [];
997
+ let record = this.record;
998
+ if (this.dirty) {
999
+ record = {
1000
+ ...record,
1001
+ writes: start.writes + 1,
1002
+ updatedAt: this.now
1003
+ };
1004
+ this.record = record;
1005
+ effects.push({
1006
+ type: "persist",
1007
+ record,
1008
+ inputs: this.inputs
1009
+ });
1010
+ }
1011
+ effects.push(...this.early);
1012
+ const presence = this.presence();
1013
+ const changed = record.revision !== start.revision;
1014
+ const groups = /* @__PURE__ */ new Map();
1015
+ for (const [conn, member] of this.members) {
1016
+ if (this.snapshotAll || this.snapshotTo.has(conn)) continue;
1017
+ const list = groups.get(member.seat) ?? [];
1018
+ list.push(conn);
1019
+ groups.set(member.seat, list);
1020
+ }
1021
+ if (changed) {
1022
+ const seats = record.seats.map(publicSeat);
1023
+ const seatsChanged = canonicalJson(seats) !== canonicalJson(start.seats.map(publicSeat));
1024
+ const settings = settingsOf(record);
1025
+ const settingsChanged = canonicalJson(settings) !== canonicalJson(settingsOf(start));
1026
+ const match = record.match;
1027
+ const pending = match !== null && record.phase === "playing" ? engine.pending(match.state) : [];
1028
+ for (const [viewer, conns] of groups) {
1029
+ const beats = [];
1030
+ for (const beat of this.beats) {
1031
+ const event = engine.eventFor(beat.event, viewer);
1032
+ if (event === null) continue;
1033
+ beats.push(beat.state === void 0 ? { event } : {
1034
+ event,
1035
+ view: engine.viewFor(beat.state, viewer)
1036
+ });
1037
+ }
1038
+ const update = {
1039
+ v: 1,
1040
+ t: "update",
1041
+ epoch: record.epoch,
1042
+ revision: record.revision,
1043
+ from: start.revision,
1044
+ phase: record.phase,
1045
+ host: record.host,
1046
+ ...seatsChanged ? { seats } : {},
1047
+ ...settingsChanged ? { settings } : {},
1048
+ beats,
1049
+ view: this.view(viewer),
1050
+ pending,
1051
+ outcome: match?.outcome ?? null,
1052
+ ...this.presentationChanged ? { presentation: match?.presentation ?? null } : {},
1053
+ offer: publicOffer(record),
1054
+ presence,
1055
+ serverTime: this.now
1056
+ };
1057
+ effects.push({
1058
+ type: "send",
1059
+ to: conns,
1060
+ message: update
1061
+ });
1062
+ }
1063
+ }
1064
+ for (const [conn, member] of this.members) if (this.snapshotAll || this.snapshotTo.has(conn)) effects.push({
1065
+ type: "send",
1066
+ to: [conn],
1067
+ message: this.snapshot(member.seat, presence)
1068
+ });
1069
+ effects.push(...this.receipts);
1070
+ const clocksChanged = canonicalJson(record.match?.clocks ?? []) !== canonicalJson(start.match?.clocks ?? []);
1071
+ if (!changed && (this.membershipChanged || clocksChanged)) {
1072
+ const others = [...groups.values()].flat();
1073
+ this.send(others, {
1074
+ v: 1,
1075
+ t: "presence",
1076
+ presence,
1077
+ serverTime: this.now
1078
+ }, effects);
1079
+ }
1080
+ const alarmAt = this.closed ? null : this.alarm();
1081
+ if (alarmAt !== this.start.alarmAt) effects.push({
1082
+ type: "alarm",
1083
+ at: alarmAt
1084
+ });
1085
+ effects.push(...this.jobs, ...this.logs);
1086
+ return {
1087
+ room: {
1088
+ record,
1089
+ members: this.members,
1090
+ absentSince: this.absentSince,
1091
+ jobStartedAt: this.jobStartedAt,
1092
+ aiInFlight: this.aiInFlight,
1093
+ aiRetry: this.aiRetry,
1094
+ alarmAt,
1095
+ closed: this.closed
1096
+ },
1097
+ effects
1098
+ };
1099
+ }
1100
+ }
1101
+ const uniqueName = (tx, name) => {
1102
+ const used = new Set(tx.record.seats.flatMap((s) => s.claim === null ? [] : [s.claim.name.toLowerCase()]));
1103
+ if (!used.has(name.toLowerCase())) return name;
1104
+ const base = name.slice(0, 21);
1105
+ let n = 2;
1106
+ while (used.has(`${base} ${n}`.toLowerCase())) n += 1;
1107
+ return `${base} ${n}`;
1108
+ };
1109
+ const join = (tx, command) => {
1110
+ if (tx.members.has(command.conn)) return;
1111
+ const wasFrozen = tx.frozen();
1112
+ const member = (seat) => ({
1113
+ seat,
1114
+ protocol: command.protocol,
1115
+ joinedAt: tx.now
1116
+ });
1117
+ const claimed = command.presentedTokenHash === null ? void 0 : tx.record.seats.find((s) => s.claim?.tokenHash === command.presentedTokenHash);
1118
+ if (claimed !== void 0 && !command.watch) {
1119
+ for (const conn of tx.connsOf(claimed.id)) {
1120
+ tx.send([conn], {
1121
+ v: 1,
1122
+ t: "replaced"
1123
+ });
1124
+ tx.drop(conn, "replaced");
1125
+ tx.log("info", "replaced", { seat: claimed.id });
1126
+ }
1127
+ tx.members.set(command.conn, member(claimed.id));
1128
+ tx.absentSince.delete(claimed.id);
1129
+ if (claimed.controller === "ai") tx.setController(claimed.id, "human");
1130
+ tx.welcome(command.conn, claimed.id, false);
1131
+ } else {
1132
+ const free = command.watch ? void 0 : freeSeat(tx);
1133
+ if (free !== void 0) {
1134
+ const { code: room, epoch, revision } = tx.record;
1135
+ const admitted = admitName(command.name, {
1136
+ room,
1137
+ epoch,
1138
+ revision,
1139
+ seat: free.id
1140
+ });
1141
+ const name = uniqueName(tx, admitted.name);
1142
+ const wasAi = free.controller === "ai";
1143
+ tx.setSeat({
1144
+ ...free,
1145
+ claim: {
1146
+ tokenHash: command.freshTokenHash,
1147
+ name
1148
+ },
1149
+ ready: false
1150
+ });
1151
+ tx.members.set(command.conn, member(free.id));
1152
+ tx.absentSince.delete(free.id);
1153
+ if (wasAi) tx.setController(free.id, "human");
1154
+ else tx.bump();
1155
+ if (tx.record.host === null) tx.set({
1156
+ ...tx.record,
1157
+ host: free.id
1158
+ });
1159
+ tx.log("info", "seat_claimed", { seat: free.id });
1160
+ tx.welcome(command.conn, free.id, true);
1161
+ if (admitted.rejection !== null) {
1162
+ tx.send([command.conn], {
1163
+ v: 1,
1164
+ t: "notice",
1165
+ code: "name-replaced",
1166
+ message: nameReplacedMessage(admitted.rejection, name)
1167
+ });
1168
+ tx.log("info", "name_replaced", {
1169
+ seat: free.id,
1170
+ reason: admitted.rejection.code
1171
+ });
1172
+ }
1173
+ } else {
1174
+ let spectators = 0;
1175
+ for (const m of tx.members.values()) if (m.seat === null) spectators += 1;
1176
+ if (spectators >= tx.record.policy.maxSpectators) {
1177
+ tx.send([command.conn], {
1178
+ v: 1,
1179
+ t: "notice",
1180
+ code: "room-full",
1181
+ message: "This room is full."
1182
+ });
1183
+ tx.early.push({
1184
+ type: "drop",
1185
+ conn: command.conn,
1186
+ reason: "room-full"
1187
+ });
1188
+ return;
1189
+ }
1190
+ tx.members.set(command.conn, member(null));
1191
+ tx.welcome(command.conn, null, false);
1192
+ }
1193
+ }
1194
+ tx.membershipChanged = true;
1195
+ tx.snapshotTo.add(command.conn);
1196
+ if (wasFrozen && !tx.frozen()) tx.syncPause();
1197
+ };
1198
+ const freeSeat = (tx) => {
1199
+ if (tx.record.phase === "lobby") return tx.record.seats.find((s) => s.claim === null && s.controller === "human");
1200
+ if (tx.record.policy.lateJoin === "take-ai-seat" && tx.record.phase !== "closed") return tx.record.seats.find((s) => s.claim === null && s.controller === "ai");
1201
+ };
1202
+ const leave = (tx, conn) => {
1203
+ const member = tx.members.get(conn);
1204
+ if (member === void 0) return;
1205
+ tx.members.delete(conn);
1206
+ tx.membershipChanged = true;
1207
+ if (member.seat === null || tx.hasMember(member.seat)) return;
1208
+ if (tx.seat(member.seat)?.claim) tx.absentSince.set(member.seat, tx.now);
1209
+ };
1210
+ /** The receipt and revision checks shared by `act` and `control` (section 3.8). True: proceed. */
1211
+ const admit = (tx, conn, id, seat, expectedRevision, print, judgeStale) => {
1212
+ const receipt = tx.record.receipts.find((r) => r.seat === seat && r.id === id);
1213
+ if (receipt !== void 0) {
1214
+ if (receipt.fingerprint === print) {
1215
+ tx.receipt(conn, id, {
1216
+ status: "accepted",
1217
+ revision: receipt.revision
1218
+ });
1219
+ tx.log("info", "duplicate_action", { seat });
1220
+ } else tx.reject(conn, id, {
1221
+ code: RejectionCode.actionIdReused,
1222
+ message: "That action ID was already used for a different action."
1223
+ }, seat);
1224
+ return false;
1225
+ }
1226
+ if (expectedRevision > tx.record.revision) {
1227
+ tx.reject(conn, id, {
1228
+ code: RejectionCode.badRevision,
1229
+ message: "Your table is ahead of the server. Reloading it."
1230
+ }, seat);
1231
+ tx.snapshotTo.add(conn);
1232
+ return false;
1233
+ }
1234
+ const decided = tx.record.match?.decisionRevision[seat] ?? 0;
1235
+ if (judgeStale && expectedRevision < decided) {
1236
+ tx.reject(conn, id, {
1237
+ code: RejectionCode.stale,
1238
+ message: "The table changed. Review it and try again."
1239
+ }, seat);
1240
+ return false;
1241
+ }
1242
+ return true;
1243
+ };
1244
+ const accept = (tx, conn, id, seat, print, revision) => {
1245
+ const mine = tx.record.receipts.filter((r) => r.seat === seat);
1246
+ const others = tx.record.receipts.filter((r) => r.seat !== seat);
1247
+ const kept = mine.slice(Math.max(0, mine.length - (limits.receiptsPerSeat - 1)));
1248
+ tx.set({
1249
+ ...tx.record,
1250
+ receipts: [
1251
+ ...others,
1252
+ ...kept,
1253
+ {
1254
+ seat,
1255
+ id,
1256
+ fingerprint: print,
1257
+ revision
1258
+ }
1259
+ ]
1260
+ });
1261
+ tx.receipt(conn, id, {
1262
+ status: "accepted",
1263
+ revision
1264
+ });
1265
+ };
1266
+ const act = (tx, command) => {
1267
+ const member = tx.members.get(command.conn);
1268
+ if (member === void 0) return void tx.log("warn", "unknown_connection", {});
1269
+ if (member.seat === null) return tx.reject(command.conn, command.id, {
1270
+ code: RejectionCode.spectator,
1271
+ message: "Spectators can watch but not play."
1272
+ }, null);
1273
+ const seat = member.seat;
1274
+ const print = fingerprint({
1275
+ expectedRevision: command.expectedRevision,
1276
+ command: command.command
1277
+ });
1278
+ if (!admit(tx, command.conn, command.id, seat, command.expectedRevision, print, true)) return;
1279
+ if (!tx.playing()) return tx.reject(command.conn, command.id, {
1280
+ code: RejectionCode.wrongPhase,
1281
+ message: "No match is in progress."
1282
+ }, seat);
1283
+ const result = tx.apply({
1284
+ kind: "command",
1285
+ seat,
1286
+ command: command.command
1287
+ });
1288
+ if (!result.ok) return tx.reject(command.conn, command.id, result.error, seat);
1289
+ accept(tx, command.conn, command.id, seat, print, tx.record.revision);
1290
+ };
1291
+ const notHost = {
1292
+ code: RejectionCode.notHost,
1293
+ message: "Only the host can do that."
1294
+ };
1295
+ const wrongPhase = (message) => ({
1296
+ code: RejectionCode.wrongPhase,
1297
+ message
1298
+ });
1299
+ const notAllowed = (message) => ({
1300
+ code: RejectionCode.notAllowed,
1301
+ message
1302
+ });
1303
+ /** Rebuilds the seat list for a new match; members, absences and the host follow their seats. */
1304
+ const startMatch = (tx, seeds, compact) => {
1305
+ const filled = compact ? tx.record.seats.filter((s) => s.claim !== null || s.controller === "ai") : tx.record.seats;
1306
+ const remap = new Map(filled.map((s, i) => [s.id, i]));
1307
+ const seats = filled.map((s, i) => ({
1308
+ ...s,
1309
+ id: i,
1310
+ ready: false
1311
+ }));
1312
+ const revision = tx.record.revision + 1;
1313
+ let match;
1314
+ let config = tx.record.config;
1315
+ try {
1316
+ const finished = tx.record.match;
1317
+ if (finished !== null && finished.outcome !== null && engine.rematch !== void 0) {
1318
+ const state = engine.schemas.state["~standard"].validate(finished.state);
1319
+ if (!(state instanceof Promise) && state.issues === void 0) config = engine.rematch(finished.config, state.value);
1320
+ }
1321
+ const parsed = engine.schemas.config["~standard"].validate(config);
1322
+ if (parsed instanceof Promise || parsed.issues !== void 0) return {
1323
+ code: RejectionCode.engineError,
1324
+ message: "The rematch settings could not be prepared."
1325
+ };
1326
+ config = parsed.value;
1327
+ match = newMatch({
1328
+ ...tx.record,
1329
+ seats,
1330
+ config
1331
+ }, seats, seeds, revision, tx.now);
1332
+ } catch (error) {
1333
+ tx.log("error", "engine_error", {
1334
+ input: "init",
1335
+ error: String(error)
1336
+ });
1337
+ return {
1338
+ code: RejectionCode.engineError,
1339
+ message: "The game could not start."
1340
+ };
1341
+ }
1342
+ if (!match.ok) return {
1343
+ code: gameRejection(match.error.code),
1344
+ message: match.error.message
1345
+ };
1346
+ for (const [conn, member] of tx.members) {
1347
+ if (member.seat === null) continue;
1348
+ const next = remap.get(member.seat);
1349
+ if (next === void 0) tx.drop(conn, "left-room");
1350
+ else tx.members.set(conn, {
1351
+ ...member,
1352
+ seat: next
1353
+ });
1354
+ }
1355
+ const absent = [...tx.absentSince].flatMap(([seat, since]) => {
1356
+ const next = remap.get(seat);
1357
+ return next === void 0 ? [] : [[next, since]];
1358
+ });
1359
+ tx.absentSince.clear();
1360
+ for (const [seat, since] of absent) tx.absentSince.set(seat, since);
1361
+ const host = tx.record.host === null ? null : remap.get(tx.record.host) ?? null;
1362
+ const receipts = tx.record.receipts.flatMap((r) => {
1363
+ const next = remap.get(r.seat);
1364
+ return next === void 0 ? [] : [{
1365
+ ...r,
1366
+ seat: next
1367
+ }];
1368
+ });
1369
+ const value = match.value;
1370
+ tx.set({
1371
+ ...tx.record,
1372
+ seats,
1373
+ host,
1374
+ receipts,
1375
+ revision,
1376
+ config,
1377
+ game: pin,
1378
+ match: value,
1379
+ offer: null,
1380
+ phase: value.outcome === null ? "playing" : "finished"
1381
+ });
1382
+ tx.snapshotAll = true;
1383
+ tx.log("info", value.number === 1 ? "match_started" : "rematch", {
1384
+ match: value.number,
1385
+ game: pin.id,
1386
+ rules: pin.rulesVersion
1387
+ });
1388
+ tx.recomputeClocks();
1389
+ return null;
1390
+ };
1391
+ /** Seats whose yes a rematch needs: every seat a player holds. Computer seats accept on their own. */
1392
+ const rematchVoters = (tx) => tx.record.seats.filter((s) => s.claim !== null && s.controller === "human").map((s) => s.id);
1393
+ /** Seats whose yes ends a match early: every other connected player. */
1394
+ const endVoters = (tx, by) => tx.record.seats.filter((s) => s.id !== by && s.claim !== null && s.controller === "human" && tx.hasMember(s.id)).map((s) => s.id);
1395
+ const setOffer = (tx, offer) => tx.set({
1396
+ ...tx.record,
1397
+ offer
1398
+ });
1399
+ /**
1400
+ * Acts on the offer once it has every yes it needs: a rematch starts (when this step carries
1401
+ * seeds), or the match ends. An offer outlives neither its match nor its proposer's seat.
1402
+ */
1403
+ const resolveOffer = (tx, seeds) => {
1404
+ const offer = tx.record.offer ?? null;
1405
+ if (offer === null) return null;
1406
+ const phase = tx.record.phase;
1407
+ if (offer.kind === "rematch" && phase !== "finished" || offer.kind === "end" && phase !== "playing") {
1408
+ setOffer(tx, null);
1409
+ return null;
1410
+ }
1411
+ if (offer.status !== "open") return null;
1412
+ const proposer = tx.seat(offer.by);
1413
+ if (proposer?.claim == null || proposer.controller !== "human") {
1414
+ setOffer(tx, {
1415
+ ...offer,
1416
+ status: "withdrawn"
1417
+ });
1418
+ tx.log("info", "offer_withdrawn", {
1419
+ kind: offer.kind,
1420
+ seat: offer.by,
1421
+ why: "seat"
1422
+ });
1423
+ return null;
1424
+ }
1425
+ if (offer.kind === "rematch") {
1426
+ if (seeds === null || !rematchVoters(tx).every((s) => offer.accepted.includes(s))) return null;
1427
+ return startMatch(tx, seeds, false);
1428
+ }
1429
+ if (!tx.frozen() && endVoters(tx, offer.by).every((s) => offer.accepted.includes(s))) tx.endMatch("agreed");
1430
+ return null;
1431
+ };
1432
+ const OFFER_LOG = {
1433
+ accept: "offer_accepted",
1434
+ decline: "offer_declined",
1435
+ withdraw: "offer_withdrawn"
1436
+ };
1437
+ /** Makes an offer, or answers the open one. */
1438
+ const offerControl = (tx, seat, ctl, seeds) => {
1439
+ const offer = tx.record.offer ?? null;
1440
+ const open = offer?.status === "open" ? offer : null;
1441
+ if (ctl.kind === "offer") {
1442
+ if (ctl.offer === "rematch" && tx.record.phase !== "finished") return wrongPhase("A rematch can be offered once the match is over.");
1443
+ if (ctl.offer === "end" && tx.record.phase !== "playing") return wrongPhase("Only a match in progress can be ended.");
1444
+ if (open !== null) {
1445
+ if (open.accepted.includes(seat)) return notAllowed("You have already agreed to that.");
1446
+ setOffer(tx, {
1447
+ ...open,
1448
+ accepted: [...open.accepted, seat]
1449
+ });
1450
+ tx.log("info", OFFER_LOG.accept, {
1451
+ kind: open.kind,
1452
+ seat
1453
+ });
1454
+ } else {
1455
+ const endsAt = ctl.offer === "end" ? tx.now + limits.endOfferMs : null;
1456
+ setOffer(tx, {
1457
+ id: tx.record.revision + 1,
1458
+ kind: ctl.offer,
1459
+ by: seat,
1460
+ status: "open",
1461
+ accepted: [seat],
1462
+ declinedBy: null,
1463
+ endsAt
1464
+ });
1465
+ tx.log("info", "offer_made", {
1466
+ kind: ctl.offer,
1467
+ seat
1468
+ });
1469
+ }
1470
+ return resolveOffer(tx, seeds);
1471
+ }
1472
+ if (open === null || open.id !== ctl.offer) return notAllowed("That offer is no longer open.");
1473
+ if (ctl.kind === "withdraw") {
1474
+ if (open.by !== seat) return notAllowed("Only the player who made the offer can withdraw it.");
1475
+ setOffer(tx, {
1476
+ ...open,
1477
+ status: "withdrawn"
1478
+ });
1479
+ } else if (open.by === seat) return notAllowed("You made this offer.");
1480
+ else if (ctl.kind === "decline") setOffer(tx, {
1481
+ ...open,
1482
+ status: "declined",
1483
+ declinedBy: seat
1484
+ });
1485
+ else {
1486
+ if (open.accepted.includes(seat)) return notAllowed("You have already accepted.");
1487
+ setOffer(tx, {
1488
+ ...open,
1489
+ accepted: [...open.accepted, seat]
1490
+ });
1491
+ }
1492
+ tx.log("info", OFFER_LOG[ctl.kind], {
1493
+ kind: open.kind,
1494
+ seat
1495
+ });
1496
+ return ctl.kind === "accept" ? resolveOffer(tx, seeds) : null;
1497
+ };
1498
+ const passHost = (tx, leaving) => {
1499
+ if (tx.record.host !== leaving) return;
1500
+ const connected = tx.hostCandidate(leaving);
1501
+ const claimed = tx.record.seats.find((s) => s.id !== leaving && s.claim !== null)?.id ?? null;
1502
+ tx.set({
1503
+ ...tx.record,
1504
+ host: connected ?? claimed
1505
+ });
1506
+ tx.log("info", "host_transferred", { seat: connected ?? claimed });
1507
+ };
1508
+ const control = (tx, command) => {
1509
+ const member = tx.members.get(command.conn);
1510
+ if (member === void 0) return void tx.log("warn", "unknown_connection", {});
1511
+ const { conn, id } = command;
1512
+ if (member.seat === null) return tx.reject(conn, id, {
1513
+ code: RejectionCode.spectator,
1514
+ message: "Spectators can watch but not play."
1515
+ }, null);
1516
+ const seat = member.seat;
1517
+ const print = fingerprint({
1518
+ expectedRevision: command.expectedRevision,
1519
+ control: command.control
1520
+ });
1521
+ if (!admit(tx, conn, id, seat, command.expectedRevision, print, false)) return;
1522
+ const [record, dirty, logs] = [
1523
+ tx.record,
1524
+ tx.dirty,
1525
+ tx.logs.length
1526
+ ];
1527
+ const problem = applyControl(tx, seat, command.control, command.seeds);
1528
+ if (problem !== null) {
1529
+ tx.record = record;
1530
+ tx.dirty = dirty;
1531
+ tx.logs.length = logs;
1532
+ return tx.reject(conn, id, problem, seat);
1533
+ }
1534
+ accept(tx, conn, id, tx.members.get(conn)?.seat ?? seat, print, tx.record.revision);
1535
+ };
1536
+ const applyControl = (tx, seat, ctl, seeds) => {
1537
+ const phase = tx.record.phase;
1538
+ const isHost = tx.record.host === seat;
1539
+ const before = tx.record.revision;
1540
+ switch (ctl.kind) {
1541
+ case "ready": {
1542
+ if (phase !== "lobby") return wrongPhase("Readiness is set in the lobby.");
1543
+ const current = tx.seat(seat);
1544
+ if (current === void 0) return notAllowed("You have no seat.");
1545
+ tx.setSeat({
1546
+ ...current,
1547
+ ready: ctl.ready
1548
+ });
1549
+ break;
1550
+ }
1551
+ case "settings": {
1552
+ if (!isHost) return notHost;
1553
+ if (phase !== "lobby") return wrongPhase("Settings change in the lobby only.");
1554
+ if (ctl.aiLevel !== void 0 && engine.ai !== void 0 && !engine.ai.levels.includes(ctl.aiLevel)) return {
1555
+ code: RejectionCode.badCommand,
1556
+ message: "That AI level does not exist."
1557
+ };
1558
+ const aiLevel = ctl.aiLevel ?? tx.record.aiLevel;
1559
+ let policy = tx.record.policy;
1560
+ if (ctl.decisionMs !== void 0) policy = {
1561
+ ...policy,
1562
+ decisionMs: ctl.decisionMs,
1563
+ ...ctl.decisionMs === null ? {} : { bank: null }
1564
+ };
1565
+ if (ctl.bank !== void 0) policy = {
1566
+ ...policy,
1567
+ bank: ctl.bank,
1568
+ ...ctl.bank === null ? {} : { decisionMs: null }
1569
+ };
1570
+ tx.set({
1571
+ ...tx.record,
1572
+ policy,
1573
+ config: ctl.config ?? tx.record.config,
1574
+ aiLevel,
1575
+ seats: ctl.aiLevel === void 0 ? tx.record.seats : tx.record.seats.map((s) => s.controller === "ai" ? {
1576
+ ...s,
1577
+ aiLevel
1578
+ } : s)
1579
+ });
1580
+ break;
1581
+ }
1582
+ case "fill-with-ai": {
1583
+ if (!isHost) return notHost;
1584
+ const target = tx.seat(ctl.seat);
1585
+ if (target === void 0) return notAllowed("There is no such seat.");
1586
+ if (phase === "lobby") {
1587
+ if (target.claim !== null || target.controller !== "human") return notAllowed("Only an open seat can be filled with the AI.");
1588
+ tx.setSeat({
1589
+ ...target,
1590
+ controller: "ai",
1591
+ aiLevel: tx.record.aiLevel ?? defaultLevel()
1592
+ });
1593
+ } else if (phase === "playing") {
1594
+ if (target.controller !== "human" || tx.hasMember(target.id)) return notAllowed("Only an absent player’s seat can be handed to the AI.");
1595
+ tx.setController(target.id, "ai");
1596
+ } else return wrongPhase("The match is over.");
1597
+ break;
1598
+ }
1599
+ case "remove-seat": {
1600
+ if (!isHost) return notHost;
1601
+ if (phase !== "lobby") return wrongPhase("Seats are removed in the lobby only.");
1602
+ const target = tx.seat(ctl.seat);
1603
+ if (target === void 0 || ctl.seat === seat) return notAllowed("You can’t remove that seat.");
1604
+ for (const c of tx.connsOf(target.id)) tx.drop(c, "left-room");
1605
+ tx.absentSince.delete(target.id);
1606
+ tx.setSeat({
1607
+ id: target.id,
1608
+ claim: null,
1609
+ controller: "human",
1610
+ aiLevel: null,
1611
+ ready: false
1612
+ });
1613
+ break;
1614
+ }
1615
+ case "start": {
1616
+ if (!isHost) return notHost;
1617
+ if (phase !== "lobby") return wrongPhase("The match has already started.");
1618
+ const filled = tx.record.seats.filter((s) => s.claim !== null || s.controller === "ai");
1619
+ if (filled.length < tx.record.policy.seats.min) return notAllowed(`At least ${tx.record.policy.seats.min} seats must be filled.`);
1620
+ if (filled.some((s) => s.controller === "human" && (!s.ready || !tx.hasMember(s.id)))) return notAllowed("Everyone must be connected and ready.");
1621
+ const problem = startMatch(tx, seeds, true);
1622
+ if (problem !== null) return problem;
1623
+ break;
1624
+ }
1625
+ case "offer":
1626
+ case "accept":
1627
+ case "decline":
1628
+ case "withdraw": {
1629
+ const problem = offerControl(tx, seat, ctl, seeds);
1630
+ if (problem !== null) return problem;
1631
+ break;
1632
+ }
1633
+ case "leave-seat": {
1634
+ const current = tx.seat(seat);
1635
+ if (current === void 0) return notAllowed("You have no seat.");
1636
+ if (phase === "playing") {
1637
+ const result = tx.apply({
1638
+ kind: "seat",
1639
+ seat,
1640
+ change: { type: "left" }
1641
+ });
1642
+ if (!result.ok) tx.log("warn", "seat_input_rejected", {
1643
+ seat,
1644
+ code: result.error.code
1645
+ });
1646
+ }
1647
+ const afterLeft = tx.seat(seat) ?? current;
1648
+ tx.setSeat({
1649
+ ...afterLeft,
1650
+ claim: null,
1651
+ ready: false,
1652
+ ...phase === "lobby" ? {
1653
+ controller: "human",
1654
+ aiLevel: null
1655
+ } : {}
1656
+ });
1657
+ for (const c of tx.connsOf(seat)) tx.drop(c, "left-room");
1658
+ if (phase === "playing") {
1659
+ if (tx.record.policy.absent === "ai") tx.setController(seat, "ai");
1660
+ else tx.absentSince.set(seat, tx.now - tx.record.policy.graceMs);
1661
+ } else tx.absentSince.delete(seat);
1662
+ passHost(tx, seat);
1663
+ const problem = resolveOffer(tx, seeds);
1664
+ if (problem !== null) tx.log("warn", "rematch_refused", { code: problem.code });
1665
+ break;
1666
+ }
1667
+ case "resign": {
1668
+ if (phase !== "playing") return wrongPhase("No match is in progress.");
1669
+ const result = tx.apply({
1670
+ kind: "resign",
1671
+ seat
1672
+ });
1673
+ if (!result.ok) return result.error;
1674
+ break;
1675
+ }
1676
+ }
1677
+ if (tx.record.revision === before) tx.bump();
1678
+ return null;
1679
+ };
1680
+ /** A heavy AI's answer (rule 7): applied only if that seat still waits on the same decision. */
1681
+ const aiChosen = (tx, command) => {
1682
+ const { seat, decisionRevision } = command;
1683
+ if (tx.aiInFlight.get(seat) === decisionRevision) tx.aiInFlight.delete(seat);
1684
+ const match = tx.record.match;
1685
+ if (!(match !== null && tx.playing() && tx.seat(seat)?.controller === "ai" && engine.pending(match.state).includes(seat) && (match.decisionRevision[seat] ?? 0) === decisionRevision)) return void tx.log("info", "ai_superseded", { seat });
1686
+ if (tx.frozen()) return void tx.log("info", "ai_deferred", { seat });
1687
+ if ("failure" in command) {
1688
+ const prior = tx.aiRetry.get(seat);
1689
+ const same = prior?.decisionRevision === decisionRevision ? prior : void 0;
1690
+ const failures = (same?.failures ?? 0) + 1;
1691
+ const faults = (same?.faults ?? 0) + (command.failure === "timeout" || command.failure === "crashed" ? 1 : 0);
1692
+ if (command.failure !== "failed" && faults < limits.aiAttempts) {
1693
+ const at = tx.now + Math.min(limits.aiRetryMaxMs, limits.aiRetryMs * 2 ** (failures - 1));
1694
+ tx.aiRetry.set(seat, {
1695
+ decisionRevision,
1696
+ at,
1697
+ failures,
1698
+ faults
1699
+ });
1700
+ tx.log("warn", "ai_retry", {
1701
+ seat,
1702
+ failure: command.failure,
1703
+ failures,
1704
+ at
1705
+ });
1706
+ return;
1707
+ }
1708
+ tx.aiRetry.delete(seat);
1709
+ tx.autoInputs += 1;
1710
+ tx.log("warn", "ai_failed", {
1711
+ seat,
1712
+ failure: command.failure,
1713
+ failures
1714
+ });
1715
+ tx.aiFallback(seat);
1716
+ return;
1717
+ }
1718
+ tx.aiRetry.delete(seat);
1719
+ tx.autoInputs += 1;
1720
+ const parsed = engine.schemas.command["~standard"].validate(command.command);
1721
+ if (parsed instanceof Promise || parsed.issues !== void 0) {
1722
+ tx.log("error", "ai_illegal_move", {
1723
+ seat,
1724
+ code: RejectionCode.badCommand
1725
+ });
1726
+ tx.aiFallback(seat);
1727
+ return;
1728
+ }
1729
+ tx.applyAi(seat, parsed.value);
1730
+ };
1731
+ const resume = (tx) => {
1732
+ for (const seat of tx.record.seats) if (seat.claim !== null) tx.absentSince.set(seat.id, tx.now);
1733
+ if (tx.record.match?.pausedAt === void 0) tx.extendClocks();
1734
+ for (const job of tx.record.match?.jobs ?? []) {
1735
+ const spec = engine.jobs?.[job.request.kind];
1736
+ tx.jobStartedAt.set(job.request.id, tx.now);
1737
+ tx.jobs.push({
1738
+ type: "start-job",
1739
+ jobId: job.request.id,
1740
+ kind: job.request.kind,
1741
+ payload: job.request.payload,
1742
+ timeoutMs: spec?.timeoutMs ?? 0
1743
+ });
1744
+ tx.log("info", "job_started", {
1745
+ kind: job.request.kind,
1746
+ job: job.request.id,
1747
+ resumed: true
1748
+ });
1749
+ }
1750
+ };
1751
+ const close = (tx, reason) => {
1752
+ tx.closed = true;
1753
+ if (reason === "unload") return;
1754
+ tx.set({
1755
+ ...tx.record,
1756
+ phase: "closed",
1757
+ revision: tx.record.revision + 1
1758
+ });
1759
+ for (const conn of [...tx.members.keys()]) {
1760
+ tx.send([conn], {
1761
+ v: 1,
1762
+ t: "closing",
1763
+ reason: "expired",
1764
+ retryAfterMs: null
1765
+ });
1766
+ tx.drop(conn, "room-closed");
1767
+ }
1768
+ tx.log("info", "room_expired", {});
1769
+ };
1770
+ const closedStep = (room, command) => {
1771
+ if (command.type !== "join" && command.type !== "act" && command.type !== "control" && command.type !== "resync") return {
1772
+ room,
1773
+ effects: []
1774
+ };
1775
+ return {
1776
+ room,
1777
+ effects: [{
1778
+ type: "send",
1779
+ to: [command.conn],
1780
+ message: {
1781
+ v: 1,
1782
+ t: "closing",
1783
+ reason: "closed",
1784
+ retryAfterMs: null
1785
+ }
1786
+ }, {
1787
+ type: "drop",
1788
+ conn: command.conn,
1789
+ reason: "room-closed"
1790
+ }]
1791
+ };
1792
+ };
1793
+ const step = (room, command, now) => {
1794
+ if (room.closed || room.record.phase === "closed") return closedStep(room, command);
1795
+ const tx = new Tx(room, now);
1796
+ try {
1797
+ if (command.type !== "resume" && command.type !== "close") tx.settle();
1798
+ switch (command.type) {
1799
+ case "join":
1800
+ join(tx, command);
1801
+ break;
1802
+ case "leave":
1803
+ leave(tx, command.conn);
1804
+ break;
1805
+ case "act":
1806
+ act(tx, command);
1807
+ break;
1808
+ case "control":
1809
+ control(tx, command);
1810
+ break;
1811
+ case "resync":
1812
+ if (tx.members.has(command.conn)) tx.snapshotTo.add(command.conn);
1813
+ break;
1814
+ case "timer-fired": break;
1815
+ case "ai-chosen":
1816
+ aiChosen(tx, command);
1817
+ break;
1818
+ case "job-completed":
1819
+ if (tx.record.match?.jobs.some((j) => j.request.id === command.jobId) !== true) tx.log("info", "job_superseded", { job: command.jobId });
1820
+ else tx.completeJob(command.jobId, command.outcome);
1821
+ break;
1822
+ case "resume":
1823
+ resume(tx);
1824
+ break;
1825
+ case "close": close(tx, command.reason);
1826
+ }
1827
+ if (!tx.closed) {
1828
+ resolveOffer(tx, null);
1829
+ tx.settle();
1830
+ tx.recomputeClocks();
1831
+ resolveOffer(tx, null);
1832
+ tx.syncPause();
1833
+ if (tx.record.revision === room.record.revision && canonicalJson(publicOffer(tx.record)) !== canonicalJson(publicOffer(room.record))) tx.bump();
1834
+ }
1835
+ return tx.finish();
1836
+ } catch (error) {
1837
+ const effects = [{
1838
+ type: "log",
1839
+ level: "error",
1840
+ event: "engine_error",
1841
+ fields: {
1842
+ room: room.record.code,
1843
+ command: command.type,
1844
+ error: String(error)
1845
+ }
1846
+ }];
1847
+ if (command.type === "act" || command.type === "control") effects.unshift({
1848
+ type: "send",
1849
+ to: [command.conn],
1850
+ message: {
1851
+ v: 1,
1852
+ t: "receipt",
1853
+ id: command.id,
1854
+ result: {
1855
+ status: "rejected",
1856
+ code: RejectionCode.engineError,
1857
+ message: "The game hit an error and ignored that move.",
1858
+ revision: room.record.revision
1859
+ }
1860
+ }
1861
+ });
1862
+ return {
1863
+ room,
1864
+ effects
1865
+ };
1866
+ }
1867
+ };
1868
+ return {
1869
+ engine,
1870
+ pin,
1871
+ create,
1872
+ load,
1873
+ step
1874
+ };
1875
+ };
1876
+ //#endregion
1877
+ export { createRoomCore };
1878
+
1879
+ //# sourceMappingURL=core.js.map