import { randomUUID } from 'node:crypto'; import { performance } from 'node:perf_hooks'; import type { Server, Socket } from 'socket.io'; import { MULTIPLAYER } from '../src/constants/multiplayer.ts'; import { MOVEMENT } from '../src/constants/movement.ts'; import { CHARACTER } from '../src/constants/character.ts'; import { SUMMIT_TERRAIN } from '../src/constants/summitTerrain.ts'; import { observer } from '../src/config/observer.ts'; import { createSummitWalking } from '../src/controls/summitWalking.ts'; import { canPray } from '../src/controls/emotes.ts'; import { calculateSun } from '../src/astronomy/sunCalculator.ts'; import type { ClientEvents, ServerEvents, JoinRequest, JoinResult, PlayerState, PlayerUpdate, RoomState } from '../src/multiplayer/protocol.ts'; import type { WeatherStatus } from '../src/weather/weather.ts'; import type { EndReason, VisitStore } from './visits.ts'; import { SERVER_LIMITS } from './constants/multiplayer.ts'; export type RoomServer = Server; type RoomSocket = Socket; interface Session { id: string; socket: RoomSocket; player?: PlayerState; departed: boolean; departureAt?: Date; endReason?: EndReason; budget: number; budgetAt: number; packetAt: number; } const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; function validJoin(value: unknown): value is JoinRequest { if (!value || typeof value !== 'object') return false; const v = value as JoinRequest; return v.locationId === MULTIPLAYER.locationId && v.roomId === MULTIPLAYER.roomId && typeof v.visitorId === 'string' && uuid.test(v.visitorId) && typeof v.nickname === 'string' && v.nickname.trim().length > 0 && v.nickname.length <= MULTIPLAYER.maxNicknameLength && !/[\u0000-\u001f\u007f]/.test(v.nickname); } function validUpdate(value: unknown): value is PlayerUpdate { if (!value || typeof value !== 'object') return false; const v = value as PlayerUpdate, gear = v.equipment; return Number.isFinite(v.x) && Number.isFinite(v.z) && Number.isFinite(v.yaw) && Math.abs(v.yaw) <= Math.PI * 2 && typeof v.moving === 'boolean' && ['idle', 'wave', 'pray'].includes(v.emote) && !!gear && typeof gear.umbrella === 'boolean' && typeof gear.raincoat === 'boolean' && typeof gear.hood === 'boolean' && (!gear.hood || gear.raincoat); } export class SummitRoom { private io: RoomServer; private store: VisitStore; private onError: (error: unknown) => void; private sessions = new Map(); private pending = new Set>(); private writes: Promise = Promise.resolve(); private storageHealthy = true; private stopping = false; private dirty = false; private sequence = 0; private serverInstanceId = randomUUID(); private walking = createSummitWalking(observer); private weather: WeatherStatus = { status: 'unavailable', weather: null }; private maxBudget = MOVEMENT.speed * SERVER_LIMITS.movementBudgetSeconds; constructor(io: RoomServer, store: VisitStore, onError: (error: unknown) => void) { this.io = io; this.store = store; this.onError = onError; io.on('connection', socket => this.attach(socket)); } get size() { return [...this.sessions.values()].filter(s => s.player && !s.departed).length; } get reserved() { return this.sessions.size; } get healthy() { return this.storageHealthy && !this.stopping; } snapshot(): RoomState { return { locationId: MULTIPLAYER.locationId, roomId: MULTIPLAYER.roomId, capacity: MULTIPLAYER.capacity, sequence: this.sequence, serverTime: Date.now(), players: [...this.sessions.values()] .filter(s => s.player && !s.departed).map(s => ({ ...s.player!, equipment: { ...s.player!.equipment } })) }; } broadcast(force = false) { const at = Date.now(); let prayerAvailable: boolean | undefined; for (const { player } of this.sessions.values()) { if (!player || player.emote === 'idle') continue; if (player.emote === 'pray' && prayerAvailable === undefined) { prayerAvailable = canPray(new Date(at), calculateSun(new Date(at), observer).sunrise); } if ((player.emote === 'wave' && at - player.emoteStartedAt >= CHARACTER.waveSeconds * 1000) || (player.emote === 'pray' && !prayerAvailable)) { player.emote = 'idle'; player.emoteStartedAt = at; player.updatedAt = at; this.dirty = true; } } if (!force && !this.dirty) return; this.sequence++; this.dirty = false; this.io.to(MULTIPLAYER.roomId).emit('room:state', this.snapshot()); } setWeather(state: WeatherStatus) { this.weather = state; this.io.to(MULTIPLAYER.roomId).emit('weather:state', state); } private failure(error: unknown) { this.storageHealthy = false; this.onError(error); } private enqueue(write: () => Promise) { this.writes = this.writes.then(write).catch(error => this.failure(error)); return this.writes; } heartbeat() { const ids = [...this.sessions.values()].filter(s => s.player && !s.departed).map(s => s.id); const at = new Date(); return this.enqueue(() => this.store.heartbeat(ids, at)); } private spawn(id: string, nickname: string): PlayerState { const heading = SUMMIT_TERRAIN.heading * Math.PI / 180; return { id, nickname, ...this.walking.start, yaw: Math.atan2(Math.sin(heading), -Math.cos(heading)), moving: false, emote: 'idle', equipment: { umbrella: false, raincoat: false, hood: false }, updatedAt: Date.now(), emoteStartedAt: Date.now() }; } private attach(socket: RoomSocket) { socket.on('clock:sync', reply => { if (typeof reply === 'function') reply(Date.now()); }); socket.on('join', (request, reply) => { if (typeof reply !== 'function') return; const job = this.join(socket, request, reply); this.pending.add(job); void job.finally(() => this.pending.delete(job)); }); socket.on('player:update', update => this.update(socket, update)); socket.on('player:reset', reply => { const s = this.sessions.get(socket.id); if (!s?.player || s.departed) { if (typeof reply === 'function') reply(null); return; } s.player = { ...this.spawn(s.id, s.player.nickname), equipment: { ...s.player.equipment } }; s.budget = this.maxBudget; s.budgetAt = performance.now(); this.dirty = true; if (typeof reply === 'function') reply(s.player); }); socket.on('disconnect', () => this.depart(socket.id, this.stopping ? 'shutdown' : 'disconnect')); } private async join(socket: RoomSocket, request: JoinRequest, reply: (result: JoinResult) => void) { if (!validJoin(request)) { reply({ ok: false, code: 'INVALID_JOIN' }); return; } const existing = this.sessions.get(socket.id); if (existing) { reply(existing.player ? { ok: true, playerId: existing.id, state: this.snapshot() } : { ok: false, code: 'JOIN_PENDING' }); return; } if (!this.healthy) { reply({ ok: false, code: 'STORAGE_UNAVAILABLE' }); return; } if (this.reserved >= MULTIPLAYER.capacity) { reply({ ok: false, code: 'ROOM_FULL' }); return; } const at = new Date(), s: Session = { id: randomUUID(), socket, departed: false, budget: this.maxBudget, budgetAt: performance.now(), packetAt: -Infinity }; // Reservation is synchronous, before any awaited storage work. this.sessions.set(socket.id, s); try { await this.store.insert({ _id: s.id, visitorId: request.visitorId, nickname: request.nickname.trim(), locationId: request.locationId, roomId: request.roomId, joinedAt: at, lastSeenAt: at, leftAt: null, endReason: null, serverInstanceId: this.serverInstanceId }); } catch (error) { this.sessions.delete(socket.id); this.failure(error); if (socket.connected) reply({ ok: false, code: 'STORAGE_UNAVAILABLE' }); // A network error may follow an acknowledged-on-server insert. Close it if it exists. await this.enqueue(() => this.store.finish(s.id, s.departureAt ?? new Date(), s.endReason ?? 'interrupted')); return; } if (s.departed || !socket.connected || this.stopping) { this.sessions.delete(socket.id); await this.enqueue(() => this.store.finish(s.id, s.departureAt ?? new Date(), s.endReason ?? 'shutdown')); return; } s.player = this.spawn(s.id, request.nickname.trim()); await socket.join(MULTIPLAYER.roomId); this.broadcast(true); reply({ ok: true, playerId: s.id, state: this.snapshot() }); socket.emit('weather:state', this.weather); } private depart(socketId: string, reason: EndReason) { const s = this.sessions.get(socketId); if (!s || s.departed) return; s.departed = true; s.departureAt = new Date(); s.endReason = reason; // Pending inserts retain their reservation until settled; no double release. if (!s.player) return; this.sessions.delete(socketId); void this.enqueue(() => this.store.finish(s.id, s.departureAt!, reason)); this.broadcast(true); } private update(socket: RoomSocket, update: PlayerUpdate) { const s = this.sessions.get(socket.id); if (!s?.player || s.departed) return; const now = performance.now(); if (now - s.packetAt < SERVER_LIMITS.updateIntervalMs) return; s.packetAt = now; s.budget = Math.min(this.maxBudget, s.budget + Math.max(0, now - s.budgetAt) / 1000 * MOVEMENT.speed); s.budgetAt = now; const distance = validUpdate(update) ? Math.hypot(update.x - s.player.x, update.z - s.player.z) : Infinity; // Bound distance before walking's swept-path loop, including for malicious huge coordinates. if (!Number.isFinite(distance) || distance > s.budget + 1e-8 || !this.walking.canTraverse(s.player, update)) { socket.emit('player:correction', s.player); return; } s.budget -= distance; const at = Date.now(); let emote = update.moving ? 'idle' as const : update.emote; if (emote === 'pray' && !canPray(new Date(at), calculateSun(new Date(at), observer).sunrise)) emote = 'idle'; const changed = update.x !== s.player.x || update.z !== s.player.z || update.yaw !== s.player.yaw || update.moving !== s.player.moving || emote !== s.player.emote || Object.keys(update.equipment).some(key => update.equipment[key as keyof typeof update.equipment] !== s.player!.equipment[key as keyof typeof update.equipment]); if (!changed) return; s.player = { ...s.player, x: update.x, z: update.z, yaw: update.yaw, moving: update.moving, emote, equipment: { umbrella: update.equipment.umbrella, raincoat: update.equipment.raincoat, hood: update.equipment.hood }, updatedAt: at, emoteStartedAt: emote === s.player.emote ? s.player.emoteStartedAt : at }; this.dirty = true; } async close() { this.stopping = true; for (const id of this.sessions.keys()) this.depart(id, 'shutdown'); await Promise.all(this.pending); await this.writes; } }