forked from phenom/healer-man
Fix benchmark maintained aura selection (#18)
This commit is contained in:
1 parent
89b6f4f79d
commit
72a67ac1e3
5 files changed
+205
-13
No files matched your search
@@ -0,0 +1,22 @@
|
||||
# Issue 18: maintenance cast selection
|
||||
|
||||
The benchmark controller scores a generic candidate list alongside the shared
|
||||
rotation. A maintained aura rejected by the shared selector must also be
|
||||
removed from that fallback list, or the next benchmark decision can cast it
|
||||
again.
|
||||
|
||||
Current, unexpired auras are passed to the selector with their actual target
|
||||
and aura ID. The selector suppresses the same applied aura only on the target
|
||||
the candidate would use. An aura on another actor does not block the cast, and
|
||||
a different aura in the same exclusive group remains eligible to replace it.
|
||||
Expired and dispelled auras are absent from the active snapshot. Periodic
|
||||
effects retain their source, target, and remaining-duration checks. An attack
|
||||
or heal that also applies an aura remains eligible for its primary effect.
|
||||
|
||||
Focused regressions cover Protection Paladin L60 Greater Blessing of Sanctuary,
|
||||
RoM Scout L30 Detection, target-scoped aura selection, and exclusive-group
|
||||
replacement. Run them with:
|
||||
|
||||
```sh
|
||||
./node_modules/.bin/vitest run src/game/benchmark/benchmarkController.maintenance.test.ts src/game/combatRotation.test.ts --reporter=dot
|
||||
```
|
||||
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilityAtLevel, abilitiesForClass } from "../abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "../aggro";
|
||||
import { useCombatStore } from "../combatStore";
|
||||
import { clearMobRuntimeRegistry } from "../mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry } from "../partyRuntimeRegistry";
|
||||
import { BenchmarkController } from "./benchmarkController";
|
||||
import { BENCHMARK_SCENARIOS } from "./benchmarkScenarios";
|
||||
|
||||
const scenario = { ...BENCHMARK_SCENARIOS[0], hostileTargets: 1 };
|
||||
|
||||
describe("benchmark maintenance aura fallback", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
});
|
||||
|
||||
it("does not re-admit an active self buff after shared rotation rejects it", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "paladin", role: "tank", level: 60 });
|
||||
const sanctuary = abilityAtLevel(
|
||||
abilitiesForClass("paladin").find((ability) => ability.name === "Greater Blessing of Sanctuary")!,
|
||||
60,
|
||||
);
|
||||
useCombatStore.setState({ abilities: [sanctuary] });
|
||||
expect(useCombatStore.getState().castAbility(sanctuary.id, { now: 100 }).ok).toBe(true);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
|
||||
&& aura.definition.id === sanctuary.effects.find((effect) => effect.kind === "apply-aura")?.aura.id)).toBe(true);
|
||||
|
||||
const decision = new BenchmarkController().act(2_000, "tank", scenario, false);
|
||||
|
||||
expect(decision.cast).toBe(false);
|
||||
expect(useCombatStore.getState().auras.filter((aura) => aura.definition.id
|
||||
=== sanctuary.effects.find((effect) => effect.kind === "apply-aura")?.aura.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("allows the same maintenance cast after its aura expires or is removed", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "paladin", role: "tank", level: 60 });
|
||||
const sanctuary = abilityAtLevel(
|
||||
abilitiesForClass("paladin").find((ability) => ability.name === "Greater Blessing of Sanctuary")!,
|
||||
60,
|
||||
);
|
||||
const auraId = sanctuary.effects.find((effect) => effect.kind === "apply-aura")!.aura.id;
|
||||
useCombatStore.setState({ abilities: [sanctuary] });
|
||||
expect(useCombatStore.getState().castAbility(sanctuary.id, { now: 100 }).ok).toBe(true);
|
||||
useCombatStore.setState((state) => ({
|
||||
auras: state.auras.map((aura) => aura.definition.id === auraId ? { ...aura, expiresAt: 1_999 } : aura),
|
||||
}));
|
||||
|
||||
expect(new BenchmarkController().act(2_000, "tank", scenario, false).cast).toBe(true);
|
||||
|
||||
useCombatStore.setState({ auras: [] });
|
||||
expect(new BenchmarkController().act(4_000, "tank", scenario, false).cast).toBe(true);
|
||||
});
|
||||
|
||||
it("does not re-admit an active RoM self debuff through generic candidate scoring", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-scout", statRuleset: "rom", role: "damage", level: 30 });
|
||||
const detection = abilityAtLevel(
|
||||
abilitiesForClass("rom-scout").find((ability) => ability.id === "rom-scout-primary-8")!,
|
||||
30,
|
||||
);
|
||||
useCombatStore.setState({ abilities: [detection] });
|
||||
expect(useCombatStore.getState().castAbility(detection.id, { now: 100 }).ok).toBe(true);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
|
||||
&& aura.definition.id === detection.effects.find((effect) => effect.kind === "apply-aura")?.aura.id)).toBe(true);
|
||||
|
||||
const decision = new BenchmarkController().act(2_000, "damage", scenario, false);
|
||||
|
||||
expect(decision.cast).toBe(false);
|
||||
expect(useCombatStore.getState().auras.filter((aura) => aura.definition.id
|
||||
=== detection.effects.find((effect) => effect.kind === "apply-aura")?.aura.id)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import type { BenchmarkScenarioDefinition } from "./benchmarkTypes";
|
||||
import { nativeRotationAbilityAllowed, nativeRotationProfileFor } from "../romFiveRotation";
|
||||
import {
|
||||
COMBAT_ROTATION_VERSION,
|
||||
abilityAuraMaintainedOnTarget,
|
||||
evaluateCombatRotation,
|
||||
nativeEarlyPriestHealReserveBlocksAbility,
|
||||
type RotationContext,
|
||||
@@ -52,6 +53,15 @@ interface ScoredAbility {
|
||||
readonly friendlyTarget: FriendlyTarget | null;
|
||||
}
|
||||
|
||||
function scoredAbilityAuraTargetId(candidate: ScoredAbility, combat: CombatState): string | null {
|
||||
const { ability, friendlyTarget } = candidate;
|
||||
if (ability.target === "friendly" || (ability.target === "any" && friendlyTarget)) {
|
||||
return friendlyTarget?.id ?? combat.selectedFriendlyActorId ?? PLAYER_AGGRO_ID;
|
||||
}
|
||||
if (ability.target === "hostile" || ability.target === "any") return combat.selectedTargetId;
|
||||
return PLAYER_AGGRO_ID;
|
||||
}
|
||||
|
||||
const TRANSIENT_FAILURES = new Set<CastFailureReason>([
|
||||
"already-casting",
|
||||
"global-cooldown",
|
||||
@@ -287,13 +297,10 @@ export class BenchmarkController {
|
||||
maintainedAbilityIds: new Set([
|
||||
...combat.effects.filter((effect) => effect.sourceActorId === PLAYER_AGGRO_ID && effect.remainingTicks > 0)
|
||||
.map((effect) => effect.sourceAbilityId),
|
||||
...combat.auras.filter((aura) => aura.sourceId === PLAYER_AGGRO_ID
|
||||
&& (aura.expiresAt === null || aura.expiresAt > now))
|
||||
.flatMap((aura) => availableAbilities
|
||||
.filter((ability) => ability.effects.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.id === aura.definition.id))
|
||||
.map((ability) => ability.id)),
|
||||
]),
|
||||
maintainedAuras: combat.auras
|
||||
.filter((aura) => aura.expiresAt === null || aura.expiresAt > now)
|
||||
.map((aura) => ({ auraId: aura.definition.id, targetId: aura.targetId })),
|
||||
maintainedPeriodicEffects: combat.effects.map((effect) => ({
|
||||
abilityId: effect.sourceAbilityId,
|
||||
sourceActorId: effect.sourceActorId,
|
||||
@@ -315,9 +322,11 @@ export class BenchmarkController {
|
||||
};
|
||||
const sharedDecision = evaluateCombatRotation(rotationContext);
|
||||
for (let index = candidates.length - 1; index >= 0; index -= 1) {
|
||||
const ability = candidates[index].ability;
|
||||
const candidate = candidates[index];
|
||||
const ability = candidate.ability;
|
||||
if (nativeEarlyPriestHealReserveBlocksAbility(ability, rotationContext)
|
||||
|| (nativeRotationProfileFor(rotationContext) && !nativeRotationAbilityAllowed(ability, rotationContext))) {
|
||||
|| (nativeRotationProfileFor(rotationContext) && !nativeRotationAbilityAllowed(ability, rotationContext))
|
||||
|| abilityAuraMaintainedOnTarget(ability, rotationContext, scoredAbilityAuraTargetId(candidate, combat))) {
|
||||
candidates.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import {
|
||||
abilityIsPrecombatPreparation,
|
||||
abilityHasRotationSemantics,
|
||||
abilityAuraMaintainedOnTarget,
|
||||
CLASSIC_ROTATION_PROFILES,
|
||||
COMBAT_ROTATION_VERSION,
|
||||
evaluateCombatRotation,
|
||||
@@ -764,6 +765,52 @@ describe("shared combat rotations", () => {
|
||||
.toBe(lightningBolt.id);
|
||||
});
|
||||
|
||||
it("matches maintained auras by recipient and exact effect while allowing a group override", () => {
|
||||
const auraAbility = (id: string, auraId: string, recipient: "caster" | "ability-target" = "caster") => semanticAbility(id, [{
|
||||
kind: "apply-aura",
|
||||
aura: {
|
||||
id: auraId,
|
||||
name: id,
|
||||
disposition: "buff",
|
||||
durationMs: 300_000,
|
||||
exclusiveGroup: "test:stance",
|
||||
modifiers: [{ kind: "stat", stat: "armor", operation: "flat", value: 10 }],
|
||||
},
|
||||
recipient,
|
||||
sourceEffectType: 6,
|
||||
sourceAuraType: 22,
|
||||
miscValue: 0,
|
||||
triggerSpellId: 0,
|
||||
}], { target: "self" });
|
||||
const stance = auraAbility("stance", "stance-aura");
|
||||
const replacementStance = auraAbility("replacement-stance", "replacement-aura");
|
||||
const allyAura = {
|
||||
...auraAbility("ally-aura", "ally-aura-id", "ability-target"),
|
||||
target: "friendly" as const,
|
||||
};
|
||||
const activeOnSelf = context({
|
||||
selfId: "player",
|
||||
maintainedAuras: [{ auraId: "stance-aura", targetId: "player" }],
|
||||
});
|
||||
|
||||
expect(abilityAuraMaintainedOnTarget(stance, activeOnSelf, "player")).toBe(true);
|
||||
expect(abilityAuraMaintainedOnTarget(allyAura, activeOnSelf, "ally")).toBe(false);
|
||||
expect(abilityAuraMaintainedOnTarget(stance, {
|
||||
...activeOnSelf,
|
||||
maintainedAuras: [{ auraId: "stance-aura", targetId: "ally" }],
|
||||
}, "player")).toBe(false);
|
||||
expect(abilityAuraMaintainedOnTarget(allyAura, {
|
||||
...activeOnSelf,
|
||||
maintainedAuras: [{ auraId: "ally-aura-id", targetId: "player" }],
|
||||
}, "player")).toBe(true);
|
||||
expect(abilityAuraMaintainedOnTarget(replacementStance, activeOnSelf, "player")).toBe(false);
|
||||
expect(abilityAuraMaintainedOnTarget({
|
||||
...stance,
|
||||
effects: [...stance.effects, { kind: "damage", coefficient: 1 }],
|
||||
}, activeOnSelf, "player")).toBe(false);
|
||||
expect(abilityAuraMaintainedOnTarget(stance, { ...activeOnSelf, maintainedAuras: [] }, "player")).toBe(false);
|
||||
});
|
||||
|
||||
it("maintains Earth Shield on a valid full-health ally", () => {
|
||||
const earthShield = ability("shaman", "Earth Shield");
|
||||
const shaman = context({
|
||||
|
||||
@@ -94,6 +94,11 @@ export interface RotationContext {
|
||||
readonly targetId: string;
|
||||
readonly remainingMs: number;
|
||||
}[];
|
||||
/** Active applied auras with their actual recipient, filtered for expiry. */
|
||||
readonly maintainedAuras?: readonly {
|
||||
readonly auraId: string;
|
||||
readonly targetId: string;
|
||||
}[];
|
||||
readonly activeAuraExclusiveGroups?: ReadonlySet<string>;
|
||||
readonly activeSummonAbilityIds?: ReadonlySet<string>;
|
||||
readonly hasActivePet?: boolean;
|
||||
@@ -378,6 +383,36 @@ function targetForAbility(
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether this applied-aura action is already active on the recipient it would use. */
|
||||
export function abilityAuraMaintainedOnTarget(
|
||||
ability: AbilityDefinition,
|
||||
context: RotationContext,
|
||||
targetId: string | null,
|
||||
): boolean {
|
||||
// An aura can be a side effect of an attack. Keep those casts available for
|
||||
// their damage or healing; this guard only handles maintenance actions.
|
||||
if (ability.effects.some((effect) => effect.kind === "damage" || effect.kind === "dot"
|
||||
|| effect.kind === "heal" || effect.kind === "hot" || effect.kind === "shield"
|
||||
|| effect.kind === "finisher-damage" || effect.kind === "execute")) return false;
|
||||
const auraEffects = ability.effects.filter((effect) => effect.kind === "apply-aura");
|
||||
if (auraEffects.length === 0) return false;
|
||||
const maintainedAuras = context.maintainedAuras;
|
||||
if (maintainedAuras !== undefined) {
|
||||
return auraEffects.some((effect) => {
|
||||
const recipientId = effect.recipient === "caster"
|
||||
? context.selfId ?? null
|
||||
: effect.recipient === "pet" ? null : targetId;
|
||||
return recipientId !== null && maintainedAuras.some((aura) => (
|
||||
aura.targetId === recipientId && aura.auraId === effect.aura.id
|
||||
));
|
||||
});
|
||||
}
|
||||
// Older selector callers only expose caster-owned exclusive groups. Keep
|
||||
// their conservative behavior until they provide recipient-scoped auras.
|
||||
return auraEffects.some((effect) => Boolean(effect.aura.exclusiveGroup
|
||||
&& context.activeAuraExclusiveGroups?.has(effect.aura.exclusiveGroup)));
|
||||
}
|
||||
|
||||
interface ScoredRotationAbility {
|
||||
readonly ability: AbilityDefinition;
|
||||
readonly score: number;
|
||||
@@ -410,13 +445,20 @@ function scoreAbility(
|
||||
&& ability.effects.some((effect) => effect.kind === "dot" || effect.kind === "hot");
|
||||
const coaPeriodic = context.maintainedPeriodicEffects !== undefined && Boolean(context.selfId)
|
||||
&& usesCoaTargetedPeriodicMaintenance(ability, context);
|
||||
const maintenanceTargetId = targetForAbility(ability, context, lowest)
|
||||
?? (ability.target === "self" ? context.selfId ?? null : null);
|
||||
const periodicMaintained = coaPeriodic
|
||||
? coaPeriodicActionMaintainedOnTarget(ability, context, targetForAbility(ability, context, lowest))
|
||||
: !nativePeriodic && (context.maintainedAbilityIds?.has(ability.id) ?? false);
|
||||
? coaPeriodicActionMaintainedOnTarget(ability, context, maintenanceTargetId)
|
||||
: !nativePeriodic && (context.maintainedPeriodicEffects !== undefined
|
||||
? context.maintainedPeriodicEffects.some((effect) => (
|
||||
effect.abilityId === ability.id
|
||||
&& effect.sourceActorId === context.selfId
|
||||
&& effect.targetId === maintenanceTargetId
|
||||
&& effect.remainingMs > 0
|
||||
))
|
||||
: (context.maintainedAbilityIds?.has(ability.id) ?? false));
|
||||
const maintained = periodicMaintained
|
||||
|| ability.effects.some((effect) => effect.kind === "apply-aura"
|
||||
&& Boolean(effect.aura.exclusiveGroup
|
||||
&& context.activeAuraExclusiveGroups?.has(effect.aura.exclusiveGroup)));
|
||||
|| abilityAuraMaintainedOnTarget(ability, context, maintenanceTargetId);
|
||||
const activeSummon = context.activeSummonAbilityIds?.has(ability.id) ?? false;
|
||||
const petSummon = isPetSummon(ability);
|
||||
const offensiveSummon = isOffensiveSummon(ability);
|
||||
|
||||
Reference in new issue
Block a user