Release Healer Man 0.1.29

This commit is contained in:
phenom committed 2026-09-11 14:26:15 -04:00
1 parent 8c9809d3e0
commit 544c1bee91
120 files changed
+4363 -1036

No files matched your search

+198 -108
View File
@@ -1,19 +1,11 @@
import { arenaAiProfile, arenaDampening, arenaDrDuration, applyArenaHealing, shouldArenaBotChooseCorrectly } from "./arena-rules.mjs";
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;
const ABILITIES = Object.freeze({
attack: Object.freeze({ cooldownMs: 1500, range: 5, damage: 40 }),
strike: Object.freeze({ cooldownMs: 6000, range: 5, damage: 120 }),
"ranged-attack": Object.freeze({ cooldownMs: 2200, range: 30, damage: 52 }),
heal: Object.freeze({ cooldownMs: 0, range: 40, healing: 150, castMs: 1500, resource: 12 }),
interrupt: Object.freeze({ cooldownMs: 15000, range: 8, lockoutMs: 4000 }),
control: Object.freeze({ cooldownMs: 30000, range: 25, controlMs: 6000, drCategory: "incapacitate" }),
defensive: Object.freeze({ cooldownMs: 90000, range: 0, defensiveMs: 8000 }),
});
function distance2d(left, right) {
return Math.hypot(left.position[0] - right.position[0], left.position[2] - right.position[2]);
}
@@ -34,26 +26,34 @@ function segmentIntersectsBox(from, to, object) {
&& Math.abs(nearestZ - object.position[2]) <= radiusZ;
}
function newActor(participant, spawn, teamIndex, arenaCenter) {
const offset = participant.role === "healer" ? -2 : teamIndex * 2;
const facing = Math.atan2(arenaCenter[0] - spawn[0], arenaCenter[2] - spawn[2]);
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], spawn[1], spawn[2] + offset],
position: [spawn[0] + Math.sin(facing) * offset, spawn[1], spawn[2] + Math.cos(facing) * offset],
facing,
movement: [0, 0],
health: 1000,
maxHealth: 1000,
resource: 100,
maxResource: 100,
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(),
@@ -134,14 +134,13 @@ export class ArenaSimulation {
this.actors = new Map();
this.lastMechanicPulse = new Map();
const teamCounts = [0, 0];
const arenaCenter = [
(this.stage.bounds.min[0] + this.stage.bounds.max[0]) / 2,
(this.stage.bounds.min[2] + this.stage.bounds.max[2]) / 2,
];
for (const participant of options.participants) {
const team = participant.team;
const actor = newActor(participant, this.stage.teamSpawns[team], teamCounts[team]++, arenaCenter);
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) {
@@ -173,19 +172,68 @@ export class ArenaSimulation {
}
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) => {
if (object.dynamicObjectId && !states.get(object.dynamicObjectId)?.collisionEnabled) return false;
// 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 || this.phase !== "active") return { accepted: false, reason: "actor-unavailable" };
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));
@@ -198,56 +246,35 @@ export class ArenaSimulation {
return { accepted: true };
}
if (intent.type === "target") {
actor.targetActorId = this.actors.has(intent.targetActorId) ? intent.targetActorId : null;
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 };
}
if (intent.type === "ability") return this.useAbility(actor, String(intent.abilityId ?? ""), intent.targetActorId);
return { accepted: false, reason: "unknown-intent" };
}
useAbility(actor, abilityId, targetActorId = null) {
useAbility(actor, abilityId, targetActorId = null, groundPosition) {
const now = this.startedAt + this.tickNumber * ARENA_TICK_MS;
const ability = ABILITIES[abilityId];
if (!ability || !actor.alive || now < (actor.cooldowns.get(abilityId) ?? 0) || now < actor.lockoutUntil) return { accepted: false, reason: "ability-unavailable" };
if (actor.effects.get("control")?.endsAt > now) return { accepted: false, reason: "controlled" };
const target = this.actor(targetActorId ?? actor.targetActorId) ?? (abilityId === "defensive" ? actor : null);
if (!target || !target.alive) return { accepted: false, reason: "invalid-target" };
const friendly = target.team === actor.team;
if ((ability.healing && !friendly) || (!ability.healing && abilityId !== "defensive" && friendly)) return { accepted: false, reason: "invalid-target" };
if (ability.range > 0 && distance2d(actor, target) > ability.range) return { accepted: false, reason: "out-of-range" };
if (target !== actor && !this.hasLineOfSight(actor, target)) return { accepted: false, reason: "line-of-sight" };
if (ability.resource && actor.resource < ability.resource) return { accepted: false, reason: "resource" };
if (ability.castMs) {
actor.resource -= ability.resource ?? 0;
actor.casting = { abilityId, targetActorId: target.id, completesAt: now + ability.castMs };
return { accepted: true, casting: true };
}
actor.cooldowns.set(abilityId, now + ability.cooldownMs);
if (ability.damage) this.damage(target, ability.damage);
if (ability.lockoutMs) {
if (!target.casting) return { accepted: true, interrupted: false };
target.casting = null;
target.lockoutUntil = now + ability.lockoutMs;
}
if (ability.controlMs) {
const state = target.dr.get(ability.drCategory) ?? { applications: 0, endedAt: -Infinity };
const dr = arenaDrDuration(ability.controlMs, state.applications, now - state.endedAt);
if (dr.durationMs > 0) target.effects.set("control", { id: "control", endsAt: now + dr.durationMs, drCategory: ability.drCategory });
target.dr.set(ability.drCategory, { applications: dr.applications, endedAt: now + dr.durationMs });
}
if (ability.defensiveMs) actor.effects.set("defensive", { id: "defensive", endsAt: now + ability.defensiveMs });
return { accepted: true };
if (actor.characterKit) return useArenaCharacterAbility(this, actor, abilityId, targetActorId, now, groundPosition);
return { accepted: false, reason: "actor-unavailable" };
}
damage(target, amount) {
damage(target, amount, school = "physical") {
const now = this.startedAt + this.tickNumber * ARENA_TICK_MS;
const reduction = target.effects.get("defensive")?.endsAt > now ? 0.5 : 0;
target.health = Math.max(0, target.health - Math.round(amount * (1 - reduction)));
if (target.health <= 0) {
target.alive = false;
target.movement = [0, 0];
target.casting = null;
}
damageArenaCharacter(this, target, amount, school, now);
}
dynamicObjects() {
@@ -268,6 +295,8 @@ export class ArenaSimulation {
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());
}
@@ -277,30 +306,33 @@ export class ArenaSimulation {
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);
actor.resource = Math.min(actor.maxResource, actor.resource + 3 * deltaSeconds);
tickArenaCharacter(this, actor, now, deltaSeconds);
if (!actor.alive) continue;
if (actor.casting?.completesAt <= now) {
const cast = actor.casting;
actor.casting = null;
if (cast.abilityId === "heal") {
const target = this.actor(cast.targetActorId);
if (target?.alive && target.team === actor.team && distance2d(actor, target) <= ABILITIES.heal.range && this.hasLineOfSight(actor, target)) {
const elapsed = Math.max(0, now - this.activeStartedAt);
target.health = Math.min(target.maxHealth, target.health + applyArenaHealing(ABILITIES.heal.healing, elapsed));
}
}
}
if (!actor.casting && !(actor.effects.get("control")?.endsAt > now)) this.moveActor(actor, deltaSeconds);
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 speed = 7;
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;
actor.position[0] = Math.max(this.stage.bounds.min[0], Math.min(this.stage.bounds.max[0], nextX));
actor.position[2] = Math.max(this.stage.bounds.min[2], Math.min(this.stage.bounds.max[2], nextZ));
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) {
@@ -308,44 +340,86 @@ export class ArenaSimulation {
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 && this.hasLineOfSight(actor, entry));
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.role === "healer" && lowAlly.health < lowAlly.maxHealth * 0.86) {
actor.movement = [0, 0];
this.useAbility(actor, "heal", lowAlly.id);
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;
}
const castingHealer = enemies.find((entry) => entry.role === "healer" && entry.casting);
if (castingHealer && shouldArenaBotChooseCorrectly(profile.healerInterruptChance, profile.minimumErrorChance, this.random)) {
this.useAbility(actor, "interrupt", castingHealer.id);
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]];
}
if (actor.health < actor.maxHealth * 0.4 && shouldArenaBotChooseCorrectly(profile.correctDefensiveChance, profile.minimumErrorChance, this.random)) {
this.useAbility(actor, "defensive", actor.id);
return;
}
if (shouldArenaBotChooseCorrectly(profile.coordinatedCcChance * 0.25, profile.minimumErrorChance, this.random)) {
const healer = enemies.find((entry) => entry.role === "healer");
if (healer && this.useAbility(actor, "control", healer.id).accepted) return;
}
const distance = distance2d(actor, target);
if (distance > 4.5) {
const dx = target.position[0] - actor.position[0];
const dz = target.position[2] - actor.position[2];
const length = Math.max(0.001, Math.hypot(dx, dz));
actor.movement = [dx / length, dz / length];
if (distance <= 30) this.useAbility(actor, "ranged-attack", target.id);
} else {
actor.movement = [0, 0];
this.useAbility(actor, "strike", target.id);
this.useAbility(actor, "attack", target.id);
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) {
@@ -417,6 +491,16 @@ export class ArenaSimulation {
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,
@@ -424,11 +508,17 @@ export class ArenaSimulation {
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 ?? [],
};
}
}