Files
healer-man/src/game/partyRuntime.ts
T
2026-08-18 16:22:19 -04:00

771 lines
29 KiB
TypeScript

import {
abilitiesForClass,
abilityAtLevel,
isAbilityUnlocked,
type AbilityDefinition,
} from "./abilityCatalog";
import type { MobCombatState } from "./combatStore";
import { useCombatStore } from "./combatStore";
import type { MobVector3 } from "./mobPopulation";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { getMobPosition } from "./mobRuntimeRegistry";
import {
getPartyRuntimePosition,
partyRuntimeCenter,
partyRuntimePositionAlongAttackCorridor,
partyRuntimeTargetVisible,
} from "./partyRuntimeRegistry";
import { usePartyStore, type PartyCommand, type PartyMember } from "./partyStore";
import { useGameStore } from "./store";
import { useManastormStore } from "./manastormStore";
import { PLAYER_AGGRO_ID, type ThreatSource } from "./aggro";
import {
equipmentAttackPower,
hastedDuration,
ratingPercent,
type WotlkAttackKind,
} from "./wotlkStats";
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
import {
emitCombatPresentation,
type CombatPresentationDelivery,
type CombatPresentationSchool,
} from "./combatPresentation";
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 objectives = requireDungeonDefinition(game.activeDungeonId).objectiveOrder;
if (game.activeDungeonId !== "wailing-caverns") return objectives;
const phase = useWailingEncounterStore.getState().phase;
return phase === "mutanus" || phase === "complete"
? objectives
: objectives.filter((objective) => objective.id !== "mutanus-spawn");
}
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" | null;
readonly presentation: PartyPresentationIdentity | null;
}
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")
.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,
};
}
/**
* 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,
): 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];
next[resurrector.index] = {
...caster,
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),
};
}
}
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 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 requested = partyHealAmount(healer.level, healer.gearStats.spellPower);
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 = next[healerIndex];
next[healerIndex] = {
...currentHealer,
status: "healing",
nextActionAt: now + HEALER_ACTION_INTERVAL_MS,
};
return {
members: next,
playerHealing: target.index < 0 ? healed : 0,
playerResurrectionPercent: 0,
targetId: target.id,
effectiveHealing: healed,
sourceMemberId: healer.id,
action: "heal",
presentation: partyHealPresentation(healer),
};
}
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 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;
let members = party.members;
const partyOrigin = partyRuntimeCenter(
members.map((member) => member.id),
combat.playerPosition,
);
let targetId = partyCommandTargetId(
party.command,
combat.mobs,
combat.selectedTargetId,
party.activeMobId,
partyOrigin,
);
const objectiveBossId = nextPartyBossObjective(combat.mobs)?.id ?? null;
let memberTargetIds: Record<string, string> = {};
// A defeated player can watch the encounter continue while waiting for a
// resurrection. Living companions remain active until the whole party falls.
if (targetId && 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 (member.controlledUntil > now) {
nextMembers[index] = member.status === "holding"
? member
: { ...member, status: "holding" };
continue;
}
const activeMember = member.controlMechanic
? { ...member, controlledUntil: 0, controlMechanic: null }
: member;
if (activeMember.role === "healer") {
delete memberTargetIds[activeMember.id];
if (now >= activeMember.nextActionAt && activeMember.status === "healing") {
nextMembers[index] = { ...activeMember, status: "ready", nextActionAt: 0 };
} else {
nextMembers[index] = activeMember;
}
continue;
}
member = activeMember;
const desiredStatus = member.role === "tank" ? "guarding" : "attacking";
const currentCombat = useCombatStore.getState();
targetId = partyCommandTargetId(
party.command,
currentCombat.mobs,
currentCombat.selectedTargetId,
targetId,
partyOrigin,
);
if (!targetId) break;
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 (
member.role === "tank"
&& targetMob
&& targetMob.targetActorId !== member.id
&& !(targetMob.forcedTarget && targetMob.forcedTarget.endsAt > now)
&& now >= member.tauntReadyAt
&& targetPosition
&& targetVisible
&& distanceSquared(memberPosition, targetPosition) <= TANK_TAUNT_RANGE * TANK_TAUNT_RANGE
) {
const tauntSource: ThreatSource = { actorId: member.id, role: "tank" };
if (currentCombat.tauntMob(memberTargetId, tauntSource, TANK_TAUNT_DURATION_MS, now)) {
runtimeMember = { ...runtimeMember, tauntReadyAt: now + TANK_TAUNT_COOLDOWN_MS };
const tauntPresentation = {
occurredAt: now,
actorGroup: "party",
sourceActorId: member.id,
targetActorId: memberTargetId,
abilityId: `party-taunt:${member.id}`,
sourceSpellId: partyTauntSpellId(member),
name: "Taunt",
source: "wow335",
school: "physical",
delivery: "aura",
origin: memberPosition,
targetPosition,
} as const;
emitCombatPresentation({ ...tauntPresentation, phase: "release" });
emitCombatPresentation({ ...tauntPresentation, phase: "aura-start" });
}
}
if (now < member.nextActionAt) {
if (runtimeMember.status !== desiredStatus) {
runtimeMember = { ...runtimeMember, status: desiredStatus };
}
nextMembers[index] = runtimeMember;
continue;
}
const attackRange = partyAttackRange(member);
const inRange = Boolean(
targetPosition
&& targetVisible
&& distanceSquared(memberPosition, targetPosition) <= attackRange * attackRange
&& Math.abs(memberPosition[1] - targetPosition[1]) <= 8,
);
if (!inRange) {
nextMembers[index] = runtimeMember.status === "advancing"
? runtimeMember
: { ...runtimeMember, status: "advancing" };
continue;
}
useCombatStore.getState().damageMob(
memberTargetId,
partyAttackDamage(member),
now,
{ actorId: member.id, role: member.role, abilityId: `party:${member.id}` },
);
const presentation = partyAttackPresentation(member);
const attackPresentation = {
occurredAt: now,
actorGroup: "party",
sourceActorId: member.id,
targetActorId: memberTargetId,
abilityId: `party:${member.id}`,
sourceSpellId: presentation.spellId,
name: presentation.name,
source: "wow335",
school: presentation.school,
delivery: presentation.delivery,
origin: memberPosition,
targetPosition: targetPosition!,
} as const;
emitCombatPresentation({ ...attackPresentation, phase: "release" });
emitCombatPresentation({ ...attackPresentation, phase: "impact" });
nextMembers[index] = {
...runtimeMember,
status: desiredStatus,
nextActionAt: now + memberSwingMs(member),
};
}
members = nextMembers;
} else {
memberTargetIds = {};
const shouldRegen = now >= nextRegenAt;
if (shouldRegen) nextRegenAt = now + 1_000;
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 = shouldRegen && deltaSeconds > 0
? Math.min(activeMember.maxHealth, activeMember.health + Math.max(1, activeMember.maxHealth * OUT_OF_COMBAT_REGEN_FRACTION))
: 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 };
});
}
const currentPlayer = useCombatStore.getState();
const support = applyHealerSupport(members, currentPlayer.health, currentPlayer.maxHealth, now);
const supporter = support.sourceMemberId
? members.find((member) => member.id === support.sourceMemberId && member.health > 0) ?? null
: null;
members = support.members;
let effectiveSupport = support.effectiveHealing;
if (support.playerResurrectionPercent > 0) {
effectiveSupport = useCombatStore.getState().revivePlayer(support.playerResurrectionPercent);
}
if (support.playerHealing > 0) {
effectiveSupport = useCombatStore.getState().healPlayer(support.playerHealing);
}
if (supporter && support.presentation && effectiveSupport > 0) {
useCombatStore.getState().addHealingThreat(
{ actorId: supporter.id, role: supporter.role },
effectiveSupport,
);
const presentation = support.presentation;
const supporterPosition = getPartyRuntimePosition(supporter.id) ?? currentPlayer.playerPosition;
const presentationTargetId = support.targetId === "__player__" ? PLAYER_AGGRO_ID : support.targetId;
const targetPosition = presentationTargetId === PLAYER_AGGRO_ID
? currentPlayer.playerPosition
: presentationTargetId
? getPartyRuntimePosition(presentationTargetId)
: null;
const healPresentation = {
occurredAt: now,
actorGroup: "party",
sourceActorId: supporter.id,
...(presentationTargetId ? { targetActorId: presentationTargetId } : {}),
abilityId: `party-${support.action}:${supporter.id}`,
sourceSpellId: presentation.spellId,
name: presentation.name,
source: "wow335",
school: presentation.school,
delivery: presentation.delivery,
origin: supporterPosition,
targetPosition: targetPosition ?? supporterPosition,
} as const;
emitCombatPresentation({ ...healPresentation, phase: "release" });
emitCombatPresentation({ ...healPresentation, phase: "impact" });
}
const targetMapChanged = (
Object.keys(party.memberTargetIds).length !== Object.keys(memberTargetIds).length
|| Object.entries(memberTargetIds).some(([memberId, mobId]) => party.memberTargetIds[memberId] !== mobId)
);
if (
membersDiffer(party.members, members)
|| party.activeMobId !== targetId
|| party.objectiveBossId !== objectiveBossId
|| targetMapChanged
) {
party.commitRuntime({ members, activeMobId: targetId, objectiveBossId, memberTargetIds });
}
}