Files
healer-man/src/game/partyRuntime.ts
T
2026-09-12 21:14:41 -04:00

1397 lines
55 KiB
TypeScript

import {
abilitiesForClass,
abilityAtLevel,
abilityById,
isAbilityUnlocked,
resourceProfileForClass,
triggeredAbilityBySpellId,
type AbilityDefinition,
type AbilityEffect,
type ResourceType,
} from "./abilityCatalog";
import {
canAbilityCastWhileMoving,
targetingRulesForAbility,
} from "./abilityRules";
import type { MobCombatState } from "./combatStore";
import { useCombatStore } from "./combatStore";
import { memberFromCompanionActor } from "./combatActorAuthority";
import type { MobVector3 } from "./mobPopulation";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { getMobPosition } from "./mobRuntimeRegistry";
import {
getPartyRuntimePosition,
partyRuntimeIsRejoining,
partyRuntimeCenter,
partyRuntimePositionAlongAttackCorridor,
partyRuntimeTargetVisible,
updatePartyRuntimePosition,
} from "./partyRuntimeRegistry";
import { usePartyStore, type PartyCommand, type PartyMember } from "./partyStore";
import { useGameStore } from "./store";
import { useManastormStore } from "./manastormStore";
import { activeDungeonCompletedObjectiveIds } from "./dungeonEncounterRuntime";
import { PLAYER_AGGRO_ID, type ThreatSource } from "./aggro";
import type { ActiveAura, DispelCategory } from "./combatAuras";
import {
equipmentAttackPower,
hastedDuration,
ratingPercent,
type WotlkAttackKind,
} from "./wotlkStats";
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
import {
emitCombatPresentation,
presentationDeliveryForAbility,
presentationSchoolForAbility,
type CombatPresentationDelivery,
type CombatPresentationSchool,
} from "./combatPresentation";
import {
choosePartyAbility,
commitPartyAbilityUse,
partyAbilityAmount,
} from "./partyAbilityAi";
import { normalizeActorResourcePool } from "./combatActors";
import { advanceCombatResourcePool } from "./combatResources";
import {
combatHasLineOfSight,
combatSpatialActor,
isPositionInFacingArc,
isSourceBehindTarget,
} from "./combatSpatial";
const OUT_OF_COMBAT_REGEN_FRACTION = 0.06;
const ATTACK_ACQUISITION_RANGE = 30;
const HEALER_ACTION_INTERVAL_MS = 2_000;
const TANK_TAUNT_RANGE = 30;
const TANK_TAUNT_COOLDOWN_MS = 8_000;
const TANK_TAUNT_DURATION_MS = 3_000;
let lastAdvanceAt = 0;
let nextRegenAt = 0;
export interface PartyPresentationIdentity {
readonly spellId: number;
readonly name: string;
readonly school: CombatPresentationSchool;
readonly delivery: CombatPresentationDelivery;
}
function partyAttackPresentation(member: PartyMember): PartyPresentationIdentity {
if (!partyUsesRangedAttacks(member)) {
const spellId = member.classId === "rogue" ? 1752
: member.classId === "death-knight" ? 45462
: member.classId === "paladin" ? 20271
: 78;
return { spellId, name: "Weapon Attack", school: "physical", delivery: "melee" };
}
if (member.classId === "hunter") return { spellId: 56641, name: "Steady Shot", school: "physical", delivery: "projectile" };
if (member.classId === "mage") return { spellId: 116, name: "Frostbolt", school: "frost", delivery: "projectile" };
if (member.classId === "warlock") return { spellId: 686, name: "Shadow Bolt", school: "shadow", delivery: "projectile" };
if (member.classId === "priest") return { spellId: 585, name: "Smite", school: "holy", delivery: "projectile" };
if (member.classId === "druid") return { spellId: 5176, name: "Wrath", school: "nature", delivery: "projectile" };
return { spellId: 403, name: "Lightning Bolt", school: "nature", delivery: "projectile" };
}
function partyHealPresentation(member: PartyMember): PartyPresentationIdentity {
if (member.classId === "paladin") return { spellId: 635, name: "Holy Light", school: "holy", delivery: "projectile" };
if (member.classId === "shaman") return { spellId: 331, name: "Healing Wave", school: "nature", delivery: "projectile" };
if (member.classId === "druid") return { spellId: 5185, name: "Healing Touch", school: "nature", delivery: "projectile" };
return { spellId: 2061, name: "Flash Heal", school: "holy", delivery: "projectile" };
}
function partyTauntSpellId(member: PartyMember): number {
if (member.classId === "paladin") return 62124;
if (member.classId === "death-knight") return 56222;
if (member.classId === "druid") return 6795;
return 355;
}
export interface PartyBossObjective {
readonly id: string;
readonly name: string;
readonly position: MobVector3;
}
export function activePartyBossObjectives(): readonly PartyBossObjective[] {
if (useGameStore.getState().gameMode === "manastorm") {
const encounter = useManastormStore.getState().currentEncounter;
return encounter ? [{
id: encounter.bossRuntimeId,
name: encounter.bossName,
position: encounter.bossPosition,
}] : [];
}
const game = useGameStore.getState();
const completed = new Set(activeDungeonCompletedObjectiveIds(game.activeDungeonId));
const mobs = useCombatStore.getState().mobs;
const objectives = requireDungeonDefinition(game.activeDungeonId).objectiveOrder
.filter((objective) => !objective.optional && !completed.has(objective.id));
const wailingPhase = game.activeDungeonId === "wailing-caverns"
? useWailingEncounterStore.getState().phase
: null;
return objectives.filter((objective) => (
!objective.eventOnly
|| Boolean(mobs[objective.id])
|| (objective.id === "mutanus-spawn"
&& (wailingPhase === "mutanus" || wailingPhase === "complete"))
));
}
export function resetPartyRuntimeClock(): void {
lastAdvanceAt = 0;
nextRegenAt = 0;
}
export function activePartyTargetId(
mobs: Readonly<Record<string, MobCombatState>>,
selectedTargetId: string | null,
): string | null {
const selected = selectedTargetId ? mobs[selectedTargetId] : null;
if (selected?.engaged && !selected.dead) return selected.id;
return Object.values(mobs)
.filter((mob) => mob.engaged && !mob.dead)
.sort((left, right) => Number(right.boss) - Number(left.boss) || left.id.localeCompare(right.id))[0]?.id ?? null;
}
export function partyAttackDamage(
member: Pick<PartyMember, "level" | "role">
& Partial<Pick<PartyMember, "classId" | "specialization" | "gearStats">>,
): number {
const coefficient = member.role === "tank" ? 0.85 : member.role === "healer" ? 0.55 : 1.25;
const gear = member.gearStats;
if (!gear) return Math.max(1, Math.round(3 + member.level * coefficient));
const classId = member.classId ?? "warrior";
const kind: WotlkAttackKind = partyUsesRangedAttacks({
classId,
specialization: member.specialization ?? "",
role: member.role,
})
? classId === "hunter" ? "ranged" : "spell"
: "melee";
const weapon = kind === "ranged" ? gear.rangedWeapon : kind === "melee" ? gear.meleeWeapon : null;
const equipmentPower = kind === "spell"
? equipmentAttackPower(classId, gear, kind)
: equipmentAttackPower(classId, gear, kind) / 14 * ((weapon?.speedMs ?? 2400) / 1_000)
+ (weapon ? (weapon.damageMin + weapon.damageMax) / 2 * 0.35 : 0);
const crit = ratingPercent(
gear.criticalStrikeRating,
kind === "spell" ? "spellCrit" : kind === "ranged" ? "rangedCrit" : "meleeCrit",
member.level,
classId,
);
const hit = ratingPercent(
gear.hitRating,
kind === "spell" ? "spellHit" : kind === "ranged" ? "rangedHit" : "meleeHit",
member.level,
classId,
);
return Math.max(
1,
Math.round((3 + member.level * coefficient + equipmentPower) * (1 + crit / 100) * (1 + hit / 100)),
);
}
export function partyUsesRangedAttacks(
member: Pick<PartyMember, "classId" | "specialization" | "role">,
): boolean {
if (member.role === "tank") return false;
if (member.role === "healer") return true;
return ["hunter", "mage", "priest", "warlock"].includes(member.classId)
|| member.specialization === "Elemental"
|| member.specialization === "Balance";
}
export function partyAttackRange(member: Pick<PartyMember, "classId" | "specialization" | "role">): number {
if (member.role === "tank") return 3.8;
if (partyUsesRangedAttacks(member)) return 19;
return 4.2;
}
export interface PartyCombatRangeBand {
readonly minimum: number;
readonly preferred: number;
readonly maximum: number;
}
const RANGED_COMBAT_RANGE_BAND: PartyCombatRangeBand = Object.freeze({
minimum: 10,
preferred: 14.5,
maximum: 18.1,
});
const TANK_COMBAT_RANGE_BAND: PartyCombatRangeBand = Object.freeze({
minimum: 0,
preferred: 2.9,
maximum: 3.15,
});
const MELEE_DAMAGE_COMBAT_RANGE_BAND: PartyCombatRangeBand = Object.freeze({
minimum: 0,
preferred: 3.3,
maximum: 3.55,
});
/**
* Movement uses a narrower band than damage validation. Ranged attackers keep
* enough room to read as ranged while melee attackers stay close to a fleeing
* target instead of treating the tank's position as their own.
*/
export function partyCombatRangeBand(
member: Pick<PartyMember, "classId" | "specialization" | "role">,
): PartyCombatRangeBand {
if (partyUsesRangedAttacks(member)) return RANGED_COMBAT_RANGE_BAND;
return member.role === "tank" ? TANK_COMBAT_RANGE_BAND : MELEE_DAMAGE_COMBAT_RANGE_BAND;
}
export function nextPartyBossObjective(
mobs: Readonly<Record<string, MobCombatState>>,
): PartyBossObjective | null {
return activePartyBossObjectives().find((objective) => !mobs[objective.id]?.dead) ?? null;
}
function distanceSquared(left: MobVector3, right: MobVector3): number {
const dx = left[0] - right[0];
const dy = left[1] - right[1];
const dz = left[2] - right[2];
return dx * dx + dy * dy + dz * dz;
}
function livingTargetInRange(
mob: MobCombatState | null | undefined,
origin: MobVector3,
maximumRange: number,
requireAttackCorridor = false,
): mob is MobCombatState {
if (!mob || mob.dead) return false;
const position = getMobPosition(mob.id);
return Boolean(
position
&& distanceSquared(position, origin) <= maximumRange * maximumRange
&& (!requireAttackCorridor || partyRuntimePositionAlongAttackCorridor(position)),
);
}
export function partyCommandTargetId(
command: PartyCommand,
mobs: Readonly<Record<string, MobCombatState>>,
selectedTargetId: string | null,
currentTargetId: string | null,
origin: MobVector3,
acquisitionRange = ATTACK_ACQUISITION_RANGE,
): string | null {
if (command === "stop") return null;
const engaged = activePartyTargetId(mobs, selectedTargetId);
if (engaged) return engaged;
if (command === "defend") return null;
const selected = selectedTargetId ? mobs[selectedTargetId] : null;
if (livingTargetInRange(selected, origin, acquisitionRange * 2, true)) return selected.id;
const current = currentTargetId ? mobs[currentTargetId] : null;
// currentTargetId was already authorized while it intersected the objective
// corridor. Keep pursuing it while its mob-specific route is active.
if (livingTargetInRange(current, origin, acquisitionRange * 1.5)) return current.id;
return Object.values(mobs)
.filter((mob) => livingTargetInRange(mob, origin, acquisitionRange, true))
.sort((left, right) => {
// Clear adds before committing to the boss at the end of a pull.
const kindOrder = Number(left.boss) - Number(right.boss);
if (kindOrder) return kindOrder;
const leftPosition = getMobPosition(left.id)!;
const rightPosition = getMobPosition(right.id)!;
return distanceSquared(leftPosition, origin) - distanceSquared(rightPosition, origin)
|| left.id.localeCompare(right.id);
})[0]?.id ?? null;
}
export interface HealerSupportResult {
readonly members: readonly PartyMember[];
readonly playerHealing: number;
readonly playerResurrectionPercent: number;
readonly targetId: string | null;
readonly effectiveHealing: number;
readonly sourceMemberId: string | null;
readonly action: "heal" | "resurrection" | "dispel" | null;
readonly presentation: PartyPresentationIdentity | null;
readonly dispelCategories: readonly DispelCategory[];
readonly dispelMaximum: number;
}
function partyHealAmount(level: number, spellPower = 0): number {
return Math.max(10, Math.round(12 + Math.max(1, level) * 2.6 + Math.max(0, spellPower) * 1.25));
}
function partyResurrectionAbility(member: PartyMember): AbilityDefinition | null {
const definition = abilitiesForClass(member.classId)
.filter((ability) => !ability.passive && (ability.target === "friendly" || ability.target === "any"))
.filter((ability) => isAbilityUnlocked(ability, member.level))
.find((ability) => ability.effects.some((effect) => effect.kind === "resurrection"));
return definition ? abilityAtLevel(definition, member.level) : null;
}
function partyResurrectionPresentation(
member: PartyMember,
ability: AbilityDefinition,
): PartyPresentationIdentity {
const nature = member.classId === "druid"
|| member.classId === "shaman"
|| member.classId === "rom-druid";
return {
spellId: ability.dbcSpellId,
name: ability.name,
school: nature ? "nature" : "holy",
delivery: "projectile",
};
}
function noSupport(members: readonly PartyMember[]): HealerSupportResult {
return {
members,
playerHealing: 0,
playerResurrectionPercent: 0,
targetId: null,
effectiveHealing: 0,
sourceMemberId: null,
action: null,
presentation: null,
dispelCategories: [],
dispelMaximum: 0,
};
}
/**
* Gives the party one legal support action at a time. Any living class with an
* unlocked resurrection can raise a fallen target; ordinary healing remains
* the dedicated healer's job. The runtime caller applies player-side changes
* to the combat store after this pure party-state update.
*/
export function applyHealerSupport(
members: readonly PartyMember[],
playerHealth: number,
playerMaxHealth: number,
now: number,
auras: readonly ActiveAura[] = [],
): HealerSupportResult {
const resurrector = members
.map((member, index) => ({ member, index, ability: partyResurrectionAbility(member) }))
.filter((candidate): candidate is typeof candidate & { ability: AbilityDefinition } => (
candidate.member.health > 0
&& candidate.member.controlledUntil <= now
&& candidate.member.nextActionAt <= now
&& candidate.ability !== null
))
.sort((left, right) => (
Number(right.member.role === "healer") - Number(left.member.role === "healer")
|| left.member.id.localeCompare(right.member.id)
))[0];
const fallen = [
...(playerHealth <= 0 && playerMaxHealth > 0
? [{ id: "__player__", index: -1, maxHealth: playerMaxHealth, rolePriority: -1 }]
: []),
...members
.map((member, index) => ({
id: member.id,
index,
maxHealth: member.maxHealth,
rolePriority: member.role === "healer" ? 0 : member.role === "tank" ? 1 : 2,
}))
.filter((candidate) => members[candidate.index].health <= 0),
].sort((left, right) => left.rolePriority - right.rolePriority || left.id.localeCompare(right.id));
const fallenTarget = fallen[0];
if (resurrector && fallenTarget) {
const resurrection = resurrector.ability.effects.find((effect) => effect.kind === "resurrection");
if (resurrection?.kind === "resurrection") {
const restored = Math.max(1, Math.round(fallenTarget.maxHealth * resurrection.percentMaxHealth));
const next = [...members];
if (fallenTarget.index >= 0) {
const target = next[fallenTarget.index];
next[fallenTarget.index] = {
...target,
health: restored,
status: "ready",
controlledUntil: 0,
controlMechanic: null,
};
}
const caster = next[resurrector.index];
const committed = commitPartyAbilityUse(caster, resurrector.ability, now) ?? caster;
next[resurrector.index] = {
...committed,
status: "healing",
nextActionAt: now + Math.max(
HEALER_ACTION_INTERVAL_MS,
resurrector.ability.castTimeMs,
resurrector.ability.gcdMs,
),
};
return {
members: next,
playerHealing: 0,
playerResurrectionPercent: fallenTarget.index < 0 ? resurrection.percentMaxHealth : 0,
targetId: fallenTarget.id,
effectiveHealing: restored,
sourceMemberId: resurrector.member.id,
action: "resurrection",
presentation: partyResurrectionPresentation(resurrector.member, resurrector.ability),
dispelCategories: [],
dispelMaximum: 0,
};
}
}
const healerIndex = members.findIndex((member) => member.role === "healer" && member.health > 0);
if (healerIndex < 0) return noSupport(members);
const healer = members[healerIndex];
if (now < healer.nextActionAt) return noSupport(members);
const dispelTarget = [
...(playerHealth > 0 ? [PLAYER_AGGRO_ID] : []),
...members.filter((member) => member.health > 0).map((member) => member.id),
].find((id) => auras.some((aura) => (
aura.targetId === id && aura.definition.disposition === "debuff" && aura.definition.dispelCategory
)));
if (dispelTarget) {
const dispelAbility = choosePartyAbility(healer, { now, target: null, allyNeedsDispel: true });
const dispel = dispelAbility?.effects.find((effect) => effect.kind === "dispel" && effect.relationship !== "hostile");
if (dispelAbility && dispel?.kind === "dispel") {
const next = [...members];
const committed = commitPartyAbilityUse(healer, dispelAbility, now) ?? healer;
next[healerIndex] = {
...committed,
status: "healing",
nextActionAt: now + Math.max(HEALER_ACTION_INTERVAL_MS, dispelAbility.castTimeMs, dispelAbility.gcdMs),
};
return {
members: next,
playerHealing: 0,
playerResurrectionPercent: 0,
targetId: dispelTarget,
effectiveHealing: 0,
sourceMemberId: healer.id,
action: "dispel",
presentation: {
spellId: dispelAbility.dbcSpellId,
name: dispelAbility.name,
school: presentationSchoolForAbility(dispelAbility),
delivery: presentationDeliveryForAbility(dispelAbility),
},
dispelCategories: dispel.categories,
dispelMaximum: dispel.maxCount,
};
}
}
const candidates = members
.map((member, index) => ({
id: member.id,
index,
health: member.health,
maxHealth: member.maxHealth,
}))
.filter((candidate) => candidate.health > 0 && candidate.health < candidate.maxHealth);
if (playerHealth > 0 && playerHealth < playerMaxHealth) {
candidates.push({ id: "__player__", index: -1, health: playerHealth, maxHealth: playerMaxHealth });
}
candidates.sort((left, right) => (
left.health / Math.max(1, left.maxHealth) - right.health / Math.max(1, right.maxHealth)
|| (right.maxHealth - right.health) - (left.maxHealth - left.health)
|| left.id.localeCompare(right.id)
));
const target = candidates[0];
if (!target) {
if (healer.status !== "healing" && healer.nextActionAt === 0) {
return noSupport(members);
}
const next = [...members];
next[healerIndex] = { ...healer, status: "ready", nextActionAt: 0 };
return noSupport(next);
}
const healingAbility = choosePartyAbility(healer, {
now,
target: null,
injuredAllies: candidates.length,
lowestHealthFraction: target.health / Math.max(1, target.maxHealth),
});
const healingEffect = healingAbility?.effects.find((effect) => effect.kind === "heal");
if (!healingAbility || !healingEffect || healingEffect.kind !== "heal") return noSupport(members);
const requested = partyAbilityAmount(healer, healingAbility, healingEffect)
+ Math.round(target.maxHealth * (healingEffect.percentMaxHealth ?? 0));
const healed = Math.min(requested, target.maxHealth - target.health);
const next = [...members];
if (target.index >= 0) {
const currentTarget = next[target.index];
next[target.index] = { ...currentTarget, health: currentTarget.health + healed };
}
const currentHealer = commitPartyAbilityUse(next[healerIndex], healingAbility, now) ?? next[healerIndex];
next[healerIndex] = {
...currentHealer,
status: "healing",
nextActionAt: now + Math.max(HEALER_ACTION_INTERVAL_MS, healingAbility.castTimeMs, healingAbility.gcdMs),
};
return {
members: next,
playerHealing: target.index < 0 ? healed : 0,
playerResurrectionPercent: 0,
targetId: target.id,
effectiveHealing: healed,
sourceMemberId: healer.id,
action: "heal",
presentation: {
spellId: healingAbility.dbcSpellId,
name: healingAbility.name,
school: presentationSchoolForAbility(healingAbility),
delivery: presentationDeliveryForAbility(healingAbility),
},
dispelCategories: [],
dispelMaximum: 0,
};
}
function actorRole(actorId: string | null, members: readonly PartyMember[]): PartyMember["role"] | null {
if (!actorId) return null;
if (actorId === PLAYER_AGGRO_ID) return usePartyStore.getState().playerRole;
return members.find((member) => member.id === actorId)?.role ?? null;
}
function tankPriorityTargetId(
mobs: Readonly<Record<string, MobCombatState>>,
members: readonly PartyMember[],
tankId: string,
tankPosition: MobVector3,
now: number,
fallbackTargetId: string | null,
): string | null {
const victimPriority = (mob: MobCombatState) => {
const role = actorRole(mob.targetActorId, members);
return role === "healer" ? 0 : role === "damage" ? 1 : role === "tank" ? 2 : 0;
};
const loose = Object.values(mobs)
.filter((mob) => (
mob.engaged
&& !mob.dead
&& mob.targetActorId !== tankId
&& !(mob.forcedTarget && mob.forcedTarget.endsAt > now)
&& getMobPosition(mob.id)
))
.sort((left, right) => {
const victimOrder = victimPriority(left) - victimPriority(right);
if (victimOrder) return victimOrder;
const bossOrder = Number(right.boss) - Number(left.boss);
if (bossOrder) return bossOrder;
return distanceSquared(getMobPosition(left.id)!, tankPosition)
- distanceSquared(getMobPosition(right.id)!, tankPosition)
|| left.id.localeCompare(right.id);
});
return loose[0]?.id ?? fallbackTargetId;
}
function memberSwingMs(member: PartyMember): number {
const base = member.role === "tank"
? 1_800
: member.classId === "rogue"
|| member.specialization === "Enhancement"
|| member.specialization === "Feral (Cat)"
? 1_550
: 1_900;
return hastedDuration(
base,
ratingPercent(
member.gearStats.hasteRating,
partyUsesRangedAttacks(member)
? member.classId === "hunter" ? "rangedHaste" : "spellHaste"
: "meleeHaste",
member.level,
member.classId,
),
500,
);
}
function emitPartyAbility(
member: PartyMember,
ability: AbilityDefinition,
targetId: string,
phase: "cast-start" | "cast-cancel" | "release" | "impact",
now: number,
instantCast = false,
): void {
const origin = getPartyRuntimePosition(member.id) ?? useCombatStore.getState().playerPosition;
const targetPosition = targetId === PLAYER_AGGRO_ID
? useCombatStore.getState().playerPosition
: getMobPosition(targetId) ?? getPartyRuntimePosition(targetId) ?? origin;
emitCombatPresentation({
occurredAt: now,
phase,
actorGroup: "party",
sourceActorId: member.id,
targetActorId: targetId,
abilityId: ability.id,
sourceSpellId: ability.dbcSpellId,
name: ability.name,
source: ability.source === "runewaker" ? "runewaker" : ability.source?.startsWith("coa") ? "ascension" : "wow335",
school: presentationSchoolForAbility(ability),
delivery: presentationDeliveryForAbility(ability),
origin,
targetPosition,
...(ability.radius === undefined ? {} : { radius: ability.radius }),
...(instantCast ? { instantCast: true } : {}),
});
}
function partyFriendlyPosition(actorId: string): MobVector3 | null {
if (actorId === PLAYER_AGGRO_ID) return useCombatStore.getState().playerPosition;
return getPartyRuntimePosition(actorId);
}
function partyAbilityTargetIsValid(
member: PartyMember,
ability: AbilityDefinition,
targetId: string,
castOrigin?: MobVector3,
): boolean {
const combat = useCombatStore.getState();
const origin = getPartyRuntimePosition(member.id) ?? combat.playerPosition;
if (castOrigin && !canAbilityCastWhileMoving(ability) && distanceSquared(origin, castOrigin) > 0.04) return false;
const mob = combat.mobs[targetId];
const hostile = Boolean(mob);
const targetPosition = hostile ? getMobPosition(targetId) : partyFriendlyPosition(targetId);
if (!targetPosition || mob?.dead) return false;
const targetActor = combat.actors[targetId];
if (!hostile && (!targetActor?.alive || targetActor.health <= 0)
&& !ability.effects.some((effect) => effect.kind === "resurrection")) return false;
const distance = Math.sqrt(distanceSquared(origin, targetPosition));
if (distance < ability.range.min || distance > ability.range.max || Math.abs(origin[1] - targetPosition[1]) > 8) return false;
const targeting = targetingRulesForAbility(ability);
if (targeting.requiresLineOfSight && !combatHasLineOfSight(origin, targetPosition)) return false;
if (hostile && targeting.requiresFacing) {
const sourceSpatial = combatSpatialActor(member.id);
if (sourceSpatial && !isPositionInFacingArc(sourceSpatial, targetPosition, targeting.attackArcRadians ?? Math.PI)) return false;
}
if (hostile && targeting.requiresBehindTarget) {
const targetSpatial = combatSpatialActor(targetId);
if (targetSpatial && !isSourceBehindTarget(origin, targetSpatial)) return false;
}
return true;
}
function partyHostileTargets(
ability: AbilityDefinition,
primaryTargetId: string,
origin: MobVector3,
sourceActorId: string,
): readonly string[] {
const combat = useCombatStore.getState();
const targeting = targetingRulesForAbility(ability);
const primaryPosition = getMobPosition(primaryTargetId);
if (!primaryPosition) return [];
if (targeting.shape === "chain") {
const selected: string[] = [];
let previous = primaryPosition;
const ordered = [primaryTargetId, ...Object.keys(combat.mobs).filter((id) => id !== primaryTargetId).sort()];
for (const id of ordered) {
if (selected.length >= (targeting.maximumTargets ?? 3)) break;
const position = getMobPosition(id);
if (!position || combat.mobs[id]?.dead) continue;
if (selected.length > 0 && distanceSquared(previous, position) > (targeting.jumpRange ?? 12) ** 2) continue;
if (!combatHasLineOfSight(previous, position)) continue;
selected.push(id);
previous = position;
}
return selected;
}
if (targeting.shape === "cone") {
const memberSpatial = combatSpatialActor(sourceActorId);
return Object.values(combat.mobs)
.filter((mob) => {
const position = getMobPosition(mob.id);
return !mob.dead && Boolean(position
&& distanceSquared(origin, position) <= ability.range.max ** 2
&& combatHasLineOfSight(origin, position)
&& (!memberSpatial || isPositionInFacingArc(memberSpatial, position, targeting.attackArcRadians ?? Math.PI / 2)));
})
.map((mob) => mob.id)
.sort();
}
if (targeting.shape === "circle" || targeting.shape === "ground" || ability.radius !== undefined) {
const radius = targeting.radius ?? ability.radius ?? 0;
return Object.values(combat.mobs)
.filter((mob) => {
const position = getMobPosition(mob.id);
return !mob.dead && Boolean(position
&& distanceSquared(primaryPosition, position) <= radius * radius
&& combatHasLineOfSight(primaryPosition, position));
})
.map((mob) => mob.id)
.sort();
}
return combatHasLineOfSight(origin, primaryPosition) ? [primaryTargetId] : [];
}
function partyHealingTargets(
ability: AbilityDefinition,
primaryTargetId: string,
members: readonly PartyMember[],
): readonly string[] {
const combat = useCombatStore.getState();
const targeting = targetingRulesForAbility(ability);
const living = [PLAYER_AGGRO_ID, ...members.map((member) => member.id)].filter((id) => combat.actors[id]?.alive);
const healthFraction = (id: string) => {
const actor = combat.actors[id];
return actor ? actor.health / Math.max(1, actor.maxHealth) : 1;
};
if (targeting.shape === "chain") {
const selected: string[] = [];
let previous = partyFriendlyPosition(primaryTargetId);
const ordered = [primaryTargetId, ...living.filter((id) => id !== primaryTargetId)
.sort((left, right) => healthFraction(left) - healthFraction(right) || left.localeCompare(right))];
for (const id of ordered) {
if (selected.length >= (targeting.maximumTargets ?? 3)) break;
const position = partyFriendlyPosition(id);
if (!position || !previous) continue;
if (selected.length > 0 && distanceSquared(previous, position) > (targeting.jumpRange ?? 12) ** 2) continue;
if (!combatHasLineOfSight(previous, position)) continue;
selected.push(id);
previous = position;
}
return selected;
}
if (targeting.shape === "circle" || targeting.shape === "ground" || ability.radius !== undefined) {
const center = partyFriendlyPosition(primaryTargetId);
const radius = targeting.radius ?? ability.radius ?? 0;
return center ? living.filter((id) => {
const position = partyFriendlyPosition(id);
return Boolean(position
&& distanceSquared(center, position) <= radius * radius
&& combatHasLineOfSight(center, position));
}) : [];
}
return [primaryTargetId];
}
interface PartyAbilityExecutionResult {
readonly member: PartyMember;
readonly effectiveAmount: number;
}
interface PartySupportIntent {
readonly ability: AbilityDefinition;
readonly targetId: string;
}
function choosePartySupportIntent(
member: PartyMember,
members: readonly PartyMember[],
now: number,
): PartySupportIntent | null {
const combat = useCombatStore.getState();
const fallenTargetId = [
...(combat.health <= 0 ? [PLAYER_AGGRO_ID] : []),
...members
.filter((candidate) => candidate.health <= 0)
.sort((left, right) => (
Number(left.role === "healer") - Number(right.role === "healer")
|| Number(left.role === "tank") - Number(right.role === "tank")
|| left.id.localeCompare(right.id)
))
.map((candidate) => candidate.id),
][0];
if (fallenTargetId) {
const resurrection = partyResurrectionAbility(member);
if (resurrection) return { ability: resurrection, targetId: fallenTargetId };
}
if (member.role !== "healer") return null;
const dispelTargetId = [
...(combat.health > 0 ? [PLAYER_AGGRO_ID] : []),
...members.filter((candidate) => candidate.health > 0).map((candidate) => candidate.id),
].find((id) => combat.auras.some((aura) => (
aura.targetId === id
&& aura.definition.disposition === "debuff"
&& aura.definition.dispelCategory
)));
if (dispelTargetId) {
const ability = choosePartyAbility(member, { now, target: null, allyNeedsDispel: true });
if (ability?.effects.some((effect) => effect.kind === "dispel" && effect.relationship !== "hostile")) {
return { ability, targetId: dispelTargetId };
}
}
const injured = [
...(combat.health > 0 && combat.health < combat.maxHealth
? [{ id: PLAYER_AGGRO_ID, health: combat.health, maxHealth: combat.maxHealth, role: "healer" as const }]
: []),
...members.filter((candidate) => candidate.health > 0 && candidate.health < candidate.maxHealth),
].sort((left, right) => (
left.health / Math.max(1, left.maxHealth) - right.health / Math.max(1, right.maxHealth)
|| (right.maxHealth - right.health) - (left.maxHealth - left.health)
|| left.id.localeCompare(right.id)
));
const target = injured[0];
if (!target) return null;
const ability = choosePartyAbility(member, {
now,
target: null,
injuredAllies: injured.length,
lowestHealthFraction: target.health / Math.max(1, target.maxHealth),
});
return ability?.effects.some((effect) => ["heal", "hot", "shield"].includes(effect.kind))
? { ability, targetId: target.id }
: null;
}
function executePartyAbility(
member: PartyMember,
ability: AbilityDefinition,
targetId: string,
now: number,
members: readonly PartyMember[],
depth = 0,
castStartAlreadyEmitted = false,
): PartyAbilityExecutionResult {
const combat = useCombatStore.getState();
const origin = getPartyRuntimePosition(member.id) ?? combat.playerPosition;
const hostileTargets = combat.mobs[targetId] ? partyHostileTargets(ability, targetId, origin, member.id) : [];
const friendlyTargets = combat.mobs[targetId] ? [] : partyHealingTargets(ability, targetId, members);
const spellSchool = presentationSchoolForAbility(ability);
const groundAreaCreated = targetingRulesForAbility(ability).shape === "ground"
? Boolean(useCombatStore.getState().createActorAreaEffect(
member.id,
ability,
getMobPosition(targetId) ?? partyFriendlyPosition(targetId) ?? origin,
now,
))
: false;
const criticalChance = Math.max(0, ratingPercent(
member.gearStats.criticalStrikeRating,
"spellCrit",
member.level,
member.classId,
) / 100);
let runtimeMember = member;
let effectiveAmount = 0;
for (const effect of ability.effects) {
if (effect.kind === "damage" || effect.kind === "dot" || effect.kind === "finisher-damage") {
for (const hostileTargetId of hostileTargets) {
if (effect.kind === "dot") {
if (!groundAreaCreated) combat.applyTimedActorEffect(member.id, hostileTargetId, ability, effect, now);
} else {
effectiveAmount += combat.damageMob(
hostileTargetId,
partyAbilityAmount(runtimeMember, ability, effect),
now,
{ actorId: member.id, role: member.role, abilityId: ability.id },
spellSchool,
).amount;
}
}
} else if (effect.kind === "heal") {
for (const friendlyTargetId of friendlyTargets) {
const actor = useCombatStore.getState().actors[friendlyTargetId];
if (!actor?.alive) continue;
const requested = partyAbilityAmount(runtimeMember, ability, effect)
+ Math.round(actor.maxHealth * (effect.percentMaxHealth ?? 0));
const result = useCombatStore.getState().healActor(friendlyTargetId, requested, {
sourceActorId: member.id,
abilityId: ability.id,
school: spellSchool,
criticalChance,
seed: `${member.id}:${ability.id}:${friendlyTargetId}:${now}:heal`,
now,
});
effectiveAmount += result.effectiveAmount;
}
} else if (effect.kind === "hot") {
if (!groundAreaCreated) for (const friendlyTargetId of friendlyTargets) {
useCombatStore.getState().applyTimedActorEffect(member.id, friendlyTargetId, ability, effect, now);
}
} else if (effect.kind === "shield") {
for (const friendlyTargetId of friendlyTargets) {
effectiveAmount += useCombatStore.getState().shieldActor(
friendlyTargetId,
partyAbilityAmount(runtimeMember, ability, effect),
effect.durationMs,
member.id,
ability,
now,
);
}
} else if (effect.kind === "resurrection") {
effectiveAmount += useCombatStore.getState().reviveActor(
targetId,
effect.percentMaxHealth,
member.id,
ability.id,
now,
);
} else if (effect.kind === "execute") {
for (const hostileTargetId of hostileTargets) {
const target = useCombatStore.getState().mobs[hostileTargetId];
if (target && target.health / Math.max(1, target.maxHealth) <= 0.2) {
effectiveAmount += combat.damageMob(
hostileTargetId,
Math.max(target.health, partyAbilityAmount(runtimeMember, ability, effect)),
now,
{ actorId: member.id, role: member.role, abilityId: ability.id },
spellSchool,
).amount;
}
}
} else if (effect.kind === "interrupt") {
for (const hostileTargetId of hostileTargets) combat.interruptMob(hostileTargetId, ability.id, effect.lockoutMs, now);
} else if (effect.kind === "taunt") {
for (const hostileTargetId of hostileTargets.slice(0, effect.maxTargets)) {
combat.tauntMob(hostileTargetId, { actorId: member.id, role: member.role, abilityId: ability.id }, effect.durationMs, now);
}
} else if (effect.kind === "status") {
for (const hostileTargetId of hostileTargets.slice(0, effect.maxTargets)) {
combat.controlMob(hostileTargetId, ability.id, effect.status, effect.durationMs, now);
}
} else if (effect.kind === "apply-aura") {
const recipients = effect.scope === "party"
? [PLAYER_AGGRO_ID, ...members.filter((candidate) => candidate.health > 0).map((candidate) => candidate.id)]
: effect.recipient === "caster"
? [member.id]
: combat.mobs[targetId] ? hostileTargets : friendlyTargets;
for (const recipientId of recipients) {
useCombatStore.getState().applyActorAura(member.id, recipientId, ability, effect, now);
if (effect.control && useCombatStore.getState().mobs[recipientId]) {
useCombatStore.getState().controlMob(recipientId, ability.id, effect.control.kind, effect.aura.durationMs ?? 1_000, now);
}
}
} else if (effect.kind === "dispel") {
const recipients = effect.scope === "party"
? [PLAYER_AGGRO_ID, ...members.map((candidate) => candidate.id)]
: [targetId];
for (const recipientId of recipients) {
effectiveAmount += useCombatStore.getState().dispelActor(recipientId, effect.categories, effect.maxCount);
}
} else if (effect.kind === "resource") {
for (const friendlyTargetId of friendlyTargets.length ? friendlyTargets : [member.id]) {
effectiveAmount += useCombatStore.getState().restoreActorResource(
friendlyTargetId,
effect.resource ?? member.resourceType,
effect.amount,
effect.percentage,
member.id,
ability.id,
now,
);
}
} else if (effect.kind === "summon") {
useCombatStore.getState().summonActor(member.id, ability, effect, origin, targetId, now);
} else if (effect.kind === "pet" && ["summon", "call", "resurrect", "tame"].includes(effect.action)) {
const summonEffect: Extract<AbilityEffect, { kind: "summon" }> = {
kind: "summon",
creatureId: effect.creatureId ?? 1,
coefficient: 0.35,
durationMs: Number.MAX_SAFE_INTEGER - now,
};
useCombatStore.getState().summonActor(member.id, ability, summonEffect, origin, targetId, now);
} else if (effect.kind === "movement") {
const targetPosition = combat.mobs[targetId] ? getMobPosition(targetId) : partyFriendlyPosition(targetId);
if (targetPosition && effect.movement !== "knockback" && effect.toward !== "away") {
updatePartyRuntimePosition(member.id, targetPosition[0], targetPosition[1], targetPosition[2]);
}
} else if (effect.kind === "cooldown-reset") {
const candidates = Object.keys(runtimeMember.cooldowns).filter((id) => {
const candidate = abilityById(id);
return candidate && (!effect.abilityName || candidate.name === effect.abilityName);
});
const cooldowns = { ...runtimeMember.cooldowns };
for (const id of candidates) {
const readyAt = cooldowns[id] ?? now;
const reduction = effect.amountPercent !== undefined
? Math.max(0, readyAt - now) * Math.max(0, Math.min(1, effect.amountPercent))
: effect.amountMs ?? Number.MAX_SAFE_INTEGER;
cooldowns[id] = Math.max(now, readyAt - reduction);
}
runtimeMember = { ...runtimeMember, cooldowns };
} else if (effect.kind === "resource-drain" && !combat.mobs[targetId]) {
const resource = effect.resource ?? runtimeMember.resourceType;
const maximum = resource === runtimeMember.resourceType ? runtimeMember.maxResource : 100;
const drained = effect.percentage ? maximum * effect.amount / 100 : effect.amount;
const current = runtimeMember.resourcePools[resource] ?? 0;
const resourcePools: Partial<Record<ResourceType, number>> = {
...runtimeMember.resourcePools,
[resource]: Math.max(0, current - drained),
};
runtimeMember = {
...runtimeMember,
resourcePools,
resource: resource === runtimeMember.resourceType ? resourcePools[resource] ?? runtimeMember.resource : runtimeMember.resource,
};
} else if (effect.kind === "trigger-spell" && depth < 2) {
const triggered = abilitiesForClass(member.classId).find((candidate) => candidate.dbcSpellId === effect.spellId)
?? triggeredAbilityBySpellId(effect.spellId);
if (triggered && triggered.id !== ability.id) {
const result = executePartyAbility(runtimeMember, triggered, targetId, now, members, depth + 1);
runtimeMember = result.member;
effectiveAmount += result.effectiveAmount;
}
}
}
emitPartyAbility(member, ability, targetId, "release", now, !castStartAlreadyEmitted);
emitPartyAbility(member, ability, targetId, "impact", now, !castStartAlreadyEmitted);
if (effectiveAmount > 0 && ability.effects.some((effect) => ["heal", "hot", "shield", "resurrection"].includes(effect.kind))) {
useCombatStore.getState().addHealingThreat({ actorId: member.id, role: member.role, abilityId: ability.id }, effectiveAmount);
}
return { member: runtimeMember, effectiveAmount };
}
function membersDiffer(left: readonly PartyMember[], right: readonly PartyMember[]): boolean {
if (left === right) return false;
if (left.length !== right.length) return true;
return left.some((member, index) => member !== right[index]);
}
/**
* Low-frequency party simulation. Call immediately after mob combat so a living
* tank can redirect that tick's pressure before the death/respawn boundary.
*/
export function advanceDungeonPartyCombat(now = Date.now()): void {
const combat = useCombatStore.getState();
const party = usePartyStore.getState();
if (!party.members.length) return;
const deltaSeconds = lastAdvanceAt > 0 ? Math.max(0, Math.min(1, (now - lastAdvanceAt) / 1_000)) : 0;
lastAdvanceAt = now;
const authoritativeMembers: readonly PartyMember[] = party.members.map((member) => (
memberFromCompanionActor(member, combat.actors[member.id])
));
let members: readonly PartyMember[] = authoritativeMembers;
const partyOrigin = partyRuntimeCenter(
members.filter((member) => !partyRuntimeIsRejoining(member.id)).map((member) => member.id),
combat.playerPosition,
);
let targetId = partyCommandTargetId(
party.command,
combat.mobs,
combat.selectedTargetId,
party.activeMobId,
partyOrigin,
);
members = members.map((member) => {
const profile = resourceProfileForClass(member.classId);
const regeneration = (profile.type === "rage" || profile.type === "runic-power")
? 0
: profile.regenerationPerSecond + member.gearStats.mp5 / 5 + member.gearStats.spirit * 0.02;
const pool = advanceCombatResourcePool(normalizeActorResourcePool(
member.resourceType,
member.resource,
member.maxResource,
regeneration,
member.lastResourceSpentAt[member.resourceType] ?? 0,
), {
inCombat: Boolean(targetId),
actorLevel: member.level,
now,
deltaSeconds,
manaSpiritRegenerationPerSecond: member.gearStats.spirit * 0.02,
});
return {
...member,
resource: pool.current,
resourcePools: { ...member.resourcePools, [member.resourceType]: pool.current },
cooldowns: Object.fromEntries(Object.entries(member.cooldowns).filter(([, readyAt]) => readyAt > now)),
schoolLockedUntil: Object.fromEntries(Object.entries(member.schoolLockedUntil).filter(([, readyAt]) => (readyAt ?? 0) > now)),
};
});
const objectiveBossId = nextPartyBossObjective(combat.mobs)?.id ?? null;
let memberTargetIds: Record<string, string> = {};
const shouldRegen = !targetId && now >= nextRegenAt;
if (shouldRegen) nextRegenAt = now + 1_000;
if (!targetId) {
members = members.map((member) => {
if (member.health <= 0 || member.controlledUntil > now || !shouldRegen || deltaSeconds <= 0) return member;
return {
...member,
health: Math.min(
member.maxHealth,
member.health + Math.max(1, member.maxHealth * OUT_OF_COMBAT_REGEN_FRACTION),
),
};
});
}
// A defeated player can watch the encounter continue while waiting for a
// resurrection. Living companions remain active until the whole party falls.
if (members.some((member) => member.health > 0)) {
const nextMembers = [...members];
for (let index = 0; index < nextMembers.length; index += 1) {
let member = nextMembers[index];
if (member.health <= 0) {
if (member.status !== "down") nextMembers[index] = { ...member, status: "down" };
continue;
}
if (partyRuntimeIsRejoining(member.id)) {
nextMembers[index] = { ...member, status: "advancing", activeCast: null, activeCastTargetId: null };
continue;
}
if (member.controlledUntil > now) {
nextMembers[index] = member.status === "holding"
? member
: { ...member, status: "holding" };
continue;
}
if (combat.auras.some((aura) => aura.targetId === member.id && aura.definition.utilityTags?.includes("pacified"))) {
nextMembers[index] = member.status === "holding" ? member : { ...member, status: "holding" };
continue;
}
const activeMember = member.controlMechanic
? { ...member, controlledUntil: 0, controlMechanic: null }
: member;
member = activeMember;
const currentCombat = useCombatStore.getState();
if (member.activeCast) {
const castingAbility = abilityById(member.activeCast.abilityId);
const castTargetId = member.activeCastTargetId;
if (!castingAbility || !castTargetId) {
nextMembers[index] = { ...member, activeCast: null, activeCastTargetId: null };
continue;
}
const hostileCast = Boolean(currentCombat.mobs[castTargetId]);
const desiredStatus = hostileCast
? member.role === "tank" ? "guarding" : "attacking"
: "healing";
const hostilePosition = hostileCast ? getMobPosition(castTargetId) : null;
if (hostileCast && (!hostilePosition || !combatHasLineOfSight(
getPartyRuntimePosition(member.id) ?? currentCombat.playerPosition, hostilePosition,
))) {
emitPartyAbility(member, castingAbility, castTargetId, "cast-cancel", now);
memberTargetIds[member.id] = castTargetId;
nextMembers[index] = { ...member, activeCast: null, activeCastTargetId: null,
status: "advancing", nextActionAt: now + 250 };
continue;
}
if (now < member.activeCast.completesAt) {
nextMembers[index] = member.status === desiredStatus ? member : { ...member, status: desiredStatus };
if (hostileCast) memberTargetIds[member.id] = castTargetId;
continue;
}
const completedMember = { ...member, activeCast: null, activeCastTargetId: null };
const valid = partyAbilityTargetIsValid(
completedMember,
castingAbility,
castTargetId,
member.activeCast.origin,
);
if (valid) {
const execution = executePartyAbility(
completedMember,
castingAbility,
castTargetId,
now,
nextMembers,
0,
true,
);
nextMembers[index] = {
...execution.member,
status: desiredStatus,
nextActionAt: now + Math.max(250, castingAbility.gcdMs),
};
} else {
nextMembers[index] = {
...completedMember,
status: hostileCast ? "advancing" : "ready",
nextActionAt: now + 250,
};
}
continue;
}
const supportIntent = choosePartySupportIntent(member, nextMembers, now);
if (supportIntent) {
delete memberTargetIds[member.id];
if (now < member.nextActionAt) {
nextMembers[index] = member.status === "healing" ? member : { ...member, status: "healing" };
continue;
}
if (!partyAbilityTargetIsValid(member, supportIntent.ability, supportIntent.targetId)) {
nextMembers[index] = { ...member, status: "advancing", nextActionAt: now + 250 };
continue;
}
const committed = commitPartyAbilityUse(member, supportIntent.ability, now);
if (!committed) {
nextMembers[index] = { ...member, status: "ready", nextActionAt: now + 250 };
continue;
}
const instantOutOfCombatResurrection = !targetId
&& supportIntent.ability.effects.some((effect) => effect.kind === "resurrection");
if (supportIntent.ability.castTimeMs > 0 && !instantOutOfCombatResurrection) {
const castOrigin = getPartyRuntimePosition(member.id) ?? currentCombat.playerPosition;
nextMembers[index] = {
...committed,
status: "healing",
activeCast: {
abilityId: supportIntent.ability.id,
school: presentationSchoolForAbility(supportIntent.ability),
mode: supportIntent.ability.castMode === "channel" ? "channel" : "cast",
startedAt: now,
completesAt: now + supportIntent.ability.castTimeMs,
originalDurationMs: supportIntent.ability.castTimeMs,
pushbackHits: 0,
origin: castOrigin,
targetId: supportIntent.targetId,
},
activeCastTargetId: supportIntent.targetId,
nextActionAt: now + supportIntent.ability.castTimeMs,
};
emitPartyAbility(member, supportIntent.ability, supportIntent.targetId, "cast-start", now);
} else {
const execution = executePartyAbility(
committed,
supportIntent.ability,
supportIntent.targetId,
now,
nextMembers,
);
nextMembers[index] = {
...execution.member,
status: "healing",
nextActionAt: now + Math.max(250, supportIntent.ability.gcdMs),
};
}
continue;
}
targetId = partyCommandTargetId(
party.command,
currentCombat.mobs,
currentCombat.selectedTargetId,
targetId,
partyOrigin,
);
if (!targetId) {
nextMembers[index] = member.status === "ready" && member.nextActionAt === 0
? member
: { ...member, status: party.command === "stop" ? "holding" : "ready", nextActionAt: 0 };
continue;
}
const desiredStatus = member.role === "tank" ? "guarding" : "attacking";
const memberPosition = getPartyRuntimePosition(member.id) ?? currentCombat.playerPosition;
const memberTargetId = member.role === "tank"
? tankPriorityTargetId(
currentCombat.mobs,
nextMembers,
member.id,
memberPosition,
now,
targetId,
)
: targetId;
if (!memberTargetId) continue;
memberTargetIds[member.id] = memberTargetId;
const targetPosition = getMobPosition(memberTargetId);
const targetVisible = Boolean(
targetPosition && partyRuntimeTargetVisible(member.id, memberTargetId)
);
let runtimeMember = member;
const targetMob = currentCombat.mobs[memberTargetId];
if (now < member.nextActionAt) {
if (runtimeMember.status !== desiredStatus) {
runtimeMember = { ...runtimeMember, status: desiredStatus };
}
nextMembers[index] = runtimeMember;
continue;
}
const selectedAbility = choosePartyAbility(runtimeMember, {
now,
target: targetMob ?? null,
targetIsLoose: member.role === "tank" && targetMob?.targetActorId !== member.id,
});
if (!selectedAbility) {
nextMembers[index] = { ...runtimeMember, status: desiredStatus, nextActionAt: now + 250 };
continue;
}
const inRange = targetVisible && partyAbilityTargetIsValid(runtimeMember, selectedAbility, memberTargetId);
if (!inRange) {
nextMembers[index] = runtimeMember.status === "advancing"
? runtimeMember
: { ...runtimeMember, status: "advancing" };
continue;
}
const committed = commitPartyAbilityUse(runtimeMember, selectedAbility, now);
if (!committed) {
nextMembers[index] = { ...runtimeMember, status: desiredStatus, nextActionAt: now + 250 };
continue;
}
if (selectedAbility.castTimeMs > 0) {
nextMembers[index] = {
...committed,
status: desiredStatus,
activeCast: {
abilityId: selectedAbility.id,
school: presentationSchoolForAbility(selectedAbility),
mode: selectedAbility.castMode === "channel" ? "channel" : "cast",
startedAt: now,
completesAt: now + selectedAbility.castTimeMs,
originalDurationMs: selectedAbility.castTimeMs,
pushbackHits: 0,
origin: memberPosition,
targetId: memberTargetId,
},
activeCastTargetId: memberTargetId,
nextActionAt: now + selectedAbility.castTimeMs,
};
emitPartyAbility(member, selectedAbility, memberTargetId, "cast-start", now);
} else {
const execution = executePartyAbility(committed, selectedAbility, memberTargetId, now, nextMembers);
nextMembers[index] = {
...execution.member,
status: desiredStatus,
nextActionAt: now + Math.max(250, selectedAbility.gcdMs),
};
}
}
members = nextMembers;
} else {
memberTargetIds = {};
const idleStatus = party.command === "stop"
? "holding" as const
: party.command === "attack" && objectiveBossId
? "advancing" as const
: "ready" as const;
members = members.map((member) => {
if (member.health <= 0) return member.status === "down" ? member : { ...member, status: "down" };
if (member.controlledUntil > now) {
return member.status === "holding" ? member : { ...member, status: "holding" };
}
const activeMember = member.controlMechanic
? { ...member, controlledUntil: 0, controlMechanic: null }
: member;
const recovered = activeMember.health;
if (activeMember.role === "healer" && activeMember.nextActionAt > now) {
if (recovered === activeMember.health) return activeMember;
return { ...activeMember, health: recovered };
}
if (recovered === activeMember.health && activeMember.status === idleStatus && activeMember.nextActionAt === 0) return activeMember;
return { ...activeMember, health: recovered, status: idleStatus, nextActionAt: 0 };
});
}
// Execution actions may have changed health while this low-frequency party
// loop was still holding its pre-cast compatibility records. Actor vitals win;
// runtime-owned resources, cooldowns, status, and casts remain on the member.
const actorsAfterActions = useCombatStore.getState().actors;
members = members.map((member) => {
const actor = actorsAfterActions[member.id];
const baseline = authoritativeMembers.find((candidate) => candidate.id === member.id);
return actor?.kind === "companion" && baseline && actor.health !== baseline.health
? { ...member, health: actor.health, maxHealth: actor.maxHealth }
: member;
});
const targetMapChanged = (
Object.keys(party.memberTargetIds).length !== Object.keys(memberTargetIds).length
|| Object.entries(memberTargetIds).some(([memberId, mobId]) => party.memberTargetIds[memberId] !== mobId)
);
if (
membersDiffer(authoritativeMembers, members)
|| party.activeMobId !== targetId
|| party.objectiveBossId !== objectiveBossId
|| targetMapChanged
) {
party.commitRuntime({ members, activeMobId: targetId, objectiveBossId, memberTargetIds });
}
useCombatStore.getState().commitCompanionActors(members);
}