feat: initialize sunrise with 100-player shared rooms
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user