Update Healer Man server

This commit is contained in:
phenom committed 2026-09-20 11:23:13 -04:00
1 parent 1fedb9368d
commit cc3b2fa4d5
27 files changed
+1988 -83

No files matched your search

+483 -21
View File
@@ -64,6 +64,7 @@ import {
advanceCombatResourcePool,
spendDeathKnightRunes,
} from "./combatResources";
import { cooldownResetCandidates } from "./cooldownReset";
import { resolveHealing, type HealingResolution } from "./combatHealing";
import {
awardExperience,
@@ -172,6 +173,19 @@ import {
type WotlkAttackKind,
} from "./wotlkStats";
import { healingRecipientIds } from "./healingAbilityRules";
import {
ROGUE_DEADLY_POISON_AURA,
ROGUE_DEADLY_POISON_COATING_TAG,
ROGUE_DEADLY_POISON_DOSE_TAG,
ROGUE_ENVENOM_BUFF_TAG,
ROGUE_INSTANT_POISON_TAG,
hasActiveAuraTag,
matchingAuraStacks,
rogueDeadlyPoisonTickDamage,
rogueEnvenomPreservesDeadlyPoison,
rogueInstantPoisonDamage,
roguePoisonApplicationChances,
} from "./roguePoison";
import {
combatAttackerFromCharacterStats,
combatDefenderFromCharacterStats,
@@ -583,6 +597,13 @@ export interface DamageResult {
readonly combatResult?: CombatResult;
}
export interface RoguePoisonProcResult {
readonly instantProcs: number;
readonly deadlyApplications: number;
readonly deadlyStacks: number;
readonly damage: number;
}
interface PlayerDamageRollContext {
readonly attackKind: WotlkAttackKind;
readonly seed: string;
@@ -731,7 +752,26 @@ export interface CombatState {
school?: DamageSchool,
rollContext?: PlayerDamageRollContext,
) => DamageResult;
triggerRogueWeaponPoisons: (
sourceActorId: AggroActorId,
targetId: string,
sourceAbilityId: string,
hitCount?: number,
now?: number,
) => RoguePoisonProcResult;
consumeRogueDeadlyPoison: (
sourceActorId: AggroActorId,
targetId: string,
maximumDoses: number,
now?: number,
) => number;
addHealingThreat: (source: ThreatSource, effectiveHealing: number) => void;
applyActorThreatEffect: (
actorId: AggroActorId,
effect: Extract<AbilityEffect, { readonly kind: "threat" }>,
redirectTargetActorId?: AggroActorId | null,
now?: number,
) => void;
tauntMob: (id: string, source: ThreatSource, durationMs: number, now?: number) => boolean;
interruptMob: (id: string, sourceAbilityId: string, lockoutMs: number, now?: number) => boolean;
controlMob: (id: string, sourceAbilityId: string, status: MobStatusKind, durationMs: number, now?: number) => boolean;
@@ -1219,6 +1259,7 @@ function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkA
}
export function abilityDamageSchool(classId: ClassId, ability: AbilityDefinition): DamageSchool {
if (["Envenom", "Instant Poison", "Deadly Poison"].includes(ability.name)) return "nature";
if (abilityAttackKind(classId, ability) !== "spell") return "physical";
const searchable = `${ability.id} ${ability.name} ${ability.description}`.toLowerCase();
if (/\b(?:fire|flame|immolate|incinerate|searing|lava)\b/.test(searchable)) return "fire";
@@ -2797,6 +2838,23 @@ function resolveAndDamageMobInWork(
attackKind,
}, now);
}
if (
combatResult.landed
&& result.amount > 0
&& attackKind === "melee"
&& !periodic
&& !["Envenom", "Instant Poison", "Deadly Poison"].includes(ability?.name ?? "")
) {
const hitCount = ability?.name === "Mutilate" || (rules.isAutoAttack && rules.isDualWielding) ? 2 : 1;
applyRogueWeaponPoisonsInWork(
work,
source.actorId,
id,
ability?.id ?? source.abilityId ?? "melee-hit",
hitCount,
now,
);
}
if (result.killed) {
evaluateProcsInWork(work, {
trigger: "kill",
@@ -3119,9 +3177,7 @@ function executeTriggeredSpellInWork(
};
}
} else if (effect.kind === "cooldown-reset") {
const candidates = effect.abilityName
? work.abilities.filter((candidate) => candidate.name.toLowerCase() === effect.abilityName!.toLowerCase())
: work.abilities;
const candidates = cooldownResetCandidates(work.abilities, ability.id, effect.abilityName);
for (const candidate of candidates) {
if (effect.mode === "reset") work.cooldowns[candidate.id] = 0;
else {
@@ -3223,6 +3279,206 @@ function timedEffect(
};
}
interface RoguePoisonSourceStats {
readonly classId: ClassId;
readonly level: number;
readonly attackPower: number;
readonly spellPenetration: number;
}
function roguePoisonSourceStats(
work: MutableCastWork,
sourceActorId: AggroActorId,
): RoguePoisonSourceStats | null {
if (sourceActorId === PLAYER_AGGRO_ID) {
if (baseClassFor(work.classId) !== "rogue") return null;
const stats = playerCharacterStats(
work.classId,
work.progression.level,
work.gearStats,
work.raceId,
work.secondaryClassId ?? null,
work.talentModifiers,
);
return {
classId: work.classId,
level: work.progression.level,
attackPower: stats.attackPower,
spellPenetration: work.gearStats.spellPenetration,
};
}
const member = usePartyStore.getState().members.find((candidate) => candidate.id === sourceActorId);
if (!member || baseClassFor(member.classId) !== "rogue") return null;
const stats = deriveCharacterStats(member.classId, member.level, member.gearStats, member.raceId);
return {
classId: member.classId,
level: member.level,
attackPower: stats.attackPower,
spellPenetration: member.gearStats.spellPenetration,
};
}
function replaceDeadlyPoisonEffect(
work: MutableCastWork,
sourceActorId: AggroActorId,
targetId: string,
stacks: number,
now: number,
refreshDuration: boolean,
): void {
const ability = abilityById("rogue-deadly-poison");
const source = roguePoisonSourceStats(work, sourceActorId);
const existing = work.effects.find((effect) => (
effect.kind === "dot"
&& effect.sourceActorId === sourceActorId
&& effect.targetId === targetId
&& effect.sourceAbilityId === "rogue-deadly-poison"
));
work.effects = work.effects.filter((effect) => effect !== existing);
if (!ability || !source || stacks <= 0) return;
const amount = rogueDeadlyPoisonTickDamage(source.level, source.attackPower, stacks);
if (!refreshDuration && existing) {
work.effects.push({ ...existing, fixedAmount: amount });
return;
}
work.effects.push({
...timedEffect(
"dot",
ability.id,
sourceActorId,
targetId,
{ coefficient: 0 },
4,
3_000,
now,
ROGUE_DEADLY_POISON_DOSE_TAG,
"nature",
source.spellPenetration,
),
fixedAmount: amount,
});
}
function applyRogueWeaponPoisonsInWork(
work: MutableCastWork,
sourceActorId: AggroActorId,
targetId: string,
sourceAbilityId: string,
hitCount: number,
now: number,
): RoguePoisonProcResult {
const source = roguePoisonSourceStats(work, sourceActorId);
const target = work.mobs[targetId];
if (!source || !target || target.dead) {
return { instantProcs: 0, deadlyApplications: 0, deadlyStacks: 0, damage: 0 };
}
const instantCoating = hasActiveAuraTag(work.auras, ROGUE_INSTANT_POISON_TAG, sourceActorId, now);
const deadlyCoating = hasActiveAuraTag(work.auras, ROGUE_DEADLY_POISON_COATING_TAG, sourceActorId, now);
const envenomBuff = hasActiveAuraTag(work.auras, ROGUE_ENVENOM_BUFF_TAG, sourceActorId, now);
const isShiv = abilityById(sourceAbilityId)?.name === "Shiv";
const chances = roguePoisonApplicationChances(envenomBuff);
let instantProcs = 0;
let deadlyApplications = 0;
let damage = 0;
const applyInstant = (): void => {
if (!instantCoating || work.mobs[targetId]?.dead) return;
const result = damageMobInWork(
work.classId,
work,
targetId,
rogueInstantPoisonDamage(source.level, source.attackPower),
now,
threatSourceForActor(work, sourceActorId, "rogue-instant-poison", true),
"nature",
source.spellPenetration,
);
if (result.amount <= 0) return;
instantProcs += 1;
damage += result.amount;
};
for (let hitIndex = 0; hitIndex < Math.max(1, Math.floor(hitCount)); hitIndex += 1) {
if (work.mobs[targetId]?.dead) break;
if (!isShiv && instantCoating && stableCombatUnit(
`${sourceActorId}:${targetId}:${sourceAbilityId}:${now}:${hitIndex}:instant`,
) < chances.instant) {
applyInstant();
}
if (!deadlyCoating || (!isShiv && stableCombatUnit(
`${sourceActorId}:${targetId}:${sourceAbilityId}:${now}:${hitIndex}:deadly`,
) >= chances.deadly)) continue;
const previousStacks = matchingAuraStacks(
work.auras,
ROGUE_DEADLY_POISON_DOSE_TAG,
sourceActorId,
targetId,
now,
);
const application = applyCombatAura(work.auras, {
definition: ROGUE_DEADLY_POISON_AURA,
sourceId: sourceActorId,
targetId,
now,
});
work.auras = [...application.auras];
deadlyApplications += 1;
recordCombatEventInWork(work, {
occurredAt: now,
kind: "aura",
sourceActorId,
targetActorId: targetId,
abilityId: "rogue-deadly-poison",
school: "nature",
rawAmount: 0,
effectiveAmount: application.aura.stacks,
overheal: 0,
absorbed: 0,
critical: false,
});
replaceDeadlyPoisonEffect(work, sourceActorId, targetId, application.aura.stacks, now, true);
// At five doses, another Deadly Poison application triggers the poison on
// the other weapon instead of creating a sixth dose.
if (previousStacks >= 5) applyInstant();
}
return {
instantProcs,
deadlyApplications,
deadlyStacks: matchingAuraStacks(
work.auras,
ROGUE_DEADLY_POISON_DOSE_TAG,
sourceActorId,
targetId,
now,
),
damage,
};
}
function consumeRogueDeadlyPoisonInWork(
work: MutableCastWork,
sourceActorId: AggroActorId,
targetId: string,
maximumDoses: number,
now: number,
): number {
const index = work.auras.findIndex((aura) => (
aura.sourceId === sourceActorId
&& aura.targetId === targetId
&& (aura.expiresAt === null || aura.expiresAt > now)
&& aura.definition.utilityTags?.includes(ROGUE_DEADLY_POISON_DOSE_TAG)
));
if (index < 0) return 0;
const aura = work.auras[index];
const consumed = Math.min(aura.stacks, Math.max(0, Math.floor(maximumDoses)));
const remaining = aura.stacks - consumed;
if (remaining <= 0) work.auras.splice(index, 1);
else work.auras[index] = { ...aura, stacks: remaining };
replaceDeadlyPoisonEffect(work, sourceActorId, targetId, remaining, now, false);
return consumed;
}
function entityAbsorbAmount(auras: readonly ActiveAura[], entityId: string): number {
return aurasForEntity(auras, entityId).reduce((total, aura) => (
total + aura.absorbs.reduce((sum, absorb) => sum + Math.max(0, absorb.remaining), 0)
@@ -4800,20 +5056,57 @@ export const useCombatStore = create<CombatState>((set, get) => ({
grantDamageResourceInWork(work, effect, result.amount, castThreatSource.actorId, ability.id, now);
}
} else if (effect.kind === "finisher-damage") {
const coefficient = effect.baseCoefficient + effect.perComboCoefficient * work.comboPoints;
const amount = talentAdjustedAmount(
equipmentAdjustedCombatAmount(
coefficient,
work.progression.level,
work.gearStats,
state.classId,
ability,
),
modifiers,
"damage-percent",
ability,
);
const comboPoints = work.comboPoints;
for (const id of affectedMobIds) {
const envenomDoses = effect.poison
? Math.min(
comboPoints,
effect.poison.maximumDoses,
matchingAuraStacks(
work.auras,
effect.poison.doseAuraTag,
castThreatSource.actorId,
id,
now,
),
)
: 0;
if (
effect.poison
&& envenomDoses > 0
&& !rogueEnvenomPreservesDeadlyPoison(
work.talentRanks,
`${castThreatSource.actorId}:${id}:${ability.id}:${now}:master-poisoner`,
)
) {
consumeRogueDeadlyPoisonInWork(
work,
castThreatSource.actorId,
id,
envenomDoses,
now,
);
}
const scaling = effect.poison
? {
coefficient: 0,
basePoints: effect.poison.basePointsPerDose * envenomDoses,
dieSides: 1,
attackPowerCoefficient: effect.poison.attackPowerCoefficientPerDose * envenomDoses,
}
: effect.baseCoefficient + effect.perComboCoefficient * comboPoints;
const amount = talentAdjustedAmount(
equipmentAdjustedCombatAmount(
scaling,
work.progression.level,
work.gearStats,
state.classId,
ability,
),
modifiers,
"damage-percent",
ability,
);
resolveAndDamageMobInWork(
state.classId,
work,
@@ -5267,12 +5560,18 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}, ability.id, now);
}
} else if (effect.kind === "apply-aura") {
const sourceDurationMs = holyAuraFriendly && ability.name === "Holy Aura" && effect.aura.durationMs !== null
const sourceDurationMs = ability.name === "Envenom"
&& effect.aura.utilityTags?.includes(ROGUE_ENVENOM_BUFF_TAG)
? (work.comboPoints + 1) * 1_000
: holyAuraFriendly && ability.name === "Holy Aura" && effect.aura.durationMs !== null
? Math.max(1_000, effect.aura.durationMs - 2_000)
: effect.aura.durationMs;
const durationMs = sourceDurationMs === null
? null
: talentAdjustedDuration(sourceDurationMs, ability, modifiers);
: ability.name === "Envenom"
&& effect.aura.utilityTags?.includes(ROGUE_ENVENOM_BUFF_TAG)
? sourceDurationMs
: talentAdjustedDuration(sourceDurationMs, ability, modifiers);
const auraDefinition = auraDefinitionForAbility(effect.aura, ability, durationMs);
const recipients = effect.scope === "party"
? [PLAYER_AURA_ENTITY_ID, ...usePartyStore.getState().members.filter((member) => member.health > 0).map((member) => member.id)]
@@ -5681,9 +5980,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}
}
for (const adjustment of cooldownAdjustments) {
const candidates = adjustment.abilityName
? state.abilities.filter((candidate) => candidate.name.toLowerCase() === adjustment.abilityName!.toLowerCase())
: state.abilities;
const candidates = cooldownResetCandidates(state.abilities, ability.id, adjustment.abilityName);
for (const candidate of candidates) {
if (adjustment.mode === "reset") cooldowns[candidate.id] = 0;
else {
@@ -5861,6 +6158,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
);
set({
mobs: work.mobs,
effects: work.effects,
auras: work.auras,
combatEvents: work.combatEvents,
actors: work.actors,
@@ -5896,6 +6194,134 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return result;
},
triggerRogueWeaponPoisons: (
sourceActorId,
targetId,
sourceAbilityId,
hitCount = 1,
now = combatNow(),
) => {
const state = get();
const progression = progressionFromState(state);
const work: MutableCastWork = {
classId: state.classId,
abilities: state.abilities,
mobs: { ...state.mobs },
effects: [...state.effects],
auras: [...state.auras],
summons: [...state.summons],
areaEffects: [...state.areaEffects],
combatEvents: [...state.combatEvents],
actors: { ...state.actors },
cooldowns: { ...state.cooldowns },
health: state.health,
maxHealth: state.maxHealth,
shield: state.shield,
shieldExpiresAt: state.shieldExpiresAt,
resource: state.resource,
maxResource: state.maxResource,
resourcePools: { ...state.resourcePools },
maxResourcePools: { ...state.maxResourcePools },
lastResourceSpentAt: { ...state.lastResourceSpentAt },
comboPoints: state.comboPoints,
comboTargetId: state.comboTargetId,
deathKnightRunes: state.deathKnightRunes,
progression,
totalDamage: 0,
totalHealing: 0,
combatResults: [],
talentRanks: state.talentRanks,
talentModifiers: state.talentModifiers,
gearStats: state.gearStats,
raceId: state.raceId,
secondaryClassId: state.secondaryClassId,
};
const result = applyRogueWeaponPoisonsInWork(
work,
sourceActorId,
targetId,
sourceAbilityId,
hitCount,
now,
);
set({
mobs: work.mobs,
effects: work.effects,
auras: work.auras,
combatEvents: work.combatEvents,
actors: work.actors,
cooldowns: work.cooldowns,
level: progression.level,
xp: progression.xp,
xpToNext: progression.xpToNext,
health: progression.health,
maxHealth: progression.maxHealth,
resource: clamp(work.resource, 0, work.maxResource),
maxResource: work.maxResource,
selectedTargetId: work.mobs[targetId]?.dead && state.selectedTargetId === targetId
? null
: state.selectedTargetId,
autoAttackTargetId: work.mobs[targetId]?.dead && state.autoAttackTargetId === targetId
? null
: state.autoAttackTargetId,
nextAutoAttackAt: work.mobs[targetId]?.dead && state.autoAttackTargetId === targetId
? 0
: state.nextAutoAttackAt,
});
return result;
},
consumeRogueDeadlyPoison: (
sourceActorId,
targetId,
maximumDoses,
now = combatNow(),
) => {
const state = get();
const work: MutableCastWork = {
classId: state.classId,
abilities: state.abilities,
mobs: { ...state.mobs },
effects: [...state.effects],
auras: [...state.auras],
summons: [...state.summons],
areaEffects: [...state.areaEffects],
combatEvents: [...state.combatEvents],
actors: { ...state.actors },
cooldowns: { ...state.cooldowns },
health: state.health,
maxHealth: state.maxHealth,
shield: state.shield,
shieldExpiresAt: state.shieldExpiresAt,
resource: state.resource,
maxResource: state.maxResource,
resourcePools: { ...state.resourcePools },
maxResourcePools: { ...state.maxResourcePools },
lastResourceSpentAt: { ...state.lastResourceSpentAt },
comboPoints: state.comboPoints,
comboTargetId: state.comboTargetId,
deathKnightRunes: state.deathKnightRunes,
progression: progressionFromState(state),
totalDamage: 0,
totalHealing: 0,
combatResults: [],
talentRanks: state.talentRanks,
talentModifiers: state.talentModifiers,
gearStats: state.gearStats,
raceId: state.raceId,
secondaryClassId: state.secondaryClassId,
};
const consumed = consumeRogueDeadlyPoisonInWork(
work,
sourceActorId,
targetId,
maximumDoses,
now,
);
if (consumed > 0) set({ auras: work.auras, effects: work.effects });
return consumed;
},
addHealingThreat: (source, effectiveHealing) => set((state) => {
const engagedIds = Object.values(state.mobs)
.filter((mob) => mob.engaged && !mob.dead)
@@ -5913,6 +6339,42 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return { mobs };
}),
applyActorThreatEffect: (actorId, effect, redirectTargetActorId = null, now = combatNow()) => set((state) => {
const mobs: Record<string, MobCombatState> = { ...state.mobs };
let changed = false;
for (const [id, mob] of Object.entries(mobs)) {
if (!mob.engaged || mob.dead) continue;
const currentThreat = mob.threatByActor[actorId] ?? 0;
const nextThreat = effect.mode === "redirect"
? 0
: effect.mode === "percent"
? currentThreat * Math.max(0, 1 + effect.amount / 100)
: Math.max(0, currentThreat + effect.amount);
const threatByActor = {
...mob.threatByActor,
[actorId]: nextThreat,
...(effect.mode === "redirect" && redirectTargetActorId
? { [redirectTargetActorId]: (mob.threatByActor[redirectTargetActorId] ?? 0) + currentThreat }
: {}),
};
const resolution = resolveAggroTarget(
threatByActor,
eligibleAggroActorIds(state, mob),
mob.targetActorId,
mob.forcedTarget,
now,
);
mobs[id] = {
...mob,
threatByActor,
targetActorId: resolution.targetActorId,
forcedTarget: resolution.forcedTarget,
};
changed = true;
}
return changed ? { mobs } : state;
}),
tauntMob: (id, source, durationMs, now = combatNow()) => {
const target = get().mobs[id];
if (!target || target.dead || durationMs <= 0 || (target.navigationEvadeUntil ?? 0) > now) return false;