@phalanx-engine/server 0.1.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 (54) hide show
  1. package/README.md +507 -0
  2. package/dist/Phalanx.d.ts +85 -0
  3. package/dist/Phalanx.d.ts.map +1 -0
  4. package/dist/Phalanx.js +600 -0
  5. package/dist/Phalanx.js.map +1 -0
  6. package/dist/config/defaults.d.ts +10 -0
  7. package/dist/config/defaults.d.ts.map +1 -0
  8. package/dist/config/defaults.js +51 -0
  9. package/dist/config/defaults.js.map +1 -0
  10. package/dist/config/validation.d.ts +15 -0
  11. package/dist/config/validation.d.ts.map +1 -0
  12. package/dist/config/validation.js +74 -0
  13. package/dist/config/validation.js.map +1 -0
  14. package/dist/index.d.ts +12 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +14 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/services/GameRoom.d.ts +359 -0
  19. package/dist/services/GameRoom.d.ts.map +1 -0
  20. package/dist/services/GameRoom.js +1272 -0
  21. package/dist/services/GameRoom.js.map +1 -0
  22. package/dist/services/MatchmakingService.d.ts +102 -0
  23. package/dist/services/MatchmakingService.d.ts.map +1 -0
  24. package/dist/services/MatchmakingService.js +286 -0
  25. package/dist/services/MatchmakingService.js.map +1 -0
  26. package/dist/services/OAuthExchangeService.d.ts +49 -0
  27. package/dist/services/OAuthExchangeService.d.ts.map +1 -0
  28. package/dist/services/OAuthExchangeService.js +96 -0
  29. package/dist/services/OAuthExchangeService.js.map +1 -0
  30. package/dist/services/PrivateRoomService.d.ts +136 -0
  31. package/dist/services/PrivateRoomService.d.ts.map +1 -0
  32. package/dist/services/PrivateRoomService.js +395 -0
  33. package/dist/services/PrivateRoomService.js.map +1 -0
  34. package/dist/services/TokenValidator.d.ts +60 -0
  35. package/dist/services/TokenValidator.d.ts.map +1 -0
  36. package/dist/services/TokenValidator.js +213 -0
  37. package/dist/services/TokenValidator.js.map +1 -0
  38. package/dist/types/index.d.ts +390 -0
  39. package/dist/types/index.d.ts.map +1 -0
  40. package/dist/types/index.js +6 -0
  41. package/dist/types/index.js.map +1 -0
  42. package/dist/utils/DeterministicRandom.d.ts +88 -0
  43. package/dist/utils/DeterministicRandom.d.ts.map +1 -0
  44. package/dist/utils/DeterministicRandom.js +140 -0
  45. package/dist/utils/DeterministicRandom.js.map +1 -0
  46. package/dist/utils/FixedMath.d.ts +22 -0
  47. package/dist/utils/FixedMath.d.ts.map +1 -0
  48. package/dist/utils/FixedMath.js +26 -0
  49. package/dist/utils/FixedMath.js.map +1 -0
  50. package/dist/utils/index.d.ts +7 -0
  51. package/dist/utils/index.d.ts.map +1 -0
  52. package/dist/utils/index.js +6 -0
  53. package/dist/utils/index.js.map +1 -0
  54. package/package.json +62 -0
@@ -0,0 +1,1272 @@
1
+ import { randomBytes } from 'crypto';
2
+ /**
3
+ * Game Room
4
+ * Handles a single match with tick synchronization and command broadcasting
5
+ */
6
+ export class GameRoom {
7
+ id;
8
+ roomId;
9
+ io;
10
+ config;
11
+ players = new Map();
12
+ socketToPlayer = new Map();
13
+ teams;
14
+ eventEmitter;
15
+ currentTick = 0;
16
+ state = 'waiting-for-players';
17
+ createdAt;
18
+ tickInterval = null;
19
+ countdownTimer = null;
20
+ countdownInterval = null;
21
+ /**
22
+ * Set when the match enters `'waiting-for-players'` (i.e. start()
23
+ * was called but at least one participant's socket was offline).
24
+ * Fires after `playersConnectTimeoutMs` and ends the match with
25
+ * `match-end: 'players-not-connected'` so the connected players
26
+ * aren't stranded indefinitely waiting for someone who's never
27
+ * coming back.
28
+ *
29
+ * Cleared in `transitionFromWaitingForPlayers` when everyone's
30
+ * online and we proceed to the countdown, or in `stop()` on shutdown.
31
+ */
32
+ playersConnectTimeout = null;
33
+ /**
34
+ * How long we'll hold the deferred-start state before giving up on
35
+ * absent participants. Long enough to cover a typical mobile
36
+ * "switched to messenger" round-trip, short enough that the
37
+ * connected player isn't staring at a frozen lobby. Pulled from
38
+ * config when available so games can tune it.
39
+ */
40
+ playersConnectTimeoutMs;
41
+ /**
42
+ * Absolute epoch-ms deadline for the countdown, set when the countdown
43
+ * starts and cleared when `game-start` is emitted. Used to compute the
44
+ * remaining seconds for a late-joining socket (e.g. a private-room host
45
+ * whose mobile browser killed the WebSocket while they were sharing the
46
+ * invite link) so the client can render the correct number instead of
47
+ * staying stuck on the last value it saw before disconnecting.
48
+ */
49
+ countdownDeadline = null;
50
+ /**
51
+ * Set to true once `game-start` has been broadcast. A host who recovers
52
+ * after this point needs to synthesize the event locally — we signal
53
+ * that via this flag in the `reconnect-state` payload so the client can
54
+ * transition out of the countdown UI without waiting for a second
55
+ * `game-start` it will never receive.
56
+ */
57
+ gameStartEmitted = false;
58
+ pendingCommands = new Map();
59
+ // Ready handshake: tracks which players have reported ready after asset loading
60
+ readyPlayers = new Set();
61
+ readyTimeout = null;
62
+ readyTimeoutMs;
63
+ // Command buffer for lockstep: Map<tick, { playerId: commands[] }>
64
+ commandBuffer = new Map();
65
+ // Track which players have submitted for each tick
66
+ tickSubmissions = new Map();
67
+ // Track last message timestamp per player (LOCKSTEP-5) - uses real time instead of ticks
68
+ lastMessageTime = new Map();
69
+ // Command history for reconnection (NET-2)
70
+ commandHistory = new Map();
71
+ // Track players who are currently lagging (to avoid spamming events)
72
+ laggingPlayers = new Set();
73
+ // Random seed for deterministic RNG (generated at match creation)
74
+ randomSeed;
75
+ // Track last sequence number per player for input validation (2.1.4)
76
+ lastSequence = new Map();
77
+ // State hashes per tick for desync detection (2.1.3)
78
+ stateHashes = new Map();
79
+ // Track consecutive desyncs for grace period (2.5.3.1)
80
+ consecutiveDesyncs = 0;
81
+ // Resolved desync config with defaults
82
+ desyncConfig;
83
+ // Resolved pause config with defaults
84
+ pauseConfig;
85
+ // Track number of pauses used by each player
86
+ pauseCount = new Map();
87
+ // Track which player initiated the current pause (for requireSamePlayerToResume)
88
+ pausedByPlayerId = null;
89
+ // Game type for this room (used in per-gameType matchmaking)
90
+ gameType;
91
+ // Resolved tick mode for this room
92
+ tickMode;
93
+ // Turn timeout handle for event mode
94
+ turnTimeout = null;
95
+ constructor(id, io, config, teams, eventEmitter, gameType) {
96
+ this.id = id;
97
+ this.roomId = id;
98
+ this.io = io;
99
+ this.config = config;
100
+ this.teams = teams;
101
+ this.eventEmitter = eventEmitter;
102
+ this.createdAt = new Date();
103
+ this.gameType = gameType;
104
+ this.tickMode = config.tickMode ?? 'continuous';
105
+ // Generate deterministic random seed for this match (32-bit unsigned integer)
106
+ this.randomSeed = randomBytes(4).readUInt32BE();
107
+ // Resolve ready timeout from config
108
+ this.readyTimeoutMs = config.readyTimeoutMs ?? 30000;
109
+ this.playersConnectTimeoutMs = config.playersConnectTimeoutMs ?? 60000;
110
+ // Resolve desync config with defaults
111
+ this.desyncConfig = {
112
+ enabled: config.desync?.enabled ?? true,
113
+ action: config.desync?.action ?? 'end-match',
114
+ gracePeriodTicks: config.desync?.gracePeriodTicks ?? 1,
115
+ };
116
+ // Resolve pause config with defaults
117
+ this.pauseConfig = {
118
+ maxPausesPerPlayer: config.pause?.maxPausesPerPlayer ?? Infinity,
119
+ requireSamePlayerToResume: config.pause?.requireSamePlayerToResume ?? false,
120
+ };
121
+ // Initialize players from teams
122
+ teams.forEach((team, teamId) => {
123
+ team.forEach((qp) => {
124
+ const playerInfo = {
125
+ id: qp.playerId,
126
+ teamId,
127
+ connected: true,
128
+ lastTick: 0,
129
+ };
130
+ this.players.set(qp.playerId, playerInfo);
131
+ this.socketToPlayer.set(qp.socketId, qp.playerId);
132
+ // Initialize activity tracking with current time
133
+ this.lastMessageTime.set(qp.playerId, Date.now());
134
+ });
135
+ });
136
+ }
137
+ /**
138
+ * Start the game room.
139
+ *
140
+ * If every participant's socket is currently live we proceed straight
141
+ * to the countdown. Otherwise the room enters
142
+ * `'waiting-for-players'` — `match-found` and
143
+ * `match-waiting-for-players` are emitted to whoever is online, and
144
+ * the countdown is held back until either:
145
+ *
146
+ * - every player has reconnected (via `GameRoom.handleReconnect`,
147
+ * typically driven by the engine's room/match-recover handlers
148
+ * for the private-room case), at which point we transition to
149
+ * the countdown; OR
150
+ *
151
+ * - `playersConnectTimeoutMs` elapses and we end the match with
152
+ * `match-end: 'players-not-connected'`, freeing the participants
153
+ * who DID show up.
154
+ *
155
+ * Putting this gate inside `GameRoom` (rather than at the
156
+ * matchmaking / private-room layer) means every flow that builds a
157
+ * match — public matchmaking, private invites, future custom
158
+ * lobbies — gets the same "don't drop a single player into a
159
+ * countdown alone" guarantee for free.
160
+ */
161
+ start() {
162
+ // Wire each player's socket: assign socket.data, join the room,
163
+ // and reflect their actual connection state in `players`. The
164
+ // constructor optimistically initialises everyone as connected,
165
+ // but the socket they were tracked under may have died between
166
+ // the matchmaking decision and now (mobile suspension etc.).
167
+ this.teams.forEach((team, teamId) => {
168
+ const teammateIds = team.map((p) => p.playerId);
169
+ const opponentIds = this.teams
170
+ .filter((_, i) => i !== teamId)
171
+ .flat()
172
+ .map((p) => p.playerId);
173
+ team.forEach((player) => {
174
+ const socket = this.io.sockets.sockets.get(player.socketId);
175
+ const playerInfo = this.players.get(player.playerId);
176
+ if (socket) {
177
+ // Assign match data to socket
178
+ const socketData = socket.data;
179
+ socketData.matchId = this.id;
180
+ socketData.playerId = player.playerId;
181
+ socketData.teamId = teamId;
182
+ socketData.teammates = teammateIds.filter((id) => id !== player.playerId);
183
+ socketData.opponents = opponentIds;
184
+ // Join the room
185
+ void socket.join(this.roomId);
186
+ if (playerInfo)
187
+ playerInfo.connected = true;
188
+ }
189
+ else if (playerInfo) {
190
+ // Socket is gone — this player will need to recover before
191
+ // we can begin. They'll show up in `match-waiting-for-players`.
192
+ playerInfo.connected = false;
193
+ }
194
+ });
195
+ });
196
+ // Always emit personalized match-found to currently-connected
197
+ // players. Reconnecting absent players will receive theirs from
198
+ // `handleReconnect` once they come back.
199
+ this.notifyMatchFound();
200
+ if (this.areAllPlayersConnected()) {
201
+ // Happy path — everyone is here, run the original immediate-
202
+ // countdown flow.
203
+ this.state = 'countdown';
204
+ this.startGameCountdown();
205
+ return;
206
+ }
207
+ // Defer: at least one socket is missing. Sit in
208
+ // `'waiting-for-players'` and announce who we're waiting on.
209
+ this.state = 'waiting-for-players';
210
+ this.notifyWaitingForPlayers();
211
+ this.playersConnectTimeout = setTimeout(() => {
212
+ this.playersConnectTimeout = null;
213
+ // Recheck inside the timer in case everyone reconnected during
214
+ // a pending microtask between schedule and fire.
215
+ if (this.state !== 'waiting-for-players')
216
+ return;
217
+ const missing = this.getDisconnectedPlayerIds();
218
+ console.log(`[GameRoom ${this.id}] players-connect timeout — ${missing.length} player(s) never returned: ${missing.join(', ')}`);
219
+ this.io.to(this.roomId).emit('match-end', {
220
+ reason: 'players-not-connected',
221
+ });
222
+ this.state = 'finished';
223
+ this.eventEmitter('match-ended', this.id, 'players-not-connected');
224
+ }, this.playersConnectTimeoutMs);
225
+ }
226
+ /**
227
+ * Drive the deferred-start state machine forward: invoked from
228
+ * `handleReconnect` once a previously-missing player has rebound
229
+ * their socket. If everyone is now present, cancels the players-
230
+ * connect timeout and transitions into the countdown; otherwise
231
+ * just re-broadcasts the updated waiting-for-players list.
232
+ */
233
+ maybeBeginCountdownAfterReconnect() {
234
+ if (this.state !== 'waiting-for-players')
235
+ return;
236
+ if (this.areAllPlayersConnected()) {
237
+ this.clearPlayersReconnectTimeout('all players connected before countdown');
238
+ console.log(`[GameRoom ${this.id}] all players connected — starting countdown`);
239
+ this.state = 'countdown';
240
+ this.startGameCountdown();
241
+ }
242
+ else {
243
+ // Still missing someone — refresh the announcement so any
244
+ // already-connected client UI (e.g. "waiting for X, Y…") can
245
+ // update its label.
246
+ this.notifyWaitingForPlayers();
247
+ }
248
+ }
249
+ /** True iff every participant's `connected` flag is set. */
250
+ areAllPlayersConnected() {
251
+ for (const p of this.players.values()) {
252
+ if (!p.connected)
253
+ return false;
254
+ }
255
+ return true;
256
+ }
257
+ /** PlayerIds whose `connected` flag is currently false. */
258
+ getDisconnectedPlayerIds() {
259
+ const out = [];
260
+ for (const [id, p] of this.players) {
261
+ if (!p.connected)
262
+ out.push(id);
263
+ }
264
+ return out;
265
+ }
266
+ /**
267
+ * Broadcast `match-waiting-for-players` to every currently-connected
268
+ * socket in the room, listing which playerIds we're still waiting on.
269
+ * Uses `this.io.to(this.roomId).emit(...)` so socket.io handles room
270
+ * routing to currently connected sockets while we include the canonical
271
+ * `matchId` in the payload, consistent with `notifyMatchFound`.
272
+ */
273
+ notifyWaitingForPlayers() {
274
+ const missing = this.getDisconnectedPlayerIds();
275
+ this.io.to(this.roomId).emit('match-waiting-for-players', {
276
+ matchId: this.id,
277
+ missingPlayerIds: missing,
278
+ });
279
+ }
280
+ /**
281
+ * Emit a lifecycle event directly to every currently-connected player.
282
+ *
283
+ * `match-found` is already sent as a direct socket event, while the
284
+ * countdown used to depend on Socket.IO room membership established a
285
+ * few lines earlier in `start()`. Mobile transports can race around
286
+ * polling/websocket upgrade or a brief reconnect: the host still sees
287
+ * `match-found` and switches to the countdown screen, but misses the
288
+ * room broadcast that should advance it. Direct delivery keeps the
289
+ * pre-game lifecycle level with the personalized match-found path.
290
+ */
291
+ emitLifecycleToConnectedPlayers(eventName, payload) {
292
+ for (const [socketId, playerId] of this.socketToPlayer) {
293
+ const player = this.players.get(playerId);
294
+ if (!player?.connected)
295
+ continue;
296
+ const socket = this.io.sockets.sockets.get(socketId);
297
+ socket?.emit(eventName, payload);
298
+ }
299
+ }
300
+ /**
301
+ * Start the game countdown
302
+ * Emits countdown events (5, 4, 3, 2, 1, 0) every second, then game-start
303
+ */
304
+ startGameCountdown() {
305
+ if (this.config.countdownSeconds <= 0) {
306
+ // Skip countdown entirely — go straight to waiting-for-ready.
307
+ // No deadline to record: the countdown phase is effectively zero
308
+ // length, so a reconnecting client should observe `gameStartEmitted`.
309
+ this.gameStartEmitted = true;
310
+ this.emitLifecycleToConnectedPlayers('game-start', {
311
+ matchId: this.id,
312
+ randomSeed: this.randomSeed,
313
+ });
314
+ this.enterWaitingForReady();
315
+ return;
316
+ }
317
+ let countdown = this.config.countdownSeconds;
318
+ // Record the absolute wall-clock deadline so a reconnecting socket
319
+ // can compute its own remaining-seconds value without having to wait
320
+ // for the next tick of the 1Hz broadcast.
321
+ this.countdownDeadline = Date.now() + countdown * 1000;
322
+ // Emit initial countdown
323
+ this.emitLifecycleToConnectedPlayers('countdown', { seconds: countdown });
324
+ countdown--;
325
+ this.countdownInterval = setInterval(() => {
326
+ this.emitLifecycleToConnectedPlayers('countdown', { seconds: countdown });
327
+ countdown--;
328
+ if (countdown < 0) {
329
+ if (this.countdownInterval) {
330
+ clearInterval(this.countdownInterval);
331
+ this.countdownInterval = null;
332
+ }
333
+ // The countdown phase is over — clear the deadline and mark the
334
+ // game-start as emitted so a late recover falls into the
335
+ // synthesize-locally path rather than waiting for an event that
336
+ // will never fire again.
337
+ this.countdownDeadline = null;
338
+ this.gameStartEmitted = true;
339
+ // Emit game-start event with random seed for deterministic RNG
340
+ this.emitLifecycleToConnectedPlayers('game-start', {
341
+ matchId: this.id,
342
+ randomSeed: this.randomSeed,
343
+ });
344
+ this.enterWaitingForReady();
345
+ }
346
+ }, 1000);
347
+ }
348
+ /**
349
+ * Transition to waiting-for-ready state.
350
+ * The tick loop will not begin until all clients send 'client-ready'.
351
+ */
352
+ enterWaitingForReady() {
353
+ this.state = 'waiting-for-ready';
354
+ this.readyPlayers.clear();
355
+ this.armReadyTimeout();
356
+ }
357
+ armReadyTimeout() {
358
+ if (this.readyTimeout) {
359
+ clearTimeout(this.readyTimeout);
360
+ }
361
+ this.readyTimeout = setTimeout(() => {
362
+ this.endMatchDueToReadyTimeout();
363
+ }, this.readyTimeoutMs);
364
+ }
365
+ clearReadyTimeout(reason) {
366
+ if (!this.readyTimeout)
367
+ return;
368
+ clearTimeout(this.readyTimeout);
369
+ this.readyTimeout = null;
370
+ }
371
+ armPlayersReconnectTimeout(reason) {
372
+ if (this.playersConnectTimeout)
373
+ return;
374
+ this.playersConnectTimeout = setTimeout(() => {
375
+ this.playersConnectTimeout = null;
376
+ if (this.state !== 'waiting-for-players' && this.state !== 'waiting-for-ready') {
377
+ return;
378
+ }
379
+ const missing = this.getDisconnectedPlayerIds();
380
+ if (missing.length === 0) {
381
+ if (this.state === 'waiting-for-ready' && !this.readyTimeout) {
382
+ this.armReadyTimeout();
383
+ }
384
+ return;
385
+ }
386
+ console.log(`[GameRoom ${this.id}] players reconnect timeout — ${missing.length} player(s) never returned: ${missing.join(', ')}`);
387
+ this.io.to(this.roomId).emit('match-end', {
388
+ reason: 'players-not-connected',
389
+ });
390
+ this.state = 'finished';
391
+ this.eventEmitter('match-ended', this.id, 'players-not-connected');
392
+ }, this.playersConnectTimeoutMs);
393
+ }
394
+ clearPlayersReconnectTimeout(reason) {
395
+ if (!this.playersConnectTimeout)
396
+ return;
397
+ clearTimeout(this.playersConnectTimeout);
398
+ this.playersConnectTimeout = null;
399
+ }
400
+ /**
401
+ * Notify all players that a match has been found
402
+ * Each player receives personalized data about their teammates and opponents
403
+ */
404
+ notifyMatchFound() {
405
+ this.teams.forEach((team, teamId) => {
406
+ team.forEach((player) => {
407
+ const socket = this.io.sockets.sockets.get(player.socketId);
408
+ if (socket) {
409
+ const payload = this.buildMatchFoundPayloadForTeam(player.playerId, teamId, team);
410
+ if (payload)
411
+ socket.emit('match-found', payload);
412
+ }
413
+ });
414
+ });
415
+ }
416
+ /**
417
+ * Build the personalized `match-found` payload for `playerId`.
418
+ *
419
+ * Used by the initial broadcast in {@link notifyMatchFound} and by
420
+ * retroactive delivery when a host who was offline at `joinRoom`
421
+ * time finally reconnects via `room-recover`. Returns `null` if
422
+ * the player is not in this match.
423
+ */
424
+ buildMatchFoundPayload(playerId) {
425
+ for (let teamId = 0; teamId < this.teams.length; teamId++) {
426
+ const team = this.teams[teamId];
427
+ if (team && team.some((p) => p.playerId === playerId)) {
428
+ return this.buildMatchFoundPayloadForTeam(playerId, teamId, team);
429
+ }
430
+ }
431
+ return null;
432
+ }
433
+ buildMatchFoundPayloadForTeam(playerId, teamId, team) {
434
+ const teammates = team
435
+ .filter((p) => p.playerId !== playerId)
436
+ .map((p) => ({ playerId: p.playerId, username: p.username }));
437
+ const opponents = this.teams
438
+ .filter((_, i) => i !== teamId)
439
+ .flat()
440
+ .map((p) => ({ playerId: p.playerId, username: p.username }));
441
+ return {
442
+ matchId: this.id,
443
+ playerId,
444
+ teamId,
445
+ teammates,
446
+ opponents,
447
+ };
448
+ }
449
+ /**
450
+ * Handle a player reporting ready after asset loading.
451
+ *
452
+ * The match only starts once *every* player in the room is both
453
+ * connected and has explicitly reported `client-ready`
454
+ * (see `areAllPlayersConnectedAndReady`). If any player is
455
+ * disconnected (e.g. mid-reconnect during `waiting-for-ready`),
456
+ * the gate stays closed even if all currently-connected players
457
+ * are ready.
458
+ *
459
+ * Once the gate opens:
460
+ * - In `continuous` tick mode the tick loop is started.
461
+ * - In `event` tick mode no tick loop is created; the turn
462
+ * timeout is armed via `resetTurnTimeout()` instead.
463
+ *
464
+ * @param playerId - The player reporting ready
465
+ */
466
+ handlePlayerReady(playerId) {
467
+ const player = this.players.get(playerId);
468
+ if (!player || !player.connected) {
469
+ return;
470
+ }
471
+ if (this.state !== 'waiting-for-ready') {
472
+ return;
473
+ }
474
+ // Ignore duplicate ready signals
475
+ if (this.readyPlayers.has(playerId)) {
476
+ return;
477
+ }
478
+ this.readyPlayers.add(playerId);
479
+ // Broadcast player-ready to the room so clients can update loading screens
480
+ this.io.to(this.roomId).emit('player-ready', { playerId });
481
+ if (this.areAllPlayersConnectedAndReady()) {
482
+ this.clearReadyTimeout('all players ready');
483
+ this.startGame();
484
+ }
485
+ }
486
+ /**
487
+ * End the match because not all clients reported ready within the timeout.
488
+ */
489
+ endMatchDueToReadyTimeout() {
490
+ this.readyTimeout = null;
491
+ const disconnectedPlayerIds = this.getDisconnectedPlayerIds();
492
+ if (disconnectedPlayerIds.length > 0) {
493
+ this.armPlayersReconnectTimeout('ready timeout while players disconnected');
494
+ return;
495
+ }
496
+ this.stop(true);
497
+ this.io.to(this.roomId).emit('match-end', {
498
+ reason: 'ready-timeout',
499
+ });
500
+ this.eventEmitter('match-ended', this.id, 'ready-timeout');
501
+ }
502
+ /**
503
+ * Start the actual game (after countdown)
504
+ */
505
+ startGame() {
506
+ this.state = 'playing';
507
+ this.currentTick = 0;
508
+ // Reset activity timestamps for all players at game start
509
+ const now = Date.now();
510
+ for (const playerId of this.players.keys()) {
511
+ this.lastMessageTime.set(playerId, now);
512
+ }
513
+ // Emit match-started event
514
+ this.eventEmitter('match-started', this.getMatchInfo());
515
+ if (this.tickMode === 'continuous') {
516
+ // Start tick loop for continuous mode
517
+ const tickIntervalMs = 1000 / this.config.tickRate;
518
+ this.tickInterval = setInterval(() => {
519
+ this.processTick();
520
+ }, tickIntervalMs);
521
+ }
522
+ else {
523
+ // Event mode: no tick loop — start turn timeout
524
+ this.resetTurnTimeout();
525
+ }
526
+ }
527
+ /**
528
+ * Process a single tick
529
+ */
530
+ processTick() {
531
+ // Broadcast tick-sync to all players every tick
532
+ this.io.to(this.roomId).emit('tick-sync', {
533
+ tick: this.currentTick,
534
+ timestamp: Date.now(),
535
+ });
536
+ // Check for lagging/disconnected players (LOCKSTEP-5)
537
+ this.checkPlayerTimeouts();
538
+ const commands = this.pendingCommands.get(this.currentTick) || [];
539
+ // Sort for deterministic order across all clients:
540
+ // 1. Primary: by playerId (alphabetical)
541
+ // 2. Secondary: by command type (alphabetical) for stable ordering
542
+ // This ensures all clients process commands in exactly the same order
543
+ commands.sort((a, b) => {
544
+ const playerCompare = a.playerId.localeCompare(b.playerId);
545
+ if (playerCompare !== 0)
546
+ return playerCompare;
547
+ // Same player - sort by command type for stability
548
+ return a.type.localeCompare(b.type);
549
+ });
550
+ // Store command history for reconnection (NET-2)
551
+ this.storeCommandHistory(this.currentTick, commands);
552
+ // Broadcast commands batch to all players
553
+ this.io.to(this.roomId).emit('commands-batch', {
554
+ tick: this.currentTick,
555
+ commands,
556
+ });
557
+ // Clean up old commands and tick data
558
+ this.pendingCommands.delete(this.currentTick);
559
+ this.clearOldTicks(this.currentTick);
560
+ // Advance tick
561
+ this.currentTick++;
562
+ }
563
+ /**
564
+ * Handle a player command
565
+ */
566
+ handleCommand(playerId, command) {
567
+ const player = this.players.get(playerId);
568
+ if (!player || this.state !== 'playing') {
569
+ return false;
570
+ }
571
+ // Validate tick range
572
+ const tickDiff = command.tick - this.currentTick;
573
+ if (tickDiff < -this.config.maxTickBehind ||
574
+ tickDiff > this.config.maxTickAhead) {
575
+ return false;
576
+ }
577
+ // Let external handlers validate
578
+ const result = this.eventEmitter('player-command', playerId, command);
579
+ if (result === false) {
580
+ return false;
581
+ }
582
+ // Store command for the specified tick
583
+ const targetTick = Math.max(command.tick, this.currentTick);
584
+ if (!this.pendingCommands.has(targetTick)) {
585
+ this.pendingCommands.set(targetTick, []);
586
+ }
587
+ this.pendingCommands.get(targetTick).push(command);
588
+ // Update player's last tick
589
+ player.lastTick = command.tick;
590
+ return true;
591
+ }
592
+ /**
593
+ * Validate command sequence number (2.1.4)
594
+ * Returns true if sequence is valid, false otherwise
595
+ */
596
+ validateCommandSequence(playerId, command) {
597
+ // If command has no sequence, accept it (backward compatibility)
598
+ if (command.sequence === undefined) {
599
+ return true;
600
+ }
601
+ const lastSeq = this.lastSequence.get(playerId) ?? -1;
602
+ const expectedSeq = lastSeq + 1;
603
+ if (command.sequence !== expectedSeq) {
604
+ return false;
605
+ }
606
+ // Update last sequence
607
+ this.lastSequence.set(playerId, command.sequence);
608
+ return true;
609
+ }
610
+ /**
611
+ * Receive commands from a player for a specific tick (LOCKSTEP-2)
612
+ * Commands can be empty if player has no actions for this tick.
613
+ * This is normal - units may be moving/idle and player doesn't need to input anything.
614
+ */
615
+ receivePlayerCommands(playerId, tick, commands) {
616
+ const player = this.players.get(playerId);
617
+ if (!player || this.state !== 'playing') {
618
+ return { accepted: false };
619
+ }
620
+ // Update activity tracking (LOCKSTEP-5) - any message = player is alive
621
+ this.updatePlayerActivity(playerId);
622
+ // Validate tick range - can't submit for ticks too far in the past or future
623
+ const tickDiff = tick - this.currentTick;
624
+ if (tickDiff < -this.config.maxTickBehind ||
625
+ tickDiff > this.config.maxTickAhead) {
626
+ return { accepted: false };
627
+ }
628
+ // Validate input sequences if enabled (2.1.4)
629
+ const validCommands = [];
630
+ const invalidCommands = [];
631
+ if (this.config.validateInputSequence) {
632
+ for (const cmd of commands) {
633
+ if (!this.validateCommandSequence(playerId, cmd)) {
634
+ invalidCommands.push(cmd);
635
+ }
636
+ else {
637
+ validCommands.push(cmd);
638
+ }
639
+ }
640
+ }
641
+ else {
642
+ // No validation - accept all commands
643
+ validCommands.push(...commands);
644
+ }
645
+ // Get or create tick entry in command buffer
646
+ if (!this.commandBuffer.has(tick)) {
647
+ this.commandBuffer.set(tick, {});
648
+ }
649
+ const tickData = this.commandBuffer.get(tick);
650
+ // Store commands for this player (can be empty array - this is valid)
651
+ tickData[playerId] = validCommands;
652
+ // Track submission
653
+ if (!this.tickSubmissions.has(tick)) {
654
+ this.tickSubmissions.set(tick, new Set());
655
+ }
656
+ this.tickSubmissions.get(tick).add(playerId);
657
+ // Update player's last tick
658
+ player.lastTick = tick;
659
+ // Let external handlers process each command
660
+ for (const command of validCommands) {
661
+ this.eventEmitter('player-command', playerId, command);
662
+ }
663
+ if (this.tickMode === 'event') {
664
+ // Event mode: immediately broadcast commands and advance tick
665
+ const commands = [...validCommands];
666
+ // Normalize per-command ticks to match the batch tick
667
+ for (const cmd of commands) {
668
+ cmd.tick = this.currentTick;
669
+ }
670
+ commands.sort((a, b) => {
671
+ const playerCompare = a.playerId.localeCompare(b.playerId);
672
+ if (playerCompare !== 0)
673
+ return playerCompare;
674
+ return a.type.localeCompare(b.type);
675
+ });
676
+ this.storeCommandHistory(this.currentTick, commands);
677
+ this.io.to(this.roomId).emit('commands-batch', {
678
+ tick: this.currentTick,
679
+ commands,
680
+ });
681
+ this.currentTick++;
682
+ // Prune stale commandBuffer/tickSubmissions entries to prevent memory leak
683
+ // (processTick is never called in event mode, so clearOldTicks must run here)
684
+ this.clearOldTicks(this.currentTick);
685
+ this.resetTurnTimeout();
686
+ }
687
+ else {
688
+ // Continuous mode: add to pending commands for next tick broadcast
689
+ const targetTick = Math.max(tick, this.currentTick);
690
+ if (!this.pendingCommands.has(targetTick)) {
691
+ this.pendingCommands.set(targetTick, []);
692
+ }
693
+ this.pendingCommands.get(targetTick).push(...validCommands);
694
+ }
695
+ return {
696
+ accepted: true,
697
+ invalidCommands: invalidCommands.length > 0 ? invalidCommands : undefined,
698
+ };
699
+ }
700
+ /**
701
+ * Get all commands for a specific tick
702
+ */
703
+ getCommandsForTick(tick) {
704
+ return this.commandBuffer.get(tick) || null;
705
+ }
706
+ /**
707
+ * Check if all players have submitted for a specific tick
708
+ */
709
+ allPlayersSubmittedForTick(tick) {
710
+ const submissions = this.tickSubmissions.get(tick);
711
+ if (!submissions) {
712
+ return false;
713
+ }
714
+ for (const [playerId, playerInfo] of this.players) {
715
+ if (playerInfo.connected && !submissions.has(playerId)) {
716
+ return false;
717
+ }
718
+ }
719
+ return true;
720
+ }
721
+ /**
722
+ * Get which players have submitted for a specific tick
723
+ */
724
+ getSubmissionsForTick(tick) {
725
+ return this.tickSubmissions.get(tick) || new Set();
726
+ }
727
+ /**
728
+ * Clean up old ticks after they've been processed
729
+ */
730
+ clearOldTicks(beforeTick) {
731
+ for (const [tick] of this.commandBuffer) {
732
+ if (tick < beforeTick) {
733
+ this.commandBuffer.delete(tick);
734
+ }
735
+ }
736
+ for (const [tick] of this.tickSubmissions) {
737
+ if (tick < beforeTick) {
738
+ this.tickSubmissions.delete(tick);
739
+ }
740
+ }
741
+ }
742
+ // ============================================================
743
+ // LOCKSTEP-5: Activity Tracking and Timeout Detection
744
+ // ============================================================
745
+ /**
746
+ * Update player activity timestamp (called on any message from player)
747
+ * Uses real time instead of ticks - more reliable with Socket.IO ping/pong
748
+ */
749
+ updatePlayerActivity(playerId) {
750
+ this.lastMessageTime.set(playerId, Date.now());
751
+ // If player was lagging, they're now back
752
+ if (this.laggingPlayers.has(playerId)) {
753
+ this.laggingPlayers.delete(playerId);
754
+ }
755
+ }
756
+ /**
757
+ * Check for lagging/disconnected players (LOCKSTEP-5)
758
+ * Uses real time (ms) instead of ticks for more reliable detection.
759
+ * Skipped entirely in event mode (turn timeout handles inactivity).
760
+ */
761
+ checkPlayerTimeouts() {
762
+ if (this.tickMode === 'event')
763
+ return;
764
+ const now = Date.now();
765
+ // Convert tick-based config to milliseconds
766
+ const lagThresholdMs = (this.config.timeoutTicks / this.config.tickRate) * 1000;
767
+ const disconnectThresholdMs = (this.config.disconnectTicks / this.config.tickRate) * 1000;
768
+ for (const [playerId, playerInfo] of this.players) {
769
+ if (!playerInfo.connected)
770
+ continue;
771
+ const lastMessage = this.lastMessageTime.get(playerId) || 0;
772
+ const msSinceLastMessage = now - lastMessage;
773
+ if (msSinceLastMessage >= disconnectThresholdMs) {
774
+ // Player timed out - mark as disconnected
775
+ this.io.to(this.roomId).emit('player-timeout', {
776
+ playerId,
777
+ lastMessageTime: lastMessage,
778
+ currentTick: this.currentTick,
779
+ msSinceLastMessage,
780
+ });
781
+ playerInfo.connected = false;
782
+ this.laggingPlayers.delete(playerId);
783
+ this.eventEmitter('player-timeout', playerId, this.id);
784
+ }
785
+ else if (msSinceLastMessage >= lagThresholdMs) {
786
+ // Player is lagging - emit warning (only once per lagging period)
787
+ if (!this.laggingPlayers.has(playerId)) {
788
+ this.laggingPlayers.add(playerId);
789
+ this.io.to(this.roomId).emit('player-lagging', {
790
+ playerId,
791
+ currentTick: this.currentTick,
792
+ msSinceLastMessage,
793
+ });
794
+ }
795
+ }
796
+ }
797
+ }
798
+ // ============================================================
799
+ // EVENT MODE: Turn Timeout
800
+ // ============================================================
801
+ /**
802
+ * Reset (or start) the turn timeout for event mode.
803
+ * If no commands arrive within turnTimeoutMs the match ends.
804
+ */
805
+ resetTurnTimeout() {
806
+ if (this.turnTimeout) {
807
+ clearTimeout(this.turnTimeout);
808
+ this.turnTimeout = null;
809
+ }
810
+ const turnTimeoutMs = this.config.turnTimeoutMs ?? 60000;
811
+ this.turnTimeout = setTimeout(() => {
812
+ this.endMatchDueToTurnTimeout();
813
+ }, turnTimeoutMs);
814
+ }
815
+ /**
816
+ * End the match because the turn timeout expired (event mode only).
817
+ */
818
+ endMatchDueToTurnTimeout() {
819
+ this.turnTimeout = null;
820
+ this.stop(true);
821
+ this.io.to(this.roomId).emit('match-end', {
822
+ reason: 'turn-timeout',
823
+ });
824
+ this.eventEmitter('match-ended', this.id, 'turn-timeout');
825
+ }
826
+ // ============================================================
827
+ // NET-2: Command History for Reconnection
828
+ // ============================================================
829
+ /**
830
+ * Store command history for reconnection support (NET-2)
831
+ */
832
+ storeCommandHistory(tick, commands) {
833
+ this.commandHistory.set(tick, [...commands]);
834
+ // Prune old history
835
+ const oldestToKeep = tick - this.config.commandHistoryTicks;
836
+ for (const [historyTick] of this.commandHistory) {
837
+ if (historyTick < oldestToKeep) {
838
+ this.commandHistory.delete(historyTick);
839
+ }
840
+ }
841
+ }
842
+ /**
843
+ * Get recent command history for reconnecting player (NET-2)
844
+ */
845
+ getRecentCommandHistory(fromTick) {
846
+ const history = [];
847
+ for (let tick = fromTick; tick < this.currentTick; tick++) {
848
+ const commands = this.commandHistory.get(tick);
849
+ if (commands) {
850
+ history.push({ tick, commands });
851
+ }
852
+ }
853
+ return history;
854
+ }
855
+ /**
856
+ * Pause the game.
857
+ * Stops the tick loop and broadcasts 'game-paused' to all clients.
858
+ * All clients freeze deterministically because the pause only takes effect
859
+ * when they receive this broadcast (after the last completed tick).
860
+ *
861
+ * @param requestedBy - Player ID who requested the pause
862
+ * @returns true if the game was paused, false if not in a pauseable state or player exceeded pause limit
863
+ */
864
+ pause(requestedBy) {
865
+ if (this.state !== 'playing')
866
+ return false;
867
+ // Check if player has pauses remaining
868
+ const currentPauseCount = this.pauseCount.get(requestedBy) ?? 0;
869
+ if (currentPauseCount >= this.pauseConfig.maxPausesPerPlayer) {
870
+ return false;
871
+ }
872
+ // Stop sending ticks — the current tick has already been emitted
873
+ if (this.tickInterval) {
874
+ clearInterval(this.tickInterval);
875
+ this.tickInterval = null;
876
+ }
877
+ if (this.turnTimeout) {
878
+ clearTimeout(this.turnTimeout);
879
+ this.turnTimeout = null;
880
+ }
881
+ this.state = 'paused';
882
+ // Track who paused and increment their pause count
883
+ this.pausedByPlayerId = requestedBy;
884
+ this.pauseCount.set(requestedBy, currentPauseCount + 1);
885
+ // Broadcast to all clients so they freeze at the same logical point
886
+ this.io.to(this.roomId).emit('game-paused', {
887
+ requestedBy,
888
+ lastTick: this.currentTick - 1,
889
+ });
890
+ this.eventEmitter('match-paused', this.id, requestedBy);
891
+ return true;
892
+ }
893
+ /**
894
+ * Resume the game after a pause.
895
+ * Restarts the tick loop and broadcasts 'game-resumed' to all clients.
896
+ *
897
+ * @param requestedBy - Player ID who requested the resume
898
+ * @returns true if the game was resumed, false if not currently paused or player not allowed to resume
899
+ */
900
+ resume(requestedBy) {
901
+ if (this.state !== 'paused')
902
+ return false;
903
+ // Check if only the player who paused can resume
904
+ if (this.pauseConfig.requireSamePlayerToResume &&
905
+ this.pausedByPlayerId !== null &&
906
+ this.pausedByPlayerId !== requestedBy) {
907
+ return false;
908
+ }
909
+ this.state = 'playing';
910
+ // Clear the paused-by tracker
911
+ this.pausedByPlayerId = null;
912
+ // Reset activity timestamps so no player is flagged as lagging right after resume
913
+ const now = Date.now();
914
+ for (const playerId of this.players.keys()) {
915
+ this.lastMessageTime.set(playerId, now);
916
+ }
917
+ // Broadcast to all clients so they unfreeze
918
+ this.io.to(this.roomId).emit('game-resumed', {
919
+ requestedBy,
920
+ });
921
+ if (this.tickMode === 'continuous') {
922
+ // Restart tick loop
923
+ const tickIntervalMs = 1000 / this.config.tickRate;
924
+ this.tickInterval = setInterval(() => {
925
+ this.processTick();
926
+ }, tickIntervalMs);
927
+ }
928
+ else {
929
+ // Event mode: restart turn timeout
930
+ this.resetTurnTimeout();
931
+ }
932
+ this.eventEmitter('match-resumed', this.id, requestedBy);
933
+ return true;
934
+ }
935
+ /**
936
+ * Get current tick number
937
+ */
938
+ getCurrentTick() {
939
+ return this.currentTick;
940
+ }
941
+ /**
942
+ * Stop the game room
943
+ * @param skipNotify - If true, skip emitting match-ended events (used when already handled)
944
+ */
945
+ stop(skipNotify = false) {
946
+ if (this.tickInterval) {
947
+ clearInterval(this.tickInterval);
948
+ this.tickInterval = null;
949
+ }
950
+ if (this.countdownTimer) {
951
+ clearTimeout(this.countdownTimer);
952
+ this.countdownTimer = null;
953
+ }
954
+ if (this.countdownInterval) {
955
+ clearInterval(this.countdownInterval);
956
+ this.countdownInterval = null;
957
+ }
958
+ if (this.readyTimeout) {
959
+ clearTimeout(this.readyTimeout);
960
+ this.readyTimeout = null;
961
+ }
962
+ if (this.playersConnectTimeout) {
963
+ clearTimeout(this.playersConnectTimeout);
964
+ this.playersConnectTimeout = null;
965
+ }
966
+ if (this.turnTimeout) {
967
+ clearTimeout(this.turnTimeout);
968
+ this.turnTimeout = null;
969
+ }
970
+ this.state = 'finished';
971
+ if (!skipNotify) {
972
+ // Emit match-ended event
973
+ this.eventEmitter('match-ended', this.id, 'stopped');
974
+ // Notify players
975
+ this.io.to(this.roomId).emit('match-end', {
976
+ reason: 'stopped',
977
+ });
978
+ }
979
+ }
980
+ /**
981
+ * Handle player disconnection (NET-2)
982
+ */
983
+ handleDisconnect(socketId) {
984
+ const playerId = this.socketToPlayer.get(socketId);
985
+ if (!playerId) {
986
+ return;
987
+ }
988
+ const player = this.players.get(playerId);
989
+ if (player) {
990
+ player.connected = false;
991
+ this.readyPlayers.delete(playerId);
992
+ if (this.state === 'waiting-for-ready') {
993
+ this.clearReadyTimeout('player disconnected during waiting-for-ready');
994
+ this.armPlayersReconnectTimeout('player disconnected during waiting-for-ready');
995
+ }
996
+ this.eventEmitter('player-disconnected', playerId, this.id);
997
+ // Notify other players with grace period info
998
+ this.io.to(this.roomId).emit('player-disconnected', {
999
+ playerId,
1000
+ matchId: this.id,
1001
+ gracePeriodMs: this.config.reconnectGracePeriodMs,
1002
+ });
1003
+ }
1004
+ }
1005
+ /**
1006
+ * Handle player reconnection (NET-2)
1007
+ */
1008
+ handleReconnect(playerId, socketId) {
1009
+ const player = this.players.get(playerId);
1010
+ if (!player) {
1011
+ return false;
1012
+ }
1013
+ // Update socket mapping
1014
+ for (const [oldSocketId, pid] of this.socketToPlayer.entries()) {
1015
+ if (pid === playerId) {
1016
+ this.socketToPlayer.delete(oldSocketId);
1017
+ break;
1018
+ }
1019
+ }
1020
+ this.socketToPlayer.set(socketId, playerId);
1021
+ player.connected = true;
1022
+ this.laggingPlayers.delete(playerId);
1023
+ // Reconnecting during the startup ready gate invalidates any previous
1024
+ // ready signal from the old socket. The client must confirm readiness
1025
+ // again after its recovered socket is wired and local game state is ready.
1026
+ if (this.state === 'waiting-for-ready') {
1027
+ this.readyPlayers.delete(playerId);
1028
+ }
1029
+ if (this.state === 'waiting-for-ready' && this.areAllPlayersConnected()) {
1030
+ this.clearPlayersReconnectTimeout('all players reconnected during waiting-for-ready');
1031
+ this.armReadyTimeout();
1032
+ }
1033
+ // Update activity timestamp
1034
+ this.lastMessageTime.set(playerId, Date.now());
1035
+ this.eventEmitter('player-reconnected', playerId, this.id);
1036
+ // Join the room
1037
+ const socket = this.io.sockets.sockets.get(socketId);
1038
+ if (socket) {
1039
+ void socket.join(this.roomId);
1040
+ const socketData = socket.data;
1041
+ socketData.matchId = this.id;
1042
+ socketData.playerId = playerId;
1043
+ // If we're still in the deferred-start phase, this player has
1044
+ // never received their `match-found` (the original broadcast in
1045
+ // `start()` only reached live sockets). Send it now, then drive
1046
+ // the wait-for-players → countdown transition. We deliberately
1047
+ // skip the `reconnect-state` snapshot here — there's no tick
1048
+ // history to replay yet, and a `reconnect-state` carrying
1049
+ // `state: 'waiting-for-players'` would just confuse a client
1050
+ // expecting it to mean "the game is in progress".
1051
+ if (this.state === 'waiting-for-players') {
1052
+ const matchFoundPayload = this.buildMatchFoundPayload(playerId);
1053
+ if (matchFoundPayload) {
1054
+ socket.emit('match-found', matchFoundPayload);
1055
+ }
1056
+ socket.to(this.roomId).emit('player-reconnected', { playerId });
1057
+ // Look up the team info for the right teammates/opponents
1058
+ // assignment on socket.data — same fields `start()` would
1059
+ // have set for them on the happy path.
1060
+ for (let teamId = 0; teamId < this.teams.length; teamId++) {
1061
+ const team = this.teams[teamId];
1062
+ if (!team || !team.some((p) => p.playerId === playerId))
1063
+ continue;
1064
+ socketData.teamId = teamId;
1065
+ socketData.teammates = team
1066
+ .map((p) => p.playerId)
1067
+ .filter((id) => id !== playerId);
1068
+ socketData.opponents = this.teams
1069
+ .filter((_, i) => i !== teamId)
1070
+ .flat()
1071
+ .map((p) => p.playerId);
1072
+ break;
1073
+ }
1074
+ this.maybeBeginCountdownAfterReconnect();
1075
+ return true;
1076
+ }
1077
+ // Send reconnect-state with command history (NET-2).
1078
+ //
1079
+ // Also carries a countdown / game-start snapshot so a client who
1080
+ // reconnects mid-countdown can render the correct remaining seconds
1081
+ // instead of freezing on the last value it saw, and a client who
1082
+ // reconnects after `game-start` can synthesize the event locally
1083
+ // (it will never be re-broadcast).
1084
+ const fromTick = Math.max(0, this.currentTick - this.config.commandHistoryTicks);
1085
+ // Use Math.ceil so a deadline 2.1s away is reported as 3, matching
1086
+ // the integer value the periodic `countdown` broadcast would have
1087
+ // emitted at the most recent whole second.
1088
+ const countdownSecondsRemaining = this.countdownDeadline !== null
1089
+ ? Math.max(0, Math.ceil((this.countdownDeadline - Date.now()) / 1000))
1090
+ : null;
1091
+ socket.emit('reconnect-state', {
1092
+ matchId: this.id,
1093
+ currentTick: this.currentTick,
1094
+ state: this.state,
1095
+ players: Array.from(this.players.values()),
1096
+ recentCommands: this.getRecentCommandHistory(fromTick),
1097
+ countdownSecondsRemaining,
1098
+ gameStartEmitted: this.gameStartEmitted,
1099
+ randomSeed: this.randomSeed,
1100
+ });
1101
+ // Notify other players
1102
+ socket.to(this.roomId).emit('player-reconnected', { playerId });
1103
+ }
1104
+ return true;
1105
+ }
1106
+ /**
1107
+ * Returns true only when every player in the room is currently
1108
+ * connected *and* has reported `client-ready`. This is the
1109
+ * authoritative gate for leaving the `waiting-for-ready` state
1110
+ * and starting the match (continuous or event tick mode).
1111
+ *
1112
+ * A disconnected player keeps the gate closed even if all other
1113
+ * players are ready, which is required for correct reconnect
1114
+ * behavior during `waiting-for-ready`.
1115
+ */
1116
+ areAllPlayersConnectedAndReady() {
1117
+ for (const [playerId, info] of this.players.entries()) {
1118
+ if (!info.connected || !this.readyPlayers.has(playerId)) {
1119
+ return false;
1120
+ }
1121
+ }
1122
+ return true;
1123
+ }
1124
+ /**
1125
+ * Whether the given playerId is a participant of this match.
1126
+ * Used by `PrivateRoomService.recoverRoom` to authenticate a
1127
+ * `room-recover` against a deferred / running match without
1128
+ * exposing the players map.
1129
+ */
1130
+ hasPlayer(playerId) {
1131
+ return this.players.has(playerId);
1132
+ }
1133
+ /**
1134
+ * Get match information
1135
+ */
1136
+ getMatchInfo() {
1137
+ return {
1138
+ id: this.id,
1139
+ players: Array.from(this.players.values()),
1140
+ currentTick: this.currentTick,
1141
+ state: this.state,
1142
+ createdAt: this.createdAt,
1143
+ gameType: this.gameType,
1144
+ };
1145
+ }
1146
+ /**
1147
+ * Get the room ID
1148
+ */
1149
+ getId() {
1150
+ return this.id;
1151
+ }
1152
+ /**
1153
+ * Get the random seed for this match
1154
+ * Clients use this to initialize their deterministic RNG
1155
+ */
1156
+ getRandomSeed() {
1157
+ return this.randomSeed;
1158
+ }
1159
+ // ============================================================
1160
+ // STATE HASHING (2.1.3): Desync Detection
1161
+ // ============================================================
1162
+ /**
1163
+ * Receive state hash from a player for a specific tick
1164
+ * @param playerId - The player sending the hash
1165
+ * @param tick - The tick this hash is for
1166
+ * @param hash - The state hash string
1167
+ */
1168
+ receiveStateHash(playerId, tick, hash) {
1169
+ // Only process if state hashing is enabled
1170
+ if (!this.config.enableStateHashing) {
1171
+ return;
1172
+ }
1173
+ const player = this.players.get(playerId);
1174
+ if (!player || this.state !== 'playing') {
1175
+ return;
1176
+ }
1177
+ // Get or create hash map for this tick
1178
+ if (!this.stateHashes.has(tick)) {
1179
+ this.stateHashes.set(tick, new Map());
1180
+ }
1181
+ const tickHashes = this.stateHashes.get(tick);
1182
+ tickHashes.set(playerId, hash);
1183
+ // Check if all connected players have submitted for this tick
1184
+ const connectedPlayers = Array.from(this.players.entries())
1185
+ .filter(([_, p]) => p.connected)
1186
+ .map(([id]) => id);
1187
+ const allSubmitted = connectedPlayers.every((id) => tickHashes.has(id));
1188
+ if (allSubmitted) {
1189
+ this.checkForDesync(tick, tickHashes);
1190
+ // Clean up old hashes
1191
+ this.cleanupOldStateHashes(tick);
1192
+ }
1193
+ }
1194
+ /**
1195
+ * Check if there's a desync at a given tick
1196
+ */
1197
+ checkForDesync(tick, hashes) {
1198
+ const hashValues = Array.from(hashes.values());
1199
+ const allMatch = hashValues.every((h) => h === hashValues[0]);
1200
+ const hashObject = {};
1201
+ hashes.forEach((hash, playerId) => {
1202
+ hashObject[playerId] = hash;
1203
+ });
1204
+ if (!allMatch) {
1205
+ // Increment consecutive desync counter
1206
+ this.consecutiveDesyncs++;
1207
+ // Emit desync event to server handlers
1208
+ this.eventEmitter('desync-detected', this.id, tick, hashObject);
1209
+ // Check if we've exceeded the grace period
1210
+ if (this.consecutiveDesyncs >= this.desyncConfig.gracePeriodTicks) {
1211
+ // Take configured action
1212
+ if (this.desyncConfig.action === 'end-match') {
1213
+ // End the match due to confirmed desync
1214
+ this.endMatchDueToDesync(tick, hashObject);
1215
+ }
1216
+ else {
1217
+ // Log only - broadcast to clients for their logging/debugging
1218
+ this.io.to(this.roomId).emit('hash-comparison', {
1219
+ tick,
1220
+ hashes: hashObject,
1221
+ });
1222
+ }
1223
+ }
1224
+ else {
1225
+ // Still within grace period - just broadcast for logging
1226
+ this.io.to(this.roomId).emit('hash-comparison', {
1227
+ tick,
1228
+ hashes: hashObject,
1229
+ });
1230
+ }
1231
+ }
1232
+ else {
1233
+ // Hashes match - reset consecutive desync counter
1234
+ this.consecutiveDesyncs = 0;
1235
+ // Broadcast successful comparison (for client-side logging if needed)
1236
+ this.io.to(this.roomId).emit('hash-comparison', {
1237
+ tick,
1238
+ hashes: hashObject,
1239
+ });
1240
+ }
1241
+ }
1242
+ /**
1243
+ * End the match due to confirmed desync
1244
+ */
1245
+ endMatchDueToDesync(tick, hashes) {
1246
+ // Stop the game (skip default notification since we handle it here)
1247
+ this.stop(true);
1248
+ // Notify players with desync details
1249
+ this.io.to(this.roomId).emit('match-end', {
1250
+ reason: 'desync',
1251
+ details: {
1252
+ tick,
1253
+ hashes,
1254
+ },
1255
+ winner: null,
1256
+ });
1257
+ // Emit match-ended event
1258
+ this.eventEmitter('match-ended', this.id, 'desync');
1259
+ }
1260
+ /**
1261
+ * Clean up state hashes older than the specified tick
1262
+ */
1263
+ cleanupOldStateHashes(currentTick) {
1264
+ const keepTicks = 10; // Keep last 10 ticks of hashes for debugging
1265
+ for (const [tick] of this.stateHashes) {
1266
+ if (tick < currentTick - keepTicks) {
1267
+ this.stateHashes.delete(tick);
1268
+ }
1269
+ }
1270
+ }
1271
+ }
1272
+ //# sourceMappingURL=GameRoom.js.map