@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.
- package/CHANGELOG.md +38 -0
- package/README.md +147 -2
- package/dist/canonical.d.ts +10 -0
- package/dist/canonical.js +30 -0
- package/dist/canonical.js.map +1 -0
- package/dist/core.d.ts +3 -0
- package/dist/core.js +1879 -0
- package/dist/core.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +7 -0
- package/dist/names.d.ts +19 -0
- package/dist/names.js +28 -0
- package/dist/names.js.map +1 -0
- package/dist/node/host.d.ts +67 -0
- package/dist/node/host.js +323 -0
- package/dist/node/host.js.map +1 -0
- package/dist/node/index.d.ts +6 -0
- package/dist/node/index.js +7 -0
- package/dist/node/jobs.d.ts +48 -0
- package/dist/node/jobs.js +176 -0
- package/dist/node/jobs.js.map +1 -0
- package/dist/node/registry.d.ts +32 -0
- package/dist/node/registry.js +43 -0
- package/dist/node/registry.js.map +1 -0
- package/dist/node/server.d.ts +59 -0
- package/dist/node/server.js +577 -0
- package/dist/node/server.js.map +1 -0
- package/dist/node/store.d.ts +53 -0
- package/dist/node/store.js +245 -0
- package/dist/node/store.js.map +1 -0
- package/dist/node/support.d.ts +67 -0
- package/dist/node/support.js +126 -0
- package/dist/node/support.js.map +1 -0
- package/dist/policy.d.ts +15 -0
- package/dist/policy.js +41 -0
- package/dist/policy.js.map +1 -0
- package/dist/protocol/index.d.ts +43 -0
- package/dist/protocol/index.js +48 -0
- package/dist/protocol/index.js.map +1 -0
- package/dist/protocol/schemas.d.ts +792 -0
- package/dist/protocol/schemas.js +332 -0
- package/dist/protocol/schemas.js.map +1 -0
- package/dist/protocol/types.d.ts +279 -0
- package/dist/protocol/types.js +1 -0
- package/dist/record.d.ts +173 -0
- package/dist/record.js +181 -0
- package/dist/record.js.map +1 -0
- package/dist/types.d.ts +374 -0
- package/dist/types.js +15 -0
- package/dist/types.js.map +1 -0
- 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
|