@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,211 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { DedicatedServer } from '../src/dedicated.js';
3
+ import { createGame, GameExports } from '@quake2ts/game';
4
+ import { ClientState } from '../src/client.js';
5
+ import { UPDATE_BACKUP } from '@quake2ts/shared';
6
+ import {
7
+ createMockTransport,
8
+ MockTransport,
9
+ createMockServerClient,
10
+ createMockGameExports,
11
+ createGameStateSnapshotFactory,
12
+ createServerSnapshot
13
+ } from '@quake2ts/test-utils';
14
+
15
+ // Mock dependencies
16
+ vi.mock('node:fs/promises', () => ({
17
+ default: {
18
+ readFile: vi.fn().mockResolvedValue(Buffer.from([0])),
19
+ },
20
+ }));
21
+ vi.mock('@quake2ts/engine', () => ({
22
+ parseBsp: vi.fn().mockReturnValue({
23
+ planes: [],
24
+ nodes: [],
25
+ leafs: [],
26
+ brushes: [],
27
+ models: [],
28
+ leafLists: { leafBrushes: [] },
29
+ texInfo: [],
30
+ brushSides: [],
31
+ visibility: { numClusters: 0, clusters: [] }
32
+ }),
33
+ }));
34
+ vi.mock('@quake2ts/game', () => ({
35
+ createGame: vi.fn(),
36
+ createPlayerInventory: vi.fn(),
37
+ createPlayerWeaponStates: vi.fn(),
38
+ }));
39
+
40
+ const FRAME_TIME_MS = 100; // 10Hz
41
+
42
+ describe('DedicatedServer', () => {
43
+ let server: DedicatedServer;
44
+ let mockGame: GameExports;
45
+ let consoleLogSpy: any;
46
+ let consoleWarnSpy: any;
47
+ let transport: MockTransport;
48
+
49
+ beforeEach(async () => {
50
+ // Only fake specific timers to avoid blocking internal promises/fs mocks
51
+ vi.useFakeTimers({
52
+ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date']
53
+ });
54
+ consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
55
+ consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
56
+
57
+ mockGame = createMockGameExports({
58
+ clientBegin: vi.fn(() => ({ id: 1, classname: 'player' } as any)),
59
+ frame: vi.fn().mockReturnValue({
60
+ state: createGameStateSnapshotFactory({
61
+ gravity: { x: 0, y: 0, z: -800 },
62
+ stats: new Array(32).fill(0),
63
+ })
64
+ }),
65
+ clientConnect: vi.fn().mockReturnValue(true),
66
+ });
67
+
68
+ (createGame as vi.Mock).mockReturnValue(mockGame);
69
+
70
+ transport = createMockTransport();
71
+ server = new DedicatedServer({ transport });
72
+ await server.startServer('test.bsp');
73
+ });
74
+
75
+ afterEach(() => {
76
+ server.stopServer();
77
+ vi.useRealTimers();
78
+ vi.clearAllMocks();
79
+ consoleLogSpy.mockRestore();
80
+ consoleWarnSpy.mockRestore();
81
+ });
82
+
83
+ it('should initialize the game and start the frame loop', () => {
84
+ expect(createGame).toHaveBeenCalled();
85
+ expect(mockGame.init).toHaveBeenCalled();
86
+ expect(mockGame.spawnWorld).toHaveBeenCalled();
87
+
88
+ vi.advanceTimersByTime(FRAME_TIME_MS);
89
+ expect(mockGame.frame).toHaveBeenCalledTimes(2);
90
+ });
91
+
92
+ it('should run the main game loop and process client commands', () => {
93
+ // Use helper to create default frames if needed, or rely on createMockServerClient defaults if updated
94
+ // For now, we still need frames for the dedicated server logic to work properly
95
+ const frames = [];
96
+ for (let i = 0; i < UPDATE_BACKUP; i++) {
97
+ frames.push({
98
+ areaBytes: 0,
99
+ areaBits: new Uint8Array(0),
100
+ playerState: {},
101
+ numEntities: 0,
102
+ firstEntity: 0,
103
+ sentTime: 0,
104
+ entities: []
105
+ });
106
+ }
107
+
108
+ const fakeClient = createMockServerClient(0, {
109
+ frames: frames as any[],
110
+ lastCmd: { msec: 100, angles: {x: 0, y: 90, z: 0}, buttons: 1, forwardmove: 200, sidemove: 0, upmove: 0, sequence: 1, lightlevel: 0, impulse: 0, serverFrame: 0 },
111
+ state: ClientState.Active,
112
+ edict: { id: 1, classname: 'player' } as any
113
+ });
114
+
115
+ // @ts-ignore
116
+ server.svs.clients[0] = fakeClient;
117
+
118
+ (mockGame.frame as any).mockClear();
119
+ (mockGame.clientThink as any).mockClear();
120
+
121
+ vi.advanceTimersByTime(FRAME_TIME_MS);
122
+
123
+ expect(mockGame.clientThink).toHaveBeenCalledWith(fakeClient.edict, fakeClient.lastCmd);
124
+ expect(mockGame.frame).toHaveBeenCalledTimes(1);
125
+ expect(mockGame.frame).toHaveBeenCalledWith(expect.objectContaining({ frame: 2 }));
126
+
127
+ vi.advanceTimersByTime(FRAME_TIME_MS);
128
+ expect(mockGame.frame).toHaveBeenCalledTimes(2);
129
+ expect(mockGame.frame).toHaveBeenCalledWith(expect.objectContaining({ frame: 3 }));
130
+
131
+ // Test server snapshot creation using helper
132
+ // This serves as a sanity check that the helpers are compatible with the server state
133
+ const snapshot = createServerSnapshot(server.sv, 0);
134
+ expect(snapshot).toBeDefined();
135
+ expect(snapshot.serverTime).toBe(server.sv.time);
136
+ });
137
+
138
+
139
+ it('should not process commands for clients that are not active', () => {
140
+ const frames = [];
141
+ for (let i = 0; i < UPDATE_BACKUP; i++) {
142
+ frames.push({
143
+ areaBytes: 0,
144
+ areaBits: new Uint8Array(0),
145
+ playerState: {},
146
+ numEntities: 0,
147
+ firstEntity: 0,
148
+ sentTime: 0,
149
+ entities: []
150
+ });
151
+ }
152
+
153
+ const fakeClient = createMockServerClient(0, {
154
+ frames: frames as any[],
155
+ state: ClientState.Connected, // Not Active
156
+ edict: { id: 1, classname: 'player' } as any
157
+ });
158
+
159
+ // @ts-ignore
160
+ server.svs.clients[0] = fakeClient;
161
+
162
+ (mockGame.frame as any).mockClear();
163
+ (mockGame.clientThink as any).mockClear();
164
+
165
+ vi.advanceTimersByTime(FRAME_TIME_MS);
166
+
167
+ expect(mockGame.clientThink).not.toHaveBeenCalled();
168
+ expect(mockGame.frame).toHaveBeenCalledTimes(1);
169
+ });
170
+
171
+ it('should compensate for slow frames (drift compensation)', async () => {
172
+ vi.clearAllTimers();
173
+ (mockGame.frame as any).mockClear();
174
+ server.stopServer();
175
+
176
+ transport = createMockTransport();
177
+ server = new DedicatedServer({ transport });
178
+ await server.startServer('test.bsp');
179
+
180
+ expect(mockGame.frame).toHaveBeenCalledTimes(1);
181
+
182
+ const frameMock2 = vi.fn().mockImplementation(() => {
183
+ const now = Date.now();
184
+ vi.setSystemTime(now + 40);
185
+ return { state: { packetEntities: [], stats: [] } };
186
+ });
187
+ mockGame.frame = frameMock2;
188
+
189
+ vi.advanceTimersByTime(100);
190
+ expect(frameMock2).toHaveBeenCalledTimes(1);
191
+
192
+ const frameMock3 = vi.fn().mockImplementation(() => {
193
+ const now = Date.now();
194
+ vi.setSystemTime(now + 120);
195
+ return { state: { packetEntities: [], stats: [] } };
196
+ });
197
+ mockGame.frame = frameMock3;
198
+
199
+ vi.advanceTimersByTime(59);
200
+ expect(frameMock3).toHaveBeenCalledTimes(0);
201
+
202
+ vi.advanceTimersByTime(1);
203
+ expect(frameMock3).toHaveBeenCalledTimes(1);
204
+
205
+ const frameMock4 = vi.fn().mockReturnValue({ state: { packetEntities: [], stats: [] } });
206
+ mockGame.frame = frameMock4;
207
+
208
+ vi.advanceTimersByTime(1);
209
+ expect(frameMock4).toHaveBeenCalledTimes(1);
210
+ });
211
+ });
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { DedicatedServer } from '../src/dedicated.js';
3
+ import * as gameModule from '@quake2ts/game';
4
+ import { CollisionEntityIndex, traceBox } from '@quake2ts/shared';
5
+ import { createMockCollisionEntityIndex } from '@quake2ts/test-utils';
6
+
7
+ // Mock dependencies
8
+ vi.mock('@quake2ts/shared', async () => {
9
+ const actual = await vi.importActual('@quake2ts/shared');
10
+ return {
11
+ ...actual,
12
+ traceBox: vi.fn(),
13
+ CollisionEntityIndex: vi.fn().mockImplementation(() => createMockCollisionEntityIndex())
14
+ };
15
+ });
16
+
17
+ describe('DedicatedServer Trace Integration', () => {
18
+ let server: DedicatedServer;
19
+
20
+ afterEach(() => {
21
+ if (server) {
22
+ // Restore mocked game object to have shutdown so stop() doesn't crash
23
+ if ((server as any).game && !(server as any).game.shutdown) {
24
+ (server as any).game.shutdown = vi.fn();
25
+ }
26
+ server.stop();
27
+ }
28
+ });
29
+
30
+ it('should invoke CollisionEntityIndex.trace and resolve entity', async () => {
31
+ const createGameSpy = vi.spyOn(gameModule, 'createGame');
32
+ server = new DedicatedServer(27998); // Use different port
33
+
34
+ // Setup mock for entity tracing
35
+ const mockEntityIndex = (server as any).entityIndex;
36
+ mockEntityIndex.trace.mockReturnValue({
37
+ fraction: 0.5,
38
+ allsolid: false,
39
+ startsolid: false,
40
+ endpos: { x: 50, y: 0, z: 0 },
41
+ entityId: 10,
42
+ contents: 1,
43
+ surfaceFlags: 0,
44
+ plane: { normal: { x: -1, y: 0, z: 0 }, dist: 50 }
45
+ });
46
+
47
+ // Start server
48
+ await server.start('test.bsp');
49
+
50
+ expect(createGameSpy).toHaveBeenCalled();
51
+ const imports = createGameSpy.mock.calls[0][0] as any;
52
+
53
+ // Mock game instance for entity lookup, INCLUDING shutdown for cleanup
54
+ const mockEntity = { index: 10, classname: 'test_entity' };
55
+ (server as any).game = {
56
+ entities: {
57
+ getByIndex: vi.fn().mockReturnValue(mockEntity)
58
+ },
59
+ shutdown: vi.fn()
60
+ };
61
+
62
+ // Call trace
63
+ const traceResult = imports.trace(
64
+ { x: 0, y: 0, z: 0 },
65
+ { x: -16, y: -16, z: -24 },
66
+ { x: 16, y: 16, z: 32 },
67
+ { x: 100, y: 0, z: 0 },
68
+ null,
69
+ 0xFFFFFFFF
70
+ );
71
+
72
+ // Verify entity index was called
73
+ expect(mockEntityIndex.trace).toHaveBeenCalled();
74
+
75
+ // Verify result contains the entity
76
+ expect(traceResult.fraction).toBe(0.5);
77
+ expect(traceResult.ent).toBe(mockEntity);
78
+ });
79
+
80
+ it('should fallback to world trace (via entityIndex) if entity trace is further', async () => {
81
+ const createGameSpy = vi.spyOn(gameModule, 'createGame');
82
+ server = new DedicatedServer(27999);
83
+
84
+ // Setup mock for entity tracing to return a miss (entityId: null)
85
+ const mockEntityIndex = (server as any).entityIndex;
86
+ mockEntityIndex.trace.mockReturnValue({
87
+ fraction: 0.3,
88
+ allsolid: false,
89
+ startsolid: false,
90
+ endpos: { x: 30, y: 0, z: 0 },
91
+ entityId: null
92
+ });
93
+
94
+ await server.start('test.bsp');
95
+ const imports = createGameSpy.mock.calls[0][0] as any;
96
+
97
+ // Mock game instance
98
+ (server as any).game = {
99
+ entities: {
100
+ getByIndex: vi.fn().mockReturnValue({})
101
+ },
102
+ shutdown: vi.fn()
103
+ };
104
+
105
+ const traceResult = imports.trace(
106
+ { x: 0, y: 0, z: 0 },
107
+ null, null,
108
+ { x: 100, y: 0, z: 0 },
109
+ null,
110
+ 0xFFFFFFFF
111
+ );
112
+
113
+ // If entityIndex.trace returned entityId: null, then ent should be null.
114
+ expect(traceResult.ent).toBeNull();
115
+ expect(traceResult.fraction).toBe(0.3);
116
+ });
117
+ });
@@ -0,0 +1,235 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { DedicatedServer } from '../../src/dedicated.js';
3
+ import { createClient, Client, ClientState } from '../../src/client.js';
4
+ import { ServerCommand, ConfigStringIndex, PlayerStat, MAX_CONFIGSTRINGS, BinaryStream, BinaryWriter, NetDriver } from '@quake2ts/shared';
5
+ import { Entity, createGame } from '@quake2ts/game';
6
+ import { createMockTransport, MockTransport, createMockNetDriver, createMockGameExports, createGameStateSnapshotFactory } from '@quake2ts/test-utils';
7
+
8
+ // Mock dependencies
9
+ // ws mock removed
10
+ vi.mock('../../src/net/nodeWsDriver.js');
11
+ vi.mock('@quake2ts/game', async (importOriginal) => {
12
+ const actual = await importOriginal() as any;
13
+ return {
14
+ ...actual,
15
+ createGame: vi.fn(),
16
+ createPlayerInventory: vi.fn(() => ({
17
+ ammo: { counts: [] },
18
+ items: new Set(),
19
+ ownedWeapons: new Set(),
20
+ powerups: new Map()
21
+ })),
22
+ createPlayerWeaponStates: vi.fn(() => ({}))
23
+ };
24
+ });
25
+ vi.mock('node:fs/promises', async (importOriginal) => {
26
+ const actual = await importOriginal() as any;
27
+ return {
28
+ ...actual,
29
+ default: {
30
+ ...actual.default,
31
+ readFile: vi.fn().mockResolvedValue(Buffer.from(''))
32
+ },
33
+ readFile: vi.fn().mockResolvedValue(Buffer.from(''))
34
+ };
35
+ });
36
+ vi.mock('@quake2ts/engine', () => ({
37
+ parseBsp: vi.fn().mockReturnValue({
38
+ planes: [],
39
+ nodes: [],
40
+ leafs: [],
41
+ brushes: [],
42
+ models: [],
43
+ leafLists: { leafBrushes: [] },
44
+ texInfo: [],
45
+ brushSides: [],
46
+ visibility: { numClusters: 0, clusters: [] }
47
+ })
48
+ }));
49
+
50
+
51
+ describe('Integration: Config String & Stats Sync', () => {
52
+ let server: DedicatedServer;
53
+ let mockClient: Client;
54
+ let mockDriver: NetDriver;
55
+ let consoleLogSpy: any;
56
+ let consoleWarnSpy: any;
57
+ let transport: MockTransport;
58
+
59
+ beforeEach(async () => {
60
+ vi.useFakeTimers({
61
+ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date']
62
+ });
63
+ // Suppress logs for cleaner output
64
+ consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
65
+ consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
66
+
67
+ (createGame as vi.Mock).mockReturnValue(createMockGameExports({
68
+ clientBegin: vi.fn().mockReturnValue({ index: 1, origin: { x: 0, y: 0, z: 0 } } as any),
69
+ frame: vi.fn().mockReturnValue({
70
+ state: createGameStateSnapshotFactory({
71
+ stats: new Array(32).fill(0),
72
+ packetEntities: []
73
+ })
74
+ }),
75
+ clientConnect: vi.fn().mockReturnValue(true),
76
+ }));
77
+
78
+ transport = createMockTransport();
79
+ server = new DedicatedServer({ port: 27910, transport });
80
+
81
+ // Start server
82
+ await server.startServer('maps/test.bsp');
83
+
84
+ // Setup mock driver instance
85
+ mockDriver = createMockNetDriver({
86
+ send: vi.fn()
87
+ });
88
+
89
+ // Simulate connection via Transport
90
+ transport.simulateConnection(mockDriver, {});
91
+
92
+ const clients = (server as any).svs.clients;
93
+ // Find client with our mock driver
94
+ mockClient = clients.find((c: Client | null) => c && c.net === mockDriver);
95
+
96
+ if (!mockClient) {
97
+ mockClient = clients.find((c: Client | null) => c !== null);
98
+ }
99
+ });
100
+
101
+ afterEach(() => {
102
+ server.stopServer();
103
+ vi.useRealTimers();
104
+ consoleLogSpy.mockRestore();
105
+ consoleWarnSpy.mockRestore();
106
+ vi.clearAllMocks();
107
+ });
108
+
109
+ it('should broadcast config strings to connected clients', () => {
110
+ // 1. Client connects and enters game
111
+ mockClient.state = ClientState.Connected;
112
+
113
+ // 2. Server sets a config string (e.g., map name or model)
114
+ const testIndex = ConfigStringIndex.Models + 1;
115
+ const testValue = "models/weapons/g_shotg/tris.md2";
116
+
117
+ // Call configstring via GameEngine interface (exposed on server)
118
+ server.configstring(testIndex, testValue);
119
+
120
+ // 3. Verify driver.send was called with correct data
121
+ expect(mockDriver.send).toHaveBeenCalled();
122
+
123
+ // Inspect the last call to see if it contains the config string command
124
+ const calls = (mockDriver.send as any).mock.calls;
125
+ const lastCallData = calls[calls.length - 1][0];
126
+
127
+ // Scan for the command byte (ServerCommand.configstring)
128
+ // With NetChan, it's wrapped.
129
+ const view = new Uint8Array(lastCallData.buffer);
130
+ let found = false;
131
+
132
+ // Command byte (13) + index (2 bytes) + value
133
+ // We look for [13, index_low, index_high]
134
+ // testIndex is ConfigStringIndex.Models + 1 (probably small number)
135
+ const idxLow = testIndex & 0xff;
136
+ const idxHigh = (testIndex >> 8) & 0xff;
137
+
138
+ for(let i=0; i<view.length-3; i++) {
139
+ if (view[i] === ServerCommand.configstring && view[i+1] === idxLow && view[i+2] === idxHigh) {
140
+ found = true;
141
+ break;
142
+ }
143
+ }
144
+ expect(found).toBe(true);
145
+ });
146
+
147
+ it('should send full config string list on client connect', () => {
148
+ // 1. Pre-populate some config strings
149
+ (server as any).sv.configStrings[ConfigStringIndex.Models] = "model1";
150
+ (server as any).sv.configStrings[ConfigStringIndex.Sounds] = "sound1";
151
+
152
+ // 2. Simulate client connection flow
153
+ (server as any).handleConnect(mockClient, "userinfo");
154
+
155
+ // 3. Check sent packets
156
+ const calls = (mockDriver.send as any).mock.calls;
157
+ let foundServerData = false;
158
+ let foundModel1 = false;
159
+ let foundSound1 = false;
160
+
161
+ for (const call of calls) {
162
+ const data = call[0] instanceof Uint8Array ? call[0] : new Uint8Array(call[0].buffer);
163
+
164
+ // Search for strings in the packet data
165
+ const textDecoder = new TextDecoder();
166
+ // We can't decode the whole binary as text easily, but we can search for substring bytes
167
+ // Or just decode and search (binary garbage might be invalid utf8 but strings usually survive)
168
+ const text = textDecoder.decode(data);
169
+
170
+ if (text.includes("baseq2") && text.includes("maps/test.bsp")) {
171
+ foundServerData = true;
172
+ }
173
+ if (text.includes("model1")) foundModel1 = true;
174
+ if (text.includes("sound1")) foundSound1 = true;
175
+ }
176
+
177
+ expect(foundServerData).toBe(true);
178
+ expect(foundModel1).toBe(true);
179
+ expect(foundSound1).toBe(true);
180
+ });
181
+
182
+ it('should sync player stats in frame updates', () => {
183
+ // 1. Activate client
184
+ mockClient.state = ClientState.Active;
185
+
186
+ // 2. Mock game frame returning stats
187
+ const mockStats = new Array(32).fill(0);
188
+ mockStats[PlayerStat.STAT_HEALTH] = 100;
189
+ mockStats[PlayerStat.STAT_ARMOR] = 50;
190
+
191
+ // We need to update the mock implementation of 'frame' for this specific test
192
+ // Accessing the game instance created by createGame
193
+ const game = (server as any).game;
194
+ game.frame.mockReturnValue({
195
+ state: {
196
+ stats: mockStats,
197
+ packetEntities: [],
198
+ origin: { x:0, y:0, z:0 },
199
+ velocity: { x:0, y:0, z:0 },
200
+ gravity: { x:0, y:0, z:0 },
201
+ deltaAngles: { x:0, y:0, z:0 },
202
+ viewangles: { x:0, y:0, z:0 },
203
+ kick_angles: { x:0, y:0, z:0 },
204
+ gunoffset: { x:0, y:0, z:0 },
205
+ gunangles: { x:0, y:0, z:0 },
206
+ blend: [0,0,0,0]
207
+ }
208
+ });
209
+
210
+ // 3. Run server frame
211
+ (server as any).runFrame();
212
+
213
+ // 4. Check for frame packet
214
+ const calls = (mockDriver.send as any).mock.calls;
215
+ const lastCallData = calls[calls.length - 1][0];
216
+
217
+ // Scan buffer for stats values (100 and 50)
218
+ // Since NetChan header is 10 bytes, start from there.
219
+
220
+ const view = new Uint8Array(lastCallData.buffer);
221
+ let foundHealth = false;
222
+ let foundArmor = false;
223
+
224
+ // Simple scan for values (heuristic)
225
+ // Note: Stats are shorts (2 bytes)
226
+ for (let i = 10; i < view.length - 1; i++) {
227
+ const val = view[i] | (view[i+1] << 8);
228
+ if (val === 100) foundHealth = true;
229
+ if (val === 50) foundArmor = true;
230
+ }
231
+
232
+ expect(foundHealth).toBe(true);
233
+ expect(foundArmor).toBe(true);
234
+ });
235
+ });
@@ -0,0 +1,144 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { DedicatedServer } from '../src/dedicated.js';
3
+ import { Entity, Solid, createGame, GameExports } from '@quake2ts/game';
4
+ import { createMockGameExports, createMockCollisionEntityIndex } from '@quake2ts/test-utils';
5
+
6
+ vi.mock('@quake2ts/game', async (importOriginal) => {
7
+ const actual = await importOriginal();
8
+ return {
9
+ ...actual as any,
10
+ createGame: vi.fn(),
11
+ };
12
+ });
13
+
14
+ describe('Lag Compensation', () => {
15
+ let server: DedicatedServer;
16
+ let target: Entity;
17
+ let attacker: Entity;
18
+ let entities: Entity[];
19
+ let mockGame: GameExports;
20
+
21
+ beforeEach(() => {
22
+ vi.clearAllMocks();
23
+
24
+ // Setup mock entities
25
+ target = new Entity(1);
26
+ target.solid = Solid.Bsp; // Needs to be solid to be tracked
27
+ target.takedamage = true;
28
+ target.origin = { x: 100, y: 0, z: 0 };
29
+ target.mins = { x: -16, y: -16, z: -24 };
30
+ target.maxs = { x: 16, y: 16, z: 32 };
31
+ target.angles = { x: 0, y: 0, z: 0 };
32
+
33
+ attacker = new Entity(2);
34
+ attacker.origin = { x: 0, y: 0, z: 0 };
35
+
36
+ entities = [target, attacker];
37
+
38
+ const defaultGame = createMockGameExports();
39
+ mockGame = createMockGameExports({
40
+ entities: {
41
+ ...defaultGame.entities,
42
+ forEachEntity: vi.fn((cb: any) => entities.forEach(cb)),
43
+ getByIndex: vi.fn((id: number) => entities.find(e => e.index === id)) as any,
44
+ trace: vi.fn(),
45
+ } as any
46
+ });
47
+
48
+ (createGame as vi.Mock).mockReturnValue(mockGame);
49
+
50
+ server = new DedicatedServer(0);
51
+
52
+ // Initialize server (starts game)
53
+ // start() is async and does file io.
54
+ // We manually inject the game instance to bypass start()
55
+ (server as any).game = mockGame;
56
+ (server as any).entityIndex = createMockCollisionEntityIndex();
57
+ });
58
+
59
+ it('should record entity history', () => {
60
+ // Record at time 1000
61
+ vi.setSystemTime(1000);
62
+ (server as any).recordHistory();
63
+
64
+ // Check history
65
+ const history = (server as any).history.get(target.index);
66
+ expect(history).toBeDefined();
67
+ expect(history).toHaveLength(1);
68
+ expect(history![0].time).toBe(1000);
69
+ expect(history![0].origin).toEqual({ x: 100, y: 0, z: 0 });
70
+
71
+ // Move entity
72
+ target.origin = { x: 200, y: 0, z: 0 };
73
+
74
+ // Record at time 1100
75
+ vi.setSystemTime(1100);
76
+ (server as any).recordHistory();
77
+
78
+ expect(history).toHaveLength(2);
79
+ expect(history![1].time).toBe(1100);
80
+ expect(history![1].origin).toEqual({ x: 200, y: 0, z: 0 });
81
+ });
82
+
83
+ it('should interpolate entity position based on lag', () => {
84
+ // Setup history
85
+ // Time 1000: x=100
86
+ // Time 1100: x=200
87
+ target.origin = { x: 100, y: 0, z: 0 };
88
+ vi.setSystemTime(1000);
89
+ (server as any).recordHistory();
90
+
91
+ target.origin = { x: 200, y: 0, z: 0 };
92
+ vi.setSystemTime(1100);
93
+ (server as any).recordHistory();
94
+
95
+ // Current time is 1100. Target is at 200.
96
+ // Attacker has 50ms lag. Target should be rewound to time 1050.
97
+ // 1050 is halfway between 1000 and 1100.
98
+ // Origin should be 150.
99
+
100
+ server.setLagCompensation(true, attacker, 50);
101
+
102
+ expect(target.origin.x).toBeCloseTo(150);
103
+
104
+ // Restore
105
+ server.setLagCompensation(false);
106
+ expect(target.origin.x).toBe(200);
107
+ });
108
+
109
+ it('should handle lag larger than history', () => {
110
+ target.origin = { x: 100, y: 0, z: 0 };
111
+ vi.setSystemTime(1000);
112
+ (server as any).recordHistory();
113
+
114
+ target.origin = { x: 200, y: 0, z: 0 };
115
+ vi.setSystemTime(1100);
116
+ (server as any).recordHistory();
117
+
118
+ // Lag 200ms -> Time 900.
119
+ // Oldest sample is 1000. Should clamp to 1000 (x=100).
120
+ server.setLagCompensation(true, attacker, 200);
121
+
122
+ expect(target.origin.x).toBe(100);
123
+
124
+ server.setLagCompensation(false);
125
+ });
126
+
127
+ it('should handle negative lag (extrapolation not supported, clamps to newest)', () => {
128
+ target.origin = { x: 100, y: 0, z: 0 };
129
+ vi.setSystemTime(1000);
130
+ (server as any).recordHistory();
131
+
132
+ target.origin = { x: 200, y: 0, z: 0 };
133
+ vi.setSystemTime(1100);
134
+ (server as any).recordHistory();
135
+
136
+ // Lag -100ms (impossible ping) -> Time 1200.
137
+ // Newest is 1100. Should clamp to 1100 (x=200).
138
+ server.setLagCompensation(true, attacker, -100);
139
+
140
+ expect(target.origin.x).toBe(200);
141
+
142
+ server.setLagCompensation(false);
143
+ });
144
+ });