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,266 @@
1
+ // Hub Server - Central registry and routing for federated game servers
2
+ import { WebSocketServer } from 'ws';
3
+ import { createServer } from 'http';
4
+ import { PoolRegistry } from './PoolRegistry.js';
5
+ import { parsePoolToHubMessage, parseClientToHubMessage, serializeHubToPoolMessage, serializeHubToClientMessage, } from '../protocol.js';
6
+ export class HubServer {
7
+ wss = null;
8
+ httpServer = null;
9
+ poolRegistry;
10
+ connections = new Map();
11
+ config;
12
+ tokenMap = new Map();
13
+ constructor(config) {
14
+ this.config = config;
15
+ this.poolRegistry = new PoolRegistry();
16
+ }
17
+ // Start the hub server
18
+ start() {
19
+ this.httpServer = createServer();
20
+ this.wss = new WebSocketServer({ server: this.httpServer });
21
+ this.wss.on('connection', (ws, req) => {
22
+ this.handleConnection(ws, req);
23
+ });
24
+ this.httpServer.listen(this.config.port, () => {
25
+ console.log(`[HubServer] Listening on port ${this.config.port}`);
26
+ });
27
+ // Clean up expired tokens periodically
28
+ setInterval(() => this.cleanupExpiredTokens(), 60000);
29
+ }
30
+ // Stop the hub server
31
+ stop() {
32
+ this.poolRegistry.stop();
33
+ if (this.wss) {
34
+ this.wss.close();
35
+ this.wss = null;
36
+ }
37
+ if (this.httpServer) {
38
+ this.httpServer.close();
39
+ this.httpServer = null;
40
+ }
41
+ this.connections.clear();
42
+ this.tokenMap.clear();
43
+ }
44
+ // Handle new WebSocket connection
45
+ handleConnection(ws, req) {
46
+ const connection = {
47
+ ws,
48
+ type: 'unknown',
49
+ };
50
+ this.connections.set(ws, connection);
51
+ console.log(`[HubServer] New connection from ${req.socket.remoteAddress}`);
52
+ ws.on('message', (data) => {
53
+ this.handleMessage(ws, data.toString());
54
+ });
55
+ ws.on('close', () => {
56
+ this.handleDisconnect(ws);
57
+ });
58
+ ws.on('error', (error) => {
59
+ console.error(`[HubServer] WebSocket error:`, error);
60
+ this.handleDisconnect(ws);
61
+ });
62
+ }
63
+ // Handle incoming message
64
+ handleMessage(ws, data) {
65
+ const connection = this.connections.get(ws);
66
+ if (!connection)
67
+ return;
68
+ // Try to parse as pool server message
69
+ const poolMsg = parsePoolToHubMessage(data);
70
+ if (poolMsg) {
71
+ this.handlePoolMessage(ws, connection, poolMsg);
72
+ return;
73
+ }
74
+ // Try to parse as client message
75
+ const clientMsg = parseClientToHubMessage(data);
76
+ if (clientMsg) {
77
+ this.handleClientMessage(ws, connection, clientMsg);
78
+ return;
79
+ }
80
+ console.warn(`[HubServer] Unknown message type:`, data.substring(0, 100));
81
+ }
82
+ // Handle pool server messages
83
+ handlePoolMessage(ws, connection, msg) {
84
+ switch (msg.type) {
85
+ case 'pool_register':
86
+ this.handlePoolRegister(ws, connection, msg.serverName, msg.endpoint, msg.maxRooms);
87
+ break;
88
+ case 'pool_heartbeat':
89
+ if (connection.poolId) {
90
+ this.poolRegistry.updatePoolHeartbeat(connection.poolId, msg.rooms, msg.playerCount, msg.load);
91
+ }
92
+ break;
93
+ case 'pool_room_created':
94
+ if (connection.poolId) {
95
+ this.poolRegistry.handleRoomCreated(connection.poolId, msg.room);
96
+ console.log(`[HubServer] Room created on pool ${connection.poolId}: ${msg.room.name}`);
97
+ }
98
+ break;
99
+ case 'pool_room_closed':
100
+ if (connection.poolId) {
101
+ this.poolRegistry.handleRoomClosed(connection.poolId, msg.roomId);
102
+ console.log(`[HubServer] Room closed on pool ${connection.poolId}: ${msg.roomId}`);
103
+ }
104
+ break;
105
+ case 'pool_unregister':
106
+ if (connection.poolId) {
107
+ this.poolRegistry.unregisterPool(connection.poolId);
108
+ connection.type = 'unknown';
109
+ connection.poolId = undefined;
110
+ }
111
+ break;
112
+ }
113
+ }
114
+ // Handle pool server registration
115
+ handlePoolRegister(ws, connection, serverName, endpoint, maxRooms) {
116
+ // Validate
117
+ if (!serverName || !endpoint) {
118
+ ws.send(serializeHubToPoolMessage({
119
+ type: 'pool_rejected',
120
+ reason: 'Missing serverName or endpoint',
121
+ }));
122
+ return;
123
+ }
124
+ // Register the pool
125
+ const poolId = this.poolRegistry.registerPool(ws, serverName, endpoint, maxRooms);
126
+ connection.type = 'pool';
127
+ connection.poolId = poolId;
128
+ ws.send(serializeHubToPoolMessage({
129
+ type: 'pool_accepted',
130
+ poolId,
131
+ }));
132
+ console.log(`[HubServer] Pool ${serverName} registered with ID ${poolId}`);
133
+ }
134
+ // Handle client messages
135
+ handleClientMessage(ws, connection, msg) {
136
+ connection.type = 'client';
137
+ switch (msg.type) {
138
+ case 'hub_list_rooms':
139
+ this.handleListRooms(ws);
140
+ break;
141
+ case 'hub_get_endpoint':
142
+ this.handleGetEndpoint(ws, msg.roomId);
143
+ break;
144
+ case 'hub_create_room':
145
+ this.handleCreateRoom(ws, msg.config, msg.preferredPool);
146
+ break;
147
+ }
148
+ }
149
+ // Handle room list request
150
+ handleListRooms(ws) {
151
+ const rooms = this.poolRegistry.getAggregatedRooms();
152
+ const pools = this.poolRegistry.getPoolSummaries();
153
+ ws.send(serializeHubToClientMessage({
154
+ type: 'hub_room_list',
155
+ rooms,
156
+ pools,
157
+ }));
158
+ }
159
+ // Handle get endpoint request (client wants to join a room)
160
+ handleGetEndpoint(ws, roomId) {
161
+ const pool = this.poolRegistry.getPoolForRoom(roomId);
162
+ if (!pool) {
163
+ ws.send(serializeHubToClientMessage({
164
+ type: 'hub_room_not_found',
165
+ roomId,
166
+ }));
167
+ return;
168
+ }
169
+ // Generate a join token
170
+ const token = this.generateToken();
171
+ this.tokenMap.set(token, {
172
+ roomId,
173
+ endpoint: pool.info.endpoint,
174
+ expires: Date.now() + 60000, // 1 minute expiry
175
+ });
176
+ ws.send(serializeHubToClientMessage({
177
+ type: 'hub_room_endpoint',
178
+ roomId,
179
+ endpoint: pool.info.endpoint,
180
+ token,
181
+ }));
182
+ }
183
+ // Handle create room request
184
+ handleCreateRoom(ws, config, preferredPool) {
185
+ // Find a pool to create the room on
186
+ let pool;
187
+ if (preferredPool) {
188
+ pool = this.poolRegistry.getPool(preferredPool);
189
+ // Check if pool has capacity
190
+ if (pool && pool.info.currentRooms >= pool.info.maxRooms) {
191
+ pool = undefined;
192
+ }
193
+ }
194
+ if (!pool) {
195
+ pool = this.poolRegistry.getLeastLoadedPool();
196
+ }
197
+ if (!pool) {
198
+ ws.send(serializeHubToClientMessage({
199
+ type: 'hub_error',
200
+ error: 'No available pool servers',
201
+ }));
202
+ return;
203
+ }
204
+ // For now, we just tell the client to connect to the pool and create there
205
+ // The pool will report the room back to us via pool_room_created
206
+ const token = this.generateToken();
207
+ const roomId = `room_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
208
+ this.tokenMap.set(token, {
209
+ roomId,
210
+ endpoint: pool.info.endpoint,
211
+ expires: Date.now() + 60000,
212
+ });
213
+ ws.send(serializeHubToClientMessage({
214
+ type: 'hub_room_created',
215
+ roomId,
216
+ endpoint: pool.info.endpoint,
217
+ token,
218
+ }));
219
+ console.log(`[HubServer] Directing room creation to pool ${pool.info.name}`);
220
+ }
221
+ // Handle disconnection
222
+ handleDisconnect(ws) {
223
+ const connection = this.connections.get(ws);
224
+ if (connection) {
225
+ if (connection.type === 'pool' && connection.poolId) {
226
+ this.poolRegistry.unregisterPool(connection.poolId);
227
+ console.log(`[HubServer] Pool ${connection.poolId} disconnected`);
228
+ }
229
+ this.connections.delete(ws);
230
+ }
231
+ }
232
+ // Generate a random token
233
+ generateToken() {
234
+ return `token_${Date.now()}_${Math.random().toString(36).substr(2, 16)}`;
235
+ }
236
+ // Clean up expired tokens
237
+ cleanupExpiredTokens() {
238
+ const now = Date.now();
239
+ for (const [token, info] of this.tokenMap) {
240
+ if (info.expires < now) {
241
+ this.tokenMap.delete(token);
242
+ }
243
+ }
244
+ }
245
+ // Validate a join token (called by pool servers)
246
+ validateToken(token) {
247
+ const info = this.tokenMap.get(token);
248
+ if (info && info.expires > Date.now()) {
249
+ this.tokenMap.delete(token); // One-time use
250
+ return { roomId: info.roomId, endpoint: info.endpoint };
251
+ }
252
+ return null;
253
+ }
254
+ // Get registry for external access
255
+ getPoolRegistry() {
256
+ return this.poolRegistry;
257
+ }
258
+ // Get stats
259
+ getStats() {
260
+ return {
261
+ pools: this.poolRegistry.getPoolCount(),
262
+ rooms: this.poolRegistry.getAggregatedRooms().length,
263
+ players: this.poolRegistry.getTotalPlayerCount(),
264
+ };
265
+ }
266
+ }
@@ -0,0 +1,35 @@
1
+ import { WebSocket } from 'ws';
2
+ import { PoolServerInfo, RoomInfo, AggregatedRoomInfo } from '../protocol.js';
3
+ export interface RegisteredPool {
4
+ info: PoolServerInfo;
5
+ ws: WebSocket;
6
+ isAlive: boolean;
7
+ }
8
+ export declare class PoolRegistry {
9
+ private pools;
10
+ private heartbeatInterval;
11
+ private readonly HEARTBEAT_INTERVAL;
12
+ private readonly DEAD_POOL_TIMEOUT;
13
+ constructor();
14
+ registerPool(ws: WebSocket, serverName: string, endpoint: string, maxRooms: number): string;
15
+ unregisterPool(poolId: string): void;
16
+ findPoolByWs(ws: WebSocket): RegisteredPool | undefined;
17
+ updatePoolHeartbeat(poolId: string, rooms: RoomInfo[], playerCount: number, load: number): void;
18
+ handleRoomCreated(poolId: string, room: RoomInfo): void;
19
+ handleRoomClosed(poolId: string, roomId: string): void;
20
+ getAggregatedRooms(): AggregatedRoomInfo[];
21
+ getPoolForRoom(roomId: string): RegisteredPool | undefined;
22
+ getPoolSummaries(): {
23
+ id: string;
24
+ name: string;
25
+ playerCount: number;
26
+ }[];
27
+ getLeastLoadedPool(): RegisteredPool | undefined;
28
+ getPool(poolId: string): RegisteredPool | undefined;
29
+ getAllPools(): RegisteredPool[];
30
+ getPoolCount(): number;
31
+ getTotalPlayerCount(): number;
32
+ private startHeartbeatChecker;
33
+ stop(): void;
34
+ private generatePoolId;
35
+ }
@@ -0,0 +1,185 @@
1
+ // Pool Server Registry - Tracks connected pool servers for the hub
2
+ import { WebSocket } from 'ws';
3
+ import { serializeHubToPoolMessage, } from '../protocol.js';
4
+ export class PoolRegistry {
5
+ pools = new Map();
6
+ heartbeatInterval = null;
7
+ HEARTBEAT_INTERVAL = 10000; // 10 seconds
8
+ DEAD_POOL_TIMEOUT = 30000; // 30 seconds
9
+ constructor() {
10
+ this.startHeartbeatChecker();
11
+ }
12
+ // Register a new pool server
13
+ registerPool(ws, serverName, endpoint, maxRooms) {
14
+ const poolId = this.generatePoolId();
15
+ const poolInfo = {
16
+ id: poolId,
17
+ name: serverName,
18
+ endpoint,
19
+ maxRooms,
20
+ currentRooms: 0,
21
+ playerCount: 0,
22
+ load: 0,
23
+ lastHeartbeat: Date.now(),
24
+ rooms: [],
25
+ };
26
+ this.pools.set(poolId, {
27
+ info: poolInfo,
28
+ ws,
29
+ isAlive: true,
30
+ });
31
+ console.log(`[PoolRegistry] Pool registered: ${serverName} (${poolId}) at ${endpoint}`);
32
+ return poolId;
33
+ }
34
+ // Unregister a pool server
35
+ unregisterPool(poolId) {
36
+ const pool = this.pools.get(poolId);
37
+ if (pool) {
38
+ console.log(`[PoolRegistry] Pool unregistered: ${pool.info.name} (${poolId})`);
39
+ this.pools.delete(poolId);
40
+ }
41
+ }
42
+ // Find pool by WebSocket connection
43
+ findPoolByWs(ws) {
44
+ for (const pool of this.pools.values()) {
45
+ if (pool.ws === ws) {
46
+ return pool;
47
+ }
48
+ }
49
+ return undefined;
50
+ }
51
+ // Update pool heartbeat with current state
52
+ updatePoolHeartbeat(poolId, rooms, playerCount, load) {
53
+ const pool = this.pools.get(poolId);
54
+ if (pool) {
55
+ pool.info.rooms = rooms;
56
+ pool.info.currentRooms = rooms.length;
57
+ pool.info.playerCount = playerCount;
58
+ pool.info.load = load;
59
+ pool.info.lastHeartbeat = Date.now();
60
+ pool.isAlive = true;
61
+ }
62
+ }
63
+ // Handle room created event from pool
64
+ handleRoomCreated(poolId, room) {
65
+ const pool = this.pools.get(poolId);
66
+ if (pool) {
67
+ // Add to pool's room list if not already there
68
+ const existingIndex = pool.info.rooms.findIndex(r => r.id === room.id);
69
+ if (existingIndex >= 0) {
70
+ pool.info.rooms[existingIndex] = room;
71
+ }
72
+ else {
73
+ pool.info.rooms.push(room);
74
+ }
75
+ pool.info.currentRooms = pool.info.rooms.length;
76
+ }
77
+ }
78
+ // Handle room closed event from pool
79
+ handleRoomClosed(poolId, roomId) {
80
+ const pool = this.pools.get(poolId);
81
+ if (pool) {
82
+ pool.info.rooms = pool.info.rooms.filter(r => r.id !== roomId);
83
+ pool.info.currentRooms = pool.info.rooms.length;
84
+ }
85
+ }
86
+ // Get all rooms aggregated from all pools
87
+ getAggregatedRooms() {
88
+ const rooms = [];
89
+ for (const pool of this.pools.values()) {
90
+ for (const room of pool.info.rooms) {
91
+ rooms.push({
92
+ ...room,
93
+ poolId: pool.info.id,
94
+ poolName: pool.info.name,
95
+ poolEndpoint: pool.info.endpoint,
96
+ });
97
+ }
98
+ }
99
+ return rooms;
100
+ }
101
+ // Get pool info for a specific room
102
+ getPoolForRoom(roomId) {
103
+ for (const pool of this.pools.values()) {
104
+ if (pool.info.rooms.some(r => r.id === roomId)) {
105
+ return pool;
106
+ }
107
+ }
108
+ return undefined;
109
+ }
110
+ // Get all pool summaries (for client display)
111
+ getPoolSummaries() {
112
+ return Array.from(this.pools.values()).map(pool => ({
113
+ id: pool.info.id,
114
+ name: pool.info.name,
115
+ playerCount: pool.info.playerCount,
116
+ }));
117
+ }
118
+ // Get pool with lowest load for room creation
119
+ getLeastLoadedPool() {
120
+ let leastLoaded;
121
+ let lowestLoad = Infinity;
122
+ for (const pool of this.pools.values()) {
123
+ // Check if pool has capacity
124
+ if (pool.info.currentRooms < pool.info.maxRooms && pool.info.load < lowestLoad) {
125
+ lowestLoad = pool.info.load;
126
+ leastLoaded = pool;
127
+ }
128
+ }
129
+ return leastLoaded;
130
+ }
131
+ // Get specific pool by ID
132
+ getPool(poolId) {
133
+ return this.pools.get(poolId);
134
+ }
135
+ // Get all pools
136
+ getAllPools() {
137
+ return Array.from(this.pools.values());
138
+ }
139
+ // Get pool count
140
+ getPoolCount() {
141
+ return this.pools.size;
142
+ }
143
+ // Get total player count across all pools
144
+ getTotalPlayerCount() {
145
+ let total = 0;
146
+ for (const pool of this.pools.values()) {
147
+ total += pool.info.playerCount;
148
+ }
149
+ return total;
150
+ }
151
+ // Start heartbeat checker to detect dead pools
152
+ startHeartbeatChecker() {
153
+ this.heartbeatInterval = setInterval(() => {
154
+ const now = Date.now();
155
+ for (const [poolId, pool] of this.pools) {
156
+ // Send ping to pool
157
+ if (pool.ws.readyState === WebSocket.OPEN) {
158
+ pool.ws.send(serializeHubToPoolMessage({ type: 'pool_ping' }));
159
+ }
160
+ // Check for dead pools
161
+ if (now - pool.info.lastHeartbeat > this.DEAD_POOL_TIMEOUT) {
162
+ console.log(`[PoolRegistry] Pool ${pool.info.name} (${poolId}) timed out - removing`);
163
+ pool.ws.close();
164
+ this.pools.delete(poolId);
165
+ }
166
+ }
167
+ }, this.HEARTBEAT_INTERVAL);
168
+ }
169
+ // Stop the registry
170
+ stop() {
171
+ if (this.heartbeatInterval) {
172
+ clearInterval(this.heartbeatInterval);
173
+ this.heartbeatInterval = null;
174
+ }
175
+ // Close all pool connections
176
+ for (const pool of this.pools.values()) {
177
+ pool.ws.close();
178
+ }
179
+ this.pools.clear();
180
+ }
181
+ // Generate unique pool ID
182
+ generatePoolId() {
183
+ return `pool_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
184
+ }
185
+ }
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ import { HubServer } from './hub/HubServer.js';
3
+ import { GameServer } from './pool/GameServer.js';
4
+ declare const config: {
5
+ mode: "standalone" | "hub-only" | "pool";
6
+ hubUrl?: string;
7
+ serverName: string;
8
+ port: number;
9
+ hubPort: number;
10
+ maxRooms: number;
11
+ publicUrl?: string;
12
+ };
13
+ declare let hubServer: HubServer | null;
14
+ declare let gameServer: GameServer | null;
15
+ export { hubServer, gameServer, config };
package/dist/index.js ADDED
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ // CS-CLI Multiplayer Server
3
+ // Supports multiple startup modes:
4
+ // Default: Hub + built-in pool server (standalone)
5
+ // --hub-only: Hub only (no games, just routing)
6
+ // --pool --hub=URL: Pool server that connects to external hub
7
+ import { HubServer } from './hub/HubServer.js';
8
+ import { GameServer } from './pool/GameServer.js';
9
+ import { DEFAULT_SERVER_CONFIG } from './types.js';
10
+ // Parse command line arguments
11
+ function parseArgs() {
12
+ const args = process.argv.slice(2);
13
+ const parsed = {
14
+ mode: 'standalone',
15
+ serverName: process.env.SERVER_NAME || 'CS-CLI Server',
16
+ port: parseInt(process.env.PORT || '8080', 10),
17
+ hubPort: parseInt(process.env.HUB_PORT || '8081', 10),
18
+ maxRooms: parseInt(process.env.MAX_ROOMS || '100', 10),
19
+ publicUrl: process.env.PUBLIC_URL, // e.g., wss://game.example.com
20
+ };
21
+ for (const arg of args) {
22
+ if (arg === '--hub-only') {
23
+ parsed.mode = 'hub-only';
24
+ }
25
+ else if (arg === '--pool') {
26
+ parsed.mode = 'pool';
27
+ }
28
+ else if (arg.startsWith('--hub=')) {
29
+ parsed.hubUrl = arg.substring(6);
30
+ }
31
+ else if (arg.startsWith('--name=')) {
32
+ parsed.serverName = arg.substring(7);
33
+ }
34
+ else if (arg.startsWith('--port=')) {
35
+ parsed.port = parseInt(arg.substring(7), 10);
36
+ }
37
+ else if (arg.startsWith('--hub-port=')) {
38
+ parsed.hubPort = parseInt(arg.substring(11), 10);
39
+ }
40
+ else if (arg.startsWith('--max-rooms=')) {
41
+ parsed.maxRooms = parseInt(arg.substring(12), 10);
42
+ }
43
+ else if (arg.startsWith('--public-url=')) {
44
+ parsed.publicUrl = arg.substring(13);
45
+ }
46
+ }
47
+ // Pool mode requires hub URL
48
+ if (parsed.mode === 'pool' && !parsed.hubUrl) {
49
+ console.error('Error: Pool mode requires --hub=URL');
50
+ process.exit(1);
51
+ }
52
+ return parsed;
53
+ }
54
+ const config = parseArgs();
55
+ // Track servers for shutdown
56
+ let hubServer = null;
57
+ let gameServer = null;
58
+ // Start based on mode
59
+ switch (config.mode) {
60
+ case 'standalone':
61
+ // Run hub + built-in pool server
62
+ console.log(`
63
+ ╔═══════════════════════════════════════════════════╗
64
+ ║ ║
65
+ ║ CS-CLI Server (Standalone Mode) ║
66
+ ║ ║
67
+ ║ Hub Port: ${config.hubPort.toString().padEnd(37)}║
68
+ ║ Game Port: ${config.port.toString().padEnd(36)}║
69
+ ║ Max Rooms: ${config.maxRooms.toString().padEnd(36)}║
70
+ ║ ║
71
+ ╚═══════════════════════════════════════════════════╝
72
+ `);
73
+ // Start hub server
74
+ hubServer = new HubServer({
75
+ port: config.hubPort,
76
+ });
77
+ hubServer.start();
78
+ // Start built-in game server that connects to hub
79
+ // Use PUBLIC_URL if set, otherwise default to localhost
80
+ const standalonePublicEndpoint = config.publicUrl || `ws://localhost:${config.port}`;
81
+ gameServer = new GameServer({
82
+ ...DEFAULT_SERVER_CONFIG,
83
+ port: config.port,
84
+ maxRooms: config.maxRooms,
85
+ serverName: config.serverName,
86
+ publicEndpoint: standalonePublicEndpoint,
87
+ hubUrl: `ws://localhost:${config.hubPort}`,
88
+ });
89
+ gameServer.start();
90
+ console.log(` Public endpoint: ${standalonePublicEndpoint}`);
91
+ break;
92
+ case 'hub-only':
93
+ // Run only the hub server
94
+ console.log(`
95
+ ╔═══════════════════════════════════════════════════╗
96
+ ║ ║
97
+ ║ CS-CLI Hub Server (Hub-Only Mode) ║
98
+ ║ ║
99
+ ║ Port: ${config.hubPort.toString().padEnd(41)}║
100
+ ║ ║
101
+ ║ Waiting for pool servers to connect... ║
102
+ ║ ║
103
+ ╚═══════════════════════════════════════════════════╝
104
+ `);
105
+ hubServer = new HubServer({
106
+ port: config.hubPort,
107
+ });
108
+ hubServer.start();
109
+ break;
110
+ case 'pool':
111
+ // Run game server that connects to external hub
112
+ console.log(`
113
+ ╔═══════════════════════════════════════════════════╗
114
+ ║ ║
115
+ ║ CS-CLI Pool Server ║
116
+ ║ ║
117
+ ║ Name: ${config.serverName.substring(0, 40).padEnd(40)}║
118
+ ║ Port: ${config.port.toString().padEnd(41)}║
119
+ ║ Hub: ${config.hubUrl.substring(0, 42).padEnd(42)}║
120
+ ║ Max Rooms: ${config.maxRooms.toString().padEnd(36)}║
121
+ ║ ║
122
+ ╚═══════════════════════════════════════════════════╝
123
+ `);
124
+ // Use PUBLIC_URL if set, otherwise default to localhost
125
+ const poolPublicEndpoint = config.publicUrl || `ws://localhost:${config.port}`;
126
+ gameServer = new GameServer({
127
+ ...DEFAULT_SERVER_CONFIG,
128
+ port: config.port,
129
+ maxRooms: config.maxRooms,
130
+ serverName: config.serverName,
131
+ publicEndpoint: poolPublicEndpoint,
132
+ hubUrl: config.hubUrl,
133
+ });
134
+ gameServer.start();
135
+ console.log(` Public endpoint: ${poolPublicEndpoint}`);
136
+ break;
137
+ }
138
+ // Graceful shutdown
139
+ function shutdown() {
140
+ console.log('\nShutting down server...');
141
+ if (gameServer) {
142
+ gameServer.stop();
143
+ }
144
+ if (hubServer) {
145
+ hubServer.stop();
146
+ }
147
+ // Force exit after 5 seconds
148
+ setTimeout(() => {
149
+ console.log('Force exit');
150
+ process.exit(1);
151
+ }, 5000);
152
+ setTimeout(() => {
153
+ console.log('Server closed');
154
+ process.exit(0);
155
+ }, 1000);
156
+ }
157
+ process.on('SIGINT', shutdown);
158
+ process.on('SIGTERM', shutdown);
159
+ // Stats logging
160
+ setInterval(() => {
161
+ if (hubServer) {
162
+ const stats = hubServer.getStats();
163
+ if (stats.pools > 0 || stats.rooms > 0 || stats.players > 0) {
164
+ console.log(`[Hub] ${stats.pools} pools, ${stats.rooms} rooms, ${stats.players} players`);
165
+ }
166
+ }
167
+ if (gameServer) {
168
+ const stats = gameServer.getStats();
169
+ if (stats.clients > 0 || stats.rooms > 0) {
170
+ console.log(`[Game] ${stats.clients} clients, ${stats.rooms} rooms, hub: ${stats.hubConnected}`);
171
+ }
172
+ }
173
+ }, 60000);
174
+ // Export for testing
175
+ export { hubServer, gameServer, config };