Release Healer Man 0.1.49

This commit is contained in:
phenom committed 2026-09-17 09:13:15 -04:00
1 parent 39425f2494
commit 612170afd0
91 files changed
+2278 -263

No files matched your search

+3 -2
View File
@@ -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]);
+73 -11
View File
@@ -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
+2
View File
@@ -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);
});
+5 -1
View File
@@ -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. `
+6
View File
@@ -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 }
+2
View File
@@ -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]);