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