Update Healer Man server
This commit is contained in:
1 parent
1fedb9368d
commit
cc3b2fa4d5
27 files changed
+1988
-83
No files matched your search
+246
-15
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user