Files
healer-man/server/dungeon-race-combat-log.mjs
T
2026-09-27 14:31:15 -04:00

85 lines
4.9 KiB
JavaScript

export const MAX_RACE_COMBAT_LOG_BYTES = 4 * 1024 * 1024;
const kinds = new Set(["damage", "healing", "absorb", "resource", "aura", "cast",
"interrupt", "death", "resurrection"]);
function text(value, maximum = 128) {
return typeof value === "string" && value.length > 0 && value.length <= maximum
&& !/[\u0000-\u001f\u007f]/.test(value);
}
function amount(value, maximum = 1_000_000_000) {
return Number.isSafeInteger(value) && value >= 0 && value <= maximum;
}
/** Client-reported diagnostics only; race outcome still comes from the boss clear. */
export function normalizeRaceCombatLog(input, { runId, dungeonId }, ErrorType) {
const invalid = () => { throw new ErrorType("Choose a valid race combat log.", 400, "invalid_race_combat_log"); };
if (!input || typeof input !== "object" || input.version !== 1 || input.runId !== runId
|| typeof input.completed !== "boolean"
|| input.dungeonId !== dungeonId || !amount(input.durationMs, 2 * 60 * 60_000)
|| !amount(input.droppedEventCount) || !amount(input.droppedKillCount)
|| !Array.isArray(input.events) || input.events.length > 10_000
|| !Array.isArray(input.kills) || input.kills.length > 1_000
|| !Array.isArray(input.packs) || input.packs.length > 1_000
|| !Array.isArray(input.party) || input.party.length > 5
|| !Array.isArray(input.partyDamage) || input.partyDamage.length > 5) invalid();
const events = input.events.map((event) => {
if (!event || !amount(event.atMs, 2 * 60 * 60_000) || !kinds.has(event.kind)
|| !text(event.source) || !text(event.target) || (event.ability !== undefined && !text(event.ability))
|| !amount(event.raw) || !amount(event.effective) || !amount(event.overheal)
|| !amount(event.absorbed) || typeof event.critical !== "boolean"
|| (event.outcome !== undefined && !text(event.outcome))) invalid();
return { atMs: event.atMs, kind: event.kind, source: event.source, target: event.target,
...(event.ability ? { ability: event.ability } : {}), raw: event.raw,
effective: event.effective, overheal: event.overheal, absorbed: event.absorbed,
critical: event.critical, ...(event.outcome ? { outcome: event.outcome } : {}) };
});
const kills = input.kills.map((kill) => {
if (!kill || !text(kill.id) || !text(kill.name) || typeof kill.boss !== "boolean"
|| !amount(kill.maxHealth) || !amount(kill.engagedAtMs, 2 * 60 * 60_000)
|| (kill.firstDamageAtMs !== null && !amount(kill.firstDamageAtMs, 2 * 60 * 60_000))
|| !amount(kill.diedAtMs, 2 * 60 * 60_000) || kill.diedAtMs < kill.engagedAtMs
|| !amount(kill.durationMs, 2 * 60 * 60_000)) invalid();
return { id: kill.id, name: kill.name, boss: kill.boss, maxHealth: kill.maxHealth,
engagedAtMs: kill.engagedAtMs, firstDamageAtMs: kill.firstDamageAtMs,
diedAtMs: kill.diedAtMs, durationMs: kill.durationMs };
});
const party = input.party.map((member) => {
if (!member || !text(member.actorId) || !text(member.label) || !text(member.classId)
|| !amount(member.level, 80) || member.level < 1
|| !["tank", "healer", "damage"].includes(member.role)) invalid();
return { actorId: member.actorId, label: member.label, classId: member.classId,
level: member.level, role: member.role };
});
const partyIds = new Set(party.map((member) => member.actorId));
if (partyIds.size !== party.length) invalid();
const partyDamage = input.partyDamage.map((entry) => {
if (!entry || !partyIds.has(entry.actorId) || !amount(entry.damage)) invalid();
return { actorId: entry.actorId, damage: entry.damage };
});
if (new Set(partyDamage.map((entry) => entry.actorId)).size !== partyDamage.length) invalid();
const knownMobIds = new Set(kills.map((kill) => kill.id));
const packs = input.packs.map((pack) => {
if (!pack || !amount(pack.startedAtMs, 2 * 60 * 60_000)
|| !amount(pack.endedAtMs, 2 * 60 * 60_000) || pack.endedAtMs < pack.startedAtMs
|| !amount(pack.durationMs, 2 * 60 * 60_000)
|| !Array.isArray(pack.mobIds) || pack.mobIds.length < 1 || pack.mobIds.length > 1_000
|| pack.mobIds.some((id) => !knownMobIds.has(id))
|| !Array.isArray(pack.partyDamage) || pack.partyDamage.length > 5) invalid();
const damage = pack.partyDamage.map((entry) => {
if (!entry || !partyIds.has(entry.actorId) || !amount(entry.damage)) invalid();
return { actorId: entry.actorId, damage: entry.damage };
});
return { startedAtMs: pack.startedAtMs, endedAtMs: pack.endedAtMs,
durationMs: pack.durationMs, mobIds: [...pack.mobIds], partyDamage: damage };
});
const normalized = { version: 1, runId, dungeonId, durationMs: input.durationMs,
completed: input.completed,
events, droppedEventCount: input.droppedEventCount, kills, packs,
droppedKillCount: input.droppedKillCount, party, partyDamage };
if (Buffer.byteLength(JSON.stringify(normalized)) > MAX_RACE_COMBAT_LOG_BYTES) invalid();
return normalized;
}