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
@@ -12,6 +12,7 @@ import {
|
||||
} from "../../app/characterCatalog";
|
||||
import {
|
||||
abilitiesForClass,
|
||||
abilityTalentNodeId,
|
||||
preloadAbilityCatalogsForClasses,
|
||||
type AbilityAiHints,
|
||||
type AbilityDefinition,
|
||||
@@ -205,8 +206,8 @@ function isClassTree(tree: TalentUiTree): boolean {
|
||||
function coaNodeAbilities(classId: CoaClassId): ReadonlyMap<string, readonly AbilityDefinition[]> {
|
||||
const mutable = new Map<string, AbilityDefinition[]>();
|
||||
for (const ability of abilitiesForClass(classId)) {
|
||||
if (ability.talentEntryId === undefined) continue;
|
||||
const nodeId = `coa-entry-${ability.talentEntryId}`;
|
||||
const nodeId = abilityTalentNodeId(ability);
|
||||
if (!nodeId) continue;
|
||||
const existing = mutable.get(nodeId);
|
||||
if (existing) existing.push(ability);
|
||||
else mutable.set(nodeId, [ability]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
abilityAtLevel,
|
||||
isAbilityUnlocked,
|
||||
learnedAbilityForBuild,
|
||||
resourceProfileForClass,
|
||||
type AbilityDefinition,
|
||||
} from "../abilityCatalog";
|
||||
import { aiHintsForAbility, canAbilityCastWhileMoving, targetingRulesForAbility } from "../abilityRules";
|
||||
@@ -15,8 +15,9 @@ import type { PartyState } from "../partyStore";
|
||||
import { usePartyStore } from "../partyStore";
|
||||
import type { PartyRole } from "../partyRoles";
|
||||
import type { BenchmarkScenarioDefinition } from "./benchmarkTypes";
|
||||
import { COMBAT_ROTATION_VERSION, evaluateCombatRotation } from "../combatRotation";
|
||||
|
||||
export const BENCHMARK_POLICY_ID = "baseline-v3";
|
||||
export const BENCHMARK_POLICY_ID = `rotation-v4:${COMBAT_ROTATION_VERSION}`;
|
||||
|
||||
export interface BenchmarkPolicyDecision {
|
||||
readonly made: boolean;
|
||||
@@ -68,12 +69,9 @@ function friendlyTargets(combat: CombatState, party: PartyState): readonly Frien
|
||||
}
|
||||
|
||||
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 (ability.passive) return null;
|
||||
const ranked = learnedAbilityForBuild(ability, combat.level, combat.talentRanks);
|
||||
if (!ranked) return null;
|
||||
if (ranked.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")) return null;
|
||||
return ranked;
|
||||
}
|
||||
@@ -195,13 +193,77 @@ export class BenchmarkController {
|
||||
) return { made: false, cast: false, resourceBlocked: false, invalidFailures: 0 };
|
||||
|
||||
const targets = friendlyTargets(combat, party);
|
||||
const candidates = combat.abilities
|
||||
const availableAbilities = combat.abilities
|
||||
.map((ability) => learnedAbility(ability, combat))
|
||||
.filter((ability): ability is AbilityDefinition => ability !== null)
|
||||
.filter((ability) => !moving || ability.castTimeMs <= 0 || canAbilityCastWhileMoving(ability))
|
||||
.filter((ability) => !moving || ability.castTimeMs <= 0 || canAbilityCastWhileMoving(ability));
|
||||
const candidates = availableAbilities
|
||||
.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));
|
||||
const hostileTarget = combat.selectedTargetId ? combat.mobs[combat.selectedTargetId] : null;
|
||||
const sharedDecision = evaluateCombatRotation({
|
||||
now,
|
||||
classId: combat.classId,
|
||||
secondaryClassId: combat.secondaryClassId,
|
||||
specializationId: combat.specializationId,
|
||||
role,
|
||||
level: combat.level,
|
||||
health: combat.health,
|
||||
maxHealth: combat.maxHealth,
|
||||
selfId: PLAYER_AGGRO_ID,
|
||||
resources: combat.resourcePools,
|
||||
maximumResources: combat.maxResourcePools,
|
||||
primaryResource: resourceProfileForClass(combat.classId).type,
|
||||
comboPoints: combat.comboPoints,
|
||||
comboTargetId: combat.comboTargetId,
|
||||
runes: combat.deathKnightRunes,
|
||||
abilities: availableAbilities,
|
||||
hostileTarget,
|
||||
friendlyTargets: [
|
||||
{ id: PLAYER_AGGRO_ID, health: combat.health, maxHealth: combat.maxHealth, fallen: combat.health <= 0 },
|
||||
...party.members.map((member) => ({
|
||||
id: member.id,
|
||||
health: member.health,
|
||||
maxHealth: member.maxHealth,
|
||||
fallen: member.health <= 0,
|
||||
})),
|
||||
],
|
||||
hostileCount: scenario.hostileTargets,
|
||||
targetIsLoose: role === "tank" && Boolean(hostileTarget?.targetActorId && hostileTarget.targetActorId !== PLAYER_AGGRO_ID),
|
||||
hasDangerousThreat: role !== "tank" && hostileTarget?.targetActorId === PLAYER_AGGRO_ID,
|
||||
moving,
|
||||
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)),
|
||||
]),
|
||||
activeAuraExclusiveGroups: new Set(combat.auras
|
||||
.filter((aura) => aura.sourceId === PLAYER_AGGRO_ID && (aura.expiresAt === null || aura.expiresAt > now))
|
||||
.flatMap((aura) => aura.definition.exclusiveGroup ? [aura.definition.exclusiveGroup] : [])),
|
||||
activeSummonAbilityIds: new Set(combat.summons
|
||||
.filter((summon) => summon.ownerActorId === PLAYER_AGGRO_ID && summon.expiresAt > now)
|
||||
.map((summon) => summon.sourceAbilityId)),
|
||||
hasActivePet: combat.summons.some((summon) => {
|
||||
if (summon.ownerActorId !== PLAYER_AGGRO_ID || summon.expiresAt <= now) return false;
|
||||
const source = availableAbilities.find((ability) => ability.id === summon.sourceAbilityId);
|
||||
return source?.effects.some((effect) => effect.kind === "pet"
|
||||
&& ["summon", "call", "resurrect", "tame"].includes(effect.action)) ?? false;
|
||||
}),
|
||||
});
|
||||
if (sharedDecision) {
|
||||
const friendlyTarget = sharedDecision.targetId
|
||||
? targets.find((target) => target.id === sharedDecision.targetId) ?? null
|
||||
: null;
|
||||
const existing = candidates.findIndex((candidate) => candidate.ability.id === sharedDecision.ability.id);
|
||||
if (existing >= 0) candidates.splice(existing, 1);
|
||||
candidates.unshift({ ability: sharedDecision.ability, score: Number.POSITIVE_INFINITY, friendlyTarget });
|
||||
}
|
||||
|
||||
// Rage healers must begin weapon attacks to fund their heals before allies are injured.
|
||||
if (role === "healer" && combat.selectedTargetId
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
BenchmarkScenarioDefinition,
|
||||
} from "./benchmarkTypes";
|
||||
import { BENCHMARK_POLICY_ID } from "./benchmarkController";
|
||||
import { COMBAT_ROTATION_VERSION } from "../combatRotation";
|
||||
|
||||
interface MutableAbilityMetrics {
|
||||
castsStarted: number;
|
||||
@@ -260,6 +261,7 @@ export class BenchmarkRecorder {
|
||||
schemaVersion: 1,
|
||||
runId: `${this.#build.id}__${this.#scenario.id}-v${this.#scenario.version}__seed-${this.#seed}`,
|
||||
policyId: BENCHMARK_POLICY_ID,
|
||||
rotationVersion: COMBAT_ROTATION_VERSION,
|
||||
seed: this.#seed,
|
||||
build: this.#build,
|
||||
scenario: this.#scenario,
|
||||
|
||||
@@ -98,6 +98,7 @@ function matrix(): BenchmarkMatrixReport {
|
||||
seeds: [1, 2, 3, 4],
|
||||
durationScale: 1,
|
||||
policyId: "baseline-v1",
|
||||
rotationVersion: "playerbots-inspired-v1",
|
||||
},
|
||||
scenarios: [scenario],
|
||||
builds: [build],
|
||||
@@ -150,6 +151,7 @@ describe("benchmark reporting", () => {
|
||||
const csv = serializeBenchmarkMatrixCsv(matrix());
|
||||
|
||||
expect(csv).toContain("scenario_id,scenario_version,scenario_name");
|
||||
expect(csv).toContain("policy_id,rotation_version,scenario_id");
|
||||
expect(csv).toContain('mage-burst,wow,mage,,damage,60,"spell, ""burst"""');
|
||||
expect(csv).toContain(",dps,3,0,12,20,28\n");
|
||||
expect(csv.endsWith("\n")).toBe(true);
|
||||
@@ -164,6 +166,7 @@ describe("benchmark reporting", () => {
|
||||
expect(markdown).toContain("DPS: 20 [12–28]");
|
||||
expect(markdown).toContain("1 invalid run(s) are excluded");
|
||||
expect(markdown).toContain("does not calculate a composite score");
|
||||
expect(markdown).toContain("rotation: `playerbots-inspired-v1`");
|
||||
expect(markdown.endsWith("\n")).toBe(true);
|
||||
expect(markdown.endsWith("\n\n")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -318,6 +318,7 @@ export function serializeBenchmarkMatrixCsv(input: BenchmarkReportInput): string
|
||||
"seeds",
|
||||
"duration_scale",
|
||||
"policy_id",
|
||||
"rotation_version",
|
||||
"scenario_id",
|
||||
"scenario_version",
|
||||
"scenario_name",
|
||||
@@ -354,6 +355,7 @@ export function serializeBenchmarkMatrixCsv(input: BenchmarkReportInput): string
|
||||
summary.cohort.seeds.join(";"),
|
||||
summary.cohort.durationScale,
|
||||
summary.cohort.policyId,
|
||||
summary.cohort.rotationVersion ?? null,
|
||||
row.scenarioId,
|
||||
row.scenarioVersion,
|
||||
row.scenarioName,
|
||||
@@ -430,7 +432,9 @@ export function renderBenchmarkMatrixMarkdown(input: BenchmarkReportInput): stri
|
||||
`Generated ${summary.generatedAt} with app ${summary.appVersion}. `
|
||||
+ `Cohort: level ${summary.cohort.level}, gear \`${summary.cohort.gearPresetId}\`, `
|
||||
+ `${summary.cohort.seeds.length} seed(s), duration scale ${summary.cohort.durationScale}.`,
|
||||
`Policy: \`${summary.cohort.policyId}\`.`,
|
||||
`Policy: \`${summary.cohort.policyId}\`${summary.cohort.rotationVersion
|
||||
? `; rotation: \`${summary.cohort.rotationVersion}\``
|
||||
: ""}.`,
|
||||
"",
|
||||
`${summary.validRunCount}/${summary.runCount} runs were valid; `
|
||||
+ `${summary.invalidRunCount} invalid run(s) are excluded from distributions. `
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
BenchmarkRunReport,
|
||||
BenchmarkScenarioDefinition,
|
||||
} from "./benchmarkTypes";
|
||||
import { specializationsForClass } from "../specializationCatalog";
|
||||
|
||||
const BENCHMARK_CLOCK_ORIGIN = 1_000_000;
|
||||
const BENCHMARK_MOB_HEALTH = 1_000_000_000;
|
||||
@@ -274,9 +275,12 @@ export async function runCombatBenchmark(
|
||||
let completed = true;
|
||||
try {
|
||||
const combat = useCombatStore.getState();
|
||||
const specializationId = specializationsForClass(build.classId)
|
||||
.find((specialization) => specialization.displayName === build.orientation)?.id;
|
||||
combat.initializeCharacter({
|
||||
classId: build.classId,
|
||||
secondaryClassId: build.secondaryClassId,
|
||||
specializationId,
|
||||
level: build.level,
|
||||
talentRanks: build.talentRanks,
|
||||
inventory: build.inventory,
|
||||
@@ -296,6 +300,8 @@ export async function runCombatBenchmark(
|
||||
const positions = partyPositions(scenario.partyLayout, party.members.length);
|
||||
party.members.forEach((member, index) => registerPartyRuntimePosition(member.id, positions[index] ?? [0, 0, 0]));
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
useCombatStore.getState().applyDefaultPartyBuffs(now);
|
||||
useCombatStore.getState().applyPrecombatPreparation(now);
|
||||
|
||||
const referenceTank = build.role === "tank"
|
||||
? { maxHealth: initializedCombat.maxHealth }
|
||||
|
||||
@@ -126,6 +126,7 @@ export interface BenchmarkRunReport {
|
||||
readonly schemaVersion: 1;
|
||||
readonly runId: string;
|
||||
readonly policyId: string;
|
||||
readonly rotationVersion?: string;
|
||||
readonly seed: number;
|
||||
readonly build: BenchmarkBuildDefinition;
|
||||
readonly scenario: BenchmarkScenarioDefinition;
|
||||
@@ -149,6 +150,7 @@ export interface BenchmarkMatrixReport {
|
||||
readonly seeds: readonly number[];
|
||||
readonly durationScale: number;
|
||||
readonly policyId: string;
|
||||
readonly rotationVersion?: string;
|
||||
};
|
||||
readonly scenarios: readonly BenchmarkScenarioDefinition[];
|
||||
readonly builds: readonly BenchmarkBuildDefinition[];
|
||||
|
||||
@@ -62,6 +62,8 @@ export async function runDungeonBossBenchmark(
|
||||
combat.setPlayerPosition([-2, 0, 0]);
|
||||
usePartyStore.getState().initializeParty(seed, build.level, useCombatStore.getState().maxHealth, build.role, 5, build.source);
|
||||
combat.synchronizeActors();
|
||||
combat.applyDefaultPartyBuffs(now);
|
||||
combat.applyPrecombatPreparation(now);
|
||||
combat.registerMob(bossId, { name: entity.name, boss: true, serverEntry: entity.combat?.serverEntry,
|
||||
...scaling, attacks: entity.combat?.attacks, xpReward: 0 });
|
||||
setMobPosition(bossId, [0, 0, 0]);
|
||||
|
||||
Reference in new issue
Block a user