import { arenaAiProfile, arenaDampening, shouldArenaBotChooseCorrectly } from "./arena-rules.mjs"; import { initializeArenaCharacter, useArenaCharacterAbility, tickArenaCharacter, arenaCharacterSnapshot, damageArenaCharacter, commandArenaCharacter, bindArenaCharacter } from "./generated/arena-combat-runtime.mjs"; import { arenaFloorHeight, arenaGeometryLineClear, arenaNavigationPath } from "./arena-geometry.mjs"; export const ARENA_TICK_RATE = 20; export const ARENA_SNAPSHOT_RATE = 10; export const ARENA_TICK_MS = 1000 / ARENA_TICK_RATE; function distance2d(left, right) { return Math.hypot(left.position[0] - right.position[0], left.position[2] - right.position[2]); } function segmentIntersectsBox(from, to, object) { if (object.shape !== "box" && object.shape !== "cylinder") return false; const radiusX = object.size[0] / 2; const radiusZ = object.size[2] / 2; const dx = to[0] - from[0]; const dz = to[2] - from[2]; const lengthSquared = dx * dx + dz * dz; const amount = lengthSquared > 0 ? Math.max(0, Math.min(1, ((object.position[0] - from[0]) * dx + (object.position[2] - from[2]) * dz) / lengthSquared)) : 0; const nearestX = from[0] + dx * amount; const nearestZ = from[2] + dz * amount; return Math.abs(nearestX - object.position[0]) <= radiusX && Math.abs(nearestZ - object.position[2]) <= radiusZ; } function newActor(participant, spawn, teamIndex, preparationGate) { const facing = Math.atan2(preparationGate[0] - spawn[0], preparationGate[2] - spawn[2]); // Keep the first player on the validated anchor. Role-based sideways offsets // put healers inside the starting-room wall in Nagrand. const offset = teamIndex * 2; return { id: participant.actorId, accountId: participant.accountId ?? null, characterId: participant.characterId ?? null, characterKit: participant.characterKit ?? null, name: participant.name, team: participant.team, role: participant.role, isBot: Boolean(participant.isBot), disconnected: Boolean(participant.disconnected), alive: true, position: [spawn[0] + Math.sin(facing) * offset, spawn[1], spawn[2] + Math.cos(facing) * offset], facing, movement: [0, 0], health: 1000, maxHealth: 1000, resource: participant.characterKit?.resource?.initial ?? 100, maxResource: participant.characterKit?.resource?.maximum ?? 100, gcdUntil: 0, lastAbilityId: undefined, abilityRevision: 0, auras: [], periodic: new Map(), targetActorId: null, cooldowns: new Map(), effects: new Map(), dr: new Map(), casting: null, lockoutUntil: 0, lastSequence: 0, nextBotDecisionAt: 0, rating: Number(participant.rating ?? 0), }; } export function arenaDynamicObjectState(stage, activeElapsedMs) { return stage.dynamicObjects.map((definition) => { if (definition.kind === "starting-elevators") { const delay = definition.initialDelayMs ?? 0; const travelMs = definition.activeMs || 1500; if (activeElapsedMs < delay) return { id: definition.id, state: "raised", progress: 0, collisionEnabled: true }; if (activeElapsedMs < delay + travelMs) { return { id: definition.id, state: "moving", progress: (activeElapsedMs - delay) / travelMs, collisionEnabled: true }; } return { id: definition.id, state: "open", progress: 1, collisionEnabled: false }; } if (definition.kind === "waterfall") { const start = definition.runtimeStartMs ?? definition.initialDelayRangeMs?.[0] ?? 30000; const cycle = definition.activeMs + definition.cycleMs; const time = activeElapsedMs - start; if (time < -definition.warningMs) return { id: definition.id, state: "open", progress: 0, collisionEnabled: false }; if (time < 0) return { id: definition.id, state: "warning", progress: (definition.warningMs + time) / definition.warningMs, collisionEnabled: false }; const within = time % Math.max(1, cycle); if (within >= definition.activeMs) { const remaining = cycle - within; const warning = remaining <= definition.warningMs; return { id: definition.id, state: warning ? "warning" : "open", progress: warning ? 1 - remaining / definition.warningMs : 0, collisionEnabled: false }; } return { id: definition.id, state: "active", progress: within / definition.activeMs, collisionEnabled: true }; } if (definition.kind === "pipe-knockback") { const warningStart = Math.max(0, (definition.initialDelayMs ?? 5000) - definition.warningMs); if (activeElapsedMs < warningStart) return { id: definition.id, state: "closed", progress: 0, collisionEnabled: true }; if (activeElapsedMs < (definition.initialDelayMs ?? 5000)) return { id: definition.id, state: "warning", progress: 1, collisionEnabled: true }; if (activeElapsedMs < (definition.initialDelayMs ?? 5000) + definition.activeMs) return { id: definition.id, state: "active", progress: 1, collisionEnabled: false }; return { id: definition.id, state: "closed", progress: 0, collisionEnabled: true }; } const delay = definition.initialDelayMs ?? 0; const time = activeElapsedMs - delay; const raised = time >= 0 && (definition.cycleMs <= 0 || time % definition.cycleMs < definition.activeMs); return { id: definition.id, state: definition.kind === "fire-cycle" ? (raised ? "active" : "closed") : (raised ? "raised" : "lowered"), progress: raised ? 1 : 0, collisionEnabled: definition.kind === "moving-pillars" ? raised : false, }; }); } export class ArenaSimulation { constructor(options) { this.id = options.id; this.stage = { ...options.stage, dynamicObjects: options.stage.dynamicObjects.map((object) => ({ ...object })), lineOfSight: options.stage.lineOfSight.map((object) => ({ ...object })), }; this.bracket = options.bracket; this.mode = options.mode; this.ruleset = options.ruleset; this.random = options.random ?? Math.random; this.onSnapshot = options.onSnapshot ?? (() => {}); this.onResult = options.onResult ?? (() => {}); this.phase = "preparation"; this.tickNumber = 0; this.startedAt = Number(options.startedAt ?? Date.now()); this.phaseEndsAt = this.startedAt + 10_000; this.activeStartedAt = null; this.finished = false; this.interval = null; this.actors = new Map(); this.lastMechanicPulse = new Map(); const teamCounts = [0, 0]; for (const participant of options.participants) { const team = participant.team; const teamIndex = teamCounts[team]++; const actor = newActor(participant, this.stage.teamSpawns[team], teamIndex, this.stage.preparationGates[team]); if (teamIndex) actor.position[1] = arenaFloorHeight(this.stage.id, actor.position[0], actor.position[2], actor.position[1], 2) ?? actor.position[1]; this.actors.set(actor.id, actor); initializeArenaCharacter(actor, this.startedAt); } for (const object of this.stage.dynamicObjects) { if (object.initialDelayRangeMs) { const [min, max] = object.initialDelayRangeMs; object.runtimeStartMs = min + this.random() * Math.max(0, max - min); } } } start() { if (!this.interval && !this.finished) this.interval = setInterval(() => this.step(Date.now()), ARENA_TICK_MS); return this; } resetPreparationClock(startedAt = Date.now()) { if (this.interval || this.tickNumber !== 0 || this.phase !== "preparation" || this.finished) return false; this.startedAt = Number(startedAt); this.phaseEndsAt = this.startedAt + 10_000; return true; } stop() { if (this.interval) clearInterval(this.interval); this.interval = null; } actor(actorId) { return this.actors.get(actorId) ?? null; } hasLineOfSight(left, right, dynamicStates = null) { if (!arenaGeometryLineClear(this.stage.id, [left.position[0], left.position[1] + 1.2, left.position[2]], [right.position[0], right.position[1] + 1.2, right.position[2]])) return false; const states = dynamicStates ?? new Map(this.dynamicObjects().map((state) => [state.id, state])); return !this.stage.lineOfSight.some((object) => { // Static walls come from the real mesh. Old proxy boxes created invisible // obstacles and blocked routes that are open in the detailed arena. if (!object.dynamicObjectId || !states.get(object.dynamicObjectId)?.collisionEnabled) return false; return segmentIntersectsBox(left.position, right.position, object); }); } targetActor(actorId) { const actor = this.actor(actorId); if (actor) return actor; for (const owner of this.actors.values()) { const summon = owner.combat.summons.find((candidate) => candidate.id === actorId); if (summon) return { ...owner, ...summon, team: owner.team, alive: summon.health > 0, facing: owner.facing }; } return null; } projectGroundPoint(origin, requested, range) { if (Math.hypot(requested[0] - origin[0], requested[2] - origin[2]) > range) return null; if ([0, 2].some((axis) => requested[axis] < this.stage.bounds.min[axis] || requested[axis] > this.stage.bounds.max[axis])) return null; const floor = arenaFloorHeight(this.stage.id, requested[0], requested[2], requested[1], 2); if (floor === null && arenaFloorHeight(this.stage.id, origin[0], origin[2], origin[1], 2) !== null) return null; const point = [requested[0], floor ?? requested[1], requested[2]]; return this.hasLineOfSight({ position: origin }, { position: point }) ? point : null; } resolvePosition(actor, requested, teleport = false) { const x = Math.max(this.stage.bounds.min[0] + .35, Math.min(this.stage.bounds.max[0] - .35, requested[0])); const z = Math.max(this.stage.bounds.min[2] + .35, Math.min(this.stage.bounds.max[2] - .35, requested[2])); const floor = arenaFloorHeight(this.stage.id, x, z, actor.position[1], teleport ? 3 : 1); const oldFloor = arenaFloorHeight(this.stage.id, actor.position[0], actor.position[2], actor.position[1], 2); if (floor === null && oldFloor !== null) return [...actor.position]; const ground = floor ?? actor.groundY ?? actor.position[1]; const target = [x, Math.max(ground, requested[1]), z]; for (const height of [.55, 1.4]) { const from = [actor.position[0], actor.position[1] + height, actor.position[2]]; const to = [target[0], target[1] + height, target[2]]; if (!arenaGeometryLineClear(this.stage.id, from, to)) return [...actor.position]; } const dynamic = new Map(this.dynamicObjects().map((entry) => [entry.id, entry])); for (const object of this.stage.lineOfSight) { if (!object.dynamicObjectId || !dynamic.get(object.dynamicObjectId)?.collisionEnabled) continue; if (target[1] > object.position[1] + object.size[1] / 2 || target[1] + 1.8 < object.position[1] - object.size[1] / 2) continue; if (Math.abs(x - object.position[0]) < object.size[0] / 2 + .35 && Math.abs(z - object.position[2]) < object.size[2] / 2 + .35) return [...actor.position]; } actor.groundY = ground; return target; } receiveIntent(actorId, intent) { const actor = this.actor(actorId); if (!actor || actor.isBot || actor.disconnected || !actor.alive || !["active", "preparation"].includes(this.phase)) return { accepted: false, reason: "actor-unavailable" }; const sequence = Math.trunc(Number(intent?.sequence)); if (!Number.isSafeInteger(sequence) || sequence <= actor.lastSequence) return { accepted: false, reason: "stale-sequence" }; actor.lastSequence = sequence; if (intent.type === "bindings" && intent.actionBindings && typeof intent.actionBindings === "object") { bindArenaCharacter(actor, intent.actionBindings); return { accepted: true }; } if (intent.type === "movement") { const x = Math.max(-1, Math.min(1, Number(intent.movement?.x) || 0)); const z = Math.max(-1, Math.min(1, Number(intent.movement?.z) || 0)); const length = Math.hypot(x, z); actor.movement = length > 1 ? [x / length, z / length] : [x, z]; return { accepted: true }; } if (intent.type === "facing") { actor.facing = Number.isFinite(Number(intent.facing)) ? Number(intent.facing) : actor.facing; return { accepted: true }; } if (intent.type === "target") { actor.targetActorId = this.targetActor(intent.targetActorId) ? intent.targetActorId : null; return { accepted: true }; } if (intent.type === "ability") { const ability = actor.characterKit.abilities[String(intent.abilityId ?? "")]; if (this.phase !== "active" && (ability?.target === "hostile" || ability?.effects.some((effect) => ["damage", "dot", "status", "execute", "finisher-damage"].includes(effect.kind)))) return { accepted: false, reason: "preparation" }; const ground = Array.isArray(intent.groundPosition) && intent.groundPosition.length === 3 && intent.groundPosition.every(Number.isFinite) ? intent.groundPosition : undefined; return this.useAbility(actor, String(intent.abilityId ?? ""), intent.targetActorId, ground); } if (intent.type === "command") { commandArenaCharacter(this, actor, intent.command, this.startedAt + this.tickNumber * ARENA_TICK_MS); return { accepted: true }; } if (intent.type === "jump") { if (!actor.jumpVelocity && actor.combat.controlledUntil <= this.startedAt + this.tickNumber * ARENA_TICK_MS) actor.jumpVelocity = 7; return { accepted: true }; } return { accepted: false, reason: "unknown-intent" }; } useAbility(actor, abilityId, targetActorId = null, groundPosition) { const now = this.startedAt + this.tickNumber * ARENA_TICK_MS; if (actor.characterKit) return useArenaCharacterAbility(this, actor, abilityId, targetActorId, now, groundPosition); return { accepted: false, reason: "actor-unavailable" }; } damage(target, amount, school = "physical") { const now = this.startedAt + this.tickNumber * ARENA_TICK_MS; damageArenaCharacter(this, target, amount, school, now); } dynamicObjects() { const elapsed = this.activeStartedAt == null ? -1 : this.tickNumber * ARENA_TICK_MS - (this.activeStartedAt - this.startedAt); return arenaDynamicObjectState(this.stage, elapsed); } step() { if (this.finished) return; this.tickNumber += 1; const simulationNow = this.startedAt + this.tickNumber * ARENA_TICK_MS; if (this.phase === "preparation" && simulationNow >= this.phaseEndsAt) { this.phase = "active"; this.activeStartedAt = this.phaseEndsAt; this.phaseEndsAt = null; } if (this.phase === "active") { this.updateActors(simulationNow); this.applyMechanics(simulationNow); this.checkResult(); } else if (this.phase === "preparation") { for (const actor of this.actors.values()) tickArenaCharacter(this, actor, simulationNow, ARENA_TICK_MS / 1000); } if (this.tickNumber % (ARENA_TICK_RATE / ARENA_SNAPSHOT_RATE) === 0) this.onSnapshot(this.snapshot()); } updateActors(now) { const deltaSeconds = ARENA_TICK_MS / 1000; for (const actor of this.actors.values()) { for (const [effectId, effect] of actor.effects) if (effect.endsAt <= now) actor.effects.delete(effectId); for (const [abilityId, endsAt] of actor.cooldowns) if (endsAt <= now) actor.cooldowns.delete(abilityId); tickArenaCharacter(this, actor, now, deltaSeconds); if (!actor.alive) continue; if (actor.isBot && actor.navigationPath?.length && !actor.casting) this.followBotPath(actor); if (actor.combat.controlledUntil <= now) this.moveActor(actor, deltaSeconds); if (actor.isBot && now >= actor.nextBotDecisionAt) this.botDecision(actor, now); } } moveActor(actor, deltaSeconds) { const statuses = actor.statuses ?? []; const speed = statuses.some((status) => status.kind === "root") ? 0 : 7 * (1 - Math.max(0, ...statuses.filter((status) => status.kind === "slow").map((status) => status.magnitude ?? 0))); const nextX = actor.position[0] + actor.movement[0] * speed * deltaSeconds; const nextZ = actor.position[2] + actor.movement[1] * speed * deltaSeconds; const previous = actor.position; actor.position = this.resolvePosition(actor, [nextX, previous[1], nextZ]); if (actor.position[0] === previous[0] && actor.position[2] === previous[2]) { actor.position = this.resolvePosition(actor, [nextX, previous[1], previous[2]]); actor.position = this.resolvePosition(actor, [actor.position[0], previous[1], nextZ]); } if (Math.hypot(...actor.movement) > 0.01) actor.facing = Math.atan2(actor.movement[0], actor.movement[1]); actor.groundY ??= actor.position[1]; if (actor.jumpVelocity || actor.position[1] > actor.groundY + .01) { actor.jumpVelocity ??= 0; actor.position[1] += actor.jumpVelocity * deltaSeconds; actor.jumpVelocity -= 20 * deltaSeconds; if (actor.position[1] <= actor.groundY) { actor.position[1] = actor.groundY; actor.jumpVelocity = 0; } } } botDecision(actor, now) { const rating = actor.rating ?? 0; const profile = arenaAiProfile(rating); actor.nextBotDecisionAt = now + (profile.reactionMinSeconds + this.random() * (profile.reactionMaxSeconds - profile.reactionMinSeconds)) * 1000; const allies = [...this.actors.values()].filter((entry) => entry.team === actor.team && entry.alive); const enemies = [...this.actors.values()].filter((entry) => entry.team !== actor.team && entry.alive); const target = enemies.sort((left, right) => left.health - right.health)[0]; if (!target) { actor.movement = [0, 0]; return; } actor.targetActorId = target.id; commandArenaCharacter(this, actor, "attack", now); const lowAlly = allies.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth)[0]; if (actor.characterKit) { if (actor.casting) { actor.movement = [0, 0]; return; } const abilities = Object.values(actor.characterKit.abilities).filter((ability) => now >= (actor.cooldowns.get(ability.id) ?? 0)); const has = (ability, kind) => ability.effects.some((effect) => effect.kind === kind); let candidates = []; let castTarget = target; if (actor.role === "healer" && lowAlly.health < lowAlly.maxHealth * .86) { candidates = abilities.filter((ability) => (ability.target === "friendly" || ability.target === "any") && (has(ability, "heal") || has(ability, "hot"))); castTarget = lowAlly; } else if (actor.health < actor.maxHealth * .4 && shouldArenaBotChooseCorrectly(profile.correctDefensiveChance, profile.minimumErrorChance, this.random)) { candidates = abilities.filter((ability) => ability.target === "self" && (has(ability, "heal") || has(ability, "shield") || has(ability, "apply-aura"))); castTarget = actor; } else { if (target.casting && shouldArenaBotChooseCorrectly(profile.healerInterruptChance, profile.minimumErrorChance, this.random)) candidates.push(...abilities.filter((ability) => has(ability, "interrupt"))); candidates.push(...abilities.filter((ability) => ability.target === "hostile" && (has(ability, "damage") || has(ability, "finisher-damage") || has(ability, "dot")))); } const distance = distance2d(actor, castTarget); const range = Math.max(4, ...candidates.map((ability) => ability.range.max)); const dx = castTarget.position[0] - actor.position[0], dz = castTarget.position[2] - actor.position[2]; actor.facing = Math.atan2(dx, dz); const needsMovement = distance > Math.min(range - .5, 24) || !this.hasLineOfSight(actor, castTarget); if (needsMovement) { if (!actor.navigationPath?.length || now >= (actor.nextPathAt ?? 0)) { actor.navigationPath = arenaNavigationPath(this.stage.id, actor.position, castTarget.position); actor.nextPathAt = now + 2500; } actor.movement = [dx / Math.max(.001, distance), dz / Math.max(.001, distance)]; this.followBotPath(actor); } else { actor.navigationPath = []; actor.movement = [0, 0]; } actor.targetActorId = castTarget.id; for (const ability of candidates) { if (distance <= ability.range.max || ability.target === "self") { if (this.useAbility(actor, ability.id, castTarget.id, ability.targeting?.shape === "ground" ? castTarget.position : undefined).accepted) { if (actor.casting) actor.movement = [0, 0]; break; } } } return; } } canWalkLine(actor, to) { const probe = { ...actor, position: [...actor.position] }; const distance = distance2d(actor, { position: to }); const steps = Math.max(1, Math.ceil(distance / .5)); for (let step = 1; step <= steps; step++) { const requested = [actor.position[0] + (to[0] - actor.position[0]) * step / steps, probe.position[1], actor.position[2] + (to[2] - actor.position[2]) * step / steps]; const resolved = this.resolvePosition(probe, requested); if (Math.hypot(resolved[0] - requested[0], resolved[2] - requested[2]) > .01) return false; // Keep the navmesh portals on ramps and drops. A straight shortcut can // cross an upper floor, fall, and leave the bot below its next waypoint. if (Math.abs((probe.groundY ?? resolved[1]) - actor.position[1]) > .2) return false; probe.position = [resolved[0], probe.groundY ?? resolved[1], resolved[2]]; } return true; } followBotPath(actor) { const path = actor.navigationPath; if (!path?.length) return; while (path.length > 1 && distance2d(actor, { position: path[0] }) < .7) path.shift(); // Smooth portals only across a collision-checked, continuously walkable line. if ((actor.nextPathSmoothTick ?? 0) <= this.tickNumber) { for (let index = Math.min(path.length - 1, 8); index > 0; index--) { if (this.canWalkLine(actor, path[index])) { path.splice(0, index); break; } } actor.nextPathSmoothTick = this.tickNumber + 10; } const dx = path[0][0] - actor.position[0], dz = path[0][2] - actor.position[2], distance = Math.hypot(dx, dz); actor.movement = distance < .2 ? [0, 0] : [dx / distance, dz / distance]; } applyMechanics(now) { const activeElapsed = now - this.activeStartedAt; const states = new Map(this.dynamicObjects().map((state) => [state.id, state])); if (this.stage.id === "dalaran-sewers") { for (const id of ["pipes", "waterfall"]) { if (states.get(id)?.state !== "active") continue; const pulseMs = id === "waterfall" ? 1500 : 750; const previous = this.lastMechanicPulse.get(id) ?? -Infinity; if (activeElapsed - previous < pulseMs) continue; this.lastMechanicPulse.set(id, activeElapsed); for (const actor of this.actors.values()) { if (!actor.alive || Math.abs(actor.position[0]) > 6 || Math.abs(actor.position[2]) > 14) continue; const direction = actor.position[2] >= 0 ? 1 : -1; actor.position[2] += direction * (id === "waterfall" ? 4 : 2); } } } if (this.stage.id === "ring-of-valor" && states.get("fire")?.state === "active") { const previous = this.lastMechanicPulse.get("fire") ?? -Infinity; if (activeElapsed - previous >= 1000) { this.lastMechanicPulse.set("fire", activeElapsed); for (const actor of this.actors.values()) { if (actor.alive && Math.abs(actor.position[2]) > 25) this.damage(actor, 35); } } } } checkResult() { const alive = [0, 1].map((team) => [...this.actors.values()].some((actor) => actor.team === team && actor.alive)); if (alive[0] && alive[1]) return; const winnerTeam = alive[0] === alive[1] ? null : alive[0] ? 0 : 1; this.finish(winnerTeam, winnerTeam == null ? "simultaneous-elimination" : "elimination"); } finish(winnerTeam, reason) { if (this.finished) return; this.finished = true; this.phase = reason === "void" ? "void" : "finished"; this.stop(); this.onSnapshot(this.snapshot()); this.onResult({ matchId: this.id, winnerTeam, draw: winnerTeam == null, reason }); } disconnect(actorId) { const actor = this.actor(actorId); if (!actor) return false; actor.disconnected = true; actor.isBot = true; actor.nextBotDecisionAt = 0; return true; } snapshot() { const now = this.startedAt + this.tickNumber * ARENA_TICK_MS; const elapsed = this.activeStartedAt == null ? 0 : Math.max(0, now - this.activeStartedAt); return { matchId: this.id, tick: this.tickNumber, serverTime: now, phase: this.phase, phaseEndsAt: this.phaseEndsAt, stageId: this.stage.id, bracket: this.bracket, mode: this.mode, ruleset: this.ruleset, dampening: this.phase === "active" || this.phase === "finished" ? arenaDampening(elapsed) : 0.1, actors: [...this.actors.values()].map((actor) => ({ id: actor.id, name: actor.name, team: actor.team, role: actor.role, characterId: actor.characterId, avatar: actor.avatar, visualEquipment: actor.visualEquipment, ...(actor.characterKit ? { classId: actor.characterKit.classId, level: actor.characterKit.level, actionBindings: actor.characterKit.actionBindings, abilityIds: Object.keys(actor.characterKit.abilities), resourceType: actor.characterKit.resource.type, gcdRemainingMs: Math.max(0, actor.gcdUntil - now), lastAbilityId: actor.lastAbilityId, abilityRevision: actor.abilityRevision, combat: arenaCharacterSnapshot(actor, now), } : {}), isBot: actor.isBot, disconnected: actor.disconnected, alive: actor.alive, position: [...actor.position], facing: actor.facing, health: actor.health, maxHealth: actor.maxHealth, resource: Math.round(actor.resource), maxResource: actor.maxResource, targetActorId: actor.targetActorId, cooldowns: Object.fromEntries([...actor.cooldowns].map(([id, endsAt]) => [id, Math.max(0, endsAt - now)])), effects: [ ...actor.auras.map((aura) => ({ id: aura.definition.name, icon: aura.definition.icon, disposition: aura.definition.disposition, remainingMs: aura.expiresAt == null ? 0 : Math.max(0, aura.expiresAt - now), stacks: aura.stacks })), ...(actor.statuses ?? []).map((status) => ({ id: status.kind, disposition: "debuff", remainingMs: Math.max(0, status.endsAt - now) })), ...[...actor.periodic.values()].map((effect) => ({ id: effect.id, remainingMs: Math.max(0, effect.nextAt - now) + (effect.ticks - 1) * effect.intervalMs })), ...(this.combatEffects ?? []).filter((effect) => effect.targetId === actor.id).map((effect) => ({ id: effect.sourceAbilityId, disposition: effect.kind === "dot" ? "debuff" : "buff", remainingMs: Math.max(0, effect.nextTickAt - now) + (effect.remainingTicks - 1) * effect.intervalMs })), ...[...actor.effects.values()].map((effect) => ({ id: effect.id, remainingMs: Math.max(0, effect.endsAt - now) })), ...(actor.casting ? [{ id: `casting:${actor.casting.abilityId}`, remainingMs: Math.max(0, actor.casting.completesAt - now) }] : []), ], })), dynamicObjects: this.dynamicObjects(), combatPresentation: this.combatPresentation ?? [], combatEvents: this.combatEvents ?? [], }; } }