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.
207 lines
11 KiB
TypeScript
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;
|
|
}
|
|
}
|