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(), }; } }