283 lines
21 KiB
TypeScript
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 };
|
|
}
|