csterm-server 1.0.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/dist/GameRunner.d.ts +54 -0
- package/dist/GameRunner.js +998 -0
- package/dist/LockstepRunner.d.ts +39 -0
- package/dist/LockstepRunner.js +276 -0
- package/dist/Room.d.ts +43 -0
- package/dist/Room.js +498 -0
- package/dist/RoomManager.d.ts +29 -0
- package/dist/RoomManager.js +277 -0
- package/dist/VoiceRelay.d.ts +62 -0
- package/dist/VoiceRelay.js +167 -0
- package/dist/hub/HubServer.d.ts +37 -0
- package/dist/hub/HubServer.js +266 -0
- package/dist/hub/PoolRegistry.d.ts +35 -0
- package/dist/hub/PoolRegistry.js +185 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +175 -0
- package/dist/pool/GameServer.d.ts +29 -0
- package/dist/pool/GameServer.js +185 -0
- package/dist/pool/PoolClient.d.ts +48 -0
- package/dist/pool/PoolClient.js +204 -0
- package/dist/protocol.d.ts +433 -0
- package/dist/protocol.js +59 -0
- package/dist/test/HeadlessClient.d.ts +44 -0
- package/dist/test/HeadlessClient.js +196 -0
- package/dist/test/testTeamSync.d.ts +2 -0
- package/dist/test/testTeamSync.js +82 -0
- package/dist/types.d.ts +164 -0
- package/dist/types.js +139 -0
- package/package.json +50 -0
package/dist/Room.js
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
// Single game room for CS-CLI multiplayer server
|
|
2
|
+
import { WebSocket } from 'ws';
|
|
3
|
+
import { serializeServerMessage, } from './protocol.js';
|
|
4
|
+
import { createVec3, } from './types.js';
|
|
5
|
+
import { GameRunner } from './GameRunner.js';
|
|
6
|
+
import { LockstepRunner } from './LockstepRunner.js';
|
|
7
|
+
import { getVoiceRelay, removeVoiceRelay, isVoiceFrame } from './VoiceRelay.js';
|
|
8
|
+
// Enable lockstep mode (set to true to use deterministic lockstep)
|
|
9
|
+
const USE_LOCKSTEP = true;
|
|
10
|
+
// Default map data for dm_arena
|
|
11
|
+
const DEFAULT_MAP = {
|
|
12
|
+
id: 'dm_arena',
|
|
13
|
+
name: 'DM Arena',
|
|
14
|
+
spawnPoints: [
|
|
15
|
+
// DM spawns
|
|
16
|
+
{ position: createVec3(-48, 0.1, -48), angle: 45 * Math.PI / 180, team: 'DM' },
|
|
17
|
+
{ position: createVec3(48, 0.1, -48), angle: 135 * Math.PI / 180, team: 'DM' },
|
|
18
|
+
{ position: createVec3(-48, 0.1, 48), angle: -45 * Math.PI / 180, team: 'DM' },
|
|
19
|
+
{ position: createVec3(48, 0.1, 48), angle: -135 * Math.PI / 180, team: 'DM' },
|
|
20
|
+
{ position: createVec3(-28, 0.1, -28), angle: 45 * Math.PI / 180, team: 'DM' },
|
|
21
|
+
{ position: createVec3(28, 0.1, -28), angle: 135 * Math.PI / 180, team: 'DM' },
|
|
22
|
+
{ position: createVec3(-28, 0.1, 28), angle: -45 * Math.PI / 180, team: 'DM' },
|
|
23
|
+
{ position: createVec3(28, 0.1, 28), angle: -135 * Math.PI / 180, team: 'DM' },
|
|
24
|
+
{ position: createVec3(0, 0.1, -35), angle: Math.PI, team: 'DM' },
|
|
25
|
+
{ position: createVec3(0, 0.1, 35), angle: 0, team: 'DM' },
|
|
26
|
+
{ position: createVec3(-35, 0.1, 0), angle: Math.PI / 2, team: 'DM' },
|
|
27
|
+
{ position: createVec3(35, 0.1, 0), angle: -Math.PI / 2, team: 'DM' },
|
|
28
|
+
// T spawns (south)
|
|
29
|
+
{ position: createVec3(-20, 0.1, 48), angle: 0, team: 'T' },
|
|
30
|
+
{ position: createVec3(0, 0.1, 50), angle: 0, team: 'T' },
|
|
31
|
+
{ position: createVec3(20, 0.1, 48), angle: 0, team: 'T' },
|
|
32
|
+
{ position: createVec3(-35, 0.1, 42), angle: 0, team: 'T' },
|
|
33
|
+
{ position: createVec3(35, 0.1, 42), angle: 0, team: 'T' },
|
|
34
|
+
// CT spawns (north)
|
|
35
|
+
{ position: createVec3(-20, 0.1, -48), angle: Math.PI, team: 'CT' },
|
|
36
|
+
{ position: createVec3(0, 0.1, -50), angle: Math.PI, team: 'CT' },
|
|
37
|
+
{ position: createVec3(20, 0.1, -48), angle: Math.PI, team: 'CT' },
|
|
38
|
+
{ position: createVec3(-35, 0.1, -42), angle: Math.PI, team: 'CT' },
|
|
39
|
+
{ position: createVec3(35, 0.1, -42), angle: Math.PI, team: 'CT' },
|
|
40
|
+
],
|
|
41
|
+
colliders: [
|
|
42
|
+
// Outer walls
|
|
43
|
+
{ min: createVec3(-60, 0, -59), max: createVec3(60, 12, -57) },
|
|
44
|
+
{ min: createVec3(-60, 0, 57), max: createVec3(60, 12, 59) },
|
|
45
|
+
{ min: createVec3(57, 0, -58), max: createVec3(59, 12, 58) },
|
|
46
|
+
{ min: createVec3(-59, 0, -58), max: createVec3(-57, 12, 58) },
|
|
47
|
+
// Central pillar
|
|
48
|
+
{ min: createVec3(-2.5, 0, -2.5), max: createVec3(2.5, 8, 2.5) },
|
|
49
|
+
// Buildings
|
|
50
|
+
{ min: createVec3(-41, 0, -40), max: createVec3(-29, 6, -30) },
|
|
51
|
+
{ min: createVec3(29, 0, -41), max: createVec3(40, 6, -29) },
|
|
52
|
+
{ min: createVec3(-40, 0, 29), max: createVec3(-30, 6, 41) },
|
|
53
|
+
{ min: createVec3(29, 0, 29), max: createVec3(41, 6, 41) },
|
|
54
|
+
],
|
|
55
|
+
bounds: {
|
|
56
|
+
min: createVec3(-60, 0, -60),
|
|
57
|
+
max: createVec3(60, 20, 60),
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
// Bot names
|
|
61
|
+
const BOT_NAMES = [
|
|
62
|
+
'Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo',
|
|
63
|
+
'Foxtrot', 'Golf', 'Hotel', 'India', 'Juliet',
|
|
64
|
+
];
|
|
65
|
+
export class Room {
|
|
66
|
+
id;
|
|
67
|
+
config;
|
|
68
|
+
hostId;
|
|
69
|
+
lastActivity;
|
|
70
|
+
clients = new Map();
|
|
71
|
+
gameRunner = null;
|
|
72
|
+
lockstepRunner = null;
|
|
73
|
+
serverConfig;
|
|
74
|
+
mapData;
|
|
75
|
+
// Team assignments
|
|
76
|
+
teamAssignments = new Map();
|
|
77
|
+
// Voice relay
|
|
78
|
+
voiceRelay;
|
|
79
|
+
constructor(id, config, hostId, serverConfig) {
|
|
80
|
+
this.id = id;
|
|
81
|
+
this.config = config;
|
|
82
|
+
this.hostId = hostId;
|
|
83
|
+
this.serverConfig = serverConfig;
|
|
84
|
+
this.lastActivity = Date.now();
|
|
85
|
+
this.mapData = DEFAULT_MAP;
|
|
86
|
+
this.voiceRelay = getVoiceRelay(id);
|
|
87
|
+
}
|
|
88
|
+
// ============ Player Management ============
|
|
89
|
+
addPlayer(clientId, client) {
|
|
90
|
+
console.log(`[Room] Adding player ${clientId} (${client.name}). Existing clients: ${this.clients.size}`);
|
|
91
|
+
// Helper to send directly to new joiner's socket (since they're not in this.clients yet)
|
|
92
|
+
const sendToNewJoiner = (message) => {
|
|
93
|
+
if (client.socket.readyState === WebSocket.OPEN) {
|
|
94
|
+
try {
|
|
95
|
+
client.socket.send(serializeServerMessage(message));
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
// Ignore send errors
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
// Send existing players to new joiner BEFORE adding them
|
|
103
|
+
for (const [existingId, existingClient] of this.clients) {
|
|
104
|
+
console.log(`[Room] Sending existing player ${existingId} to new joiner ${clientId}`);
|
|
105
|
+
sendToNewJoiner({
|
|
106
|
+
type: 'player_joined',
|
|
107
|
+
playerId: existingId,
|
|
108
|
+
playerName: existingClient.name || 'Player',
|
|
109
|
+
});
|
|
110
|
+
// Send their team assignment
|
|
111
|
+
const existingTeam = this.teamAssignments.get(existingId);
|
|
112
|
+
console.log(`[Room] Existing player ${existingId} team: ${existingTeam}`);
|
|
113
|
+
if (existingTeam) {
|
|
114
|
+
sendToNewJoiner({
|
|
115
|
+
type: 'player_team_changed',
|
|
116
|
+
playerId: existingId,
|
|
117
|
+
team: existingTeam,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
// Also send their ready state
|
|
121
|
+
if (existingClient.isReady) {
|
|
122
|
+
sendToNewJoiner({
|
|
123
|
+
type: 'player_ready',
|
|
124
|
+
playerId: existingId,
|
|
125
|
+
ready: true,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
this.clients.set(clientId, client);
|
|
130
|
+
this.lastActivity = Date.now();
|
|
131
|
+
// Broadcast new player to all OTHER clients
|
|
132
|
+
this.broadcastExcept(clientId, {
|
|
133
|
+
type: 'player_joined',
|
|
134
|
+
playerId: clientId,
|
|
135
|
+
playerName: client.name || 'Player',
|
|
136
|
+
});
|
|
137
|
+
// Assign team
|
|
138
|
+
const team = this.assignTeam(clientId);
|
|
139
|
+
this.teamAssignments.set(clientId, team);
|
|
140
|
+
// Send team assignment
|
|
141
|
+
this.sendToClient(clientId, {
|
|
142
|
+
type: 'assigned_team',
|
|
143
|
+
team,
|
|
144
|
+
});
|
|
145
|
+
// If game is running, spawn the player
|
|
146
|
+
if (USE_LOCKSTEP && this.lockstepRunner) {
|
|
147
|
+
this.lockstepRunner.addPlayer(clientId, client.name || 'Player', team);
|
|
148
|
+
}
|
|
149
|
+
else if (this.gameRunner) {
|
|
150
|
+
this.gameRunner.addPlayer(clientId, client.name || 'Player', team);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
removePlayer(clientId) {
|
|
154
|
+
this.clients.delete(clientId);
|
|
155
|
+
this.teamAssignments.delete(clientId);
|
|
156
|
+
this.lastActivity = Date.now();
|
|
157
|
+
if (USE_LOCKSTEP && this.lockstepRunner) {
|
|
158
|
+
this.lockstepRunner.removePlayer(clientId);
|
|
159
|
+
}
|
|
160
|
+
else if (this.gameRunner) {
|
|
161
|
+
this.gameRunner.removePlayer(clientId);
|
|
162
|
+
}
|
|
163
|
+
// Transfer host if needed
|
|
164
|
+
if (clientId === this.hostId && this.clients.size > 0) {
|
|
165
|
+
this.hostId = this.clients.keys().next().value;
|
|
166
|
+
console.log(`Host transferred to ${this.hostId}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
getPlayerCount() {
|
|
170
|
+
return this.clients.size;
|
|
171
|
+
}
|
|
172
|
+
// ============ Team Management ============
|
|
173
|
+
assignTeam(clientId) {
|
|
174
|
+
// Count current teams
|
|
175
|
+
let tCount = 0;
|
|
176
|
+
let ctCount = 0;
|
|
177
|
+
for (const team of this.teamAssignments.values()) {
|
|
178
|
+
if (team === 'T')
|
|
179
|
+
tCount++;
|
|
180
|
+
else if (team === 'CT')
|
|
181
|
+
ctCount++;
|
|
182
|
+
}
|
|
183
|
+
// Assign to smaller team, or random if equal
|
|
184
|
+
if (tCount < ctCount)
|
|
185
|
+
return 'T';
|
|
186
|
+
if (ctCount < tCount)
|
|
187
|
+
return 'CT';
|
|
188
|
+
return Math.random() < 0.5 ? 'T' : 'CT';
|
|
189
|
+
}
|
|
190
|
+
// ============ Game Control ============
|
|
191
|
+
startGame() {
|
|
192
|
+
if (USE_LOCKSTEP) {
|
|
193
|
+
this.startLockstepGame();
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
this.startServerAuthoritativeGame();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
startLockstepGame() {
|
|
200
|
+
if (this.lockstepRunner) {
|
|
201
|
+
// Already running, don't restart
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
console.log(`[Room] Starting lockstep game in room ${this.id}`);
|
|
205
|
+
// Notify clients game is starting
|
|
206
|
+
this.broadcast({
|
|
207
|
+
type: 'game_starting',
|
|
208
|
+
countdown: 0,
|
|
209
|
+
});
|
|
210
|
+
// Create lockstep runner (no bots in lockstep mode)
|
|
211
|
+
this.lockstepRunner = new LockstepRunner(this.mapData, this.config, this.serverConfig, (msg) => this.broadcast(msg), (clientId, msg) => this.sendToClient(clientId, msg));
|
|
212
|
+
// Add all connected players
|
|
213
|
+
for (const [clientId, client] of this.clients) {
|
|
214
|
+
const team = this.teamAssignments.get(clientId) || 'T';
|
|
215
|
+
this.lockstepRunner.addPlayer(clientId, client.name || 'Player', team);
|
|
216
|
+
}
|
|
217
|
+
// Start the lockstep runner
|
|
218
|
+
this.lockstepRunner.start();
|
|
219
|
+
console.log(`Lockstep game started in room ${this.id}`);
|
|
220
|
+
}
|
|
221
|
+
startServerAuthoritativeGame() {
|
|
222
|
+
if (this.gameRunner) {
|
|
223
|
+
// Already running, don't restart
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
// Notify clients game is starting
|
|
227
|
+
this.broadcast({
|
|
228
|
+
type: 'game_starting',
|
|
229
|
+
countdown: 0, // Start immediately
|
|
230
|
+
});
|
|
231
|
+
// Create game state
|
|
232
|
+
const gameState = this.createInitialGameState();
|
|
233
|
+
// Create game runner
|
|
234
|
+
this.gameRunner = new GameRunner(gameState, this.mapData, this.config, this.serverConfig, (msg) => this.broadcast(msg), (clientId, msg) => this.sendToClient(clientId, msg));
|
|
235
|
+
// Add all connected players
|
|
236
|
+
for (const [clientId, client] of this.clients) {
|
|
237
|
+
const team = this.teamAssignments.get(clientId) || 'T';
|
|
238
|
+
this.gameRunner.addPlayer(clientId, client.name || 'Player', team);
|
|
239
|
+
}
|
|
240
|
+
// Add bots - balance teams based on current player assignments
|
|
241
|
+
let tCount = 0;
|
|
242
|
+
let ctCount = 0;
|
|
243
|
+
for (const team of this.teamAssignments.values()) {
|
|
244
|
+
if (team === 'T')
|
|
245
|
+
tCount++;
|
|
246
|
+
else if (team === 'CT')
|
|
247
|
+
ctCount++;
|
|
248
|
+
}
|
|
249
|
+
for (let i = 0; i < this.config.botCount; i++) {
|
|
250
|
+
const botName = BOT_NAMES[i % BOT_NAMES.length];
|
|
251
|
+
// Assign bot to smaller team
|
|
252
|
+
let team;
|
|
253
|
+
if (tCount < ctCount) {
|
|
254
|
+
team = 'T';
|
|
255
|
+
tCount++;
|
|
256
|
+
}
|
|
257
|
+
else if (ctCount < tCount) {
|
|
258
|
+
team = 'CT';
|
|
259
|
+
ctCount++;
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
// Equal - alternate
|
|
263
|
+
team = i % 2 === 0 ? 'T' : 'CT';
|
|
264
|
+
if (team === 'T')
|
|
265
|
+
tCount++;
|
|
266
|
+
else
|
|
267
|
+
ctCount++;
|
|
268
|
+
}
|
|
269
|
+
this.gameRunner.addBot(botName, team, this.config.botDifficulty);
|
|
270
|
+
}
|
|
271
|
+
// Start the game
|
|
272
|
+
this.gameRunner.start();
|
|
273
|
+
// Notify players
|
|
274
|
+
this.broadcast({
|
|
275
|
+
type: 'phase_change',
|
|
276
|
+
phase: 'warmup',
|
|
277
|
+
roundNumber: 0,
|
|
278
|
+
tScore: 0,
|
|
279
|
+
ctScore: 0,
|
|
280
|
+
});
|
|
281
|
+
console.log(`Server-authoritative game started in room ${this.id}`);
|
|
282
|
+
}
|
|
283
|
+
createInitialGameState() {
|
|
284
|
+
return {
|
|
285
|
+
phase: 'pre_match',
|
|
286
|
+
phaseStartTime: Date.now(),
|
|
287
|
+
roundNumber: 0,
|
|
288
|
+
tScore: 0,
|
|
289
|
+
ctScore: 0,
|
|
290
|
+
roundWinner: null,
|
|
291
|
+
players: new Map(),
|
|
292
|
+
bots: new Map(),
|
|
293
|
+
droppedWeapons: new Map(),
|
|
294
|
+
tick: 0,
|
|
295
|
+
lastBroadcastTick: 0,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
stop() {
|
|
299
|
+
if (this.lockstepRunner) {
|
|
300
|
+
this.lockstepRunner.stop();
|
|
301
|
+
this.lockstepRunner = null;
|
|
302
|
+
}
|
|
303
|
+
if (this.gameRunner) {
|
|
304
|
+
this.gameRunner.stop();
|
|
305
|
+
this.gameRunner = null;
|
|
306
|
+
}
|
|
307
|
+
// Clear voice relay
|
|
308
|
+
this.voiceRelay.clear();
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Full cleanup when room is destroyed
|
|
312
|
+
*/
|
|
313
|
+
destroy() {
|
|
314
|
+
this.stop();
|
|
315
|
+
removeVoiceRelay(this.id);
|
|
316
|
+
}
|
|
317
|
+
// ============ Message Handling ============
|
|
318
|
+
handleMessage(clientId, message) {
|
|
319
|
+
this.lastActivity = Date.now();
|
|
320
|
+
switch (message.type) {
|
|
321
|
+
case 'ready':
|
|
322
|
+
this.handleReady(clientId);
|
|
323
|
+
break;
|
|
324
|
+
case 'start_game':
|
|
325
|
+
this.handleStartGame(clientId);
|
|
326
|
+
break;
|
|
327
|
+
case 'change_team':
|
|
328
|
+
if ('team' in message) {
|
|
329
|
+
this.handleChangeTeam(clientId, message.team);
|
|
330
|
+
}
|
|
331
|
+
break;
|
|
332
|
+
case 'lockstep_input':
|
|
333
|
+
// Handle lockstep input
|
|
334
|
+
if (USE_LOCKSTEP && this.lockstepRunner && 'tick' in message && 'input' in message) {
|
|
335
|
+
this.lockstepRunner.handleLockstepInput(clientId, message.tick, message.input, message.actions || [], message.position || { x: 0, y: 0, z: 0 }, message.yaw || 0, message.pitch || 0, message.health ?? 100, message.isAlive ?? true);
|
|
336
|
+
}
|
|
337
|
+
break;
|
|
338
|
+
case 'lockstep_sync':
|
|
339
|
+
// Handle lockstep sync check
|
|
340
|
+
if (USE_LOCKSTEP && this.lockstepRunner && 'tick' in message && 'hash' in message) {
|
|
341
|
+
this.lockstepRunner.handleSyncCheck(clientId, message.tick, message.hash);
|
|
342
|
+
}
|
|
343
|
+
break;
|
|
344
|
+
case 'input':
|
|
345
|
+
case 'fire':
|
|
346
|
+
case 'reload':
|
|
347
|
+
case 'buy_weapon':
|
|
348
|
+
case 'pickup_weapon':
|
|
349
|
+
case 'drop_weapon':
|
|
350
|
+
case 'select_weapon':
|
|
351
|
+
// Forward to game runner (server-authoritative mode only)
|
|
352
|
+
if (!USE_LOCKSTEP && this.gameRunner) {
|
|
353
|
+
this.gameRunner.handleInput(clientId, message);
|
|
354
|
+
}
|
|
355
|
+
break;
|
|
356
|
+
case 'chat':
|
|
357
|
+
this.handleChat(clientId, message.message, message.teamOnly);
|
|
358
|
+
break;
|
|
359
|
+
default:
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
handleReady(clientId) {
|
|
364
|
+
const client = this.clients.get(clientId);
|
|
365
|
+
if (!client)
|
|
366
|
+
return;
|
|
367
|
+
client.isReady = !client.isReady;
|
|
368
|
+
this.broadcast({
|
|
369
|
+
type: 'player_ready',
|
|
370
|
+
playerId: clientId,
|
|
371
|
+
ready: client.isReady,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
handleStartGame(clientId) {
|
|
375
|
+
// Only host can start
|
|
376
|
+
if (clientId !== this.hostId)
|
|
377
|
+
return;
|
|
378
|
+
// Check if all players are ready (or just the host for now)
|
|
379
|
+
this.startGame();
|
|
380
|
+
}
|
|
381
|
+
handleChangeTeam(clientId, team) {
|
|
382
|
+
if (team !== 'T' && team !== 'CT')
|
|
383
|
+
return;
|
|
384
|
+
this.teamAssignments.set(clientId, team);
|
|
385
|
+
// Send team assignment back to player
|
|
386
|
+
this.sendToClient(clientId, {
|
|
387
|
+
type: 'assigned_team',
|
|
388
|
+
team,
|
|
389
|
+
});
|
|
390
|
+
// Broadcast team change to all players
|
|
391
|
+
this.broadcast({
|
|
392
|
+
type: 'player_team_changed',
|
|
393
|
+
playerId: clientId,
|
|
394
|
+
team,
|
|
395
|
+
});
|
|
396
|
+
console.log(`Player ${clientId} changed to team ${team}`);
|
|
397
|
+
}
|
|
398
|
+
handleChat(clientId, message, teamOnly) {
|
|
399
|
+
const client = this.clients.get(clientId);
|
|
400
|
+
if (!client)
|
|
401
|
+
return;
|
|
402
|
+
const chatMessage = {
|
|
403
|
+
type: 'chat_received',
|
|
404
|
+
senderId: clientId,
|
|
405
|
+
senderName: client.name || 'Unknown',
|
|
406
|
+
message,
|
|
407
|
+
teamOnly,
|
|
408
|
+
};
|
|
409
|
+
if (teamOnly) {
|
|
410
|
+
// Only send to same team
|
|
411
|
+
const senderTeam = this.teamAssignments.get(clientId);
|
|
412
|
+
for (const [id, _] of this.clients) {
|
|
413
|
+
if (this.teamAssignments.get(id) === senderTeam) {
|
|
414
|
+
this.sendToClient(id, chatMessage);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
this.broadcast(chatMessage);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
// ============ Voice Relay ============
|
|
423
|
+
/**
|
|
424
|
+
* Handle binary data (voice frames)
|
|
425
|
+
* Returns true if it was a voice frame
|
|
426
|
+
*/
|
|
427
|
+
handleBinaryData(clientId, data) {
|
|
428
|
+
let uint8Data;
|
|
429
|
+
if (data instanceof ArrayBuffer) {
|
|
430
|
+
uint8Data = new Uint8Array(data);
|
|
431
|
+
}
|
|
432
|
+
else if (Buffer.isBuffer(data)) {
|
|
433
|
+
uint8Data = new Uint8Array(data.buffer, data.byteOffset, data.length);
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
uint8Data = data;
|
|
437
|
+
}
|
|
438
|
+
if (!isVoiceFrame(uint8Data)) {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
this.lastActivity = Date.now();
|
|
442
|
+
this.voiceRelay.relayVoiceFrame(uint8Data, clientId, this.clients, this.teamAssignments);
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
// ============ Communication ============
|
|
446
|
+
broadcast(message) {
|
|
447
|
+
const data = serializeServerMessage(message);
|
|
448
|
+
for (const client of this.clients.values()) {
|
|
449
|
+
if (client.socket.readyState === WebSocket.OPEN) {
|
|
450
|
+
try {
|
|
451
|
+
client.socket.send(data);
|
|
452
|
+
}
|
|
453
|
+
catch (e) {
|
|
454
|
+
// Ignore send errors
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
broadcastExcept(excludeId, message) {
|
|
460
|
+
const data = serializeServerMessage(message);
|
|
461
|
+
for (const [id, client] of this.clients) {
|
|
462
|
+
if (id !== excludeId && client.socket.readyState === WebSocket.OPEN) {
|
|
463
|
+
try {
|
|
464
|
+
client.socket.send(data);
|
|
465
|
+
}
|
|
466
|
+
catch (e) {
|
|
467
|
+
// Ignore send errors
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
sendToClient(clientId, message) {
|
|
473
|
+
const client = this.clients.get(clientId);
|
|
474
|
+
if (!client || client.socket.readyState !== WebSocket.OPEN)
|
|
475
|
+
return;
|
|
476
|
+
try {
|
|
477
|
+
client.socket.send(serializeServerMessage(message));
|
|
478
|
+
}
|
|
479
|
+
catch (e) {
|
|
480
|
+
// Ignore send errors
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
// ============ Info ============
|
|
484
|
+
getInfo() {
|
|
485
|
+
return {
|
|
486
|
+
id: this.id,
|
|
487
|
+
name: this.config.name,
|
|
488
|
+
map: this.config.map,
|
|
489
|
+
mode: this.config.mode,
|
|
490
|
+
playerCount: this.clients.size,
|
|
491
|
+
maxPlayers: this.config.maxPlayers,
|
|
492
|
+
botCount: this.config.botCount,
|
|
493
|
+
isPrivate: this.config.isPrivate,
|
|
494
|
+
phase: this.lockstepRunner?.getPhase() || this.gameRunner?.getPhase() || 'pre_match',
|
|
495
|
+
hostId: this.hostId,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { WebSocket } from 'ws';
|
|
2
|
+
import { RoomConfig, RoomInfo, ClientMessage, ServerMessage } from './protocol.js';
|
|
3
|
+
import { ConnectedClient, ServerConfig } from './types.js';
|
|
4
|
+
export declare class RoomManager {
|
|
5
|
+
private rooms;
|
|
6
|
+
private clients;
|
|
7
|
+
private config;
|
|
8
|
+
private cleanupInterval;
|
|
9
|
+
onRoomCreated?: (room: RoomInfo) => void;
|
|
10
|
+
onRoomClosed?: (roomId: string) => void;
|
|
11
|
+
constructor(config?: Partial<ServerConfig>);
|
|
12
|
+
addClient(socket: WebSocket): string;
|
|
13
|
+
removeClient(clientId: string): void;
|
|
14
|
+
getClient(clientId: string): ConnectedClient | undefined;
|
|
15
|
+
createRoom(clientId: string, config: RoomConfig): string | null;
|
|
16
|
+
joinRoom(clientId: string, roomId: string, playerName: string, password?: string): boolean;
|
|
17
|
+
leaveRoom(clientId: string): void;
|
|
18
|
+
removeRoom(roomId: string): void;
|
|
19
|
+
listRooms(): RoomInfo[];
|
|
20
|
+
handleMessage(clientId: string, message: ClientMessage): void;
|
|
21
|
+
handleBinaryData(clientId: string, data: Buffer): void;
|
|
22
|
+
sendToClient(clientId: string, message: ServerMessage): void;
|
|
23
|
+
cleanup(): void;
|
|
24
|
+
shutdown(): void;
|
|
25
|
+
getStats(): {
|
|
26
|
+
rooms: number;
|
|
27
|
+
clients: number;
|
|
28
|
+
};
|
|
29
|
+
}
|