@joystyk/session 0.0.0-stage → 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 (55) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +36 -0
  4. package/README.md +266 -2
  5. package/dist/assistance.d.ts +3 -0
  6. package/dist/assistance.js +20 -0
  7. package/dist/assistance.js.map +1 -0
  8. package/dist/browser/audio-sounds.d.ts +19 -0
  9. package/dist/browser/audio-sounds.js +413 -0
  10. package/dist/browser/audio-sounds.js.map +1 -0
  11. package/dist/browser/audio.d.ts +16 -0
  12. package/dist/browser/audio.js +182 -0
  13. package/dist/browser/audio.js.map +1 -0
  14. package/dist/browser/index.d.ts +48 -0
  15. package/dist/browser/index.js +140 -0
  16. package/dist/browser/index.js.map +1 -0
  17. package/dist/browser/preparer.d.ts +3 -0
  18. package/dist/browser/preparer.js +73 -0
  19. package/dist/browser/preparer.js.map +1 -0
  20. package/dist/browser/rooms.d.ts +35 -0
  21. package/dist/browser/rooms.js +140 -0
  22. package/dist/browser/rooms.js.map +1 -0
  23. package/dist/fixed-step.d.ts +66 -0
  24. package/dist/fixed-step.js +280 -0
  25. package/dist/fixed-step.js.map +1 -0
  26. package/dist/index.d.ts +6 -0
  27. package/dist/index.js +7 -0
  28. package/dist/local.d.ts +130 -0
  29. package/dist/local.js +723 -0
  30. package/dist/local.js.map +1 -0
  31. package/dist/ports.d.ts +70 -0
  32. package/dist/ports.js +10 -0
  33. package/dist/ports.js.map +1 -0
  34. package/dist/publisher.d.ts +11 -0
  35. package/dist/publisher.js +44 -0
  36. package/dist/publisher.js.map +1 -0
  37. package/dist/remote.d.ts +128 -0
  38. package/dist/remote.js +474 -0
  39. package/dist/remote.js.map +1 -0
  40. package/dist/replay.d.ts +65 -0
  41. package/dist/replay.js +199 -0
  42. package/dist/replay.js.map +1 -0
  43. package/dist/rngs.d.ts +22 -0
  44. package/dist/rngs.js +34 -0
  45. package/dist/rngs.js.map +1 -0
  46. package/dist/saves.d.ts +20 -0
  47. package/dist/saves.js +59 -0
  48. package/dist/saves.js.map +1 -0
  49. package/dist/stopwatch.d.ts +22 -0
  50. package/dist/stopwatch.js +51 -0
  51. package/dist/stopwatch.js.map +1 -0
  52. package/dist/values.d.ts +19 -0
  53. package/dist/values.js +66 -0
  54. package/dist/values.js.map +1 -0
  55. package/package.json +56 -3
package/dist/remote.js ADDED
@@ -0,0 +1,474 @@
1
+ import { decode } from "./values.js";
2
+ import { createPublisher } from "./publisher.js";
3
+ import { CloseCode, PROTOCOL_VERSION, ServerMessageSchema } from "@joystyk/rooms/protocol";
4
+ //#region src/remote.ts
5
+ /** Tokens kept in memory only: for tests, and for a page whose storage is blocked. */
6
+ var memoryTokens = () => {
7
+ const tokens = /* @__PURE__ */ new Map();
8
+ return {
9
+ get: (room) => tokens.get(room) ?? null,
10
+ set: (room, token) => void tokens.set(room, token),
11
+ delete: (room) => void tokens.delete(room)
12
+ };
13
+ };
14
+ var NOTICES = {
15
+ offline: "Waiting for the connection to come back.",
16
+ spectator: "You are watching this game.",
17
+ replaced: "This seat is now open in another tab.",
18
+ left: "You left the room.",
19
+ closed: "This room has closed.",
20
+ update: "This room plays a newer version of the game. Reload the page to keep playing.",
21
+ invalid: "That move is not valid.",
22
+ wrongSeat: "You can only move for your own seat."
23
+ };
24
+ var notice = (code, message) => ({
25
+ code,
26
+ message
27
+ });
28
+ /** Greed's backoff: min(10 s, 500 ms · 2ⁿ) plus up to 250 ms of jitter. */
29
+ var reconnectDelay = (attempt, random) => Math.min(1e4, 500 * 2 ** attempt) + Math.floor(random * 250);
30
+ /** A policy close (4008) waits at least this long. */
31
+ var POLICY_DELAY_MS = 15e3;
32
+ var seatInfo = (seat) => seat.controller === "ai" && seat.aiLevel !== null ? {
33
+ id: seat.id,
34
+ name: seat.name,
35
+ controller: "ai",
36
+ aiLevel: seat.aiLevel
37
+ } : {
38
+ id: seat.id,
39
+ name: seat.name,
40
+ controller: seat.controller
41
+ };
42
+ /**
43
+ * Opens a session in a server room. The socket opens at once; the first snapshot arrives
44
+ * after the server's `welcome`. Nothing throws: problems become `meta.notice` and
45
+ * `meta.connection`.
46
+ */
47
+ var createRemoteSession = (options) => {
48
+ const { game, room: code, clock, tokens } = options;
49
+ const report = options.report ?? (() => {});
50
+ const random = options.random ?? (() => .5);
51
+ let status = "connecting";
52
+ let socket = null;
53
+ let generation = 0;
54
+ let attempt = 0;
55
+ let timer = null;
56
+ let retryAfter;
57
+ let disposed = false;
58
+ /** Set once a `welcome` arrived on the current socket. */
59
+ let joined = false;
60
+ let seat = null;
61
+ let revision = 0;
62
+ let epoch = -1;
63
+ let phase = "lobby";
64
+ let host = null;
65
+ let seats = [];
66
+ let settings = {
67
+ decisionMs: null,
68
+ bank: null,
69
+ aiLevel: null
70
+ };
71
+ let pin = null;
72
+ let view = null;
73
+ let pending = [];
74
+ let outcome = null;
75
+ let presentation = null;
76
+ let connected = [];
77
+ let clocks = [];
78
+ let clocksReadAt = clock.now();
79
+ let spectators = 0;
80
+ let paused = false;
81
+ let offer = null;
82
+ /** The server's time when the clocks were read. */
83
+ let serverTime = 0;
84
+ let match = 0;
85
+ let hasRoom = false;
86
+ let currentNotice = null;
87
+ /** Actions sent without a receipt yet: resent with the same ID after a reconnect. */
88
+ const inflight = /* @__PURE__ */ new Map();
89
+ const build = (beats) => ({
90
+ meta: {
91
+ connection: status,
92
+ seat,
93
+ revision,
94
+ seats: seats.map(seatInfo),
95
+ outcome,
96
+ safePoint: phase !== "playing",
97
+ notice: currentNotice,
98
+ clocks
99
+ },
100
+ view,
101
+ beats,
102
+ pending,
103
+ presentation,
104
+ room: hasRoom && pin !== null ? {
105
+ code,
106
+ epoch,
107
+ phase,
108
+ host,
109
+ seats,
110
+ settings,
111
+ game: pin,
112
+ connected,
113
+ spectators,
114
+ paused,
115
+ match,
116
+ clocksReadAt,
117
+ offer: remoteOffer()
118
+ } : null
119
+ });
120
+ function remoteOffer() {
121
+ if (offer === null) return null;
122
+ return {
123
+ ...offer,
124
+ endsInMs: offer.endsAt === null ? null : Math.max(0, offer.endsAt - serverTime)
125
+ };
126
+ }
127
+ const publisher = createPublisher(build([]), {
128
+ freeze: options.freeze ?? true,
129
+ report
130
+ });
131
+ const publish = (beats = []) => publisher.publish(build(beats));
132
+ const send = (message) => {
133
+ if (socket === null) return false;
134
+ try {
135
+ socket.send(JSON.stringify(message));
136
+ return true;
137
+ } catch (error) {
138
+ report(error, "Sending to the room failed.");
139
+ return false;
140
+ }
141
+ };
142
+ const cancelTimer = () => {
143
+ timer?.();
144
+ timer = null;
145
+ };
146
+ /** Stops for good: no more reconnects. */
147
+ const stop = (why) => {
148
+ cancelTimer();
149
+ status = "closed";
150
+ if (why !== null) currentNotice = why;
151
+ const open = socket;
152
+ socket = null;
153
+ generation += 1;
154
+ open?.close(CloseCode.normal, "done");
155
+ };
156
+ const open = () => {
157
+ cancelTimer();
158
+ generation += 1;
159
+ const mine = generation;
160
+ joined = false;
161
+ retryAfter = void 0;
162
+ const events = {
163
+ open: () => {
164
+ if (mine !== generation) return;
165
+ const token = tokens.get(code);
166
+ send({
167
+ v: PROTOCOL_VERSION,
168
+ t: "join",
169
+ room: code,
170
+ game: game.id,
171
+ name: options.name,
172
+ build: game.build,
173
+ ...token === null ? {} : { token },
174
+ ...options.watch === true ? { watch: true } : {}
175
+ });
176
+ },
177
+ message: (text) => {
178
+ if (mine === generation) receive(text);
179
+ },
180
+ close: (closeCode) => {
181
+ if (mine === generation) closed(closeCode);
182
+ }
183
+ };
184
+ try {
185
+ socket = options.connect(events);
186
+ } catch (error) {
187
+ report(error, "Opening the room connection failed.");
188
+ socket = null;
189
+ closed(1006);
190
+ }
191
+ };
192
+ const schedule = (delay) => {
193
+ cancelTimer();
194
+ timer = clock.after(delay, () => {
195
+ timer = null;
196
+ if (!disposed && status !== "closed") open();
197
+ });
198
+ };
199
+ const closed = (closeCode) => {
200
+ socket = null;
201
+ generation += 1;
202
+ if (disposed || status === "closed") return;
203
+ switch (closeCode) {
204
+ case CloseCode.replaced:
205
+ stop(notice("replaced", NOTICES.replaced));
206
+ return publish();
207
+ case CloseCode.leftRoom:
208
+ stop(notice("left", NOTICES.left));
209
+ return publish();
210
+ case CloseCode.noSuchRoom:
211
+ tokens.delete(code);
212
+ stop(notice("closed", NOTICES.closed));
213
+ return publish();
214
+ }
215
+ if (retryAfter === null) {
216
+ stop(notice("closed", NOTICES.closed));
217
+ return publish();
218
+ }
219
+ const delay = closeCode === CloseCode.restarting && retryAfter !== void 0 ? retryAfter : closeCode === CloseCode.policy ? Math.max(POLICY_DELAY_MS, reconnectDelay(attempt, random())) : reconnectDelay(attempt, random());
220
+ attempt += 1;
221
+ status = "reconnecting";
222
+ publish();
223
+ schedule(delay);
224
+ };
225
+ const decodeView = (raw) => {
226
+ if (raw === null) return {
227
+ ok: true,
228
+ value: null
229
+ };
230
+ const decoded = decode(game.schemas.view, raw, "view");
231
+ if (!decoded.ok) {
232
+ report(decoded.error, "The room sent a view this game cannot read.");
233
+ return { ok: false };
234
+ }
235
+ return {
236
+ ok: true,
237
+ value: decoded.value
238
+ };
239
+ };
240
+ const decodeBeats = (beats) => beats.flatMap((beat) => {
241
+ const event = decode(game.schemas.event, beat.event, "event");
242
+ if (!event.ok) {
243
+ report(event.error, "The room sent an event this game cannot read.");
244
+ return [];
245
+ }
246
+ if (beat.view === void 0) return [{ event: event.value }];
247
+ const after = decodeView(beat.view);
248
+ return after.ok && after.value !== null ? [{
249
+ event: event.value,
250
+ view: after.value
251
+ }] : [{ event: event.value }];
252
+ });
253
+ /** A room on a different rules version can't be played with this page's engine. */
254
+ const versionProblem = (other) => other.id !== game.id || other.rulesVersion !== game.rulesVersion;
255
+ const takePresence = (message) => {
256
+ serverTime = message.serverTime;
257
+ connected = message.presence.connected;
258
+ clocks = message.presence.clocks;
259
+ clocksReadAt = clock.now();
260
+ spectators = message.presence.spectators;
261
+ paused = message.presence.paused;
262
+ };
263
+ const onSnapshot = (message) => {
264
+ if (hasRoom && message.epoch === epoch && message.revision < revision) return;
265
+ if (versionProblem(message.game)) {
266
+ stop(notice("update", NOTICES.update));
267
+ return publish();
268
+ }
269
+ const decoded = decodeView(message.view);
270
+ if (!decoded.ok) return;
271
+ const newMatch = message.phase === "playing" && (phase !== "playing" || !hasRoom);
272
+ epoch = message.epoch;
273
+ revision = message.revision;
274
+ phase = message.phase;
275
+ seat = message.seat;
276
+ seats = message.seats;
277
+ host = message.host;
278
+ pin = message.game;
279
+ settings = message.settings;
280
+ view = decoded.value;
281
+ pending = message.pending;
282
+ outcome = message.outcome;
283
+ presentation = message.presentation;
284
+ offer = message.offer ?? null;
285
+ if (newMatch) match += 1;
286
+ takePresence(message);
287
+ hasRoom = true;
288
+ if (status !== "online") {
289
+ status = "online";
290
+ attempt = 0;
291
+ if (currentNotice?.code === "offline") currentNotice = null;
292
+ for (const queued of inflight.values()) send(queued);
293
+ }
294
+ publish();
295
+ };
296
+ const onUpdate = (message) => {
297
+ if (!hasRoom || message.epoch !== epoch) {
298
+ send({
299
+ v: PROTOCOL_VERSION,
300
+ t: "resync"
301
+ });
302
+ return;
303
+ }
304
+ if (message.revision <= revision) return;
305
+ if (message.from !== revision) {
306
+ send({
307
+ v: PROTOCOL_VERSION,
308
+ t: "resync"
309
+ });
310
+ return;
311
+ }
312
+ const decoded = decodeView(message.view);
313
+ if (!decoded.ok) return;
314
+ const startedMatch = message.phase === "playing" && phase !== "playing";
315
+ revision = message.revision;
316
+ phase = message.phase;
317
+ host = message.host;
318
+ if (message.seats !== void 0) seats = message.seats;
319
+ if (message.settings !== void 0) settings = message.settings;
320
+ view = decoded.value;
321
+ pending = message.pending;
322
+ outcome = message.outcome;
323
+ if (message.presentation !== void 0) presentation = message.presentation;
324
+ offer = message.offer ?? null;
325
+ if (startedMatch) match += 1;
326
+ takePresence(message);
327
+ currentNotice = null;
328
+ publish(decodeBeats(message.beats));
329
+ };
330
+ const receive = (text) => {
331
+ let raw;
332
+ try {
333
+ raw = JSON.parse(text);
334
+ } catch (error) {
335
+ return report(error, "The room sent a message that is not JSON.");
336
+ }
337
+ const parsed = ServerMessageSchema.safeParse(raw);
338
+ if (!parsed.success) return report(parsed.error, "The room sent a message this client cannot read.");
339
+ const message = parsed.data;
340
+ switch (message.t) {
341
+ case "welcome":
342
+ joined = true;
343
+ if (message.token !== void 0) tokens.set(code, message.token);
344
+ seat = message.seat;
345
+ host = message.host;
346
+ if (versionProblem(message.game)) {
347
+ stop(notice("update", NOTICES.update));
348
+ publish();
349
+ }
350
+ return;
351
+ case "snapshot": return onSnapshot(message);
352
+ case "update": return onUpdate(message);
353
+ case "receipt":
354
+ inflight.delete(message.id);
355
+ if (message.result.status === "rejected") {
356
+ currentNotice = notice(message.result.code, message.result.message);
357
+ publish();
358
+ }
359
+ return;
360
+ case "presence":
361
+ takePresence(message);
362
+ return publish();
363
+ case "notice":
364
+ currentNotice = notice(message.code, message.message);
365
+ return publish();
366
+ case "replaced":
367
+ stop(notice("replaced", NOTICES.replaced));
368
+ return publish();
369
+ case "closing":
370
+ retryAfter = message.retryAfterMs;
371
+ return;
372
+ }
373
+ };
374
+ /** Sends an action with a fresh ID and the revision the player saw, or says why it can't. */
375
+ const act = (message, needsSeat) => {
376
+ if (status !== "online" || !joined) {
377
+ currentNotice = notice("offline", NOTICES.offline);
378
+ return publish();
379
+ }
380
+ if (needsSeat && seat === null) {
381
+ currentNotice = notice("spectator", NOTICES.spectator);
382
+ return publish();
383
+ }
384
+ const outgoing = message(options.actionId());
385
+ if ("id" in outgoing) inflight.set(outgoing.id, outgoing);
386
+ send(outgoing);
387
+ };
388
+ const dispatch = (input) => {
389
+ if (disposed) return;
390
+ switch (input.kind) {
391
+ case "command": {
392
+ if (input.seat !== void 0 && seat !== null && input.seat !== seat) {
393
+ currentNotice = notice("wrong-seat", NOTICES.wrongSeat);
394
+ return publish();
395
+ }
396
+ const checked = decode(game.schemas.command, input.command, "move");
397
+ if (!checked.ok) {
398
+ currentNotice = notice("bad-command", NOTICES.invalid);
399
+ return publish();
400
+ }
401
+ return act((id) => ({
402
+ v: PROTOCOL_VERSION,
403
+ t: "act",
404
+ id,
405
+ rev: revision,
406
+ command: checked.value
407
+ }), true);
408
+ }
409
+ case "resign": return act((id) => ({
410
+ v: PROTOCOL_VERSION,
411
+ t: "control",
412
+ id,
413
+ rev: revision,
414
+ control: { kind: "resign" }
415
+ }), true);
416
+ case "control": return act((id) => ({
417
+ v: PROTOCOL_VERSION,
418
+ t: "control",
419
+ id,
420
+ rev: revision,
421
+ control: input.control
422
+ }), true);
423
+ case "resync":
424
+ if (status === "online") send({
425
+ v: PROTOCOL_VERSION,
426
+ t: "resync"
427
+ });
428
+ return;
429
+ case "reconnect":
430
+ if (status !== "closed" || currentNotice?.code !== "replaced") return;
431
+ status = "connecting";
432
+ attempt = 0;
433
+ currentNotice = null;
434
+ publish();
435
+ return open();
436
+ case "leave":
437
+ tokens.delete(code);
438
+ if (status === "online" && seat !== null) {
439
+ send({
440
+ v: PROTOCOL_VERSION,
441
+ t: "control",
442
+ id: options.actionId(),
443
+ rev: revision,
444
+ control: { kind: "leave-seat" }
445
+ });
446
+ return;
447
+ }
448
+ stop(notice("left", NOTICES.left));
449
+ return publish();
450
+ default: return;
451
+ }
452
+ };
453
+ open();
454
+ return {
455
+ code,
456
+ get: publisher.get,
457
+ subscribe: publisher.subscribe,
458
+ dispatch,
459
+ dispose: () => {
460
+ if (disposed) return;
461
+ disposed = true;
462
+ cancelTimer();
463
+ const last = socket;
464
+ socket = null;
465
+ generation += 1;
466
+ last?.close(CloseCode.normal, "disposed");
467
+ publisher.close();
468
+ }
469
+ };
470
+ };
471
+ //#endregion
472
+ export { createRemoteSession, memoryTokens, reconnectDelay };
473
+
474
+ //# sourceMappingURL=remote.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"remote.js","names":[],"sources":["../src/remote.ts"],"sourcesContent":["// The remote session client: a Layer 1 `GameSession` whose authority is a server room\n// (room-server.md section 5). It speaks `@joystyk/rooms/protocol` over a socket port: every\n// message it receives is decoded with the protocol's Zod schemas and the game's own view and\n// event schemas, every action carries a fresh action ID and the revision the player saw, and\n// a dropped connection rejoins with the seat token after Greed's backoff. Snapshots follow the\n// same six publishing rules as the local runner; see README.md.\nimport type {\n GameSession,\n Json,\n Notice,\n Outcome,\n SeatClock,\n SeatId,\n SeatInfo,\n SessionMeta,\n Validator,\n ViewBeat,\n} from '@joystyk/contracts';\nimport {\n CloseCode,\n PROTOCOL_VERSION,\n ServerMessageSchema,\n type ClientMessage,\n type GamePin,\n type PublicSeat,\n type RoomControl,\n type RoomOffer,\n type RoomPhase,\n type RoomSettings,\n type ServerMessage,\n type SnapshotMessage,\n type UpdateMessage,\n} from '@joystyk/rooms/protocol';\nimport type { Clock, Reporter } from './ports.ts';\nimport { createPublisher } from './publisher.ts';\nimport { decode } from './values.ts';\n\n// --- Ports ---------------------------------------------------------------------------------------\n\n/** One open connection to the room server. */\nexport interface RemoteSocket {\n send(text: string): void;\n close(code?: number, reason?: string): void;\n}\n\n/** What a socket reports. `close` fires once, also when the connection never opened. */\nexport type SocketEvents = {\n open(): void;\n message(text: string): void;\n close(code: number): void;\n};\n\n/** Opens a connection to the room server's `/ws`. The browser adapter wraps `WebSocket`. */\nexport type SocketFactory = (events: SocketEvents) => RemoteSocket;\n\n/** Where seat tokens live, one per room code. The browser adapter uses local storage, so every tab shares them. */\nexport interface TokenStore {\n get(room: string): string | null;\n set(room: string, token: string): void;\n delete(room: string): void;\n}\n\n/** Tokens kept in memory only: for tests, and for a page whose storage is blocked. */\nexport const memoryTokens = (): TokenStore => {\n const tokens = new Map<string, string>();\n return {\n get: (room) => tokens.get(room) ?? null,\n set: (room, token) => void tokens.set(room, token),\n delete: (room) => void tokens.delete(room),\n };\n};\n\n// --- Inputs and snapshots ------------------------------------------------------------------------\n\n/**\n * What the game's UI and the launcher dispatch. Commands always come from the viewer's seat.\n * `resign` is the platform's resign control. `control` carries a room control (ready,\n * settings, start, offers …) from the launcher's lobby and results. `reconnect` takes the seat back\n * after another tab replaced this one; `leave` gives the seat up and closes the session.\n * Local-only inputs (`pause`, `resume`, `reveal`, `undo`) are ignored: a room never pauses.\n */\nexport type RemoteInput<Command> =\n /** A command for this connection's seat. A named seat must be that seat: a room never acts for another (gp-o2c). */\n | { readonly kind: 'command'; readonly command: Command; readonly seat?: SeatId }\n | { readonly kind: 'resign' }\n | { readonly kind: 'control'; readonly control: RoomControl }\n | { readonly kind: 'reconnect' }\n | { readonly kind: 'leave' }\n | { readonly kind: 'resync' };\n\n/** A rematch or end-match offer, with an end offer's time left measured at `clocksReadAt`. */\nexport type RemoteOffer = RoomOffer & {\n /** An end offer: milliseconds until the match ends if nobody declines. Null for a rematch. */\n readonly endsInMs: number | null;\n};\n\n/** The room around the match: what the launcher's lobby and frame draw. */\nexport type RemoteRoom = {\n readonly code: string;\n readonly epoch: number;\n readonly phase: RoomPhase;\n readonly host: SeatId | null;\n readonly seats: readonly PublicSeat[];\n readonly settings: RoomSettings;\n readonly game: GamePin;\n /** Indexed by seat: whether a player is connected to it. */\n readonly connected: readonly boolean[];\n readonly spectators: number;\n /** No player is connected, so the table is paused and its clocks hold their time (D-58). */\n readonly paused: boolean;\n /** Counts matches in this room; it rises at every start and rematch. */\n readonly match: number;\n /** When the clocks in `meta.clocks` (and the offer's time left) were read, on this session's clock. */\n readonly clocksReadAt: number;\n /** The latest rematch or end-match offer (D-61). */\n readonly offer: RemoteOffer | null;\n};\n\n/** The remote client's snapshot: the contract's `EngineSnapshot`, plus the room. `view` is null in a lobby. */\nexport type RemoteSnapshot<View, Event> = {\n readonly meta: SessionMeta;\n readonly view: View | null;\n readonly beats: readonly ViewBeat<View, Event>[];\n readonly pending: readonly SeatId[];\n readonly presentation: Json | null;\n /** Null until the first snapshot arrives. */\n readonly room: RemoteRoom | null;\n};\n\nexport interface RemoteSession<Command, View, Event> extends GameSession<\n RemoteSnapshot<View, Event>,\n RemoteInput<Command>\n> {\n /** The room code this session plays in. */\n readonly code: string;\n}\n\n/** The engine parts the client needs: its schemas, to decode what the server sends. */\nexport type RemoteGame<Command, View, Event> = {\n readonly id: string;\n /** The build this page carries, sent with `join`. */\n readonly build: string;\n readonly rulesVersion: number;\n readonly schemas: {\n readonly command: Validator<Command>;\n readonly view: Validator<View>;\n readonly event: Validator<Event>;\n };\n};\n\nexport type RemoteSessionOptions<Command, View, Event> = {\n readonly game: RemoteGame<Command, View, Event>;\n /** The six-character room code. */\n readonly room: string;\n /** The display name to claim a seat with (the server filters it again). */\n readonly name: string;\n /** Ask to watch even if a seat is free (D-33). */\n readonly watch?: boolean;\n readonly connect: SocketFactory;\n readonly tokens: TokenStore;\n readonly clock: Clock;\n /** A fresh action ID: 8–40 URL-safe characters, unique for the seat across reloads. */\n readonly actionId: () => string;\n /** A number in [0, 1) for the reconnect jitter. Default: always 0.5. */\n readonly random?: () => number;\n /** Deep-freeze snapshots. Default true. */\n readonly freeze?: boolean;\n readonly report?: Reporter;\n};\n\n// --- Words for players -----------------------------------------------------------------------------\n\nconst NOTICES = {\n offline: 'Waiting for the connection to come back.',\n spectator: 'You are watching this game.',\n replaced: 'This seat is now open in another tab.',\n left: 'You left the room.',\n closed: 'This room has closed.',\n update: 'This room plays a newer version of the game. Reload the page to keep playing.',\n invalid: 'That move is not valid.',\n wrongSeat: 'You can only move for your own seat.',\n} as const;\n\nconst notice = (code: string, message: string): Notice => ({ code, message });\n\n/** Greed's backoff: min(10 s, 500 ms · 2ⁿ) plus up to 250 ms of jitter. */\nexport const reconnectDelay = (attempt: number, random: number): number =>\n Math.min(10_000, 500 * 2 ** attempt) + Math.floor(random * 250);\n\n/** A policy close (4008) waits at least this long. */\nconst POLICY_DELAY_MS = 15_000;\n\nconst seatInfo = (seat: PublicSeat): SeatInfo =>\n seat.controller === 'ai' && seat.aiLevel !== null\n ? { id: seat.id, name: seat.name, controller: 'ai', aiLevel: seat.aiLevel }\n : { id: seat.id, name: seat.name, controller: seat.controller };\n\ntype Status = SessionMeta['connection'];\n\n/**\n * Opens a session in a server room. The socket opens at once; the first snapshot arrives\n * after the server's `welcome`. Nothing throws: problems become `meta.notice` and\n * `meta.connection`.\n */\nexport const createRemoteSession = <Command, View, Event>(\n options: RemoteSessionOptions<Command, View, Event>,\n): RemoteSession<Command, View, Event> => {\n const { game, room: code, clock, tokens } = options;\n const report: Reporter = options.report ?? (() => {});\n const random = options.random ?? (() => 0.5);\n\n let status: Status = 'connecting';\n let socket: RemoteSocket | null = null;\n let generation = 0;\n let attempt = 0;\n let timer: (() => void) | null = null;\n let retryAfter: number | null | undefined;\n let disposed = false;\n /** Set once a `welcome` arrived on the current socket. */\n let joined = false;\n\n let seat: SeatId | null = null;\n let revision = 0;\n let epoch = -1;\n let phase: RoomPhase = 'lobby';\n let host: SeatId | null = null;\n let seats: readonly PublicSeat[] = [];\n let settings: RoomSettings = { decisionMs: null, bank: null, aiLevel: null };\n let pin: GamePin | null = null;\n let view: View | null = null;\n let pending: readonly SeatId[] = [];\n let outcome: Outcome | null = null;\n let presentation: Json | null = null;\n let connected: readonly boolean[] = [];\n let clocks: readonly SeatClock[] = [];\n let clocksReadAt = clock.now();\n let spectators = 0;\n let paused = false;\n let offer: RoomOffer | null = null;\n /** The server's time when the clocks were read. */\n let serverTime = 0;\n let match = 0;\n let hasRoom = false;\n let currentNotice: Notice | null = null;\n /** Actions sent without a receipt yet: resent with the same ID after a reconnect. */\n const inflight = new Map<string, ClientMessage>();\n\n const build = (beats: readonly ViewBeat<View, Event>[]): RemoteSnapshot<View, Event> => ({\n meta: {\n connection: status,\n seat,\n revision,\n seats: seats.map(seatInfo),\n outcome,\n safePoint: phase !== 'playing',\n notice: currentNotice,\n clocks,\n },\n view,\n beats,\n pending,\n presentation,\n room:\n hasRoom && pin !== null\n ? {\n code,\n epoch,\n phase,\n host,\n seats,\n settings,\n game: pin,\n connected,\n spectators,\n paused,\n match,\n clocksReadAt,\n offer: remoteOffer(),\n }\n : null,\n });\n\n function remoteOffer(): RemoteOffer | null {\n if (offer === null) return null;\n return { ...offer, endsInMs: offer.endsAt === null ? null : Math.max(0, offer.endsAt - serverTime) };\n }\n\n const publisher = createPublisher<RemoteSnapshot<View, Event>>(build([]), { freeze: options.freeze ?? true, report });\n const publish = (beats: readonly ViewBeat<View, Event>[] = []): void => publisher.publish(build(beats));\n\n // --- The connection ---------------------------------------------------------------------------\n\n const send = (message: ClientMessage): boolean => {\n if (socket === null) return false;\n try {\n socket.send(JSON.stringify(message));\n return true;\n } catch (error) {\n report(error, 'Sending to the room failed.');\n return false;\n }\n };\n\n const cancelTimer = (): void => {\n timer?.();\n timer = null;\n };\n\n /** Stops for good: no more reconnects. */\n const stop = (why: Notice | null): void => {\n cancelTimer();\n status = 'closed';\n if (why !== null) currentNotice = why;\n const open = socket;\n socket = null;\n generation += 1;\n open?.close(CloseCode.normal, 'done');\n };\n\n const open = (): void => {\n cancelTimer();\n generation += 1;\n const mine = generation;\n joined = false;\n retryAfter = undefined;\n const events: SocketEvents = {\n open: () => {\n if (mine !== generation) return;\n const token = tokens.get(code);\n send({\n v: PROTOCOL_VERSION,\n t: 'join',\n room: code,\n game: game.id,\n name: options.name,\n build: game.build,\n ...(token === null ? {} : { token }),\n ...(options.watch === true ? { watch: true } : {}),\n });\n },\n message: (text) => {\n if (mine === generation) receive(text);\n },\n close: (closeCode) => {\n if (mine === generation) closed(closeCode);\n },\n };\n try {\n socket = options.connect(events);\n } catch (error) {\n report(error, 'Opening the room connection failed.');\n socket = null;\n closed(1006);\n }\n };\n\n const schedule = (delay: number): void => {\n cancelTimer();\n timer = clock.after(delay, () => {\n timer = null;\n if (!disposed && status !== 'closed') open();\n });\n };\n\n const closed = (closeCode: number): void => {\n socket = null;\n generation += 1;\n if (disposed || status === 'closed') return;\n switch (closeCode) {\n case CloseCode.replaced:\n stop(notice('replaced', NOTICES.replaced));\n return publish();\n case CloseCode.leftRoom:\n stop(notice('left', NOTICES.left));\n return publish();\n case CloseCode.noSuchRoom:\n tokens.delete(code);\n stop(notice('closed', NOTICES.closed));\n return publish();\n default:\n break;\n }\n if (retryAfter === null) {\n stop(notice('closed', NOTICES.closed));\n return publish();\n }\n const delay =\n closeCode === CloseCode.restarting && retryAfter !== undefined\n ? retryAfter\n : closeCode === CloseCode.policy\n ? Math.max(POLICY_DELAY_MS, reconnectDelay(attempt, random()))\n : reconnectDelay(attempt, random());\n attempt += 1;\n status = 'reconnecting';\n publish();\n schedule(delay);\n };\n\n // --- What the server sends ----------------------------------------------------------------------\n\n const decodeView = (\n raw: Json | null,\n ): { readonly ok: true; readonly value: View | null } | { readonly ok: false } => {\n if (raw === null) return { ok: true, value: null };\n const decoded = decode(game.schemas.view, raw, 'view');\n if (!decoded.ok) {\n report(decoded.error, 'The room sent a view this game cannot read.');\n return { ok: false };\n }\n return { ok: true, value: decoded.value };\n };\n\n const decodeBeats = (beats: UpdateMessage['beats']): ViewBeat<View, Event>[] =>\n beats.flatMap((beat) => {\n const event = decode(game.schemas.event, beat.event, 'event');\n if (!event.ok) {\n report(event.error, 'The room sent an event this game cannot read.');\n return [];\n }\n if (beat.view === undefined) return [{ event: event.value }];\n const after = decodeView(beat.view);\n return after.ok && after.value !== null ? [{ event: event.value, view: after.value }] : [{ event: event.value }];\n });\n\n /** A room on a different rules version can't be played with this page's engine. */\n const versionProblem = (other: GamePin): boolean => other.id !== game.id || other.rulesVersion !== game.rulesVersion;\n\n const takePresence = (message: Pick<SnapshotMessage, 'presence' | 'serverTime'>): void => {\n serverTime = message.serverTime;\n connected = message.presence.connected;\n clocks = message.presence.clocks;\n clocksReadAt = clock.now();\n spectators = message.presence.spectators;\n paused = message.presence.paused;\n };\n\n const onSnapshot = (message: SnapshotMessage): void => {\n // An older snapshot of the same epoch is a straggler; a new epoch (a restored backup) always wins.\n if (hasRoom && message.epoch === epoch && message.revision < revision) return;\n if (versionProblem(message.game)) {\n stop(notice('update', NOTICES.update));\n return publish();\n }\n const decoded = decodeView(message.view);\n if (!decoded.ok) return;\n // A match began since this client last looked: a start, a rematch, or a first look mid-game.\n const newMatch = message.phase === 'playing' && (phase !== 'playing' || !hasRoom);\n epoch = message.epoch;\n revision = message.revision;\n phase = message.phase;\n seat = message.seat;\n seats = message.seats;\n host = message.host;\n pin = message.game;\n settings = message.settings;\n view = decoded.value;\n pending = message.pending;\n outcome = message.outcome;\n presentation = message.presentation;\n offer = message.offer ?? null;\n if (newMatch) match += 1;\n takePresence(message);\n hasRoom = true;\n if (status !== 'online') {\n status = 'online';\n attempt = 0;\n if (currentNotice?.code === 'offline') currentNotice = null;\n // Actions whose receipt was lost go again with the same ID; the receipt answers them.\n for (const queued of inflight.values()) send(queued);\n }\n publish();\n };\n\n const onUpdate = (message: UpdateMessage): void => {\n if (!hasRoom || message.epoch !== epoch) {\n send({ v: PROTOCOL_VERSION, t: 'resync' });\n return;\n }\n if (message.revision <= revision) return;\n if (message.from !== revision) {\n // A gap can only come from a bug; a fresh snapshot repairs it.\n send({ v: PROTOCOL_VERSION, t: 'resync' });\n return;\n }\n const decoded = decodeView(message.view);\n if (!decoded.ok) return;\n const startedMatch = message.phase === 'playing' && phase !== 'playing';\n revision = message.revision;\n phase = message.phase;\n host = message.host;\n if (message.seats !== undefined) seats = message.seats;\n if (message.settings !== undefined) settings = message.settings;\n view = decoded.value;\n pending = message.pending;\n outcome = message.outcome;\n if (message.presentation !== undefined) presentation = message.presentation;\n offer = message.offer ?? null;\n if (startedMatch) match += 1;\n takePresence(message);\n currentNotice = null;\n publish(decodeBeats(message.beats));\n };\n\n const receive = (text: string): void => {\n let raw: unknown;\n try {\n raw = JSON.parse(text);\n } catch (error) {\n return report(error, 'The room sent a message that is not JSON.');\n }\n const parsed = ServerMessageSchema.safeParse(raw);\n if (!parsed.success) return report(parsed.error, 'The room sent a message this client cannot read.');\n const message = parsed.data as ServerMessage;\n switch (message.t) {\n case 'welcome':\n joined = true;\n if (message.token !== undefined) tokens.set(code, message.token);\n seat = message.seat;\n host = message.host;\n if (versionProblem(message.game)) {\n stop(notice('update', NOTICES.update));\n publish();\n }\n return;\n case 'snapshot':\n return onSnapshot(message);\n case 'update':\n return onUpdate(message);\n case 'receipt': {\n inflight.delete(message.id);\n if (message.result.status === 'rejected') {\n currentNotice = notice(message.result.code, message.result.message);\n publish();\n }\n return;\n }\n case 'presence':\n takePresence(message);\n return publish();\n case 'notice':\n currentNotice = notice(message.code, message.message);\n return publish();\n case 'replaced':\n stop(notice('replaced', NOTICES.replaced));\n return publish();\n case 'closing':\n retryAfter = message.retryAfterMs;\n return;\n }\n };\n\n // --- What the player does -----------------------------------------------------------------------\n\n /** Sends an action with a fresh ID and the revision the player saw, or says why it can't. */\n const act = (message: (id: string) => ClientMessage, needsSeat: boolean): void => {\n if (status !== 'online' || !joined) {\n currentNotice = notice('offline', NOTICES.offline);\n return publish();\n }\n if (needsSeat && seat === null) {\n currentNotice = notice('spectator', NOTICES.spectator);\n return publish();\n }\n const outgoing = message(options.actionId());\n if ('id' in outgoing) inflight.set(outgoing.id, outgoing);\n send(outgoing);\n };\n\n const dispatch = (input: RemoteInput<Command>): void => {\n if (disposed) return;\n switch (input.kind) {\n case 'command': {\n if (input.seat !== undefined && seat !== null && input.seat !== seat) {\n currentNotice = notice('wrong-seat', NOTICES.wrongSeat);\n return publish();\n }\n const checked = decode(game.schemas.command, input.command, 'move');\n if (!checked.ok) {\n currentNotice = notice('bad-command', NOTICES.invalid);\n return publish();\n }\n return act(\n (id) => ({ v: PROTOCOL_VERSION, t: 'act', id, rev: revision, command: checked.value as Json }),\n true,\n );\n }\n case 'resign':\n return act(\n (id) => ({ v: PROTOCOL_VERSION, t: 'control', id, rev: revision, control: { kind: 'resign' } }),\n true,\n );\n case 'control':\n return act((id) => ({ v: PROTOCOL_VERSION, t: 'control', id, rev: revision, control: input.control }), true);\n case 'resync':\n if (status === 'online') send({ v: PROTOCOL_VERSION, t: 'resync' });\n return;\n case 'reconnect':\n if (status !== 'closed' || currentNotice?.code !== 'replaced') return;\n status = 'connecting';\n attempt = 0;\n currentNotice = null;\n publish();\n return open();\n case 'leave':\n tokens.delete(code);\n if (status === 'online' && seat !== null) {\n // The server answers with a 4002 close, which ends the session.\n send({\n v: PROTOCOL_VERSION,\n t: 'control',\n id: options.actionId(),\n rev: revision,\n control: { kind: 'leave-seat' },\n });\n return;\n }\n stop(notice('left', NOTICES.left));\n return publish();\n default:\n // Local-only inputs (pause, resume, reveal, undo) mean nothing in a room.\n return;\n }\n };\n\n open();\n\n return {\n code,\n get: publisher.get,\n subscribe: publisher.subscribe,\n dispatch: dispatch as (input: RemoteInput<Command>) => void,\n dispose: () => {\n if (disposed) return;\n disposed = true;\n cancelTimer();\n const last = socket;\n socket = null;\n generation += 1;\n last?.close(CloseCode.normal, 'disposed');\n publisher.close();\n },\n };\n};\n"],"mappings":"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"}
@@ -0,0 +1,65 @@
1
+ import { type DiscreteEngine, type EngineAssist, type EngineInput, type EngineReplay, type FixedStepSimulation, type GameError, type JobRequest, type Json, type Result, type Rng, type RngState, type SeatInfo, type SimulationReplay, type Transition, type Validator } from '@joystyk/contracts';
2
+ import type { RngFactory } from './ports.js';
3
+ /** One accepted input and what its transition reported. */
4
+ export type InputRecord<Command> = {
5
+ readonly input: EngineInput<Command>;
6
+ /** Jobs the transition requested. */
7
+ readonly jobs: readonly JobRequest[];
8
+ readonly barrier: boolean;
9
+ /** The assistance the transition flagged, if any. */
10
+ readonly assist: EngineAssist | null;
11
+ /** The transition set `startsClock`. */
12
+ readonly startsClock: boolean;
13
+ readonly restartsGame: boolean;
14
+ /** The seats as they were when the input arrived, to tell human commands from AI ones. */
15
+ readonly seatsBefore: readonly SeatInfo[];
16
+ };
17
+ /** The assistance a transition flagged. Anything but a kind an engine may report is ignored. */
18
+ export declare const assistOf: (transition: {
19
+ readonly assist?: EngineAssist;
20
+ }) => EngineAssist | null;
21
+ /** What the runner keeps of one accepted input and its transition. */
22
+ export declare const recordOf: <Command, State, Event>(input: EngineInput<Command>, transition: Transition<State, Event>, seatsBefore: readonly SeatInfo[]) => InputRecord<Command>;
23
+ /** Applies a seat change to the seat list, as the runner shows it in `meta.seats`. */
24
+ export declare const applySeatChange: <Command>(seats: readonly SeatInfo[], input: EngineInput<Command>) => readonly SeatInfo[];
25
+ /**
26
+ * Calls `apply` the way a runner must: a throw becomes an `EngineThrew` error, and on any
27
+ * failure the random source is restored, so a rejected input never consumes randomness that
28
+ * a replay (which skips rejected inputs) would not.
29
+ */
30
+ export declare const safeApply: <State extends Json, Command extends Json, View extends Json, Event extends Json, Config extends Json>(engine: DiscreteEngine<State, Command, View, Event, Config>, state: State, input: EngineInput<Command>, rng: Rng, restoreRng: RngFactory) => {
31
+ readonly result: Result<Transition<State, Event>>;
32
+ readonly rng: Rng;
33
+ readonly thrown?: unknown;
34
+ };
35
+ export type Rebuilt<State, Command> = {
36
+ readonly state: State;
37
+ readonly rng: Rng;
38
+ readonly seats: readonly SeatInfo[];
39
+ readonly records: readonly InputRecord<Command>[];
40
+ };
41
+ /** Runs `init` with the seed, then every input. Any rejection fails the whole replay. */
42
+ export declare const rebuild: <State extends Json, Command extends Json, View extends Json, Event extends Json, Config extends Json>(engine: DiscreteEngine<State, Command, View, Event, Config>, start: {
43
+ readonly config: Config;
44
+ readonly seats: readonly SeatInfo[];
45
+ readonly seed: RngState;
46
+ }, inputs: readonly EngineInput<Command>[], restoreRng: RngFactory) => Result<Rebuilt<State, Command>, GameError>;
47
+ /**
48
+ * Replays a recorded Layer 2 game and returns its final state and random state. The
49
+ * conformance tests compare these with the recorded game's.
50
+ */
51
+ export declare const replayEngine: <State extends Json, Command extends Json, View extends Json, Event extends Json, Config extends Json>(engine: DiscreteEngine<State, Command, View, Event, Config>, replay: EngineReplay<Command, Config>, restoreRng?: RngFactory) => Result<{
52
+ readonly state: State;
53
+ readonly rng: RngState;
54
+ readonly barriers: readonly number[];
55
+ }, GameError>;
56
+ /**
57
+ * Replays a recorded Layer 3 game: `init` with the seed, then `step` up to `endTick`, feeding
58
+ * each tick its inputs in array order.
59
+ */
60
+ export declare const replaySimulation: <State extends Json, Input extends Json, View, Event extends Json, Config extends Json>(simulation: FixedStepSimulation<State, Input, View, Event, Config>, replay: SimulationReplay<Input, Config>, restoreRng?: RngFactory) => Result<{
61
+ readonly state: State;
62
+ readonly rng: RngState;
63
+ }, GameError>;
64
+ /** Parses one input from a stored replay; commands go through the engine's schema. */
65
+ export declare const parseEngineInput: <Command>(value: unknown, command: Validator<Command>) => Result<EngineInput<Command>, GameError>;