Release Healer Man 0.1.19

This commit is contained in:
phenom committed 2026-09-06 11:06:03 -04:00
1 parent eaddad4134
commit d9bebb4e81
13 files changed
+343 -37

No files matched your search

+244 -19
View File
@@ -155,6 +155,8 @@ import {
deriveCharacterStats,
equipmentAttackPower,
hastedDuration,
healthFromTotalStamina,
MANA_POOL_MULTIPLIER,
ratingPercent,
type CharacterStats,
type WotlkAttackKind,
@@ -673,6 +675,8 @@ export interface CombatState {
confirmGroundTarget: (position?: CombatPosition, now?: number) => CastResult;
cancelGroundTarget: () => void;
synchronizeActors: () => void;
/** Grants learned long-term class stat buffs to the full party without setup casts. */
applyDefaultPartyBuffs: (now?: number) => void;
/** Commits companion simulation results to authoritative actors and mirrors party frames. */
commitCompanionActors: (members: readonly import("./partyStore").PartyMember[]) => void;
castSlot: (index: number, origin?: CombatPosition, now?: number) => CastResult;
@@ -1517,7 +1521,10 @@ export function resolvedAbilityCosts(
: cost.resource === activeResourceType
? state.maxResource
: state.maxResourcePools[cost.resource] ?? defaultResourceMaximum(cost.resource);
const baseAmount = cost.percentage ? maximum * cost.amount / 100 : cost.amount;
const percentageMaximum = cost.resource === "mana" && ability.source === "wow335-progression"
? maximum / MANA_POOL_MULTIPLIER
: maximum;
const baseAmount = cost.percentage ? percentageMaximum * cost.amount / 100 : cost.amount;
const rawAmount = Math.max(0, Math.round(applyTalentModifierValue(
baseAmount,
modifiers,
@@ -1889,11 +1896,16 @@ function auraDefinitionForAbility(
ability: AbilityDefinition,
durationMs = definition.durationMs,
): AuraDefinition {
const defaultPartyBuffFamily = DEFAULT_PARTY_BUFF_FAMILY.get(ability.name.toLowerCase());
return {
...definition,
description: definition.description ?? ability.description,
icon: definition.icon ?? ability.icon,
durationMs,
...(defaultPartyBuffFamily ? {
uniqueBySource: false,
exclusiveGroup: `default-party-buff:${defaultPartyBuffFamily}`,
} : {}),
};
}
@@ -1916,14 +1928,16 @@ function applyAuraStats(base: CharacterStats, auras: readonly ActiveAura[]): Cha
"all-resistance",
"magical-defense",
);
const stamina = primary(base.stamina, "stamina");
const staminaHealth = healthFromTotalStamina(stamina) - healthFromTotalStamina(base.stamina);
return {
...base,
strength: primary(base.strength, "strength"),
agility: primary(base.agility, "agility", "dexterity"),
stamina: primary(base.stamina, "stamina"),
stamina,
intellect: primary(base.intellect, "intellect"),
spirit: primary(base.spirit, "spirit", "wisdom"),
health: stat(base.health, "maximum-health"),
health: stat(base.health + staminaHealth, "maximum-health"),
mana: stat(base.mana, "maximum-mana"),
attackPower: stat(base.attackPower, "attack-power", "physical-attack"),
rangedAttackPower: stat(base.rangedAttackPower, "ranged-attack-power", "physical-attack"),
@@ -1951,6 +1965,53 @@ function applyAuraStats(base: CharacterStats, auras: readonly ActiveAura[]): Cha
};
}
function auraMaximumHealthBonus(
base: CharacterStats,
auras: readonly ActiveAura[],
targetId: string,
): number {
return Math.round(applyAuraStats(base, aurasForEntity(auras, targetId)).health - base.health);
}
function auraAdjustedPlayerMaxHealth(
classId: ClassId,
level: number,
modifiers: readonly TalentModifier[],
gearStats: ItemStats,
raceId: RaceId,
secondaryClassId: ClassId | null | undefined,
auras: readonly ActiveAura[],
): number {
const baseStats = playerCharacterStats(classId, level, gearStats, raceId, secondaryClassId ?? null, modifiers);
return Math.max(
1,
talentAdjustedMaxHealth(classId, level, modifiers, gearStats, raceId, secondaryClassId)
+ auraMaximumHealthBonus(baseStats, auras, PLAYER_AURA_ENTITY_ID),
);
}
const DEFAULT_PARTY_BUFF_FAMILY = Object.freeze(new Map<string, string>([
["power word: fortitude", "fortitude"],
["prayer of fortitude", "fortitude"],
["divine spirit", "spirit"],
["prayer of spirit", "spirit"],
["shadow protection", "shadow-protection"],
["prayer of shadow protection", "shadow-protection"],
["arcane intellect", "arcane-intellect"],
["arcane brilliance", "arcane-intellect"],
["mark of the wild", "mark-of-the-wild"],
["gift of the wild", "mark-of-the-wild"],
]));
function defaultPartyBuffPotency(
effects: readonly Extract<AbilityEffect, { kind: "apply-aura" }>[],
): number {
return effects.reduce((total, effect) => total + (effect.aura.modifiers ?? []).reduce(
(subtotal, modifier) => subtotal + Math.abs(modifier.value) * (modifier.operation === "percent" ? 1_000 : 1),
0,
), 0);
}
function syncPassiveAuras(
current: readonly ActiveAura[],
abilities: readonly AbilityDefinition[],
@@ -2199,13 +2260,15 @@ function grantMobExperience(
progression.levelsGained += result.levelsGained;
if (result.levelsGained > 0) {
work.talentModifiers = characterStatModifiers(classId, result.level, work.talentRanks);
progression.maxHealth = talentAdjustedMaxHealth(
work.auras = syncPassiveAuras(work.auras, work.abilities, result.level, work.talentRanks, combatNow());
progression.maxHealth = auraAdjustedPlayerMaxHealth(
classId,
result.level,
work.talentModifiers,
work.gearStats,
work.raceId,
work.secondaryClassId,
work.auras,
);
progression.health = progression.maxHealth;
work.health = progression.health;
@@ -2218,7 +2281,6 @@ function grantMobExperience(
work.raceId,
work.secondaryClassId,
);
work.auras = syncPassiveAuras(work.auras, work.abilities, result.level, work.talentRanks, combatNow());
}
return scaledAward;
}
@@ -2722,12 +2784,14 @@ function executeTriggeredSpellInWork(
const recipient = effect.recipient === "caster"
? sourceId
: targetId ?? sourceId;
work.auras = [...applyCombatAura(work.auras, {
const previousAuras = work.auras;
work.auras = [...applyCombatAura(previousAuras, {
definition: auraDefinitionForAbility(effect.aura, ability),
sourceId,
targetId: recipient,
now,
}).auras];
applyAuraHealthTransitionInWork(work, recipient, previousAuras, work.auras);
if (effect.control && work.mobs[recipient]) {
work.mobs[recipient] = applyStatus(work.mobs[recipient], {
kind: "status",
@@ -2966,6 +3030,47 @@ function mirrorCompanionActor(actor: CombatActorState): void {
});
}
function applyAuraHealthTransitionInWork(
work: MutableCastWork,
targetId: string,
previousAuras: readonly ActiveAura[],
nextAuras: readonly ActiveAura[],
): void {
if (targetId === PLAYER_AURA_ENTITY_ID) {
const maxHealth = auraAdjustedPlayerMaxHealth(
work.classId,
work.progression.level,
work.talentModifiers,
work.gearStats,
work.raceId,
work.secondaryClassId,
nextAuras,
);
const delta = maxHealth - work.maxHealth;
if (!delta) return;
work.maxHealth = maxHealth;
work.health = work.health <= 0 ? 0 : clamp(work.health + delta, 1, work.maxHealth);
work.progression.maxHealth = work.maxHealth;
work.progression.health = work.health;
const actor = work.actors[targetId];
if (actor) work.actors[targetId] = { ...actor, health: work.health, maxHealth: work.maxHealth };
return;
}
const member = usePartyStore.getState().members.find((candidate) => candidate.id === targetId);
const actor = ensureCompanionActorInWork(work, targetId);
if (!member || !actor) return;
const base = deriveCharacterStats(member.classId, member.level, member.gearStats, member.raceId);
const delta = auraMaximumHealthBonus(base, nextAuras, targetId)
- auraMaximumHealthBonus(base, previousAuras, targetId);
if (!delta) return;
const maxHealth = Math.max(1, actor.maxHealth + delta);
const health = actor.health <= 0 ? 0 : clamp(actor.health + delta, 1, maxHealth);
const nextActor = Object.freeze({ ...actor, health, maxHealth });
work.actors[targetId] = nextActor;
mirrorCompanionActor(nextActor);
}
function healCompanionActorInWork(work: MutableCastWork, id: string, amount: number): number {
const actor = ensureCompanionActorInWork(work, id);
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
@@ -3268,13 +3373,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-")
? resolvedEquipmentStats(state.equipment, state.inventory, context)
: activeEquipmentStatsForClass(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
const maxHealth = talentAdjustedMaxHealth(
const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId,
state.level,
state.talentModifiers,
gearStats,
state.raceId,
state.secondaryClassId,
state.auras,
);
const maxResource = talentAdjustedMaxResource(
state.classId,
@@ -3542,13 +3648,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
role: state.gearRole,
level: state.level,
}) : activeEquipmentStatsForClass(change.assignments, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
const maxHealth = talentAdjustedMaxHealth(
const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId,
state.level,
state.talentModifiers,
gearStats,
state.raceId,
state.secondaryClassId,
state.auras,
);
const maxResource = talentAdjustedMaxResource(
state.classId,
@@ -3591,13 +3698,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
role: state.gearRole,
level: state.level,
}) : activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
const maxHealth = talentAdjustedMaxHealth(
const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId,
state.level,
state.talentModifiers,
gearStats,
state.raceId,
state.secondaryClassId,
state.auras,
);
const maxResource = talentAdjustedMaxResource(
state.classId,
@@ -3814,6 +3922,59 @@ export const useCombatStore = create<CombatState>((set, get) => ({
members: party.members.map((member) => memberFromCompanionActor(member, actors[member.id])),
}));
},
applyDefaultPartyBuffs: (now = combatNow()) => {
get().synchronizeActors();
const state = get();
const partyMembers = usePartyStore.getState().members;
const sources = [
{
id: PLAYER_AURA_ENTITY_ID,
level: state.level,
abilities: state.abilities,
},
...partyMembers.filter((member) => member.health > 0).map((member) => ({
id: member.id,
level: member.level,
abilities: abilitiesForClass(member.classId),
})),
];
const selected = new Map<string, {
sourceId: string;
ability: AbilityDefinition;
effects: readonly Extract<AbilityEffect, { kind: "apply-aura" }>[];
potency: number;
}>();
for (const source of sources) {
for (const catalogAbility of source.abilities) {
const family = DEFAULT_PARTY_BUFF_FAMILY.get(catalogAbility.name.toLowerCase());
if (!family || catalogAbility.passive || !isAbilityUnlocked(catalogAbility, source.level)) continue;
const ability = abilityAtLevel(catalogAbility, source.level);
const effects = ability.effects.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => (
effect.kind === "apply-aura" && effect.aura.disposition === "buff"
));
if (!effects.length) continue;
const potency = defaultPartyBuffPotency(effects);
if ((selected.get(family)?.potency ?? Number.NEGATIVE_INFINITY) >= potency) continue;
selected.set(family, { sourceId: source.id, ability, effects, potency });
}
}
const recipients = [PLAYER_AURA_ENTITY_ID, ...partyMembers.filter((member) => member.health > 0).map((member) => member.id)];
for (const [family, buff] of selected) {
for (const recipientId of recipients) {
for (const effect of buff.effects) {
get().applyActorAura(buff.sourceId, recipientId, buff.ability, {
...effect,
aura: {
...effect.aura,
uniqueBySource: false,
exclusiveGroup: `default-party-buff:${family}`,
},
}, now);
}
}
}
get().synchronizeActors();
},
commitCompanionActors: (members) => {
let actors: Readonly<Record<string, CombatActorState>> = {};
set((state) => {
@@ -4760,12 +4921,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
? affectedMobIds
: [targetId ?? PLAYER_AURA_ENTITY_ID];
for (const recipientId of recipients) {
work.auras = [...applyCombatAura(work.auras, {
const previousAuras = work.auras;
work.auras = [...applyCombatAura(previousAuras, {
definition: auraDefinition,
sourceId: PLAYER_AURA_ENTITY_ID,
targetId: recipientId,
now,
}).auras];
applyAuraHealthTransitionInWork(work, recipientId, previousAuras, work.auras);
if (auraDefinition.controlImmunities?.length) {
if (
recipientId === PLAYER_AURA_ENTITY_ID
@@ -5733,8 +5896,49 @@ export const useCombatStore = create<CombatState>((set, get) => ({
targetId: targetActorId,
now,
});
let health = state.health;
let maxHealth = state.maxHealth;
const actors = { ...state.actors };
let companionActor: CombatActorState | null = null;
if (targetActorId === PLAYER_AURA_ENTITY_ID) {
const nextMaxHealth = auraAdjustedPlayerMaxHealth(
state.classId,
state.level,
state.talentModifiers,
state.gearStats,
state.raceId,
state.secondaryClassId,
result.auras,
);
const delta = nextMaxHealth - maxHealth;
maxHealth = nextMaxHealth;
health = health <= 0 ? 0 : clamp(health + delta, 1, maxHealth);
const actor = actors[targetActorId];
if (actor) actors[targetActorId] = { ...actor, health, maxHealth };
} else {
const member = usePartyStore.getState().members.find((candidate) => candidate.id === targetActorId);
const actor = member ? companionActorFromMember(
member,
entityAbsorbAmount(state.auras, targetActorId),
state.actors[targetActorId],
) : null;
if (member && actor) {
const base = deriveCharacterStats(member.classId, member.level, member.gearStats, member.raceId);
const delta = auraMaximumHealthBonus(base, result.auras, targetActorId)
- auraMaximumHealthBonus(base, state.auras, targetActorId);
if (delta) {
const actorMaxHealth = Math.max(1, actor.maxHealth + delta);
const actorHealth = actor.health <= 0 ? 0 : clamp(actor.health + delta, 1, actorMaxHealth);
companionActor = Object.freeze({ ...actor, health: actorHealth, maxHealth: actorMaxHealth });
actors[targetActorId] = companionActor;
}
}
}
set({
auras: result.auras,
health,
maxHealth,
actors,
combatEvents: recordCombatEvent(state.combatEvents, {
occurredAt: now,
kind: "aura",
@@ -5749,6 +5953,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
critical: false,
}),
});
if (companionActor) mirrorCompanionActor(companionActor);
return true;
},
@@ -7028,6 +7233,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const delta = clamp(Number.isFinite(deltaSeconds) ? deltaSeconds : 0, 0, 1);
const resourceProfile = resourceProfileForClass(state.classId);
const inCombat = Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead);
const auraExpiration = expireAuras(state.auras, now);
const activeAuras = [...auraExpiration.active];
const baseRegeneration = applyTalentModifierValue(
(resourceProfile.type === "rage" || resourceProfile.type === "runic-power")
? 0
@@ -7037,7 +7244,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
state.talentModifiers,
"resource-regeneration-percent",
);
const playerAuras = aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID);
const playerAuras = aurasForEntity(activeAuras, PLAYER_AURA_ENTITY_ID);
const regeneration = resolveAuraResourceValue(
resolveAuraResourceValue(baseRegeneration, playerAuras, resourceProfile.type, "regeneration"),
playerAuras,
@@ -7097,7 +7304,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
abilities: state.abilities,
mobs: { ...state.mobs },
effects: [],
auras: [...expireAuras(state.auras, now).active],
auras: activeAuras,
summons: state.summons.filter((summon) => summon.expiresAt > now),
areaEffects: state.areaEffects.filter((effect) => effect.expiresAt > now),
combatEvents: [...state.combatEvents],
@@ -7132,6 +7339,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
raceId: state.raceId,
secondaryClassId: state.secondaryClassId,
};
for (const targetId of new Set(auraExpiration.expired.map((aura) => aura.targetId))) {
applyAuraHealthTransitionInWork(work, targetId, state.auras, activeAuras);
}
let autoAttackTargetId = state.autoAttackTargetId;
let nextAutoAttackAt = state.nextAutoAttackAt;
@@ -7847,9 +8057,20 @@ export const useCombatStore = create<CombatState>((set, get) => ({
activeCast: null,
schoolLockedUntil: {},
nextWoundAnimationAt: 0,
}]));
}]));
const auras = syncPassiveAuras([], state.abilities, state.level, state.talentRanks, combatNow());
const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId,
state.level,
state.talentModifiers,
state.gearStats,
state.raceId,
state.secondaryClassId,
auras,
);
return {
health: state.maxHealth,
health: maxHealth,
maxHealth,
controlledUntil: 0,
controlMechanic: null,
shield: 0,
@@ -7876,7 +8097,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
selectedTargetId: null,
selectedFriendlyActorId: null,
effects: [],
auras: syncPassiveAuras([], state.abilities, state.level, state.talentRanks, combatNow()),
auras,
summons: [],
areaEffects: [],
pendingGroundTarget: null,
@@ -7912,13 +8133,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return false;
}
const talentModifiers = characterStatModifiers(state.classId, state.level, result.ranks);
const maxHealth = talentAdjustedMaxHealth(
const auras = syncPassiveAuras(state.auras, state.abilities, state.level, result.ranks, combatNow());
const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId,
state.level,
talentModifiers,
state.gearStats,
state.raceId,
state.secondaryClassId,
auras,
);
const maxResource = talentAdjustedMaxResource(
state.classId,
@@ -7935,7 +8158,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
maxResource,
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource),
auras: syncPassiveAuras(state.auras, state.abilities, state.level, result.ranks, combatNow()),
auras,
feedback: nextFeedback("talent", "Conquest node rank allocated."),
});
return true;
@@ -7944,13 +8167,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
resetTalents: () => set((state) => {
const talentRanks = resetTalentRanks(state.talentRanks, state.classId);
const talentModifiers = characterStatModifiers(state.classId, state.level, talentRanks);
const maxHealth = talentAdjustedMaxHealth(
const auras = syncPassiveAuras(state.auras, state.abilities, state.level, talentRanks, combatNow());
const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId,
state.level,
talentModifiers,
state.gearStats,
state.raceId,
state.secondaryClassId,
auras,
);
const maxResource = talentAdjustedMaxResource(
state.classId,
@@ -7967,7 +8192,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
maxResource,
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource),
auras: syncPassiveAuras(state.auras, state.abilities, state.level, talentRanks, combatNow()),
auras,
feedback: nextFeedback("talent", "Talent points reset."),
};
}),