Skip to content

Commit 9c0effd

Browse files
committed
Merge branch 'release/0.49.0'
2 parents da3f74f + 7b4949d commit 9c0effd

99 files changed

Lines changed: 12108 additions & 2996 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.dockerignore‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,38 @@
1-
node_modules
1+
node_modules/
2+
out/
3+
4+
scripts/
5+
benchmarks/
6+
tests/
7+
tests_out/
8+
plugins/
9+
10+
.git/
11+
.github/
12+
13+
.devcontainer/
14+
.vscode/
15+
.claude/
16+
17+
oxlintrc.json
18+
prettier.config.js
19+
.prettierignore
20+
.eslintignore
21+
*.config.mjs
22+
23+
README.md
24+
CODE_OF_CONDUCT.md
25+
CONTRIBUTING.md
26+
AUTHORS
27+
LICENSE
28+
signature-header.txt
29+
typedoc.json
30+
31+
.npmignore
32+
.ignore
33+
.gitignore
34+
shell.nix
35+
pm2-configs/
36+
list.env
37+
config.yaml
38+
docker-compose.yml

‎.github/workflows/dev-test.yml‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ jobs:
4242
runs-on: ubuntu-latest
4343
strategy:
4444
matrix:
45-
node-version: [24.x, 25.x]
45+
node-version: [24.x, 25.x, 26.x]
4646
steps:
4747
- uses: actions/checkout@v3
4848
- name: Use Node.js ${{ matrix.node-version }}
@@ -56,7 +56,7 @@ jobs:
5656
runs-on: ubuntu-latest
5757
strategy:
5858
matrix:
59-
node-version: [24.x, 25.x]
59+
node-version: [24.x, 25.x, 26.x]
6060
steps:
6161
- uses: actions/checkout@v3
6262
- name: Use Node.js ${{ matrix.node-version }}
@@ -72,7 +72,7 @@ jobs:
7272
runs-on: ubuntu-latest
7373
strategy:
7474
matrix:
75-
node-version: [24.x, 25.x]
75+
node-version: [24.x, 25.x, 26.x]
7676
steps:
7777
- uses: actions/checkout@v3
7878
- name: Use Node.js ${{ matrix.node-version }}
@@ -86,7 +86,7 @@ jobs:
8686
runs-on: windows-latest
8787
strategy:
8888
matrix:
89-
node-version: [24.x, 25.x]
89+
node-version: [24.x, 25.x, 26.x]
9090
steps:
9191
- uses: actions/checkout@v3
9292
- name: Use Node.js ${{ matrix.node-version }}

‎.gitignore‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,6 @@ tests_out
66
benchmarks_out
77
bin/*.exe
88
.vs
9-
config.yaml
9+
config.yaml
10+
/navMeshDump.obj
11+
*.tsbuildinfo
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
// ======================================================================
2+
// NPC AI tick stress test — profiles zombie FSM + Recast navigation
3+
// at scale, with a configurable number of zombies and fake player targets.
4+
//
5+
// Run after `npm run build`:
6+
// npm run stress-ai — normal run
7+
// npm run stress-ai-profile — with --cpu-prof (writes cpuprofile file)
8+
// npm run stress-ai-inspect — with --inspect for DevTools
9+
// ======================================================================
10+
11+
/* eslint-disable @typescript-eslint/no-require-imports */
12+
export {};
13+
process.env["DISABLE_PLUGINS"] = "true";
14+
15+
// ---- CONFIG (edit these) --------------------------------------------
16+
const CONFIG = {
17+
/** Number of fake player characters placed in the zombie cluster (triggers chase/attack) */
18+
playerCount: 100,
19+
durationSeconds: 60,
20+
/** Stats print interval in ms */
21+
statsIntervalMs: 5000,
22+
/** Sounds injected per fake player per AI tick (simulates weapon fire / footsteps) */
23+
soundsPerPlayer: 1,
24+
/** Radius of each injected sound */
25+
soundRadius: 100,
26+
/** Agitation value of each injected sound */
27+
soundAgitation: 10,
28+
/** Max metres a fake player drifts per tick (0 = static) */
29+
playerDriftRadius: 5
30+
};
31+
// ---------------------------------------------------------------------
32+
33+
const { ZoneServer2016 } = require("../../../h1z1-server");
34+
const { createFakeCharacter } = require("../../../out/utils/test.utils");
35+
36+
// ---- Helpers --------------------------------------------------------
37+
38+
function randomPosition(
39+
cx: number,
40+
cy: number,
41+
cz: number,
42+
radius: number
43+
): Float32Array {
44+
const angle = Math.random() * Math.PI * 2;
45+
const dist = Math.random() * radius;
46+
return new Float32Array([
47+
cx + Math.cos(angle) * dist,
48+
cy,
49+
cz + Math.sin(angle) * dist,
50+
1
51+
]);
52+
}
53+
54+
/** Rolling window of the last N samples */
55+
class RollingStats {
56+
private samples: number[] = [];
57+
constructor(private readonly window: number = 20) {}
58+
push(v: number) {
59+
this.samples.push(v);
60+
if (this.samples.length > this.window) this.samples.shift();
61+
}
62+
avg() {
63+
return this.samples.length === 0
64+
? 0
65+
: this.samples.reduce((a, b) => a + b, 0) / this.samples.length;
66+
}
67+
max() {
68+
return this.samples.length === 0 ? 0 : Math.max(...this.samples);
69+
}
70+
}
71+
72+
// ---- Main -----------------------------------------------------------
73+
74+
async function main() {
75+
console.log("=== H1Z1 NPC AI Tick Stress Test ===");
76+
console.log(
77+
`Config: ${CONFIG.playerCount} fake players` +
78+
` | ${CONFIG.durationSeconds}s | stats every ${CONFIG.statsIntervalMs / 1000}s` +
79+
` | ${CONFIG.soundsPerPlayer} sound(s)/player/tick r=${CONFIG.soundRadius} agit=${CONFIG.soundAgitation}` +
80+
` | drift=${CONFIG.playerDriftRadius}m`
81+
);
82+
83+
const server = new ZoneServer2016(0);
84+
85+
console.log("Starting server (no network, no MongoDB)…");
86+
await server.start();
87+
88+
// Stub out sendData — NPCs will try to broadcast movement/animation packets
89+
// to connected clients; fake characters have no real UDP connection.
90+
server.sendData = () => {};
91+
server.sendUnbufferedData = () => {};
92+
server.sendOrderedData = () => {};
93+
94+
// Known-good open area near the H1Z1 Z1 map centre, snapped to navmesh height.
95+
// Positions outside the navmesh still work — agents park on the nearest valid point.
96+
const CENTER_X = -600;
97+
const CENTER_Y = 30;
98+
const CENTER_Z = 700;
99+
const SPAWN_RADIUS = 400;
100+
101+
// --- Wait for server default NPC spawn ------------------------------
102+
console.log("Waiting for server default NPC spawn…");
103+
const npcWaitStart = Date.now();
104+
while (Object.keys(server._npcs).length === 0) {
105+
if (Date.now() - npcWaitStart > 30_000) {
106+
console.error("Timeout waiting for default NPC spawn");
107+
break;
108+
}
109+
await new Promise((r) => setTimeout(r, 500));
110+
}
111+
const npcCount = Object.keys(server._npcs).length;
112+
console.log(`Default NPCs spawned: ${npcCount}`);
113+
114+
// --- Create fake players --------------------------------------------
115+
// Place them among the default NPCs so they trigger chase/attack states,
116+
// giving a realistic mixed-state distribution.
117+
console.log(`Creating ${CONFIG.playerCount} fake player characters…`);
118+
const fakePlayers: any[] = [];
119+
for (let i = 0; i < CONFIG.playerCount; i++) {
120+
const character = createFakeCharacter(server);
121+
const pos = randomPosition(CENTER_X, CENTER_Y, CENTER_Z, SPAWN_RADIUS / 4);
122+
if (!character.state) character.state = {};
123+
character.state.position = pos;
124+
fakePlayers.push(character);
125+
}
126+
console.log("Setup complete. Measuring AI tick performance…\n");
127+
128+
// --- Instrument updatePathfindingPositions --------------------------
129+
let totalPathTicks = 0;
130+
let totalPathMs = 0;
131+
const pathStats = new RollingStats();
132+
133+
const origTick = server.updatePathfindingPositions.bind(server);
134+
server.updatePathfindingPositions = () => {
135+
const t0 = performance.now();
136+
origTick();
137+
const dt = performance.now() - t0;
138+
pathStats.push(dt);
139+
totalPathMs += dt;
140+
totalPathTicks++;
141+
};
142+
143+
// --- Instrument tickAi ---------------------------------------------
144+
let totalAiTicks = 0;
145+
let totalAiTickMs = 0;
146+
const aiTickStats = new RollingStats();
147+
148+
const origTickAi = (server as any).tickAi.bind(server);
149+
(server as any).tickAi = () => {
150+
// Simulate per-player noise each tick (weapon fire / footsteps).
151+
// Must happen before origTickAi, which runs tickNpcFsms then clears server.sounds.
152+
for (const player of fakePlayers) {
153+
for (let s = 0; s < CONFIG.soundsPerPlayer; s++) {
154+
server.sounds.push({
155+
position: player.state.position,
156+
radius: CONFIG.soundRadius,
157+
agitation: CONFIG.soundAgitation
158+
});
159+
}
160+
if (CONFIG.playerDriftRadius > 0) {
161+
const angle = Math.random() * Math.PI * 2;
162+
const drift = Math.random() * CONFIG.playerDriftRadius;
163+
player.state.position[0] += Math.cos(angle) * drift;
164+
player.state.position[2] += Math.sin(angle) * drift;
165+
}
166+
}
167+
const t0 = performance.now();
168+
origTickAi();
169+
const dt = performance.now() - t0;
170+
aiTickStats.push(dt);
171+
totalAiTickMs += dt;
172+
totalAiTicks++;
173+
};
174+
175+
// --- Stats loop -----------------------------------------------------
176+
const startTime = Date.now();
177+
const statRows: {
178+
t: number;
179+
aiAvgMs: number;
180+
aiMaxMs: number;
181+
pathAvgMs: number;
182+
pathMaxMs: number;
183+
memMB: number;
184+
stateDist: Record<string, number>;
185+
}[] = [];
186+
187+
const ZOMBIE_STATES = [
188+
"idle",
189+
"wander",
190+
"investigate",
191+
"chase",
192+
"stumble",
193+
"attack",
194+
"attacking",
195+
"feed",
196+
"dead"
197+
];
198+
199+
function getStateDist(srv: any): Record<string, number> {
200+
const dist: Record<string, number> = {};
201+
for (const s of ZOMBIE_STATES) dist[s] = 0;
202+
for (const k in srv._npcs) {
203+
const npc = srv._npcs[k];
204+
if (npc.fsm) {
205+
const state: string = npc.fsm.state ?? "unknown";
206+
dist[state] = (dist[state] ?? 0) + 1;
207+
}
208+
}
209+
return dist;
210+
}
211+
212+
const statsTimer = setInterval(() => {
213+
const t = Math.round((Date.now() - startTime) / 1000);
214+
const memMB = Math.round(process.memoryUsage().rss / 1024 / 1024);
215+
const dist = getStateDist(server);
216+
217+
const aiAvg = aiTickStats.avg();
218+
const aiMax = aiTickStats.max();
219+
const pAvg = pathStats.avg();
220+
const pMax = pathStats.max();
221+
222+
statRows.push({
223+
t,
224+
aiAvgMs: aiAvg,
225+
aiMaxMs: aiMax,
226+
pathAvgMs: pAvg,
227+
pathMaxMs: pMax,
228+
memMB,
229+
stateDist: dist
230+
});
231+
232+
const distStr = ZOMBIE_STATES.filter((s) => dist[s] > 0)
233+
.map((s) => `${s}:${dist[s]}`)
234+
.join(" ");
235+
236+
console.log(
237+
`[${String(t).padStart(3)}s]` +
238+
` tickAI avg=${aiAvg.toFixed(2).padStart(7)}ms max=${aiMax.toFixed(2).padStart(7)}ms` +
239+
` | pathfind avg=${pAvg.toFixed(2).padStart(7)}ms max=${pMax.toFixed(2).padStart(7)}ms` +
240+
` mem=${memMB}MB [${distStr}]`
241+
);
242+
}, CONFIG.statsIntervalMs);
243+
244+
// --- Shutdown -------------------------------------------------------
245+
setTimeout(async () => {
246+
clearInterval(statsTimer);
247+
248+
const elapsed = (Date.now() - startTime) / 1000;
249+
const memPeak = statRows.length
250+
? Math.max(...statRows.map((r) => r.memMB))
251+
: 0;
252+
const memAvg = statRows.length
253+
? Math.round(statRows.reduce((a, r) => a + r.memMB, 0) / statRows.length)
254+
: 0;
255+
256+
const wallMs = elapsed * 1000;
257+
258+
const aiAvgOverall =
259+
totalAiTicks > 0 ? (totalAiTickMs / totalAiTicks).toFixed(2) : "0";
260+
const aiPeak = statRows.length
261+
? Math.max(...statRows.map((r) => r.aiMaxMs)).toFixed(2)
262+
: "0";
263+
const aiCpuPct = ((totalAiTickMs / wallMs) * 100).toFixed(1);
264+
265+
const pathAvgOverall =
266+
totalPathTicks > 0 ? (totalPathMs / totalPathTicks).toFixed(2) : "0";
267+
const pathPeak = statRows.length
268+
? Math.max(...statRows.map((r) => r.pathMaxMs)).toFixed(2)
269+
: "0";
270+
const pathCpuPct = ((totalPathMs / wallMs) * 100).toFixed(1);
271+
272+
const finalDist = getStateDist(server);
273+
274+
console.log("\n=== FINAL REPORT ===");
275+
console.log(`Duration: ${elapsed.toFixed(1)}s`);
276+
console.log(`Zombies: ${npcCount}`);
277+
console.log(`Fake players: ${CONFIG.playerCount}`);
278+
console.log(`Total AI ticks: ${totalAiTicks}`);
279+
console.log(
280+
`tickAI avg: ${aiAvgOverall}ms peak: ${aiPeak}ms (${aiCpuPct}% of wall time)`
281+
);
282+
console.log(`Total pathfind ticks: ${totalPathTicks}`);
283+
console.log(
284+
`pathfind avg: ${pathAvgOverall}ms peak: ${pathPeak}ms (${pathCpuPct}% of wall time)`
285+
);
286+
console.log(`Memory avg / peak: ${memAvg}MB / ${memPeak}MB`);
287+
console.log(`Final state dist:`);
288+
for (const s of ZOMBIE_STATES) {
289+
if (finalDist[s] > 0) {
290+
const pct = ((finalDist[s] / npcCount) * 100).toFixed(1);
291+
console.log(
292+
` ${s.padEnd(12)} ${String(finalDist[s]).padStart(5)} (${pct}%)`
293+
);
294+
}
295+
}
296+
297+
if (process.execArgv.some((a: string) => a.startsWith("--cpu-prof"))) {
298+
console.log(
299+
"\nCPU profile written — open .cpuprofile in Chrome DevTools > Performance > Load profile"
300+
);
301+
}
302+
303+
await server.stop();
304+
process.exit(0);
305+
}, CONFIG.durationSeconds * 1000);
306+
}
307+
308+
main().catch((err) => {
309+
console.error("NPC AI stress test crashed:", err);
310+
process.exit(1);
311+
});

0 commit comments

Comments
 (0)