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,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LockstepRunner - Server-side input relay for deterministic lockstep multiplayer
|
|
3
|
+
*
|
|
4
|
+
* Unlike GameRunner which runs authoritative physics, LockstepRunner only:
|
|
5
|
+
* 1. Collects inputs from all clients for each tick
|
|
6
|
+
* 2. Broadcasts all inputs when the tick is ready
|
|
7
|
+
* 3. Tracks player connections and disconnections
|
|
8
|
+
*
|
|
9
|
+
* All physics simulation happens on clients.
|
|
10
|
+
*/
|
|
11
|
+
import { RoomConfig, ServerMessage, TeamId, Vec3, PlayerInput, LockstepAction, GamePhase } from './protocol.js';
|
|
12
|
+
import { ServerConfig, MapData } from './types.js';
|
|
13
|
+
export declare class LockstepRunner {
|
|
14
|
+
private players;
|
|
15
|
+
private currentTick;
|
|
16
|
+
private pendingInputs;
|
|
17
|
+
private usedSpawns;
|
|
18
|
+
private tickCheckInterval;
|
|
19
|
+
private lastTickTime;
|
|
20
|
+
private isRunning;
|
|
21
|
+
private broadcast;
|
|
22
|
+
private sendToClient;
|
|
23
|
+
private mapData;
|
|
24
|
+
private roomConfig;
|
|
25
|
+
private serverConfig;
|
|
26
|
+
private syncHashes;
|
|
27
|
+
constructor(mapData: MapData, roomConfig: RoomConfig, serverConfig: ServerConfig, broadcast: (msg: ServerMessage) => void, sendToClient: (clientId: string, msg: ServerMessage) => void);
|
|
28
|
+
start(): void;
|
|
29
|
+
stop(): void;
|
|
30
|
+
getPhase(): GamePhase;
|
|
31
|
+
addPlayer(clientId: string, name: string, team: TeamId): void;
|
|
32
|
+
removePlayer(clientId: string): void;
|
|
33
|
+
handleLockstepInput(clientId: string, tick: number, input: PlayerInput, actions: LockstepAction[], position: Vec3, yaw: number, pitch: number, health: number, isAlive: boolean): void;
|
|
34
|
+
handleSyncCheck(clientId: string, tick: number, hash: string): void;
|
|
35
|
+
private checkAndProcessTick;
|
|
36
|
+
private processTick;
|
|
37
|
+
private verifySyncForTick;
|
|
38
|
+
private getSpawnPoint;
|
|
39
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LockstepRunner - Server-side input relay for deterministic lockstep multiplayer
|
|
3
|
+
*
|
|
4
|
+
* Unlike GameRunner which runs authoritative physics, LockstepRunner only:
|
|
5
|
+
* 1. Collects inputs from all clients for each tick
|
|
6
|
+
* 2. Broadcasts all inputs when the tick is ready
|
|
7
|
+
* 3. Tracks player connections and disconnections
|
|
8
|
+
*
|
|
9
|
+
* All physics simulation happens on clients.
|
|
10
|
+
*/
|
|
11
|
+
// Timing constants
|
|
12
|
+
const TICK_RATE = 60;
|
|
13
|
+
const TICK_MS = 1000 / TICK_RATE;
|
|
14
|
+
const INPUT_TIMEOUT_MS = TICK_MS * 2; // Wait up to 2 ticks for stragglers
|
|
15
|
+
export class LockstepRunner {
|
|
16
|
+
players = new Map();
|
|
17
|
+
currentTick = 0;
|
|
18
|
+
pendingInputs = new Map();
|
|
19
|
+
usedSpawns = new Set();
|
|
20
|
+
// Timing
|
|
21
|
+
tickCheckInterval = null;
|
|
22
|
+
lastTickTime = 0;
|
|
23
|
+
isRunning = false;
|
|
24
|
+
// Callbacks
|
|
25
|
+
broadcast;
|
|
26
|
+
sendToClient;
|
|
27
|
+
// Config
|
|
28
|
+
mapData;
|
|
29
|
+
roomConfig;
|
|
30
|
+
serverConfig;
|
|
31
|
+
// Sync tracking
|
|
32
|
+
syncHashes = new Map(); // playerId -> (tick -> hash)
|
|
33
|
+
constructor(mapData, roomConfig, serverConfig, broadcast, sendToClient) {
|
|
34
|
+
this.mapData = mapData;
|
|
35
|
+
this.roomConfig = roomConfig;
|
|
36
|
+
this.serverConfig = serverConfig;
|
|
37
|
+
this.broadcast = broadcast;
|
|
38
|
+
this.sendToClient = sendToClient;
|
|
39
|
+
}
|
|
40
|
+
// ============ Lifecycle ============
|
|
41
|
+
start() {
|
|
42
|
+
if (this.isRunning)
|
|
43
|
+
return;
|
|
44
|
+
this.isRunning = true;
|
|
45
|
+
this.currentTick = 0;
|
|
46
|
+
this.lastTickTime = Date.now();
|
|
47
|
+
// Send lockstep start message to all clients
|
|
48
|
+
const startMsg = {
|
|
49
|
+
type: 'lockstep_start',
|
|
50
|
+
tick: 0,
|
|
51
|
+
players: Array.from(this.players.values()).map(p => ({
|
|
52
|
+
id: p.id,
|
|
53
|
+
name: p.name,
|
|
54
|
+
team: p.team,
|
|
55
|
+
spawnPosition: p.spawnPosition,
|
|
56
|
+
spawnYaw: p.spawnYaw,
|
|
57
|
+
})),
|
|
58
|
+
mapData: {
|
|
59
|
+
bounds: this.mapData.bounds,
|
|
60
|
+
colliders: this.mapData.colliders,
|
|
61
|
+
spawnPoints: this.mapData.spawnPoints.map(sp => ({
|
|
62
|
+
position: sp.position,
|
|
63
|
+
angle: sp.angle,
|
|
64
|
+
team: sp.team,
|
|
65
|
+
})),
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
this.broadcast(startMsg);
|
|
69
|
+
console.log(`[LockstepRunner] Started with ${this.players.size} players`);
|
|
70
|
+
// Start tick processing loop
|
|
71
|
+
this.tickCheckInterval = setInterval(() => {
|
|
72
|
+
this.checkAndProcessTick();
|
|
73
|
+
}, TICK_MS / 2);
|
|
74
|
+
}
|
|
75
|
+
stop() {
|
|
76
|
+
if (this.tickCheckInterval) {
|
|
77
|
+
clearInterval(this.tickCheckInterval);
|
|
78
|
+
this.tickCheckInterval = null;
|
|
79
|
+
}
|
|
80
|
+
this.isRunning = false;
|
|
81
|
+
console.log('[LockstepRunner] Stopped');
|
|
82
|
+
}
|
|
83
|
+
getPhase() {
|
|
84
|
+
return this.isRunning ? 'live' : 'pre_match';
|
|
85
|
+
}
|
|
86
|
+
// ============ Player Management ============
|
|
87
|
+
addPlayer(clientId, name, team) {
|
|
88
|
+
const spawn = this.getSpawnPoint(team);
|
|
89
|
+
const player = {
|
|
90
|
+
id: clientId,
|
|
91
|
+
name,
|
|
92
|
+
team,
|
|
93
|
+
spawnPosition: { ...spawn.position, y: spawn.position.y + 1.7 }, // Add eye height
|
|
94
|
+
spawnYaw: spawn.angle,
|
|
95
|
+
};
|
|
96
|
+
this.players.set(clientId, player);
|
|
97
|
+
console.log(`[LockstepRunner] Added player ${name} (${clientId}) to team ${team} at spawn (${spawn.position.x.toFixed(1)}, ${spawn.position.y.toFixed(1)}, ${spawn.position.z.toFixed(1)})`);
|
|
98
|
+
// If game is already running, send current state to new player
|
|
99
|
+
if (this.isRunning) {
|
|
100
|
+
this.sendToClient(clientId, {
|
|
101
|
+
type: 'lockstep_start',
|
|
102
|
+
tick: this.currentTick,
|
|
103
|
+
players: Array.from(this.players.values()).map(p => ({
|
|
104
|
+
id: p.id,
|
|
105
|
+
name: p.name,
|
|
106
|
+
team: p.team,
|
|
107
|
+
spawnPosition: p.spawnPosition,
|
|
108
|
+
spawnYaw: p.spawnYaw,
|
|
109
|
+
})),
|
|
110
|
+
mapData: {
|
|
111
|
+
bounds: this.mapData.bounds,
|
|
112
|
+
colliders: this.mapData.colliders,
|
|
113
|
+
spawnPoints: this.mapData.spawnPoints.map(sp => ({
|
|
114
|
+
position: sp.position,
|
|
115
|
+
angle: sp.angle,
|
|
116
|
+
team: sp.team,
|
|
117
|
+
})),
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
removePlayer(clientId) {
|
|
123
|
+
this.players.delete(clientId);
|
|
124
|
+
this.syncHashes.delete(clientId);
|
|
125
|
+
console.log(`[LockstepRunner] Removed player ${clientId}`);
|
|
126
|
+
}
|
|
127
|
+
// ============ Input Handling ============
|
|
128
|
+
handleLockstepInput(clientId, tick, input, actions, position, yaw, pitch, health, isAlive) {
|
|
129
|
+
if (!this.isRunning)
|
|
130
|
+
return;
|
|
131
|
+
// Store input for the specified tick
|
|
132
|
+
if (!this.pendingInputs.has(tick)) {
|
|
133
|
+
this.pendingInputs.set(tick, {
|
|
134
|
+
tick,
|
|
135
|
+
inputs: new Map(),
|
|
136
|
+
receivedAt: Date.now(),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
const tickInputs = this.pendingInputs.get(tick);
|
|
140
|
+
tickInputs.inputs.set(clientId, { input, actions, position, yaw, pitch, health, isAlive });
|
|
141
|
+
// Log if actions are received
|
|
142
|
+
if (actions.length > 0) {
|
|
143
|
+
console.log(`[LockstepRunner] Received ${actions.length} actions from ${clientId.slice(0, 8)} at tick ${tick}: ${JSON.stringify(actions)}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
handleSyncCheck(clientId, tick, hash) {
|
|
147
|
+
// Store hash for sync verification
|
|
148
|
+
if (!this.syncHashes.has(clientId)) {
|
|
149
|
+
this.syncHashes.set(clientId, new Map());
|
|
150
|
+
}
|
|
151
|
+
this.syncHashes.get(clientId).set(tick, hash);
|
|
152
|
+
// Check if all players agree on this tick
|
|
153
|
+
this.verifySyncForTick(tick);
|
|
154
|
+
}
|
|
155
|
+
// ============ Tick Processing ============
|
|
156
|
+
checkAndProcessTick() {
|
|
157
|
+
if (!this.isRunning)
|
|
158
|
+
return;
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
const timeSinceLastTick = now - this.lastTickTime;
|
|
161
|
+
// Get inputs for current tick
|
|
162
|
+
const tickInputs = this.pendingInputs.get(this.currentTick);
|
|
163
|
+
const inputCount = tickInputs ? tickInputs.inputs.size : 0;
|
|
164
|
+
const expectedInputs = this.players.size;
|
|
165
|
+
// Advance tick if:
|
|
166
|
+
// 1. We have all inputs, OR
|
|
167
|
+
// 2. We've waited long enough (don't stall forever)
|
|
168
|
+
const haveAllInputs = inputCount >= expectedInputs;
|
|
169
|
+
const timeoutReached = timeSinceLastTick >= INPUT_TIMEOUT_MS;
|
|
170
|
+
if (haveAllInputs || timeoutReached) {
|
|
171
|
+
this.processTick();
|
|
172
|
+
this.lastTickTime = now;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
processTick() {
|
|
176
|
+
// Collect all inputs for current tick
|
|
177
|
+
const tickInputs = this.pendingInputs.get(this.currentTick);
|
|
178
|
+
const inputs = [];
|
|
179
|
+
if (tickInputs) {
|
|
180
|
+
for (const [playerId, data] of tickInputs.inputs) {
|
|
181
|
+
inputs.push({
|
|
182
|
+
playerId,
|
|
183
|
+
input: data.input,
|
|
184
|
+
actions: data.actions,
|
|
185
|
+
position: data.position,
|
|
186
|
+
yaw: data.yaw,
|
|
187
|
+
pitch: data.pitch,
|
|
188
|
+
health: data.health,
|
|
189
|
+
isAlive: data.isAlive,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Broadcast tick to all clients
|
|
194
|
+
const tickMsg = {
|
|
195
|
+
type: 'lockstep_tick',
|
|
196
|
+
tick: this.currentTick,
|
|
197
|
+
inputs,
|
|
198
|
+
};
|
|
199
|
+
// Log if any actions are being broadcast
|
|
200
|
+
const totalActions = inputs.reduce((sum, i) => sum + i.actions.length, 0);
|
|
201
|
+
if (totalActions > 0) {
|
|
202
|
+
const actionsWithData = inputs.filter(i => i.actions.length > 0).map(i => ({
|
|
203
|
+
player: i.playerId.slice(0, 8),
|
|
204
|
+
actions: i.actions.map(a => ({ type: a.type, hasData: !!a.data })),
|
|
205
|
+
}));
|
|
206
|
+
console.log(`[LockstepRunner] Broadcasting tick ${this.currentTick} with ${totalActions} actions: ${JSON.stringify(actionsWithData)}`);
|
|
207
|
+
}
|
|
208
|
+
this.broadcast(tickMsg);
|
|
209
|
+
// Cleanup old tick data
|
|
210
|
+
this.pendingInputs.delete(this.currentTick - 10);
|
|
211
|
+
// Advance tick
|
|
212
|
+
this.currentTick++;
|
|
213
|
+
}
|
|
214
|
+
verifySyncForTick(tick) {
|
|
215
|
+
// Collect all hashes for this tick
|
|
216
|
+
const hashes = new Map(); // hash -> playerIds
|
|
217
|
+
for (const [playerId, playerHashes] of this.syncHashes) {
|
|
218
|
+
const hash = playerHashes.get(tick);
|
|
219
|
+
if (hash) {
|
|
220
|
+
if (!hashes.has(hash)) {
|
|
221
|
+
hashes.set(hash, []);
|
|
222
|
+
}
|
|
223
|
+
hashes.get(hash).push(playerId);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// Check for desync
|
|
227
|
+
if (hashes.size > 1) {
|
|
228
|
+
// Multiple different hashes - desync detected!
|
|
229
|
+
console.error(`[LockstepRunner] DESYNC at tick ${tick}!`);
|
|
230
|
+
// Find the majority hash (or first if tie)
|
|
231
|
+
let majorityHash = '';
|
|
232
|
+
let majorityCount = 0;
|
|
233
|
+
for (const [hash, players] of hashes) {
|
|
234
|
+
if (players.length > majorityCount) {
|
|
235
|
+
majorityHash = hash;
|
|
236
|
+
majorityCount = players.length;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// Notify desynced players
|
|
240
|
+
for (const [hash, players] of hashes) {
|
|
241
|
+
if (hash !== majorityHash) {
|
|
242
|
+
for (const playerId of players) {
|
|
243
|
+
console.error(`[LockstepRunner] Player ${playerId} desynced: expected ${majorityHash}, got ${hash}`);
|
|
244
|
+
this.sendToClient(playerId, {
|
|
245
|
+
type: 'lockstep_desync',
|
|
246
|
+
tick,
|
|
247
|
+
expectedHash: majorityHash,
|
|
248
|
+
receivedHash: hash,
|
|
249
|
+
playerId,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// Cleanup old sync data
|
|
256
|
+
for (const playerHashes of this.syncHashes.values()) {
|
|
257
|
+
playerHashes.delete(tick - 100);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// ============ Utility ============
|
|
261
|
+
getSpawnPoint(team) {
|
|
262
|
+
// Filter spawns by team
|
|
263
|
+
const validSpawns = this.mapData.spawnPoints.filter((s, idx) => !this.usedSpawns.has(idx) && (s.team === team || s.team === 'DM'));
|
|
264
|
+
if (validSpawns.length === 0) {
|
|
265
|
+
// All spawns used, reset
|
|
266
|
+
this.usedSpawns.clear();
|
|
267
|
+
return this.mapData.spawnPoints[0];
|
|
268
|
+
}
|
|
269
|
+
// Pick spawn deterministically (by index to ensure consistency)
|
|
270
|
+
const idx = this.usedSpawns.size % validSpawns.length;
|
|
271
|
+
const spawn = validSpawns[idx];
|
|
272
|
+
const originalIdx = this.mapData.spawnPoints.indexOf(spawn);
|
|
273
|
+
this.usedSpawns.add(originalIdx);
|
|
274
|
+
return spawn;
|
|
275
|
+
}
|
|
276
|
+
}
|
package/dist/Room.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { RoomConfig, RoomInfo, ClientMessage, ServerMessage } from './protocol.js';
|
|
2
|
+
import { ConnectedClient, ServerConfig } from './types.js';
|
|
3
|
+
export declare class Room {
|
|
4
|
+
id: string;
|
|
5
|
+
config: RoomConfig;
|
|
6
|
+
hostId: string;
|
|
7
|
+
lastActivity: number;
|
|
8
|
+
private clients;
|
|
9
|
+
private gameRunner;
|
|
10
|
+
private lockstepRunner;
|
|
11
|
+
private serverConfig;
|
|
12
|
+
private mapData;
|
|
13
|
+
private teamAssignments;
|
|
14
|
+
private voiceRelay;
|
|
15
|
+
constructor(id: string, config: RoomConfig, hostId: string, serverConfig: ServerConfig);
|
|
16
|
+
addPlayer(clientId: string, client: ConnectedClient): void;
|
|
17
|
+
removePlayer(clientId: string): void;
|
|
18
|
+
getPlayerCount(): number;
|
|
19
|
+
private assignTeam;
|
|
20
|
+
startGame(): void;
|
|
21
|
+
private startLockstepGame;
|
|
22
|
+
private startServerAuthoritativeGame;
|
|
23
|
+
private createInitialGameState;
|
|
24
|
+
stop(): void;
|
|
25
|
+
/**
|
|
26
|
+
* Full cleanup when room is destroyed
|
|
27
|
+
*/
|
|
28
|
+
destroy(): void;
|
|
29
|
+
handleMessage(clientId: string, message: ClientMessage): void;
|
|
30
|
+
private handleReady;
|
|
31
|
+
private handleStartGame;
|
|
32
|
+
private handleChangeTeam;
|
|
33
|
+
private handleChat;
|
|
34
|
+
/**
|
|
35
|
+
* Handle binary data (voice frames)
|
|
36
|
+
* Returns true if it was a voice frame
|
|
37
|
+
*/
|
|
38
|
+
handleBinaryData(clientId: string, data: Buffer | ArrayBuffer | Uint8Array): boolean;
|
|
39
|
+
broadcast(message: ServerMessage): void;
|
|
40
|
+
broadcastExcept(excludeId: string, message: ServerMessage): void;
|
|
41
|
+
sendToClient(clientId: string, message: ServerMessage): void;
|
|
42
|
+
getInfo(): RoomInfo;
|
|
43
|
+
}
|