@lagless/relay-server 0.0.33

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.
@@ -0,0 +1,491 @@
1
+ import { createLogger } from '@lagless/misc';
2
+ import { TickInputKind, HeaderSchema, packServerHello, packPong, packStateResponse, } from '@lagless/net-wire';
3
+ import { InputBinarySchema, LE } from '@lagless/binary';
4
+ import { ServerClock } from './server-clock.js';
5
+ import { InputHandler } from './input-handler.js';
6
+ import { StateTransfer } from './state-transfer.js';
7
+ import { PlayerConnection } from './player-connection.js';
8
+ import { LeaveReason, SERVER_SLOT, } from './types.js';
9
+ const log = createLogger('RelayRoom');
10
+ function uuidToBytes(playerId) {
11
+ const hex = playerId.replace(/-/g, '');
12
+ // Valid UUID: 32 hex chars → proper hex-to-bytes conversion
13
+ if (hex.length === 32 && /^[0-9a-fA-F]{32}$/.test(hex)) {
14
+ const bytes = new Uint8Array(16);
15
+ for (let i = 0; i < 16; i++) {
16
+ bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
17
+ }
18
+ return bytes;
19
+ }
20
+ // Fallback: XOR-hash arbitrary string into 16 bytes
21
+ const bytes = new Uint8Array(16);
22
+ for (let i = 0; i < playerId.length; i++) {
23
+ bytes[i % 16] ^= playerId.charCodeAt(i);
24
+ }
25
+ return bytes;
26
+ }
27
+ // ─────────────────────────────────────────────────────────────
28
+ // RelayRoom
29
+ // ─────────────────────────────────────────────────────────────
30
+ /**
31
+ * Manages one match room. Sealed by design — use RoomHooks for game-specific behavior.
32
+ */
33
+ export class RelayRoom {
34
+ matchId;
35
+ roomType;
36
+ _config;
37
+ _hooks;
38
+ _clock;
39
+ _inputHandler;
40
+ _stateTransfer;
41
+ _connections = new Map();
42
+ _playersByPlayerId = new Map();
43
+ _results = new Map();
44
+ _serverEventJournal = [];
45
+ _context;
46
+ _createdAt;
47
+ _seed;
48
+ _scopeJson;
49
+ _inputRegistry;
50
+ _disposed = false;
51
+ _reconnectTimer = null;
52
+ _serverSeq = 1;
53
+ _latencySimulator = null;
54
+ _nextSlot;
55
+ constructor(matchId, roomType, _config, _hooks, inputRegistry, players, seed, scopeJson = '{}') {
56
+ this.matchId = matchId;
57
+ this.roomType = roomType;
58
+ this._config = _config;
59
+ this._hooks = _hooks;
60
+ this._createdAt = performance.now();
61
+ this._seed = seed;
62
+ this._scopeJson = scopeJson;
63
+ this._inputRegistry = inputRegistry;
64
+ this._clock = new ServerClock(_config.tickRateHz);
65
+ this._inputHandler = new InputHandler(this._clock, _config);
66
+ this._stateTransfer = new StateTransfer(_config.stateTransferTimeoutMs);
67
+ this._context = new RoomContextImpl(this);
68
+ // Initialize player slots
69
+ let slot = 0;
70
+ for (const p of players) {
71
+ const info = {
72
+ playerId: p.playerId,
73
+ slot,
74
+ isBot: p.isBot,
75
+ metadata: Object.freeze({ ...p.metadata }),
76
+ };
77
+ const conn = new PlayerConnection(info, null);
78
+ this._connections.set(slot, conn);
79
+ this._playersByPlayerId.set(p.playerId, conn);
80
+ slot++;
81
+ }
82
+ this._nextSlot = slot;
83
+ // Start reconnect expiry checker
84
+ if (_config.reconnectTimeoutMs > 0) {
85
+ this._reconnectTimer = setInterval(() => this.checkReconnectTimeouts(), 1000);
86
+ }
87
+ log.info(`Room ${matchId} created with ${players.length} players`);
88
+ }
89
+ async init() {
90
+ await this._hooks.onRoomCreated?.(this._context);
91
+ // Emit join events for bots — they never connect via WebSocket
92
+ // but the simulation needs PlayerJoined server events for them
93
+ for (const conn of this._connections.values()) {
94
+ if (conn.isBot) {
95
+ await this._hooks.onPlayerJoin?.(this._context, conn.info);
96
+ }
97
+ }
98
+ }
99
+ // ─── Public getters ────────────────────────────────────
100
+ get isDisposed() { return this._disposed; }
101
+ get tick() { return this._clock.tick; }
102
+ get config() { return this._config; }
103
+ get context() { return this._context; }
104
+ get createdAt() { return this._createdAt; }
105
+ get hasOpenSlots() {
106
+ return !this._disposed && this._config.lateJoinEnabled && this._nextSlot < this._config.maxPlayers;
107
+ }
108
+ get latencySimulator() { return this._latencySimulator; }
109
+ set latencySimulator(sim) { this._latencySimulator = sim; }
110
+ getConnectedHumanCount() {
111
+ let count = 0;
112
+ for (const c of this._connections.values()) {
113
+ if (c.isConnected && !c.isBot)
114
+ count++;
115
+ }
116
+ return count;
117
+ }
118
+ getTotalHumanCount() {
119
+ let count = 0;
120
+ for (const c of this._connections.values()) {
121
+ if (!c.isBot)
122
+ count++;
123
+ }
124
+ return count;
125
+ }
126
+ // ─── Late-Join ───────────────────────────────────────────
127
+ addPlayer(playerId, isBot, metadata) {
128
+ if (this._disposed)
129
+ return null;
130
+ if (!this._config.lateJoinEnabled)
131
+ return null;
132
+ if (this._nextSlot >= this._config.maxPlayers)
133
+ return null;
134
+ if (this._playersByPlayerId.has(playerId))
135
+ return null;
136
+ if (this._hooks.shouldAcceptLateJoin?.(this._context, playerId, metadata) === false) {
137
+ return null;
138
+ }
139
+ const slot = this._nextSlot++;
140
+ const info = {
141
+ playerId,
142
+ slot,
143
+ isBot,
144
+ metadata: Object.freeze({ ...metadata }),
145
+ };
146
+ const conn = new PlayerConnection(info, null);
147
+ conn.markDisconnected();
148
+ this._connections.set(slot, conn);
149
+ this._playersByPlayerId.set(playerId, conn);
150
+ log.info(`Late-join: player ${playerId} added to room ${this.matchId} (slot=${slot})`);
151
+ return info;
152
+ }
153
+ // ─── Internal API (used by RoomContextImpl) ───────────
154
+ /** @internal */
155
+ getPlayerInfos() {
156
+ const result = [];
157
+ for (const conn of this._connections.values()) {
158
+ result.push(conn.info);
159
+ }
160
+ return result;
161
+ }
162
+ /** @internal */
163
+ isSlotConnected(slot) {
164
+ return this._connections.get(slot)?.isConnected ?? false;
165
+ }
166
+ /** @internal */
167
+ sendToSlot(slot, message) {
168
+ this._connections.get(slot)?.send(message);
169
+ }
170
+ /** @internal */
171
+ broadcastToAll(message) {
172
+ for (const conn of this._connections.values()) {
173
+ conn.send(message);
174
+ }
175
+ }
176
+ /** @internal */
177
+ requestEndMatch() {
178
+ this.endMatch();
179
+ }
180
+ // ─── Connection Handling ───────────────────────────────
181
+ async handlePlayerConnect(playerId, ws) {
182
+ if (this._disposed)
183
+ return false;
184
+ const conn = this._playersByPlayerId.get(playerId);
185
+ if (!conn) {
186
+ log.warn(`Unknown player ${playerId} tried to connect to room ${this.matchId}`);
187
+ return false;
188
+ }
189
+ if (conn.isConnected) {
190
+ log.warn(`Player ${playerId} already connected to room ${this.matchId}`);
191
+ return false;
192
+ }
193
+ if (conn.isGone) {
194
+ log.warn(`Player ${playerId} reconnect rejected — already gone`);
195
+ return false;
196
+ }
197
+ const isReconnect = conn.hasConnectedBefore;
198
+ if (isReconnect) {
199
+ const canReconnect = this._hooks.shouldAcceptReconnect?.(this._context, playerId) ?? true;
200
+ if (!canReconnect) {
201
+ log.info(`Reconnect denied for player ${playerId}`);
202
+ return false;
203
+ }
204
+ }
205
+ conn.connect(ws);
206
+ // Send ServerHello
207
+ const helloMessage = this.buildServerHello(conn.slot);
208
+ conn.send(helloMessage);
209
+ log.info(`ServerHello sent to slot=${conn.slot} serverTick=${this._clock.tick}`);
210
+ // State sync: state transfer if tick > 0, otherwise journal replay
211
+ const needsStateTransfer = this._clock.tick > 0 && this._config.lateJoinEnabled;
212
+ if (needsStateTransfer) {
213
+ const stateResult = await this._stateTransfer.requestState(this._connections, conn.slot);
214
+ if (stateResult) {
215
+ this.sendStateToPlayer(conn, stateResult);
216
+ // Send journal events that happened AFTER the state snapshot tick —
217
+ // these are not yet reflected in the transferred state
218
+ const postStateEvents = this._serverEventJournal.filter(e => e.tick > stateResult.tick);
219
+ if (postStateEvents.length > 0) {
220
+ log.info(`Post-state journal: ${postStateEvents.length} events (tick > ${stateResult.tick}) to slot=${conn.slot}`);
221
+ this._inputHandler.sendInputBatchToPlayer(postStateEvents, conn);
222
+ }
223
+ }
224
+ else {
225
+ log.warn(`State transfer failed for player ${playerId} — falling back to journal replay`);
226
+ this._inputHandler.sendInputBatchToPlayer(this._serverEventJournal, conn);
227
+ }
228
+ }
229
+ else {
230
+ log.info(`Journal replay: ${this._serverEventJournal.length} events to slot=${conn.slot}`);
231
+ this._inputHandler.sendInputBatchToPlayer(this._serverEventJournal, conn);
232
+ }
233
+ log.info(`Player ${playerId} ${isReconnect ? 'reconnected to' : 'joined'} room ${this.matchId} (slot=${conn.slot})`);
234
+ if (isReconnect) {
235
+ await this._hooks.onPlayerReconnect?.(this._context, conn.info);
236
+ }
237
+ else {
238
+ await this._hooks.onPlayerJoin?.(this._context, conn.info);
239
+ }
240
+ return true;
241
+ }
242
+ async handlePlayerDisconnect(playerId) {
243
+ const conn = this._playersByPlayerId.get(playerId);
244
+ if (!conn || !conn.isConnected)
245
+ return;
246
+ if (this._config.reconnectTimeoutMs > 0) {
247
+ conn.markDisconnected();
248
+ }
249
+ else {
250
+ conn.markGone();
251
+ }
252
+ log.info(`Player ${playerId} disconnected from room ${this.matchId}`);
253
+ await this._hooks.onPlayerLeave?.(this._context, conn.info, LeaveReason.Disconnected);
254
+ this.checkMatchEnd();
255
+ }
256
+ // ─── Message Handling ─────────────────────────────────
257
+ handleMessage(playerId, data) {
258
+ const conn = this._playersByPlayerId.get(playerId);
259
+ if (!conn || !conn.isConnected) {
260
+ log.warn(`handleMessage: player=${playerId} conn=${!!conn} isConnected=${conn?.isConnected}`);
261
+ return;
262
+ }
263
+ if (data.byteLength < HeaderSchema.byteLength) {
264
+ log.warn(`handleMessage: data too short (${data.byteLength} bytes)`);
265
+ return;
266
+ }
267
+ const view = new DataView(data);
268
+ const msgType = view.getUint8(1);
269
+ switch (msgType) {
270
+ case 9: // MsgType.TickInputBatch
271
+ this.handleTickInputBatch(conn, data);
272
+ break;
273
+ case 4: // MsgType.Ping
274
+ this.handlePing(conn, data);
275
+ break;
276
+ case 8: // MsgType.PlayerFinished
277
+ this.handlePlayerFinished(conn, data);
278
+ break;
279
+ case 7: // MsgType.StateResponse
280
+ this.handleStateResponse(conn, data);
281
+ break;
282
+ default:
283
+ log.warn(`Unknown message type ${msgType} from player ${playerId} (dataLen=${data.byteLength})`);
284
+ }
285
+ }
286
+ // ─── Disposal ─────────────────────────────────────────
287
+ async dispose() {
288
+ if (this._disposed)
289
+ return;
290
+ this._disposed = true;
291
+ if (this._reconnectTimer) {
292
+ clearInterval(this._reconnectTimer);
293
+ this._reconnectTimer = null;
294
+ }
295
+ this._stateTransfer.dispose();
296
+ log.info(`Room ${this.matchId} disposing`);
297
+ await this._hooks.onRoomDisposed?.(this._context);
298
+ for (const conn of this._connections.values()) {
299
+ conn.markGone();
300
+ }
301
+ this._connections.clear();
302
+ this._playersByPlayerId.clear();
303
+ }
304
+ // ─── Server Events (called via RoomContext) ────────────
305
+ /** @internal - called by RoomContextImpl */
306
+ _emitServerEvent(inputId, data, tick) {
307
+ // For server events, we pack: inputId (u8) + data fields
308
+ // The payload format matches InputBinarySchema convention
309
+ // but we keep it simple: just the raw bytes the caller provides
310
+ const payloadArrayBuffer = InputBinarySchema.packBatch(this._inputRegistry, [
311
+ {
312
+ inputId,
313
+ ordinal: 0,
314
+ values: data,
315
+ },
316
+ ]);
317
+ // TODO: proper InputBinarySchema packing when game provides registry
318
+ const payload = new Uint8Array(payloadArrayBuffer); // placeholder
319
+ const input = {
320
+ tick,
321
+ playerSlot: SERVER_SLOT,
322
+ seq: this._serverSeq++,
323
+ kind: TickInputKind.Server,
324
+ payload,
325
+ };
326
+ this._serverEventJournal.push(input);
327
+ this._inputHandler.broadcastInput(input, this._connections);
328
+ }
329
+ // ─── Private: Message Handlers ────────────────────────
330
+ handleTickInputBatch(conn, raw) {
331
+ const results = this._inputHandler.validateClientInputBatch(conn.slot, raw);
332
+ const accepted = [];
333
+ for (const result of results) {
334
+ if (result.accepted) {
335
+ accepted.push(result.input);
336
+ }
337
+ else {
338
+ this._inputHandler.sendCancel(conn, result.tick, result.seq, result.reason);
339
+ }
340
+ }
341
+ if (accepted.length > 0) {
342
+ const broadcast = () => this._inputHandler.broadcastInputBatch(accepted, this._connections);
343
+ if (this._latencySimulator) {
344
+ this._latencySimulator.apply(broadcast);
345
+ }
346
+ else {
347
+ broadcast();
348
+ }
349
+ }
350
+ }
351
+ handlePing(conn, raw) {
352
+ const view = new DataView(raw);
353
+ const cSend = view.getFloat64(HeaderSchema.byteLength, LE);
354
+ const now = performance.now();
355
+ const pong = packPong({
356
+ cSend,
357
+ sRecv: now,
358
+ sSend: now,
359
+ sTick: this._clock.tick,
360
+ });
361
+ const send = () => conn.send(pong);
362
+ if (this._latencySimulator) {
363
+ this._latencySimulator.apply(send);
364
+ }
365
+ else {
366
+ send();
367
+ }
368
+ }
369
+ handlePlayerFinished(conn, raw) {
370
+ const view = new DataView(raw);
371
+ const offset = HeaderSchema.byteLength;
372
+ const tick = view.getUint32(offset, LE);
373
+ // offset + 4: playerSlot (u8) — already known from conn.slot
374
+ const payloadLength = view.getUint16(offset + 5, LE);
375
+ const payload = new Uint8Array(raw, offset + 7, payloadLength);
376
+ // Store raw payload as result (game-specific parsing happens in hooks)
377
+ this._results.set(conn.slot, payload.slice());
378
+ log.info(`Player ${conn.playerId} finished at tick ${tick}`);
379
+ this._hooks.onPlayerFinished?.(this._context, conn.info, payload.slice());
380
+ this.checkMatchEnd();
381
+ }
382
+ handleStateResponse(conn, raw) {
383
+ const view = new DataView(raw);
384
+ const offset = HeaderSchema.byteLength;
385
+ const requestId = view.getUint32(offset, LE);
386
+ const tick = view.getUint32(offset + 4, LE);
387
+ const hash = view.getUint32(offset + 8, LE);
388
+ const stateLength = view.getUint32(offset + 12, LE);
389
+ const state = raw.slice(offset + 16, offset + 16 + stateLength);
390
+ this._stateTransfer.receiveResponse(conn.slot, requestId, tick, hash, state);
391
+ }
392
+ // ─── Private: Lifecycle ────────────────────────────────
393
+ checkMatchEnd() {
394
+ const connectedHumans = this.getConnectedHumanCount();
395
+ if (connectedHumans === 0) {
396
+ this.endMatch();
397
+ return;
398
+ }
399
+ const totalHumans = this.getTotalHumanCount();
400
+ let finishedOrGone = 0;
401
+ for (const conn of this._connections.values()) {
402
+ if (conn.isBot)
403
+ continue;
404
+ if (this._results.has(conn.slot) || conn.isGone)
405
+ finishedOrGone++;
406
+ }
407
+ if (finishedOrGone >= totalHumans) {
408
+ this.endMatch();
409
+ }
410
+ }
411
+ async endMatch() {
412
+ log.info(`Match ${this.matchId} ending`);
413
+ await this._hooks.onMatchEnd?.(this._context, this._results);
414
+ await this.dispose();
415
+ }
416
+ checkReconnectTimeouts() {
417
+ for (const conn of this._connections.values()) {
418
+ if (conn.isReconnectExpired(this._config.reconnectTimeoutMs)) {
419
+ conn.markGone();
420
+ log.info(`Reconnect timeout for player ${conn.playerId}`);
421
+ this._hooks.onPlayerLeave?.(this._context, conn.info, LeaveReason.Timeout);
422
+ this.checkMatchEnd();
423
+ }
424
+ }
425
+ }
426
+ buildServerHello(forSlot) {
427
+ const players = Array.from(this._connections.values()).map(conn => ({
428
+ playerId: uuidToBytes(conn.playerId),
429
+ slot: conn.slot,
430
+ isBot: conn.isBot,
431
+ metadataJson: JSON.stringify(conn.info.metadata),
432
+ }));
433
+ return packServerHello({
434
+ seed: this._seed,
435
+ playerSlot: forSlot,
436
+ serverTick: this._clock.tick,
437
+ maxPlayers: this._config.maxPlayers,
438
+ players,
439
+ scopeJson: this._scopeJson,
440
+ });
441
+ }
442
+ sendStateToPlayer(conn, result) {
443
+ const msg = packStateResponse({
444
+ requestId: 0, // not a request from client
445
+ tick: result.tick,
446
+ hash: result.hash,
447
+ state: result.state,
448
+ });
449
+ conn.send(msg);
450
+ }
451
+ }
452
+ // ─────────────────────────────────────────────────────────────
453
+ // RoomContext Implementation
454
+ // ─────────────────────────────────────────────────────────────
455
+ class RoomContextImpl {
456
+ _room;
457
+ constructor(_room) {
458
+ this._room = _room;
459
+ }
460
+ get matchId() { return this._room.matchId; }
461
+ get tick() { return this._room.tick; }
462
+ get config() { return this._room.config; }
463
+ get createdAt() { return this._room.createdAt; }
464
+ emitServerEvent(inputId, data) {
465
+ this._room._emitServerEvent(inputId, data, this._room.tick + 1);
466
+ }
467
+ emitServerEventAt(inputId, data, tick) {
468
+ if (tick < this._room.tick) {
469
+ throw new Error(`Cannot emit server event in the past (tick ${tick} < current ${this._room.tick})`);
470
+ }
471
+ this._room._emitServerEvent(inputId, data, tick);
472
+ }
473
+ getPlayers() {
474
+ return this._room.getPlayerInfos();
475
+ }
476
+ getConnectedPlayerCount() {
477
+ return this._room.getConnectedHumanCount();
478
+ }
479
+ isPlayerConnected(slot) {
480
+ return this._room.isSlotConnected(slot);
481
+ }
482
+ sendTo(slot, message) {
483
+ this._room.sendToSlot(slot, message);
484
+ }
485
+ broadcast(message) {
486
+ this._room.broadcastToAll(message);
487
+ }
488
+ endMatch() {
489
+ this._room.requestEndMatch();
490
+ }
491
+ }
@@ -0,0 +1,23 @@
1
+ import { RelayRoom } from './relay-room.js';
2
+ import type { MatchId, RoomTypeDefinition, CreateMatchRequest, RoomHooks, RoomTypeConfig, InputRegistry } from './types.js';
3
+ /**
4
+ * Manages all rooms on this server instance.
5
+ * Handles room type registration, creation, lookup, and disposal.
6
+ */
7
+ export declare class RoomRegistry {
8
+ private readonly _roomTypes;
9
+ private readonly _rooms;
10
+ private _disposalTimer;
11
+ constructor();
12
+ registerRoomType<TResult>(typeName: string, config: RoomTypeConfig, hooks: RoomHooks<TResult>, inputRegistry: InputRegistry): void;
13
+ createRoom(request: CreateMatchRequest, seed: Uint8Array, scopeJson?: string): Promise<RelayRoom>;
14
+ getRoom(matchId: MatchId): RelayRoom | undefined;
15
+ get roomCount(): number;
16
+ getRoomType(typeName: string): RoomTypeDefinition | undefined;
17
+ private cleanupDisposed;
18
+ findRoomForLateJoin(roomType: string): RelayRoom | undefined;
19
+ /** Iterates all active (non-disposed) rooms. */
20
+ forEachRoom(fn: (room: RelayRoom) => void): void;
21
+ dispose(): Promise<void>;
22
+ }
23
+ //# sourceMappingURL=room-registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"room-registry.d.ts","sourceRoot":"","sources":["../../src/lib/room-registry.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,KAAK,EACV,OAAO,EAAE,kBAAkB,EAAE,kBAAkB,EAC/C,SAAS,EAAE,cAAc,EAAE,aAAa,EACzC,MAAM,YAAY,CAAC;AAIpB;;;GAGG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAyC;IACpE,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiC;IACxD,OAAO,CAAC,cAAc,CAAiC;;IAMhD,gBAAgB,CAAC,OAAO,EAC7B,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,cAAc,EACtB,KAAK,EAAE,SAAS,CAAC,OAAO,CAAC,EACzB,aAAa,EAAE,aAAa,GAC3B,IAAI;IAQM,UAAU,CACrB,OAAO,EAAE,kBAAkB,EAC3B,IAAI,EAAE,UAAU,EAChB,SAAS,SAAO,GACf,OAAO,CAAC,SAAS,CAAC;IA2Bd,OAAO,CAAC,OAAO,EAAE,OAAO,GAAG,SAAS,GAAG,SAAS;IAIvD,IAAW,SAAS,IAAI,MAAM,CAE7B;IAEM,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,kBAAkB,GAAG,SAAS;IAIpE,OAAO,CAAC,eAAe;IAShB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS;IASnE,gDAAgD;IACzC,WAAW,CAAC,EAAE,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,IAAI,GAAG,IAAI;IAM1C,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;CAOtC"}
@@ -0,0 +1,75 @@
1
+ import { createLogger } from '@lagless/misc';
2
+ import { RelayRoom } from './relay-room.js';
3
+ const log = createLogger('RoomRegistry');
4
+ /**
5
+ * Manages all rooms on this server instance.
6
+ * Handles room type registration, creation, lookup, and disposal.
7
+ */
8
+ export class RoomRegistry {
9
+ _roomTypes = new Map();
10
+ _rooms = new Map();
11
+ _disposalTimer;
12
+ constructor() {
13
+ this._disposalTimer = setInterval(() => this.cleanupDisposed(), 10_000);
14
+ }
15
+ registerRoomType(typeName, config, hooks, inputRegistry) {
16
+ if (this._roomTypes.has(typeName)) {
17
+ throw new Error(`Room type "${typeName}" already registered`);
18
+ }
19
+ this._roomTypes.set(typeName, { config, hooks, inputRegistry });
20
+ log.info(`Registered room type "${typeName}"`);
21
+ }
22
+ async createRoom(request, seed, scopeJson = '{}') {
23
+ const roomType = this._roomTypes.get(request.roomType);
24
+ if (!roomType) {
25
+ throw new Error(`Unknown room type "${request.roomType}"`);
26
+ }
27
+ if (this._rooms.has(request.matchId)) {
28
+ throw new Error(`Room "${request.matchId}" already exists`);
29
+ }
30
+ const room = new RelayRoom(request.matchId, request.roomType, roomType.config, roomType.hooks, roomType.inputRegistry, request.players, seed, scopeJson);
31
+ await room.init();
32
+ this._rooms.set(request.matchId, room);
33
+ log.info(`Created room "${request.matchId}" type="${request.roomType}" (total: ${this._rooms.size})`);
34
+ return room;
35
+ }
36
+ getRoom(matchId) {
37
+ return this._rooms.get(matchId);
38
+ }
39
+ get roomCount() {
40
+ return this._rooms.size;
41
+ }
42
+ getRoomType(typeName) {
43
+ return this._roomTypes.get(typeName);
44
+ }
45
+ cleanupDisposed() {
46
+ for (const [id, room] of this._rooms) {
47
+ if (room.isDisposed) {
48
+ this._rooms.delete(id);
49
+ log.info(`Cleaned up disposed room "${id}" (remaining: ${this._rooms.size})`);
50
+ }
51
+ }
52
+ }
53
+ findRoomForLateJoin(roomType) {
54
+ for (const room of this._rooms.values()) {
55
+ if (!room.isDisposed && room.roomType === roomType && room.hasOpenSlots) {
56
+ return room;
57
+ }
58
+ }
59
+ return undefined;
60
+ }
61
+ /** Iterates all active (non-disposed) rooms. */
62
+ forEachRoom(fn) {
63
+ for (const room of this._rooms.values()) {
64
+ if (!room.isDisposed)
65
+ fn(room);
66
+ }
67
+ }
68
+ async dispose() {
69
+ clearInterval(this._disposalTimer);
70
+ for (const room of this._rooms.values()) {
71
+ await room.dispose();
72
+ }
73
+ this._rooms.clear();
74
+ }
75
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Authoritative server clock for a match room.
3
+ * Provides the canonical tick number based on elapsed wall-clock time.
4
+ */
5
+ export declare class ServerClock {
6
+ private readonly _startedAt;
7
+ private readonly _tickMs;
8
+ constructor(tickRateHz: number, startedAt?: number);
9
+ get tickMs(): number;
10
+ get tick(): number;
11
+ get startedAt(): number;
12
+ get elapsedMs(): number;
13
+ }
14
+ //# sourceMappingURL=server-clock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server-clock.d.ts","sourceRoot":"","sources":["../../src/lib/server-clock.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;gBAErB,UAAU,EAAE,MAAM,EAAE,SAAS,SAAoB;IAK7D,IAAW,MAAM,IAAI,MAAM,CAE1B;IAED,IAAW,IAAI,IAAI,MAAM,CAExB;IAED,IAAW,SAAS,IAAI,MAAM,CAE7B;IAED,IAAW,SAAS,IAAI,MAAM,CAE7B;CACF"}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Authoritative server clock for a match room.
3
+ * Provides the canonical tick number based on elapsed wall-clock time.
4
+ */
5
+ export class ServerClock {
6
+ _startedAt;
7
+ _tickMs;
8
+ constructor(tickRateHz, startedAt = performance.now()) {
9
+ this._tickMs = 1000 / tickRateHz;
10
+ this._startedAt = startedAt;
11
+ }
12
+ get tickMs() {
13
+ return this._tickMs;
14
+ }
15
+ get tick() {
16
+ return Math.floor((performance.now() - this._startedAt) / this._tickMs);
17
+ }
18
+ get startedAt() {
19
+ return this._startedAt;
20
+ }
21
+ get elapsedMs() {
22
+ return performance.now() - this._startedAt;
23
+ }
24
+ }
@@ -0,0 +1,47 @@
1
+ import type { PlayerConnection } from './player-connection.js';
2
+ import type { PlayerSlot } from './types.js';
3
+ export interface StateResponse {
4
+ readonly playerSlot: PlayerSlot;
5
+ readonly tick: number;
6
+ readonly hash: number;
7
+ readonly state: ArrayBuffer;
8
+ }
9
+ export interface StateTransferResult {
10
+ readonly state: ArrayBuffer;
11
+ readonly tick: number;
12
+ readonly hash: number;
13
+ readonly votedBy: number;
14
+ readonly totalResponses: number;
15
+ }
16
+ /**
17
+ * Handles state transfer for late-join and reconnect.
18
+ *
19
+ * Flow:
20
+ * 1. New player connects to an active match
21
+ * 2. Server sends StateRequest to all connected clients
22
+ * 3. Clients respond with their snapshot + tick + hash
23
+ * 4. Majority vote selects the correct state
24
+ * 5. Server sends selected state to the new player
25
+ */
26
+ export declare class StateTransfer {
27
+ private readonly _timeoutMs;
28
+ private _nextRequestId;
29
+ private readonly _pendingRequests;
30
+ constructor(_timeoutMs: number);
31
+ /**
32
+ * Request state from connected clients and resolve via majority vote.
33
+ */
34
+ requestState(connections: ReadonlyMap<PlayerSlot, PlayerConnection>, excludeSlot?: PlayerSlot): Promise<StateTransferResult | null>;
35
+ /**
36
+ * Called when a StateResponse message arrives from a client.
37
+ */
38
+ receiveResponse(playerSlot: PlayerSlot, requestId: number, tick: number, hash: number, state: ArrayBuffer): void;
39
+ /**
40
+ * Cancel all pending requests (e.g. on room disposal).
41
+ */
42
+ dispose(): void;
43
+ private resolveRequest;
44
+ private majorityVote;
45
+ private getEligibleRespondents;
46
+ }
47
+ //# sourceMappingURL=state-transfer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state-transfer.d.ts","sourceRoot":"","sources":["../../src/lib/state-transfer.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAM7C,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,UAAU,EAAE,UAAU,CAAC;IAChC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC;CAC7B;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;CACjC;AAYD;;;;;;;;;GASG;AACH,qBAAa,aAAa;IAKtB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAJ7B,OAAO,CAAC,cAAc,CAAK;IAC3B,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAqC;gBAGnD,UAAU,EAAE,MAAM;IAGrC;;OAEG;IACI,YAAY,CACjB,WAAW,EAAE,WAAW,CAAC,UAAU,EAAE,gBAAgB,CAAC,EACtD,WAAW,CAAC,EAAE,UAAU,GACvB,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC;IAiCtC;;OAEG;IACI,eAAe,CACpB,UAAU,EAAE,UAAU,EACtB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,WAAW,GACjB,IAAI;IAoBP;;OAEG;IACI,OAAO,IAAI,IAAI;IAUtB,OAAO,CAAC,cAAc;IAqBtB,OAAO,CAAC,YAAY;IA8BpB,OAAO,CAAC,sBAAsB;CAY/B"}