forked from phenom/healer-man
Release Healer Man 0.1.5
This commit is contained in:
1 parent
f4c9cf356c
commit
55cfd43d66
62 files changed
+3030
-153
No files matched your search
+144
-21
@@ -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" });
|
||||
|
||||
Reference in new issue
Block a user