@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.
- package/dist/client.d.ts +51 -0
- package/dist/client.js +100 -0
- package/dist/dedicated.d.ts +72 -0
- package/dist/dedicated.js +1104 -0
- package/dist/index.cjs +1586 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +1543 -0
- package/dist/net/nodeWsDriver.d.ts +16 -0
- package/dist/net/nodeWsDriver.js +122 -0
- package/dist/protocol/player.d.ts +2 -0
- package/dist/protocol/player.js +1 -0
- package/dist/protocol/write.d.ts +7 -0
- package/dist/protocol/write.js +167 -0
- package/dist/protocol.d.ts +17 -0
- package/dist/protocol.js +71 -0
- package/dist/server.d.ts +50 -0
- package/dist/server.js +12 -0
- package/dist/transport.d.ts +7 -0
- package/dist/transport.js +1 -0
- package/dist/transports/websocket.d.ts +11 -0
- package/dist/transports/websocket.js +38 -0
- package/package.json +35 -0
- package/src/client.ts +173 -0
- package/src/dedicated.ts +1295 -0
- package/src/index.ts +8 -0
- package/src/net/nodeWsDriver.ts +129 -0
- package/src/protocol/player.ts +2 -0
- package/src/protocol/write.ts +185 -0
- package/src/protocol.ts +91 -0
- package/src/server.ts +76 -0
- package/src/transport.ts +8 -0
- package/src/transports/websocket.ts +42 -0
- package/test.bsp +0 -0
- package/tests/client.test.ts +20 -0
- package/tests/connection_flow.test.ts +93 -0
- package/tests/dedicated.test.ts +211 -0
- package/tests/dedicated_trace.test.ts +117 -0
- package/tests/integration/configstring_sync.test.ts +235 -0
- package/tests/lag.test.ts +144 -0
- package/tests/protocol/player.test.ts +88 -0
- package/tests/protocol/write.test.ts +107 -0
- package/tests/protocol.test.ts +102 -0
- package/tests/server-state.test.ts +17 -0
- package/tests/server.test.ts +99 -0
- package/tests/unit/dedicated_timeout.test.ts +142 -0
- package/tsconfig.json +9 -0
- package/tsconfig.tsbuildinfo +1 -0
- 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
|
+
});
|