212 lines
7.2 KiB
JavaScript
212 lines
7.2 KiB
JavaScript
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(),
|
|
};
|
|
}
|