Files
healer-man/src/game/benchmark/benchmarkController.ts
T
2026-09-13 15:09:35 -04:00

270 lines
13 KiB
TypeScript

import {
abilityAtLevel,
isAbilityUnlocked,
type AbilityDefinition,
} from "../abilityCatalog";
import { aiHintsForAbility, canAbilityCastWhileMoving, targetingRulesForAbility } from "../abilityRules";
import { PLAYER_AGGRO_ID } from "../aggro";
import type { CastFailureReason, CombatState } from "../combatStore";
import { equipmentAdjustedCombatAmount, resolvedAbilityCosts, useCombatStore } from "../combatStore";
import { getMobPosition } from "../mobRuntimeRegistry";
import { getPartyRuntimePosition } from "../partyRuntimeRegistry";
import { healingAbilityCanAidOthers, healingAbilityHasPendingHot, healingAbilityIsArea } from "../healingAbilityRules";
import type { PartyState } from "../partyStore";
import { usePartyStore } from "../partyStore";
import type { PartyRole } from "../partyRoles";
import type { BenchmarkScenarioDefinition } from "./benchmarkTypes";
export const BENCHMARK_POLICY_ID = "baseline-v3";
export interface BenchmarkPolicyDecision {
readonly made: boolean;
readonly cast: boolean;
readonly resourceBlocked: boolean;
readonly invalidFailures: number;
}
interface FriendlyTarget {
readonly id: string;
readonly health: number;
readonly maxHealth: number;
readonly missingPercent: number;
}
interface ScoredAbility {
readonly ability: AbilityDefinition;
readonly score: number;
readonly friendlyTarget: FriendlyTarget | null;
}
const TRANSIENT_FAILURES = new Set<CastFailureReason>([
"already-casting",
"global-cooldown",
"cooldown",
"not-enough-resource",
"not-enough-runes",
"condition-not-met",
"player-controlled",
"school-locked",
]);
function healthTarget(id: string, health: number, maxHealth: number): FriendlyTarget {
return {
id,
health,
maxHealth,
missingPercent: maxHealth > 0 ? Math.max(0, 1 - health / maxHealth) : 0,
};
}
function friendlyTargets(combat: CombatState, party: PartyState): readonly FriendlyTarget[] {
return [
healthTarget(PLAYER_AGGRO_ID, combat.health, combat.maxHealth),
...party.members.map((member) => healthTarget(member.id, member.health, member.maxHealth)),
]
.filter((target) => target.health > 0)
.sort((left, right) => right.missingPercent - left.missingPercent || left.id.localeCompare(right.id));
}
function learnedAbility(ability: AbilityDefinition, combat: CombatState): AbilityDefinition | null {
if (ability.passive || !isAbilityUnlocked(ability, combat.level)) return null;
const learnedRank = ability.talentEntryId === undefined
? undefined
: combat.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0;
if (ability.talentEntryId !== undefined && !learnedRank) return null;
const ranked = abilityAtLevel(ability, combat.level, learnedRank);
if (ranked.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")) return null;
return ranked;
}
function isAreaAbility(ability: AbilityDefinition): boolean {
const targeting = targetingRulesForAbility(ability);
return targeting.shape !== "unit"
|| (targeting.maximumTargets ?? 1) > 1
|| (targeting.radius ?? ability.radius ?? 0) > 0;
}
function throughputWeight(ability: AbilityDefinition): number {
let weight = 0;
for (const effect of ability.effects) {
if (effect.kind === "damage" || effect.kind === "heal" || effect.kind === "shield" || effect.kind === "dot" || effect.kind === "hot") {
weight += Math.max(0, effect.coefficient) * 10;
weight += Math.max(0, effect.basePoints ?? 0) * 0.01;
} else if (effect.kind === "finisher-damage") {
weight += Math.max(0, effect.baseCoefficient + effect.perComboCoefficient * 3) * 10;
} else if (effect.kind === "execute") {
weight += Math.max(0, effect.coefficient) * 10;
}
}
return Math.min(40, weight);
}
function scoreAbility(
ability: AbilityDefinition,
role: PartyRole,
scenario: BenchmarkScenarioDefinition,
combat: CombatState,
targets: readonly FriendlyTarget[],
): ScoredAbility | null {
const hints = aiHintsForAbility(ability);
const categories = new Set(hints.categories);
const injured = targets.filter((target) => target.missingPercent > 0.005);
const lowest = targets[0] ?? null;
const area = isAreaAbility(ability);
let score = hints.priority ?? 0;
let friendlyTarget: FriendlyTarget | null = null;
if (role === "healer") {
const canHeal = categories.has("heal") || categories.has("emergency-heal") || categories.has("area-heal");
const canShield = categories.has("mitigation") && ability.effects.some((effect) => effect.kind === "shield");
if ((!canHeal && !canShield) || !lowest || injured.length === 0) return null;
friendlyTarget = !healingAbilityCanAidOthers(ability)
? targets.find((target) => target.id === PLAYER_AGGRO_ID) ?? lowest
: lowest;
if (friendlyTarget.missingPercent <= 0.005) return null;
const pendingHot = healingAbilityHasPendingHot(ability, PLAYER_AGGRO_ID, friendlyTarget.id, combat.effects);
const directHeal = ability.effects.some(effect => effect.kind === "heal");
if (pendingHot && !directHeal) return null;
score += canHeal ? 100 : 70;
score += friendlyTarget.missingPercent * 180;
const healingAmount = ability.effects.reduce((total, effect) => total + (effect.kind === "heal" || effect.kind === "hot"
? (equipmentAdjustedCombatAmount(effect, combat.level, combat.gearStats, combat.classId, ability)
+ (effect.kind === "heal" ? friendlyTarget!.maxHealth * (effect.percentMaxHealth ?? 0) : 0))
* (effect.kind === "hot" ? Math.min(3, effect.ticks) : 1) : 0), 0);
score += Math.min(70, healingAmount / Math.max(1, friendlyTarget.maxHealth) * 100);
if (ability.effects.some(effect => effect.kind === "heal" && (effect.percentMaxHealth ?? 0) >= 0.5)) {
score += friendlyTarget.missingPercent >= 0.4 ? 100 : -100;
}
const healthCost = resolvedAbilityCosts(ability, combat.talentModifiers, combat).find(cost => cost.resource === "health")?.amount ?? 0;
if (healthCost > 0) {
if (combat.health / combat.maxHealth < 0.5) return null;
if (friendlyTarget.id === PLAYER_AGGRO_ID && healingAbilityCanAidOthers(ability)) return null;
score -= healthCost / combat.maxHealth * 500;
}
if (pendingHot && friendlyTarget.missingPercent < 0.5) score -= 120;
if (categories.has("emergency-heal")) score += friendlyTarget.missingPercent >= 0.5 ? 100 : -15;
if (healingAbilityIsArea(ability)) score += injured.length >= 2 ? 35 * injured.length : -35;
if (!directHeal && friendlyTarget.missingPercent >= 0.5) score -= 60;
if (canShield) score += friendlyTarget.missingPercent >= 0.2 ? 45 : -20;
if (canShield && (combat.actors[friendlyTarget.id]?.absorb ?? 0) > 0) return null;
} else {
const hostileDamage = categories.has("damage") || categories.has("execute") || categories.has("debuff");
if (role === "damage" && !hostileDamage) return null;
if (role === "tank" && !hostileDamage && !categories.has("mitigation") && !categories.has("taunt")) return null;
if (hostileDamage) score += 100 + throughputWeight(ability);
if (categories.has("execute")) {
const target = combat.selectedTargetId ? combat.mobs[combat.selectedTargetId] : null;
const executeActive = Boolean(target && target.health / Math.max(1, target.maxHealth) <= 0.2);
score += executeActive ? 120 : -180;
}
if (scenario.hostileTargets > 1) score += area ? 60 + scenario.hostileTargets * 4 : -15;
else if (area) score -= 15;
if (ability.effects.some((effect) => effect.kind === "dot")
&& combat.effects.some((effect) => effect.sourceAbilityId === ability.id && effect.targetId === combat.selectedTargetId)) {
score -= 250;
}
if (role === "tank") {
const playerHealth = combat.health / Math.max(1, combat.maxHealth);
if (categories.has("mitigation")) score += playerHealth < 0.8 ? 150 : 20;
if (categories.has("taunt")) {
const looseMobs = Object.values(combat.mobs).filter((mob) => !mob.dead && mob.targetActorId !== PLAYER_AGGRO_ID).length;
score += looseMobs > 0 ? 220 : -80;
}
}
}
return { ability, score, friendlyTarget };
}
/** A deterministic, shared baseline policy. It is intentionally not an optimizer. */
export class BenchmarkController {
#nextDecisionAt = 0;
act(now: number, role: PartyRole, scenario: BenchmarkScenarioDefinition, moving: boolean): BenchmarkPolicyDecision {
const combat = useCombatStore.getState();
const party = usePartyStore.getState();
if (
now < this.#nextDecisionAt
|| combat.health <= 0
|| combat.activeCast
|| combat.globalCooldownEndsAt > now
|| combat.controlledUntil > now
) return { made: false, cast: false, resourceBlocked: false, invalidFailures: 0 };
const targets = friendlyTargets(combat, party);
const candidates = combat.abilities
.map((ability) => learnedAbility(ability, combat))
.filter((ability): ability is AbilityDefinition => ability !== null)
.filter((ability) => !moving || ability.castTimeMs <= 0 || canAbilityCastWhileMoving(ability))
.map((ability) => scoreAbility(ability, role, scenario, combat, targets))
.filter((candidate): candidate is ScoredAbility => candidate !== null)
.sort((left, right) => right.score - left.score || left.ability.id.localeCompare(right.ability.id));
// Rage healers must begin weapon attacks to fund their heals before allies are injured.
if (role === "healer" && combat.selectedTargetId
&& combat.abilities.some(ability => ability.effects.some(effect => effect.kind === "heal") && ability.cost.resource === "rage")) {
const starter = combat.abilities.map(ability => learnedAbility(ability, combat)).find(ability => ability
&& ability.target === "hostile" && ability.range.max <= 5
&& ability.effects.some(effect => effect.kind === "damage")
&& resolvedAbilityCosts(ability, combat.talentModifiers, combat).every(cost => cost.amount === 0));
if (starter && !combat.autoAttackTargetId) candidates.push({ ability: starter, score: 0, friendlyTarget: null });
if (combat.resource < 35) {
const generators = combat.abilities.map(ability => learnedAbility(ability, combat)).filter(ability => ability
&& ability.target === "hostile" && ability.effects.some(effect => (effect.kind === "resource"
&& (!effect.resource || effect.resource === "rage") && effect.amount > 0)
|| ((effect.kind === "damage" || effect.kind === "dot")
&& effect.onDamageResource?.resource === "rage" && effect.onDamageResource.amount > 0))
&& resolvedAbilityCosts(ability, combat.talentModifiers, combat).every(cost => cost.resource !== "rage"
&& (cost.resource !== "health" || (combat.health / combat.maxHealth > 0.65 && cost.amount / combat.maxHealth <= 0.1))));
for (const generator of generators) {
if (!generator) continue;
const needsRageHeal = targets.some(target => target.id !== PLAYER_AGGRO_ID && target.missingPercent > 0.1);
candidates.push({ ability: generator, score: needsRageHeal && combat.resource < 20 ? 400 : -10, friendlyTarget: null });
candidates.sort((left, right) => right.score - left.score || left.ability.id.localeCompare(right.ability.id));
}
}
}
let resourceBlocked = false;
let invalidFailures = 0;
for (const candidate of candidates) {
const { ability, friendlyTarget } = candidate;
let targetActorId: string | null | undefined;
if (ability.target === "friendly" || (ability.target === "any" && friendlyTarget)) {
if (friendlyTarget?.id === PLAYER_AGGRO_ID) {
party.selectSelf();
targetActorId = null;
} else if (friendlyTarget) {
party.selectMember(friendlyTarget.id);
targetActorId = friendlyTarget.id;
}
} else if (ability.target === "hostile" || ability.target === "any") {
party.clearSelection();
useCombatStore.getState().selectFriendlyActor(null);
targetActorId = combat.selectedTargetId;
}
const hostilePosition = combat.selectedTargetId ? getMobPosition(combat.selectedTargetId) : null;
const targeting = targetingRulesForAbility(ability);
const result = useCombatStore.getState().castAbility(ability.id, {
now,
origin: combat.playerPosition,
targetActorId,
...(targeting.shape === "ground"
? { groundPosition: ability.target === "hostile" || ability.target === "any"
? hostilePosition ?? combat.playerPosition
: ability.target === "self" || friendlyTarget?.id === PLAYER_AGGRO_ID ? combat.playerPosition
: getPartyRuntimePosition(friendlyTarget?.id ?? "") ?? combat.playerPosition }
: {}),
});
if (result.ok) {
this.#nextDecisionAt = now + 50;
return { made: true, cast: true, resourceBlocked: false, invalidFailures };
}
if (result.reason === "not-enough-resource" || result.reason === "not-enough-runes") resourceBlocked = true;
if (result.reason && !TRANSIENT_FAILURES.has(result.reason)) invalidFailures += 1;
}
this.#nextDecisionAt = now + 1_500;
return { made: true, cast: false, resourceBlocked, invalidFailures };
}
}