forked from phenom/healer-man
771 lines
29 KiB
TypeScript
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 });
|
|
}
|
|
}
|