import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; import { once } from 'node:events'; import { setTimeout as delay } from 'node:timers/promises'; import { monitorEventLoopDelay } from 'node:perf_hooks'; import { cpus, totalmem } from 'node:os'; import { writeFile, mkdir } from 'node:fs/promises'; import { io, type Socket } from 'socket.io-client'; import { createApp } from '../server/app.ts'; import { MemoryVisitStore } from '../server/visits.ts'; import { MULTIPLAYER } from '../src/constants/multiplayer.ts'; import type { ClientEvents, ServerEvents, JoinResult, PlayerUpdate } from '../src/multiplayer/protocol.ts'; const duration = Number(process.env.LOAD_SECONDS ?? 300); assert.ok(Number.isFinite(duration) && duration >= 1 && duration <= 3600); const store = new MemoryVisitStore(); const errors: string[] = []; const app = createApp({ visitStore: store, weather: false, onError: error => errors.push(String(error)) }); const sockets: Socket[] = []; let timer: ReturnType | undefined; const lag = monitorEventLoopDelay({ resolution: 20 }); try { app.httpServer.listen(0, '127.0.0.1'); await once(app.httpServer, 'listening'); const url = `http://127.0.0.1:${(app.httpServer.address() as { port: number }).port}`; const connect = async () => { const socket: Socket = io(url, { transports: ['websocket'], forceNew: true, reconnection: false }); sockets.push(socket); await Promise.race([once(socket, 'connect'), once(socket, 'connect_error').then(([error]) => { throw error; })]); const reply: JoinResult = await socket.timeout(10000).emitWithAck('join', { ...MULTIPLAYER, visitorId: randomUUID(), nickname: '부하검증' }); return { socket, reply }; }; const participants = await Promise.all(Array.from({ length: 100 }, connect)); assert.ok(participants.every(p => p.reply.ok)); const overflow = await connect(); assert.deepEqual(overflow.reply, { ok: false, code: 'ROOM_FULL' }); overflow.socket.disconnect(); const positions: PlayerUpdate[] = participants.map(p => { if (!p.reply.ok) throw new Error('Admission failed'); const self = p.reply.state.players.find(player => player.id === p.reply.playerId)!; return { ...self, equipment: { umbrella: false, raincoat: false, hood: false } }; }); const latencies: number[] = []; let snapshots = 0, corrections = 0, unexpectedDisconnects = 0; for (const { socket } of participants) { socket.on('player:correction', () => corrections++); socket.on('disconnect', () => unexpectedDisconnects++); } participants[0].socket.on('room:state', state => { snapshots++; latencies.push(Math.max(0, Date.now() - state.serverTime)); }); lag.enable(); const cpuStart = process.cpuUsage(), start = performance.now(), initialMemory = process.memoryUsage(); let tick = 0; timer = setInterval(() => { tick++; participants.forEach(({ socket }, i) => { const base = positions[i]; // Sub-metre movement on the common spawn platform, well below walking speed. socket.emit('player:update', { ...base, x: base.x + Math.sin(tick / 10 + i / 20) * .15, z: base.z + Math.cos(tick / 10 + i / 20) * .15, yaw: Math.sin(tick / 30), moving: true, equipment: { umbrella: tick % 40 < 20, raincoat: i % 2 === 0, hood: false } }); }); }, MULTIPLAYER.tickMs); await delay(duration * 1000); clearInterval(timer); timer = undefined; assert.equal(unexpectedDisconnects, 0); assert.equal(app.room.size, 100); participants[99].socket.disconnect(); for (let n = 0; app.room.size !== 99 && n < 100; n++) await delay(10); const replacement = await connect(); assert.ok(replacement.reply.ok, 'released slot can be reused'); latencies.sort((a, b) => a - b); const cpu = process.cpuUsage(cpuStart); const result = { durationSeconds: (performance.now() - start) / 1000, clients: 100, tickHz: 10, capacityAndReplacement: true, corrections, snapshots, errors, snapshotDeliveryP95Ms: latencies[Math.floor(latencies.length * .95)] ?? null, snapshotDeliveryMaxMs: latencies.at(-1) ?? null, eventLoopP95Ms: lag.percentile(95) / 1e6, cpuSeconds: (cpu.user + cpu.system) / 1e6, rssStartMB: initialMemory.rss / 1048576, rssEndMB: process.memoryUsage().rss / 1048576, hardware: { cpu: cpus()[0]?.model, logicalCpus: cpus().length, ramGB: totalmem() / 1073741824 }, limits: 'Local server AND 100 synthetic clients share one Node process; memory visit store, no browser rendering. Snapshot latency starts at broadcast, excludes input-to-broadcast waiting.' }; assert.equal(errors.length, 0); assert.equal(corrections, 0, 'load motion should be accepted'); await mkdir('artifacts/multiplayer', { recursive: true }); await writeFile('artifacts/multiplayer/load-report.json', JSON.stringify(result, null, 2)); console.log(JSON.stringify(result, null, 2)); } finally { if (timer) clearInterval(timer); lag.disable(); sockets.forEach(socket => socket.disconnect()); await app.close(); }