Files
2026-09-17 09:13:15 -04:00

283 lines
21 KiB
TypeScript

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<string, number>();
const damaged = new Map<string, number>();
const previous = new Map<string, { health: number; maxHealth: number }>([...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<string, string | null>) {
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 };
}