Release Healer Man 0.1.6
This commit is contained in:
@@ -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(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user