@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.
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/lib/input-handler.d.ts +48 -0
- package/dist/lib/input-handler.d.ts.map +1 -0
- package/dist/lib/input-handler.js +143 -0
- package/dist/lib/latency-simulator.d.ts +22 -0
- package/dist/lib/latency-simulator.d.ts.map +1 -0
- package/dist/lib/latency-simulator.js +47 -0
- package/dist/lib/player-connection.d.ts +29 -0
- package/dist/lib/player-connection.d.ts.map +1 -0
- package/dist/lib/player-connection.js +62 -0
- package/dist/lib/relay-room.d.ts +71 -0
- package/dist/lib/relay-room.d.ts.map +1 -0
- package/dist/lib/relay-room.js +491 -0
- package/dist/lib/room-registry.d.ts +23 -0
- package/dist/lib/room-registry.d.ts.map +1 -0
- package/dist/lib/room-registry.js +75 -0
- package/dist/lib/server-clock.d.ts +14 -0
- package/dist/lib/server-clock.d.ts.map +1 -0
- package/dist/lib/server-clock.js +24 -0
- package/dist/lib/state-transfer.d.ts +47 -0
- package/dist/lib/state-transfer.d.ts.map +1 -0
- package/dist/lib/state-transfer.js +134 -0
- package/dist/lib/types.d.ts +99 -0
- package/dist/lib/types.d.ts.map +1 -0
- package/dist/lib/types.js +15 -0
- package/dist/tsconfig.lib.tsbuildinfo +1 -0
- package/package.json +39 -0
|
@@ -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"}
|