Release Healer Man 0.1.41

This commit is contained in:
phenom committed 2026-09-13 15:09:35 -04:00
1 parent 197a64117c
commit c3f8a69b51
42 files changed
+886 -127

No files matched your search

+105 -36
View File
@@ -3,6 +3,7 @@ import { combatNow } from "./combatClock";
import { useGameStore } from "./store";
import {
baseClassFor,
isRomClassId,
type ClassId,
type RaceId,
} from "../app/characterCatalog";
@@ -19,6 +20,7 @@ import {
resourceProfileForClass,
triggeredAbilityBySpellId,
type AbilityAmountScaling,
type AbilityDamageResource,
type AbilityDefinition,
type AbilityEffect,
type MobStatusKind,
@@ -155,6 +157,7 @@ import {
type ItemStats,
} from "./itemStats";
import {
baseResourcesFor,
damageAfterEquipmentMitigation,
deriveCharacterStats,
equipmentAttackPower,
@@ -164,6 +167,7 @@ import {
type CharacterStats,
type WotlkAttackKind,
} from "./wotlkStats";
import { healingRecipientIds } from "./healingAbilityRules";
import {
combatAttackerFromCharacterStats,
combatDefenderFromCharacterStats,
@@ -417,7 +421,7 @@ export interface ActiveCast {
readonly nextTickAt: number;
}
export interface TimedCombatEffect {
export interface TimedCombatEffect extends AbilityDamageResource {
readonly id: string;
readonly kind: "dot" | "hot";
readonly sourceAbilityId: string;
@@ -431,6 +435,8 @@ export interface TimedCombatEffect {
readonly pointsPerLevel?: number;
readonly coefficientPerLevel?: number;
readonly bonusPowerCoefficient?: number;
readonly attackPowerCoefficient?: number;
readonly spiritCoefficient?: number;
readonly sourceLevel?: number;
readonly maxScalingLevel?: number;
readonly remainingTicks: number;
@@ -1193,7 +1199,7 @@ function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkA
));
const physicalKeyword = /\b(?:strike|slash|stab|shot|arrow|mangle|claw|bite|weapon|melee)\b/.test(searchable);
const rangedPhysicalKeyword = /\b(?:shot|arrow|bow|gun|bullet|javelin)\b/.test(searchable);
if (healing) return "spell";
if (healing && ability.target !== "hostile") return "spell";
if (ability.range.max > 5 && (rangedPhysicalKeyword || baseClassFor(classId) === "hunter")) return "ranged";
if (["warrior", "rogue", "death-knight"].includes(baseClassFor(classId)) || physicalKeyword) return "melee";
return "spell";
@@ -1253,7 +1259,11 @@ export function equipmentAdjustedCombatAmount(
0,
scaling.bonusPowerCoefficient ?? coefficient,
);
return Math.max(0, Math.round(averageRoll * earlyLevelScale + bonusPower));
const attributes = scaling.attackPowerCoefficient || scaling.spiritCoefficient
? deriveCharacterStats(classId, level, gearStats) : null;
const scriptedPower = (attributes?.attackPower ?? 0) * (scaling.attackPowerCoefficient ?? 0)
+ (attributes?.spirit ?? 0) * (scaling.spiritCoefficient ?? 0);
return Math.max(0, Math.round(averageRoll * earlyLevelScale + bonusPower + scriptedPower));
}
function playerCharacterStats(
@@ -1609,6 +1619,7 @@ function initialResourcePools(
classId: ClassId,
abilities: readonly AbilityDefinition[],
activeMaximum: number,
manaMaximum = 100,
): { pools: Partial<Record<ResourceType, number>>; maximums: Partial<Record<ResourceType, number>> } {
const activeProfile = resourceProfileForClass(classId);
const resourceTypes = new Set<ResourceType>([activeProfile.type]);
@@ -1617,7 +1628,7 @@ function initialResourcePools(
const pools: Partial<Record<ResourceType, number>> = {};
for (const type of resourceTypes) {
if (type === "health") continue;
const maximum = type === activeProfile.type ? activeMaximum : defaultResourceMaximum(type);
const maximum = type === activeProfile.type ? activeMaximum : type === "mana" ? manaMaximum : defaultResourceMaximum(type);
maximums[type] = maximum;
pools[type] = type === "rage" || type === "runic-power" || type === "nature-power" || type === "psi"
? 0
@@ -1629,12 +1640,14 @@ function initialResourcePools(
export function resolvedAbilityCosts(
ability: AbilityDefinition,
modifiers: readonly TalentModifier[],
state: Pick<CombatState, "classId" | "maxHealth" | "maxResource" | "maxResourcePools"> & Partial<Pick<CombatState, "auras">>,
state: Pick<CombatState, "classId" | "maxHealth" | "maxResource" | "maxResourcePools"> & Partial<Pick<CombatState, "auras" | "level">>,
): readonly { resource: ResourceType; amount: number }[] {
const aggregated = new Map<ResourceType, number>();
const activeResourceType = resourceProfileForClass(state.classId).type;
for (const cost of ability.costs?.length ? ability.costs : [ability.cost]) {
const maximum = cost.resource === "health"
const maximum = cost.percentageBasis === "base-mana"
? baseResourcesFor(state.classId, state.level ?? ability.unlockLevel).mana
: cost.resource === "health"
? state.maxHealth
: cost.resource === activeResourceType
? state.maxResource
@@ -2887,7 +2900,7 @@ function executeTriggeredSpellInWork(
ability,
);
for (const mobId of mobTargets) {
resolveAndDamageMobInWork(
const result = resolveAndDamageMobInWork(
work.classId,
work,
mobId,
@@ -2899,6 +2912,7 @@ function executeTriggeredSpellInWork(
`${sourceId}:${ability.id}:${mobId}:${now}:trigger:${depth}`,
ability,
);
grantDamageResourceInWork(work, effect, result.amount, sourceId, ability.id, now);
}
} else if (effect.kind === "heal") {
const rawAmount = talentAdjustedAmount(
@@ -2907,17 +2921,21 @@ function executeTriggeredSpellInWork(
"healing-percent",
ability,
);
const recipient = targetId && !work.mobs[targetId] ? targetId : PLAYER_AURA_ENTITY_ID;
const amount = Math.round(resolveAuraHealingValue(
resolveAuraHealingValue(rawAmount, aurasForEntity(work.auras, sourceId), "done"),
aurasForEntity(work.auras, recipient),
"taken",
));
if (recipient === PLAYER_AURA_ENTITY_ID) {
work.totalHealing += healPlayerInWork(work, amount);
} else {
const healed = healCompanionActorInWork(work, recipient, amount);
work.totalHealing += healed;
const recipients = healingRecipientIds(effect, sourceId, targetId, work.actors,
id => id === PLAYER_AURA_ENTITY_ID ? useCombatStore.getState().playerPosition : getPartyRuntimePosition(id) ?? getMobPosition(id),
[targetId && !work.mobs[targetId] ? targetId : sourceId], combatHasLineOfSight, now);
for (const recipient of recipients) {
const amount = Math.round(resolveAuraHealingValue(
resolveAuraHealingValue(rawAmount, aurasForEntity(work.auras, sourceId), "done"),
aurasForEntity(work.auras, recipient),
"taken",
));
if (recipient === PLAYER_AURA_ENTITY_ID) {
work.totalHealing += healPlayerInWork(work, amount);
} else {
const healed = healCompanionActorInWork(work, recipient, amount);
work.totalHealing += healed;
}
}
} else if (effect.kind === "status") {
for (const mobId of mobTargets) {
@@ -2972,10 +2990,14 @@ function executeTriggeredSpellInWork(
));
}
} else if (effect.kind === "hot") {
const recipient = targetId && !work.mobs[targetId] ? targetId : null;
work.effects.push(timedEffect(
"hot", ability.id, source.actorId, recipient, effect, effect.ticks, effect.intervalMs, now,
));
const recipients = healingRecipientIds(effect, sourceId, targetId, work.actors,
id => id === PLAYER_AURA_ENTITY_ID ? useCombatStore.getState().playerPosition : getPartyRuntimePosition(id) ?? getMobPosition(id),
[targetId && !work.mobs[targetId] ? targetId : sourceId], combatHasLineOfSight, now);
for (const recipient of recipients) {
work.effects.push(timedEffect(
"hot", ability.id, source.actorId, recipient, effect, effect.ticks, effect.intervalMs, now,
));
}
} else if (effect.kind === "shield") {
const amount = equipmentAdjustedCombatAmount(
effect, work.progression.level, work.gearStats, work.classId, ability,
@@ -3106,12 +3128,43 @@ function executeTriggeredSpellInWork(
}
}
function grantDamageResourceInWork(
work: MutableCastWork, effect: AbilityDamageResource, damage: number,
sourceActorId: string, abilityId: string, now: number,
): void {
const gain = effect.onDamageResource;
if (!gain || damage <= 0 || gain.amount <= 0) return;
const player = sourceActorId === PLAYER_AURA_ENTITY_ID;
const actor = player ? null : ensureCompanionActorInWork(work, sourceActorId) ?? work.actors[sourceActorId];
if (player ? work.health <= 0 : !actor?.alive) return;
const pool = actor?.resources[gain.resource];
const maximum = player ? work.maxResourcePools[gain.resource] ?? defaultResourceMaximum(gain.resource)
: pool?.maximum ?? defaultResourceMaximum(gain.resource);
const current = player ? work.resourcePools[gain.resource] ?? 0 : pool?.current ?? 0;
const auras = aurasForEntity(work.auras, sourceActorId);
const requested = Math.max(0, resolveAuraResourceValue(
resolveAuraResourceValue(gain.amount, auras, gain.resource, "generation"), auras, "power", "generation",
));
const restored = Math.max(0, Math.min(requested, maximum - current));
if (player) {
work.resourcePools[gain.resource] = current + restored;
if (gain.resource === resourceProfileForClass(work.classId).type) work.resource = current + restored;
} else if (actor) {
const next = { ...actor, resources: { ...actor.resources,
[gain.resource]: { ...(pool ?? normalizeActorResourcePool(gain.resource, current, maximum)), current: current + restored } } };
work.actors[sourceActorId] = next;
mirrorCompanionActor(next);
}
recordCombatEventInWork(work, { occurredAt: now, kind: "resource", sourceActorId, targetActorId: sourceActorId,
abilityId, rawAmount: requested, effectiveAmount: restored, overheal: requested - restored, absorbed: 0, critical: false });
}
function timedEffect(
kind: "dot" | "hot",
abilityId: string,
sourceActorId: AggroActorId,
targetId: string | null,
scaling: AbilityAmountScaling & { readonly coefficient: number },
scaling: AbilityAmountScaling & AbilityDamageResource & { readonly coefficient: number; readonly startsImmediately?: boolean },
ticks: number,
intervalMs: number,
now: number,
@@ -3132,11 +3185,14 @@ function timedEffect(
...(scaling.pointsPerLevel === undefined ? {} : { pointsPerLevel: scaling.pointsPerLevel }),
...(scaling.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: scaling.coefficientPerLevel }),
...(scaling.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: scaling.bonusPowerCoefficient }),
...(scaling.attackPowerCoefficient === undefined ? {} : { attackPowerCoefficient: scaling.attackPowerCoefficient }),
...(scaling.spiritCoefficient === undefined ? {} : { spiritCoefficient: scaling.spiritCoefficient }),
...(scaling.sourceLevel === undefined ? {} : { sourceLevel: scaling.sourceLevel }),
...(scaling.maxScalingLevel === undefined ? {} : { maxScalingLevel: scaling.maxScalingLevel }),
...(scaling.onDamageResource ? { onDamageResource: scaling.onDamageResource } : {}),
remainingTicks: ticks,
intervalMs,
nextTickAt: now + intervalMs,
nextTickAt: now + (scaling.startsImmediately ? 0 : intervalMs),
...(tag ? { tag } : {}),
...(damageSchool ? { damageSchool } : {}),
...(spellPenetration ? { spellPenetration } : {}),
@@ -3495,7 +3551,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
raceId,
secondaryClassId,
);
const initialPools = initialResourcePools(classId, abilities, maxResource);
// RoM secondary pools use native resource limits, not the mapped WoW class's mana.
const initialPools = initialResourcePools(classId, abilities, maxResource,
isRomClassId(classId) ? defaultResourceMaximum("mana")
: deriveCharacterStats(baseClassFor(classId), progression.level, gearStats, raceId).mana);
set((state) => ({
...initialState(),
classId,
@@ -4500,7 +4559,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
summons: [...state.summons],
areaEffects: [...state.areaEffects],
combatEvents: [...state.combatEvents],
actors: { ...state.actors },
actors: { ...combatActorSnapshots(state) },
cooldowns: { ...state.cooldowns },
health: healthAfterCosts,
maxHealth: state.maxHealth,
@@ -4606,7 +4665,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
ability,
);
for (const id of affectedMobIds) {
resolveAndDamageMobInWork(
const result = resolveAndDamageMobInWork(
state.classId,
work,
id,
@@ -4618,6 +4677,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
`${castThreatSource.actorId}:${ability.id}:${id}:${now}:direct`,
ability,
);
grantDamageResourceInWork(work, effect, result.amount, castThreatSource.actorId, ability.id, now);
}
} else if (effect.kind === "finisher-damage") {
const coefficient = effect.baseCoefficient + effect.perComboCoefficient * work.comboPoints;
@@ -4723,6 +4783,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
);
const criticalChance = Math.max(0, casterStats.spellCritPercent / 100);
let directHealing = 0;
healingTargetIds = [...healingRecipientIds(effect, PLAYER_AURA_ENTITY_ID, targetId, work.actors,
id => friendlyPosition(id) ?? getMobPosition(id), healingTargetIds, combatHasLineOfSight, now)];
for (const healingTargetId of [...new Set(healingTargetIds)]) {
const friendlyTarget = partyMembers.find((member) => member.id === healingTargetId);
const friendlySummon = work.summons.find((summon) => summon.id === healingTargetId);
@@ -5029,14 +5091,20 @@ export const useCombatStore = create<CombatState>((set, get) => ({
} else if (effect.kind === "hot") {
if (targeting?.shape === "ground") continue;
const hotTargetId = effectiveTarget === "friendly" ? targetId : null;
work.effects = work.effects.filter((entry) => !(
entry.kind === "hot"
&& entry.sourceAbilityId === ability.id
&& entry.targetId === hotTargetId
));
work.effects.push(timedEffect(
"hot", ability.id, castThreatSource.actorId, hotTargetId, effect, effect.ticks, effect.intervalMs, now,
));
const recipients = healingRecipientIds(effect, PLAYER_AURA_ENTITY_ID, targetId, work.actors,
id => id === PLAYER_AURA_ENTITY_ID ? playerOrigin : getPartyRuntimePosition(id) ?? getMobPosition(id),
[hotTargetId ?? PLAYER_AURA_ENTITY_ID], combatHasLineOfSight, now);
for (const recipient of recipients) {
const normalizedTarget = recipient === PLAYER_AURA_ENTITY_ID ? null : recipient;
work.effects = work.effects.filter((entry) => !(
entry.kind === "hot"
&& entry.sourceAbilityId === ability.id
&& entry.targetId === normalizedTarget
));
work.effects.push(timedEffect(
"hot", ability.id, castThreatSource.actorId, normalizedTarget, effect, effect.ticks, effect.intervalMs, now,
));
}
} else if (effect.kind === "taunt") {
const friendlyAttackers = effectiveTarget === "friendly" && targetId
? Object.values(work.mobs)
@@ -7849,7 +7917,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
: baseAmount;
if (effect.kind === "dot" && effect.targetId) {
const dotAttackKind = ability ? abilityAttackKind(state.classId, ability) : "spell";
resolveAndDamageMobInWork(
const result = resolveAndDamageMobInWork(
state.classId,
work,
effect.targetId,
@@ -7869,6 +7937,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
canBlock: false,
},
);
grantDamageResourceInWork(work, effect, result.amount, effect.sourceActorId, effect.sourceAbilityId, nextTickAt);
} else if (effect.kind === "hot") {
const hotTargetId = effect.targetId ?? PLAYER_AURA_ENTITY_ID;
const modifiedHealing = Math.round(resolveAuraHealingValue(