feat: initialize sunrise with 100-player shared rooms
This commit is contained in:
+204
@@ -0,0 +1,204 @@
|
||||
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);
|
||||
}
|
||||
|
||||
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 },
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user