@quake2ts/server 0.0.1

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.
Files changed (48) hide show
  1. package/dist/client.d.ts +51 -0
  2. package/dist/client.js +100 -0
  3. package/dist/dedicated.d.ts +72 -0
  4. package/dist/dedicated.js +1104 -0
  5. package/dist/index.cjs +1586 -0
  6. package/dist/index.d.ts +7 -0
  7. package/dist/index.js +1543 -0
  8. package/dist/net/nodeWsDriver.d.ts +16 -0
  9. package/dist/net/nodeWsDriver.js +122 -0
  10. package/dist/protocol/player.d.ts +2 -0
  11. package/dist/protocol/player.js +1 -0
  12. package/dist/protocol/write.d.ts +7 -0
  13. package/dist/protocol/write.js +167 -0
  14. package/dist/protocol.d.ts +17 -0
  15. package/dist/protocol.js +71 -0
  16. package/dist/server.d.ts +50 -0
  17. package/dist/server.js +12 -0
  18. package/dist/transport.d.ts +7 -0
  19. package/dist/transport.js +1 -0
  20. package/dist/transports/websocket.d.ts +11 -0
  21. package/dist/transports/websocket.js +38 -0
  22. package/package.json +35 -0
  23. package/src/client.ts +173 -0
  24. package/src/dedicated.ts +1295 -0
  25. package/src/index.ts +8 -0
  26. package/src/net/nodeWsDriver.ts +129 -0
  27. package/src/protocol/player.ts +2 -0
  28. package/src/protocol/write.ts +185 -0
  29. package/src/protocol.ts +91 -0
  30. package/src/server.ts +76 -0
  31. package/src/transport.ts +8 -0
  32. package/src/transports/websocket.ts +42 -0
  33. package/test.bsp +0 -0
  34. package/tests/client.test.ts +20 -0
  35. package/tests/connection_flow.test.ts +93 -0
  36. package/tests/dedicated.test.ts +211 -0
  37. package/tests/dedicated_trace.test.ts +117 -0
  38. package/tests/integration/configstring_sync.test.ts +235 -0
  39. package/tests/lag.test.ts +144 -0
  40. package/tests/protocol/player.test.ts +88 -0
  41. package/tests/protocol/write.test.ts +107 -0
  42. package/tests/protocol.test.ts +102 -0
  43. package/tests/server-state.test.ts +17 -0
  44. package/tests/server.test.ts +99 -0
  45. package/tests/unit/dedicated_timeout.test.ts +142 -0
  46. package/tsconfig.json +9 -0
  47. package/tsconfig.tsbuildinfo +1 -0
  48. package/vitest.config.ts +40 -0
@@ -0,0 +1,1295 @@
1
+ import { WebSocketNetDriver } from './net/nodeWsDriver.js';
2
+ import { createGame, GameExports, GameImports, GameEngine, Entity, MulticastType, GameStateSnapshot, Solid } from '@quake2ts/game';
3
+ import { Client, createClient, ClientState } from './client.js';
4
+ import { ClientMessageParser } from './protocol.js';
5
+ import { BinaryWriter, ServerCommand, BinaryStream, UserCommand, traceBox, CollisionModel, UPDATE_BACKUP, MAX_CONFIGSTRINGS, MAX_EDICTS, EntityState, CollisionEntityIndex, inPVS, inPHS, crc8, NetDriver, ConfigStringIndex } from '@quake2ts/shared';
6
+ import { parseBsp, PakArchive } from '@quake2ts/engine';
7
+ import fs from 'node:fs/promises';
8
+ import * as path from 'node:path';
9
+ import { createPlayerInventory, createPlayerWeaponStates } from '@quake2ts/game';
10
+ import { Server, ServerState, ServerStatic } from './server.js';
11
+ import { writeDeltaEntity, writeRemoveEntity } from '@quake2ts/shared';
12
+ import { writePlayerState, ProtocolPlayerState } from './protocol/player.js';
13
+ import { writeServerCommand } from './protocol/write.js';
14
+ import { Vec3, lerpAngle } from '@quake2ts/shared';
15
+ import { NetworkTransport } from './transport.js';
16
+ import { WebSocketTransport } from './transports/websocket.js';
17
+
18
+ function lerp(a: number, b: number, t: number): number {
19
+ return a + (b - a) * t;
20
+ }
21
+
22
+ const DEFAULT_MAX_CLIENTS = 16;
23
+ const FRAME_RATE = 10; // 10Hz dedicated server loop (Q2 standard)
24
+ const FRAME_TIME_MS = 1000 / FRAME_RATE;
25
+
26
+ // Lag Compensation History
27
+ interface EntityHistory {
28
+ time: number;
29
+ origin: Vec3;
30
+ mins: Vec3;
31
+ maxs: Vec3;
32
+ angles: Vec3;
33
+ }
34
+
35
+ interface EntityBackup {
36
+ origin: Vec3;
37
+ mins: Vec3;
38
+ maxs: Vec3;
39
+ angles: Vec3;
40
+ link: boolean;
41
+ }
42
+
43
+ export interface ClientInfo {
44
+ id: number;
45
+ name: string;
46
+ ping: number;
47
+ address: string;
48
+ }
49
+
50
+ export interface ServerOptions {
51
+ mapName?: string;
52
+ maxPlayers?: number;
53
+ deathmatch?: boolean;
54
+ port?: number;
55
+ transport?: NetworkTransport;
56
+ }
57
+
58
+ export class DedicatedServer implements GameEngine {
59
+ private transport: NetworkTransport;
60
+ private svs: ServerStatic;
61
+ private sv: Server;
62
+ private game: GameExports | null = null;
63
+ private frameTimeout: NodeJS.Timeout | null = null;
64
+ private entityIndex: CollisionEntityIndex | null = null;
65
+
66
+ // History buffer: Map<EntityIndex, HistoryArray>
67
+ private history = new Map<number, EntityHistory[]>();
68
+ private backup = new Map<number, EntityBackup>();
69
+
70
+ // Events
71
+ public onClientConnected?: (clientId: number, name: string) => void;
72
+ public onClientDisconnected?: (clientId: number) => void;
73
+ public onServerError?: (error: Error) => void;
74
+
75
+ private options: ServerOptions;
76
+
77
+ constructor(optionsOrPort: ServerOptions | number = {}) {
78
+ const options = typeof optionsOrPort === 'number' ? { port: optionsOrPort } : optionsOrPort;
79
+ this.options = {
80
+ port: 27910,
81
+ maxPlayers: DEFAULT_MAX_CLIENTS,
82
+ deathmatch: true,
83
+ ...options
84
+ };
85
+
86
+ this.transport = this.options.transport || new WebSocketTransport();
87
+
88
+ this.svs = {
89
+ initialized: false,
90
+ realTime: 0,
91
+ mapCmd: '',
92
+ spawnCount: 0,
93
+ clients: new Array(this.options.maxPlayers!).fill(null),
94
+ lastHeartbeat: 0,
95
+ challenges: []
96
+ };
97
+ this.sv = {
98
+ state: ServerState.Dead,
99
+ attractLoop: false,
100
+ loadGame: false,
101
+ startTime: 0, // Initialize startTime
102
+ time: 0,
103
+ frame: 0,
104
+ name: '',
105
+ collisionModel: null,
106
+ configStrings: new Array(MAX_CONFIGSTRINGS).fill(''),
107
+ baselines: new Array(MAX_EDICTS).fill(null),
108
+ multicastBuf: new Uint8Array(0)
109
+ };
110
+ this.entityIndex = new CollisionEntityIndex();
111
+ }
112
+
113
+ public setTransport(transport: NetworkTransport) {
114
+ if (this.svs.initialized) {
115
+ throw new Error('Cannot set transport after server started');
116
+ }
117
+ this.transport = transport;
118
+ }
119
+
120
+ public async startServer(mapName?: string) {
121
+ const map = mapName || this.options.mapName;
122
+ if (!map) {
123
+ throw new Error('No map specified');
124
+ }
125
+ await this.start(map);
126
+ }
127
+
128
+ public stopServer() {
129
+ this.stop();
130
+ }
131
+
132
+ public kickPlayer(clientId: number) {
133
+ if (clientId < 0 || clientId >= this.svs.clients.length) return;
134
+ const client = this.svs.clients[clientId];
135
+ if (client && client.state >= ClientState.Connected) {
136
+ console.log(`Kicking client ${clientId}`);
137
+ // Send disconnect message if possible
138
+ if (client.netchan) {
139
+ const writer = new BinaryWriter();
140
+ writer.writeByte(ServerCommand.print);
141
+ writer.writeByte(2);
142
+ writer.writeString('Kicked by server.\n');
143
+ try {
144
+ const packet = client.netchan.transmit(writer.getData());
145
+ client.net.send(packet);
146
+ } catch (e) {}
147
+ }
148
+ this.dropClient(client);
149
+ }
150
+ }
151
+
152
+ public async changeMap(mapName: string) {
153
+ console.log(`Changing map to ${mapName}`);
154
+
155
+ // Notify clients?
156
+ this.multicast(
157
+ {x:0,y:0,z:0},
158
+ MulticastType.All,
159
+ ServerCommand.print,
160
+ 2,
161
+ `Changing map to ${mapName}...\n`
162
+ );
163
+
164
+ // Stop current game loop
165
+ if (this.frameTimeout) clearTimeout(this.frameTimeout);
166
+
167
+ // Reset Server State
168
+ this.sv.state = ServerState.Loading;
169
+ this.sv.collisionModel = null;
170
+ this.sv.time = 0;
171
+ this.sv.frame = 0;
172
+ this.sv.configStrings.fill('');
173
+ this.sv.baselines.fill(null);
174
+ this.history.clear();
175
+ this.entityIndex = new CollisionEntityIndex();
176
+
177
+ // Load new Map
178
+ await this.loadMap(mapName);
179
+
180
+ // Re-init game
181
+ this.initGame();
182
+
183
+ // Send new serverdata to all connected clients and respawn them
184
+ for (const client of this.svs.clients) {
185
+ if (client && client.state >= ClientState.Connected) {
186
+ // Reset client game state
187
+ client.edict = null; // Will be respawned
188
+ client.state = ClientState.Connected; // Move back to connected state to trigger spawn
189
+
190
+ // Send new serverdata
191
+ this.sendServerData(client);
192
+
193
+ // Force them to reload/precache
194
+ client.netchan.writeReliableByte(ServerCommand.stufftext);
195
+ client.netchan.writeReliableString(`map ${mapName}\n`);
196
+
197
+ // Trigger spawn
198
+ this.handleBegin(client);
199
+ }
200
+ }
201
+
202
+ // Resume loop
203
+ this.runFrame();
204
+ }
205
+
206
+ public getConnectedClients(): ClientInfo[] {
207
+ const list: ClientInfo[] = [];
208
+ for (const client of this.svs.clients) {
209
+ if (client && client.state >= ClientState.Connected) {
210
+ list.push({
211
+ id: client.index,
212
+ name: 'Player', // TODO: Parse userinfo for name
213
+ ping: client.ping,
214
+ address: 'unknown'
215
+ });
216
+ }
217
+ }
218
+ return list;
219
+ }
220
+
221
+ private async start(mapName: string) {
222
+ console.log(`Starting Dedicated Server on port ${this.options.port}...`);
223
+ this.sv.name = mapName;
224
+ this.svs.initialized = true;
225
+ this.svs.spawnCount++;
226
+
227
+ // 1. Initialize Network
228
+ this.transport.onConnection((driver, info) => {
229
+ console.log('New connection', info ? `from ${info.socket?.remoteAddress}` : '');
230
+ this.handleConnection(driver, info);
231
+ });
232
+
233
+ this.transport.onError((err) => {
234
+ if (this.onServerError) this.onServerError(err);
235
+ });
236
+
237
+ await this.transport.listen(this.options.port!);
238
+
239
+ // 2. Load Map
240
+ await this.loadMap(mapName);
241
+
242
+ // 3. Initialize Game
243
+ this.initGame();
244
+
245
+ // 4. Start Loop
246
+ this.runFrame();
247
+ console.log('Server started.');
248
+ }
249
+
250
+ private async loadMap(mapName: string) {
251
+ try {
252
+ console.log(`Loading map ${mapName}...`);
253
+ this.sv.state = ServerState.Loading;
254
+ this.sv.name = mapName;
255
+
256
+ let arrayBuffer: ArrayBuffer;
257
+
258
+ // Check if map exists on disk
259
+ try {
260
+ await fs.access(mapName);
261
+ const mapData = await fs.readFile(mapName);
262
+ arrayBuffer = mapData.buffer.slice(mapData.byteOffset, mapData.byteOffset + mapData.byteLength) as ArrayBuffer;
263
+ } catch (e) {
264
+ console.log(`Map file ${mapName} not found on disk, checking pak.pak...`);
265
+ // Check pak.pak
266
+ const possiblePakPaths = [
267
+ path.resolve(process.cwd(), 'pak.pak'),
268
+ path.resolve(process.cwd(), '../pak.pak'),
269
+ path.resolve(process.cwd(), '../../pak.pak'),
270
+ path.resolve('baseq2/pak.pak')
271
+ ];
272
+
273
+ let pakPath: string | null = null;
274
+ for (const p of possiblePakPaths) {
275
+ try {
276
+ await fs.access(p);
277
+ pakPath = p;
278
+ break;
279
+ } catch {}
280
+ }
281
+
282
+ if (!pakPath) {
283
+ throw new Error(`Map ${mapName} not found and pak.pak not found.`);
284
+ }
285
+
286
+ const pakBuffer = await fs.readFile(pakPath);
287
+ const pakArrayBuffer = pakBuffer.buffer.slice(pakBuffer.byteOffset, pakBuffer.byteOffset + pakBuffer.byteLength) as ArrayBuffer;
288
+ const pak = PakArchive.fromArrayBuffer('pak.pak', pakArrayBuffer);
289
+
290
+ const entry = pak.getEntry(mapName);
291
+ if (!entry) {
292
+ throw new Error(`Map ${mapName} not found in pak.pak`);
293
+ }
294
+
295
+ const data = pak.readFile(mapName);
296
+ arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer;
297
+ }
298
+
299
+ const bspMap = parseBsp(arrayBuffer);
300
+
301
+ // Convert BspMap to CollisionModel manually
302
+ const planes = bspMap.planes.map(p => {
303
+ const normal = { x: p.normal[0], y: p.normal[1], z: p.normal[2] };
304
+ let signbits = 0;
305
+ if (normal.x < 0) signbits |= 1;
306
+ if (normal.y < 0) signbits |= 2;
307
+ if (normal.z < 0) signbits |= 4;
308
+ return {
309
+ normal,
310
+ dist: p.dist,
311
+ type: p.type,
312
+ signbits
313
+ };
314
+ });
315
+
316
+ const nodes = bspMap.nodes.map(n => ({
317
+ plane: planes[n.planeIndex],
318
+ children: n.children
319
+ }));
320
+
321
+ const leafBrushes: number[] = [];
322
+ const leaves = bspMap.leafs.map((l, i) => {
323
+ const brushes = bspMap.leafLists.leafBrushes[i];
324
+ const firstLeafBrush = leafBrushes.length;
325
+ leafBrushes.push(...brushes);
326
+ return {
327
+ contents: l.contents,
328
+ cluster: l.cluster,
329
+ area: l.area,
330
+ firstLeafBrush,
331
+ numLeafBrushes: brushes.length
332
+ };
333
+ });
334
+
335
+ const brushes = bspMap.brushes.map(b => {
336
+ const sides = [];
337
+ for (let i = 0; i < b.numSides; i++) {
338
+ const sideIndex = b.firstSide + i;
339
+ const bspSide = bspMap.brushSides[sideIndex];
340
+ const plane = planes[bspSide.planeIndex];
341
+ const texInfo = bspMap.texInfo[bspSide.texInfo];
342
+ const surfaceFlags = texInfo ? texInfo.flags : 0;
343
+
344
+ sides.push({
345
+ plane,
346
+ surfaceFlags
347
+ });
348
+ }
349
+ return {
350
+ contents: b.contents,
351
+ sides,
352
+ checkcount: 0
353
+ };
354
+ });
355
+
356
+ const bmodels = bspMap.models.map(m => ({
357
+ mins: { x: m.mins[0], y: m.mins[1], z: m.mins[2] },
358
+ maxs: { x: m.maxs[0], y: m.maxs[1], z: m.maxs[2] },
359
+ origin: { x: m.origin[0], y: m.origin[1], z: m.origin[2] },
360
+ headnode: m.headNode
361
+ }));
362
+
363
+ let visibility;
364
+ if (bspMap.visibility) {
365
+ visibility = {
366
+ numClusters: bspMap.visibility.numClusters,
367
+ clusters: bspMap.visibility.clusters
368
+ };
369
+ }
370
+
371
+ this.sv.collisionModel = {
372
+ planes,
373
+ nodes,
374
+ leaves,
375
+ brushes,
376
+ leafBrushes,
377
+ bmodels,
378
+ visibility
379
+ };
380
+
381
+ console.log(`Map loaded successfully.`);
382
+ } catch (e) {
383
+ console.warn('Failed to load map:', e);
384
+ if (this.onServerError) this.onServerError(e as Error);
385
+ }
386
+ }
387
+
388
+ private initGame() {
389
+ this.sv.startTime = Date.now();
390
+ const imports: GameImports = {
391
+ trace: (start, mins, maxs, end, passent, contentmask) => {
392
+ if (this.entityIndex) {
393
+ const result = this.entityIndex.trace({
394
+ start,
395
+ end,
396
+ mins: mins || undefined,
397
+ maxs: maxs || undefined,
398
+ model: this.sv.collisionModel as CollisionModel,
399
+ passId: passent ? passent.index : undefined,
400
+ contentMask: contentmask
401
+ });
402
+
403
+ let hitEntity: Entity | null = null;
404
+ if (result.entityId !== null && result.entityId !== undefined && this.game) {
405
+ hitEntity = this.game.entities.getByIndex(result.entityId) ?? null;
406
+ }
407
+
408
+ return {
409
+ allsolid: result.allsolid,
410
+ startsolid: result.startsolid,
411
+ fraction: result.fraction,
412
+ endpos: result.endpos,
413
+ plane: result.plane || null,
414
+ surfaceFlags: result.surfaceFlags || 0,
415
+ contents: result.contents || 0,
416
+ ent: hitEntity
417
+ };
418
+ }
419
+
420
+ const worldResult = this.sv.collisionModel ? traceBox({
421
+ start,
422
+ end,
423
+ mins: mins || undefined,
424
+ maxs: maxs || undefined,
425
+ model: this.sv.collisionModel,
426
+ contentMask: contentmask
427
+ }) : {
428
+ fraction: 1.0,
429
+ endpos: { ...end },
430
+ allsolid: false,
431
+ startsolid: false,
432
+ plane: null,
433
+ surfaceFlags: 0,
434
+ contents: 0
435
+ };
436
+
437
+ return {
438
+ allsolid: worldResult.allsolid,
439
+ startsolid: worldResult.startsolid,
440
+ fraction: worldResult.fraction,
441
+ endpos: worldResult.endpos,
442
+ plane: worldResult.plane || null,
443
+ surfaceFlags: worldResult.surfaceFlags || 0,
444
+ contents: worldResult.contents || 0,
445
+ ent: null
446
+ };
447
+ },
448
+ pointcontents: (p) => 0,
449
+ linkentity: (ent) => {
450
+ if (!this.entityIndex) return;
451
+ this.entityIndex.link({
452
+ id: ent.index,
453
+ origin: ent.origin,
454
+ mins: ent.mins,
455
+ maxs: ent.maxs,
456
+ contents: ent.solid === 0 ? 0 : 1,
457
+ surfaceFlags: 0
458
+ });
459
+ },
460
+ areaEdicts: (mins, maxs) => {
461
+ if (!this.entityIndex) return [];
462
+ return this.entityIndex.gatherTriggerTouches({ x: 0, y: 0, z: 0 }, mins, maxs, 0xFFFFFFFF);
463
+ },
464
+ multicast: (origin, type, event, ...args) => this.multicast(origin, type, event, ...args),
465
+ unicast: (ent, reliable, event, ...args) => this.unicast(ent, reliable, event, ...args),
466
+ configstring: (index, value) => this.SV_SetConfigString(index, value),
467
+ serverCommand: (cmd) => { console.log(`Server command: ${cmd}`); },
468
+ setLagCompensation: (active, client, lagMs) => this.setLagCompensation(active, client, lagMs)
469
+ };
470
+
471
+ this.game = createGame(imports, this, {
472
+ gravity: { x: 0, y: 0, z: -800 },
473
+ deathmatch: this.options.deathmatch !== false
474
+ });
475
+
476
+ this.game.init(0);
477
+ this.game.spawnWorld();
478
+
479
+ this.populateBaselines();
480
+
481
+ this.sv.state = ServerState.Game;
482
+ }
483
+
484
+ private populateBaselines() {
485
+ if (!this.game) return;
486
+
487
+ this.game.entities.forEachEntity((ent) => {
488
+ if (ent.index >= MAX_EDICTS) return;
489
+ if (ent.modelindex > 0 || ent.solid !== Solid.Not) {
490
+ this.sv.baselines[ent.index] = this.entityToState(ent);
491
+ }
492
+ });
493
+ }
494
+
495
+ private entityToState(ent: Entity): EntityState {
496
+ return {
497
+ number: ent.index,
498
+ origin: { ...ent.origin },
499
+ angles: { ...ent.angles },
500
+ modelIndex: ent.modelindex,
501
+ frame: ent.frame,
502
+ skinNum: ent.skin,
503
+ effects: ent.effects,
504
+ renderfx: ent.renderfx,
505
+ solid: ent.solid,
506
+ sound: ent.sounds,
507
+ event: 0
508
+ };
509
+ }
510
+
511
+ public stop() {
512
+ if (this.frameTimeout) clearTimeout(this.frameTimeout);
513
+ this.transport.close();
514
+ this.game?.shutdown();
515
+ this.sv.state = ServerState.Dead;
516
+ }
517
+
518
+ private handleConnection(driver: NetDriver, info?: any) {
519
+ let clientIndex = -1;
520
+ for (let i = 0; i < this.options.maxPlayers!; i++) {
521
+ if (this.svs.clients[i] === null || this.svs.clients[i]!.state === ClientState.Free) {
522
+ clientIndex = i;
523
+ break;
524
+ }
525
+ }
526
+
527
+ if (clientIndex === -1) {
528
+ console.log('Server full, rejecting connection');
529
+ driver.disconnect();
530
+ return;
531
+ }
532
+
533
+ const client = createClient(clientIndex, driver);
534
+ client.lastMessage = this.sv.frame;
535
+ client.lastCommandTime = Date.now();
536
+ this.svs.clients[clientIndex] = client;
537
+
538
+ console.log(`Client ${clientIndex} attached to slot from ${info?.socket?.remoteAddress || 'unknown'}`);
539
+
540
+ driver.onMessage((data) => this.onClientMessage(client, data));
541
+ driver.onClose(() => this.onClientDisconnect(client));
542
+ }
543
+
544
+ private onClientMessage(client: Client, data: Uint8Array) {
545
+ const buffer = data.byteOffset === 0 && data.byteLength === data.buffer.byteLength
546
+ ? data.buffer
547
+ : data.slice().buffer;
548
+
549
+ if (buffer instanceof ArrayBuffer) {
550
+ client.messageQueue.push(new Uint8Array(buffer));
551
+ } else {
552
+ // SharedArrayBuffer fallback or other weirdness
553
+ client.messageQueue.push(new Uint8Array(buffer as any));
554
+ }
555
+ }
556
+
557
+ private onClientDisconnect(client: Client) {
558
+ console.log(`Client ${client.index} disconnected`);
559
+ if (client.edict && this.game) {
560
+ this.game.clientDisconnect(client.edict);
561
+ }
562
+
563
+ if (this.onClientDisconnected) {
564
+ this.onClientDisconnected(client.index);
565
+ }
566
+
567
+ client.state = ClientState.Free;
568
+
569
+ this.svs.clients[client.index] = null;
570
+ if (this.entityIndex && client.edict) {
571
+ this.entityIndex.unlink(client.edict.index);
572
+ }
573
+ }
574
+
575
+ private dropClient(client: Client) {
576
+ if (client.net) {
577
+ client.net.disconnect();
578
+ }
579
+ }
580
+
581
+ private handleMove(client: Client, cmd: UserCommand, checksum: number, lastFrame: number) {
582
+ if (lastFrame > 0 && lastFrame <= client.lastFrame && lastFrame > client.lastFrame - UPDATE_BACKUP) {
583
+ const frameIdx = lastFrame % UPDATE_BACKUP;
584
+ const frame = client.frames[frameIdx];
585
+
586
+ if (frame.packetCRC !== checksum) {
587
+ console.warn(`Client ${client.index} checksum mismatch for frame ${lastFrame}: expected ${frame.packetCRC}, got ${checksum}`);
588
+ }
589
+ }
590
+
591
+ client.lastCmd = cmd;
592
+ client.lastMessage = this.sv.frame;
593
+ client.commandCount++;
594
+ }
595
+
596
+ private handleUserInfo(client: Client, info: string) {
597
+ client.userInfo = info;
598
+ }
599
+
600
+ private handleStringCmd(client: Client, cmd: string) {
601
+ console.log(`Client ${client.index} stringcmd: ${cmd}`);
602
+ if (cmd === 'getchallenge') {
603
+ this.handleGetChallenge(client);
604
+ } else if (cmd.startsWith('connect ')) {
605
+ const userInfo = cmd.substring(8);
606
+ this.handleConnect(client, userInfo);
607
+ } else if (cmd === 'begin') {
608
+ this.handleBegin(client);
609
+ } else if (cmd === 'status') {
610
+ this.handleStatus(client);
611
+ }
612
+ }
613
+
614
+ private handleStatus(client: Client) {
615
+ let activeClients = 0;
616
+ for (const c of this.svs.clients) {
617
+ if (c && c.state >= ClientState.Connected) {
618
+ activeClients++;
619
+ }
620
+ }
621
+
622
+ let status = `map: ${this.sv.name}\n`;
623
+ status += `players: ${activeClients} active (${this.options.maxPlayers} max)\n\n`;
624
+ status += `num score ping name lastmsg address qport rate\n`;
625
+ status += `--- ----- ---- --------------- ------- --------------------- ----- -----\n`;
626
+
627
+ for (const c of this.svs.clients) {
628
+ if (c && c.state >= ClientState.Connected) {
629
+ const score = 0;
630
+ const ping = 0;
631
+ const lastMsg = this.sv.frame - c.lastMessage;
632
+ const address = 'unknown';
633
+ status += `${c.index.toString().padStart(3)} ${score.toString().padStart(5)} ${ping.toString().padStart(4)} ${c.userInfo.substring(0, 15).padEnd(15)} ${lastMsg.toString().padStart(7)} ${address.padEnd(21)} ${c.netchan.qport.toString().padStart(5)} 0\n`;
634
+ }
635
+ }
636
+
637
+ const writer = new BinaryWriter();
638
+ writer.writeByte(ServerCommand.print);
639
+ writer.writeByte(2);
640
+ writer.writeString(status);
641
+ const packet = client.netchan.transmit(writer.getData());
642
+ client.net.send(packet);
643
+ }
644
+
645
+ private handleGetChallenge(client: Client) {
646
+ const challenge = Math.floor(Math.random() * 1000000) + 1;
647
+ client.challenge = challenge;
648
+
649
+ const writer = new BinaryWriter();
650
+ writer.writeByte(ServerCommand.stufftext);
651
+ writer.writeString(`challenge ${challenge}\n`);
652
+ const packet = client.netchan.transmit(writer.getData());
653
+ client.net.send(packet);
654
+ }
655
+
656
+ private handleConnect(client: Client, userInfo: string) {
657
+ if (!this.game) return;
658
+
659
+ const result = this.game.clientConnect(client.edict || null, userInfo);
660
+ if (result === true) {
661
+ client.state = ClientState.Connected;
662
+ client.userInfo = userInfo;
663
+ console.log(`Client ${client.index} connected: ${userInfo}`);
664
+
665
+ if (this.onClientConnected) {
666
+ // Extract name from userinfo if possible, default to Player
667
+ this.onClientConnected(client.index, 'Player');
668
+ }
669
+
670
+ try {
671
+ this.sendServerData(client);
672
+
673
+ client.netchan.writeReliableByte(ServerCommand.stufftext);
674
+ client.netchan.writeReliableString("precache\n");
675
+
676
+ const packet = client.netchan.transmit();
677
+ client.net.send(packet);
678
+ } catch (e) {
679
+ console.warn(`Client ${client.index} reliable buffer overflow or connection error`);
680
+ this.dropClient(client);
681
+ }
682
+ } else {
683
+ console.log(`Client ${client.index} rejected: ${result}`);
684
+ const writer = new BinaryWriter();
685
+ writer.writeByte(ServerCommand.print);
686
+ writer.writeByte(2);
687
+ writer.writeString(`Connection rejected: ${result}\n`);
688
+ const packet = client.netchan.transmit(writer.getData());
689
+ client.net.send(packet);
690
+ }
691
+ }
692
+
693
+ private handleBegin(client: Client) {
694
+ if (client.state === ClientState.Connected) {
695
+ this.spawnClient(client);
696
+ }
697
+ }
698
+
699
+ private spawnClient(client: Client) {
700
+ if (!this.game) return;
701
+
702
+ const ent = this.game.clientBegin({
703
+ inventory: createPlayerInventory(),
704
+ weaponStates: createPlayerWeaponStates(),
705
+ buttons: 0,
706
+ pm_type: 0,
707
+ pm_time: 0,
708
+ pm_flags: 0,
709
+ gun_frame: 0,
710
+ rdflags: 0,
711
+ fov: 90,
712
+ pers: {
713
+ connected: true,
714
+ inventory: [],
715
+ health: 100,
716
+ max_health: 100,
717
+ savedFlags: 0,
718
+ selected_item: 0
719
+ }
720
+ });
721
+
722
+ client.edict = ent;
723
+ client.state = ClientState.Active;
724
+
725
+ console.log(`Client ${client.index} entered game`);
726
+ }
727
+
728
+ private sendServerData(client: Client) {
729
+ client.netchan.writeReliableByte(ServerCommand.serverdata);
730
+ client.netchan.writeReliableLong(34);
731
+ client.netchan.writeReliableLong(this.sv.frame);
732
+ client.netchan.writeReliableByte(0);
733
+ client.netchan.writeReliableString("baseq2");
734
+ client.netchan.writeReliableShort(client.index);
735
+ client.netchan.writeReliableString(this.sv.name || "maps/test.bsp");
736
+
737
+ for (let i = 0; i < MAX_CONFIGSTRINGS; i++) {
738
+ if (this.sv.configStrings[i]) {
739
+ client.netchan.writeReliableByte(ServerCommand.configstring);
740
+ client.netchan.writeReliableShort(i);
741
+ client.netchan.writeReliableString(this.sv.configStrings[i]);
742
+ }
743
+ }
744
+
745
+ const baselineWriter = new BinaryWriter();
746
+
747
+ for (let i = 0; i < MAX_EDICTS; i++) {
748
+ if (this.sv.baselines[i]) {
749
+ baselineWriter.reset();
750
+ baselineWriter.writeByte(ServerCommand.spawnbaseline);
751
+ writeDeltaEntity({} as EntityState, this.sv.baselines[i]!, baselineWriter, true, true);
752
+
753
+ const data = baselineWriter.getData();
754
+ for(let j=0; j<data.length; j++) {
755
+ client.netchan.writeReliableByte(data[j]);
756
+ }
757
+ }
758
+ }
759
+ }
760
+
761
+ private SV_SetConfigString(index: number, value: string) {
762
+ if (index < 0 || index >= MAX_CONFIGSTRINGS) return;
763
+
764
+ this.sv.configStrings[index] = value;
765
+
766
+ for (const client of this.svs.clients) {
767
+ if (client && client.state >= ClientState.Connected) {
768
+ if (client.netchan) {
769
+ try {
770
+ client.netchan.writeReliableByte(ServerCommand.configstring);
771
+ client.netchan.writeReliableShort(index);
772
+ client.netchan.writeReliableString(value);
773
+
774
+ const packet = client.netchan.transmit();
775
+ client.net.send(packet);
776
+ } catch (e) {
777
+ console.warn(`Client ${client.index} reliable buffer overflow`);
778
+ this.dropClient(client);
779
+ }
780
+ }
781
+ }
782
+ }
783
+ }
784
+
785
+ private SV_WriteConfigString(writer: BinaryWriter, index: number, value: string) {
786
+ writer.writeByte(ServerCommand.configstring);
787
+ writer.writeShort(index);
788
+ writer.writeString(value);
789
+ }
790
+
791
+ private SV_ReadPackets() {
792
+ for (const client of this.svs.clients) {
793
+ if (!client || client.state === ClientState.Free) continue;
794
+
795
+ while (client.messageQueue.length > 0) {
796
+ const rawData = client.messageQueue.shift();
797
+ if (!rawData) continue;
798
+
799
+ if (rawData.byteLength >= 10) {
800
+ const view = new DataView(rawData.buffer, rawData.byteOffset, rawData.byteLength);
801
+ const incomingQPort = view.getUint16(8, true);
802
+ if (client.netchan.qport !== incomingQPort) {
803
+ client.netchan.qport = incomingQPort;
804
+ }
805
+ }
806
+
807
+ const data = client.netchan.process(rawData);
808
+ if (!data) {
809
+ continue;
810
+ }
811
+
812
+ if (data.length === 0) {
813
+ continue;
814
+ }
815
+
816
+ let buffer: ArrayBuffer;
817
+ if (data.buffer instanceof ArrayBuffer) {
818
+ buffer = data.buffer;
819
+ } else {
820
+ buffer = new Uint8Array(data).buffer as ArrayBuffer;
821
+ }
822
+
823
+ const reader = new BinaryStream(buffer);
824
+ const parser = new ClientMessageParser(reader, {
825
+ onMove: (checksum, lastFrame, cmd) => this.handleMove(client, cmd, checksum, lastFrame),
826
+ onUserInfo: (info) => this.handleUserInfo(client, info),
827
+ onStringCmd: (cmd) => this.handleStringCmd(client, cmd),
828
+ onNop: () => {},
829
+ onBad: () => {
830
+ console.warn(`Bad command from client ${client.index}`);
831
+ }
832
+ });
833
+
834
+ try {
835
+ parser.parseMessage();
836
+ } catch (e) {
837
+ console.error(`Error parsing message from client ${client.index}:`, e);
838
+ }
839
+ }
840
+ }
841
+ }
842
+
843
+ private runFrame() {
844
+ if (!this.game) return;
845
+
846
+ const startTime = Date.now();
847
+
848
+ this.sv.frame++;
849
+ this.sv.time += 100; // 100ms per frame
850
+
851
+ // 1. Read network packets
852
+ this.SV_ReadPackets();
853
+
854
+ // 2. Run client commands
855
+ for (const client of this.svs.clients) {
856
+ if (!client || client.state === ClientState.Free) continue;
857
+
858
+ if (client.edict && client.edict.client) {
859
+ client.edict.client.ping = client.ping;
860
+ }
861
+
862
+ if (client.state >= ClientState.Connected) {
863
+ const timeoutFrames = 300;
864
+ if (this.sv.frame - client.lastMessage > timeoutFrames) {
865
+ console.log(`Client ${client.index} timed out`);
866
+ this.dropClient(client);
867
+ continue;
868
+ }
869
+ }
870
+
871
+ if (client && client.state === ClientState.Active && client.edict) {
872
+ const now = Date.now();
873
+ if (now - client.lastCommandTime >= 1000) {
874
+ client.lastCommandTime = now;
875
+ client.commandCount = 0;
876
+ }
877
+
878
+ if (client.commandCount > 200) {
879
+ console.warn(`Client ${client.index} kicked for command flooding (count: ${client.commandCount})`);
880
+ this.dropClient(client);
881
+ continue;
882
+ }
883
+
884
+ this.game.clientThink(client.edict, client.lastCmd);
885
+ }
886
+ }
887
+
888
+ // 3. Run simulation
889
+ const snapshot = this.game.frame({
890
+ frame: this.sv.frame,
891
+ deltaMs: FRAME_TIME_MS,
892
+ nowMs: Date.now()
893
+ });
894
+
895
+ // 3.1 Record History for Lag Compensation
896
+ this.recordHistory();
897
+
898
+ // 4. Send Updates
899
+ if (snapshot && snapshot.state) {
900
+ this.SV_SendClientMessages(snapshot.state);
901
+ }
902
+
903
+ const endTime = Date.now();
904
+ const elapsed = endTime - startTime;
905
+ const sleepTime = Math.max(0, FRAME_TIME_MS - elapsed);
906
+
907
+ if (this.sv.state === ServerState.Game) {
908
+ this.frameTimeout = setTimeout(() => this.runFrame(), sleepTime);
909
+ }
910
+ }
911
+
912
+ private SV_SendClientMessages(snapshot: GameStateSnapshot) {
913
+ for (const client of this.svs.clients) {
914
+ if (client && client.state === ClientState.Active) {
915
+ this.SV_SendClientFrame(client, snapshot);
916
+ }
917
+ }
918
+ }
919
+
920
+ private SV_SendClientFrame(client: Client, snapshot: GameStateSnapshot) {
921
+ const MTU = 1400;
922
+ const writer = new BinaryWriter(MTU);
923
+ writer.writeByte(ServerCommand.frame);
924
+ writer.writeLong(this.sv.frame);
925
+
926
+ let deltaFrame = 0;
927
+ if (client.lastFrame && client.lastFrame < this.sv.frame && client.lastFrame >= this.sv.frame - UPDATE_BACKUP) {
928
+ deltaFrame = client.lastFrame;
929
+ }
930
+
931
+ writer.writeLong(deltaFrame);
932
+ writer.writeByte(0);
933
+ writer.writeByte(0);
934
+
935
+ writer.writeByte(ServerCommand.playerinfo);
936
+
937
+ const ps: ProtocolPlayerState = {
938
+ pm_type: snapshot.pmType,
939
+ origin: snapshot.origin,
940
+ velocity: snapshot.velocity,
941
+ pm_time: snapshot.pm_time,
942
+ pm_flags: snapshot.pmFlags,
943
+ gravity: Math.abs(snapshot.gravity.z),
944
+ delta_angles: snapshot.deltaAngles,
945
+ viewoffset: { x: 0, y: 0, z: 22 },
946
+ viewangles: snapshot.viewangles,
947
+ kick_angles: snapshot.kick_angles,
948
+ gun_index: snapshot.gunindex,
949
+ gun_frame: snapshot.gun_frame,
950
+ gun_offset: snapshot.gunoffset,
951
+ gun_angles: snapshot.gunangles,
952
+ blend: snapshot.blend,
953
+ fov: snapshot.fov,
954
+ rdflags: snapshot.rdflags,
955
+ stats: snapshot.stats,
956
+ watertype: snapshot.watertype // Populate watertype
957
+ };
958
+
959
+ writePlayerState(writer, ps);
960
+
961
+ writer.writeByte(ServerCommand.packetentities);
962
+
963
+ const entities = snapshot.packetEntities || [];
964
+ const currentEntityIds: number[] = [];
965
+
966
+ const frameIdx = this.sv.frame % UPDATE_BACKUP;
967
+ const currentFrame = client.frames[frameIdx];
968
+ currentFrame.entities = entities;
969
+
970
+ let oldEntities: EntityState[] = [];
971
+ if (deltaFrame > 0) {
972
+ const oldFrameIdx = deltaFrame % UPDATE_BACKUP;
973
+ oldEntities = client.frames[oldFrameIdx].entities;
974
+ }
975
+
976
+ for (const entityState of currentFrame.entities) {
977
+ if (writer.getOffset() > MTU - 200) {
978
+ console.warn('Packet MTU limit reached, dropping remaining entities');
979
+ break;
980
+ }
981
+
982
+ currentEntityIds.push(entityState.number);
983
+
984
+ const oldState = oldEntities.find(e => e.number === entityState.number);
985
+
986
+ if (oldState) {
987
+ writeDeltaEntity(oldState, entityState, writer, false, false);
988
+ } else {
989
+ writeDeltaEntity({} as EntityState, entityState, writer, false, true);
990
+ }
991
+ }
992
+
993
+ for (const oldId of client.lastPacketEntities) {
994
+ if (writer.getOffset() > MTU - 10) {
995
+ console.warn('Packet MTU limit reached, dropping remaining removals');
996
+ break;
997
+ }
998
+
999
+ if (!currentEntityIds.includes(oldId)) {
1000
+ writeRemoveEntity(oldId, writer);
1001
+ }
1002
+ }
1003
+
1004
+ writer.writeShort(0);
1005
+
1006
+ const frameData = writer.getData();
1007
+ currentFrame.packetCRC = crc8(frameData);
1008
+
1009
+ const packet = client.netchan.transmit(frameData);
1010
+ client.net.send(packet);
1011
+
1012
+ client.lastFrame = this.sv.frame;
1013
+ client.lastPacketEntities = currentEntityIds;
1014
+ }
1015
+
1016
+ // GameEngine Implementation
1017
+ trace(start: any, end: any): any {
1018
+ return { fraction: 1.0 };
1019
+ }
1020
+
1021
+ modelIndex(name: string): number {
1022
+ console.log(`modelIndex(${name}) called`);
1023
+ // Find existing
1024
+ const start = ConfigStringIndex.Models;
1025
+ const end = ConfigStringIndex.Sounds;
1026
+ for (let i = start + 1; i < end; i++) {
1027
+ if (this.sv.configStrings[i] === name) {
1028
+ return i - start;
1029
+ }
1030
+ }
1031
+
1032
+ // Find empty slot
1033
+ for (let i = start + 1; i < end; i++) {
1034
+ if (!this.sv.configStrings[i]) {
1035
+ this.SV_SetConfigString(i, name);
1036
+ return i - start;
1037
+ }
1038
+ }
1039
+
1040
+ console.warn(`MAX_MODELS overflow for ${name}`);
1041
+ return 0;
1042
+ }
1043
+
1044
+ soundIndex(name: string): number {
1045
+ const start = ConfigStringIndex.Sounds;
1046
+ const end = ConfigStringIndex.Images;
1047
+ for (let i = start + 1; i < end; i++) {
1048
+ if (this.sv.configStrings[i] === name) {
1049
+ return i - start;
1050
+ }
1051
+ }
1052
+
1053
+ for (let i = start + 1; i < end; i++) {
1054
+ if (!this.sv.configStrings[i]) {
1055
+ this.SV_SetConfigString(i, name);
1056
+ return i - start;
1057
+ }
1058
+ }
1059
+
1060
+ console.warn(`MAX_SOUNDS overflow for ${name}`);
1061
+ return 0;
1062
+ }
1063
+
1064
+ imageIndex(name: string): number {
1065
+ const start = ConfigStringIndex.Images;
1066
+ const end = ConfigStringIndex.Lights;
1067
+ for (let i = start + 1; i < end; i++) {
1068
+ if (this.sv.configStrings[i] === name) {
1069
+ return i - start;
1070
+ }
1071
+ }
1072
+
1073
+ for (let i = start + 1; i < end; i++) {
1074
+ if (!this.sv.configStrings[i]) {
1075
+ this.SV_SetConfigString(i, name);
1076
+ return i - start;
1077
+ }
1078
+ }
1079
+
1080
+ console.warn(`MAX_IMAGES overflow for ${name}`);
1081
+ return 0;
1082
+ }
1083
+
1084
+ multicast(origin: any, type: MulticastType, event: ServerCommand, ...args: any[]): void {
1085
+ const writer = new BinaryWriter();
1086
+
1087
+ writeServerCommand(writer, event, ...args);
1088
+
1089
+ const data = writer.getData();
1090
+ const reliable = false;
1091
+
1092
+ for (const client of this.svs.clients) {
1093
+ if (!client || client.state < ClientState.Active || !client.edict) {
1094
+ continue;
1095
+ }
1096
+
1097
+ let send = false;
1098
+ switch (type) {
1099
+ case MulticastType.All:
1100
+ send = true;
1101
+ break;
1102
+ case MulticastType.Pvs:
1103
+ if (this.sv.collisionModel) {
1104
+ send = inPVS(origin, client.edict.origin, this.sv.collisionModel);
1105
+ } else {
1106
+ send = true;
1107
+ }
1108
+ break;
1109
+ case MulticastType.Phs:
1110
+ if (this.sv.collisionModel) {
1111
+ send = inPHS(origin, client.edict.origin, this.sv.collisionModel);
1112
+ } else {
1113
+ send = true;
1114
+ }
1115
+ break;
1116
+ }
1117
+
1118
+ if (send) {
1119
+ if (reliable) {
1120
+ try {
1121
+ for (let i = 0; i < data.length; i++) {
1122
+ client.netchan.writeReliableByte(data[i]);
1123
+ }
1124
+ } catch (e) {
1125
+ }
1126
+ } else {
1127
+ const packet = client.netchan.transmit(data);
1128
+ client.net.send(packet);
1129
+ }
1130
+ }
1131
+ }
1132
+ }
1133
+
1134
+ unicast(ent: Entity, reliable: boolean, event: ServerCommand, ...args: any[]): void {
1135
+ const client = this.svs.clients.find(c => c?.edict === ent);
1136
+ if (client && client.state >= ClientState.Connected) {
1137
+ const writer = new BinaryWriter();
1138
+ writeServerCommand(writer, event, ...args);
1139
+ const data = writer.getData();
1140
+
1141
+ if (reliable) {
1142
+ try {
1143
+ for (let i = 0; i < data.length; i++) {
1144
+ client.netchan.writeReliableByte(data[i]);
1145
+ }
1146
+ const packet = client.netchan.transmit();
1147
+ client.net.send(packet);
1148
+ } catch (e) {
1149
+ console.warn(`Client ${client.index} reliable buffer overflow in unicast`);
1150
+ this.dropClient(client);
1151
+ }
1152
+ } else {
1153
+ const packet = client.netchan.transmit(data);
1154
+ client.net.send(packet);
1155
+ }
1156
+ }
1157
+ }
1158
+
1159
+ configstring(index: number, value: string): void {
1160
+ this.SV_SetConfigString(index, value);
1161
+ }
1162
+
1163
+ private recordHistory() {
1164
+ if (!this.game) return;
1165
+ const now = Date.now();
1166
+ const HISTORY_MAX_MS = 1000;
1167
+
1168
+ this.game.entities.forEachEntity((ent) => {
1169
+ if (ent.solid !== Solid.Not || ent.takedamage) {
1170
+ let hist = this.history.get(ent.index);
1171
+ if (!hist) {
1172
+ hist = [];
1173
+ this.history.set(ent.index, hist);
1174
+ }
1175
+
1176
+ hist.push({
1177
+ time: now,
1178
+ origin: { ...ent.origin },
1179
+ mins: { ...ent.mins },
1180
+ maxs: { ...ent.maxs },
1181
+ angles: { ...ent.angles }
1182
+ });
1183
+
1184
+ while (hist.length > 0 && hist[0].time < now - HISTORY_MAX_MS) {
1185
+ hist.shift();
1186
+ }
1187
+ }
1188
+ });
1189
+ }
1190
+
1191
+ setLagCompensation(active: boolean, client?: Entity, lagMs?: number): void {
1192
+ if (!this.game || !this.entityIndex) return;
1193
+
1194
+ if (active) {
1195
+ if (!client || lagMs === undefined) return;
1196
+
1197
+ const now = Date.now();
1198
+ const targetTime = now - lagMs;
1199
+
1200
+ this.game.entities.forEachEntity((ent) => {
1201
+ if (ent === client) return;
1202
+ if (ent.solid === Solid.Not && !ent.takedamage) return;
1203
+
1204
+ const hist = this.history.get(ent.index);
1205
+ if (!hist || hist.length === 0) return;
1206
+
1207
+ let i = hist.length - 1;
1208
+ while (i >= 0 && hist[i].time > targetTime) {
1209
+ i--;
1210
+ }
1211
+
1212
+ if (i < 0) {
1213
+ i = 0;
1214
+ }
1215
+
1216
+ const s1 = hist[i];
1217
+ const s2 = (i + 1 < hist.length) ? hist[i + 1] : s1;
1218
+
1219
+ let frac = 0;
1220
+ if (s1.time !== s2.time) {
1221
+ frac = (targetTime - s1.time) / (s2.time - s1.time);
1222
+ }
1223
+ if (frac < 0) frac = 0;
1224
+ if (frac > 1) frac = 1;
1225
+
1226
+ const origin = {
1227
+ x: s1.origin.x + (s2.origin.x - s1.origin.x) * frac,
1228
+ y: s1.origin.y + (s2.origin.y - s1.origin.y) * frac,
1229
+ z: s1.origin.z + (s2.origin.z - s1.origin.z) * frac
1230
+ };
1231
+
1232
+ const angles = {
1233
+ x: lerpAngle(s1.angles.x, s2.angles.x, frac),
1234
+ y: lerpAngle(s1.angles.y, s2.angles.y, frac),
1235
+ z: lerpAngle(s1.angles.z, s2.angles.z, frac)
1236
+ };
1237
+
1238
+ this.backup.set(ent.index, {
1239
+ origin: { ...ent.origin },
1240
+ mins: { ...ent.mins },
1241
+ maxs: { ...ent.maxs },
1242
+ angles: { ...ent.angles },
1243
+ link: true
1244
+ });
1245
+
1246
+ ent.origin = origin;
1247
+ ent.angles = angles;
1248
+ ent.mins = {
1249
+ x: s1.mins.x + (s2.mins.x - s1.mins.x) * frac,
1250
+ y: s1.mins.y + (s2.mins.y - s1.mins.y) * frac,
1251
+ z: s1.mins.z + (s2.mins.z - s1.mins.z) * frac
1252
+ };
1253
+ ent.maxs = {
1254
+ x: s1.maxs.x + (s2.maxs.x - s1.maxs.x) * frac,
1255
+ y: s1.maxs.y + (s2.maxs.y - s1.maxs.y) * frac,
1256
+ z: s1.maxs.z + (s2.maxs.z - s1.maxs.z) * frac
1257
+ };
1258
+
1259
+ this.entityIndex!.link({
1260
+ id: ent.index,
1261
+ origin: ent.origin,
1262
+ mins: ent.mins,
1263
+ maxs: ent.maxs,
1264
+ contents: ent.solid === 0 ? 0 : 1,
1265
+ surfaceFlags: 0
1266
+ });
1267
+ });
1268
+
1269
+ } else {
1270
+ this.backup.forEach((state, id) => {
1271
+ const ent = this.game?.entities.getByIndex(id);
1272
+ if (ent) {
1273
+ ent.origin = state.origin;
1274
+ ent.mins = state.mins;
1275
+ ent.maxs = state.maxs;
1276
+ ent.angles = state.angles;
1277
+
1278
+ this.entityIndex!.link({
1279
+ id: ent.index,
1280
+ origin: ent.origin,
1281
+ mins: ent.mins,
1282
+ maxs: ent.maxs,
1283
+ contents: ent.solid === 0 ? 0 : 1,
1284
+ surfaceFlags: 0
1285
+ });
1286
+ }
1287
+ });
1288
+ this.backup.clear();
1289
+ }
1290
+ }
1291
+ }
1292
+
1293
+ export function createServer(options: ServerOptions = {}): DedicatedServer {
1294
+ return new DedicatedServer(options);
1295
+ }