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/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
+ }