@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
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Export net implementations
|
|
2
|
+
export * from './net/nodeWsDriver.js';
|
|
3
|
+
export * from './dedicated.js';
|
|
4
|
+
export * from './client.js';
|
|
5
|
+
export * from './protocol.js';
|
|
6
|
+
export { ServerOptions, createServer } from './dedicated.js';
|
|
7
|
+
export { NetworkTransport } from './transport.js';
|
|
8
|
+
export * from './server.js';
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { NetDriver } from '@quake2ts/shared';
|
|
2
|
+
import WebSocket from 'ws';
|
|
3
|
+
|
|
4
|
+
export class WebSocketNetDriver implements NetDriver {
|
|
5
|
+
private socket: WebSocket | null = null;
|
|
6
|
+
private messageCallback: ((data: Uint8Array) => void) | null = null;
|
|
7
|
+
private closeCallback: (() => void) | null = null;
|
|
8
|
+
private errorCallback: ((error: Error) => void) | null = null;
|
|
9
|
+
|
|
10
|
+
async connect(url: string): Promise<void> {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
try {
|
|
13
|
+
this.socket = new WebSocket(url);
|
|
14
|
+
this.socket.binaryType = 'arraybuffer';
|
|
15
|
+
|
|
16
|
+
this.socket.onopen = () => {
|
|
17
|
+
resolve();
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
this.socket.onerror = (event) => {
|
|
21
|
+
const error = new Error('WebSocket connection error ' + event.message);
|
|
22
|
+
if (this.errorCallback) {
|
|
23
|
+
this.errorCallback(error);
|
|
24
|
+
}
|
|
25
|
+
reject(error);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
this.socket.onclose = () => {
|
|
29
|
+
if (this.closeCallback) {
|
|
30
|
+
this.closeCallback();
|
|
31
|
+
}
|
|
32
|
+
this.socket = null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
this.socket.onmessage = (event) => {
|
|
36
|
+
if (this.messageCallback) {
|
|
37
|
+
if (event.data instanceof ArrayBuffer) {
|
|
38
|
+
this.messageCallback(new Uint8Array(event.data));
|
|
39
|
+
} else if (Buffer.isBuffer(event.data)) {
|
|
40
|
+
// ws in Node might return Buffer
|
|
41
|
+
this.messageCallback(new Uint8Array(event.data));
|
|
42
|
+
} else if (Array.isArray(event.data)) {
|
|
43
|
+
// Buffer[]
|
|
44
|
+
const totalLength = event.data.reduce((acc, buf) => acc + buf.length, 0);
|
|
45
|
+
const result = new Uint8Array(totalLength);
|
|
46
|
+
let offset = 0;
|
|
47
|
+
for (const buf of event.data) {
|
|
48
|
+
result.set(buf, offset);
|
|
49
|
+
offset += buf.length;
|
|
50
|
+
}
|
|
51
|
+
this.messageCallback(result);
|
|
52
|
+
} else {
|
|
53
|
+
console.warn('Received non-binary message from server', typeof event.data);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
} catch (e) {
|
|
59
|
+
reject(e);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Method to attach an existing socket (server-side incoming connection)
|
|
65
|
+
attach(socket: WebSocket) {
|
|
66
|
+
this.socket = socket;
|
|
67
|
+
this.socket.binaryType = 'arraybuffer';
|
|
68
|
+
|
|
69
|
+
this.socket.onclose = () => {
|
|
70
|
+
if (this.closeCallback) this.closeCallback();
|
|
71
|
+
this.socket = null;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
this.socket.onerror = (event) => {
|
|
75
|
+
if (this.errorCallback) this.errorCallback(new Error(event.message));
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
this.socket.onmessage = (event) => {
|
|
79
|
+
if (this.messageCallback) {
|
|
80
|
+
if (event.data instanceof ArrayBuffer) {
|
|
81
|
+
this.messageCallback(new Uint8Array(event.data));
|
|
82
|
+
} else if (Buffer.isBuffer(event.data)) {
|
|
83
|
+
this.messageCallback(new Uint8Array(event.data));
|
|
84
|
+
} else if (Array.isArray(event.data)) { // ws specific
|
|
85
|
+
// handle fragmentation if necessary, usually it's Buffer[]
|
|
86
|
+
const totalLength = event.data.reduce((acc: number, buf: Buffer) => acc + buf.length, 0);
|
|
87
|
+
const result = new Uint8Array(totalLength);
|
|
88
|
+
let offset = 0;
|
|
89
|
+
for (const buf of event.data) {
|
|
90
|
+
result.set(buf, offset);
|
|
91
|
+
offset += buf.length;
|
|
92
|
+
}
|
|
93
|
+
this.messageCallback(result);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
disconnect(): void {
|
|
100
|
+
if (this.socket) {
|
|
101
|
+
this.socket.close();
|
|
102
|
+
this.socket = null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
send(data: Uint8Array): void {
|
|
107
|
+
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
|
108
|
+
this.socket.send(data);
|
|
109
|
+
} else {
|
|
110
|
+
console.warn('Attempted to send data on closed or connecting socket');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
onMessage(callback: (data: Uint8Array) => void): void {
|
|
115
|
+
this.messageCallback = callback;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
onClose(callback: () => void): void {
|
|
119
|
+
this.closeCallback = callback;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
onError(callback: (error: Error) => void): void {
|
|
123
|
+
this.errorCallback = callback;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
isConnected(): boolean {
|
|
127
|
+
return this.socket !== null && this.socket.readyState === WebSocket.OPEN;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { BinaryWriter, ServerCommand, TempEntity, Vec3 } from '@quake2ts/shared';
|
|
2
|
+
import { Entity } from '@quake2ts/game';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Writes a server command and its arguments to a BinaryWriter.
|
|
6
|
+
* This handles the serialization of generic arguments passed to multicast/unicast
|
|
7
|
+
* into the specific binary format expected by the protocol.
|
|
8
|
+
*/
|
|
9
|
+
export function writeServerCommand(writer: BinaryWriter, event: ServerCommand, ...args: any[]): void {
|
|
10
|
+
writer.writeByte(event);
|
|
11
|
+
|
|
12
|
+
switch (event) {
|
|
13
|
+
case ServerCommand.print: {
|
|
14
|
+
// args: [level: number, text: string]
|
|
15
|
+
const level = args[0] as number;
|
|
16
|
+
const text = args[1] as string;
|
|
17
|
+
writer.writeByte(level);
|
|
18
|
+
writer.writeString(text);
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
case ServerCommand.centerprint: {
|
|
23
|
+
// args: [text: string]
|
|
24
|
+
const text = args[0] as string;
|
|
25
|
+
writer.writeString(text);
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
case ServerCommand.stufftext: {
|
|
30
|
+
// args: [text: string]
|
|
31
|
+
const text = args[0] as string;
|
|
32
|
+
writer.writeString(text);
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
case ServerCommand.sound: {
|
|
37
|
+
// args: [flags, soundNum, volume?, attenuation?, offset?, ent?, pos?]
|
|
38
|
+
const flags = args[0] as number;
|
|
39
|
+
const soundNum = args[1] as number;
|
|
40
|
+
const volume = args[2] as number | undefined;
|
|
41
|
+
const attenuation = args[3] as number | undefined;
|
|
42
|
+
const offset = args[4] as number | undefined;
|
|
43
|
+
const ent = args[5] as number | undefined;
|
|
44
|
+
const pos = args[6] as Vec3 | undefined;
|
|
45
|
+
|
|
46
|
+
writer.writeByte(flags);
|
|
47
|
+
writer.writeByte(soundNum);
|
|
48
|
+
|
|
49
|
+
if (flags & 1) { // SND_VOLUME
|
|
50
|
+
writer.writeByte(volume || 0);
|
|
51
|
+
}
|
|
52
|
+
if (flags & 2) { // SND_ATTENUATION
|
|
53
|
+
writer.writeByte(attenuation || 0);
|
|
54
|
+
}
|
|
55
|
+
if (flags & 16) { // SND_OFFSET
|
|
56
|
+
writer.writeByte(offset || 0);
|
|
57
|
+
}
|
|
58
|
+
if (flags & 8) { // SND_ENT
|
|
59
|
+
writer.writeShort(ent || 0);
|
|
60
|
+
}
|
|
61
|
+
if (flags & 4) { // SND_POS
|
|
62
|
+
if (pos) {
|
|
63
|
+
writer.writePos(pos);
|
|
64
|
+
} else {
|
|
65
|
+
writer.writePos({x:0, y:0, z:0});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
case ServerCommand.muzzleflash: {
|
|
72
|
+
// args: [entityIndex: number, flashType: number]
|
|
73
|
+
const entIndex = args[0] as number;
|
|
74
|
+
const flashType = args[1] as number;
|
|
75
|
+
writer.writeShort(entIndex);
|
|
76
|
+
writer.writeByte(flashType);
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
case ServerCommand.temp_entity: {
|
|
81
|
+
// args: [type: TempEntity, ...params]
|
|
82
|
+
const type = args[0] as TempEntity;
|
|
83
|
+
writer.writeByte(type);
|
|
84
|
+
writeTempEntity(writer, type, args.slice(1));
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
default:
|
|
89
|
+
console.warn(`writeServerCommand: Unhandled command ${event}`);
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function writeTempEntity(writer: BinaryWriter, type: TempEntity, args: any[]): void {
|
|
95
|
+
switch (type) {
|
|
96
|
+
case TempEntity.ROCKET_EXPLOSION:
|
|
97
|
+
case TempEntity.GRENADE_EXPLOSION:
|
|
98
|
+
case TempEntity.EXPLOSION1:
|
|
99
|
+
case TempEntity.EXPLOSION2:
|
|
100
|
+
case TempEntity.ROCKET_EXPLOSION_WATER:
|
|
101
|
+
case TempEntity.GRENADE_EXPLOSION_WATER:
|
|
102
|
+
case TempEntity.BFG_EXPLOSION:
|
|
103
|
+
case TempEntity.BFG_BIGEXPLOSION:
|
|
104
|
+
case TempEntity.PLASMA_EXPLOSION:
|
|
105
|
+
case TempEntity.PLAIN_EXPLOSION:
|
|
106
|
+
case TempEntity.TRACKER_EXPLOSION:
|
|
107
|
+
case TempEntity.EXPLOSION1_BIG:
|
|
108
|
+
case TempEntity.EXPLOSION1_NP:
|
|
109
|
+
case TempEntity.EXPLOSION1_NL:
|
|
110
|
+
case TempEntity.EXPLOSION2_NL:
|
|
111
|
+
case TempEntity.BERSERK_SLAM:
|
|
112
|
+
// Format: [pos]
|
|
113
|
+
writer.writePos(args[0] as Vec3);
|
|
114
|
+
break;
|
|
115
|
+
|
|
116
|
+
case TempEntity.BLASTER:
|
|
117
|
+
case TempEntity.FLECHETTE:
|
|
118
|
+
// Format: [pos, dir]
|
|
119
|
+
writer.writePos(args[0] as Vec3);
|
|
120
|
+
writer.writeDir(args[1] as Vec3);
|
|
121
|
+
break;
|
|
122
|
+
|
|
123
|
+
case TempEntity.RAILTRAIL:
|
|
124
|
+
case TempEntity.DEBUGTRAIL:
|
|
125
|
+
case TempEntity.BUBBLETRAIL:
|
|
126
|
+
case TempEntity.BUBBLETRAIL2:
|
|
127
|
+
case TempEntity.BFG_LASER:
|
|
128
|
+
case TempEntity.LIGHTNING_BEAM:
|
|
129
|
+
case TempEntity.LIGHTNING:
|
|
130
|
+
// Format: [start, end]
|
|
131
|
+
writer.writePos(args[0] as Vec3);
|
|
132
|
+
writer.writePos(args[1] as Vec3);
|
|
133
|
+
break;
|
|
134
|
+
|
|
135
|
+
case TempEntity.LASER_SPARKS:
|
|
136
|
+
case TempEntity.WELDING_SPARKS:
|
|
137
|
+
case TempEntity.TUNNEL_SPARKS:
|
|
138
|
+
case TempEntity.ELECTRIC_SPARKS:
|
|
139
|
+
case TempEntity.HEATBEAM_SPARKS:
|
|
140
|
+
case TempEntity.HEATBEAM_STEAM:
|
|
141
|
+
case TempEntity.STEAM:
|
|
142
|
+
// Format: [count, pos, dir, color?]
|
|
143
|
+
// Q2: writeByte(count), writePos(start), writeDir(normal), writeByte(skin/color)
|
|
144
|
+
writer.writeByte(args[0] as number);
|
|
145
|
+
writer.writePos(args[1] as Vec3);
|
|
146
|
+
writer.writeDir(args[2] as Vec3);
|
|
147
|
+
writer.writeByte(args[3] as number || 0);
|
|
148
|
+
break;
|
|
149
|
+
|
|
150
|
+
case TempEntity.PARASITE_ATTACK:
|
|
151
|
+
case TempEntity.MEDIC_CABLE_ATTACK:
|
|
152
|
+
// Format: [entIndex, start, end]
|
|
153
|
+
// args[0] is Entity usually
|
|
154
|
+
const ent = args[0] as Entity;
|
|
155
|
+
writer.writeShort(ent ? ent.index : 0);
|
|
156
|
+
writer.writePos(args[1] as Vec3);
|
|
157
|
+
writer.writePos(args[2] as Vec3);
|
|
158
|
+
break;
|
|
159
|
+
|
|
160
|
+
case TempEntity.GUNSHOT:
|
|
161
|
+
case TempEntity.BLOOD:
|
|
162
|
+
case TempEntity.SPARKS:
|
|
163
|
+
case TempEntity.BULLET_SPARKS:
|
|
164
|
+
case TempEntity.SCREEN_SPARKS:
|
|
165
|
+
case TempEntity.SHIELD_SPARKS:
|
|
166
|
+
// Format: [pos, dir]
|
|
167
|
+
writer.writePos(args[0] as Vec3);
|
|
168
|
+
writer.writeDir(args[1] as Vec3);
|
|
169
|
+
break;
|
|
170
|
+
|
|
171
|
+
case TempEntity.SPLASH:
|
|
172
|
+
case TempEntity.POWER_SPLASH:
|
|
173
|
+
case TempEntity.WIDOWSPLASH:
|
|
174
|
+
// Format: [count, pos, dir, color]
|
|
175
|
+
writer.writeByte(args[0] as number);
|
|
176
|
+
writer.writePos(args[1] as Vec3);
|
|
177
|
+
writer.writeDir(args[2] as Vec3);
|
|
178
|
+
writer.writeByte(args[3] as number || 0);
|
|
179
|
+
break;
|
|
180
|
+
|
|
181
|
+
default:
|
|
182
|
+
console.warn(`writeTempEntity: Unhandled TempEntity ${type}`);
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { BinaryStream, ClientCommand, UserCommand } from '@quake2ts/shared';
|
|
2
|
+
|
|
3
|
+
export interface ClientMessageHandler {
|
|
4
|
+
onMove(checksum: number, lastFrame: number, userCmd: UserCommand): void;
|
|
5
|
+
onUserInfo(info: string): void;
|
|
6
|
+
onStringCmd(cmd: string): void;
|
|
7
|
+
onNop(): void;
|
|
8
|
+
onBad(): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class ClientMessageParser {
|
|
12
|
+
private stream: BinaryStream;
|
|
13
|
+
private handler: ClientMessageHandler;
|
|
14
|
+
|
|
15
|
+
constructor(stream: BinaryStream, handler: ClientMessageHandler) {
|
|
16
|
+
this.stream = stream;
|
|
17
|
+
this.handler = handler;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
public parseMessage(): void {
|
|
21
|
+
while (this.stream.hasMore()) {
|
|
22
|
+
const cmdId = this.stream.readByte();
|
|
23
|
+
if (cmdId === -1) break;
|
|
24
|
+
|
|
25
|
+
switch (cmdId) {
|
|
26
|
+
case ClientCommand.move:
|
|
27
|
+
this.parseMove();
|
|
28
|
+
break;
|
|
29
|
+
case ClientCommand.userinfo:
|
|
30
|
+
this.parseUserInfo();
|
|
31
|
+
break;
|
|
32
|
+
case ClientCommand.stringcmd:
|
|
33
|
+
this.parseStringCmd();
|
|
34
|
+
break;
|
|
35
|
+
case ClientCommand.nop:
|
|
36
|
+
this.handler.onNop();
|
|
37
|
+
break;
|
|
38
|
+
default:
|
|
39
|
+
console.warn(`Unknown client command: ${cmdId}`);
|
|
40
|
+
this.handler.onBad();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private parseMove(): void {
|
|
47
|
+
const checksum = this.stream.readByte();
|
|
48
|
+
const lastFrame = this.stream.readLong();
|
|
49
|
+
|
|
50
|
+
// Read UserCmd
|
|
51
|
+
// TODO: support delta compression if needed (Q2 uses delta compression for usercmds)
|
|
52
|
+
// For now, assume full UserCmd or implement basic read
|
|
53
|
+
|
|
54
|
+
const msec = this.stream.readByte();
|
|
55
|
+
const buttons = this.stream.readByte();
|
|
56
|
+
const angles = {
|
|
57
|
+
x: this.stream.readShort() * (360.0 / 65536.0),
|
|
58
|
+
y: this.stream.readShort() * (360.0 / 65536.0),
|
|
59
|
+
z: this.stream.readShort() * (360.0 / 65536.0),
|
|
60
|
+
};
|
|
61
|
+
const forwardmove = this.stream.readShort();
|
|
62
|
+
const sidemove = this.stream.readShort();
|
|
63
|
+
const upmove = this.stream.readShort();
|
|
64
|
+
const impulse = this.stream.readByte();
|
|
65
|
+
const lightlevel = this.stream.readByte(); // Used for light-based stealth, usually ignored by server logic except for stats
|
|
66
|
+
|
|
67
|
+
const userCmd: UserCommand = {
|
|
68
|
+
msec,
|
|
69
|
+
buttons,
|
|
70
|
+
angles,
|
|
71
|
+
forwardmove,
|
|
72
|
+
sidemove,
|
|
73
|
+
upmove,
|
|
74
|
+
impulse,
|
|
75
|
+
lightlevel,
|
|
76
|
+
sequence: 0 // Server doesn't read sequence from packet body in standard protocol, it tracks it
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
this.handler.onMove(checksum, lastFrame, userCmd);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private parseUserInfo(): void {
|
|
83
|
+
const info = this.stream.readString();
|
|
84
|
+
this.handler.onUserInfo(info);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private parseStringCmd(): void {
|
|
88
|
+
const cmd = this.stream.readString();
|
|
89
|
+
this.handler.onStringCmd(cmd);
|
|
90
|
+
}
|
|
91
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { CollisionModel } from "@quake2ts/shared";
|
|
2
|
+
import { EntityState, MAX_CONFIGSTRINGS, MAX_EDICTS, MAX_CHALLENGES } from "@quake2ts/shared";
|
|
3
|
+
import { Client } from "./client.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ServerState corresponds to server_state_t in the original source.
|
|
7
|
+
*/
|
|
8
|
+
export enum ServerState {
|
|
9
|
+
Dead, // no map loaded
|
|
10
|
+
Loading, // spawning level edicts
|
|
11
|
+
Game, // actively running
|
|
12
|
+
Cinematic,
|
|
13
|
+
Demo,
|
|
14
|
+
Pic
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Challenge {
|
|
18
|
+
adr: string; // IP address
|
|
19
|
+
challenge: number;
|
|
20
|
+
time: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* ServerStatic holds the state that is constant across server restarts.
|
|
25
|
+
* Corresponds to server_static_t in the original source.
|
|
26
|
+
*/
|
|
27
|
+
export interface ServerStatic {
|
|
28
|
+
initialized: boolean;
|
|
29
|
+
realTime: number; // always increasing
|
|
30
|
+
|
|
31
|
+
mapCmd: string; // ie: *intro.cin+base
|
|
32
|
+
|
|
33
|
+
spawnCount: number; // incremented each server start
|
|
34
|
+
|
|
35
|
+
clients: (Client | null)[];
|
|
36
|
+
|
|
37
|
+
// In original this is: entity_state_t *client_entities;
|
|
38
|
+
// We might need a different approach in TS, but keeping the concept:
|
|
39
|
+
// This buffer holds entity states for all clients history.
|
|
40
|
+
// For now we might store it on the client directly or here.
|
|
41
|
+
// Original: client_entities[maxclients*UPDATE_BACKUP*MAX_PACKET_ENTITIES]
|
|
42
|
+
|
|
43
|
+
lastHeartbeat: number;
|
|
44
|
+
|
|
45
|
+
challenges: Challenge[];
|
|
46
|
+
|
|
47
|
+
// Demo recording stuff
|
|
48
|
+
demoFile?: any; // File handle
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Server holds the state for the current running server instance.
|
|
53
|
+
* Corresponds to server_t in the original source.
|
|
54
|
+
*/
|
|
55
|
+
export interface Server {
|
|
56
|
+
state: ServerState;
|
|
57
|
+
|
|
58
|
+
attractLoop: boolean;
|
|
59
|
+
loadGame: boolean;
|
|
60
|
+
|
|
61
|
+
startTime: number; // Added back as it was used in dedicated.ts and is useful
|
|
62
|
+
time: number; // sv.framenum * 100 msec
|
|
63
|
+
frame: number;
|
|
64
|
+
|
|
65
|
+
name: string; // map name
|
|
66
|
+
|
|
67
|
+
// Models are handled by AssetManager in engine usually,
|
|
68
|
+
// but server needs collision models.
|
|
69
|
+
collisionModel: CollisionModel | null;
|
|
70
|
+
|
|
71
|
+
configStrings: string[]; // [MAX_CONFIGSTRINGS]
|
|
72
|
+
baselines: (EntityState | null)[]; // [MAX_EDICTS]
|
|
73
|
+
|
|
74
|
+
// Multicast buffer
|
|
75
|
+
multicastBuf: Uint8Array;
|
|
76
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { NetDriver } from '@quake2ts/shared';
|
|
2
|
+
|
|
3
|
+
export interface NetworkTransport {
|
|
4
|
+
listen(port: number): Promise<void>;
|
|
5
|
+
close(): void;
|
|
6
|
+
onConnection(callback: (driver: NetDriver, info?: any) => void): void;
|
|
7
|
+
onError(callback: (error: Error) => void): void;
|
|
8
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { WebSocketServer } from 'ws';
|
|
2
|
+
import { WebSocketNetDriver } from '../net/nodeWsDriver.js';
|
|
3
|
+
import { NetworkTransport } from '../transport.js';
|
|
4
|
+
import { NetDriver } from '@quake2ts/shared';
|
|
5
|
+
|
|
6
|
+
export class WebSocketTransport implements NetworkTransport {
|
|
7
|
+
private wss: WebSocketServer | null = null;
|
|
8
|
+
private connectionCallback: ((driver: NetDriver, info?: any) => void) | null = null;
|
|
9
|
+
private errorCallback: ((error: Error) => void) | null = null;
|
|
10
|
+
|
|
11
|
+
async listen(port: number): Promise<void> {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
this.wss = new WebSocketServer({ port });
|
|
14
|
+
this.wss.on('listening', () => resolve());
|
|
15
|
+
this.wss.on('connection', (ws, req) => {
|
|
16
|
+
const driver = new WebSocketNetDriver();
|
|
17
|
+
driver.attach(ws);
|
|
18
|
+
if (this.connectionCallback) {
|
|
19
|
+
this.connectionCallback(driver, req);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
this.wss.on('error', (err) => {
|
|
23
|
+
if (this.errorCallback) this.errorCallback(err);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
close() {
|
|
29
|
+
if (this.wss) {
|
|
30
|
+
this.wss.close();
|
|
31
|
+
this.wss = null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
onConnection(callback: (driver: NetDriver, info?: any) => void) {
|
|
36
|
+
this.connectionCallback = callback;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
onError(callback: (error: Error) => void) {
|
|
40
|
+
this.errorCallback = callback;
|
|
41
|
+
}
|
|
42
|
+
}
|
package/test.bsp
ADDED
|
Binary file
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { createClient, ClientState } from '../src/client.js';
|
|
3
|
+
import { NetChan } from '@quake2ts/shared';
|
|
4
|
+
import { createMockNetDriver } from '@quake2ts/test-utils';
|
|
5
|
+
|
|
6
|
+
describe('Server Client', () => {
|
|
7
|
+
it('should initialize with NetChan', () => {
|
|
8
|
+
const mockNetDriver = createMockNetDriver();
|
|
9
|
+
|
|
10
|
+
const client = createClient(0, mockNetDriver);
|
|
11
|
+
|
|
12
|
+
expect(client).toBeDefined();
|
|
13
|
+
expect(client.netchan).toBeDefined();
|
|
14
|
+
expect(client.netchan).toBeInstanceOf(NetChan);
|
|
15
|
+
expect(client.state).toBe(ClientState.Connected);
|
|
16
|
+
|
|
17
|
+
// Verify qport is set (it's random, but should be a number)
|
|
18
|
+
expect(typeof client.netchan.qport).toBe('number');
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
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 { createMockTransport, MockTransport, createMockServerClient, createMockNetDriver, createMockGameExports, createMockConnection } from '@quake2ts/test-utils';
|
|
6
|
+
|
|
7
|
+
// Mock dependencies
|
|
8
|
+
// ws mock removed
|
|
9
|
+
vi.mock('node:fs/promises', () => ({
|
|
10
|
+
default: {
|
|
11
|
+
readFile: vi.fn().mockResolvedValue(Buffer.from([0])),
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
vi.mock('@quake2ts/engine', () => ({
|
|
15
|
+
parseBsp: vi.fn().mockReturnValue({}),
|
|
16
|
+
}));
|
|
17
|
+
vi.mock('@quake2ts/game', () => ({
|
|
18
|
+
createGame: vi.fn(),
|
|
19
|
+
createPlayerInventory: vi.fn(),
|
|
20
|
+
createPlayerWeaponStates: vi.fn(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
describe('DedicatedServer Connection Flow', () => {
|
|
24
|
+
let server: DedicatedServer;
|
|
25
|
+
let mockGame: GameExports;
|
|
26
|
+
let sentMessages: Uint8Array[] = [];
|
|
27
|
+
let consoleLogSpy: any;
|
|
28
|
+
let consoleWarnSpy: any;
|
|
29
|
+
let transport: MockTransport;
|
|
30
|
+
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
sentMessages = [];
|
|
33
|
+
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
34
|
+
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
35
|
+
|
|
36
|
+
mockGame = createMockGameExports({
|
|
37
|
+
clientConnect: vi.fn().mockReturnValue(true),
|
|
38
|
+
clientBegin: vi.fn(() => ({ id: 1, classname: 'player' } as any)),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
(createGame as vi.Mock).mockReturnValue(mockGame);
|
|
42
|
+
|
|
43
|
+
transport = createMockTransport();
|
|
44
|
+
server = new DedicatedServer({ transport });
|
|
45
|
+
await server.startServer('test.bsp');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
server.stopServer();
|
|
50
|
+
vi.clearAllMocks();
|
|
51
|
+
consoleLogSpy.mockRestore();
|
|
52
|
+
consoleWarnSpy.mockRestore();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should handle "connect" command', () => {
|
|
56
|
+
// 1. Setup a client using proper factory
|
|
57
|
+
const mockNet = createMockNetDriver({
|
|
58
|
+
send: vi.fn((data) => {
|
|
59
|
+
sentMessages.push(data);
|
|
60
|
+
})
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Use createMockConnection for more semantic setup, overriding with our mockNet
|
|
64
|
+
const client = createMockConnection(ClientState.Connected, {
|
|
65
|
+
net: mockNet,
|
|
66
|
+
edict: null, // Explicitly null edict as player hasn't entered game yet
|
|
67
|
+
index: 0
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Make sure transmit returns something so client.net.send is called with it
|
|
71
|
+
(client.netchan.transmit as any).mockReturnValue(new Uint8Array([1, 2, 3]));
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
// Inject client
|
|
75
|
+
// @ts-ignore
|
|
76
|
+
server.svs.clients[0] = client;
|
|
77
|
+
|
|
78
|
+
// 2. Simulate "connect" string command
|
|
79
|
+
// Use private access for unit testing
|
|
80
|
+
// @ts-ignore
|
|
81
|
+
server.handleStringCmd(client, 'connect \\name\\Player\\skin\\male/grunt');
|
|
82
|
+
|
|
83
|
+
// 3. Verify clientConnect was called
|
|
84
|
+
expect(mockGame.clientConnect).toHaveBeenCalledWith(null, '\\name\\Player\\skin\\male/grunt');
|
|
85
|
+
|
|
86
|
+
// 4. Verify response (ServerData)
|
|
87
|
+
// Check if any messages were sent
|
|
88
|
+
expect(sentMessages.length).toBeGreaterThan(0);
|
|
89
|
+
|
|
90
|
+
// Just verify that client.net.send was called.
|
|
91
|
+
expect(mockNet.send).toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
});
|