Files
sunrise/server/room.ts
T
horoli eb49ea4b84 Improve summit terrain, mobile HUD and multiplayer night equipment
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.
2026-10-31 23:14:44 +09:00

207 lines
11 KiB
TypeScript

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<ClientEvents, ServerEvents>;
type RoomSocket = Socket<ClientEvents, ServerEvents>;
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)
&& (gear.flashlight === undefined || typeof gear.flashlight === 'boolean');
}
export class SummitRoom {
private io: RoomServer;
private store: VisitStore;
private onError: (error: unknown) => void;
private sessions = new Map<string, Session>();
private pending = new Set<Promise<void>>();
private writes: Promise<void> = 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<void>) {
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,
...(update.equipment.flashlight === undefined ? {} : { flashlight: update.equipment.flashlight }) },
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;
}
}