forked from phenom/healer-man
Release Healer Man 0.1.49
This commit is contained in:
1 parent
39425f2494
commit
612170afd0
91 files changed
+2278
-263
No files matched your search
+68
-53
@@ -1,12 +1,16 @@
|
||||
import {
|
||||
abilitiesForClass,
|
||||
abilityAtLevel,
|
||||
isAbilityUnlocked,
|
||||
abilitiesForCharacter,
|
||||
learnedAbilityForBuild,
|
||||
type AbilityDefinition,
|
||||
type AbilityEffect,
|
||||
type ResourceType,
|
||||
} from "./abilityCatalog";
|
||||
import { aiHintsForAbility, runeCostForAbility } from "./abilityRules";
|
||||
import {
|
||||
evaluateCombatRotation,
|
||||
type RotationDecision,
|
||||
type RotationFriendlyTarget,
|
||||
} from "./combatRotation";
|
||||
import { spendDeathKnightRunes } from "./combatResources";
|
||||
import { equipmentAdjustedCombatAmount, type MobCombatState } from "./combatStore";
|
||||
import type { PartyMember } from "./partyStore";
|
||||
@@ -17,11 +21,18 @@ export interface PartyAbilityContext {
|
||||
readonly now: number;
|
||||
readonly target: MobCombatState | null;
|
||||
readonly targetIsLoose?: boolean;
|
||||
readonly hasDangerousThreat?: boolean;
|
||||
readonly requiredControl?: boolean;
|
||||
readonly allyNeedsDispel?: boolean;
|
||||
readonly fallenAlly?: boolean;
|
||||
readonly injuredAllies?: number;
|
||||
readonly lowestHealthFraction?: number;
|
||||
readonly hostileCount?: number;
|
||||
readonly friendlyTargets?: readonly RotationFriendlyTarget[];
|
||||
readonly maintainedAbilityIds?: ReadonlySet<string>;
|
||||
readonly activeAuraExclusiveGroups?: ReadonlySet<string>;
|
||||
readonly activeSummonAbilityIds?: ReadonlySet<string>;
|
||||
readonly hasActivePet?: boolean;
|
||||
readonly canUseAbility?: (ability: AbilityDefinition) => boolean;
|
||||
}
|
||||
|
||||
@@ -39,8 +50,7 @@ function abilityCosts(member: PartyMember, ability: AbilityDefinition): readonly
|
||||
}
|
||||
|
||||
export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinition, now: number): boolean {
|
||||
if (ability.passive || !isAbilityUnlocked(ability, member.level)) return false;
|
||||
if (ability.talentEntryId !== undefined && !(member.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0)) return false;
|
||||
if (ability.passive || !learnedAbilityForBuild(ability, member.level, member.talentRanks)) return false;
|
||||
if (member.globalCooldownEndsAt > now || (member.cooldowns[ability.id] ?? 0) > now) return false;
|
||||
if ((member.schoolLockedUntil[ability.castRules?.school ?? "physical"] ?? 0) > now) return false;
|
||||
if (aiHintsForAbility(ability).categories.includes("taunt") && member.tauntReadyAt > now) return false;
|
||||
@@ -51,62 +61,67 @@ export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinit
|
||||
}
|
||||
|
||||
function candidateAbilities(member: PartyMember, now: number): readonly AbilityDefinition[] {
|
||||
return abilitiesForClass(member.classId)
|
||||
.map((ability) => abilityAtLevel(ability, member.level, ability.talentEntryId === undefined
|
||||
? undefined : member.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0))
|
||||
return abilitiesForCharacter(member.classId, member.secondaryClassId)
|
||||
.map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks))
|
||||
.filter((ability): ability is AbilityDefinition => ability !== null)
|
||||
.filter((ability) => partyAbilityIsReady(member, ability, now));
|
||||
}
|
||||
|
||||
function scoreAbility(member: PartyMember, ability: AbilityDefinition, context: PartyAbilityContext): number {
|
||||
const categories = new Set(aiHintsForAbility(ability).categories);
|
||||
const damaging = categories.has("damage") || categories.has("execute");
|
||||
if (categories.has("taunt") && (member.role !== "tank" || !context.targetIsLoose)) return 0;
|
||||
if (!damaging && categories.has("interrupt") && !context.target?.activeCast) return 0;
|
||||
const control = categories.has("control") || ability.effects.some((effect) => effect.kind === "apply-aura" && effect.control);
|
||||
if (!damaging && control && !context.requiredControl) return 0;
|
||||
let score = ability.ai?.priority ?? 0;
|
||||
if (member.role === "tank") {
|
||||
if (context.target?.activeCast && categories.has("interrupt")) score += 1_000;
|
||||
if (context.targetIsLoose && categories.has("taunt")) score += 900;
|
||||
if (categories.has("mitigation") && member.health / member.maxHealth < 0.55) score += 760;
|
||||
if (categories.has("control") && context.requiredControl) score += 700;
|
||||
if (categories.has("debuff")) score += 300;
|
||||
if (categories.has("damage")) score += 180;
|
||||
} else if (member.role === "healer") {
|
||||
if (context.fallenAlly && categories.has("resurrection")) score += 1_200;
|
||||
if (context.allyNeedsDispel && categories.has("dispel")) score += 1_050;
|
||||
if ((context.lowestHealthFraction ?? 1) < 0.3 && categories.has("emergency-heal")) score += 950;
|
||||
if ((context.injuredAllies ?? 0) >= 3 && categories.has("area-heal")) score += 850;
|
||||
if ((context.lowestHealthFraction ?? 1) < 0.9 && categories.has("heal")) score += 600;
|
||||
if (categories.has("mitigation") && ((context.lowestHealthFraction ?? 1) < 0.8 || member.health / member.maxHealth < 0.8)) score += 350;
|
||||
if ((context.lowestHealthFraction ?? 1) < 0.9 && categories.has("heal")) {
|
||||
score += Math.min(150, ability.effects.reduce((total, effect) => total + (effect.kind === "heal" || effect.kind === "hot"
|
||||
? partyAbilityAmount(member, ability, effect) * (effect.kind === "hot" ? Math.min(3, effect.ticks) : 1) : 0), 0) / member.maxHealth * 100);
|
||||
}
|
||||
if (context.target?.activeCast && categories.has("interrupt")) score += 250;
|
||||
if (categories.has("damage")) score += 80;
|
||||
} else {
|
||||
if (context.target?.activeCast && categories.has("interrupt")) score += 1_000;
|
||||
if (context.requiredControl && categories.has("control")) score += 900;
|
||||
if ((context.target?.health ?? 1) / Math.max(1, context.target?.maxHealth ?? 1) <= 0.2 && categories.has("execute")) score += 800;
|
||||
if (categories.has("debuff")) score += 520;
|
||||
if (categories.has("damage")) score += 300;
|
||||
}
|
||||
if (ability.castTimeMs > 0 && context.target?.activeCast && categories.has("interrupt")) score -= 800;
|
||||
return score;
|
||||
export function choosePartyRotationDecision(
|
||||
member: PartyMember,
|
||||
context: PartyAbilityContext,
|
||||
): RotationDecision | null {
|
||||
const friendlyTargets = context.friendlyTargets ?? (context.fallenAlly || context.allyNeedsDispel
|
||||
|| (context.lowestHealthFraction ?? 1) < 1
|
||||
? [{
|
||||
id: "rotation-friendly-target",
|
||||
health: (context.lowestHealthFraction ?? 1) * 100,
|
||||
maxHealth: 100,
|
||||
fallen: context.fallenAlly,
|
||||
needsDispel: context.allyNeedsDispel,
|
||||
}]
|
||||
: []);
|
||||
const maximumResources = Object.fromEntries(
|
||||
[...new Set([...Object.keys(member.resourcePools), member.resourceType])]
|
||||
.map((resource) => [resource, companionResourceMaximum(member, resource)]),
|
||||
);
|
||||
return evaluateCombatRotation({
|
||||
now: context.now,
|
||||
classId: member.classId,
|
||||
secondaryClassId: member.secondaryClassId,
|
||||
specializationId: member.specializationId,
|
||||
specialization: member.specialization,
|
||||
role: member.role,
|
||||
level: member.level,
|
||||
health: member.health,
|
||||
maxHealth: member.maxHealth,
|
||||
selfId: member.id,
|
||||
resources: member.resourcePools,
|
||||
maximumResources,
|
||||
primaryResource: member.resourceType,
|
||||
comboPoints: member.comboPoints,
|
||||
comboTargetId: member.comboTargetId,
|
||||
runes: member.runes,
|
||||
abilities: candidateAbilities(member, context.now),
|
||||
hostileTarget: context.target,
|
||||
friendlyTargets,
|
||||
hostileCount: context.hostileCount,
|
||||
targetIsLoose: context.targetIsLoose,
|
||||
hasDangerousThreat: context.hasDangerousThreat,
|
||||
requiredControl: context.requiredControl,
|
||||
maintainedAbilityIds: context.maintainedAbilityIds,
|
||||
activeAuraExclusiveGroups: context.activeAuraExclusiveGroups,
|
||||
activeSummonAbilityIds: context.activeSummonAbilityIds,
|
||||
hasActivePet: context.hasActivePet,
|
||||
canUseAbility: context.canUseAbility,
|
||||
});
|
||||
}
|
||||
|
||||
export function choosePartyAbility(
|
||||
member: PartyMember,
|
||||
context: PartyAbilityContext,
|
||||
): AbilityDefinition | null {
|
||||
return candidateAbilities(member, context.now)
|
||||
.filter((ability) => context.canUseAbility?.(ability) ?? true)
|
||||
.map((ability) => ({ ability, score: scoreAbility(member, ability, context) }))
|
||||
.filter((candidate) => candidate.score > 0)
|
||||
.sort((left, right) => right.score - left.score
|
||||
|| left.ability.cooldownMs - right.ability.cooldownMs
|
||||
|| left.ability.id.localeCompare(right.ability.id))[0]?.ability ?? null;
|
||||
return choosePartyRotationDecision(member, context)?.ability ?? null;
|
||||
}
|
||||
|
||||
export function commitPartyAbilityUse(
|
||||
@@ -155,7 +170,7 @@ export function partyAbilityAmount(
|
||||
const coefficient = "coefficient" in effect
|
||||
? effect.coefficient
|
||||
: effect.kind === "finisher-damage"
|
||||
? effect.baseCoefficient + effect.perComboCoefficient * 5
|
||||
? effect.baseCoefficient + effect.perComboCoefficient * member.comboPoints
|
||||
: 0;
|
||||
return equipmentAdjustedCombatAmount(
|
||||
"coefficient" in effect ? effect : coefficient,
|
||||
|
||||
Reference in new issue
Block a user