Release Healer Man 0.1.6

This commit is contained in:
phenom
2026-08-20 14:05:50 -04:00
parent 55cfd43d66
commit b12fb84859
96 changed files with 10874 additions and 609 deletions
+211
View File
@@ -0,0 +1,211 @@
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(),
};
}