Release Healer Man 0.1.5

This commit is contained in:
phenom committed 2026-08-18 16:22:19 -04:00
1 parent f4c9cf356c
commit 55cfd43d66
62 files changed
+3030 -153

No files matched your search

+144 -21
View File
@@ -1,3 +1,9 @@
import {
abilitiesForClass,
abilityAtLevel,
isAbilityUnlocked,
type AbilityDefinition,
} from "./abilityCatalog";
import type { MobCombatState } from "./combatStore";
import { useCombatStore } from "./combatStore";
import type { MobVector3 } from "./mobPopulation";
@@ -35,7 +41,7 @@ const TANK_TAUNT_DURATION_MS = 3_000;
let lastAdvanceAt = 0;
let nextRegenAt = 0;
interface PartyPresentationIdentity {
export interface PartyPresentationIdentity {
readonly spellId: number;
readonly name: string;
readonly school: CombatPresentationSchool;
@@ -265,18 +271,59 @@ export function partyCommandTargetId(
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 dedicated NPC healer one legal, level-scaled heal at a time. The
* runtime caller applies `playerHealing` to the combat store after this pure
* party-state update.
* 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[],
@@ -284,10 +331,74 @@ export function applyHealerSupport(
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 { members, playerHealing: 0, targetId: null, effectiveHealing: 0 };
if (healerIndex < 0) return noSupport(members);
const healer = members[healerIndex];
if (now < healer.nextActionAt) return { members, playerHealing: 0, targetId: null, effectiveHealing: 0 };
if (now < healer.nextActionAt) return noSupport(members);
const candidates = members
.map((member, index) => ({
@@ -308,11 +419,11 @@ export function applyHealerSupport(
const target = candidates[0];
if (!target) {
if (healer.status !== "healing" && healer.nextActionAt === 0) {
return { members, playerHealing: 0, targetId: null, effectiveHealing: 0 };
return noSupport(members);
}
const next = [...members];
next[healerIndex] = { ...healer, status: "ready", nextActionAt: 0 };
return { members: next, playerHealing: 0, targetId: null, effectiveHealing: 0 };
return noSupport(next);
}
const requested = partyHealAmount(healer.level, healer.gearStats.spellPower);
@@ -331,8 +442,12 @@ export function applyHealerSupport(
return {
members: next,
playerHealing: target.index < 0 ? healed : 0,
playerResurrectionPercent: 0,
targetId: target.id,
effectiveHealing: healed,
sourceMemberId: healer.id,
action: "heal",
presentation: partyHealPresentation(healer),
};
}
@@ -428,7 +543,9 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
const objectiveBossId = nextPartyBossObjective(combat.mobs)?.id ?? null;
let memberTargetIds: Record<string, string> = {};
if (targetId && useCombatStore.getState().health > 0) {
// 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];
@@ -596,18 +713,24 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
const currentPlayer = useCombatStore.getState();
const support = applyHealerSupport(members, currentPlayer.health, currentPlayer.maxHealth, now);
const healer = members.find((member) => member.role === "healer" && member.health > 0) ?? null;
const supporter = support.sourceMemberId
? members.find((member) => member.id === support.sourceMemberId && member.health > 0) ?? null
: null;
members = support.members;
if (support.playerHealing > 0) {
useCombatStore.getState().healPlayer(support.playerHealing);
let effectiveSupport = support.effectiveHealing;
if (support.playerResurrectionPercent > 0) {
effectiveSupport = useCombatStore.getState().revivePlayer(support.playerResurrectionPercent);
}
if (healer && support.effectiveHealing > 0) {
if (support.playerHealing > 0) {
effectiveSupport = useCombatStore.getState().healPlayer(support.playerHealing);
}
if (supporter && support.presentation && effectiveSupport > 0) {
useCombatStore.getState().addHealingThreat(
{ actorId: healer.id, role: healer.role },
support.effectiveHealing,
{ actorId: supporter.id, role: supporter.role },
effectiveSupport,
);
const presentation = partyHealPresentation(healer);
const healerPosition = getPartyRuntimePosition(healer.id) ?? currentPlayer.playerPosition;
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
@@ -617,16 +740,16 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
const healPresentation = {
occurredAt: now,
actorGroup: "party",
sourceActorId: healer.id,
sourceActorId: supporter.id,
...(presentationTargetId ? { targetActorId: presentationTargetId } : {}),
abilityId: `party-heal:${healer.id}`,
abilityId: `party-${support.action}:${supporter.id}`,
sourceSpellId: presentation.spellId,
name: presentation.name,
source: "wow335",
school: presentation.school,
delivery: presentation.delivery,
origin: healerPosition,
targetPosition: targetPosition ?? healerPosition,
origin: supporterPosition,
targetPosition: targetPosition ?? supporterPosition,
} as const;
emitCombatPresentation({ ...healPresentation, phase: "release" });
emitCombatPresentation({ ...healPresentation, phase: "impact" });