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
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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 };
|