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; }