96 lines
5.0 KiB
TypeScript
96 lines
5.0 KiB
TypeScript
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<ServerEvents, ClientEvents>[] = [];
|
|
let timer: ReturnType<typeof setInterval> | 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<ServerEvents, ClientEvents> = 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();
|
|
}
|