feat: initialize sunrise with 100-player shared rooms

This commit is contained in:
2026-10-30 16:36:35 +09:00
commit 31d6eac4c2
257 changed files with 66063 additions and 0 deletions
+95
View File
@@ -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();
}