@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,374 @@
1
+ import type { AnyDiscreteEngine, EngineInput, Json, JobOutcome, JobRequest, Outcome, Result, Rng, RngState, SeatId, SeatInfo } from '@joystyk/contracts';
2
+ import type { ActionId, BankSetting, GamePin, OfferKind, RoomCode, RoomControl, RoomPhase, ServerMessage, WelcomeMessage } from './protocol/index.js';
3
+ export type { ActionId, BankSetting, GamePin, OfferKind, RoomCode, RoomControl, RoomOffer, RoomPhase, } from './protocol/index.js';
4
+ /** Assigned by the adapter to each socket; never persisted. */
5
+ export type ConnId = string;
6
+ /** Per-game defaults come from the manifest; the creator may change some within bounds. */
7
+ export type RoomPolicy = {
8
+ readonly seats: {
9
+ readonly min: number;
10
+ readonly max: number;
11
+ };
12
+ /** 'host': a lobby, then the host starts. 'immediate': the match starts at creation with AI in every other seat. */
13
+ readonly start: 'host' | 'immediate';
14
+ /** Someone arriving mid-match watches, or takes over an unclaimed AI seat. */
15
+ readonly lateJoin: 'spectate' | 'take-ai-seat';
16
+ /** After the grace period, an absent player's seat goes to the AI, has its turns expired, or waits. */
17
+ readonly absent: 'ai' | 'timeout' | 'wait';
18
+ readonly graceMs: number;
19
+ /** Per-move clock; null = off, or a bank runs instead. */
20
+ readonly decisionMs: number | null;
21
+ /** A time bank with increment (the manifest's `clock.mode: 'bank'`); when set, decisionMs is null. */
22
+ readonly bank?: BankSetting | null;
23
+ /** 0 disables watching. */
24
+ readonly maxSpectators: number;
25
+ /** Delete after this long without a change. */
26
+ readonly retainMs: number;
27
+ };
28
+ export type SeatClaim = {
29
+ readonly tokenHash: string;
30
+ readonly name: string;
31
+ };
32
+ export type SeatRecord = {
33
+ readonly id: SeatId;
34
+ /** Who holds the seat. Null: open, or played by the AI with nobody to give it back to. */
35
+ readonly claim: SeatClaim | null;
36
+ readonly controller: 'human' | 'ai';
37
+ readonly aiLevel: string | null;
38
+ readonly ready: boolean;
39
+ };
40
+ export type DecisionClock = {
41
+ readonly seat: SeatId;
42
+ /** Epoch ms. */
43
+ readonly at: number;
44
+ /** The clock belongs to this decision: it survives changes that leave the seat's decision alone. */
45
+ readonly decisionRevision: number;
46
+ /** Bank mode: when the bank starts running (after the playback hold). */
47
+ readonly startsAt?: number;
48
+ /** Bank mode: false when the engine's `decisionMs` replaced the bank for this decision. */
49
+ readonly onBank?: boolean;
50
+ };
51
+ export type PendingJob = {
52
+ /** Id, kind and payload, from the contract. */
53
+ readonly request: JobRequest;
54
+ /** The revision that requested it. */
55
+ readonly revision: number;
56
+ };
57
+ export type MatchRecord = {
58
+ /** 1, then +1 per rematch. */
59
+ readonly number: number;
60
+ /** Pinned for this match. */
61
+ readonly game: GamePin;
62
+ readonly config: Json;
63
+ /** The seats `init` received: with `seed`, `config` and the input log, the replay. */
64
+ readonly seats: readonly SeatInfo[];
65
+ /** The engine rng at init: the replay seed. */
66
+ readonly seed: RngState;
67
+ /** Engine state. */
68
+ readonly state: Json;
69
+ /** Never sent to a client. */
70
+ readonly rng: RngState;
71
+ /**
72
+ * The AI's stream, kept apart so AI choices never shift the engine's draws. Each decision
73
+ * forks a child from it; it moves on only when an AI move is accepted (rule 7).
74
+ */
75
+ readonly aiRng: RngState;
76
+ /** Per seat: the last revision that changed its `decisionKey`. */
77
+ readonly decisionRevision: readonly number[];
78
+ /** Durable: they survive a restart. */
79
+ readonly clocks: readonly DecisionClock[];
80
+ /** Bank mode: each seat's remaining bank in milliseconds. */
81
+ readonly banks?: readonly number[];
82
+ readonly jobs: readonly PendingJob[];
83
+ /** Latest job presentation, for clients that reconnect. */
84
+ readonly presentation: Json | null;
85
+ /** Recent engine events, unredacted; redacted per viewer when sent. */
86
+ readonly log: readonly Json[];
87
+ readonly outcome: Outcome | null;
88
+ readonly startedAt: number;
89
+ /**
90
+ * Set while the table is paused (no player connected, D-58): when the pause began. Clocks
91
+ * keep their deadlines meanwhile; when a player returns, every deadline moves on by the
92
+ * pause, so no clock or bank is charged for it. Durable, so a pause survives a restart.
93
+ */
94
+ readonly pausedAt?: number;
95
+ };
96
+ /** A rematch or end-match offer (section 3.10, D-61). */
97
+ export type OfferRecord = {
98
+ /** The revision that opened it; answers name it. */
99
+ readonly id: number;
100
+ readonly kind: OfferKind;
101
+ readonly by: SeatId;
102
+ readonly status: 'open' | 'declined' | 'withdrawn';
103
+ /** Human seats that accepted, the proposer first. Computer seats accept without being listed. */
104
+ readonly accepted: readonly SeatId[];
105
+ readonly declinedBy: SeatId | null;
106
+ /** An end offer: when the match ends if nobody declines. */
107
+ readonly endsAt: number | null;
108
+ };
109
+ export type Receipt = {
110
+ readonly seat: SeatId;
111
+ readonly id: ActionId;
112
+ /** Hash of the canonical JSON of { expectedRevision, command or control }. */
113
+ readonly fingerprint: string;
114
+ /** The revision the action produced. */
115
+ readonly revision: number;
116
+ };
117
+ export type RoomRecord = {
118
+ /** The room record's own schema version. */
119
+ readonly format: 1;
120
+ readonly code: RoomCode;
121
+ /** Bumped by a restore from backup (section 6.6). */
122
+ readonly epoch: number;
123
+ /** +1 per persist; guards against two writers (section 4.3). */
124
+ readonly writes: number;
125
+ readonly policy: RoomPolicy;
126
+ readonly phase: RoomPhase;
127
+ readonly revision: number;
128
+ /** The game the room plays: the pin of the current match, or the lobby's intended engine. */
129
+ readonly game: GamePin;
130
+ /** The configuration the next match starts with. */
131
+ readonly config: Json;
132
+ /** The level given to seats filled with the AI. */
133
+ readonly aiLevel: string | null;
134
+ readonly seats: readonly SeatRecord[];
135
+ readonly host: SeatId | null;
136
+ /** Null in a lobby before the first start. */
137
+ readonly match: MatchRecord | null;
138
+ /** The latest rematch or end-match offer; absent in records written before D-61. */
139
+ readonly offer?: OfferRecord | null;
140
+ readonly receipts: readonly Receipt[];
141
+ readonly createdAt: number;
142
+ readonly updatedAt: number;
143
+ };
144
+ export type Member = {
145
+ /** Null: spectator. */
146
+ readonly seat: SeatId | null;
147
+ /** The wire version this connection speaks. */
148
+ readonly protocol: number;
149
+ readonly joinedAt: number;
150
+ };
151
+ /** What the core steps. The adapter holds one per room in memory. */
152
+ export type LiveRoom = {
153
+ readonly record: RoomRecord;
154
+ readonly members: ReadonlyMap<ConnId, Member>;
155
+ /** Claimed human seats with no connection, since when. */
156
+ readonly absentSince: ReadonlyMap<SeatId, number>;
157
+ /** Job id → start time, for job timeouts. */
158
+ readonly jobStartedAt: ReadonlyMap<string, number>;
159
+ /** Heavy AI: seat → the decisionRevision a `start-ai` is out for. Live-only. */
160
+ readonly aiInFlight: ReadonlyMap<SeatId, number>;
161
+ /** Heavy AI: seat → when to ask the pool again after it failed that decision. Live-only. */
162
+ readonly aiRetry: ReadonlyMap<SeatId, AiRetry>;
163
+ /** The last alarm the core asked for, so it emits `alarm` only when it changes. */
164
+ readonly alarmAt: number | null;
165
+ /** Set by `close`; a closed copy never persists again. */
166
+ readonly closed: boolean;
167
+ };
168
+ /**
169
+ * Why the pool gave no answer to a `start-ai`. `busy`: it refused the job unrun. `lost`: a
170
+ * restart caused by another job ended it. `timeout` and `crashed`: it ran too long or its worker
171
+ * died while running it. `failed`: the AI's module threw or the job has no module.
172
+ */
173
+ export type AiFailure = 'busy' | 'lost' | 'timeout' | 'crashed' | 'failed';
174
+ /** A heavy AI decision waiting to be asked again (rule 7). */
175
+ export type AiRetry = {
176
+ readonly decisionRevision: number;
177
+ readonly at: number;
178
+ /** Failed asks for this decision so far, which set the backoff. */
179
+ readonly failures: number;
180
+ /** Of those, timeouts and crashes, which count toward `aiAttempts`. */
181
+ readonly faults: number;
182
+ };
183
+ /** Fresh seeds from the adapter's CSPRNG, for a match that starts in this step. */
184
+ export type MatchSeeds = {
185
+ readonly engine: RngState;
186
+ readonly ai: RngState;
187
+ };
188
+ export type RoomCommand = {
189
+ readonly type: 'join';
190
+ readonly conn: ConnId;
191
+ /**
192
+ * As the client sent it. The core runs the identity filter (`@joystyk/identity/names`) and
193
+ * gives a refused name a generated one, with a `name-replaced` notice.
194
+ */
195
+ readonly name: string;
196
+ readonly protocol: number;
197
+ readonly presentedTokenHash: string | null;
198
+ /** Hash of a fresh token the adapter generated; used only if the core assigns a new seat. */
199
+ readonly freshTokenHash: string;
200
+ /** Spectate even if a seat is free. */
201
+ readonly watch: boolean;
202
+ }
203
+ /** The socket closed. Not a departure: the seat's grace period starts. */
204
+ | {
205
+ readonly type: 'leave';
206
+ readonly conn: ConnId;
207
+ } | {
208
+ readonly type: 'act';
209
+ readonly conn: ConnId;
210
+ readonly id: ActionId;
211
+ readonly expectedRevision: number;
212
+ /** Already passed engine.schemas.command. */
213
+ readonly command: Json;
214
+ } | {
215
+ readonly type: 'control';
216
+ readonly conn: ConnId;
217
+ readonly id: ActionId;
218
+ readonly expectedRevision: number;
219
+ readonly control: RoomControl;
220
+ /** Used only when this control starts a match (start, or the answer that completes a rematch offer). */
221
+ readonly seeds: MatchSeeds;
222
+ }
223
+ /** The client asked for a full snapshot. */
224
+ | {
225
+ readonly type: 'resync';
226
+ readonly conn: ConnId;
227
+ } | {
228
+ readonly type: 'timer-fired';
229
+ } | {
230
+ readonly type: 'job-completed';
231
+ readonly jobId: string;
232
+ readonly outcome: JobOutcome;
233
+ }
234
+ /** Heavy AI: the pool's answer to `start-ai`, unparsed, or why there is none. */
235
+ | {
236
+ readonly type: 'ai-chosen';
237
+ readonly seat: SeatId;
238
+ readonly decisionRevision: number;
239
+ readonly command: Json;
240
+ } | {
241
+ readonly type: 'ai-chosen';
242
+ readonly seat: SeatId;
243
+ readonly decisionRevision: number;
244
+ readonly command: null;
245
+ readonly failure: AiFailure;
246
+ }
247
+ /** First command after loading from storage. */
248
+ | {
249
+ readonly type: 'resume';
250
+ } | {
251
+ readonly type: 'close';
252
+ readonly reason: 'expired' | 'unload';
253
+ };
254
+ /** One engine input, for the room's replay log (the contract's Replay.inputs). */
255
+ export type LoggedInput = {
256
+ readonly match: number;
257
+ readonly revision: number;
258
+ readonly at: number;
259
+ /** Job outcomes without their presentation. */
260
+ readonly input: EngineInput<Json>;
261
+ };
262
+ export type DropReason = 'replaced' | 'left-room' | 'room-closed' | 'room-full';
263
+ export type RoomEffect =
264
+ /** Save. Always the first effect when the record changed; nothing else happens if it fails. */
265
+ {
266
+ readonly type: 'persist';
267
+ readonly record: RoomRecord;
268
+ readonly inputs: readonly LoggedInput[];
269
+ }
270
+ /** Welcome one connection. With `issueToken`, the adapter adds the fresh token it generated. */
271
+ | {
272
+ readonly type: 'welcome';
273
+ readonly conn: ConnId;
274
+ readonly message: WelcomeMessage;
275
+ readonly issueToken: boolean;
276
+ }
277
+ /** Send one message to several connections: every member of a seat, or all spectators. */
278
+ | {
279
+ readonly type: 'send';
280
+ readonly to: readonly ConnId[];
281
+ readonly message: ServerMessage;
282
+ }
283
+ /** Close a connection the room no longer serves. */
284
+ | {
285
+ readonly type: 'drop';
286
+ readonly conn: ConnId;
287
+ readonly reason: DropReason;
288
+ }
289
+ /** Wake the room at this time with { type: 'timer-fired' }. Null cancels. */
290
+ | {
291
+ readonly type: 'alarm';
292
+ readonly at: number | null;
293
+ } | {
294
+ readonly type: 'start-job';
295
+ readonly jobId: string;
296
+ readonly kind: string;
297
+ readonly payload: Json;
298
+ readonly timeoutMs: number;
299
+ }
300
+ /** Heavy AI: choose on the worker pool. `rng` is this decision's forked stream. */
301
+ | {
302
+ readonly type: 'start-ai';
303
+ readonly seat: SeatId;
304
+ readonly decisionRevision: number;
305
+ readonly view: Json;
306
+ readonly level: string;
307
+ readonly rng: RngState;
308
+ } | {
309
+ readonly type: 'log';
310
+ readonly level: 'info' | 'warn' | 'error';
311
+ readonly event: string;
312
+ readonly fields: {
313
+ readonly [k: string]: Json;
314
+ };
315
+ };
316
+ export type Step = {
317
+ readonly room: LiveRoom;
318
+ readonly effects: readonly RoomEffect[];
319
+ };
320
+ export type RoomError = {
321
+ readonly code: 'bad-config' | 'bad-policy' | 'init-failed';
322
+ readonly message: string;
323
+ /** Server-only diagnostic data. Never serialize the error wholesale to a client. */
324
+ readonly diagnostic?: {
325
+ readonly reference: string;
326
+ readonly error: string;
327
+ };
328
+ };
329
+ export type CreateParams = {
330
+ readonly code: RoomCode;
331
+ readonly policy: RoomPolicy;
332
+ /** Already passed engine.schemas.config. */
333
+ readonly config: Json;
334
+ /** From the adapter's CSPRNG. Used when the match starts at creation (start: 'immediate'). */
335
+ readonly seeds: MatchSeeds;
336
+ /** `name` as the client sent it; a name the identity filter refuses becomes a generated one. */
337
+ readonly creator: {
338
+ readonly name: string;
339
+ readonly tokenHash: string;
340
+ };
341
+ };
342
+ export type CoreLimits = {
343
+ readonly receiptsPerSeat: number;
344
+ readonly logEvents: number;
345
+ readonly aiInputsPerStep: number;
346
+ readonly resumeGraceMs: number;
347
+ /** How long an offer to end a match waits for a decline before the match ends (D-61). */
348
+ readonly endOfferMs: number;
349
+ /** A heavy AI's first wait before the pool is asked again; each further failure doubles it. */
350
+ readonly aiRetryMs: number;
351
+ readonly aiRetryMaxMs: number;
352
+ /** Timeouts and crashes of one decision before its seat gets the timeout move. */
353
+ readonly aiAttempts: number;
354
+ };
355
+ export declare const DEFAULT_LIMITS: CoreLimits;
356
+ export type RoomCoreOptions = {
357
+ readonly engine: AnyDiscreteEngine;
358
+ /** The build this core runs; `pin.rulesVersion` must equal `engine.rulesVersion`. */
359
+ readonly pin: GamePin;
360
+ /** Rebuilds the engine's seeded generator from saved state (usually the kit's `restoreRng`). */
361
+ readonly rng: (state: RngState) => Rng;
362
+ /** The manifest's `ai.heavy`: choose on the worker pool (`start-ai`), never inside a step. */
363
+ readonly heavyAi?: boolean;
364
+ readonly limits?: Partial<CoreLimits>;
365
+ };
366
+ export interface RoomCore {
367
+ readonly engine: AnyDiscreteEngine;
368
+ readonly pin: GamePin;
369
+ /** A new record. For start: 'immediate', the match is initialized too. */
370
+ create(params: CreateParams, now: number): Result<RoomRecord, RoomError>;
371
+ /** Wrap a loaded record. The adapter then steps { type: 'resume' }. */
372
+ load(record: RoomRecord): LiveRoom;
373
+ step(room: LiveRoom, command: RoomCommand, now: number): Step;
374
+ }
package/dist/types.js ADDED
@@ -0,0 +1,15 @@
1
+ //#region src/types.ts
2
+ var DEFAULT_LIMITS = {
3
+ receiptsPerSeat: 64,
4
+ logEvents: 300,
5
+ aiInputsPerStep: 256,
6
+ resumeGraceMs: 15e3,
7
+ endOfferMs: 6e4,
8
+ aiRetryMs: 1e3,
9
+ aiRetryMaxMs: 16e3,
10
+ aiAttempts: 3
11
+ };
12
+ //#endregion
13
+ export { DEFAULT_LIMITS };
14
+
15
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","names":[],"sources":["../src/types.ts"],"sourcesContent":["// The room core's types (room-server.md sections 3.2 to 3.4). A room is a pure value; every\n// change goes through `step(room, command, now)`, which returns the next room and a list of\n// effects for the adapter to carry out.\nimport type {\n AnyDiscreteEngine,\n EngineInput,\n Json,\n JobOutcome,\n JobRequest,\n Outcome,\n Result,\n Rng,\n RngState,\n SeatId,\n SeatInfo,\n} from '@joystyk/contracts';\nimport type {\n ActionId,\n BankSetting,\n GamePin,\n OfferKind,\n RoomCode,\n RoomControl,\n RoomPhase,\n ServerMessage,\n WelcomeMessage,\n} from './protocol/index.ts';\n\nexport type {\n ActionId,\n BankSetting,\n GamePin,\n OfferKind,\n RoomCode,\n RoomControl,\n RoomOffer,\n RoomPhase,\n} from './protocol/index.ts';\n\n/** Assigned by the adapter to each socket; never persisted. */\nexport type ConnId = string;\n\n/** Per-game defaults come from the manifest; the creator may change some within bounds. */\nexport type RoomPolicy = {\n readonly seats: { readonly min: number; readonly max: number };\n /** 'host': a lobby, then the host starts. 'immediate': the match starts at creation with AI in every other seat. */\n readonly start: 'host' | 'immediate';\n /** Someone arriving mid-match watches, or takes over an unclaimed AI seat. */\n readonly lateJoin: 'spectate' | 'take-ai-seat';\n /** After the grace period, an absent player's seat goes to the AI, has its turns expired, or waits. */\n readonly absent: 'ai' | 'timeout' | 'wait';\n readonly graceMs: number;\n /** Per-move clock; null = off, or a bank runs instead. */\n readonly decisionMs: number | null;\n /** A time bank with increment (the manifest's `clock.mode: 'bank'`); when set, decisionMs is null. */\n readonly bank?: BankSetting | null;\n /** 0 disables watching. */\n readonly maxSpectators: number;\n /** Delete after this long without a change. */\n readonly retainMs: number;\n};\n\nexport type SeatClaim = { readonly tokenHash: string; readonly name: string };\n\nexport type SeatRecord = {\n readonly id: SeatId;\n /** Who holds the seat. Null: open, or played by the AI with nobody to give it back to. */\n readonly claim: SeatClaim | null;\n readonly controller: 'human' | 'ai';\n readonly aiLevel: string | null;\n readonly ready: boolean;\n};\n\nexport type DecisionClock = {\n readonly seat: SeatId;\n /** Epoch ms. */\n readonly at: number;\n /** The clock belongs to this decision: it survives changes that leave the seat's decision alone. */\n readonly decisionRevision: number;\n /** Bank mode: when the bank starts running (after the playback hold). */\n readonly startsAt?: number;\n /** Bank mode: false when the engine's `decisionMs` replaced the bank for this decision. */\n readonly onBank?: boolean;\n};\n\nexport type PendingJob = {\n /** Id, kind and payload, from the contract. */\n readonly request: JobRequest;\n /** The revision that requested it. */\n readonly revision: number;\n};\n\nexport type MatchRecord = {\n /** 1, then +1 per rematch. */\n readonly number: number;\n /** Pinned for this match. */\n readonly game: GamePin;\n readonly config: Json;\n /** The seats `init` received: with `seed`, `config` and the input log, the replay. */\n readonly seats: readonly SeatInfo[];\n /** The engine rng at init: the replay seed. */\n readonly seed: RngState;\n /** Engine state. */\n readonly state: Json;\n /** Never sent to a client. */\n readonly rng: RngState;\n /**\n * The AI's stream, kept apart so AI choices never shift the engine's draws. Each decision\n * forks a child from it; it moves on only when an AI move is accepted (rule 7).\n */\n readonly aiRng: RngState;\n /** Per seat: the last revision that changed its `decisionKey`. */\n readonly decisionRevision: readonly number[];\n /** Durable: they survive a restart. */\n readonly clocks: readonly DecisionClock[];\n /** Bank mode: each seat's remaining bank in milliseconds. */\n readonly banks?: readonly number[];\n readonly jobs: readonly PendingJob[];\n /** Latest job presentation, for clients that reconnect. */\n readonly presentation: Json | null;\n /** Recent engine events, unredacted; redacted per viewer when sent. */\n readonly log: readonly Json[];\n readonly outcome: Outcome | null;\n readonly startedAt: number;\n /**\n * Set while the table is paused (no player connected, D-58): when the pause began. Clocks\n * keep their deadlines meanwhile; when a player returns, every deadline moves on by the\n * pause, so no clock or bank is charged for it. Durable, so a pause survives a restart.\n */\n readonly pausedAt?: number;\n};\n\n/** A rematch or end-match offer (section 3.10, D-61). */\nexport type OfferRecord = {\n /** The revision that opened it; answers name it. */\n readonly id: number;\n readonly kind: OfferKind;\n readonly by: SeatId;\n readonly status: 'open' | 'declined' | 'withdrawn';\n /** Human seats that accepted, the proposer first. Computer seats accept without being listed. */\n readonly accepted: readonly SeatId[];\n readonly declinedBy: SeatId | null;\n /** An end offer: when the match ends if nobody declines. */\n readonly endsAt: number | null;\n};\n\nexport type Receipt = {\n readonly seat: SeatId;\n readonly id: ActionId;\n /** Hash of the canonical JSON of { expectedRevision, command or control }. */\n readonly fingerprint: string;\n /** The revision the action produced. */\n readonly revision: number;\n};\n\nexport type RoomRecord = {\n /** The room record's own schema version. */\n readonly format: 1;\n readonly code: RoomCode;\n /** Bumped by a restore from backup (section 6.6). */\n readonly epoch: number;\n /** +1 per persist; guards against two writers (section 4.3). */\n readonly writes: number;\n readonly policy: RoomPolicy;\n readonly phase: RoomPhase;\n readonly revision: number;\n /** The game the room plays: the pin of the current match, or the lobby's intended engine. */\n readonly game: GamePin;\n /** The configuration the next match starts with. */\n readonly config: Json;\n /** The level given to seats filled with the AI. */\n readonly aiLevel: string | null;\n readonly seats: readonly SeatRecord[];\n readonly host: SeatId | null;\n /** Null in a lobby before the first start. */\n readonly match: MatchRecord | null;\n /** The latest rematch or end-match offer; absent in records written before D-61. */\n readonly offer?: OfferRecord | null;\n readonly receipts: readonly Receipt[];\n readonly createdAt: number;\n readonly updatedAt: number;\n};\n\nexport type Member = {\n /** Null: spectator. */\n readonly seat: SeatId | null;\n /** The wire version this connection speaks. */\n readonly protocol: number;\n readonly joinedAt: number;\n};\n\n/** What the core steps. The adapter holds one per room in memory. */\nexport type LiveRoom = {\n readonly record: RoomRecord;\n readonly members: ReadonlyMap<ConnId, Member>;\n /** Claimed human seats with no connection, since when. */\n readonly absentSince: ReadonlyMap<SeatId, number>;\n /** Job id → start time, for job timeouts. */\n readonly jobStartedAt: ReadonlyMap<string, number>;\n /** Heavy AI: seat → the decisionRevision a `start-ai` is out for. Live-only. */\n readonly aiInFlight: ReadonlyMap<SeatId, number>;\n /** Heavy AI: seat → when to ask the pool again after it failed that decision. Live-only. */\n readonly aiRetry: ReadonlyMap<SeatId, AiRetry>;\n /** The last alarm the core asked for, so it emits `alarm` only when it changes. */\n readonly alarmAt: number | null;\n /** Set by `close`; a closed copy never persists again. */\n readonly closed: boolean;\n};\n\n/**\n * Why the pool gave no answer to a `start-ai`. `busy`: it refused the job unrun. `lost`: a\n * restart caused by another job ended it. `timeout` and `crashed`: it ran too long or its worker\n * died while running it. `failed`: the AI's module threw or the job has no module.\n */\nexport type AiFailure = 'busy' | 'lost' | 'timeout' | 'crashed' | 'failed';\n\n/** A heavy AI decision waiting to be asked again (rule 7). */\nexport type AiRetry = {\n readonly decisionRevision: number;\n readonly at: number;\n /** Failed asks for this decision so far, which set the backoff. */\n readonly failures: number;\n /** Of those, timeouts and crashes, which count toward `aiAttempts`. */\n readonly faults: number;\n};\n\n/** Fresh seeds from the adapter's CSPRNG, for a match that starts in this step. */\nexport type MatchSeeds = { readonly engine: RngState; readonly ai: RngState };\n\nexport type RoomCommand =\n | {\n readonly type: 'join';\n readonly conn: ConnId;\n /**\n * As the client sent it. The core runs the identity filter (`@joystyk/identity/names`) and\n * gives a refused name a generated one, with a `name-replaced` notice.\n */\n readonly name: string;\n readonly protocol: number;\n readonly presentedTokenHash: string | null;\n /** Hash of a fresh token the adapter generated; used only if the core assigns a new seat. */\n readonly freshTokenHash: string;\n /** Spectate even if a seat is free. */\n readonly watch: boolean;\n }\n /** The socket closed. Not a departure: the seat's grace period starts. */\n | { readonly type: 'leave'; readonly conn: ConnId }\n | {\n readonly type: 'act';\n readonly conn: ConnId;\n readonly id: ActionId;\n readonly expectedRevision: number;\n /** Already passed engine.schemas.command. */\n readonly command: Json;\n }\n | {\n readonly type: 'control';\n readonly conn: ConnId;\n readonly id: ActionId;\n readonly expectedRevision: number;\n readonly control: RoomControl;\n /** Used only when this control starts a match (start, or the answer that completes a rematch offer). */\n readonly seeds: MatchSeeds;\n }\n /** The client asked for a full snapshot. */\n | { readonly type: 'resync'; readonly conn: ConnId }\n | { readonly type: 'timer-fired' }\n | { readonly type: 'job-completed'; readonly jobId: string; readonly outcome: JobOutcome }\n /** Heavy AI: the pool's answer to `start-ai`, unparsed, or why there is none. */\n | { readonly type: 'ai-chosen'; readonly seat: SeatId; readonly decisionRevision: number; readonly command: Json }\n | {\n readonly type: 'ai-chosen';\n readonly seat: SeatId;\n readonly decisionRevision: number;\n readonly command: null;\n readonly failure: AiFailure;\n }\n /** First command after loading from storage. */\n | { readonly type: 'resume' }\n | { readonly type: 'close'; readonly reason: 'expired' | 'unload' };\n\n/** One engine input, for the room's replay log (the contract's Replay.inputs). */\nexport type LoggedInput = {\n readonly match: number;\n readonly revision: number;\n readonly at: number;\n /** Job outcomes without their presentation. */\n readonly input: EngineInput<Json>;\n};\n\nexport type DropReason = 'replaced' | 'left-room' | 'room-closed' | 'room-full';\n\nexport type RoomEffect =\n /** Save. Always the first effect when the record changed; nothing else happens if it fails. */\n | { readonly type: 'persist'; readonly record: RoomRecord; readonly inputs: readonly LoggedInput[] }\n /** Welcome one connection. With `issueToken`, the adapter adds the fresh token it generated. */\n | { readonly type: 'welcome'; readonly conn: ConnId; readonly message: WelcomeMessage; readonly issueToken: boolean }\n /** Send one message to several connections: every member of a seat, or all spectators. */\n | { readonly type: 'send'; readonly to: readonly ConnId[]; readonly message: ServerMessage }\n /** Close a connection the room no longer serves. */\n | { readonly type: 'drop'; readonly conn: ConnId; readonly reason: DropReason }\n /** Wake the room at this time with { type: 'timer-fired' }. Null cancels. */\n | { readonly type: 'alarm'; readonly at: number | null }\n | {\n readonly type: 'start-job';\n readonly jobId: string;\n readonly kind: string;\n readonly payload: Json;\n readonly timeoutMs: number;\n }\n /** Heavy AI: choose on the worker pool. `rng` is this decision's forked stream. */\n | {\n readonly type: 'start-ai';\n readonly seat: SeatId;\n readonly decisionRevision: number;\n readonly view: Json;\n readonly level: string;\n readonly rng: RngState;\n }\n | {\n readonly type: 'log';\n readonly level: 'info' | 'warn' | 'error';\n readonly event: string;\n readonly fields: { readonly [k: string]: Json };\n };\n\nexport type Step = {\n readonly room: LiveRoom;\n readonly effects: readonly RoomEffect[];\n};\n\nexport type RoomError = {\n readonly code: 'bad-config' | 'bad-policy' | 'init-failed';\n readonly message: string;\n /** Server-only diagnostic data. Never serialize the error wholesale to a client. */\n readonly diagnostic?: { readonly reference: string; readonly error: string };\n};\n\nexport type CreateParams = {\n readonly code: RoomCode;\n readonly policy: RoomPolicy;\n /** Already passed engine.schemas.config. */\n readonly config: Json;\n /** From the adapter's CSPRNG. Used when the match starts at creation (start: 'immediate'). */\n readonly seeds: MatchSeeds;\n /** `name` as the client sent it; a name the identity filter refuses becomes a generated one. */\n readonly creator: { readonly name: string; readonly tokenHash: string };\n};\n\nexport type CoreLimits = {\n readonly receiptsPerSeat: number;\n readonly logEvents: number;\n readonly aiInputsPerStep: number;\n readonly resumeGraceMs: number;\n /** How long an offer to end a match waits for a decline before the match ends (D-61). */\n readonly endOfferMs: number;\n /** A heavy AI's first wait before the pool is asked again; each further failure doubles it. */\n readonly aiRetryMs: number;\n readonly aiRetryMaxMs: number;\n /** Timeouts and crashes of one decision before its seat gets the timeout move. */\n readonly aiAttempts: number;\n};\n\nexport const DEFAULT_LIMITS: CoreLimits = {\n receiptsPerSeat: 64,\n logEvents: 300,\n aiInputsPerStep: 256,\n resumeGraceMs: 15_000,\n endOfferMs: 60_000,\n aiRetryMs: 1_000,\n aiRetryMaxMs: 16_000,\n aiAttempts: 3,\n};\n\nexport type RoomCoreOptions = {\n readonly engine: AnyDiscreteEngine;\n /** The build this core runs; `pin.rulesVersion` must equal `engine.rulesVersion`. */\n readonly pin: GamePin;\n /** Rebuilds the engine's seeded generator from saved state (usually the kit's `restoreRng`). */\n readonly rng: (state: RngState) => Rng;\n /** The manifest's `ai.heavy`: choose on the worker pool (`start-ai`), never inside a step. */\n readonly heavyAi?: boolean;\n readonly limits?: Partial<CoreLimits>;\n};\n\nexport interface RoomCore {\n readonly engine: AnyDiscreteEngine;\n readonly pin: GamePin;\n /** A new record. For start: 'immediate', the match is initialized too. */\n create(params: CreateParams, now: number): Result<RoomRecord, RoomError>;\n /** Wrap a loaded record. The adapter then steps { type: 'resume' }. */\n load(record: RoomRecord): LiveRoom;\n step(room: LiveRoom, command: RoomCommand, now: number): Step;\n}\n"],"mappings":";AA2WA,IAAa,iBAA6B;CACxC,iBAAiB;CACjB,WAAW;CACX,iBAAiB;CACjB,eAAe;CACf,YAAY;CACZ,WAAW;CACX,cAAc;CACd,YAAY;AACd"}
package/package.json CHANGED
@@ -1,20 +1,65 @@
1
1
  {
2
2
  "name": "@joystyk/rooms",
3
- "version": "0.0.0",
4
- "description": "Reserved for the first Joystyk library release.",
3
+ "version": "0.2.0",
4
+ "description": "The room server: a pure room core, the versioned wire protocol and a Node adapter (WebSockets, SQLite, timers, worker jobs).",
5
+ "private": false,
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./protocol": {
14
+ "types": "./dist/protocol/index.d.ts",
15
+ "default": "./dist/protocol/index.js"
16
+ },
17
+ "./node": {
18
+ "types": "./dist/node/index.d.ts",
19
+ "default": "./dist/node/index.js"
20
+ }
21
+ },
22
+ "dependencies": {
23
+ "ws": "8.22.0",
24
+ "zod": "4.6.5",
25
+ "@joystyk/contracts": "0.2.0",
26
+ "@joystyk/ops": "0.2.0",
27
+ "@joystyk/kit": "0.2.0",
28
+ "@joystyk/identity": "0.2.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "24.10.1",
32
+ "@types/ws": "8.18.1",
33
+ "fast-check": "4.10.2",
34
+ "typescript": "5.9.3",
35
+ "vite": "8.3.3",
36
+ "vitest": "4.1.11"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "files": [
42
+ "dist",
43
+ "README.md",
44
+ "LICENSE",
45
+ "NOTICE",
46
+ "CHANGELOG.md"
47
+ ],
48
+ "engines": {
49
+ "node": ">=24.15.0"
50
+ },
5
51
  "license": "MIT",
6
52
  "repository": {
7
53
  "type": "git",
8
54
  "url": "git+https://github.com/joystyk-games/game-libraries.git",
9
55
  "directory": "packages/rooms"
10
56
  },
11
- "homepage": "https://github.com/joystyk-games/game-libraries",
12
- "files": [
13
- "README.md",
14
- "LICENSE",
15
- "NOTICE"
16
- ],
17
- "publishConfig": {
18
- "access": "public"
57
+ "homepage": "https://github.com/joystyk-games/game-libraries/tree/main/packages/rooms#readme",
58
+ "bugs": {
59
+ "url": "https://github.com/joystyk-games/game-libraries/issues"
60
+ },
61
+ "scripts": {
62
+ "typecheck": "tsc --noEmit",
63
+ "build": "node ../../scripts/build-package.ts"
19
64
  }
20
- }
65
+ }