@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
@@ -0,0 +1,323 @@
1
+ import "../protocol/schemas.js";
2
+ import { CloseCode, RejectionCode, encodeFor } from "../protocol/index.js";
3
+ import { expiresAt } from "./store.js";
4
+ //#region src/node/host.ts
5
+ var DROP_CODES = {
6
+ replaced: CloseCode.replaced,
7
+ "left-room": CloseCode.leftRoom,
8
+ "room-closed": CloseCode.noSuchRoom,
9
+ "room-full": CloseCode.policy
10
+ };
11
+ var RoomHost = class {
12
+ rooms = /* @__PURE__ */ new Map();
13
+ o;
14
+ constructor(options) {
15
+ this.o = options;
16
+ }
17
+ log(level, event, fields = {}) {
18
+ this.o.log.write(level, event, fields);
19
+ }
20
+ /** The live room, loading it from storage (and resuming it) on first use. */
21
+ load(code) {
22
+ const existing = this.rooms.get(code);
23
+ if (existing !== void 0) return { live: existing };
24
+ if (this.rooms.size >= this.o.maxRooms) return { problem: "busy" };
25
+ const record = this.o.store.load(code);
26
+ if (record === null || record.phase === "closed") return { problem: "missing" };
27
+ const pin = record.match?.game ?? record.game;
28
+ const core = this.o.registry.core(pin.id, pin.rulesVersion) ?? (record.match === null ? this.o.registry.current(pin.id)?.core ?? null : null);
29
+ if (core === null) {
30
+ this.log("error", "engine_missing", {
31
+ room: code,
32
+ game: pin.id,
33
+ rules: pin.rulesVersion
34
+ });
35
+ return { problem: "engine-missing" };
36
+ }
37
+ if (record.match !== null) {
38
+ const checked = core.engine.schemas.state["~standard"].validate(record.match.state);
39
+ if (checked instanceof Promise || checked.issues !== void 0) {
40
+ this.o.store.quarantine(code, "state");
41
+ return { problem: "missing" };
42
+ }
43
+ }
44
+ const live = {
45
+ room: core.load(record),
46
+ core,
47
+ timer: null,
48
+ conns: /* @__PURE__ */ new Map(),
49
+ idleSince: this.o.clock.now(),
50
+ retry: []
51
+ };
52
+ this.rooms.set(code, live);
53
+ this.log("info", "room_loaded", {
54
+ room: code,
55
+ game: pin.id,
56
+ rules: pin.rulesVersion
57
+ });
58
+ this.dispatch(code, { type: "resume" });
59
+ return { live };
60
+ }
61
+ /** Steps one command and carries out its effects: persist first, then everything else. */
62
+ dispatch(code, command) {
63
+ const live = this.rooms.get(code);
64
+ if (live === void 0) return;
65
+ let core = live.core;
66
+ const before = live.room;
67
+ const now = this.o.clock.now();
68
+ let step = null;
69
+ const newest = this.o.registry.current(before.record.game.id)?.core;
70
+ if (command.type === "control" && before.record.phase !== "playing" && newest !== void 0 && newest !== core) {
71
+ const upgraded = newest.step(before, command, now);
72
+ if (upgraded.room.record.match?.number !== before.record.match?.number) {
73
+ core = newest;
74
+ step = upgraded;
75
+ }
76
+ }
77
+ step ??= core.step(before, command, now);
78
+ const first = step.effects[0];
79
+ if (first?.type === "persist") {
80
+ let saved;
81
+ try {
82
+ saved = this.o.store.save(first.record, first.inputs, before.record.writes);
83
+ } catch (error) {
84
+ this.log("error", "persist_failed", {
85
+ room: code,
86
+ revision: first.record.revision,
87
+ error: String(error)
88
+ });
89
+ this.storageUnavailable(live, command);
90
+ this.armRetry(code, live);
91
+ return;
92
+ }
93
+ if (saved === "conflict") {
94
+ this.log("error", "write_conflict", { room: code });
95
+ this.unload(code, "conflict");
96
+ return;
97
+ }
98
+ }
99
+ live.room = step.room;
100
+ if (step.room.record.match?.number !== before.record.match?.number) live.core = core;
101
+ for (const effect of step.effects) if (effect.type !== "persist") this.perform(code, live, effect);
102
+ }
103
+ /** The old state stays; tell whoever asked, and keep what must not be lost for the retry. */
104
+ storageUnavailable(live, command) {
105
+ switch (command.type) {
106
+ case "act":
107
+ case "control": {
108
+ const message = {
109
+ v: 1,
110
+ t: "receipt",
111
+ id: command.id,
112
+ result: {
113
+ status: "rejected",
114
+ code: RejectionCode.storageUnavailable,
115
+ message: "The table could not be saved. Try again.",
116
+ revision: live.room.record.revision
117
+ }
118
+ };
119
+ this.sendTo(live, [command.conn], message);
120
+ break;
121
+ }
122
+ case "join": {
123
+ const conn = live.conns.get(command.conn);
124
+ live.conns.delete(command.conn);
125
+ conn?.close(CloseCode.tryAgainLater, "storage unavailable");
126
+ break;
127
+ }
128
+ case "leave":
129
+ case "ai-chosen":
130
+ case "resume":
131
+ case "job-completed": live.retry.push(command);
132
+ }
133
+ }
134
+ armRetry(code, live) {
135
+ live.timer?.cancel();
136
+ live.timer = this.o.clock.setTimer(this.o.clock.now() + this.o.persistRetryMs, () => {
137
+ live.timer = null;
138
+ const queued = live.retry.splice(0);
139
+ for (const command of queued) this.dispatch(code, command);
140
+ this.dispatch(code, { type: "timer-fired" });
141
+ });
142
+ live.room = {
143
+ ...live.room,
144
+ alarmAt: null
145
+ };
146
+ }
147
+ sendTo(live, to, message) {
148
+ const encoded = /* @__PURE__ */ new Map();
149
+ for (const id of to) {
150
+ const conn = live.conns.get(id);
151
+ if (conn === void 0) continue;
152
+ if (conn.bufferedAmount > this.o.outgoingBufferBytes) {
153
+ conn.close(CloseCode.tryAgainLater, "connection too slow");
154
+ continue;
155
+ }
156
+ let text = encoded.get(conn.protocol);
157
+ if (text === void 0) {
158
+ text = JSON.stringify(encodeFor(message, conn.protocol));
159
+ encoded.set(conn.protocol, text);
160
+ }
161
+ conn.send(text);
162
+ }
163
+ }
164
+ perform(code, live, effect) {
165
+ switch (effect.type) {
166
+ case "persist": return;
167
+ case "welcome": {
168
+ const conn = live.conns.get(effect.conn);
169
+ if (conn === void 0) return;
170
+ const token = effect.issueToken ? conn.pendingToken : null;
171
+ conn.pendingToken = null;
172
+ this.sendTo(live, [effect.conn], token === null ? effect.message : {
173
+ ...effect.message,
174
+ token
175
+ });
176
+ return;
177
+ }
178
+ case "send":
179
+ this.sendTo(live, effect.to, effect.message);
180
+ return;
181
+ case "drop": {
182
+ const conn = live.conns.get(effect.conn);
183
+ live.conns.delete(effect.conn);
184
+ conn?.close(DROP_CODES[effect.reason], effect.reason);
185
+ if (live.conns.size === 0) live.idleSince = this.o.clock.now();
186
+ return;
187
+ }
188
+ case "alarm":
189
+ if (live.retry.length > 0) {
190
+ this.armRetry(code, live);
191
+ return;
192
+ }
193
+ live.timer?.cancel();
194
+ live.timer = effect.at === null ? null : this.o.clock.setTimer(effect.at, () => this.dispatch(code, { type: "timer-fired" }));
195
+ return;
196
+ case "start-job":
197
+ this.startJob(code, live, effect);
198
+ return;
199
+ case "start-ai":
200
+ this.startAi(code, live, effect);
201
+ return;
202
+ case "log":
203
+ this.o.log.write(effect.level, effect.event, {
204
+ ...effect.fields,
205
+ game: live.core.pin.id,
206
+ rules: live.core.pin.rulesVersion
207
+ });
208
+ return;
209
+ }
210
+ }
211
+ startJob(code, live, effect) {
212
+ if (this.o.draining()) return;
213
+ const started = this.o.clock.now();
214
+ const spec = live.core.engine.jobs?.[effect.kind];
215
+ this.o.pool.run({
216
+ room: code,
217
+ jobId: effect.jobId,
218
+ gameId: live.core.pin.id,
219
+ rulesVersion: live.core.pin.rulesVersion,
220
+ kind: effect.kind,
221
+ payload: effect.payload,
222
+ timeoutMs: effect.timeoutMs
223
+ }).then((outcome) => {
224
+ if (!outcome.ok) return {
225
+ ok: false,
226
+ reason: outcome.reason === "timeout" ? "timeout" : "failed"
227
+ };
228
+ if (spec === void 0) return outcome;
229
+ const checked = spec.result["~standard"].validate(outcome.value);
230
+ if (checked instanceof Promise || checked.issues !== void 0) return {
231
+ ok: false,
232
+ reason: "failed"
233
+ };
234
+ return outcome;
235
+ }).then((outcome) => {
236
+ this.log("info", "job_finished", {
237
+ room: code,
238
+ kind: effect.kind,
239
+ ok: outcome.ok,
240
+ ms: this.o.clock.now() - started
241
+ });
242
+ if (this.rooms.get(code) === live) this.dispatch(code, {
243
+ type: "job-completed",
244
+ jobId: effect.jobId,
245
+ outcome
246
+ });
247
+ });
248
+ }
249
+ /** Heavy AI on the pool, through the game's `jobs/ai.worker.ts`; a failure answers null and says why. */
250
+ startAi(code, live, effect) {
251
+ if (this.o.draining()) return;
252
+ const started = this.o.clock.now();
253
+ this.o.pool.run({
254
+ room: code,
255
+ jobId: `ai-${effect.seat}-${effect.decisionRevision}`,
256
+ gameId: live.core.pin.id,
257
+ rulesVersion: live.core.pin.rulesVersion,
258
+ kind: "ai",
259
+ payload: {
260
+ view: effect.view,
261
+ seat: effect.seat,
262
+ level: effect.level,
263
+ rng: effect.rng
264
+ },
265
+ timeoutMs: this.o.aiTimeoutMs,
266
+ answer: "value"
267
+ }).then((outcome) => {
268
+ this.log("info", "ai_finished", {
269
+ room: code,
270
+ seat: effect.seat,
271
+ ok: outcome.ok,
272
+ ...outcome.ok ? {} : { failure: outcome.reason },
273
+ ms: this.o.clock.now() - started
274
+ });
275
+ if (this.rooms.get(code) !== live) return;
276
+ const { seat, decisionRevision } = effect;
277
+ this.dispatch(code, outcome.ok ? {
278
+ type: "ai-chosen",
279
+ seat,
280
+ decisionRevision,
281
+ command: outcome.value
282
+ } : {
283
+ type: "ai-chosen",
284
+ seat,
285
+ decisionRevision,
286
+ command: null,
287
+ failure: outcome.reason
288
+ });
289
+ });
290
+ }
291
+ /** Forgets a room: no further callback may write it (the core marks it closed). */
292
+ unload(code, reason) {
293
+ const live = this.rooms.get(code);
294
+ if (live === void 0) return;
295
+ live.timer?.cancel();
296
+ live.timer = null;
297
+ live.room = live.core.step(live.room, {
298
+ type: "close",
299
+ reason: "unload"
300
+ }, this.o.clock.now()).room;
301
+ this.rooms.delete(code);
302
+ if (reason === "conflict") for (const conn of live.conns.values()) conn.close(CloseCode.tryAgainLater, "room moved");
303
+ this.log("info", "room_unloaded", {
304
+ room: code,
305
+ reason
306
+ });
307
+ }
308
+ /** Unloads idle rooms and closes expired ones. */
309
+ sweep(idleMs) {
310
+ const now = this.o.clock.now();
311
+ for (const [code, live] of [...this.rooms]) if (now >= expiresAt(live.room.record) && live.room.record.phase !== "closed") {
312
+ this.dispatch(code, {
313
+ type: "close",
314
+ reason: "expired"
315
+ });
316
+ this.unload(code, "expired");
317
+ } else if (live.conns.size === 0 && now - live.idleSince >= idleMs) this.unload(code, "idle");
318
+ }
319
+ };
320
+ //#endregion
321
+ export { RoomHost };
322
+
323
+ //# sourceMappingURL=host.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"host.js","names":[],"sources":["../../src/node/host.ts"],"sourcesContent":["// The room host: live rooms in memory, `dispatch` (room-server.md section 4.2) and the code\n// that carries out each effect. It knows nothing about HTTP or `ws`; a connection is anything\n// that can send text and close.\nimport type { JobOutcome } from '@joystyk/contracts';\nimport { CloseCode, encodeFor, RejectionCode, PROTOCOL_VERSION, type ServerMessage } from '../protocol/index.ts';\nimport type { ConnId, DropReason, LiveRoom, RoomCode, RoomCommand, RoomCore, RoomEffect, Step } from '../types.ts';\nimport type { JobPool } from './jobs.ts';\nimport type { EngineRegistry } from './registry.ts';\nimport { expiresAt, type RoomStore } from './store.ts';\nimport type { Clock, Logger } from './support.ts';\n\nexport interface Connection {\n readonly id: ConnId;\n /** The wire version this connection joined with. */\n protocol: number;\n /** The fresh token generated for this connection's join; revealed only if the core issues a seat. */\n pendingToken: string | null;\n readonly bufferedAmount: number;\n send(text: string): void;\n close(code: number, reason: string): void;\n}\n\ntype Live = {\n room: LiveRoom;\n core: RoomCore;\n timer: { cancel(): void } | null;\n readonly conns: Map<ConnId, Connection>;\n idleSince: number;\n /** Commands whose persist failed and that must not be lost: retried before the next timer. */\n retry: RoomCommand[];\n};\n\nexport type HostOptions = {\n readonly store: RoomStore;\n readonly registry: EngineRegistry;\n readonly clock: Clock;\n readonly pool: JobPool;\n readonly log: Logger;\n readonly maxRooms: number;\n readonly outgoingBufferBytes: number;\n readonly persistRetryMs: number;\n /** How long a heavy AI may choose (ROOMS_AI_TIMEOUT_MS). */\n readonly aiTimeoutMs: number;\n readonly draining: () => boolean;\n};\n\nconst DROP_CODES: Record<DropReason, number> = {\n replaced: CloseCode.replaced,\n 'left-room': CloseCode.leftRoom,\n 'room-closed': CloseCode.noSuchRoom,\n 'room-full': CloseCode.policy,\n};\n\nexport type LoadResult = { readonly live: Live } | { readonly problem: 'missing' | 'busy' | 'engine-missing' };\n\nexport class RoomHost {\n readonly rooms = new Map<RoomCode, Live>();\n private readonly o: HostOptions;\n\n constructor(options: HostOptions) {\n this.o = options;\n }\n\n private log(\n level: 'info' | 'warn' | 'error',\n event: string,\n fields: { readonly [k: string]: string | number | boolean | null } = {},\n ): void {\n this.o.log.write(level, event, fields);\n }\n\n /** The live room, loading it from storage (and resuming it) on first use. */\n load(code: RoomCode): LoadResult {\n const existing = this.rooms.get(code);\n if (existing !== undefined) return { live: existing };\n if (this.rooms.size >= this.o.maxRooms) return { problem: 'busy' };\n const record = this.o.store.load(code);\n if (record === null || record.phase === 'closed') return { problem: 'missing' };\n const pin = record.match?.game ?? record.game;\n const core =\n this.o.registry.core(pin.id, pin.rulesVersion) ??\n (record.match === null ? (this.o.registry.current(pin.id)?.core ?? null) : null);\n if (core === null) {\n this.log('error', 'engine_missing', { room: code, game: pin.id, rules: pin.rulesVersion });\n return { problem: 'engine-missing' };\n }\n if (record.match !== null) {\n const checked = core.engine.schemas.state['~standard'].validate(record.match.state);\n if (checked instanceof Promise || checked.issues !== undefined) {\n this.o.store.quarantine(code, 'state');\n return { problem: 'missing' };\n }\n }\n const live: Live = {\n room: core.load(record),\n core,\n timer: null,\n conns: new Map(),\n idleSince: this.o.clock.now(),\n retry: [],\n };\n this.rooms.set(code, live);\n this.log('info', 'room_loaded', { room: code, game: pin.id, rules: pin.rulesVersion });\n this.dispatch(code, { type: 'resume' });\n return { live };\n }\n\n /** Steps one command and carries out its effects: persist first, then everything else. */\n dispatch(code: RoomCode, command: RoomCommand): void {\n const live = this.rooms.get(code);\n if (live === undefined) return;\n let core = live.core;\n const before = live.room;\n const now = this.o.clock.now();\n let step: Step | null = null;\n const newest = this.o.registry.current(before.record.game.id)?.core;\n if (command.type === 'control' && before.record.phase !== 'playing' && newest !== undefined && newest !== core) {\n // A new match runs on the newest engine: a start, or the answer that completes a rematch\n // offer, is the upgrade point (section 6.1). Steps are pure, so a step on the newest core\n // that starts no match is dropped and the room's own core takes the command.\n const upgraded = newest.step(before, command, now);\n if (upgraded.room.record.match?.number !== before.record.match?.number) {\n core = newest;\n step = upgraded;\n }\n }\n step ??= core.step(before, command, now);\n const first = step.effects[0];\n if (first?.type === 'persist') {\n let saved: 'saved' | 'conflict';\n try {\n saved = this.o.store.save(first.record, first.inputs, before.record.writes);\n } catch (error) {\n this.log('error', 'persist_failed', { room: code, revision: first.record.revision, error: String(error) });\n this.storageUnavailable(live, command);\n this.armRetry(code, live);\n return;\n }\n if (saved === 'conflict') {\n this.log('error', 'write_conflict', { room: code });\n this.unload(code, 'conflict');\n return;\n }\n }\n live.room = step.room;\n if (step.room.record.match?.number !== before.record.match?.number) live.core = core;\n for (const effect of step.effects) if (effect.type !== 'persist') this.perform(code, live, effect);\n }\n\n /** The old state stays; tell whoever asked, and keep what must not be lost for the retry. */\n private storageUnavailable(live: Live, command: RoomCommand): void {\n switch (command.type) {\n case 'act':\n case 'control': {\n const message: ServerMessage = {\n v: PROTOCOL_VERSION,\n t: 'receipt',\n id: command.id,\n result: {\n status: 'rejected',\n code: RejectionCode.storageUnavailable,\n message: 'The table could not be saved. Try again.',\n revision: live.room.record.revision,\n },\n };\n this.sendTo(live, [command.conn], message);\n break;\n }\n case 'join': {\n const conn = live.conns.get(command.conn);\n live.conns.delete(command.conn);\n conn?.close(CloseCode.tryAgainLater, 'storage unavailable');\n break;\n }\n case 'leave':\n case 'ai-chosen':\n case 'resume':\n case 'job-completed':\n live.retry.push(command);\n break;\n default:\n break;\n }\n }\n\n private armRetry(code: RoomCode, live: Live): void {\n live.timer?.cancel();\n live.timer = this.o.clock.setTimer(this.o.clock.now() + this.o.persistRetryMs, () => {\n live.timer = null;\n const queued = live.retry.splice(0);\n for (const command of queued) this.dispatch(code, command);\n this.dispatch(code, { type: 'timer-fired' });\n });\n // The core will ask for its alarm again once a step succeeds.\n live.room = { ...live.room, alarmAt: null };\n }\n\n private sendTo(live: Live, to: readonly ConnId[], message: ServerMessage): void {\n const encoded = new Map<number, string>();\n for (const id of to) {\n const conn = live.conns.get(id);\n if (conn === undefined) continue;\n if (conn.bufferedAmount > this.o.outgoingBufferBytes) {\n conn.close(CloseCode.tryAgainLater, 'connection too slow');\n continue;\n }\n let text = encoded.get(conn.protocol);\n if (text === undefined) {\n text = JSON.stringify(encodeFor(message, conn.protocol));\n encoded.set(conn.protocol, text);\n }\n conn.send(text);\n }\n }\n\n private perform(code: RoomCode, live: Live, effect: RoomEffect): void {\n switch (effect.type) {\n case 'persist':\n return;\n case 'welcome': {\n const conn = live.conns.get(effect.conn);\n if (conn === undefined) return;\n const token = effect.issueToken ? conn.pendingToken : null;\n conn.pendingToken = null;\n this.sendTo(live, [effect.conn], token === null ? effect.message : { ...effect.message, token });\n return;\n }\n case 'send':\n this.sendTo(live, effect.to, effect.message);\n return;\n case 'drop': {\n const conn = live.conns.get(effect.conn);\n live.conns.delete(effect.conn);\n conn?.close(DROP_CODES[effect.reason], effect.reason);\n if (live.conns.size === 0) live.idleSince = this.o.clock.now();\n return;\n }\n case 'alarm':\n if (live.retry.length > 0) {\n this.armRetry(code, live);\n return;\n }\n live.timer?.cancel();\n live.timer =\n effect.at === null\n ? null\n : this.o.clock.setTimer(effect.at, () => this.dispatch(code, { type: 'timer-fired' }));\n return;\n case 'start-job':\n this.startJob(code, live, effect);\n return;\n case 'start-ai':\n this.startAi(code, live, effect);\n return;\n case 'log':\n this.o.log.write(effect.level, effect.event, {\n ...effect.fields,\n game: live.core.pin.id,\n rules: live.core.pin.rulesVersion,\n });\n return;\n }\n }\n\n private startJob(code: RoomCode, live: Live, effect: Extract<RoomEffect, { type: 'start-job' }>): void {\n // While draining, unstarted jobs stay in match.jobs and re-run after the restart.\n if (this.o.draining()) return;\n const started = this.o.clock.now();\n const spec = live.core.engine.jobs?.[effect.kind];\n void this.o.pool\n .run({\n room: code,\n jobId: effect.jobId,\n gameId: live.core.pin.id,\n rulesVersion: live.core.pin.rulesVersion,\n kind: effect.kind,\n payload: effect.payload,\n timeoutMs: effect.timeoutMs,\n })\n .then((outcome): JobOutcome => {\n // An engine sees only the contract's two failures.\n if (!outcome.ok) return { ok: false, reason: outcome.reason === 'timeout' ? 'timeout' : 'failed' };\n if (spec === undefined) return outcome;\n const checked = spec.result['~standard'].validate(outcome.value);\n if (checked instanceof Promise || checked.issues !== undefined) return { ok: false, reason: 'failed' };\n return outcome;\n })\n .then((outcome) => {\n this.log('info', 'job_finished', {\n room: code,\n kind: effect.kind,\n ok: outcome.ok,\n ms: this.o.clock.now() - started,\n });\n if (this.rooms.get(code) === live) this.dispatch(code, { type: 'job-completed', jobId: effect.jobId, outcome });\n });\n }\n\n /** Heavy AI on the pool, through the game's `jobs/ai.worker.ts`; a failure answers null and says why. */\n private startAi(code: RoomCode, live: Live, effect: Extract<RoomEffect, { type: 'start-ai' }>): void {\n if (this.o.draining()) return;\n const started = this.o.clock.now();\n void this.o.pool\n .run({\n room: code,\n jobId: `ai-${effect.seat}-${effect.decisionRevision}`,\n gameId: live.core.pin.id,\n rulesVersion: live.core.pin.rulesVersion,\n kind: 'ai',\n payload: { view: effect.view, seat: effect.seat, level: effect.level, rng: effect.rng },\n timeoutMs: this.o.aiTimeoutMs,\n answer: 'value',\n })\n .then((outcome) => {\n this.log('info', 'ai_finished', {\n room: code,\n seat: effect.seat,\n ok: outcome.ok,\n ...(outcome.ok ? {} : { failure: outcome.reason }),\n ms: this.o.clock.now() - started,\n });\n if (this.rooms.get(code) !== live) return;\n const { seat, decisionRevision } = effect;\n this.dispatch(\n code,\n outcome.ok\n ? { type: 'ai-chosen', seat, decisionRevision, command: outcome.value }\n : { type: 'ai-chosen', seat, decisionRevision, command: null, failure: outcome.reason },\n );\n });\n }\n\n /** Forgets a room: no further callback may write it (the core marks it closed). */\n unload(code: RoomCode, reason: 'idle' | 'conflict' | 'drain' | 'expired'): void {\n const live = this.rooms.get(code);\n if (live === undefined) return;\n live.timer?.cancel();\n live.timer = null;\n live.room = live.core.step(live.room, { type: 'close', reason: 'unload' }, this.o.clock.now()).room;\n this.rooms.delete(code);\n if (reason === 'conflict')\n for (const conn of live.conns.values()) conn.close(CloseCode.tryAgainLater, 'room moved');\n this.log('info', 'room_unloaded', { room: code, reason });\n }\n\n /** Unloads idle rooms and closes expired ones. */\n sweep(idleMs: number): void {\n const now = this.o.clock.now();\n for (const [code, live] of [...this.rooms]) {\n if (now >= expiresAt(live.room.record) && live.room.record.phase !== 'closed') {\n this.dispatch(code, { type: 'close', reason: 'expired' });\n this.unload(code, 'expired');\n } else if (live.conns.size === 0 && now - live.idleSince >= idleMs) {\n this.unload(code, 'idle');\n }\n }\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,6 @@
1
+ export { RoomHost, type Connection, type HostOptions, type LoadResult } from './host.js';
2
+ export { inlineJobPool, workerJobPool, type JobPool, type PoolJob, type PoolOutcome, type WorkerPoolOptions, } from './jobs.js';
3
+ export { createEngineRegistry, type CurrentEngine, type EngineRegistry, type RegisteredEngine } from './registry.js';
4
+ export { createRoomServer, DEFAULT_SERVER_LIMITS, type RoomServer, type RoomServerOptions, type ServerLimits, } from './server.js';
5
+ export { expiresAt, MIGRATIONS, openSqliteStore, SCHEMA_VERSION, type RoomStore, type SqliteStoreOptions, } from './store.js';
6
+ export { clientAddress, createLogger, hashToken, memoryLogger, newRoomCode, newSeatToken, parseTrustedProxy, randomWords, systemClock, TokenBucket, WindowCounter, type Clock, type Logger, type LogLevel, } from './support.js';
@@ -0,0 +1,7 @@
1
+ import { MIGRATIONS, SCHEMA_VERSION, expiresAt, openSqliteStore } from "./store.js";
2
+ import { RoomHost } from "./host.js";
3
+ import { inlineJobPool, workerJobPool } from "./jobs.js";
4
+ import { createEngineRegistry } from "./registry.js";
5
+ import { TokenBucket, WindowCounter, clientAddress, createLogger, hashToken, memoryLogger, newRoomCode, newSeatToken, parseTrustedProxy, randomWords, systemClock } from "./support.js";
6
+ import { DEFAULT_SERVER_LIMITS, createRoomServer } from "./server.js";
7
+ export { DEFAULT_SERVER_LIMITS, MIGRATIONS, RoomHost, SCHEMA_VERSION, TokenBucket, WindowCounter, clientAddress, createEngineRegistry, createLogger, createRoomServer, expiresAt, hashToken, inlineJobPool, memoryLogger, newRoomCode, newSeatToken, openSqliteStore, parseTrustedProxy, randomWords, systemClock, workerJobPool };
@@ -0,0 +1,48 @@
1
+ import type { JobOutcome, Json } from '@joystyk/contracts';
2
+ import type { AiFailure, RoomCode } from '../types.js';
3
+ import type { Logger } from './support.js';
4
+ export type PoolJob = {
5
+ readonly room: RoomCode;
6
+ readonly jobId: string;
7
+ readonly gameId: string;
8
+ /** A room always supplies its pin; omitted internal probes use the current engine. */
9
+ readonly rulesVersion?: number;
10
+ readonly kind: string;
11
+ readonly payload: Json;
12
+ readonly timeoutMs: number;
13
+ /**
14
+ * 'outcome' (default): the module answers with a JobOutcome. 'value': it answers with a
15
+ * plain value (a heavy AI's command), which the pool wraps as { ok: true, value }.
16
+ */
17
+ readonly answer?: 'outcome' | 'value';
18
+ };
19
+ /** A job's outcome, or why the pool could not run it to the end (see `AiFailure`). */
20
+ export type PoolOutcome = JobOutcome | {
21
+ readonly ok: false;
22
+ readonly reason: AiFailure;
23
+ };
24
+ export interface JobPool {
25
+ /** Never rejects: failures come back as { ok: false }. */
26
+ run(job: PoolJob): Promise<PoolOutcome>;
27
+ readonly inFlight: number;
28
+ /** Resolves true once nothing is running, or false after `timeoutMs`. */
29
+ idle(timeoutMs: number): Promise<boolean>;
30
+ close(): Promise<void>;
31
+ }
32
+ /** Runs jobs in the main thread through a handler. For tests and development fixtures. */
33
+ export declare const inlineJobPool: (handler: (job: PoolJob) => PoolOutcome | Promise<PoolOutcome>) => JobPool;
34
+ export type WorkerPoolOptions = {
35
+ /** Worker threads (ROOMS_JOB_WORKERS). */
36
+ readonly workers: number;
37
+ /** The entry module URL for a game's job kind, or null when this build has none. */
38
+ readonly module: (gameId: string, kind: string, rulesVersion?: number) => URL | null;
39
+ /** Queued or running jobs per worker. */
40
+ readonly perWorker?: number;
41
+ readonly log?: Logger;
42
+ };
43
+ /**
44
+ * Greed's physics pool, generalized. A full pool refuses a job at once (`busy`). A timeout or
45
+ * crash terminates that worker and starts a fresh one; the job it was running fails with
46
+ * `timeout` or `crashed`, and every job queued behind it with `lost`.
47
+ */
48
+ export declare const workerJobPool: (options: WorkerPoolOptions) => JobPool;
@@ -0,0 +1,176 @@
1
+ import { Worker } from "node:worker_threads";
2
+ //#region src/node/jobs.ts
3
+ /** Shared bookkeeping for `inFlight` and `idle`. */
4
+ var Tracker = class {
5
+ inFlight = 0;
6
+ waiters = [];
7
+ track(work) {
8
+ this.inFlight += 1;
9
+ return work.finally(() => {
10
+ this.inFlight -= 1;
11
+ if (this.inFlight === 0) for (const wake of this.waiters.splice(0)) wake();
12
+ });
13
+ }
14
+ idle(timeoutMs) {
15
+ if (this.inFlight === 0) return Promise.resolve(true);
16
+ return new Promise((resolve) => {
17
+ const timer = setTimeout(() => resolve(false), timeoutMs);
18
+ this.waiters.push(() => {
19
+ clearTimeout(timer);
20
+ resolve(true);
21
+ });
22
+ });
23
+ }
24
+ };
25
+ /** Runs jobs in the main thread through a handler. For tests and development fixtures. */
26
+ var inlineJobPool = (handler) => {
27
+ const tracker = new Tracker();
28
+ return {
29
+ run: (job) => tracker.track(Promise.resolve().then(() => handler(job)).catch(() => ({
30
+ ok: false,
31
+ reason: "failed"
32
+ }))),
33
+ get inFlight() {
34
+ return tracker.inFlight;
35
+ },
36
+ idle: (timeoutMs) => tracker.idle(timeoutMs),
37
+ close: async () => {}
38
+ };
39
+ };
40
+ /**
41
+ * The worker host. It imports a job's entry module (a game's `jobs/<kind>.worker.ts`, whose
42
+ * default export maps a payload to a JobOutcome, or `jobs/ai.worker.ts`, which maps
43
+ * `{ view, seat, level, rng }` to a command) and answers each request. Jobs run one at a
44
+ * time, in order, and each says when it starts, so its timeout never includes its wait in the
45
+ * queue. Written as an eval'd script so it works the same from source and from a bundle.
46
+ */
47
+ var HOST = `
48
+ const { parentPort } = require('node:worker_threads');
49
+ const modules = new Map();
50
+ let queue = Promise.resolve();
51
+ const run = async ({ job, module, payload, answer }) => {
52
+ parentPort.postMessage({ job, started: true });
53
+ try {
54
+ let loaded = modules.get(module);
55
+ if (loaded === undefined) { loaded = await import(module); modules.set(module, loaded); }
56
+ const result = await loaded.default(payload);
57
+ parentPort.postMessage({ job, outcome: answer === 'value' ? { ok: true, value: result } : result });
58
+ } catch (error) {
59
+ parentPort.postMessage({ job, error: String(error) });
60
+ }
61
+ };
62
+ parentPort.on('message', (message) => { queue = queue.then(() => run(message)); });
63
+ `;
64
+ /**
65
+ * Greed's physics pool, generalized. A full pool refuses a job at once (`busy`). A timeout or
66
+ * crash terminates that worker and starts a fresh one; the job it was running fails with
67
+ * `timeout` or `crashed`, and every job queued behind it with `lost`.
68
+ */
69
+ var workerJobPool = (options) => {
70
+ const perWorker = options.perWorker ?? 16;
71
+ const tracker = new Tracker();
72
+ let sequence = 0;
73
+ let stopped = false;
74
+ const slots = [];
75
+ const spawn = (slot) => {
76
+ const worker = new Worker(HOST, { eval: true });
77
+ worker.unref();
78
+ const target = slot ?? {
79
+ worker,
80
+ pending: /* @__PURE__ */ new Map(),
81
+ running: null
82
+ };
83
+ target.worker = worker;
84
+ target.running = null;
85
+ worker.on("message", (data) => {
86
+ if (target.worker !== worker) return;
87
+ const pending = target.pending.get(data.job);
88
+ if (pending === void 0) return;
89
+ if (data.started === true) {
90
+ target.running = data.job;
91
+ pending.timer = setTimeout(() => restart(target, worker, "timeout"), pending.timeoutMs);
92
+ return;
93
+ }
94
+ if (pending.timer !== null) clearTimeout(pending.timer);
95
+ target.pending.delete(data.job);
96
+ if (target.running === data.job) target.running = null;
97
+ if (data.error !== void 0) options.log?.write("warn", "job_failed", { error: data.error });
98
+ pending.resolve(data.outcome ?? {
99
+ ok: false,
100
+ reason: "failed"
101
+ });
102
+ });
103
+ worker.on("error", () => restart(target, worker, "crashed"));
104
+ worker.on("exit", () => restart(target, worker, "crashed"));
105
+ return target;
106
+ };
107
+ const restart = (slot, worker, reason) => {
108
+ if (stopped || slot.worker !== worker) return;
109
+ for (const [job, pending] of slot.pending) {
110
+ if (pending.timer !== null) clearTimeout(pending.timer);
111
+ pending.resolve({
112
+ ok: false,
113
+ reason: job === slot.running ? reason : "lost"
114
+ });
115
+ }
116
+ slot.pending.clear();
117
+ spawn(slot);
118
+ worker.terminate();
119
+ };
120
+ for (let i = 0; i < Math.max(1, options.workers); i += 1) slots.push(spawn());
121
+ const run = (job) => {
122
+ const module = options.module(job.gameId, job.kind, job.rulesVersion);
123
+ const slot = [...slots].sort((a, b) => a.pending.size - b.pending.size)[0];
124
+ if (stopped || module === null || slot === void 0 || slot.pending.size >= perWorker) {
125
+ options.log?.write("warn", module === null ? "job_module_missing" : "job_rejected_busy", {
126
+ room: job.room,
127
+ kind: job.kind
128
+ });
129
+ return Promise.resolve({
130
+ ok: false,
131
+ reason: module === null || stopped ? "failed" : "busy"
132
+ });
133
+ }
134
+ sequence += 1;
135
+ const id = sequence;
136
+ const worker = slot.worker;
137
+ return tracker.track(new Promise((resolve) => {
138
+ slot.pending.set(id, {
139
+ resolve,
140
+ timeoutMs: job.timeoutMs,
141
+ timer: null
142
+ });
143
+ worker.postMessage({
144
+ job: id,
145
+ module: module.href,
146
+ payload: job.payload,
147
+ answer: job.answer ?? "outcome"
148
+ });
149
+ }));
150
+ };
151
+ return {
152
+ run,
153
+ get inFlight() {
154
+ return tracker.inFlight;
155
+ },
156
+ idle: (timeoutMs) => tracker.idle(timeoutMs),
157
+ close: async () => {
158
+ stopped = true;
159
+ for (const slot of slots) {
160
+ for (const pending of slot.pending.values()) {
161
+ if (pending.timer !== null) clearTimeout(pending.timer);
162
+ pending.resolve({
163
+ ok: false,
164
+ reason: "failed"
165
+ });
166
+ }
167
+ slot.pending.clear();
168
+ }
169
+ await Promise.all(slots.map((slot) => slot.worker.terminate()));
170
+ }
171
+ };
172
+ };
173
+ //#endregion
174
+ export { inlineJobPool, workerJobPool };
175
+
176
+ //# sourceMappingURL=jobs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"jobs.js","names":[],"sources":["../../src/node/jobs.ts"],"sourcesContent":["// The job pool (room-server.md section 4.4): background work on worker_threads, behind an\n// interface so tests and development can run jobs inline.\nimport { Worker } from 'node:worker_threads';\nimport type { JobOutcome, Json } from '@joystyk/contracts';\nimport type { AiFailure, RoomCode } from '../types.ts';\nimport type { Logger } from './support.ts';\n\nexport type PoolJob = {\n readonly room: RoomCode;\n readonly jobId: string;\n readonly gameId: string;\n /** A room always supplies its pin; omitted internal probes use the current engine. */\n readonly rulesVersion?: number;\n readonly kind: string;\n readonly payload: Json;\n readonly timeoutMs: number;\n /**\n * 'outcome' (default): the module answers with a JobOutcome. 'value': it answers with a\n * plain value (a heavy AI's command), which the pool wraps as { ok: true, value }.\n */\n readonly answer?: 'outcome' | 'value';\n};\n\n/** A job's outcome, or why the pool could not run it to the end (see `AiFailure`). */\nexport type PoolOutcome = JobOutcome | { readonly ok: false; readonly reason: AiFailure };\n\nexport interface JobPool {\n /** Never rejects: failures come back as { ok: false }. */\n run(job: PoolJob): Promise<PoolOutcome>;\n readonly inFlight: number;\n /** Resolves true once nothing is running, or false after `timeoutMs`. */\n idle(timeoutMs: number): Promise<boolean>;\n close(): Promise<void>;\n}\n\n/** Shared bookkeeping for `inFlight` and `idle`. */\nclass Tracker {\n inFlight = 0;\n private waiters: (() => void)[] = [];\n\n track<T>(work: Promise<T>): Promise<T> {\n this.inFlight += 1;\n return work.finally(() => {\n this.inFlight -= 1;\n if (this.inFlight === 0) for (const wake of this.waiters.splice(0)) wake();\n });\n }\n\n idle(timeoutMs: number): Promise<boolean> {\n if (this.inFlight === 0) return Promise.resolve(true);\n return new Promise((resolve) => {\n const timer = setTimeout(() => resolve(false), timeoutMs);\n this.waiters.push(() => {\n clearTimeout(timer);\n resolve(true);\n });\n });\n }\n}\n\n/** Runs jobs in the main thread through a handler. For tests and development fixtures. */\nexport const inlineJobPool = (handler: (job: PoolJob) => PoolOutcome | Promise<PoolOutcome>): JobPool => {\n const tracker = new Tracker();\n return {\n run: (job) =>\n tracker.track(\n Promise.resolve()\n .then(() => handler(job))\n .catch((): PoolOutcome => ({ ok: false, reason: 'failed' })),\n ),\n get inFlight() {\n return tracker.inFlight;\n },\n idle: (timeoutMs) => tracker.idle(timeoutMs),\n close: async () => {},\n };\n};\n\n/**\n * The worker host. It imports a job's entry module (a game's `jobs/<kind>.worker.ts`, whose\n * default export maps a payload to a JobOutcome, or `jobs/ai.worker.ts`, which maps\n * `{ view, seat, level, rng }` to a command) and answers each request. Jobs run one at a\n * time, in order, and each says when it starts, so its timeout never includes its wait in the\n * queue. Written as an eval'd script so it works the same from source and from a bundle.\n */\nconst HOST = `\nconst { parentPort } = require('node:worker_threads');\nconst modules = new Map();\nlet queue = Promise.resolve();\nconst run = async ({ job, module, payload, answer }) => {\n parentPort.postMessage({ job, started: true });\n try {\n let loaded = modules.get(module);\n if (loaded === undefined) { loaded = await import(module); modules.set(module, loaded); }\n const result = await loaded.default(payload);\n parentPort.postMessage({ job, outcome: answer === 'value' ? { ok: true, value: result } : result });\n } catch (error) {\n parentPort.postMessage({ job, error: String(error) });\n }\n};\nparentPort.on('message', (message) => { queue = queue.then(() => run(message)); });\n`;\n\ntype Pending = {\n readonly resolve: (outcome: PoolOutcome) => void;\n readonly timeoutMs: number;\n timer: ReturnType<typeof setTimeout> | null;\n};\ntype Slot = { worker: Worker; readonly pending: Map<number, Pending>; running: number | null };\n\nexport type WorkerPoolOptions = {\n /** Worker threads (ROOMS_JOB_WORKERS). */\n readonly workers: number;\n /** The entry module URL for a game's job kind, or null when this build has none. */\n readonly module: (gameId: string, kind: string, rulesVersion?: number) => URL | null;\n /** Queued or running jobs per worker. */\n readonly perWorker?: number;\n readonly log?: Logger;\n};\n\n/**\n * Greed's physics pool, generalized. A full pool refuses a job at once (`busy`). A timeout or\n * crash terminates that worker and starts a fresh one; the job it was running fails with\n * `timeout` or `crashed`, and every job queued behind it with `lost`.\n */\nexport const workerJobPool = (options: WorkerPoolOptions): JobPool => {\n const perWorker = options.perWorker ?? 16;\n const tracker = new Tracker();\n let sequence = 0;\n let stopped = false;\n const slots: Slot[] = [];\n\n const spawn = (slot?: Slot): Slot => {\n const worker = new Worker(HOST, { eval: true });\n worker.unref();\n const target: Slot = slot ?? { worker, pending: new Map(), running: null };\n target.worker = worker;\n target.running = null;\n worker.on('message', (data: { job: number; started?: true; outcome?: JobOutcome; error?: string }) => {\n if (target.worker !== worker) return;\n const pending = target.pending.get(data.job);\n if (pending === undefined) return;\n if (data.started === true) {\n target.running = data.job;\n pending.timer = setTimeout(() => restart(target, worker, 'timeout'), pending.timeoutMs);\n return;\n }\n if (pending.timer !== null) clearTimeout(pending.timer);\n target.pending.delete(data.job);\n if (target.running === data.job) target.running = null;\n if (data.error !== undefined) options.log?.write('warn', 'job_failed', { error: data.error });\n pending.resolve(data.outcome ?? { ok: false, reason: 'failed' });\n });\n worker.on('error', () => restart(target, worker, 'crashed'));\n worker.on('exit', () => restart(target, worker, 'crashed'));\n return target;\n };\n\n const restart = (slot: Slot, worker: Worker, reason: 'timeout' | 'crashed'): void => {\n if (stopped || slot.worker !== worker) return;\n for (const [job, pending] of slot.pending) {\n if (pending.timer !== null) clearTimeout(pending.timer);\n pending.resolve({ ok: false, reason: job === slot.running ? reason : 'lost' });\n }\n slot.pending.clear();\n spawn(slot);\n void worker.terminate();\n };\n\n for (let i = 0; i < Math.max(1, options.workers); i += 1) slots.push(spawn());\n\n const run = (job: PoolJob): Promise<PoolOutcome> => {\n const module = options.module(job.gameId, job.kind, job.rulesVersion);\n const slot = [...slots].sort((a, b) => a.pending.size - b.pending.size)[0];\n if (stopped || module === null || slot === undefined || slot.pending.size >= perWorker) {\n options.log?.write('warn', module === null ? 'job_module_missing' : 'job_rejected_busy', {\n room: job.room,\n kind: job.kind,\n });\n return Promise.resolve({ ok: false, reason: module === null || stopped ? 'failed' : 'busy' });\n }\n sequence += 1;\n const id = sequence;\n const worker = slot.worker;\n return tracker.track(\n new Promise<PoolOutcome>((resolve) => {\n slot.pending.set(id, { resolve, timeoutMs: job.timeoutMs, timer: null });\n worker.postMessage({ job: id, module: module.href, payload: job.payload, answer: job.answer ?? 'outcome' });\n }),\n );\n };\n\n return {\n run,\n get inFlight() {\n return tracker.inFlight;\n },\n idle: (timeoutMs) => tracker.idle(timeoutMs),\n close: async () => {\n stopped = true;\n for (const slot of slots) {\n for (const pending of slot.pending.values()) {\n if (pending.timer !== null) clearTimeout(pending.timer);\n pending.resolve({ ok: false, reason: 'failed' });\n }\n slot.pending.clear();\n }\n await Promise.all(slots.map((slot) => slot.worker.terminate()));\n },\n };\n};\n"],"mappings":";;;AAoCA,IAAM,UAAN,MAAc;CACZ,WAAW;CACX,UAAkC,CAAC;CAEnC,MAAS,MAA8B;EACrC,KAAK,YAAY;EACjB,OAAO,KAAK,cAAc;GACxB,KAAK,YAAY;GACjB,IAAI,KAAK,aAAa,GAAG,KAAK,MAAM,QAAQ,KAAK,QAAQ,OAAO,CAAC,GAAG,KAAK;EAC3E,CAAC;CACH;CAEA,KAAK,WAAqC;EACxC,IAAI,KAAK,aAAa,GAAG,OAAO,QAAQ,QAAQ,IAAI;EACpD,OAAO,IAAI,SAAS,YAAY;GAC9B,MAAM,QAAQ,iBAAiB,QAAQ,KAAK,GAAG,SAAS;GACxD,KAAK,QAAQ,WAAW;IACtB,aAAa,KAAK;IAClB,QAAQ,IAAI;GACd,CAAC;EACH,CAAC;CACH;AACF;;AAGA,IAAa,iBAAiB,YAA2E;CACvG,MAAM,UAAU,IAAI,QAAQ;CAC5B,OAAO;EACL,MAAM,QACJ,QAAQ,MACN,QAAQ,QAAQ,CAAC,CACd,WAAW,QAAQ,GAAG,CAAC,CAAC,CACxB,aAA0B;GAAE,IAAI;GAAO,QAAQ;EAAS,EAAE,CAC/D;EACF,IAAI,WAAW;GACb,OAAO,QAAQ;EACjB;EACA,OAAO,cAAc,QAAQ,KAAK,SAAS;EAC3C,OAAO,YAAY,CAAC;CACtB;AACF;;;;;;;;AASA,IAAM,OAAO;;;;;;;;;;;;;;;;;;;;;;AAwCb,IAAa,iBAAiB,YAAwC;CACpE,MAAM,YAAY,QAAQ,aAAa;CACvC,MAAM,UAAU,IAAI,QAAQ;CAC5B,IAAI,WAAW;CACf,IAAI,UAAU;CACd,MAAM,QAAgB,CAAC;CAEvB,MAAM,SAAS,SAAsB;EACnC,MAAM,SAAS,IAAI,OAAO,MAAM,EAAE,MAAM,KAAK,CAAC;EAC9C,OAAO,MAAM;EACb,MAAM,SAAe,QAAQ;GAAE;GAAQ,yBAAS,IAAI,IAAI;GAAG,SAAS;EAAK;EACzE,OAAO,SAAS;EAChB,OAAO,UAAU;EACjB,OAAO,GAAG,YAAY,SAAgF;GACpG,IAAI,OAAO,WAAW,QAAQ;GAC9B,MAAM,UAAU,OAAO,QAAQ,IAAI,KAAK,GAAG;GAC3C,IAAI,YAAY,KAAA,GAAW;GAC3B,IAAI,KAAK,YAAY,MAAM;IACzB,OAAO,UAAU,KAAK;IACtB,QAAQ,QAAQ,iBAAiB,QAAQ,QAAQ,QAAQ,SAAS,GAAG,QAAQ,SAAS;IACtF;GACF;GACA,IAAI,QAAQ,UAAU,MAAM,aAAa,QAAQ,KAAK;GACtD,OAAO,QAAQ,OAAO,KAAK,GAAG;GAC9B,IAAI,OAAO,YAAY,KAAK,KAAK,OAAO,UAAU;GAClD,IAAI,KAAK,UAAU,KAAA,GAAW,QAAQ,KAAK,MAAM,QAAQ,cAAc,EAAE,OAAO,KAAK,MAAM,CAAC;GAC5F,QAAQ,QAAQ,KAAK,WAAW;IAAE,IAAI;IAAO,QAAQ;GAAS,CAAC;EACjE,CAAC;EACD,OAAO,GAAG,eAAe,QAAQ,QAAQ,QAAQ,SAAS,CAAC;EAC3D,OAAO,GAAG,cAAc,QAAQ,QAAQ,QAAQ,SAAS,CAAC;EAC1D,OAAO;CACT;CAEA,MAAM,WAAW,MAAY,QAAgB,WAAwC;EACnF,IAAI,WAAW,KAAK,WAAW,QAAQ;EACvC,KAAK,MAAM,CAAC,KAAK,YAAY,KAAK,SAAS;GACzC,IAAI,QAAQ,UAAU,MAAM,aAAa,QAAQ,KAAK;GACtD,QAAQ,QAAQ;IAAE,IAAI;IAAO,QAAQ,QAAQ,KAAK,UAAU,SAAS;GAAO,CAAC;EAC/E;EACA,KAAK,QAAQ,MAAM;EACnB,MAAM,IAAI;EACV,OAAY,UAAU;CACxB;CAEA,KAAK,IAAI,IAAI,GAAG,IAAI,KAAK,IAAI,GAAG,QAAQ,OAAO,GAAG,KAAK,GAAG,MAAM,KAAK,MAAM,CAAC;CAE5E,MAAM,OAAO,QAAuC;EAClD,MAAM,SAAS,QAAQ,OAAO,IAAI,QAAQ,IAAI,MAAM,IAAI,YAAY;EACpE,MAAM,OAAO,CAAC,GAAG,KAAK,CAAC,CAAC,MAAM,GAAG,MAAM,EAAE,QAAQ,OAAO,EAAE,QAAQ,IAAI,CAAC,CAAC;EACxE,IAAI,WAAW,WAAW,QAAQ,SAAS,KAAA,KAAa,KAAK,QAAQ,QAAQ,WAAW;GACtF,QAAQ,KAAK,MAAM,QAAQ,WAAW,OAAO,uBAAuB,qBAAqB;IACvF,MAAM,IAAI;IACV,MAAM,IAAI;GACZ,CAAC;GACD,OAAO,QAAQ,QAAQ;IAAE,IAAI;IAAO,QAAQ,WAAW,QAAQ,UAAU,WAAW;GAAO,CAAC;EAC9F;EACA,YAAY;EACZ,MAAM,KAAK;EACX,MAAM,SAAS,KAAK;EACpB,OAAO,QAAQ,MACb,IAAI,SAAsB,YAAY;GACpC,KAAK,QAAQ,IAAI,IAAI;IAAE;IAAS,WAAW,IAAI;IAAW,OAAO;GAAK,CAAC;GACvE,OAAO,YAAY;IAAE,KAAK;IAAI,QAAQ,OAAO;IAAM,SAAS,IAAI;IAAS,QAAQ,IAAI,UAAU;GAAU,CAAC;EAC5G,CAAC,CACH;CACF;CAEA,OAAO;EACL;EACA,IAAI,WAAW;GACb,OAAO,QAAQ;EACjB;EACA,OAAO,cAAc,QAAQ,KAAK,SAAS;EAC3C,OAAO,YAAY;GACjB,UAAU;GACV,KAAK,MAAM,QAAQ,OAAO;IACxB,KAAK,MAAM,WAAW,KAAK,QAAQ,OAAO,GAAG;KAC3C,IAAI,QAAQ,UAAU,MAAM,aAAa,QAAQ,KAAK;KACtD,QAAQ,QAAQ;MAAE,IAAI;MAAO,QAAQ;KAAS,CAAC;IACjD;IACA,KAAK,QAAQ,MAAM;GACrB;GACA,MAAM,QAAQ,IAAI,MAAM,KAAK,SAAS,KAAK,OAAO,UAAU,CAAC,CAAC;EAChE;CACF;AACF"}
@@ -0,0 +1,32 @@
1
+ import type { AnyDiscreteEngine, Rng, RngState } from '@joystyk/contracts';
2
+ import type { CoreLimits, GamePin, RoomCore, RoomPolicy } from '../types.js';
3
+ export type RegisteredEngine = {
4
+ readonly pin: GamePin;
5
+ readonly engine: AnyDiscreteEngine;
6
+ readonly policy: RoomPolicy;
7
+ /** The manifest's `ai.heavy`: choose on the worker pool with the `ai` job module. */
8
+ readonly heavyAi?: boolean;
9
+ /** Worker entry modules by job kind (a game's `jobs/<kind>.worker.ts`; `ai` for heavy AI). */
10
+ readonly jobs?: {
11
+ readonly [kind: string]: URL;
12
+ };
13
+ };
14
+ export type CurrentEngine = {
15
+ readonly core: RoomCore;
16
+ readonly pin: GamePin;
17
+ readonly policy: RoomPolicy;
18
+ };
19
+ export interface EngineRegistry {
20
+ /** The core for a game at a rules version, or null if this build no longer carries it. */
21
+ core(gameId: string, rulesVersion: number): RoomCore | null;
22
+ /** The newest engine for a game: new rooms and rematches use it. */
23
+ current(gameId: string): CurrentEngine | null;
24
+ games(): readonly string[];
25
+ /** Pinned room work uses its rules version; unversioned probes select current. */
26
+ jobModule(gameId: string, kind: string, rulesVersion?: number): URL | null;
27
+ }
28
+ /**
29
+ * Builds a registry. For each game, the entry with the highest rules version is current;
30
+ * the others are frozen engines kept for rooms already playing on them.
31
+ */
32
+ export declare const createEngineRegistry: (entries: readonly RegisteredEngine[], rng: (state: RngState) => Rng, limits?: Partial<CoreLimits>) => EngineRegistry;