import { GameDatabaseError } from "./database.mjs"; const PLAYER_TIMEOUT_MS = 15_000; const ROOM_TIMEOUT_MS = 30 * 60_000; const MAX_EVENTS = 256; const MAX_BATCH_EVENTS = 48; const ALLOWED_EVENT_KINDS = new Set([ "damage", "healing", "resurrection", "loot", "portal", ]); const ALLOWED_SCHOOLS = new Set([ "physical", "holy", "fire", "nature", "frost", "shadow", "arcane", ]); function finite(value, fallback = 0) { const number = Number(value); return Number.isFinite(number) ? number : fallback; } function boundedString(value, maximum = 160) { return String(value ?? "").slice(0, maximum); } function vector3(value) { if (!Array.isArray(value) || value.length < 3) return [0, 0, 0]; return value.slice(0, 3).map((entry) => Math.max(-100_000, Math.min(100_000, finite(entry)))); } function jsonClone(value, fallback = null) { try { return JSON.parse(JSON.stringify(value)); } catch { return fallback; } } function requireActivity(account, snapshot) { const group = snapshot?.group; const activity = group?.activity; const member = group?.members?.find((candidate) => candidate.accountId === account.id); if (!group || !activity || !member) { throw new GameDatabaseError("The shared activity is no longer active.", 409, "online_activity_required"); } return { group, activity, member }; } function sanitizePresence(input, member, now) { const maximumHealth = Math.max(1, Math.min(1_000_000_000, Math.round(finite(input?.maxHealth, 1)))); const maximumResource = Math.max(0, Math.min(1_000_000_000, Math.round(finite(input?.maxResource)))); return { accountId: member.accountId, username: member.username, character: member.character, role: member.role, position: vector3(input?.position), yaw: Math.max(-Math.PI * 8, Math.min(Math.PI * 8, finite(input?.yaw))), grounded: input?.grounded !== false, health: Math.max(0, Math.min(maximumHealth, Math.round(finite(input?.health, maximumHealth)))), maxHealth: maximumHealth, resource: Math.max(0, Math.min(maximumResource, finite(input?.resource))), maxResource: maximumResource, resourceName: boundedString(input?.resourceName, 40), selectedTargetId: input?.selectedTargetId ? boundedString(input.selectedTargetId) : null, activeCast: input?.activeCast && typeof input.activeCast === "object" ? jsonClone(input.activeCast) : null, animationEvent: input?.animationEvent && typeof input.animationEvent === "object" ? jsonClone(input.animationEvent) : null, equipment: Array.isArray(input?.equipment) ? jsonClone(input.equipment.slice(0, 24), []) : [], updatedAt: now, }; } function sanitizeEvent(input, accountId, leaderAccountId) { const kind = boundedString(input?.kind, 32); if (!ALLOWED_EVENT_KINDS.has(kind)) return null; const targetActorId = boundedString(input?.targetActorId); if (!targetActorId && kind !== "portal") return null; const sourceActorId = boundedString(input?.sourceActorId); const localPlayerActorId = `online-player:${accountId}`; // Non-leaders may submit only their own player actions. Enemy and AI events // are produced by the activity authority and cannot be forged by members. if (accountId !== leaderAccountId && sourceActorId !== localPlayerActorId) return null; const school = boundedString(input?.school, 24); return { clientEventId: boundedString(input?.clientEventId, 200), kind, sourceActorId: sourceActorId || localPlayerActorId, targetActorId, abilityId: input?.abilityId ? boundedString(input.abilityId) : null, school: ALLOWED_SCHOOLS.has(school) ? school : null, rawAmount: Math.max(0, Math.min(1_000_000_000, finite(input?.rawAmount))), effectiveAmount: Math.max(0, Math.min(1_000_000_000, finite(input?.effectiveAmount))), critical: input?.critical === true, occurredAt: Math.max(0, Math.trunc(finite(input?.occurredAt, Date.now()))), }; } export function createOnlineSessionManager() { const rooms = new Map(); function prune(now) { for (const [activityId, room] of rooms) { if (now - room.updatedAt > ROOM_TIMEOUT_MS) rooms.delete(activityId); } } function roomFor(group, activity, now) { prune(now); let room = rooms.get(activity.id); if (!room || room.groupId !== group.id) { room = { activityId: activity.id, groupId: group.id, revision: 0, nextEventId: 1, players: new Map(), events: [], eventKeys: new Set(), world: null, updatedAt: now, }; rooms.set(activity.id, room); } return room; } function sync(account, groupSnapshot, input = {}) { const now = Date.now(); const { group, activity, member } = requireActivity(account, groupSnapshot); const room = roomFor(group, activity, now); const presence = sanitizePresence(input.presence, member, now); room.players.set(account.id, presence); room.revision += 1; room.updatedAt = now; if (input.world !== undefined) { if (group.leaderAccountId !== account.id) { throw new GameDatabaseError("Only the activity leader can publish shared world state.", 403, "activity_authority_required"); } if (!input.world || typeof input.world !== "object" || Array.isArray(input.world)) { throw new GameDatabaseError("Shared world state must be an object.", 400, "invalid_world_state"); } room.world = { ...jsonClone(input.world, {}), publishedAt: now, publishedBy: account.id, }; room.revision += 1; } const batch = Array.isArray(input.events) ? input.events.slice(0, MAX_BATCH_EVENTS) : []; for (const candidate of batch) { const event = sanitizeEvent(candidate, account.id, group.leaderAccountId); if (!event?.clientEventId) continue; const eventKey = `${account.id}:${event.clientEventId}`; if (room.eventKeys.has(eventKey)) continue; room.eventKeys.add(eventKey); room.events.push({ ...event, id: room.nextEventId, sourceAccountId: account.id, createdAt: now, }); room.nextEventId += 1; room.revision += 1; } if (room.events.length > MAX_EVENTS) { const removed = room.events.splice(0, room.events.length - MAX_EVENTS); for (const event of removed) room.eventKeys.delete(`${event.sourceAccountId}:${event.clientEventId}`); } const memberIds = new Set(group.members.map((candidate) => candidate.accountId)); for (const [accountId, player] of room.players) { if (!memberIds.has(accountId) || now - player.updatedAt > PLAYER_TIMEOUT_MS) { room.players.delete(accountId); } } const afterEventId = Math.max(0, Math.trunc(finite(input.afterEventId))); return { activity, groupId: group.id, leaderAccountId: group.leaderAccountId, localAccountId: account.id, authority: group.leaderAccountId === account.id, revision: room.revision, serverTime: now, players: [...room.players.values()].sort((left, right) => left.accountId.localeCompare(right.accountId)), world: room.world, events: room.events.filter((event) => event.id > afterEventId), latestEventId: room.nextEventId - 1, }; } return { sync, clear: () => rooms.clear(), }; }