forked from phenom/healer-man
Release Healer Man 0.1.9
This commit is contained in:
1 parent
c245a199b6
commit
d060296ade
74 files changed
+5901
-334
No files matched your search
@@ -0,0 +1,220 @@
|
||||
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 { useCombatStore } from "../combatStore";
|
||||
import { getMobPosition } from "../mobRuntimeRegistry";
|
||||
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-v1";
|
||||
|
||||
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 = ability.target === "self"
|
||||
? targets.find((target) => target.id === PLAYER_AGGRO_ID) ?? lowest
|
||||
: lowest;
|
||||
score += canHeal ? 100 : 70;
|
||||
score += friendlyTarget.missingPercent * 180;
|
||||
if (categories.has("emergency-heal")) score += friendlyTarget.missingPercent >= 0.5 ? 100 : -15;
|
||||
if (categories.has("area-heal") || area) score += injured.length >= 2 ? 35 * injured.length : -35;
|
||||
if (canShield) score += friendlyTarget.missingPercent >= 0.2 ? 45 : -20;
|
||||
} 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));
|
||||
|
||||
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
|
||||
: 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 };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user