Add the Blender flashlight asset with a 600 lm beam and reduced bloom, shared-mode self badge, and compact phone controls. Relocate the summit marker, blend terrain seams, widen walking routes, and align local server configuration on port 2027.
275 lines
15 KiB
TypeScript
275 lines
15 KiB
TypeScript
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, flashlight: 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 }); }
|
|
});
|