Files
healer-man/server/arena-simulation.mjs
T
2026-09-05 21:27:30 -04:00

435 lines
20 KiB
JavaScript

import { arenaAiProfile, arenaDampening, arenaDrDuration, applyArenaHealing, shouldArenaBotChooseCorrectly } from "./arena-rules.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]);
}
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, arenaCenter) {
const offset = participant.role === "healer" ? -2 : teamIndex * 2;
const facing = Math.atan2(arenaCenter[0] - spawn[0], arenaCenter[2] - spawn[2]);
return {
id: participant.actorId,
accountId: participant.accountId ?? null,
characterId: participant.characterId ?? 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],
facing,
movement: [0, 0],
health: 1000,
maxHealth: 1000,
resource: 100,
maxResource: 100,
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];
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);
this.actors.set(actor.id, actor);
}
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) {
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;
return segmentIntersectsBox(left.position, right.position, object);
});
}
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" };
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 === "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.actors.has(intent.targetActorId) ? intent.targetActorId : null;
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) {
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 };
}
damage(target, amount) {
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;
}
}
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();
}
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);
actor.resource = Math.min(actor.maxResource, actor.resource + 3 * 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 && now >= actor.nextBotDecisionAt) this.botDecision(actor, now);
}
}
moveActor(actor, deltaSeconds) {
const speed = 7;
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));
}
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 && this.hasLineOfSight(actor, entry));
const target = enemies.sort((left, right) => left.health - right.health)[0];
if (!target) {
actor.movement = [0, 0];
return;
}
actor.targetActorId = target.id;
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);
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;
}
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);
}
}
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,
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.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(),
};
}
}