@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.
- package/README.md +507 -0
- package/dist/Phalanx.d.ts +85 -0
- package/dist/Phalanx.d.ts.map +1 -0
- package/dist/Phalanx.js +600 -0
- package/dist/Phalanx.js.map +1 -0
- package/dist/config/defaults.d.ts +10 -0
- package/dist/config/defaults.d.ts.map +1 -0
- package/dist/config/defaults.js +51 -0
- package/dist/config/defaults.js.map +1 -0
- package/dist/config/validation.d.ts +15 -0
- package/dist/config/validation.d.ts.map +1 -0
- package/dist/config/validation.js +74 -0
- package/dist/config/validation.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/services/GameRoom.d.ts +359 -0
- package/dist/services/GameRoom.d.ts.map +1 -0
- package/dist/services/GameRoom.js +1272 -0
- package/dist/services/GameRoom.js.map +1 -0
- package/dist/services/MatchmakingService.d.ts +102 -0
- package/dist/services/MatchmakingService.d.ts.map +1 -0
- package/dist/services/MatchmakingService.js +286 -0
- package/dist/services/MatchmakingService.js.map +1 -0
- package/dist/services/OAuthExchangeService.d.ts +49 -0
- package/dist/services/OAuthExchangeService.d.ts.map +1 -0
- package/dist/services/OAuthExchangeService.js +96 -0
- package/dist/services/OAuthExchangeService.js.map +1 -0
- package/dist/services/PrivateRoomService.d.ts +136 -0
- package/dist/services/PrivateRoomService.d.ts.map +1 -0
- package/dist/services/PrivateRoomService.js +395 -0
- package/dist/services/PrivateRoomService.js.map +1 -0
- package/dist/services/TokenValidator.d.ts +60 -0
- package/dist/services/TokenValidator.d.ts.map +1 -0
- package/dist/services/TokenValidator.js +213 -0
- package/dist/services/TokenValidator.js.map +1 -0
- package/dist/types/index.d.ts +390 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +6 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/DeterministicRandom.d.ts +88 -0
- package/dist/utils/DeterministicRandom.d.ts.map +1 -0
- package/dist/utils/DeterministicRandom.js +140 -0
- package/dist/utils/DeterministicRandom.js.map +1 -0
- package/dist/utils/FixedMath.d.ts +22 -0
- package/dist/utils/FixedMath.d.ts.map +1 -0
- package/dist/utils/FixedMath.js +26 -0
- package/dist/utils/FixedMath.js.map +1 -0
- package/dist/utils/index.d.ts +7 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +6 -0
- package/dist/utils/index.js.map +1 -0
- 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
|