feat: initialize sunrise with 100-player shared rooms
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { once } from 'node:events';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join as joinPath } from 'node:path';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { createApp, type AppOptions } from '../server/app.ts';
|
||||
import { MemoryVisitStore, type VisitRecord } from '../server/visits.ts';
|
||||
import { MULTIPLAYER } from '../src/constants/multiplayer.ts';
|
||||
import type { JoinRequest, JoinResult, PlayerState, RoomState } from '../src/multiplayer/protocol.ts';
|
||||
|
||||
const request = (): JoinRequest => ({ locationId: MULTIPLAYER.locationId, roomId: MULTIPLAYER.roomId, visitorId: randomUUID(), nickname: 'Visitor' });
|
||||
const pause = (ms = 30) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
async function until(check: () => boolean) { for (let i = 0; i < 100; i++) { if (check()) return; await pause(10); } assert.fail('condition timed out'); }
|
||||
async function fixture(store = new MemoryVisitStore(), options: Partial<AppOptions> = {}) {
|
||||
const app = createApp({ visitStore: store, weather: false, onError: () => {}, ...options });
|
||||
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 clients: Socket[] = [];
|
||||
return { app, store, url, async connect() {
|
||||
const socket = io(url, { transports: ['websocket'], forceNew: true, reconnection: false });
|
||||
clients.push(socket); await new Promise<void>((resolve, reject) => {
|
||||
socket.once('connect', resolve); socket.once('connect_error', reject);
|
||||
}); return socket;
|
||||
}, async close() { clients.forEach(socket => socket.disconnect()); await app.close(); } };
|
||||
}
|
||||
const join = (socket: Socket, value = request()): Promise<JoinResult> => socket.timeout(3000).emitWithAck('join', value);
|
||||
|
||||
test('failed admission replies before blocked cleanup and releases its reservation', async () => {
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
class OfflineStore extends MemoryVisitStore {
|
||||
override async insert() { throw Error('offline'); }
|
||||
override async finish() { await gate; }
|
||||
}
|
||||
const f = await fixture(new OfflineStore());
|
||||
try {
|
||||
const socket = await f.connect();
|
||||
const result = await socket.timeout(500).emitWithAck('join', request());
|
||||
assert.deepEqual(result, { ok: false, code: 'STORAGE_UNAVAILABLE' });
|
||||
assert.equal(f.app.room.reserved, 0);
|
||||
} finally { release(); await f.close(); }
|
||||
});
|
||||
|
||||
test('lobby lists only the available summit channel and reports occupancy without joining', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const response = await fetch(`${f.url}/api/lobby`);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get('cache-control'), 'no-store');
|
||||
const lobby = await response.json();
|
||||
assert.equal(lobby.servers.find((s: { id: string }) => s.id === 'gwanaksan').enabled, true);
|
||||
assert.equal(lobby.servers.find((s: { id: string }) => s.id === 'hajodae').enabled, false);
|
||||
assert.equal(lobby.servers.find((s: { id: string }) => s.id === 'gisamun').enabled, false);
|
||||
assert.deepEqual(lobby.channel, { id: MULTIPLAYER.roomId, name: '채널 1', capacity: 100, players: 0, available: true });
|
||||
assert.equal(f.store.records.size, 0);
|
||||
await join(await f.connect());
|
||||
assert.equal((await (await fetch(`${f.url}/api/lobby`)).json()).channel.players, 1);
|
||||
} finally { await f.close(); }
|
||||
});
|
||||
function player(result: JoinResult): PlayerState { assert.equal(result.ok, true); if (!result.ok) throw Error(); return result.state.players.find(p => p.id === result.playerId)!; }
|
||||
|
||||
test('100 real simultaneous admissions reserve capacity; 101 rejected and released slot reusable', async () => {
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
class SlowStore extends MemoryVisitStore { override async insert(record: VisitRecord) { await gate; await super.insert(record); } }
|
||||
const f = await fixture(new SlowStore());
|
||||
try {
|
||||
const clients = await Promise.all(Array.from({ length: 101 }, () => f.connect()));
|
||||
const admissions = clients.map(c => join(c));
|
||||
await until(() => f.app.room.reserved === 100);
|
||||
assert.equal(f.app.room.size, 0);
|
||||
release();
|
||||
const results = await Promise.all(admissions);
|
||||
assert.equal(results.filter(r => r.ok).length, 100);
|
||||
assert.deepEqual(results.find(r => !r.ok), { ok: false, code: 'ROOM_FULL' });
|
||||
const index = results.findIndex(r => r.ok);
|
||||
const duplicate = await join(clients[index]);
|
||||
assert.equal(player(duplicate).id, player(results[index]).id);
|
||||
assert.equal(f.store.records.size, 100);
|
||||
clients[index].disconnect();
|
||||
await until(() => f.app.room.size === 99);
|
||||
assert.equal((await join(clients[results.findIndex(r => !r.ok)])).ok, true);
|
||||
} finally { release(); await f.close(); }
|
||||
});
|
||||
|
||||
test('pending admission is reserved, duplicate pending rejected, disconnect during insert closes once', async () => {
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
class SlowStore extends MemoryVisitStore { override async insert(record: VisitRecord) { await gate; await super.insert(record); } }
|
||||
const f = await fixture(new SlowStore());
|
||||
try {
|
||||
const socket = await f.connect();
|
||||
socket.emit('join', request(), () => {});
|
||||
await until(() => f.app.room.reserved === 1);
|
||||
assert.deepEqual(await join(socket), { ok: false, code: 'JOIN_PENDING' });
|
||||
socket.disconnect();
|
||||
await until(() => f.app.io.sockets.sockets.size === 0);
|
||||
assert.equal(f.app.room.reserved, 1);
|
||||
release();
|
||||
await until(() => f.store.records.size === 1 && [...f.store.records.values()][0].endReason !== null);
|
||||
assert.equal(f.app.room.reserved, 0);
|
||||
assert.equal(f.app.room.size, 0);
|
||||
assert.equal([...f.store.records.values()][0].endReason, 'disconnect');
|
||||
} finally { release(); await f.close(); }
|
||||
});
|
||||
|
||||
test('storage failure denies admission and frees reservation; invalid joins never persist', async () => {
|
||||
class BrokenStore extends MemoryVisitStore { override async insert() { throw Error('offline'); } }
|
||||
const f = await fixture(new BrokenStore());
|
||||
try {
|
||||
const socket = await f.connect();
|
||||
assert.deepEqual(await join(socket, { ...request(), locationId: 'coast' }), { ok: false, code: 'INVALID_JOIN' });
|
||||
assert.deepEqual(await join(socket, { ...request(), visitorId: 'spoof' }), { ok: false, code: 'INVALID_JOIN' });
|
||||
assert.deepEqual(await join(socket), { ok: false, code: 'STORAGE_UNAVAILABLE' });
|
||||
assert.equal(f.app.room.reserved, 0); assert.equal(f.app.room.size, 0);
|
||||
} finally { await f.close(); }
|
||||
});
|
||||
|
||||
test('validated gear reaches late joiner; invalid movement corrects; reset preserves gear and reconnect clears it', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.connect(); const initial = player(await join(a));
|
||||
const gear = { umbrella: true, raincoat: true, hood: true };
|
||||
a.emit('player:update', { ...initial, equipment: gear, id: 'spoof' });
|
||||
await until(() => f.app.room.snapshot().players[0]?.equipment.hood === true);
|
||||
const b = await f.connect(); const second = await join(b); assert.ok(second.ok);
|
||||
assert.deepEqual(second.state.players.find(p => p.id === initial.id)?.equipment, gear);
|
||||
assert.ok(second.state.players.every(p => p.id !== 'spoof'));
|
||||
for (const invalid of [{ x: 1e9 }, { yaw: 100 }, { equipment: { umbrella: false, raincoat: false, hood: true } }, { emote: 'hack' }]) {
|
||||
await pause(110);
|
||||
const correction = new Promise<PlayerState>(resolve => a.once('player:correction', resolve));
|
||||
a.emit('player:update', { ...initial, ...invalid });
|
||||
assert.equal((await correction).x, initial.x);
|
||||
}
|
||||
const reset: PlayerState = await a.timeout(3000).emitWithAck('player:reset');
|
||||
assert.deepEqual(reset.equipment, gear);
|
||||
a.disconnect(); await until(() => f.app.room.size === 1);
|
||||
const c = await f.connect(); const reconnected = player(await join(c));
|
||||
assert.notEqual(reconnected.id, initial.id); assert.equal(reconnected.equipment.hood, false);
|
||||
const time = await c.timeout(3000).emitWithAck('clock:sync'); assert.ok(Math.abs(time - Date.now()) < 1000);
|
||||
assert.deepEqual(await (await fetch(f.url + '/health')).json(), { ok: true });
|
||||
} finally { await f.close(); }
|
||||
});
|
||||
|
||||
test('server tick expires a wave even when its client sends no idle update', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.connect(); const p = player(await join(a));
|
||||
a.emit('player:update', { ...p, emote: 'wave' });
|
||||
await until(() => f.app.room.snapshot().players[0].emote === 'wave');
|
||||
await pause(3200);
|
||||
const ended = f.app.room.snapshot().players[0];
|
||||
assert.equal(ended.emote, 'idle'); assert.ok(ended.updatedAt > p.updatedAt);
|
||||
} finally { await f.close(); }
|
||||
});
|
||||
|
||||
test('stationary room sends no 100ms snapshots, changed snapshots have increasing sequence', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.connect(); const p = player(await join(a)); await pause(150);
|
||||
const states: RoomState[] = []; a.on('room:state', s => states.push(s));
|
||||
await pause(250); assert.equal(states.length, 0);
|
||||
a.emit('player:update', { ...p, equipment: { umbrella: true, raincoat: false, hood: false } });
|
||||
await until(() => states.length === 1);
|
||||
a.emit('player:reset', () => {}); await until(() => states.length === 2);
|
||||
assert.ok(states[1].sequence > states[0].sequence);
|
||||
} finally { await f.close(); }
|
||||
});
|
||||
|
||||
test('heartbeat and disconnect writes serialize, shutdown closes all visits and store', async () => {
|
||||
let release!: () => void, heartbeatStarted = false, closed = false;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
const writes: string[] = [];
|
||||
class SlowHeartbeat extends MemoryVisitStore {
|
||||
override async heartbeat(ids: string[], at: Date) { heartbeatStarted = true; await gate; writes.push('heartbeat'); await super.heartbeat(ids, at); }
|
||||
override async finish(id: string, at: Date, reason: 'disconnect' | 'shutdown' | 'interrupted') { writes.push(reason); await super.finish(id, at, reason); }
|
||||
override async close() { closed = true; }
|
||||
}
|
||||
const f = await fixture(new SlowHeartbeat());
|
||||
try {
|
||||
const a = await f.connect(); await join(a);
|
||||
const b = await f.connect(); await join(b);
|
||||
const beat = f.app.room.heartbeat(); await until(() => heartbeatStarted);
|
||||
a.disconnect(); await until(() => f.app.room.size === 1);
|
||||
assert.deepEqual(writes, []);
|
||||
release(); await beat;
|
||||
await f.app.close();
|
||||
assert.deepEqual(writes, ['heartbeat', 'disconnect', 'shutdown']);
|
||||
assert.equal(closed, true);
|
||||
assert.ok([...f.store.records.values()].every(record => record.leftAt !== null));
|
||||
} finally { release(); await f.close(); }
|
||||
});
|
||||
|
||||
test('heartbeat failure fails closed for subsequent admission and health', async () => {
|
||||
class BrokenHeartbeat extends MemoryVisitStore { override async heartbeat() { throw Error('write failed'); } }
|
||||
const f = await fixture(new BrokenHeartbeat());
|
||||
try {
|
||||
const a = await f.connect(); await join(a); await f.app.room.heartbeat();
|
||||
const b = await f.connect();
|
||||
assert.deepEqual(await join(b), { ok: false, code: 'STORAGE_UNAVAILABLE' });
|
||||
const health = await fetch(f.url + '/health'); assert.equal(health.status, 503);
|
||||
assert.deepEqual(await health.json(), { ok: false });
|
||||
} finally { await f.close(); }
|
||||
});
|
||||
|
||||
test('movement spends a bounded shared token budget, rejects unsafe path and malformed updates', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.connect(); let p = player(await join(a));
|
||||
const start = { ...p };
|
||||
// Repeated 2m moves at 10Hz must not get a fresh per-packet allowance.
|
||||
a.emit('player:update', { ...p, x: p.x + 1.5, moving: true });
|
||||
await until(() => f.app.room.snapshot().players[0].x !== p.x);
|
||||
p = f.app.room.snapshot().players[0]; await pause(110);
|
||||
const corrected = new Promise<PlayerState>(resolve => a.once('player:correction', resolve));
|
||||
a.emit('player:update', { ...p, x: p.x + 1.5, moving: true });
|
||||
assert.equal((await corrected).x, p.x);
|
||||
assert.ok(Math.abs(p.x - start.x) <= 2);
|
||||
for (const update of [null, { ...p, x: null }, { ...p, moving: 1 }, { ...p, equipment: { umbrella: 'true', raincoat: false, hood: false } }]) {
|
||||
await pause(110);
|
||||
const correction = new Promise<PlayerState>(resolve => a.once('player:correction', resolve));
|
||||
a.emit('player:update', update); assert.equal((await correction).id, p.id);
|
||||
}
|
||||
} finally { await f.close(); }
|
||||
});
|
||||
|
||||
test('weather snapshot is delivered to late joiner and server updates are shared', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const state = { status: 'ready' as const, weather: { cloudCover: 80, visibility: 1000, rain: 3, snowfall: 0 } };
|
||||
f.app.room.setWeather(state);
|
||||
const a = await f.connect(); const received = new Promise(resolve => a.once('weather:state', resolve));
|
||||
await join(a); assert.deepEqual(await received, state);
|
||||
const changed = new Promise(resolve => a.once('weather:state', resolve));
|
||||
f.app.room.setWeather({ status: 'error', weather: null });
|
||||
assert.deepEqual(await changed, { status: 'error', weather: null });
|
||||
} finally { await f.close(); }
|
||||
});
|
||||
|
||||
test('origin checks apply to WebSocket and oversized payload disconnects', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const rejected = io(f.url, { transports: ['websocket'], forceNew: true, reconnection: false, extraHeaders: { Origin: 'https://untrusted.example' } });
|
||||
try { await new Promise<void>((resolve, reject) => { rejected.once('connect_error', () => resolve()); rejected.once('connect', () => reject(Error('origin accepted'))); }); }
|
||||
finally { rejected.disconnect(); }
|
||||
const a = await f.connect(); await join(a);
|
||||
const disconnected = new Promise(resolve => a.once('disconnect', resolve));
|
||||
a.emit('player:update', { data: 'x'.repeat(5000) }); await disconnected;
|
||||
await until(() => f.app.room.size === 0);
|
||||
} finally { await f.close(); }
|
||||
});
|
||||
|
||||
test('static dist serving supports gzip and HEAD and rejects traversal and hidden files', async () => {
|
||||
const directory = await mkdtemp(joinPath(tmpdir(), 'sunrise-server-'));
|
||||
await writeFile(joinPath(directory, 'index.html'), '<h1>Sunrise</h1>');
|
||||
await writeFile(joinPath(directory, 'asset.bin'), 'asset payload');
|
||||
await writeFile(joinPath(directory, 'asset.bin.gz'), gzipSync('asset payload'));
|
||||
await writeFile(joinPath(directory, '.env'), 'secret');
|
||||
const f = await fixture(new MemoryVisitStore(), { distDirectory: directory });
|
||||
try {
|
||||
assert.match(await (await fetch(f.url)).text(), /Sunrise/);
|
||||
const gzip = await fetch(f.url + '/asset.bin'); assert.equal(gzip.headers.get('content-encoding'), 'gzip');
|
||||
assert.equal(await gzip.text(), 'asset payload');
|
||||
const head = await fetch(f.url + '/asset.bin', { method: 'HEAD' }); assert.equal(await head.text(), '');
|
||||
for (const path of ['/.env', '/%2e%2e%5cpackage.json', '/%00', '/C%3a/windows']) assert.equal((await fetch(f.url + path)).status, 404);
|
||||
const identity = await fetch(f.url + '/asset.bin', { headers: { 'Accept-Encoding': 'gzip;q=0' } });
|
||||
assert.equal(identity.headers.get('content-encoding'), null);
|
||||
} finally { await f.close(); await rm(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
Reference in New Issue
Block a user