|
| 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