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

+246 -15
View File
@@ -71,6 +71,13 @@ import {
isSourceBehindTarget,
} from "./combatSpatial";
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
import { cooldownResetCandidates } from "./cooldownReset";
import {
matchingAuraStacks,
rogueEnvenomPreservesDeadlyPoison,
ROGUE_DEADLY_POISON_DOSE_TAG,
ROGUE_ENVENOM_BUFF_TAG,
} from "./roguePoison";
const OUT_OF_COMBAT_REGEN_FRACTION = 0.06;
const ATTACK_ACQUISITION_RANGE = 30;
@@ -81,6 +88,7 @@ const TANK_TAUNT_DURATION_MS = 3_000;
let lastAdvanceAt = 0;
let nextRegenAt = 0;
const nextWeaponAttackAt = new Map<string, number>();
const pendingInnerFocus = new Set<string>();
export interface PartyPresentationIdentity {
readonly spellId: number;
@@ -168,6 +176,7 @@ export function resetPartyRuntimeClock(): void {
lastAdvanceAt = 0;
nextRegenAt = 0;
nextWeaponAttackAt.clear();
pendingInnerFocus.clear();
}
export function activePartyTargetId(
@@ -709,6 +718,9 @@ function advancePartyWeaponAttack(member: PartyMember, targetId: string, now: nu
const abilityId = "party-auto-attack";
const result = useCombatStore.getState().damageMob(targetId, partyAttackDamage(member), now,
{ actorId: member.id, role: member.role, abilityId }, "physical");
if (result.amount > 0 && baseClassFor(member.classId) === "rogue") {
useCombatStore.getState().triggerRogueWeaponPoisons(member.id, targetId, abilityId, 1, now);
}
const identity = partyAttackPresentation(member);
for (const phase of ["release", "impact"] as const) {
emitCombatPresentation({ occurredAt: now, phase, actorGroup: "party", sourceActorId: member.id,
@@ -878,6 +890,11 @@ interface PartySupportIntent {
readonly targetId: string;
}
function abilityHasDirectHealing(ability: AbilityDefinition): boolean {
return ability.effects.some((effect) => effect.kind === "heal")
|| channelAmountEffects(ability, 1).some(({ effect }) => effect.kind === "heal");
}
function choosePartySupportIntent(
member: PartyMember,
members: readonly PartyMember[],
@@ -963,17 +980,102 @@ function choosePartySupportIntent(
|| (right.maxHealth - right.health) - (left.maxHealth - left.health)
|| left.id.localeCompare(right.id)
));
const friendlyTargets = injured.map((candidate) => ({
id: candidate.id,
health: candidate.health,
maxHealth: candidate.maxHealth,
role: candidate.role,
}));
const lowAllies = injured.filter((candidate) => (
candidate.health / Math.max(1, candidate.maxHealth) <= 0.6
));
const lowestHealthFraction = injured[0]
? injured[0].health / Math.max(1, injured[0].maxHealth)
: 1;
if (!pendingInnerFocus.has(member.id)
&& (lowestHealthFraction <= 0.4 || lowAllies.length >= 2)) {
const innerFocus = choosePartyAbility(member, {
now,
target: null,
injuredAllies: injured.length,
lowestHealthFraction,
friendlyTargets,
canUseAbility: ability => ability.name === "Inner Focus"
&& partyAbilityTargetIsValid(member, ability, member.id),
});
if (innerFocus) return { ability: innerFocus, targetId: member.id };
}
for (const target of injured) {
const maintainedAbilityIds = new Set(combat.auras
.filter((aura) => aura.sourceId === member.id
&& aura.targetId === target.id
&& (aura.expiresAt === null || aura.expiresAt > now))
.flatMap((aura) => abilitiesForCharacter(member.classId, member.secondaryClassId)
.map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks))
.filter((ability): ability is AbilityDefinition => ability !== null)
.filter((ability) => ability.effects.some((effect) => effect.kind === "apply-aura"
&& effect.aura.id === aura.definition.id))
.map((ability) => ability.id)));
if (pendingInnerFocus.has(member.id)) {
const empoweredHeal = choosePartyAbility(member, {
now,
target: null,
injuredAllies: injured.length,
lowestHealthFraction: target.health / Math.max(1, target.maxHealth),
friendlyTargets,
nextHealingSpellFree: true,
canUseAbility: ability => abilityHasDirectHealing(ability)
&& (healingAbilityCanAidOthers(ability) || target.id === member.id)
&& partyAbilityTargetIsValid(member, ability, ability.target === "self" ? member.id : target.id),
});
if (empoweredHeal) {
return { ability: empoweredHeal, targetId: empoweredHeal.target === "self" ? member.id : target.id };
}
}
const mitigation = choosePartyAbility(member, {
now,
target: null,
injuredAllies: injured.length,
lowestHealthFraction: target.health / Math.max(1, target.maxHealth),
friendlyTargets,
maintainedAbilityIds,
canUseAbility: ability => aiHintsForAbility(ability).categories.includes("mitigation")
&& (ability.target === "friendly" || ability.target === "any" || ability.target === "self")
&& ability.effects.some((effect) => effect.kind === "shield"
|| (effect.kind === "apply-aura" && (
Boolean(effect.aura.absorbs?.length)
|| effect.aura.modifiers?.some((modifier) => modifier.kind === "damage"
&& modifier.direction === "taken" && modifier.value < 0)
)))
&& partyAbilityTargetIsValid(member, ability, ability.target === "self" ? member.id : target.id)
&& !ability.effects.some((effect) => effect.kind === "shield"
&& (combat.actors[target.id]?.absorb ?? 0) > 0)
&& !ability.effects.some((effect) => effect.kind === "apply-aura"
&& combat.auras.some((aura) => aura.sourceId === member.id
&& aura.targetId === target.id && aura.definition.id === effect.aura.id)),
});
if (mitigation) return { ability: mitigation, targetId: mitigation.target === "self" ? member.id : target.id };
const targetHealthFraction = target.health / Math.max(1, target.maxHealth);
const periodic = targetHealthFraction >= 0.55 ? choosePartyAbility(member, {
now,
target: null,
injuredAllies: injured.length,
lowestHealthFraction: targetHealthFraction,
friendlyTargets,
maintainedAbilityIds,
canUseAbility: ability => ability.effects.some((effect) => effect.kind === "hot")
&& (healingAbilityCanAidOthers(ability) || target.id === member.id)
&& !healingAbilityHasPendingHot(ability, member.id, target.id, combat.effects)
&& partyAbilityTargetIsValid(member, ability, ability.target === "self" ? member.id : target.id),
}) : null;
if (periodic) return { ability: periodic, targetId: periodic.target === "self" ? member.id : target.id };
const ability = choosePartyAbility(member, {
now,
target: null,
injuredAllies: injured.length,
lowestHealthFraction: target.health / Math.max(1, target.maxHealth),
friendlyTargets: injured.map((candidate) => ({
id: candidate.id,
health: candidate.health,
maxHealth: candidate.maxHealth,
})),
friendlyTargets,
maintainedAbilityIds,
canUseAbility: ability => ability.effects.some(effect => ["heal", "hot", "shield"].includes(effect.kind)
|| (effect.kind === "apply-aura" && Boolean(effect.aura.absorbs?.length)))
&& (healingAbilityCanAidOthers(ability) || target.id === member.id)
@@ -1009,12 +1111,40 @@ function executePartyAbility(
now,
))
: false;
const consumesInnerFocus = pendingInnerFocus.has(member.id)
&& ability.name !== "Inner Focus"
&& abilityHasDirectHealing(ability);
if (consumesInnerFocus) pendingInnerFocus.delete(member.id);
const criticalChance = Math.max(0, ratingPercent(
member.gearStats.criticalStrikeRating,
"spellCrit",
member.level,
member.classId,
) / 100);
) / 100 + (consumesInnerFocus ? 0.25 : 0));
if (ability.name === "Inner Focus") pendingInnerFocus.add(member.id);
const envenomDosesByTarget = new Map<string, number>();
if (ability.name === "Envenom") {
for (const hostileTargetId of hostileTargets) {
const doses = Math.min(
member.comboPoints,
5,
matchingAuraStacks(
combat.auras,
ROGUE_DEADLY_POISON_DOSE_TAG,
member.id,
hostileTargetId,
now,
),
);
envenomDosesByTarget.set(hostileTargetId, doses);
if (doses > 0 && !rogueEnvenomPreservesDeadlyPoison(
member.talentRanks,
`${member.id}:${hostileTargetId}:${ability.id}:${now}:master-poisoner`,
)) {
combat.consumeRogueDeadlyPoison(member.id, hostileTargetId, doses, now);
}
}
}
let runtimeMember = member;
let effectiveAmount = 0;
for (const effect of ability.effects) {
@@ -1025,12 +1155,32 @@ function executePartyAbility(
} else {
const result = combat.damageMob(
hostileTargetId,
partyAbilityAmount(runtimeMember, ability, effect),
partyAbilityAmount(
runtimeMember,
ability,
effect,
envenomDosesByTarget.get(hostileTargetId) ?? 0,
),
now,
{ actorId: member.id, role: member.role, abilityId: ability.id },
spellSchool,
);
effectiveAmount += result.amount;
if (
result.amount > 0
&& effect.kind === "damage"
&& baseClassFor(member.classId) === "rogue"
&& spellSchool === "physical"
&& ability.range.max <= 5
) {
combat.triggerRogueWeaponPoisons(
member.id,
hostileTargetId,
ability.id,
ability.name === "Mutilate" ? 2 : 1,
now,
);
}
if (effect.kind === "damage" && effect.onDamageResource && result.amount > 0) {
combat.restoreActorResource(member.id, effect.onDamageResource.resource, effect.onDamageResource.amount,
false, member.id, ability.id, now);
@@ -1106,6 +1256,7 @@ function executePartyAbility(
combat.controlMob(hostileTargetId, ability.id, effect.status, effect.durationMs, now);
}
} else if (effect.kind === "apply-aura") {
if (ability.name === "Inner Focus") continue;
const recipients = effect.scope === "party"
? [
...(simulatedPartyExcludesPlayer() ? [] : [PLAYER_AGGRO_ID]),
@@ -1115,7 +1266,14 @@ function executePartyAbility(
? [member.id]
: combat.mobs[targetId] ? hostileTargets : friendlyTargets;
for (const recipientId of recipients) {
useCombatStore.getState().applyActorAura(member.id, recipientId, ability, effect, now);
const appliedEffect = ability.name === "Envenom"
&& effect.aura.utilityTags?.includes(ROGUE_ENVENOM_BUFF_TAG)
? {
...effect,
aura: { ...effect.aura, durationMs: (runtimeMember.comboPoints + 1) * 1_000 },
}
: effect;
useCombatStore.getState().applyActorAura(member.id, recipientId, ability, appliedEffect, now);
if (effect.control && useCombatStore.getState().mobs[recipientId]) {
useCombatStore.getState().controlMob(recipientId, ability.id, effect.control.kind, effect.aura.durationMs ?? 1_000, now);
}
@@ -1186,14 +1344,45 @@ function executePartyAbility(
useCombatStore.getState().summonActor(member.id, ability, summonEffect, origin, summonTargetId, now);
} else if (effect.kind === "movement") {
const targetPosition = combat.mobs[targetId] ? getMobPosition(targetId) : partyFriendlyPosition(targetId);
if (targetPosition && effect.movement !== "knockback" && effect.toward !== "away") {
if (effect.toward === "away") {
const danger = Object.values(combat.mobs)
.filter((mob) => mob.engaged && !mob.dead)
.sort((left, right) => (
Number(right.targetActorId === member.id) - Number(left.targetActorId === member.id)
|| Number(right.boss) - Number(left.boss)
|| left.id.localeCompare(right.id)
))[0];
const dangerPosition = danger ? getMobPosition(danger.id) : null;
if (dangerPosition) {
const dx = origin[0] - dangerPosition[0];
const dz = origin[2] - dangerPosition[2];
const length = Math.max(0.001, Math.hypot(dx, dz));
const distance = effect.movement === "teleport" ? 15 : 10;
updatePartyRuntimePosition(
member.id,
origin[0] + dx / length * distance,
origin[1],
origin[2] + dz / length * distance,
);
}
} else if (targetPosition && effect.movement !== "knockback") {
updatePartyRuntimePosition(member.id, targetPosition[0], targetPosition[1], targetPosition[2]);
}
} else if (effect.kind === "threat") {
combat.applyActorThreatEffect(
member.id,
effect,
effect.mode === "redirect" && !combat.mobs[targetId] ? targetId : null,
now,
);
} else if (effect.kind === "cooldown-reset") {
const candidates = Object.keys(runtimeMember.cooldowns).filter((id) => {
const candidate = abilityById(id);
return candidate && (!effect.abilityName || candidate.name === effect.abilityName);
});
const candidates = cooldownResetCandidates(
Object.keys(runtimeMember.cooldowns)
.map((id) => abilityById(id))
.filter((candidate): candidate is AbilityDefinition => candidate !== null),
ability.id,
effect.abilityName,
).map((candidate) => candidate.id);
const cooldowns = { ...runtimeMember.cooldowns };
for (const id of candidates) {
const readyAt = cooldowns[id] ?? now;
@@ -1478,7 +1667,10 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
nextMembers[index] = { ...member, status: "advancing", nextActionAt: now + 250 };
continue;
}
const committed = commitPartyAbilityUse(member, supportIntent.ability, now);
const committed = commitPartyAbilityUse(member, supportIntent.ability, now, {
ignoreResourceCosts: pendingInnerFocus.has(member.id)
&& abilityHasDirectHealing(supportIntent.ability),
});
if (!committed) {
nextMembers[index] = { ...member, status: "ready", nextActionAt: now + 250 };
continue;
@@ -1565,12 +1757,34 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
nextMembers[index] = runtimeMember;
continue;
}
const hasDangerousThreat = member.role !== "tank" && targetMob?.targetActorId === member.id;
const enemyUsesMeleeAttacks = targetMob?.attacks.some((attack) => (
attack.delivery === "melee" || attack.range <= 5
)) ?? false;
const incomingMeleeDanger = Boolean(targetMob?.activeCast
&& targetMob.activeCast.targetActorId === member.id
&& targetMob.activeCast.completesAt >= now
&& targetMob.activeCast.completesAt - now <= 3_000
&& targetMob.attacks.some((attack) => attack.id === targetMob.activeCast?.attackId
&& (attack.delivery === "melee" || attack.range <= 5)
&& attack.damageMultiplier >= 1.25));
const selectedDecision = choosePartyRotationDecision(runtimeMember, {
now,
target: targetMob ?? null,
targetIsLoose: member.role === "tank" && targetMob?.targetActorId !== member.id,
hasDangerousThreat: member.role !== "tank" && targetMob?.targetActorId === member.id,
hasDangerousThreat,
enemyUsesMeleeAttacks,
incomingMeleeDanger,
needsEscape: hasDangerousThreat
|| member.health / Math.max(1, member.maxHealth) <= 0.4
|| incomingMeleeDanger,
moving: runtimeMember.status === "advancing",
incomingDefensiveMechanic: imminentCooldownMechanic(targetMob ?? null, member.role, now),
availableConditionalAbilityNames: currentCombat.combatEvents.some((event) => (
event.targetActorId === member.id
&& event.occurredAt > now - 5_000
&& ["dodge", "parry", "block"].includes(event.outcome ?? "")
)) ? new Set(["Revenge"]) : undefined,
hostileCount: Object.values(currentCombat.mobs).filter((mob) => mob.engaged && !mob.dead).length,
friendlyTargets: nextMembers.filter((candidate) => candidate.health > 0).map((candidate) => ({
id: candidate.id,
@@ -1611,6 +1825,23 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
? member.id
: memberTargetId;
return partyAbilityTargetIsValid(runtimeMember, ability, abilityTargetId)
&& (ability.name !== "Envenom" || matchingAuraStacks(
currentCombat.auras,
ROGUE_DEADLY_POISON_DOSE_TAG,
member.id,
abilityTargetId,
now,
) >= Math.min(5, runtimeMember.comboPoints))
&& (ability.name !== "Shiv" || (
runtimeMember.comboPoints >= 4
&& matchingAuraStacks(
currentCombat.auras,
ROGUE_DEADLY_POISON_DOSE_TAG,
member.id,
abilityTargetId,
now,
) < Math.min(5, runtimeMember.comboPoints)
))
// Let maintained effects tick instead of resetting their first tick on every GCD.
&& !currentCombat.effects.some((effect) => effect.sourceActorId === member.id
&& effect.sourceAbilityId === ability.id && effect.targetId === abilityTargetId && effect.remainingTicks > 0)