import { useCombatStore, PLAYER_AURA_ENTITY_ID, characterCombatDefender, abilityDamageSchool, type CombatState, type CharacterCombatSeed } from "../../src/game/combatStore"; import { usePartyStore } from "../../src/game/partyStore"; import { installCombatClock } from "../../src/game/combatClock"; import { registerCombatSpatialProvider } from "../../src/game/combatSpatial"; import { clearMobRuntimeRegistry, registerMobRuntime, getMobPosition } from "../../src/game/mobRuntimeRegistry"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, getPartyRuntimePosition } from "../../src/game/partyRuntimeRegistry"; import { targetingRulesForAbility } from "../../src/game/abilityRules"; import { resourceProfileForClass, learnedAbilityForBuild } from "../../src/game/abilityCatalog"; import { deriveCharacterStats } from "../../src/game/wotlkStats"; import { subscribeCombatPresentation } from "../../src/game/combatPresentation"; import { arenaDampening } from "../../server/arena-rules.mjs"; import { createDefaultAppearance } from "../../src/app/characterCatalog"; import { equipmentItemsForOwner, PLAYER_EQUIPMENT_OWNER_ID } from "../../src/game/equipment"; const PLAYER = PLAYER_AURA_ENTITY_ID; const combatDefaults = useCombatStore.getState(); const partyDefaults = usePartyStore.getState(); let presentation: any[] = []; subscribeCombatPresentation((message) => { if (message.kind === "event") presentation.push(message.event); }); const remap = (id: string | null | undefined, from: string, to: string) => id === from ? to : id; const mapAura = (aura: any, from: string, to: string) => ({ ...aura, sourceId: remap(aura.sourceId, from, to), targetId: remap(aura.targetId, from, to) }); const mapEffect = (effect: any, from: string, to: string) => ({ ...effect, sourceActorId: remap(effect.sourceActorId, from, to), targetId: remap(effect.targetId, from, to) }); export function initializeArenaCharacter(actor: any, now: number) { if (actor.combat) return; const kit = actor.characterKit; const seed: CharacterCombatSeed = kit?.seed ?? { classId: kit?.classId ?? (actor.role === "healer" ? "priest" : "warrior"), level: kit?.level ?? 80, actionBindings: kit?.actionBindings }; const restore = installCombatClock(() => now); try { usePartyStore.setState({ ...partyDefaults, members: [], playerRole: actor.role === "healer" ? "healer" : "damage" }, true); useCombatStore.setState(combatDefaults, true); useCombatStore.getState().initializeCharacter(seed); actor.combat = useCombatStore.getState(); const state = actor.combat; actor.avatar = { classId: state.classId, raceId: seed.raceId ?? (actor.team === 1 ? "orc" : "human"), gender: (seed as any).gender ?? "male", appearance: (seed as any).appearance ?? createDefaultAppearance() }; actor.visualEquipment = equipmentItemsForOwner(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID); const abilities = state.abilities.filter((ability: any) => !ability.passive) .map((ability: any) => learnedAbilityForBuild(ability, state.level, state.talentRanks)) .filter((ability: any) => ability !== null) .map((ability: any) => ({ ...ability, targeting: targetingRulesForAbility(ability) })); actor.characterKit = { ...kit, seed, classId: state.classId, level: state.level, resource: { ...resourceProfileForClass(state.classId), maximum: state.maxResource, initial: state.resource }, abilities: Object.fromEntries(abilities.map((ability: any) => [ability.id, ability])), actionBindings: state.actionBindings }; actor.health = state.health; actor.maxHealth = state.maxHealth; actor.resource = state.resource; actor.maxResource = state.maxResource; actor.auras = state.auras.map((aura: any) => mapAura(aura, PLAYER, actor.id)); actor.statuses = []; } finally { restore(); } } export function arenaKitFromSeed(seed: CharacterCombatSeed) { const actor: any = { id: "kit", role: seed.role ?? "damage", characterKit: { seed } }; initializeArenaCharacter(actor, 0); return { ...actor.characterKit, stats: { ...deriveCharacterStats(actor.combat.classId, actor.combat.level, actor.combat.gearStats, actor.combat.raceId, {}, actor.combat.secondaryClassId), health: actor.maxHealth } }; } function partyMember(actor: any) { const state = actor.combat; return { id: actor.id, name: actor.name, classId: state.classId, raceId: state.raceId, gender: "male", appearance: {}, specialization: "", specializationId: state.specializationId, talentTreeId: "", talentRanks: state.talentRanks, role: actor.role === "healer" ? "healer" : "damage", level: state.level, health: actor.health, maxHealth: actor.maxHealth, resourceType: actor.characterKit.resource.type, resourceName: state.resourceName, resource: actor.resource, maxResource: actor.maxResource, resourcePools: state.resourcePools, lastResourceSpentAt: state.lastResourceSpentAt, cooldowns: state.cooldowns, globalCooldownEndsAt: state.globalCooldownEndsAt, activeCast: state.activeCast, activeCastTargetId: actor.targetActorId, schoolLockedUntil: state.schoolLockedUntil, runes: state.deathKnightRunes, gearStats: state.gearStats, status: actor.alive ? "ready" : "down", nextActionAt: 0, tauntReadyAt: 0, controlledUntil: state.controlledUntil, controlMechanic: state.controlMechanic }; } function enter(simulation: any, actor: any, now: number, ticking: boolean) { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); presentation = []; const allies = [...simulation.actors.values()].filter((other: any) => other !== actor && other.team === actor.team); const friendlyPets = allies.flatMap((owner: any) => owner.combat.summons.map((summon: any) => ({ owner, summon }))); const members = [...allies.map(partyMember), ...friendlyPets.map(({ owner, summon }: any) => ({ ...partyMember(owner), id: summon.id, name: summon.name, health: summon.health, maxHealth: summon.maxHealth, resource: 0, maxResource: 0, activeCast: null, status: summon.health > 0 ? "ready" : "down" }))]; usePartyStore.setState({ ...partyDefaults, members, playerRole: actor.role === "healer" ? "healer" : "damage", selectedMemberId: allies.some((other: any) => other.id === actor.targetActorId) ? actor.targetActorId : null, selfSelected: actor.targetActorId === actor.id }, true); const effects = simulation.combatEffects ?? []; const auras = [...simulation.actors.values()].flatMap((other: any) => other.auras).concat(simulation.summonAuras ?? []).map((aura: any) => mapAura(aura, actor.id, PLAYER)); useCombatStore.setState({ ...actor.combat, health: actor.health, maxHealth: actor.maxHealth, resource: actor.resource, maxResource: actor.maxResource, playerPosition: [...actor.position], selectedTargetId: simulation.targetActor(actor.targetActorId)?.team !== actor.team ? actor.targetActorId : null, selectedFriendlyActorId: actor.targetActorId === actor.id ? PLAYER : simulation.targetActor(actor.targetActorId)?.team === actor.team ? actor.targetActorId : null, auras, effects: effects.filter((effect: any) => !ticking || effect.sourceActorId === actor.id).map((effect: any) => mapEffect(effect, actor.id, PLAYER)), mobs: {}, actors: {}, combatEvents: [], settings: { ...actor.combat.settings, autoTargetNearest: false } }, true); for (const other of simulation.actors.values()) { if (other === actor) continue; if (other.team === actor.team) { registerPartyRuntimePosition(other.id, other.position); continue; } registerMobRuntime(other.id, other.position, other.facing); useCombatStore.getState().registerMob(other.id, { name: other.name, level: other.combat.level, maxHealth: other.maxHealth, xpReward: 0 }); const stats = deriveCharacterStats(other.combat.classId, other.combat.level, other.combat.gearStats, other.combat.raceId, {}, other.combat.secondaryClassId); useCombatStore.setState((state) => ({ mobs: { ...state.mobs, [other.id]: { ...state.mobs[other.id], health: other.health, maxHealth: other.maxHealth, dead: !other.alive, armor: stats.armor, maxMana: other.maxResource, characterDefender: characterCombatDefender({ ...other.combat, auras: other.auras.map((aura: any) => mapAura(aura, other.id, PLAYER)) }), characterShield: other.combat.shield, characterResources: other.combat.resourcePools, statuses: other.statuses ?? [], controlDiminishingReturns: other.controlDiminishingReturns ?? {}, mechanicImmunities: other.auras.flatMap((aura: any) => aura.definition.controlImmunities ?? []), activeCast: other.combat.activeCast ? { ...other.combat.activeCast, attackId: other.combat.activeCast.abilityId, interruptible: true } : null, schoolLockedUntil: other.combat.schoolLockedUntil, engaged: true, experienceGranted: true, resistances: { arcane: stats.arcaneResistance, fire: stats.fireResistance, frost: stats.frostResistance, nature: stats.natureResistance, shadow: stats.shadowResistance, holy: 0 }, } } })); } for (const owner of simulation.actors.values()) for (const summon of owner.combat.summons) { if (owner.team === actor.team) { if (owner !== actor) registerPartyRuntimePosition(summon.id, summon.position); continue; } registerMobRuntime(summon.id, summon.position, owner.facing); useCombatStore.getState().registerMob(summon.id, { name: summon.name, level: owner.combat.level, maxHealth: summon.maxHealth, xpReward: 0 }); useCombatStore.setState((state) => ({ mobs: { ...state.mobs, [summon.id]: { ...state.mobs[summon.id], health: summon.health, dead: summon.health <= 0, experienceGranted: true } } })); } useCombatStore.getState().synchronizeActors(); const restoreClock = installCombatClock(() => now); const restoreSpatial = registerCombatSpatialProvider({ actor: (id) => { const target = id === PLAYER ? actor : simulation.targetActor(id); return target ? { id, position: target.position, yaw: target.facing } : null; }, hasLineOfSight: (from, to) => simulation.hasLineOfSight({ position: from }, { position: to }), projectGroundPoint: (requested, range, origin = actor.position) => simulation.projectGroundPoint(origin, requested, range), }); return () => { restoreSpatial(); restoreClock(); }; } function commit(simulation: any, actor: any, now: number, ticking: boolean) { const state = useCombatStore.getState(); const dampening = simulation.activeStartedAt == null ? 0 : arenaDampening(now - simulation.activeStartedAt); const healed = new Map(); const damaged = new Map(); const previous = new Map([...simulation.actors.values()].map((other: any) => [other.id, { health: other.health, maxHealth: other.maxHealth }])); for (const event of state.combatEvents) if (event.kind === "healing") { const id = remap(event.targetActorId, PLAYER, actor.id)!; healed.set(id, (healed.get(id) ?? 0) + event.effectiveAmount); } for (const event of state.combatEvents) if (event.kind === "damage") { const id = remap(event.targetActorId, PLAYER, actor.id)!; damaged.set(id, (damaged.get(id) ?? 0) + event.effectiveAmount); } const healthAfterHealing = (id: string, health: number, maxHealth = previous.get(id)?.maxHealth ?? health) => { const before = previous.get(id); const inferred = before ? Math.max(0, health - before.health + (damaged.get(id) ?? 0) - Math.max(0, maxHealth - before.maxHealth)) : 0; return Math.max(0, health - Math.round(Math.max(healed.get(id) ?? 0, inferred) * dampening)); }; actor.combat = { ...state, mobs: {}, actors: {}, effects: [], auras: [], health: actor.health <= 0 ? 0 : healthAfterHealing(actor.id, state.health, state.maxHealth) }; actor.health = actor.combat.health; actor.maxHealth = state.maxHealth; actor.resource = state.resource; actor.maxResource = state.maxResource; actor.position = state.playerPosition.some((value, axis) => value !== actor.position[axis]) ? simulation.resolvePosition(actor, state.playerPosition, true) : [...actor.position]; actor.cooldowns = new Map(Object.entries(state.cooldowns)); actor.gcdUntil = state.globalCooldownEndsAt; actor.casting = state.activeCast ? { ...state.activeCast, targetActorId: remap(state.activeCast.targetId, PLAYER, actor.id) } : null; actor.statuses = (actor.statuses ?? []).filter((status: any) => status.endsAt > now && !(state.controlledUntil === 0 && ["stun", "fear", "sleep", "freeze", "knockdown"].includes(status.kind))); for (const member of usePartyStore.getState().members) { const other = simulation.actor(member.id); if (!other) { for (const owner of simulation.actors.values()) if (owner !== actor && owner.combat.summons.some((summon: any) => summon.id === member.id)) { owner.combat = { ...owner.combat, summons: owner.combat.summons.map((summon: any) => summon.id === member.id ? { ...summon, health: healthAfterHealing(member.id, member.health) } : summon) }; } continue; } other.health = healthAfterHealing(other.id, member.health, member.maxHealth); other.maxHealth = member.maxHealth; other.resource = member.resource; const moved = getPartyRuntimePosition(member.id); if (moved && moved.some((value, axis) => value !== other.position[axis])) other.position = simulation.resolvePosition(other, moved, true); other.combat = { ...other.combat, resourcePools: member.resourcePools, schoolLockedUntil: member.schoolLockedUntil, controlledUntil: member.controlledUntil, controlMechanic: member.controlMechanic, deathKnightRunes: member.runes }; } for (const mob of Object.values(state.mobs)) { const other = simulation.actor(mob.id); if (!other) { for (const owner of simulation.actors.values()) if (owner !== actor && owner.combat.summons.some((summon: any) => summon.id === mob.id)) { owner.combat = { ...owner.combat, summons: owner.combat.summons.map((summon: any) => summon.id === mob.id ? { ...summon, health: mob.health } : summon).filter((summon: any) => summon.health > 0) }; } continue; } other.health = mob.health; other.statuses = mob.statuses; other.controlDiminishingReturns = mob.controlDiminishingReturns; const moved = getMobPosition(mob.id); if (moved && moved.some((value, axis) => value !== other.position[axis])) other.position = simulation.resolvePosition(other, moved, true); const control = mob.statuses.filter((status) => ["stun", "fear", "confuse", "blind", "charm", "freeze", "knockdown", "sleep"].includes(status.kind)).sort((a, b) => b.endsAt - a.endsAt)[0]; other.combat = { ...other.combat, controlledUntil: control?.endsAt ?? 0, controlMechanic: control?.kind ?? null, shield: mob.characterShield ?? other.combat.shield, resourcePools: mob.characterResources ?? other.combat.resourcePools, schoolLockedUntil: mob.schoolLockedUntil, activeCast: mob.activeCast ? { ...other.combat.activeCast, ...mob.activeCast } : null }; other.resource = other.combat.resourcePools[other.characterKit.resource.type] ?? other.resource; other.casting = other.combat.activeCast; } const worldAuras = state.auras.map((aura) => mapAura(aura, PLAYER, actor.id)); simulation.summonAuras = worldAuras.filter((aura) => !simulation.actors.has(aura.targetId)); for (const other of simulation.actors.values()) { other.auras = worldAuras.filter((aura) => aura.targetId === other.id); other.statuses = (other.statuses ?? []).filter((status: any) => status.endsAt > now && (!status.sourceAbilityId.startsWith("aura:") || other.auras.some((aura: any) => `aura:${aura.definition.id}` === status.sourceAbilityId)) && !other.auras.some((aura: any) => aura.definition.controlImmunities?.includes(status.kind))); const control = other.statuses.filter((status: any) => ["stun", "fear", "confuse", "blind", "charm", "freeze", "knockdown", "sleep"].includes(status.kind)).sort((a: any, b: any) => b.endsAt - a.endsAt)[0]; other.combat = { ...other.combat, controlledUntil: control?.endsAt ?? 0, controlMechanic: control?.kind ?? null }; other.alive = other.health > 0; if (!other.alive) { other.movement = [0, 0]; other.casting = null; other.combat = { ...other.combat, activeCast: null }; } } simulation.combatEffects = [ ...(ticking ? (simulation.combatEffects ?? []).filter((effect: any) => effect.sourceActorId !== actor.id) : []), ...state.effects.map((effect) => mapEffect(effect, PLAYER, actor.id)), ]; simulation.combatPresentation = [...(simulation.combatPresentation ?? []), ...presentation.map((event) => ({ ...event, sourceActorId: remap(event.sourceActorId, PLAYER, actor.id), targetActorId: remap(event.targetActorId, PLAYER, actor.id) }))].slice(-80); simulation.combatEvents = [...(simulation.combatEvents ?? []), ...state.combatEvents.map((event) => ({ ...event, sourceActorId: remap(event.sourceActorId, PLAYER, actor.id), targetActorId: remap(event.targetActorId, PLAYER, actor.id) }))].slice(-100); } export function useArenaCharacterAbility(simulation: any, actor: any, abilityId: string, targetActorId: string | null, now: number, groundPosition?: readonly number[]) { const ability = actor.characterKit.abilities[abilityId]; if (ability && (actor.statuses ?? []).some((status: any) => status.kind === "silence" && status.endsAt > now) && (ability.castRules?.school ?? abilityDamageSchool(actor.combat.classId, ability)) !== "physical") return { accepted: false, reason: "controlled", message: "You are silenced." }; const restore = enter(simulation, actor, now, false); try { const requestedTarget = targetActorId ?? actor.targetActorId; if (requestedTarget === actor.id) { useCombatStore.setState({ selectedFriendlyActorId: PLAYER, selectedTargetId: null }); usePartyStore.setState({ selfSelected: true, selectedMemberId: null }); } const result = useCombatStore.getState().castAbility(abilityId, { now, origin: actor.position, targetActorId: requestedTarget === actor.id ? null : requestedTarget, groundPosition: groundPosition as any }); const message = useCombatStore.getState().feedback?.message; commit(simulation, actor, now, false); if (result.ok) { actor.lastAbilityId = abilityId; actor.abilityRevision += 1; } return { accepted: result.ok, reason: result.reason, message, casting: Boolean(actor.casting) }; } finally { restore(); } } export function tickArenaCharacter(simulation: any, actor: any, now: number, delta: number) { const restore = enter(simulation, actor, now, true); try { if (actor.casting && !actor.casting.canCastWhileMoving && Math.hypot(...actor.movement) > .01) useCombatStore.getState().cancelCast("movement"); useCombatStore.getState().tick(delta, now); commit(simulation, actor, now, true); } finally { restore(); } } export function arenaCharacterSnapshot(actor: any, now: number) { const state: CombatState = actor.combat; return { resourcePools: state.resourcePools, maxResourcePools: state.maxResourcePools, runes: state.deathKnightRunes, talentRanks: state.talentRanks, gearStats: state.gearStats, specializationId: state.specializationId, comboPoints: state.comboPoints, comboTargetId: state.comboTargetId, shield: state.shield, activeCast: state.activeCast, playerAnimationEvent: state.playerAnimationEvent, summons: state.summons.map((summon) => ({ ...summon, ownerActorId: actor.id })), areaEffects: state.areaEffects.map((area) => ({ ...area, sourceActorId: actor.id })), controlledUntil: state.controlledUntil, schoolLockedUntil: state.schoolLockedUntil, now }; } export function damageArenaCharacter(simulation: any, actor: any, amount: number, school: any, now: number) { const restore = enter(simulation, actor, now, false); try { useCombatStore.getState().damagePlayer(amount, school); commit(simulation, actor, now, false); } finally { restore(); } } export function commandArenaCharacter(simulation: any, actor: any, command: string, now: number) { if (command === "cancel-cast") { const restore = enter(simulation, actor, now, false); try { useCombatStore.getState().cancelCast(); commit(simulation, actor, now, false); } finally { restore(); } } if (command === "attack" || command === "defend" || command === "stop" || command === "recall") { const targetId = command === "attack" ? actor.targetActorId : null; actor.combat = { ...actor.combat, autoAttackTargetId: targetId, summons: actor.combat.summons.map((summon: any) => ({ ...summon, targetId, ...(command === "recall" ? { position: [...actor.position] } : {}), nextActionAt: command === "stop" ? Number.MAX_SAFE_INTEGER : now })) }; } } export function bindArenaCharacter(actor: any, bindings: Record) { const next = Object.fromEntries(Object.keys(actor.characterKit.actionBindings).map((id) => [id, bindings[id] && actor.characterKit.abilities[bindings[id]!] ? bindings[id] : null])); actor.characterKit = { ...actor.characterKit, actionBindings: next }; actor.combat = { ...actor.combat, actionBindings: next }; }