435 lines
20 KiB
JavaScript
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(),
|
|
};
|
|
}
|
|
}
|