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