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.
@@ -0,0 +1,277 @@
1
+ // Room lifecycle management for CS-CLI multiplayer server
2
+ import { v4 as uuidv4 } from 'uuid';
3
+ import { WebSocket } from 'ws';
4
+ import { Room } from './Room.js';
5
+ import { serializeServerMessage, } from './protocol.js';
6
+ import { DEFAULT_SERVER_CONFIG } from './types.js';
7
+ export class RoomManager {
8
+ rooms = new Map();
9
+ clients = new Map();
10
+ config;
11
+ // Cleanup interval
12
+ cleanupInterval = null;
13
+ // Event callbacks (for hub notification)
14
+ onRoomCreated;
15
+ onRoomClosed;
16
+ constructor(config = {}) {
17
+ this.config = { ...DEFAULT_SERVER_CONFIG, ...config };
18
+ // Start cleanup interval (every 30 seconds)
19
+ this.cleanupInterval = setInterval(() => this.cleanup(), 30000);
20
+ }
21
+ // ============ Client Management ============
22
+ addClient(socket) {
23
+ const clientId = uuidv4();
24
+ const client = {
25
+ id: clientId,
26
+ socket,
27
+ name: null,
28
+ roomId: null,
29
+ isReady: false,
30
+ lastActivity: Date.now(),
31
+ pendingInputs: [],
32
+ };
33
+ this.clients.set(clientId, client);
34
+ console.log(`Client connected: ${clientId}`);
35
+ return clientId;
36
+ }
37
+ removeClient(clientId) {
38
+ const client = this.clients.get(clientId);
39
+ if (!client)
40
+ return;
41
+ // Leave room if in one
42
+ if (client.roomId) {
43
+ this.leaveRoom(clientId);
44
+ }
45
+ this.clients.delete(clientId);
46
+ console.log(`Client disconnected: ${clientId}`);
47
+ }
48
+ getClient(clientId) {
49
+ return this.clients.get(clientId);
50
+ }
51
+ // ============ Room Management ============
52
+ createRoom(clientId, config) {
53
+ const client = this.clients.get(clientId);
54
+ if (!client)
55
+ return null;
56
+ // Check room limit
57
+ if (this.rooms.size >= this.config.maxRooms) {
58
+ this.sendToClient(clientId, {
59
+ type: 'room_error',
60
+ error: 'Server is full. Cannot create more rooms.',
61
+ });
62
+ return null;
63
+ }
64
+ // Validate config
65
+ if (config.maxPlayers > this.config.maxPlayersPerRoom) {
66
+ config.maxPlayers = this.config.maxPlayersPerRoom;
67
+ }
68
+ if (config.maxPlayers < 1)
69
+ config.maxPlayers = 1;
70
+ if (config.botCount < 0)
71
+ config.botCount = 0;
72
+ if (config.botCount > 8)
73
+ config.botCount = 8;
74
+ const roomId = uuidv4().substring(0, 8);
75
+ const room = new Room(roomId, config, clientId, this.config);
76
+ this.rooms.set(roomId, room);
77
+ console.log(`Room created: ${roomId} by ${clientId} (${config.name})`);
78
+ // Notify hub if callback is set
79
+ this.onRoomCreated?.(room.getInfo());
80
+ // Auto-join the creator
81
+ this.joinRoom(clientId, roomId, client.name || 'Host');
82
+ return roomId;
83
+ }
84
+ joinRoom(clientId, roomId, playerName, password) {
85
+ const client = this.clients.get(clientId);
86
+ if (!client)
87
+ return false;
88
+ // Leave current room first
89
+ if (client.roomId) {
90
+ this.leaveRoom(clientId);
91
+ }
92
+ const room = this.rooms.get(roomId);
93
+ if (!room) {
94
+ this.sendToClient(clientId, {
95
+ type: 'room_error',
96
+ error: 'Room not found.',
97
+ });
98
+ return false;
99
+ }
100
+ // Check password
101
+ if (room.config.password && room.config.password !== password) {
102
+ this.sendToClient(clientId, {
103
+ type: 'room_error',
104
+ error: 'Incorrect password.',
105
+ });
106
+ return false;
107
+ }
108
+ // Check capacity
109
+ if (room.getPlayerCount() >= room.config.maxPlayers) {
110
+ this.sendToClient(clientId, {
111
+ type: 'room_error',
112
+ error: 'Room is full.',
113
+ });
114
+ return false;
115
+ }
116
+ // Join the room
117
+ client.name = playerName;
118
+ client.roomId = roomId;
119
+ client.isReady = false;
120
+ // IMPORTANT: Send join confirmation BEFORE addPlayer
121
+ // addPlayer sends existing player info, and client resets lobby on room_joined
122
+ // So room_joined must come first, otherwise existing players get cleared
123
+ this.sendToClient(clientId, {
124
+ type: 'room_joined',
125
+ roomId,
126
+ playerId: clientId,
127
+ room: room.getInfo(),
128
+ });
129
+ // Now add player - this sends existing player info to new joiner
130
+ room.addPlayer(clientId, client);
131
+ // Notify other players (this is also done in room.addPlayer, so remove duplicate)
132
+ // Note: room.addPlayer already broadcasts to other clients
133
+ console.log(`${playerName} (${clientId}) joined room ${roomId}`);
134
+ return true;
135
+ }
136
+ leaveRoom(clientId) {
137
+ const client = this.clients.get(clientId);
138
+ if (!client || !client.roomId)
139
+ return;
140
+ const room = this.rooms.get(client.roomId);
141
+ if (room) {
142
+ const playerName = client.name || 'Unknown';
143
+ room.removePlayer(clientId);
144
+ // Notify remaining players
145
+ room.broadcast({
146
+ type: 'player_left',
147
+ playerId: clientId,
148
+ playerName,
149
+ });
150
+ console.log(`${playerName} (${clientId}) left room ${room.id}`);
151
+ // Remove room if empty
152
+ if (room.getPlayerCount() === 0) {
153
+ this.removeRoom(room.id);
154
+ }
155
+ }
156
+ client.roomId = null;
157
+ client.isReady = false;
158
+ }
159
+ removeRoom(roomId) {
160
+ const room = this.rooms.get(roomId);
161
+ if (!room)
162
+ return;
163
+ room.stop();
164
+ this.rooms.delete(roomId);
165
+ console.log(`Room removed: ${roomId}`);
166
+ // Notify hub if callback is set
167
+ this.onRoomClosed?.(roomId);
168
+ }
169
+ listRooms() {
170
+ const rooms = [];
171
+ for (const room of this.rooms.values()) {
172
+ // Don't list private rooms
173
+ if (!room.config.isPrivate) {
174
+ rooms.push(room.getInfo());
175
+ }
176
+ }
177
+ return rooms;
178
+ }
179
+ // ============ Message Handling ============
180
+ handleMessage(clientId, message) {
181
+ const client = this.clients.get(clientId);
182
+ if (!client)
183
+ return;
184
+ client.lastActivity = Date.now();
185
+ switch (message.type) {
186
+ case 'list_rooms':
187
+ this.sendToClient(clientId, {
188
+ type: 'room_list',
189
+ rooms: this.listRooms(),
190
+ });
191
+ break;
192
+ case 'create_room':
193
+ this.createRoom(clientId, message.config);
194
+ break;
195
+ case 'join_room':
196
+ this.joinRoom(clientId, message.roomId, message.playerName, message.password);
197
+ break;
198
+ case 'leave_room':
199
+ this.leaveRoom(clientId);
200
+ break;
201
+ default:
202
+ // Forward game messages to the room
203
+ if (client.roomId) {
204
+ const room = this.rooms.get(client.roomId);
205
+ if (room) {
206
+ room.handleMessage(clientId, message);
207
+ }
208
+ }
209
+ break;
210
+ }
211
+ }
212
+ // ============ Binary Data (Voice) ============
213
+ handleBinaryData(clientId, data) {
214
+ const client = this.clients.get(clientId);
215
+ if (!client || !client.roomId)
216
+ return;
217
+ const room = this.rooms.get(client.roomId);
218
+ if (room) {
219
+ room.handleBinaryData(clientId, data);
220
+ }
221
+ }
222
+ // ============ Utility ============
223
+ sendToClient(clientId, message) {
224
+ const client = this.clients.get(clientId);
225
+ if (!client || client.socket.readyState !== WebSocket.OPEN)
226
+ return;
227
+ try {
228
+ client.socket.send(serializeServerMessage(message));
229
+ }
230
+ catch (e) {
231
+ console.error(`Failed to send to client ${clientId}:`, e);
232
+ }
233
+ }
234
+ cleanup() {
235
+ const now = Date.now();
236
+ // Remove idle rooms
237
+ for (const [roomId, room] of this.rooms) {
238
+ if (room.getPlayerCount() === 0) {
239
+ const idleTime = now - room.lastActivity;
240
+ if (idleTime > this.config.roomIdleTimeout) {
241
+ this.removeRoom(roomId);
242
+ }
243
+ }
244
+ }
245
+ // Note: Client cleanup handled by WebSocket close events
246
+ }
247
+ shutdown() {
248
+ // Stop cleanup interval
249
+ if (this.cleanupInterval) {
250
+ clearInterval(this.cleanupInterval);
251
+ this.cleanupInterval = null;
252
+ }
253
+ // Stop all rooms
254
+ for (const room of this.rooms.values()) {
255
+ room.stop();
256
+ }
257
+ this.rooms.clear();
258
+ // Close all client connections
259
+ for (const client of this.clients.values()) {
260
+ try {
261
+ client.socket.close(1001, 'Server shutting down');
262
+ }
263
+ catch (e) {
264
+ // Ignore close errors
265
+ }
266
+ }
267
+ this.clients.clear();
268
+ console.log('RoomManager shutdown complete');
269
+ }
270
+ // ============ Stats ============
271
+ getStats() {
272
+ return {
273
+ rooms: this.rooms.size,
274
+ clients: this.clients.size,
275
+ };
276
+ }
277
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * VoiceRelay - Server-side voice frame relay
3
+ *
4
+ * Relays binary voice frames between clients in a room.
5
+ * Handles team filtering and position tracking for spatial audio.
6
+ */
7
+ import { TeamId } from './protocol.js';
8
+ import { ConnectedClient } from './types.js';
9
+ /**
10
+ * Check if data is a voice frame
11
+ */
12
+ export declare function isVoiceFrame(data: Buffer | ArrayBuffer | Uint8Array): boolean;
13
+ /**
14
+ * Voice relay state for a room
15
+ */
16
+ export declare class VoiceRelay {
17
+ private positions;
18
+ private senderIdToPlayerId;
19
+ private playerIdToSenderId;
20
+ /**
21
+ * Register a player's sender ID
22
+ */
23
+ registerPlayer(playerId: string, senderId: number): void;
24
+ /**
25
+ * Unregister a player
26
+ */
27
+ unregisterPlayer(playerId: string): void;
28
+ /**
29
+ * Update a player's position (for spatial audio)
30
+ */
31
+ updatePosition(playerId: string, x: number, y: number, z: number): void;
32
+ /**
33
+ * Get player ID from sender ID
34
+ */
35
+ getPlayerId(senderId: number): string | undefined;
36
+ private debugRelayCount;
37
+ /**
38
+ * Relay a voice frame to room members
39
+ *
40
+ * @param data Binary voice frame
41
+ * @param senderClientId Client ID of sender
42
+ * @param clients Map of all clients in room
43
+ * @param teamAssignments Map of client ID to team
44
+ */
45
+ relayVoiceFrame(data: Uint8Array, senderClientId: string, clients: Map<string, ConnectedClient>, teamAssignments: Map<string, TeamId>): void;
46
+ /**
47
+ * Cleanup old positions
48
+ */
49
+ cleanup(maxAgeMs?: number): void;
50
+ /**
51
+ * Clear all state
52
+ */
53
+ clear(): void;
54
+ }
55
+ /**
56
+ * Get or create voice relay for a room
57
+ */
58
+ export declare function getVoiceRelay(roomId: string): VoiceRelay;
59
+ /**
60
+ * Remove voice relay for a room
61
+ */
62
+ export declare function removeVoiceRelay(roomId: string): void;
@@ -0,0 +1,167 @@
1
+ /**
2
+ * VoiceRelay - Server-side voice frame relay
3
+ *
4
+ * Relays binary voice frames between clients in a room.
5
+ * Handles team filtering and position tracking for spatial audio.
6
+ */
7
+ import { WebSocket } from 'ws';
8
+ // Voice protocol constants (matching client)
9
+ const VOICE_FRAME_TYPE = 0x01;
10
+ const VOICE_FLAG_TEAM_ONLY = 0x02;
11
+ /**
12
+ * Extract sender ID from voice frame (bytes 2-5)
13
+ */
14
+ function getSenderIdFromFrame(data) {
15
+ if (data.length < 6)
16
+ return 0;
17
+ return data[2] | (data[3] << 8) | (data[4] << 16) | (data[5] << 24);
18
+ }
19
+ /**
20
+ * Get flags from voice frame (byte 1)
21
+ */
22
+ function getFrameFlags(data) {
23
+ if (data.length < 2)
24
+ return 0;
25
+ return data[1];
26
+ }
27
+ /**
28
+ * Check if data is a voice frame
29
+ */
30
+ export function isVoiceFrame(data) {
31
+ if (data instanceof ArrayBuffer) {
32
+ data = new Uint8Array(data);
33
+ }
34
+ else if (Buffer.isBuffer(data)) {
35
+ data = new Uint8Array(data.buffer, data.byteOffset, data.length);
36
+ }
37
+ return data.length >= 1 && data[0] === VOICE_FRAME_TYPE;
38
+ }
39
+ /**
40
+ * Voice relay state for a room
41
+ */
42
+ export class VoiceRelay {
43
+ positions = new Map();
44
+ senderIdToPlayerId = new Map();
45
+ playerIdToSenderId = new Map();
46
+ /**
47
+ * Register a player's sender ID
48
+ */
49
+ registerPlayer(playerId, senderId) {
50
+ this.senderIdToPlayerId.set(senderId, playerId);
51
+ this.playerIdToSenderId.set(playerId, senderId);
52
+ }
53
+ /**
54
+ * Unregister a player
55
+ */
56
+ unregisterPlayer(playerId) {
57
+ const senderId = this.playerIdToSenderId.get(playerId);
58
+ if (senderId !== undefined) {
59
+ this.senderIdToPlayerId.delete(senderId);
60
+ }
61
+ this.playerIdToSenderId.delete(playerId);
62
+ this.positions.delete(playerId);
63
+ }
64
+ /**
65
+ * Update a player's position (for spatial audio)
66
+ */
67
+ updatePosition(playerId, x, y, z) {
68
+ this.positions.set(playerId, {
69
+ x, y, z,
70
+ lastUpdate: Date.now(),
71
+ });
72
+ }
73
+ /**
74
+ * Get player ID from sender ID
75
+ */
76
+ getPlayerId(senderId) {
77
+ return this.senderIdToPlayerId.get(senderId);
78
+ }
79
+ // Debug counter
80
+ debugRelayCount = 0;
81
+ /**
82
+ * Relay a voice frame to room members
83
+ *
84
+ * @param data Binary voice frame
85
+ * @param senderClientId Client ID of sender
86
+ * @param clients Map of all clients in room
87
+ * @param teamAssignments Map of client ID to team
88
+ */
89
+ relayVoiceFrame(data, senderClientId, clients, teamAssignments) {
90
+ const flags = getFrameFlags(data);
91
+ const teamOnly = (flags & VOICE_FLAG_TEAM_ONLY) !== 0;
92
+ const senderTeam = teamAssignments.get(senderClientId);
93
+ const senderId = getSenderIdFromFrame(data);
94
+ this.debugRelayCount++;
95
+ if (this.debugRelayCount % 50 === 1) {
96
+ console.log(`[VoiceRelay] Relaying frame #${this.debugRelayCount} from ${senderClientId.slice(0, 8)} (sender ${senderId.toString(16)}), ${clients.size} clients in room`);
97
+ }
98
+ let relayedTo = 0;
99
+ // Relay to all other clients (optionally filtered by team)
100
+ for (const [clientId, client] of clients) {
101
+ // Don't send back to sender
102
+ if (clientId === senderClientId)
103
+ continue;
104
+ // Check team filter
105
+ if (teamOnly && senderTeam) {
106
+ const clientTeam = teamAssignments.get(clientId);
107
+ if (clientTeam !== senderTeam)
108
+ continue;
109
+ }
110
+ // Send binary frame
111
+ if (client.socket.readyState === WebSocket.OPEN) {
112
+ try {
113
+ client.socket.send(data);
114
+ relayedTo++;
115
+ }
116
+ catch {
117
+ // Ignore send errors
118
+ }
119
+ }
120
+ }
121
+ if (this.debugRelayCount % 50 === 1) {
122
+ console.log(`[VoiceRelay] Relayed to ${relayedTo} clients`);
123
+ }
124
+ }
125
+ /**
126
+ * Cleanup old positions
127
+ */
128
+ cleanup(maxAgeMs = 10000) {
129
+ const now = Date.now();
130
+ for (const [playerId, pos] of this.positions) {
131
+ if (now - pos.lastUpdate > maxAgeMs) {
132
+ this.positions.delete(playerId);
133
+ }
134
+ }
135
+ }
136
+ /**
137
+ * Clear all state
138
+ */
139
+ clear() {
140
+ this.positions.clear();
141
+ this.senderIdToPlayerId.clear();
142
+ this.playerIdToSenderId.clear();
143
+ }
144
+ }
145
+ // Room voice relays
146
+ const roomRelays = new Map();
147
+ /**
148
+ * Get or create voice relay for a room
149
+ */
150
+ export function getVoiceRelay(roomId) {
151
+ let relay = roomRelays.get(roomId);
152
+ if (!relay) {
153
+ relay = new VoiceRelay();
154
+ roomRelays.set(roomId, relay);
155
+ }
156
+ return relay;
157
+ }
158
+ /**
159
+ * Remove voice relay for a room
160
+ */
161
+ export function removeVoiceRelay(roomId) {
162
+ const relay = roomRelays.get(roomId);
163
+ if (relay) {
164
+ relay.clear();
165
+ roomRelays.delete(roomId);
166
+ }
167
+ }
@@ -0,0 +1,37 @@
1
+ import { PoolRegistry } from './PoolRegistry.js';
2
+ export interface HubServerConfig {
3
+ port: number;
4
+ builtInPoolEndpoint?: string;
5
+ }
6
+ export declare class HubServer {
7
+ private wss;
8
+ private httpServer;
9
+ private poolRegistry;
10
+ private connections;
11
+ private config;
12
+ private tokenMap;
13
+ constructor(config: HubServerConfig);
14
+ start(): void;
15
+ stop(): void;
16
+ private handleConnection;
17
+ private handleMessage;
18
+ private handlePoolMessage;
19
+ private handlePoolRegister;
20
+ private handleClientMessage;
21
+ private handleListRooms;
22
+ private handleGetEndpoint;
23
+ private handleCreateRoom;
24
+ private handleDisconnect;
25
+ private generateToken;
26
+ private cleanupExpiredTokens;
27
+ validateToken(token: string): {
28
+ roomId: string;
29
+ endpoint: string;
30
+ } | null;
31
+ getPoolRegistry(): PoolRegistry;
32
+ getStats(): {
33
+ pools: number;
34
+ rooms: number;
35
+ players: number;
36
+ };
37
+ }