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,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { combatSimulationTimerShouldRun } from "./combatSimulationLifecycle";
|
||||
|
||||
describe("combatSimulationTimerShouldRun", () => {
|
||||
it("runs during active local gameplay", () => {
|
||||
expect(combatSimulationTimerShouldRun({
|
||||
sharedOnline: false,
|
||||
paused: false,
|
||||
companionOpen: false,
|
||||
documentHidden: false,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ paused: true, companionOpen: false, documentHidden: false },
|
||||
{ paused: false, companionOpen: true, documentHidden: false },
|
||||
{ paused: false, companionOpen: false, documentHidden: true },
|
||||
])("stops inactive local gameplay for %o", (state) => {
|
||||
expect(combatSimulationTimerShouldRun({
|
||||
sharedOnline: false,
|
||||
...state,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves shared-online simulation in every local lifecycle state", () => {
|
||||
expect(combatSimulationTimerShouldRun({
|
||||
sharedOnline: true,
|
||||
paused: true,
|
||||
companionOpen: true,
|
||||
documentHidden: true,
|
||||
})).toBe(true);
|
||||
});
|
||||
});
|
||||
+84
-60
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { advanceDungeonPartyCombat } from "./partyRuntime";
|
||||
import { useGameStore } from "./store";
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
onlineSessionIsActive,
|
||||
onlineSessionOwnsWorldSimulation,
|
||||
} from "./onlineSessionRuntime";
|
||||
import { combatSimulationTimerShouldRun } from "./combatSimulationLifecycle";
|
||||
|
||||
/**
|
||||
* Connects the pure combat runtime to the active character and persistent
|
||||
@@ -42,73 +44,55 @@ export function CombatBridge() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
let lastKey = `${character.level}:${character.experience}:${JSON.stringify(character.talentRanks)}`;
|
||||
const unsubscribe = useCombatStore.subscribe((state) => {
|
||||
const nextKey = `${state.level}:${state.xp}:${JSON.stringify(state.talentRanks)}`;
|
||||
if (nextKey === lastKey) return;
|
||||
lastKey = nextKey;
|
||||
// Kills and talent changes are infrequent, so persist immediately. This
|
||||
// also guarantees a fast return to Character Select cannot outrun a
|
||||
// deferred save and lose the last level or talent point.
|
||||
useShellStore.getState().saveActiveProgression(state.level, state.xp, state.talentRanks);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [character?.id]);
|
||||
const characterId = character.id;
|
||||
const unsubscribe = useCombatStore.subscribe((state, previousState) => {
|
||||
// Save APIs intentionally target the active character. Avoid persisting a
|
||||
// final combat notification during the render-to-effect-cleanup window
|
||||
// after Character Select has already replaced the active character.
|
||||
const shell = useShellStore.getState();
|
||||
if (shell.activeCharacter?.id !== characterId) return;
|
||||
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
let lastKey = JSON.stringify(useCombatStore.getState().actionBindings);
|
||||
const unsubscribe = useCombatStore.subscribe((state) => {
|
||||
const nextKey = JSON.stringify(state.actionBindings);
|
||||
if (nextKey === lastKey) return;
|
||||
lastKey = nextKey;
|
||||
// Manastorm's local spell-selection substitute temporarily rewrites the
|
||||
// base controls. Never persist that run-only overlay to normal dungeons.
|
||||
if (useGameStore.getState().gameMode === "manastorm") return;
|
||||
useShellStore.getState().saveActiveActionBindings(state.actionBindings);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [character?.id]);
|
||||
// Persisted collections are replaced immutably by combat actions. A
|
||||
// reference/primitive comparison therefore detects the same changes as
|
||||
// serializing each collection, without doing five full-store callbacks
|
||||
// and JSON.stringify work for every 100 ms simulation notification.
|
||||
if (
|
||||
state.level !== previousState.level
|
||||
|| state.xp !== previousState.xp
|
||||
|| state.talentRanks !== previousState.talentRanks
|
||||
) {
|
||||
// Kills and talent changes are infrequent, so persist immediately. This
|
||||
// also guarantees a fast return to Character Select cannot outrun a
|
||||
// deferred save and lose the last level or talent point.
|
||||
shell.saveActiveProgression(state.level, state.xp, state.talentRanks);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
let lastInventory = JSON.stringify(character.inventory);
|
||||
const unsubscribe = useCombatStore.subscribe((state) => {
|
||||
const nextInventory = JSON.stringify(state.inventory);
|
||||
if (nextInventory === lastInventory) return;
|
||||
lastInventory = nextInventory;
|
||||
useShellStore.getState().saveActiveInventory(state.inventory);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [character?.id]);
|
||||
if (state.actionBindings !== previousState.actionBindings) {
|
||||
// Manastorm's local spell-selection substitute temporarily rewrites the
|
||||
// base controls. Never persist that run-only overlay to normal dungeons.
|
||||
if (useGameStore.getState().gameMode !== "manastorm") {
|
||||
shell.saveActiveActionBindings(state.actionBindings);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
let lastEquipment = JSON.stringify(character.equipment ?? {});
|
||||
const unsubscribe = useCombatStore.subscribe((state) => {
|
||||
const nextEquipment = JSON.stringify(state.equipment);
|
||||
if (nextEquipment === lastEquipment) return;
|
||||
lastEquipment = nextEquipment;
|
||||
useShellStore.getState().saveActiveEquipment(state.equipment);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [character?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
let lastSettings = JSON.stringify(useCombatStore.getState().settings);
|
||||
const unsubscribe = useCombatStore.subscribe((state) => {
|
||||
const nextSettings = JSON.stringify(state.settings);
|
||||
if (nextSettings === lastSettings) return;
|
||||
lastSettings = nextSettings;
|
||||
useShellStore.getState().saveActiveSettings(state.settings);
|
||||
if (state.inventory !== previousState.inventory) {
|
||||
shell.saveActiveInventory(state.inventory);
|
||||
}
|
||||
if (state.equipment !== previousState.equipment) {
|
||||
shell.saveActiveEquipment(state.equipment);
|
||||
}
|
||||
if (state.settings !== previousState.settings) {
|
||||
shell.saveActiveSettings(state.settings);
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [character?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
let previous = performance.now();
|
||||
const timer = window.setInterval(() => {
|
||||
let timer: number | null = null;
|
||||
|
||||
const advance = () => {
|
||||
const now = performance.now();
|
||||
const delta = Math.min(0.25, Math.max(0, (now - previous) / 1_000));
|
||||
previous = now;
|
||||
@@ -138,8 +122,48 @@ export function CombatBridge() {
|
||||
);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
return () => window.clearInterval(timer);
|
||||
};
|
||||
|
||||
const reconcileTimer = () => {
|
||||
const game = useGameStore.getState();
|
||||
const shouldRun = combatSimulationTimerShouldRun({
|
||||
sharedOnline: onlineSessionIsActive(),
|
||||
paused: game.paused,
|
||||
companionOpen: game.companionOpen,
|
||||
documentHidden: document.hidden,
|
||||
});
|
||||
if (shouldRun && timer === null) {
|
||||
// Reset the clock on resume so a long pause/background period cannot
|
||||
// leak into the first simulation delta.
|
||||
previous = performance.now();
|
||||
timer = window.setInterval(advance, 100);
|
||||
} else if (!shouldRun && timer !== null) {
|
||||
window.clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribeGame = useGameStore.subscribe((state, previousState) => {
|
||||
if (
|
||||
state.paused !== previousState.paused
|
||||
|| state.companionOpen !== previousState.companionOpen
|
||||
) {
|
||||
reconcileTimer();
|
||||
}
|
||||
});
|
||||
// Online runtime configuration is intentionally module-local; the session
|
||||
// store notification gives the bridge a reactive point at which to
|
||||
// reconcile while preserving continuous shared-online simulation.
|
||||
const unsubscribeOnlineSession = useOnlineSessionStore.subscribe(reconcileTimer);
|
||||
document.addEventListener("visibilitychange", reconcileTimer);
|
||||
reconcileTimer();
|
||||
|
||||
return () => {
|
||||
if (timer !== null) window.clearInterval(timer);
|
||||
unsubscribeGame();
|
||||
unsubscribeOnlineSession();
|
||||
document.removeEventListener("visibilitychange", reconcileTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CLASSES } from "../../app/characterCatalog";
|
||||
import { partyRolesForClass } from "../partyRoles";
|
||||
import { runtimeNormalizeTalentRanks } from "../talentRuntimeCatalog";
|
||||
import { loadCanonicalBenchmarkBuilds } from "./benchmarkBuilds";
|
||||
|
||||
describe("canonical benchmark builds", () => {
|
||||
it("covers every declared class-role pair exactly once with legal ranks", async () => {
|
||||
const builds = await loadCanonicalBenchmarkBuilds();
|
||||
const expectedPairs = CLASSES.flatMap((definition) => (
|
||||
partyRolesForClass(definition.id).map((role) => `${definition.id}:${role}`)
|
||||
));
|
||||
const actualPairs = builds.map((build) => `${build.classId}:${build.role}`);
|
||||
|
||||
expect(builds).toHaveLength(77);
|
||||
expect(new Set(actualPairs).size).toBe(builds.length);
|
||||
expect(new Set(actualPairs)).toEqual(new Set(expectedPairs));
|
||||
for (const build of builds) {
|
||||
expect(build.level).toBe(60);
|
||||
expect(build.gearPresetId).toBe("coefficient-empty");
|
||||
expect(build.inventory).toEqual([]);
|
||||
expect(build.equipment).toEqual({});
|
||||
expect(runtimeNormalizeTalentRanks(build.classId, build.level, build.talentRanks)).toEqual(build.talentRanks);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it("filters classes without changing their canonical definitions", async () => {
|
||||
const builds = await loadCanonicalBenchmarkBuilds(60, ["mage", "rom-priest"]);
|
||||
|
||||
expect(builds.map((build) => `${build.classId}:${build.role}`)).toEqual([
|
||||
"mage:damage",
|
||||
"rom-priest:healer",
|
||||
"rom-priest:damage",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,407 @@
|
||||
import {
|
||||
BASE_CLASS_IDS,
|
||||
CLASSES,
|
||||
COA_CLASS_IDS,
|
||||
ROM_CLASS_IDS,
|
||||
contentCategoryForClass,
|
||||
type BaseClassId,
|
||||
type ClassId,
|
||||
type CoaClassId,
|
||||
type RomClassId,
|
||||
} from "../../app/characterCatalog";
|
||||
import {
|
||||
abilitiesForClass,
|
||||
preloadAbilityCatalogsForClasses,
|
||||
type AbilityAiHints,
|
||||
type AbilityDefinition,
|
||||
} from "../abilityCatalog";
|
||||
import { aiHintsForAbility } from "../abilityRules";
|
||||
import type { EquipmentAssignments } from "../equipment";
|
||||
import { partyRolesForClass, type PartyRole } from "../partyRoles";
|
||||
import { runtimePartyTalentRanksForLevel } from "../partyTalentRuntime";
|
||||
import { clampPlayerLevel } from "../progression";
|
||||
import {
|
||||
runtimeAllocateTalentPoint,
|
||||
runtimeNormalizeTalentRanks,
|
||||
type RuntimeTalentRanks,
|
||||
} from "../talentRuntimeCatalog";
|
||||
import {
|
||||
loadTalentUiCatalog,
|
||||
type TalentUiCatalog,
|
||||
type TalentUiNode,
|
||||
type TalentUiTree,
|
||||
} from "../talentUiCatalog";
|
||||
import type { BenchmarkBuildDefinition } from "./benchmarkTypes";
|
||||
|
||||
export const CANONICAL_BENCHMARK_LEVEL = 60;
|
||||
export const CANONICAL_BENCHMARK_GEAR_PRESET_ID = "coefficient-empty";
|
||||
|
||||
const EMPTY_INVENTORY: readonly never[] = Object.freeze([]);
|
||||
const EMPTY_EQUIPMENT: EquipmentAssignments = Object.freeze({});
|
||||
const EMPTY_TALENT_RANKS: RuntimeTalentRanks = Object.freeze({});
|
||||
|
||||
const BASE_CLASS_ID_SET: ReadonlySet<ClassId> = new Set(BASE_CLASS_IDS);
|
||||
const COA_CLASS_ID_SET: ReadonlySet<ClassId> = new Set(COA_CLASS_IDS);
|
||||
const ROM_CLASS_ID_SET: ReadonlySet<ClassId> = new Set(ROM_CLASS_IDS);
|
||||
|
||||
const WOW_ORIENTATION_BY_ROLE: Readonly<
|
||||
Record<BaseClassId, Readonly<Partial<Record<PartyRole, string>>>>
|
||||
> = Object.freeze({
|
||||
warrior: { tank: "Protection", damage: "Fury" },
|
||||
paladin: { tank: "Protection", healer: "Holy", damage: "Retribution" },
|
||||
hunter: { damage: "Marksmanship" },
|
||||
rogue: { damage: "Combat" },
|
||||
priest: { healer: "Holy", damage: "Shadow" },
|
||||
"death-knight": { tank: "Blood", damage: "Frost" },
|
||||
shaman: { healer: "Restoration", damage: "Elemental" },
|
||||
mage: { damage: "Fire" },
|
||||
warlock: { damage: "Affliction" },
|
||||
druid: { tank: "Feral (Cat)", healer: "Restoration", damage: "Balance" },
|
||||
});
|
||||
|
||||
type AbilityAiCategory = AbilityAiHints["categories"][number];
|
||||
|
||||
/**
|
||||
* The large gaps keep the primary responsibility of a role ahead of merely
|
||||
* having more utility buttons. These are only selection weights; allocation
|
||||
* legality and point costs remain entirely owned by the runtime talent system.
|
||||
*/
|
||||
const COA_ROLE_CATEGORY_WEIGHTS: Readonly<
|
||||
Record<PartyRole, Readonly<Partial<Record<AbilityAiCategory, number>>>>
|
||||
> = Object.freeze({
|
||||
tank: Object.freeze({
|
||||
taunt: 1_000,
|
||||
mitigation: 500,
|
||||
control: 50,
|
||||
interrupt: 50,
|
||||
heal: 20,
|
||||
"emergency-heal": 20,
|
||||
dispel: 15,
|
||||
debuff: 10,
|
||||
execute: 10,
|
||||
damage: 10,
|
||||
}),
|
||||
healer: Object.freeze({
|
||||
"emergency-heal": 800,
|
||||
"area-heal": 700,
|
||||
heal: 500,
|
||||
mitigation: 100,
|
||||
dispel: 80,
|
||||
resurrection: 60,
|
||||
control: 20,
|
||||
interrupt: 15,
|
||||
debuff: 5,
|
||||
damage: 2,
|
||||
}),
|
||||
damage: Object.freeze({
|
||||
execute: 200,
|
||||
damage: 100,
|
||||
debuff: 40,
|
||||
interrupt: 30,
|
||||
control: 20,
|
||||
mitigation: 3,
|
||||
}),
|
||||
});
|
||||
|
||||
interface CoaRoleTextPattern {
|
||||
readonly pattern: RegExp;
|
||||
readonly weight: number;
|
||||
}
|
||||
|
||||
// CoA specialization trees contain many passive nodes with no executable
|
||||
// ability (and consequently no AbilityAiHints). Catalog text is a stable
|
||||
// secondary signal for those nodes; executable ability hints still contribute
|
||||
// directly whenever the runtime catalog provides them.
|
||||
const COA_ROLE_TEXT_PATTERNS: Readonly<Record<PartyRole, readonly CoaRoleTextPattern[]>> = Object.freeze({
|
||||
tank: Object.freeze([
|
||||
{ pattern: /\b(?:taunt|threat)\b/i, weight: 300 },
|
||||
{ pattern: /\b(?:damage (?:you )?take|damage taken|incoming damage|less damage)\b/i, weight: 160 },
|
||||
{ pattern: /\b(?:armor|block|parry|dodge|deflect)\b/i, weight: 90 },
|
||||
{ pattern: /\b(?:absorb|shield|barrier)\b/i, weight: 70 },
|
||||
{ pattern: /\b(?:maximum health|max health|stamina)\b/i, weight: 50 },
|
||||
{ pattern: /\b(?:protect|guard)\w*\b/i, weight: 35 },
|
||||
{ pattern: /\b(?:resistan\w*|immune)\b/i, weight: 20 },
|
||||
{ pattern: /\b(?:healing received|heal(?:s|ing)? you|restore\w* health)\b/i, weight: 15 },
|
||||
]),
|
||||
healer: Object.freeze([
|
||||
{ pattern: /\b(?:resurrect|revive)\w*\b/i, weight: 200 },
|
||||
{ pattern: /\b(?:heal|healing|restore health|recovery)\w*\b/i, weight: 100 },
|
||||
{ pattern: /\b(?:dispel|cleanse|purif)\w*\b/i, weight: 60 },
|
||||
{ pattern: /\b(?:absorb|shield)\w*\b/i, weight: 30 },
|
||||
{ pattern: /\b(?:ally|allies|party member)\b/i, weight: 25 },
|
||||
]),
|
||||
damage: Object.freeze([
|
||||
{ pattern: /\b(?:damage|damaging)\w*\b/i, weight: 25 },
|
||||
{ pattern: /\b(?:attack power|spell power|critical strike|critical damage|haste)\b/i, weight: 12 },
|
||||
{ pattern: /\b(?:enemy|enemies)\b/i, weight: 5 },
|
||||
]),
|
||||
});
|
||||
|
||||
function isBaseClassId(classId: ClassId): classId is BaseClassId {
|
||||
return BASE_CLASS_ID_SET.has(classId);
|
||||
}
|
||||
|
||||
function isCoaClassId(classId: ClassId): classId is CoaClassId {
|
||||
return COA_CLASS_ID_SET.has(classId);
|
||||
}
|
||||
|
||||
function isRomClassId(classId: ClassId): classId is RomClassId {
|
||||
return ROM_CLASS_ID_SET.has(classId);
|
||||
}
|
||||
|
||||
function benchmarkBuildId(classId: ClassId, role: PartyRole, level: number): string {
|
||||
return `canonical-${contentCategoryForClass(classId)}-${classId}-${role}-level-${level}`;
|
||||
}
|
||||
|
||||
function commonBuildFields(classId: ClassId, role: PartyRole, level: number) {
|
||||
return {
|
||||
id: benchmarkBuildId(classId, role, level),
|
||||
source: contentCategoryForClass(classId),
|
||||
classId,
|
||||
secondaryClassId: null,
|
||||
role,
|
||||
level,
|
||||
inventory: EMPTY_INVENTORY,
|
||||
equipment: EMPTY_EQUIPMENT,
|
||||
gearPresetId: CANONICAL_BENCHMARK_GEAR_PRESET_ID,
|
||||
} as const;
|
||||
}
|
||||
|
||||
function wowBuild(
|
||||
classId: BaseClassId,
|
||||
role: PartyRole,
|
||||
level: number,
|
||||
): BenchmarkBuildDefinition {
|
||||
const orientation = WOW_ORIENTATION_BY_ROLE[classId][role];
|
||||
if (!orientation) {
|
||||
throw new Error(`No canonical WoW talent orientation exists for ${classId}:${role}.`);
|
||||
}
|
||||
return Object.freeze({
|
||||
...commonBuildFields(classId, role, level),
|
||||
orientation,
|
||||
talentRanks: runtimePartyTalentRanksForLevel(classId, orientation, level),
|
||||
});
|
||||
}
|
||||
|
||||
function isClassTree(tree: TalentUiTree): boolean {
|
||||
return tree.name.trim().toLowerCase() === "class";
|
||||
}
|
||||
|
||||
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 existing = mutable.get(nodeId);
|
||||
if (existing) existing.push(ability);
|
||||
else mutable.set(nodeId, [ability]);
|
||||
}
|
||||
return mutable;
|
||||
}
|
||||
|
||||
function coaAbilityRoleScore(ability: AbilityDefinition, role: PartyRole): number {
|
||||
const hints = aiHintsForAbility(ability);
|
||||
const weights = COA_ROLE_CATEGORY_WEIGHTS[role];
|
||||
const categoryScore = hints.categories.reduce(
|
||||
(total, category) => total + (weights[category] ?? 0),
|
||||
0,
|
||||
);
|
||||
if (categoryScore <= 0) return 0;
|
||||
const priority = Number.isFinite(hints.priority)
|
||||
? Math.max(0, Math.min(100, hints.priority ?? 0))
|
||||
: 0;
|
||||
return categoryScore + priority;
|
||||
}
|
||||
|
||||
function coaNodeRoleScore(
|
||||
nodeId: string,
|
||||
role: PartyRole,
|
||||
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
|
||||
): number {
|
||||
return (abilitiesByNode.get(nodeId) ?? []).reduce(
|
||||
(highest, ability) => Math.max(highest, coaAbilityRoleScore(ability, role)),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function coaNodeTextRoleScore(node: TalentUiNode, role: PartyRole): number {
|
||||
const searchable = `${node.name} ${node.description}`;
|
||||
return COA_ROLE_TEXT_PATTERNS[role].reduce(
|
||||
(total, entry) => total + (entry.pattern.test(searchable) ? entry.weight : 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function coaNodeSelectionScore(
|
||||
node: TalentUiNode,
|
||||
role: PartyRole,
|
||||
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
|
||||
): number {
|
||||
return coaNodeRoleScore(node.id, role, abilitiesByNode) + coaNodeTextRoleScore(node, role);
|
||||
}
|
||||
|
||||
function coaTreeRoleScore(
|
||||
tree: TalentUiTree,
|
||||
catalog: TalentUiCatalog,
|
||||
role: PartyRole,
|
||||
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
|
||||
): number {
|
||||
return catalog.nodes.reduce((total, node) => (
|
||||
node.treeId === tree.id
|
||||
? total + coaNodeSelectionScore(node, role, abilitiesByNode) * node.maxRank
|
||||
: total
|
||||
), 0);
|
||||
}
|
||||
|
||||
function preferredCoaTree(
|
||||
catalog: TalentUiCatalog,
|
||||
role: PartyRole,
|
||||
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
|
||||
): TalentUiTree {
|
||||
const specializationTrees = catalog.trees.filter((tree) => !isClassTree(tree));
|
||||
const candidates = specializationTrees.length > 0 ? specializationTrees : catalog.trees;
|
||||
const catalogOrder = new Map(catalog.trees.map((tree, index) => [tree.id, index]));
|
||||
const selected = [...candidates].sort((left, right) => (
|
||||
coaTreeRoleScore(right, catalog, role, abilitiesByNode)
|
||||
- coaTreeRoleScore(left, catalog, role, abilitiesByNode)
|
||||
|| (catalogOrder.get(left.id) ?? 0) - (catalogOrder.get(right.id) ?? 0)
|
||||
|| left.id.localeCompare(right.id)
|
||||
))[0];
|
||||
if (!selected) throw new Error(`No CoA talent trees exist for ${catalog.classId}.`);
|
||||
return selected;
|
||||
}
|
||||
|
||||
function orderedCoaAllocationCandidates(
|
||||
catalog: TalentUiCatalog,
|
||||
preferredTree: TalentUiTree,
|
||||
role: PartyRole,
|
||||
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
|
||||
): readonly TalentUiNode[] {
|
||||
const classTreeIds = new Set(catalog.trees.filter(isClassTree).map((tree) => tree.id));
|
||||
const treePriority = (node: TalentUiNode) => (
|
||||
node.treeId === preferredTree.id ? 0 : classTreeIds.has(node.treeId) ? 1 : 2
|
||||
);
|
||||
return catalog.nodes
|
||||
.filter((node) => node.treeId === preferredTree.id || classTreeIds.has(node.treeId))
|
||||
.sort((left, right) => (
|
||||
treePriority(left) - treePriority(right)
|
||||
|| coaNodeSelectionScore(right, role, abilitiesByNode)
|
||||
- coaNodeSelectionScore(left, role, abilitiesByNode)
|
||||
|| left.row - right.row
|
||||
|| left.column - right.column
|
||||
|| left.index - right.index
|
||||
|| left.id.localeCompare(right.id)
|
||||
));
|
||||
}
|
||||
|
||||
function allocateCoaTalentRanks(
|
||||
classId: CoaClassId,
|
||||
catalog: TalentUiCatalog,
|
||||
preferredTree: TalentUiTree,
|
||||
role: PartyRole,
|
||||
level: number,
|
||||
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
|
||||
): RuntimeTalentRanks {
|
||||
const candidates = orderedCoaAllocationCandidates(
|
||||
catalog,
|
||||
preferredTree,
|
||||
role,
|
||||
abilitiesByNode,
|
||||
);
|
||||
const successfulRequests: Record<string, number> = {};
|
||||
let ranks: RuntimeTalentRanks = EMPTY_TALENT_RANKS;
|
||||
|
||||
// A successful request is never repeated, even if normalization later omits
|
||||
// a free node. This keeps zero-cost prerequisite layouts finite while failed
|
||||
// gated nodes remain eligible after lower rows have been filled.
|
||||
while (true) {
|
||||
let allocated = false;
|
||||
for (const node of candidates) {
|
||||
if ((successfulRequests[node.id] ?? 0) >= node.maxRank) continue;
|
||||
const result = runtimeAllocateTalentPoint(ranks, classId, node.id, level);
|
||||
if (!result.ok) continue;
|
||||
successfulRequests[node.id] = (successfulRequests[node.id] ?? 0) + 1;
|
||||
ranks = result.ranks;
|
||||
allocated = true;
|
||||
break;
|
||||
}
|
||||
if (!allocated) break;
|
||||
}
|
||||
|
||||
return Object.freeze({ ...runtimeNormalizeTalentRanks(classId, level, ranks) });
|
||||
}
|
||||
|
||||
function coaBuild(
|
||||
classId: CoaClassId,
|
||||
role: PartyRole,
|
||||
level: number,
|
||||
catalog: TalentUiCatalog,
|
||||
): BenchmarkBuildDefinition {
|
||||
const abilitiesByNode = coaNodeAbilities(classId);
|
||||
const orientation = preferredCoaTree(catalog, role, abilitiesByNode);
|
||||
return Object.freeze({
|
||||
...commonBuildFields(classId, role, level),
|
||||
orientation: orientation.name,
|
||||
talentRanks: allocateCoaTalentRanks(
|
||||
classId,
|
||||
catalog,
|
||||
orientation,
|
||||
role,
|
||||
level,
|
||||
abilitiesByNode,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function romBuild(
|
||||
classId: RomClassId,
|
||||
role: PartyRole,
|
||||
level: number,
|
||||
): BenchmarkBuildDefinition {
|
||||
return Object.freeze({
|
||||
...commonBuildFields(classId, role, level),
|
||||
orientation: "Primary",
|
||||
talentRanks: EMPTY_TALENT_RANKS,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one stable, legal benchmark build for every declared class-role pair.
|
||||
* A filter changes only membership; output always follows the canonical CLASSES
|
||||
* and partyRolesForClass declaration order.
|
||||
*/
|
||||
export async function loadCanonicalBenchmarkBuilds(
|
||||
level = CANONICAL_BENCHMARK_LEVEL,
|
||||
classIds?: readonly ClassId[],
|
||||
): Promise<BenchmarkBuildDefinition[]> {
|
||||
const safeLevel = clampPlayerLevel(level);
|
||||
const requested = classIds ? new Set(classIds) : null;
|
||||
const selectedClassIds = CLASSES
|
||||
.map((definition) => definition.id)
|
||||
.filter((classId) => !requested || requested.has(classId));
|
||||
const selectedCoaClassIds = selectedClassIds.filter(isCoaClassId);
|
||||
|
||||
await preloadAbilityCatalogsForClasses(selectedCoaClassIds);
|
||||
const coaCatalogs = new Map<CoaClassId, TalentUiCatalog>(await Promise.all(
|
||||
selectedCoaClassIds.map(async (classId) => [
|
||||
classId,
|
||||
await loadTalentUiCatalog(classId),
|
||||
] as const),
|
||||
));
|
||||
|
||||
const builds: BenchmarkBuildDefinition[] = [];
|
||||
for (const classId of selectedClassIds) {
|
||||
for (const role of partyRolesForClass(classId)) {
|
||||
if (isBaseClassId(classId)) {
|
||||
builds.push(wowBuild(classId, role, safeLevel));
|
||||
} else if (isCoaClassId(classId)) {
|
||||
const catalog = coaCatalogs.get(classId);
|
||||
if (!catalog) throw new Error(`No loaded CoA talent catalog exists for ${classId}.`);
|
||||
builds.push(coaBuild(classId, role, safeLevel, catalog));
|
||||
} else if (isRomClassId(classId)) {
|
||||
builds.push(romBuild(classId, role, safeLevel));
|
||||
}
|
||||
}
|
||||
}
|
||||
return builds;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import { PLAYER_AGGRO_ID } from "../aggro";
|
||||
import { resourceProfileForClass, type ResourceType } from "../abilityCatalog";
|
||||
import type { CombatState } from "../combatStore";
|
||||
import { subscribeCombatEvents, type StructuredCombatEvent } from "../combatEvents";
|
||||
import type { PartyState } from "../partyStore";
|
||||
import type {
|
||||
BenchmarkAbilityMetrics,
|
||||
BenchmarkBuildDefinition,
|
||||
BenchmarkResourceMetrics,
|
||||
BenchmarkRunMetrics,
|
||||
BenchmarkRunReport,
|
||||
BenchmarkScenarioDefinition,
|
||||
} from "./benchmarkTypes";
|
||||
import { BENCHMARK_POLICY_ID } from "./benchmarkController";
|
||||
|
||||
interface MutableAbilityMetrics {
|
||||
castsStarted: number;
|
||||
castsCompleted: number;
|
||||
interrupts: number;
|
||||
rawDamage: number;
|
||||
effectiveDamage: number;
|
||||
effectiveHealing: number;
|
||||
overheal: number;
|
||||
absorbsApplied: number;
|
||||
}
|
||||
|
||||
interface MutableResourceMetrics {
|
||||
starting: number;
|
||||
ending: number;
|
||||
minimum: number;
|
||||
maximum: number;
|
||||
timeBelowTenPercentMs: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkRecorderSample {
|
||||
readonly now: number;
|
||||
readonly deltaMs: number;
|
||||
readonly combat: CombatState;
|
||||
readonly party: PartyState;
|
||||
readonly movedDistance: number;
|
||||
readonly moving: boolean;
|
||||
readonly stackCompliant: boolean | null;
|
||||
readonly heldMobs: number;
|
||||
readonly totalThreatMobs: number;
|
||||
}
|
||||
|
||||
const emptyAbilityMetrics = (): MutableAbilityMetrics => ({
|
||||
castsStarted: 0,
|
||||
castsCompleted: 0,
|
||||
interrupts: 0,
|
||||
rawDamage: 0,
|
||||
effectiveDamage: 0,
|
||||
effectiveHealing: 0,
|
||||
overheal: 0,
|
||||
absorbsApplied: 0,
|
||||
});
|
||||
|
||||
function percent(numerator: number, denominator: number): number {
|
||||
return denominator > 0 ? numerator / denominator * 100 : 0;
|
||||
}
|
||||
|
||||
/** Collects the uncapped event stream and fixed-step state samples for one run. */
|
||||
export class BenchmarkRecorder {
|
||||
readonly #build: BenchmarkBuildDefinition;
|
||||
readonly #scenario: BenchmarkScenarioDefinition;
|
||||
readonly #seed: number;
|
||||
readonly #startedAt: number;
|
||||
readonly #priorityTargetId: string;
|
||||
readonly #playerSources = new Set<string>([PLAYER_AGGRO_ID]);
|
||||
readonly #friendlyTargets = new Set<string>([PLAYER_AGGRO_ID]);
|
||||
readonly #eventCounts = new Map<string, number>();
|
||||
readonly #abilities = new Map<string, MutableAbilityMetrics>();
|
||||
readonly #resources = new Map<ResourceType, MutableResourceMetrics>();
|
||||
readonly #alive = new Map<string, boolean>();
|
||||
readonly #unsubscribe: () => void;
|
||||
|
||||
#rawDamage = 0;
|
||||
#effectiveDamage = 0;
|
||||
#priorityTargetDamage = 0;
|
||||
#effectiveHealing = 0;
|
||||
#rawHealing = 0;
|
||||
#overheal = 0;
|
||||
#absorbsApplied = 0;
|
||||
#absorbsConsumed = 0;
|
||||
#rawDamageTaken = 0;
|
||||
#effectiveDamageTaken = 0;
|
||||
#incomingFriendlyDamageEvents = 0;
|
||||
#incomingAttacks = 0;
|
||||
#avoidedAttacks = 0;
|
||||
#deaths = 0;
|
||||
#minimumPlayerHealthPercent = 100;
|
||||
#minimumPartyHealthPercent = 100;
|
||||
#castsStarted = 0;
|
||||
#castsCompleted = 0;
|
||||
#movementInterrupts = 0;
|
||||
#invalidCasts = 0;
|
||||
#distanceMoved = 0;
|
||||
#movementTimeMs = 0;
|
||||
#stackCompliantMs = 0;
|
||||
#stackObservedMs = 0;
|
||||
#threatHeldMobMs = 0;
|
||||
#threatObservedMobMs = 0;
|
||||
#timeToResourceFailureMs: number | null = null;
|
||||
#timeToFirstDeathMs: number | null = null;
|
||||
#closed = false;
|
||||
|
||||
constructor(
|
||||
build: BenchmarkBuildDefinition,
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
seed: number,
|
||||
startedAt: number,
|
||||
priorityTargetId: string,
|
||||
) {
|
||||
this.#build = build;
|
||||
this.#scenario = scenario;
|
||||
this.#seed = seed;
|
||||
this.#startedAt = startedAt;
|
||||
this.#priorityTargetId = priorityTargetId;
|
||||
this.#unsubscribe = subscribeCombatEvents((event) => this.#recordEvent(event));
|
||||
}
|
||||
|
||||
trackPlayerSummons(combat: CombatState): void {
|
||||
for (const summon of combat.summons) {
|
||||
if (summon.ownerActorId === PLAYER_AGGRO_ID) this.#playerSources.add(summon.id);
|
||||
}
|
||||
}
|
||||
|
||||
recordInvalidCast(): void {
|
||||
this.#invalidCasts += 1;
|
||||
}
|
||||
|
||||
markResourceFailure(now: number): void {
|
||||
if (this.#timeToResourceFailureMs === null) {
|
||||
this.#timeToResourceFailureMs = Math.max(0, now - this.#startedAt);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
this.#unsubscribe();
|
||||
}
|
||||
|
||||
sample(sample: BenchmarkRecorderSample): void {
|
||||
const { combat, party, deltaMs } = sample;
|
||||
this.captureGearStats(combat);
|
||||
this.trackPlayerSummons(combat);
|
||||
this.#distanceMoved += Math.max(0, sample.movedDistance);
|
||||
if (sample.moving) this.#movementTimeMs += deltaMs;
|
||||
if (sample.stackCompliant !== null) {
|
||||
this.#stackObservedMs += deltaMs;
|
||||
if (sample.stackCompliant) this.#stackCompliantMs += deltaMs;
|
||||
}
|
||||
if (sample.totalThreatMobs > 0) {
|
||||
this.#threatObservedMobMs += sample.totalThreatMobs * deltaMs;
|
||||
this.#threatHeldMobMs += sample.heldMobs * deltaMs;
|
||||
}
|
||||
|
||||
const playerHealthPercent = percent(combat.health, combat.maxHealth);
|
||||
this.#minimumPlayerHealthPercent = Math.min(this.#minimumPlayerHealthPercent, playerHealthPercent);
|
||||
const livingPartyHealth = party.members
|
||||
.filter((member) => member.health > 0)
|
||||
.map((member) => percent(member.health, member.maxHealth));
|
||||
if (livingPartyHealth.length) {
|
||||
this.#minimumPartyHealthPercent = Math.min(this.#minimumPartyHealthPercent, ...livingPartyHealth);
|
||||
} else if (party.members.length) {
|
||||
this.#minimumPartyHealthPercent = 0;
|
||||
}
|
||||
|
||||
this.#observeAlive(PLAYER_AGGRO_ID, combat.health > 0, sample.now);
|
||||
for (const member of party.members) {
|
||||
this.#friendlyTargets.add(member.id);
|
||||
this.#observeAlive(member.id, member.health > 0, sample.now);
|
||||
}
|
||||
|
||||
const activeResource = resourceProfileForClass(combat.classId).type;
|
||||
const resourceIds = new Set<ResourceType>([
|
||||
activeResource,
|
||||
...Object.keys(combat.maxResourcePools) as ResourceType[],
|
||||
]);
|
||||
for (const id of resourceIds) {
|
||||
const maximum = id === activeResource
|
||||
? combat.maxResource
|
||||
: combat.maxResourcePools[id] ?? 0;
|
||||
const current = id === activeResource
|
||||
? combat.resource
|
||||
: combat.resourcePools[id] ?? 0;
|
||||
const metrics = this.#resources.get(id) ?? {
|
||||
starting: current,
|
||||
ending: current,
|
||||
minimum: current,
|
||||
maximum,
|
||||
timeBelowTenPercentMs: 0,
|
||||
};
|
||||
metrics.ending = current;
|
||||
metrics.minimum = Math.min(metrics.minimum, current);
|
||||
metrics.maximum = Math.max(metrics.maximum, maximum);
|
||||
if (maximum > 0 && current / maximum < 0.1) metrics.timeBelowTenPercentMs += deltaMs;
|
||||
this.#resources.set(id, metrics);
|
||||
}
|
||||
}
|
||||
|
||||
finish(simulatedDurationMs: number, completed: boolean): BenchmarkRunReport {
|
||||
this.dispose();
|
||||
const seconds = Math.max(0.001, simulatedDurationMs / 1_000);
|
||||
const reasons: string[] = [];
|
||||
if (simulatedDurationMs <= 0) reasons.push("No combat time was simulated.");
|
||||
if (this.#castsCompleted <= 0 && this.#effectiveDamage <= 0) {
|
||||
reasons.push("The policy completed no abilities and dealt no damage.");
|
||||
}
|
||||
if (this.#scenario.roles.includes("damage") && this.#build.role === "damage" && this.#effectiveDamage <= 0) {
|
||||
reasons.push("The damage build produced no effective damage.");
|
||||
}
|
||||
if (this.#scenario.roles.includes("healer") && this.#build.role === "healer" && this.#incomingFriendlyDamageEvents <= 0 && this.#rawHealing <= 0) {
|
||||
reasons.push("The healing scenario produced neither incoming pressure nor healing.");
|
||||
}
|
||||
if (this.#scenario.roles.includes("tank") && this.#build.role === "tank" && this.#incomingAttacks <= 0) {
|
||||
reasons.push("The tanking scenario produced no incoming player attacks.");
|
||||
}
|
||||
|
||||
const metrics: BenchmarkRunMetrics = Object.freeze({
|
||||
rawDamage: this.#rawDamage,
|
||||
effectiveDamage: this.#effectiveDamage,
|
||||
priorityTargetDamage: this.#priorityTargetDamage,
|
||||
effectiveHealing: this.#effectiveHealing,
|
||||
rawHealing: this.#rawHealing,
|
||||
overheal: this.#overheal,
|
||||
absorbsApplied: this.#absorbsApplied,
|
||||
absorbsConsumed: this.#absorbsConsumed,
|
||||
rawDamageTaken: this.#rawDamageTaken,
|
||||
effectiveDamageTaken: this.#effectiveDamageTaken,
|
||||
incomingAttacks: this.#incomingAttacks,
|
||||
avoidedAttacks: this.#avoidedAttacks,
|
||||
deaths: this.#deaths,
|
||||
minimumPlayerHealthPercent: this.#minimumPlayerHealthPercent,
|
||||
minimumPartyHealthPercent: this.#minimumPartyHealthPercent,
|
||||
castsStarted: this.#castsStarted,
|
||||
castsCompleted: this.#castsCompleted,
|
||||
movementInterrupts: this.#movementInterrupts,
|
||||
invalidCasts: this.#invalidCasts,
|
||||
distanceMoved: this.#distanceMoved,
|
||||
movementTimeMs: this.#movementTimeMs,
|
||||
stackCompliancePercent: this.#stackObservedMs > 0
|
||||
? percent(this.#stackCompliantMs, this.#stackObservedMs)
|
||||
: null,
|
||||
threatControlPercent: this.#threatObservedMobMs > 0
|
||||
? percent(this.#threatHeldMobMs, this.#threatObservedMobMs)
|
||||
: null,
|
||||
timeToResourceFailureMs: this.#timeToResourceFailureMs,
|
||||
timeToFirstDeathMs: this.#timeToFirstDeathMs,
|
||||
dps: this.#effectiveDamage / seconds,
|
||||
hps: this.#effectiveHealing / seconds,
|
||||
dtps: this.#effectiveDamageTaken / seconds,
|
||||
overhealPercent: percent(this.#overheal, this.#rawHealing),
|
||||
mitigationPercent: percent(this.#rawDamageTaken - this.#effectiveDamageTaken, this.#rawDamageTaken),
|
||||
avoidancePercent: percent(this.#avoidedAttacks, this.#incomingAttacks),
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
runId: `${this.#build.id}__${this.#scenario.id}-v${this.#scenario.version}__seed-${this.#seed}`,
|
||||
policyId: BENCHMARK_POLICY_ID,
|
||||
seed: this.#seed,
|
||||
build: this.#build,
|
||||
scenario: this.#scenario,
|
||||
simulatedDurationMs,
|
||||
completed,
|
||||
metrics,
|
||||
resources: this.#resourceReport(),
|
||||
abilities: this.#abilityReport(),
|
||||
eventCounts: Object.freeze(Object.fromEntries([...this.#eventCounts].sort(([left], [right]) => left.localeCompare(right)))),
|
||||
validation: Object.freeze({ valid: reasons.length === 0, reasons: Object.freeze(reasons) }),
|
||||
gearStats: this.#lastGearStats,
|
||||
});
|
||||
}
|
||||
|
||||
#lastGearStats: BenchmarkRunReport["gearStats"] = {} as BenchmarkRunReport["gearStats"];
|
||||
|
||||
captureGearStats(combat: CombatState): void {
|
||||
this.#lastGearStats = combat.gearStats;
|
||||
}
|
||||
|
||||
#observeAlive(id: string, alive: boolean, now: number): void {
|
||||
const previous = this.#alive.get(id);
|
||||
this.#alive.set(id, alive);
|
||||
if (previous === undefined || !previous || alive) return;
|
||||
this.#deaths += 1;
|
||||
if (this.#timeToFirstDeathMs === null) this.#timeToFirstDeathMs = Math.max(0, now - this.#startedAt);
|
||||
}
|
||||
|
||||
#ability(id: string): MutableAbilityMetrics {
|
||||
const metrics = this.#abilities.get(id) ?? emptyAbilityMetrics();
|
||||
this.#abilities.set(id, metrics);
|
||||
return metrics;
|
||||
}
|
||||
|
||||
#recordEvent(event: StructuredCombatEvent): void {
|
||||
this.#eventCounts.set(event.kind, (this.#eventCounts.get(event.kind) ?? 0) + 1);
|
||||
const playerSource = this.#playerSources.has(event.sourceActorId)
|
||||
|| event.sourceActorId.startsWith("player-summon-")
|
||||
|| event.sourceActorId.startsWith("player-triggered-summon-")
|
||||
|| event.sourceActorId.startsWith("player-pet-")
|
||||
|| event.sourceActorId.startsWith("player-utility-")
|
||||
|| event.sourceActorId.startsWith(`${PLAYER_AGGRO_ID}:summon-`)
|
||||
|| event.sourceActorId.startsWith(`${PLAYER_AGGRO_ID}:totem-`);
|
||||
const ability = playerSource && event.abilityId ? this.#ability(event.abilityId) : null;
|
||||
if (playerSource && event.kind === "damage" && !this.#friendlyTargets.has(event.targetActorId)) {
|
||||
this.#rawDamage += event.rawAmount;
|
||||
this.#effectiveDamage += event.effectiveAmount;
|
||||
if (event.targetActorId === this.#priorityTargetId) this.#priorityTargetDamage += event.effectiveAmount;
|
||||
if (ability) {
|
||||
ability.rawDamage += event.rawAmount;
|
||||
ability.effectiveDamage += event.effectiveAmount;
|
||||
}
|
||||
}
|
||||
if (playerSource && event.kind === "healing") {
|
||||
this.#rawHealing += event.rawAmount;
|
||||
this.#effectiveHealing += event.effectiveAmount;
|
||||
this.#overheal += event.overheal;
|
||||
if (ability) {
|
||||
ability.effectiveHealing += event.effectiveAmount;
|
||||
ability.overheal += event.overheal;
|
||||
}
|
||||
}
|
||||
if (playerSource && event.kind === "absorb") {
|
||||
const amount = Math.max(event.effectiveAmount, event.absorbed);
|
||||
this.#absorbsApplied += amount;
|
||||
if (ability) ability.absorbsApplied += amount;
|
||||
}
|
||||
if (event.kind === "damage" && !playerSource && this.#friendlyTargets.has(event.targetActorId)) {
|
||||
this.#incomingFriendlyDamageEvents += 1;
|
||||
this.#absorbsConsumed += event.absorbed;
|
||||
if (event.targetActorId === PLAYER_AGGRO_ID) {
|
||||
this.#incomingAttacks += 1;
|
||||
if (["miss", "dodge", "parry", "resist"].includes(event.outcome ?? "")) this.#avoidedAttacks += 1;
|
||||
this.#rawDamageTaken += event.rawAmount;
|
||||
this.#effectiveDamageTaken += event.effectiveAmount;
|
||||
}
|
||||
}
|
||||
if (playerSource && event.kind === "cast" && ability) {
|
||||
if (event.outcome === "started") {
|
||||
ability.castsStarted += 1;
|
||||
this.#castsStarted += 1;
|
||||
} else if (event.outcome === "completed") {
|
||||
if (ability.castsStarted <= ability.castsCompleted) {
|
||||
ability.castsStarted += 1;
|
||||
this.#castsStarted += 1;
|
||||
}
|
||||
ability.castsCompleted += 1;
|
||||
this.#castsCompleted += 1;
|
||||
}
|
||||
}
|
||||
if (playerSource && event.kind === "interrupt") {
|
||||
if (ability) ability.interrupts += 1;
|
||||
if (event.outcome === "movement") this.#movementInterrupts += 1;
|
||||
}
|
||||
}
|
||||
|
||||
#resourceReport(): readonly BenchmarkResourceMetrics[] {
|
||||
return Object.freeze([...this.#resources]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([id, metrics]) => Object.freeze({ id, ...metrics })));
|
||||
}
|
||||
|
||||
#abilityReport(): readonly BenchmarkAbilityMetrics[] {
|
||||
return Object.freeze([...this.#abilities]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([abilityId, metrics]) => Object.freeze({ abilityId, ...metrics })));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EMPTY_ITEM_STATS } from "../itemStats";
|
||||
import { benchmarkScenarioById } from "./benchmarkScenarios";
|
||||
import {
|
||||
aggregateBenchmarkMatrix,
|
||||
benchmarkQuantile,
|
||||
renderBenchmarkMatrixMarkdown,
|
||||
serializeBenchmarkMatrixCsv,
|
||||
} from "./benchmarkReport";
|
||||
import type {
|
||||
BenchmarkBuildDefinition,
|
||||
BenchmarkMatrixReport,
|
||||
BenchmarkRunMetrics,
|
||||
BenchmarkRunReport,
|
||||
} from "./benchmarkTypes";
|
||||
|
||||
const scenario = benchmarkScenarioById("damage-single-burst")!;
|
||||
|
||||
const build: BenchmarkBuildDefinition = {
|
||||
id: "mage-burst",
|
||||
source: "wow",
|
||||
classId: "mage",
|
||||
secondaryClassId: null,
|
||||
role: "damage",
|
||||
level: 60,
|
||||
orientation: 'spell, "burst"',
|
||||
talentRanks: {},
|
||||
inventory: [],
|
||||
equipment: {},
|
||||
gearPresetId: "level-60-baseline",
|
||||
};
|
||||
|
||||
const defaultMetrics: BenchmarkRunMetrics = {
|
||||
rawDamage: 0,
|
||||
effectiveDamage: 0,
|
||||
priorityTargetDamage: 0,
|
||||
effectiveHealing: 0,
|
||||
rawHealing: 0,
|
||||
overheal: 0,
|
||||
absorbsApplied: 0,
|
||||
absorbsConsumed: 0,
|
||||
rawDamageTaken: 0,
|
||||
effectiveDamageTaken: 0,
|
||||
incomingAttacks: 0,
|
||||
avoidedAttacks: 0,
|
||||
deaths: 0,
|
||||
minimumPlayerHealthPercent: 100,
|
||||
minimumPartyHealthPercent: 100,
|
||||
castsStarted: 0,
|
||||
castsCompleted: 0,
|
||||
movementInterrupts: 0,
|
||||
invalidCasts: 0,
|
||||
distanceMoved: 0,
|
||||
movementTimeMs: 0,
|
||||
stackCompliancePercent: null,
|
||||
threatControlPercent: null,
|
||||
timeToResourceFailureMs: null,
|
||||
timeToFirstDeathMs: null,
|
||||
dps: 0,
|
||||
hps: 0,
|
||||
dtps: 0,
|
||||
overhealPercent: 0,
|
||||
mitigationPercent: 0,
|
||||
avoidancePercent: 0,
|
||||
};
|
||||
|
||||
function run(
|
||||
seed: number,
|
||||
metrics: Partial<BenchmarkRunMetrics>,
|
||||
valid = true,
|
||||
): BenchmarkRunReport {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
runId: `mage-burst:${seed}`,
|
||||
policyId: "baseline-v1",
|
||||
seed,
|
||||
build,
|
||||
scenario,
|
||||
simulatedDurationMs: scenario.durationMs,
|
||||
completed: seed !== 2,
|
||||
metrics: { ...defaultMetrics, ...metrics },
|
||||
resources: [],
|
||||
abilities: [],
|
||||
eventCounts: {},
|
||||
validation: { valid, reasons: valid ? [] : ["fixture invalid"] },
|
||||
gearStats: EMPTY_ITEM_STATS,
|
||||
};
|
||||
}
|
||||
|
||||
function matrix(): BenchmarkMatrixReport {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generatedAt: "2026-08-28T12:00:00.000Z",
|
||||
appVersion: "test",
|
||||
cohort: {
|
||||
level: 60,
|
||||
gearPresetId: "level-60-baseline",
|
||||
seeds: [1, 2, 3, 4],
|
||||
durationScale: 1,
|
||||
policyId: "baseline-v1",
|
||||
},
|
||||
scenarios: [scenario],
|
||||
builds: [build],
|
||||
runs: [
|
||||
run(1, { dps: 10, priorityTargetDamage: 100 }),
|
||||
run(2, { dps: 20, priorityTargetDamage: 200 }),
|
||||
run(3, { dps: 30, priorityTargetDamage: 300 }),
|
||||
run(4, { dps: 999, priorityTargetDamage: 9_999 }, false),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("benchmark reporting", () => {
|
||||
it("uses deterministic interpolated quantiles", () => {
|
||||
expect(benchmarkQuantile([30, null, 10, 20], 0.1)).toBe(12);
|
||||
expect(benchmarkQuantile([30, null, 10, 20], 0.5)).toBe(20);
|
||||
expect(benchmarkQuantile([30, null, 10, 20], 0.9)).toBe(28);
|
||||
expect(benchmarkQuantile([null], 0.5)).toBeNull();
|
||||
expect(() => benchmarkQuantile([1], 1.1)).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it("aggregates validated runs and retains null-event counts", () => {
|
||||
const summary = aggregateBenchmarkMatrix(matrix());
|
||||
const [row] = summary.rows;
|
||||
|
||||
expect(summary).toMatchObject({ runCount: 4, validRunCount: 3, invalidRunCount: 1 });
|
||||
expect(row).toMatchObject({
|
||||
runCount: 4,
|
||||
validRunCount: 3,
|
||||
invalidRunCount: 1,
|
||||
completedRunCount: 3,
|
||||
});
|
||||
expect(row.metrics.dps).toEqual({
|
||||
count: 3,
|
||||
nullCount: 0,
|
||||
p10: 12,
|
||||
median: 20,
|
||||
p90: 28,
|
||||
});
|
||||
expect(row.metrics.timeToFirstDeathMs).toEqual({
|
||||
count: 0,
|
||||
nullCount: 3,
|
||||
p10: null,
|
||||
median: null,
|
||||
p90: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes complete metric rows as newline-clean CSV", () => {
|
||||
const csv = serializeBenchmarkMatrixCsv(matrix());
|
||||
|
||||
expect(csv).toContain("scenario_id,scenario_version,scenario_name");
|
||||
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);
|
||||
expect(csv.endsWith("\n\n")).toBe(false);
|
||||
expect(csv).not.toContain("\r");
|
||||
});
|
||||
|
||||
it("renders separate role metrics instead of a composite ranking", () => {
|
||||
const markdown = renderBenchmarkMatrixMarkdown(matrix());
|
||||
|
||||
expect(markdown).toContain("## Damage");
|
||||
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.endsWith("\n")).toBe(true);
|
||||
expect(markdown.endsWith("\n\n")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,473 @@
|
||||
import type { PartyRole } from "../partyRoles";
|
||||
import type {
|
||||
BenchmarkMatrixReport,
|
||||
BenchmarkRunMetrics,
|
||||
BenchmarkRunReport,
|
||||
BenchmarkScenarioFamily,
|
||||
} from "./benchmarkTypes";
|
||||
|
||||
export const BENCHMARK_METRIC_KEYS = Object.freeze([
|
||||
"rawDamage",
|
||||
"effectiveDamage",
|
||||
"priorityTargetDamage",
|
||||
"effectiveHealing",
|
||||
"rawHealing",
|
||||
"overheal",
|
||||
"absorbsApplied",
|
||||
"absorbsConsumed",
|
||||
"rawDamageTaken",
|
||||
"effectiveDamageTaken",
|
||||
"incomingAttacks",
|
||||
"avoidedAttacks",
|
||||
"deaths",
|
||||
"minimumPlayerHealthPercent",
|
||||
"minimumPartyHealthPercent",
|
||||
"castsStarted",
|
||||
"castsCompleted",
|
||||
"movementInterrupts",
|
||||
"invalidCasts",
|
||||
"distanceMoved",
|
||||
"movementTimeMs",
|
||||
"stackCompliancePercent",
|
||||
"threatControlPercent",
|
||||
"timeToResourceFailureMs",
|
||||
"timeToFirstDeathMs",
|
||||
"dps",
|
||||
"hps",
|
||||
"dtps",
|
||||
"overhealPercent",
|
||||
"mitigationPercent",
|
||||
"avoidancePercent",
|
||||
] as const satisfies readonly (keyof BenchmarkRunMetrics)[]);
|
||||
|
||||
export type BenchmarkMetricKey = (typeof BENCHMARK_METRIC_KEYS)[number];
|
||||
|
||||
export interface BenchmarkDistribution {
|
||||
/** Number of validated runs with a finite observation for this metric. */
|
||||
readonly count: number;
|
||||
/** Validated runs where the metric was null or non-finite. */
|
||||
readonly nullCount: number;
|
||||
readonly median: number | null;
|
||||
readonly p10: number | null;
|
||||
readonly p90: number | null;
|
||||
}
|
||||
|
||||
export interface BenchmarkMatrixSummaryRow {
|
||||
readonly scenarioId: string;
|
||||
readonly scenarioVersion: number;
|
||||
readonly scenarioName: string;
|
||||
readonly scenarioFamily: BenchmarkScenarioFamily;
|
||||
readonly buildId: string;
|
||||
readonly source: BenchmarkRunReport["build"]["source"];
|
||||
readonly classId: BenchmarkRunReport["build"]["classId"];
|
||||
readonly secondaryClassId: BenchmarkRunReport["build"]["secondaryClassId"];
|
||||
readonly role: PartyRole;
|
||||
readonly level: number;
|
||||
readonly orientation: string;
|
||||
readonly gearPresetId: string;
|
||||
readonly runCount: number;
|
||||
readonly validRunCount: number;
|
||||
readonly invalidRunCount: number;
|
||||
readonly completedRunCount: number;
|
||||
readonly metrics: Readonly<Record<BenchmarkMetricKey, BenchmarkDistribution>>;
|
||||
}
|
||||
|
||||
export interface BenchmarkMatrixSummary {
|
||||
readonly schemaVersion: 1;
|
||||
readonly generatedAt: string;
|
||||
readonly appVersion: string;
|
||||
readonly cohort: BenchmarkMatrixReport["cohort"];
|
||||
readonly runCount: number;
|
||||
readonly validRunCount: number;
|
||||
readonly invalidRunCount: number;
|
||||
readonly rows: readonly BenchmarkMatrixSummaryRow[];
|
||||
}
|
||||
|
||||
export const ROLE_BENCHMARK_METRICS = Object.freeze({
|
||||
damage: Object.freeze([
|
||||
"dps",
|
||||
"priorityTargetDamage",
|
||||
"deaths",
|
||||
"timeToFirstDeathMs",
|
||||
]),
|
||||
tank: Object.freeze([
|
||||
"dtps",
|
||||
"mitigationPercent",
|
||||
"avoidancePercent",
|
||||
"threatControlPercent",
|
||||
"minimumPlayerHealthPercent",
|
||||
"deaths",
|
||||
"timeToFirstDeathMs",
|
||||
"dps",
|
||||
]),
|
||||
healer: Object.freeze([
|
||||
"hps",
|
||||
"absorbsConsumed",
|
||||
"overhealPercent",
|
||||
"minimumPartyHealthPercent",
|
||||
"deaths",
|
||||
"timeToResourceFailureMs",
|
||||
]),
|
||||
} as const satisfies Readonly<Record<PartyRole, readonly BenchmarkMetricKey[]>>);
|
||||
|
||||
const MECHANIC_METRICS = Object.freeze([
|
||||
"movementInterrupts",
|
||||
"stackCompliancePercent",
|
||||
] as const satisfies readonly BenchmarkMetricKey[]);
|
||||
|
||||
const ROLE_ORDER: Readonly<Record<PartyRole, number>> = Object.freeze({
|
||||
tank: 0,
|
||||
healer: 1,
|
||||
damage: 2,
|
||||
});
|
||||
|
||||
const ROLE_LABELS: Readonly<Record<PartyRole, string>> = Object.freeze({
|
||||
tank: "Tank",
|
||||
healer: "Healer",
|
||||
damage: "Damage",
|
||||
});
|
||||
|
||||
const METRIC_LABELS: Readonly<Record<BenchmarkMetricKey, string>> = Object.freeze({
|
||||
rawDamage: "Raw damage",
|
||||
effectiveDamage: "Effective damage",
|
||||
priorityTargetDamage: "Priority damage",
|
||||
effectiveHealing: "Effective healing",
|
||||
rawHealing: "Raw healing",
|
||||
overheal: "Overheal",
|
||||
absorbsApplied: "Absorbs applied",
|
||||
absorbsConsumed: "Absorbs consumed",
|
||||
rawDamageTaken: "Raw damage taken",
|
||||
effectiveDamageTaken: "Effective damage taken",
|
||||
incomingAttacks: "Incoming attacks",
|
||||
avoidedAttacks: "Avoided attacks",
|
||||
deaths: "Deaths",
|
||||
minimumPlayerHealthPercent: "Minimum player health",
|
||||
minimumPartyHealthPercent: "Minimum party health",
|
||||
castsStarted: "Casts started",
|
||||
castsCompleted: "Casts completed",
|
||||
movementInterrupts: "Movement interrupts",
|
||||
invalidCasts: "Invalid casts",
|
||||
distanceMoved: "Distance moved",
|
||||
movementTimeMs: "Movement time",
|
||||
stackCompliancePercent: "Stack compliance",
|
||||
threatControlPercent: "Threat control",
|
||||
timeToResourceFailureMs: "Resource failure",
|
||||
timeToFirstDeathMs: "First death",
|
||||
dps: "DPS",
|
||||
hps: "HPS",
|
||||
dtps: "DTPS",
|
||||
overhealPercent: "Overheal",
|
||||
mitigationPercent: "Mitigation",
|
||||
avoidancePercent: "Avoidance",
|
||||
});
|
||||
|
||||
type BenchmarkReportInput = BenchmarkMatrixReport | BenchmarkMatrixSummary;
|
||||
|
||||
interface MutableAggregationGroup {
|
||||
readonly representative: BenchmarkRunReport;
|
||||
readonly runs: BenchmarkRunReport[];
|
||||
}
|
||||
|
||||
function finiteValues(values: readonly (number | null)[]): number[] {
|
||||
return values.filter((value): value is number => value !== null && Number.isFinite(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a linearly interpolated quantile after dropping null and non-finite values.
|
||||
*/
|
||||
export function benchmarkQuantile(
|
||||
values: readonly (number | null)[],
|
||||
quantile: number,
|
||||
): number | null {
|
||||
if (quantile < 0 || quantile > 1 || !Number.isFinite(quantile)) {
|
||||
throw new RangeError(`Benchmark quantile must be between 0 and 1; received ${quantile}.`);
|
||||
}
|
||||
|
||||
const sorted = finiteValues(values).sort((left, right) => left - right);
|
||||
if (sorted.length === 0) return null;
|
||||
if (sorted.length === 1) return sorted[0];
|
||||
|
||||
const rank = (sorted.length - 1) * quantile;
|
||||
const lowerIndex = Math.floor(rank);
|
||||
const upperIndex = Math.ceil(rank);
|
||||
const lower = sorted[lowerIndex];
|
||||
const upper = sorted[upperIndex];
|
||||
if (lowerIndex === upperIndex) return lower;
|
||||
return lower + ((upper - lower) * (rank - lowerIndex));
|
||||
}
|
||||
|
||||
function summarizeMetric(
|
||||
runs: readonly BenchmarkRunReport[],
|
||||
metric: BenchmarkMetricKey,
|
||||
): BenchmarkDistribution {
|
||||
const values = runs.map((run) => run.metrics[metric]);
|
||||
const observed = finiteValues(values);
|
||||
return {
|
||||
count: observed.length,
|
||||
nullCount: values.length - observed.length,
|
||||
median: benchmarkQuantile(observed, 0.5),
|
||||
p10: benchmarkQuantile(observed, 0.1),
|
||||
p90: benchmarkQuantile(observed, 0.9),
|
||||
};
|
||||
}
|
||||
|
||||
function aggregationKey(run: BenchmarkRunReport): string {
|
||||
return JSON.stringify([
|
||||
run.scenario.id,
|
||||
run.scenario.version,
|
||||
run.build.id,
|
||||
run.build.source,
|
||||
run.build.classId,
|
||||
run.build.secondaryClassId ?? null,
|
||||
run.build.role,
|
||||
run.build.level,
|
||||
run.build.orientation,
|
||||
run.build.gearPresetId,
|
||||
]);
|
||||
}
|
||||
|
||||
function compareSummaryRows(
|
||||
left: BenchmarkMatrixSummaryRow,
|
||||
right: BenchmarkMatrixSummaryRow,
|
||||
): number {
|
||||
return left.scenarioId.localeCompare(right.scenarioId)
|
||||
|| left.scenarioVersion - right.scenarioVersion
|
||||
|| ROLE_ORDER[left.role] - ROLE_ORDER[right.role]
|
||||
|| left.source.localeCompare(right.source)
|
||||
|| left.classId.localeCompare(right.classId)
|
||||
|| (left.secondaryClassId ?? "").localeCompare(right.secondaryClassId ?? "")
|
||||
|| left.buildId.localeCompare(right.buildId);
|
||||
}
|
||||
|
||||
export function aggregateBenchmarkMatrix(report: BenchmarkMatrixReport): BenchmarkMatrixSummary {
|
||||
const groups = new Map<string, MutableAggregationGroup>();
|
||||
|
||||
for (const run of report.runs) {
|
||||
const key = aggregationKey(run);
|
||||
const existing = groups.get(key);
|
||||
if (existing) {
|
||||
existing.runs.push(run);
|
||||
} else {
|
||||
groups.set(key, { representative: run, runs: [run] });
|
||||
}
|
||||
}
|
||||
|
||||
const rows = [...groups.values()].map(({ representative, runs }) => {
|
||||
const validRuns = runs.filter((run) => run.validation.valid);
|
||||
const metrics = Object.fromEntries(BENCHMARK_METRIC_KEYS.map((metric) => (
|
||||
[metric, summarizeMetric(validRuns, metric)]
|
||||
))) as unknown as Readonly<Record<BenchmarkMetricKey, BenchmarkDistribution>>;
|
||||
|
||||
return {
|
||||
scenarioId: representative.scenario.id,
|
||||
scenarioVersion: representative.scenario.version,
|
||||
scenarioName: representative.scenario.name,
|
||||
scenarioFamily: representative.scenario.family,
|
||||
buildId: representative.build.id,
|
||||
source: representative.build.source,
|
||||
classId: representative.build.classId,
|
||||
secondaryClassId: representative.build.secondaryClassId ?? null,
|
||||
role: representative.build.role,
|
||||
level: representative.build.level,
|
||||
orientation: representative.build.orientation,
|
||||
gearPresetId: representative.build.gearPresetId,
|
||||
runCount: runs.length,
|
||||
validRunCount: validRuns.length,
|
||||
invalidRunCount: runs.length - validRuns.length,
|
||||
completedRunCount: runs.filter((run) => run.completed).length,
|
||||
metrics,
|
||||
} satisfies BenchmarkMatrixSummaryRow;
|
||||
}).sort(compareSummaryRows);
|
||||
|
||||
const validRunCount = report.runs.filter((run) => run.validation.valid).length;
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generatedAt: report.generatedAt,
|
||||
appVersion: report.appVersion,
|
||||
cohort: report.cohort,
|
||||
runCount: report.runs.length,
|
||||
validRunCount,
|
||||
invalidRunCount: report.runs.length - validRunCount,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
function asSummary(input: BenchmarkReportInput): BenchmarkMatrixSummary {
|
||||
return "rows" in input ? input : aggregateBenchmarkMatrix(input);
|
||||
}
|
||||
|
||||
function csvCell(value: string | number | null): string {
|
||||
if (value === null) return "";
|
||||
const text = String(value);
|
||||
return /[",\r\n]/u.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
||||
}
|
||||
|
||||
function csvLine(values: readonly (string | number | null)[]): string {
|
||||
return values.map(csvCell).join(",");
|
||||
}
|
||||
|
||||
/** Produces one CSV row per scenario/build/metric, retaining the complete metric vector. */
|
||||
export function serializeBenchmarkMatrixCsv(input: BenchmarkReportInput): string {
|
||||
const summary = asSummary(input);
|
||||
const lines = [csvLine([
|
||||
"schema_version",
|
||||
"generated_at",
|
||||
"app_version",
|
||||
"cohort_level",
|
||||
"cohort_gear_preset_id",
|
||||
"seeds",
|
||||
"duration_scale",
|
||||
"policy_id",
|
||||
"scenario_id",
|
||||
"scenario_version",
|
||||
"scenario_name",
|
||||
"scenario_family",
|
||||
"build_id",
|
||||
"source",
|
||||
"class_id",
|
||||
"secondary_class_id",
|
||||
"role",
|
||||
"build_level",
|
||||
"orientation",
|
||||
"gear_preset_id",
|
||||
"run_count",
|
||||
"valid_run_count",
|
||||
"invalid_run_count",
|
||||
"completed_run_count",
|
||||
"metric",
|
||||
"observed_count",
|
||||
"null_count",
|
||||
"p10",
|
||||
"median",
|
||||
"p90",
|
||||
])];
|
||||
|
||||
for (const row of summary.rows) {
|
||||
for (const metric of BENCHMARK_METRIC_KEYS) {
|
||||
const distribution = row.metrics[metric];
|
||||
lines.push(csvLine([
|
||||
summary.schemaVersion,
|
||||
summary.generatedAt,
|
||||
summary.appVersion,
|
||||
summary.cohort.level,
|
||||
summary.cohort.gearPresetId,
|
||||
summary.cohort.seeds.join(";"),
|
||||
summary.cohort.durationScale,
|
||||
summary.cohort.policyId,
|
||||
row.scenarioId,
|
||||
row.scenarioVersion,
|
||||
row.scenarioName,
|
||||
row.scenarioFamily,
|
||||
row.buildId,
|
||||
row.source,
|
||||
row.classId,
|
||||
row.secondaryClassId ?? null,
|
||||
row.role,
|
||||
row.level,
|
||||
row.orientation,
|
||||
row.gearPresetId,
|
||||
row.runCount,
|
||||
row.validRunCount,
|
||||
row.invalidRunCount,
|
||||
row.completedRunCount,
|
||||
metric,
|
||||
distribution.count,
|
||||
distribution.nullCount,
|
||||
distribution.p10,
|
||||
distribution.median,
|
||||
distribution.p90,
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function markdownCell(value: string): string {
|
||||
return value.replaceAll("|", "\\|").replace(/[\r\n]+/gu, " ");
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
if (Number.isInteger(value)) return value.toLocaleString("en-US");
|
||||
return value.toLocaleString("en-US", { maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function formatMetricValue(metric: BenchmarkMetricKey, value: number): string {
|
||||
if (metric.endsWith("Percent")) return `${formatNumber(value)}%`;
|
||||
if (metric.endsWith("Ms")) return `${formatNumber(value / 1_000)}s`;
|
||||
return formatNumber(value);
|
||||
}
|
||||
|
||||
function formatDistribution(metric: BenchmarkMetricKey, value: BenchmarkDistribution): string {
|
||||
if (value.median === null || value.p10 === null || value.p90 === null) {
|
||||
return value.nullCount > 0 ? `not observed (0/${value.nullCount})` : "no valid runs";
|
||||
}
|
||||
|
||||
const range = `${formatMetricValue(metric, value.p10)}–${formatMetricValue(metric, value.p90)}`;
|
||||
const observation = value.nullCount > 0
|
||||
? `; observed ${value.count}/${value.count + value.nullCount}`
|
||||
: "";
|
||||
return `${formatMetricValue(metric, value.median)} [${range}]${observation}`;
|
||||
}
|
||||
|
||||
function roleMetricsFor(row: BenchmarkMatrixSummaryRow): readonly BenchmarkMetricKey[] {
|
||||
if (row.scenarioFamily !== "mechanics") return ROLE_BENCHMARK_METRICS[row.role];
|
||||
return [...ROLE_BENCHMARK_METRICS[row.role], ...MECHANIC_METRICS];
|
||||
}
|
||||
|
||||
function formatRoleVector(row: BenchmarkMatrixSummaryRow): string {
|
||||
return roleMetricsFor(row).map((metric) => (
|
||||
`${METRIC_LABELS[metric]}: ${formatDistribution(metric, row.metrics[metric])}`
|
||||
)).join("; ");
|
||||
}
|
||||
|
||||
/** Renders a compact, role-separated view without collapsing metrics into a score. */
|
||||
export function renderBenchmarkMatrixMarkdown(input: BenchmarkReportInput): string {
|
||||
const summary = asSummary(input);
|
||||
const lines = [
|
||||
"# Balance benchmark summary",
|
||||
"",
|
||||
`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}\`.`,
|
||||
"",
|
||||
`${summary.validRunCount}/${summary.runCount} runs were valid; `
|
||||
+ `${summary.invalidRunCount} invalid run(s) are excluded from distributions. `
|
||||
+ "Values are median [p10–p90].",
|
||||
];
|
||||
|
||||
for (const role of ["tank", "healer", "damage"] as const) {
|
||||
const roleRows = summary.rows.filter((row) => row.role === role);
|
||||
if (roleRows.length === 0) continue;
|
||||
|
||||
lines.push(
|
||||
"",
|
||||
`## ${ROLE_LABELS[role]}`,
|
||||
"",
|
||||
"| Scenario | Build | Runs | Role vector |",
|
||||
"| --- | --- | ---: | --- |",
|
||||
);
|
||||
for (const row of roleRows) {
|
||||
const scenario = `${row.scenarioName} (v${row.scenarioVersion})`;
|
||||
const secondary = row.secondaryClassId ? ` / ${row.secondaryClassId}` : "";
|
||||
const build = `${row.buildId} — ${row.source}:${row.classId}${secondary}`;
|
||||
const runs = `${row.validRunCount}/${row.runCount}`;
|
||||
lines.push(
|
||||
`| ${markdownCell(scenario)} | ${markdownCell(build)} | ${runs} | `
|
||||
+ `${markdownCell(formatRoleVector(row))} |`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (summary.rows.length === 0) {
|
||||
lines.push("", "No benchmark runs were supplied.");
|
||||
}
|
||||
|
||||
lines.push(
|
||||
"",
|
||||
"Compare builds only within the same scenario version and cohort. "
|
||||
+ "Role metrics remain separate; this report does not calculate a composite score.",
|
||||
);
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loadCanonicalBenchmarkBuilds } from "./benchmarkBuilds";
|
||||
import { runCombatBenchmark } from "./benchmarkRunner";
|
||||
import { benchmarkScenarioById } from "./benchmarkScenarios";
|
||||
|
||||
describe("combat benchmark runner", () => {
|
||||
it("replays the authoritative player path deterministically", async () => {
|
||||
const [build] = await loadCanonicalBenchmarkBuilds(60, ["mage"]);
|
||||
const scenario = benchmarkScenarioById("damage-single-burst")!;
|
||||
const options = { seed: 42, durationScale: 0.12 } as const;
|
||||
|
||||
const first = await runCombatBenchmark(build, scenario, options);
|
||||
const second = await runCombatBenchmark(build, scenario, options);
|
||||
|
||||
expect(second).toEqual(first);
|
||||
expect(first.validation).toEqual({ valid: true, reasons: [] });
|
||||
expect(first.metrics.effectiveDamage).toBeGreaterThan(0);
|
||||
expect(first.metrics.priorityTargetDamage).toBe(first.metrics.effectiveDamage);
|
||||
expect(first.metrics.castsCompleted).toBeGreaterThan(0);
|
||||
expect(first.eventCounts.damage).toBeGreaterThan(0);
|
||||
}, 60_000);
|
||||
|
||||
it("treats avoided reference attacks as valid tank pressure", async () => {
|
||||
const [build] = await loadCanonicalBenchmarkBuilds(60, ["guardian"]);
|
||||
const scenario = benchmarkScenarioById("tank-mixed-boss")!;
|
||||
|
||||
const report = await runCombatBenchmark(build, scenario, { seed: 1, durationScale: 0.1 });
|
||||
|
||||
expect(report.validation).toEqual({ valid: true, reasons: [] });
|
||||
expect(report.metrics.incomingAttacks).toBeGreaterThan(0);
|
||||
expect(report.metrics.avoidedAttacks).toBeGreaterThan(0);
|
||||
expect(report.metrics.avoidancePercent).toBeGreaterThan(0);
|
||||
}, 60_000);
|
||||
|
||||
it("keeps benchmark bosses attacking through player hard control", async () => {
|
||||
const [build] = await loadCanonicalBenchmarkBuilds(60, ["starcaller"]);
|
||||
const scenario = benchmarkScenarioById("tank-mixed-boss")!;
|
||||
|
||||
const report = await runCombatBenchmark(build, scenario, { seed: 1, durationScale: 0.1 });
|
||||
|
||||
expect(report.validation).toEqual({ valid: true, reasons: [] });
|
||||
expect(report.metrics.incomingAttacks).toBeGreaterThan(0);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,435 @@
|
||||
import { preloadAbilityCatalogsForClasses, type MobStatusKind } from "../abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID, type ThreatSource } from "../aggro";
|
||||
import type { AzerothCoreMechanic } from "../azerothCoreReference";
|
||||
import { installCombatClock } from "../combatClock";
|
||||
import { useCombatStore } from "../combatStore";
|
||||
import { resetCombatSpatialProvider } from "../combatSpatial";
|
||||
import type { EnemyAttackDefinition } from "../enemyAttacks";
|
||||
import { clearMobRuntimeRegistry, getMobPosition, setMobPosition } from "../mobRuntimeRegistry";
|
||||
import {
|
||||
clearPartyRuntimeRegistry,
|
||||
getPartyRuntimePosition,
|
||||
registerPartyRuntimePosition,
|
||||
} from "../partyRuntimeRegistry";
|
||||
import { resetPartyRuntimeClock } from "../partyRuntime";
|
||||
import { usePartyStore } from "../partyStore";
|
||||
import type { PartyRole } from "../partyRoles";
|
||||
import type { CombatPosition } from "../mobRuntimeRegistry";
|
||||
import { BenchmarkController } from "./benchmarkController";
|
||||
import { BenchmarkRecorder } from "./benchmarkRecorder";
|
||||
import type {
|
||||
BenchmarkBuildDefinition,
|
||||
BenchmarkRunOptions,
|
||||
BenchmarkRunReport,
|
||||
BenchmarkScenarioDefinition,
|
||||
} from "./benchmarkTypes";
|
||||
|
||||
const BENCHMARK_CLOCK_ORIGIN = 1_000_000;
|
||||
const BENCHMARK_MOB_HEALTH = 1_000_000_000;
|
||||
const BENCHMARK_BOSS_CONTROL_IMMUNITIES: readonly AzerothCoreMechanic[] = Object.freeze([
|
||||
"charm",
|
||||
"disarm",
|
||||
"disorient",
|
||||
"fear",
|
||||
"freeze",
|
||||
"grip",
|
||||
"horror",
|
||||
"knockout",
|
||||
"pacify",
|
||||
"polymorph",
|
||||
"root",
|
||||
"sap",
|
||||
"shackle",
|
||||
"silence",
|
||||
"sleep",
|
||||
"snare",
|
||||
"stun",
|
||||
"turn",
|
||||
]);
|
||||
const BENCHMARK_CONTROL_STATUS_KINDS: ReadonlySet<MobStatusKind> = new Set([
|
||||
"blind",
|
||||
"charm",
|
||||
"confuse",
|
||||
"disarm",
|
||||
"fear",
|
||||
"freeze",
|
||||
"knockdown",
|
||||
"pacify",
|
||||
"silence",
|
||||
"sleep",
|
||||
"stun",
|
||||
]);
|
||||
|
||||
function scaledDuration(scenario: BenchmarkScenarioDefinition, scale: number): number {
|
||||
const requested = scenario.durationMs * Math.max(0.001, scale);
|
||||
return Math.max(scenario.tickMs, Math.round(requested / scenario.tickMs) * scenario.tickMs);
|
||||
}
|
||||
|
||||
function partyPositions(layout: BenchmarkScenarioDefinition["partyLayout"], count: number): readonly CombatPosition[] {
|
||||
if (layout === "spread") {
|
||||
const spread: readonly CombatPosition[] = [
|
||||
[0, 0, 1],
|
||||
[-12, 0, -3],
|
||||
[12, 0, -3],
|
||||
[0, 0, -12],
|
||||
];
|
||||
return spread.slice(0, count);
|
||||
}
|
||||
if (layout === "stacked") {
|
||||
const stacked: readonly CombatPosition[] = [
|
||||
[-1, 0, 0.5],
|
||||
[1, 0, 0.5],
|
||||
[-0.5, 0, -1],
|
||||
[0.5, 0, -1],
|
||||
];
|
||||
return stacked.slice(0, count);
|
||||
}
|
||||
const melee: readonly CombatPosition[] = [
|
||||
[-1.5, 0, 1],
|
||||
[1.5, 0, 1],
|
||||
[-2.5, 0, -1],
|
||||
[2.5, 0, -1],
|
||||
];
|
||||
return melee.slice(0, count);
|
||||
}
|
||||
|
||||
function mobPosition(index: number, count: number, ringRadius = 2): CombatPosition {
|
||||
if (index === 0 || count <= 1) return [0, 0, 2.5];
|
||||
const angle = (index - 1) / Math.max(1, count - 1) * Math.PI * 2;
|
||||
return [Math.sin(angle) * ringRadius, 0, 2.5 + Math.cos(angle) * ringRadius];
|
||||
}
|
||||
|
||||
function benchmarkMobRingRadius(scenario: BenchmarkScenarioDefinition): number {
|
||||
return scenario.id === "damage-aoe-eight" ? 6 : 2;
|
||||
}
|
||||
|
||||
/** Keep scenario geometry fixed so pulls and knockbacks cannot change the pressure schedule. */
|
||||
function restoreBenchmarkMobPositions(
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
mobIds: readonly string[],
|
||||
): void {
|
||||
const ringRadius = benchmarkMobRingRadius(scenario);
|
||||
mobIds.forEach((id, index) => setMobPosition(
|
||||
id,
|
||||
mobPosition(index, mobIds.length, ringRadius),
|
||||
));
|
||||
}
|
||||
|
||||
function referenceAttackDamage(level: number): number {
|
||||
return 12 + level * 2;
|
||||
}
|
||||
|
||||
function enemyAttacks(
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
level: number,
|
||||
referenceHealth: number,
|
||||
): readonly EnemyAttackDefinition[] {
|
||||
const baseDamage = referenceAttackDamage(level);
|
||||
return scenario.attacks.map((profile) => ({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
delivery: profile.target === "primary" && profile.school === "physical" ? "melee" : "area",
|
||||
target: profile.target,
|
||||
school: profile.school,
|
||||
animation: profile.school === "physical" ? "attack" : "cast",
|
||||
range: profile.target === "primary" ? 5.5 : 45,
|
||||
cooldownMs: profile.intervalMs,
|
||||
initialCooldownMs: [profile.intervalMs, profile.intervalMs],
|
||||
repeatCooldownMs: [profile.intervalMs, profile.intervalMs],
|
||||
damageMultiplier: profile.damageMultiplier * referenceHealth / Math.max(1, baseDamage),
|
||||
...(profile.radius === undefined ? {} : { radius: profile.radius }),
|
||||
}));
|
||||
}
|
||||
|
||||
function movementPosition(
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
elapsedMs: number,
|
||||
): { readonly position: CombatPosition; readonly moving: boolean } {
|
||||
const movement = scenario.movement;
|
||||
if (!movement || elapsedMs < movement.startsAtMs) return { position: [0, 0, 0], moving: false };
|
||||
const cycle = (elapsedMs - movement.startsAtMs) % movement.periodMs;
|
||||
if (cycle < movement.outboundMs) {
|
||||
return {
|
||||
position: [movement.distance * cycle / Math.max(1, movement.outboundMs), 0, 0],
|
||||
moving: true,
|
||||
};
|
||||
}
|
||||
const returnStart = movement.outboundMs;
|
||||
if (cycle < returnStart + movement.returnMs) {
|
||||
const progress = (cycle - returnStart) / Math.max(1, movement.returnMs);
|
||||
return { position: [movement.distance * (1 - progress), 0, 0], moving: true };
|
||||
}
|
||||
return { position: [0, 0, 0], moving: false };
|
||||
}
|
||||
|
||||
function distance(left: CombatPosition, right: CombatPosition): number {
|
||||
return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]);
|
||||
}
|
||||
|
||||
function stackCompliant(scenario: BenchmarkScenarioDefinition): boolean | null {
|
||||
if (scenario.partyLayout !== "stacked") return null;
|
||||
const combat = useCombatStore.getState();
|
||||
return usePartyStore.getState().members
|
||||
.filter((member) => member.health > 0)
|
||||
.every((member) => {
|
||||
const position = getPartyRuntimePosition(member.id);
|
||||
return position !== null && distance(combat.playerPosition, position) <= 5;
|
||||
});
|
||||
}
|
||||
|
||||
function primaryThreatSource(role: PartyRole): ThreatSource {
|
||||
if (role === "tank") return { actorId: PLAYER_AGGRO_ID, role: "tank", abilityId: "benchmark-pull" };
|
||||
const tank = usePartyStore.getState().members.find((member) => member.role === "tank");
|
||||
return {
|
||||
actorId: tank?.id ?? usePartyStore.getState().members[0]?.id ?? PLAYER_AGGRO_ID,
|
||||
role: "tank",
|
||||
abilityId: "benchmark-pull",
|
||||
};
|
||||
}
|
||||
|
||||
function seedThreat(mobIds: readonly string[], role: PartyRole, now: number): void {
|
||||
const source = primaryThreatSource(role);
|
||||
for (const id of mobIds) {
|
||||
useCombatStore.getState().damageMob(id, 1, now, source, "physical");
|
||||
}
|
||||
}
|
||||
|
||||
function applyTankThreatCompetition(mobIds: readonly string[], now: number): void {
|
||||
const damageMembers = usePartyStore.getState().members.filter((member) => member.role === "damage").slice(0, 2);
|
||||
for (const member of damageMembers) {
|
||||
for (const id of mobIds) {
|
||||
useCombatStore.getState().damageMob(
|
||||
id,
|
||||
Math.max(1, Math.round(member.level * 0.9)),
|
||||
now,
|
||||
{ actorId: member.id, role: "damage", abilityId: "benchmark-reference-dps" },
|
||||
"physical",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function enforceExecuteWindow(
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
priorityTargetId: string,
|
||||
elapsedMs: number,
|
||||
durationMs: number,
|
||||
): void {
|
||||
if (scenario.id !== "damage-single-sustained" || elapsedMs < durationMs * 0.8) return;
|
||||
useCombatStore.setState((state) => {
|
||||
const target = state.mobs[priorityTargetId];
|
||||
if (!target || target.dead || target.health / target.maxHealth <= 0.15) return state;
|
||||
return {
|
||||
mobs: {
|
||||
...state.mobs,
|
||||
[priorityTargetId]: { ...target, health: Math.max(1, Math.round(target.maxHealth * 0.15)) },
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Reference bosses ignore player hard control so every tank receives the same attack schedule. */
|
||||
function clearBenchmarkControlStatuses(): void {
|
||||
useCombatStore.setState((state) => {
|
||||
let changed = false;
|
||||
const mobs = Object.fromEntries(Object.entries(state.mobs).map(([id, mob]) => {
|
||||
const statuses = mob.statuses.filter((status) => !BENCHMARK_CONTROL_STATUS_KINDS.has(status.kind));
|
||||
if (statuses.length !== mob.statuses.length) changed = true;
|
||||
return [id, statuses.length === mob.statuses.length ? mob : { ...mob, statuses }];
|
||||
}));
|
||||
return changed ? { mobs } : state;
|
||||
});
|
||||
}
|
||||
|
||||
function anyInjuredBelow(percent: number): boolean {
|
||||
const combat = useCombatStore.getState();
|
||||
if (combat.health > 0 && combat.health / Math.max(1, combat.maxHealth) < percent) return true;
|
||||
return usePartyStore.getState().members.some((member) => (
|
||||
member.health > 0 && member.health / Math.max(1, member.maxHealth) < percent
|
||||
));
|
||||
}
|
||||
|
||||
/** Runs one build/scenario pair against the real authoritative player combat path. */
|
||||
export async function runCombatBenchmark(
|
||||
build: BenchmarkBuildDefinition,
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
options: BenchmarkRunOptions = {},
|
||||
): Promise<BenchmarkRunReport> {
|
||||
if (!scenario.roles.includes(build.role)) {
|
||||
throw new Error(`Scenario ${scenario.id} does not support the ${build.role} role.`);
|
||||
}
|
||||
const seed = Math.trunc(options.seed ?? 1);
|
||||
const durationMs = scaledDuration(scenario, options.durationScale ?? 1);
|
||||
const classIds = build.secondaryClassId ? [build.classId, build.secondaryClassId] : [build.classId];
|
||||
await preloadAbilityCatalogsForClasses(classIds);
|
||||
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
resetCombatSpatialProvider();
|
||||
let now = BENCHMARK_CLOCK_ORIGIN;
|
||||
const restoreClock = installCombatClock(() => now);
|
||||
let recorder: BenchmarkRecorder | null = null;
|
||||
let simulatedDurationMs = 0;
|
||||
let completed = true;
|
||||
try {
|
||||
const combat = useCombatStore.getState();
|
||||
combat.initializeCharacter({
|
||||
classId: build.classId,
|
||||
secondaryClassId: build.secondaryClassId,
|
||||
level: build.level,
|
||||
talentRanks: build.talentRanks,
|
||||
inventory: build.inventory,
|
||||
equipment: build.equipment,
|
||||
});
|
||||
combat.setPlayerPosition([0, 0, 0]);
|
||||
const initializedCombat = useCombatStore.getState();
|
||||
usePartyStore.getState().initializeParty(
|
||||
`benchmark:${build.id}:${scenario.id}:${seed}`,
|
||||
build.level,
|
||||
initializedCombat.maxHealth,
|
||||
build.role,
|
||||
5,
|
||||
build.source,
|
||||
);
|
||||
const party = usePartyStore.getState();
|
||||
const positions = partyPositions(scenario.partyLayout, party.members.length);
|
||||
party.members.forEach((member, index) => registerPartyRuntimePosition(member.id, positions[index] ?? [0, 0, 0]));
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
const referenceTank = build.role === "tank"
|
||||
? { maxHealth: initializedCombat.maxHealth }
|
||||
: party.members.find((member) => member.role === "tank") ?? { maxHealth: initializedCombat.maxHealth };
|
||||
const attacks = enemyAttacks(scenario, build.level, referenceTank.maxHealth);
|
||||
const mobIds = Array.from({ length: scenario.hostileTargets }, (_, index) => `benchmark-${scenario.id}-${seed}-${index + 1}`);
|
||||
const ringRadius = benchmarkMobRingRadius(scenario);
|
||||
mobIds.forEach((id, index) => {
|
||||
setMobPosition(id, mobPosition(index, mobIds.length, ringRadius));
|
||||
useCombatStore.getState().registerMob(id, {
|
||||
name: index === 0 ? "Benchmark Boss" : `Benchmark Target ${index + 1}`,
|
||||
boss: index === 0,
|
||||
level: build.level,
|
||||
maxHealth: BENCHMARK_MOB_HEALTH,
|
||||
xpReward: 0,
|
||||
hasLoot: false,
|
||||
moveSpeed: 0,
|
||||
aggroRange: 45,
|
||||
leashRange: 60,
|
||||
attacks,
|
||||
mechanicImmunities: BENCHMARK_BOSS_CONTROL_IMMUNITIES,
|
||||
});
|
||||
});
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
seedThreat(mobIds, build.role, now);
|
||||
useCombatStore.getState().selectMob(mobIds[0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
recorder = new BenchmarkRecorder(build, scenario, seed, now, mobIds[0]);
|
||||
const controller = new BenchmarkController();
|
||||
recorder.sample({
|
||||
now,
|
||||
deltaMs: 0,
|
||||
combat: useCombatStore.getState(),
|
||||
party: usePartyStore.getState(),
|
||||
movedDistance: 0,
|
||||
moving: false,
|
||||
stackCompliant: stackCompliant(scenario),
|
||||
heldMobs: 0,
|
||||
totalThreatMobs: 0,
|
||||
});
|
||||
|
||||
let resourceFailureDecisions = 0;
|
||||
let previousPosition: CombatPosition = [0, 0, 0];
|
||||
let nextThreatCompetitionAt = 2_000;
|
||||
while (simulatedDurationMs < durationMs) {
|
||||
const deltaMs = Math.min(scenario.tickMs, durationMs - simulatedDurationMs);
|
||||
simulatedDurationMs += deltaMs;
|
||||
now = BENCHMARK_CLOCK_ORIGIN + simulatedDurationMs;
|
||||
const movement = movementPosition(scenario, simulatedDurationMs);
|
||||
useCombatStore.getState().setPlayerPosition(movement.position);
|
||||
const movedDistance = distance(previousPosition, movement.position);
|
||||
previousPosition = movement.position;
|
||||
|
||||
restoreBenchmarkMobPositions(scenario, mobIds);
|
||||
useCombatStore.getState().tick(deltaMs / 1_000, now);
|
||||
enforceExecuteWindow(scenario, mobIds[0], simulatedDurationMs, durationMs);
|
||||
restoreBenchmarkMobPositions(scenario, mobIds);
|
||||
if (scenario.attacks.length) {
|
||||
clearBenchmarkControlStatuses();
|
||||
useCombatStore.getState().advanceMobCombat(now, movement.position);
|
||||
}
|
||||
if (scenario.id === "tank-multi-target" && simulatedDurationMs >= nextThreatCompetitionAt) {
|
||||
applyTankThreatCompetition(mobIds, now);
|
||||
nextThreatCompetitionAt += 2_000;
|
||||
}
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
const decision = controller.act(now, build.role, scenario, movement.moving);
|
||||
for (let index = 0; index < decision.invalidFailures; index += 1) recorder.recordInvalidCast();
|
||||
if (scenario.resourceFailureGcds && decision.made && anyInjuredBelow(0.8)) {
|
||||
resourceFailureDecisions = decision.cast || !decision.resourceBlocked
|
||||
? 0
|
||||
: resourceFailureDecisions + 1;
|
||||
if (resourceFailureDecisions >= scenario.resourceFailureGcds) recorder.markResourceFailure(now);
|
||||
}
|
||||
|
||||
const currentCombat = useCombatStore.getState();
|
||||
const currentParty = usePartyStore.getState();
|
||||
const activeMobs = Object.values(currentCombat.mobs).filter((mob) => mob.engaged && !mob.dead);
|
||||
recorder.sample({
|
||||
now,
|
||||
deltaMs,
|
||||
combat: currentCombat,
|
||||
party: currentParty,
|
||||
movedDistance,
|
||||
moving: movement.moving,
|
||||
stackCompliant: stackCompliant(scenario),
|
||||
heldMobs: build.role === "tank"
|
||||
? activeMobs.filter((mob) => mob.targetActorId === PLAYER_AGGRO_ID).length
|
||||
: 0,
|
||||
totalThreatMobs: build.role === "tank" ? activeMobs.length : 0,
|
||||
});
|
||||
|
||||
const playerDied = currentCombat.health <= 0 && (scenario.stopOnPlayerDeath || build.role === "tank");
|
||||
const partyDied = currentParty.members.some((member) => member.health <= 0)
|
||||
&& (scenario.stopOnPartyDeath || build.role === "healer");
|
||||
const resourceFailed = scenario.resourceFailureGcds !== undefined
|
||||
&& resourceFailureDecisions >= scenario.resourceFailureGcds;
|
||||
if (playerDied || partyDied || resourceFailed) {
|
||||
completed = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return recorder.finish(simulatedDurationMs, completed);
|
||||
} catch (error: unknown) {
|
||||
if (!recorder) throw error;
|
||||
const partial = recorder.finish(simulatedDurationMs, false);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return Object.freeze({
|
||||
...partial,
|
||||
completed: false,
|
||||
validation: Object.freeze({
|
||||
valid: false,
|
||||
reasons: Object.freeze([...partial.validation.reasons, `Runtime error: ${message}`]),
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
recorder?.dispose();
|
||||
restoreClock();
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
resetCombatSpatialProvider();
|
||||
}
|
||||
}
|
||||
|
||||
/** Selects only scenario/build combinations that share a declared role. */
|
||||
export function compatibleBenchmarkPairs(
|
||||
builds: readonly BenchmarkBuildDefinition[],
|
||||
scenarios: readonly BenchmarkScenarioDefinition[],
|
||||
): readonly (readonly [BenchmarkBuildDefinition, BenchmarkScenarioDefinition])[] {
|
||||
return builds.flatMap((build) => scenarios
|
||||
.filter((scenario) => scenario.roles.includes(build.role))
|
||||
.map((scenario) => [build, scenario] as const));
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PartyRole } from "../partyRoles";
|
||||
import { BENCHMARK_SCENARIOS, benchmarkScenarioById } from "./benchmarkScenarios";
|
||||
|
||||
const EXPECTED_DURATIONS_MS: Readonly<Record<string, number>> = Object.freeze({
|
||||
"damage-single-burst": 30_000,
|
||||
"damage-single-sustained": 180_000,
|
||||
"damage-cleave-three": 90_000,
|
||||
"damage-aoe-eight": 60_000,
|
||||
"tank-mixed-boss": 180_000,
|
||||
"tank-multi-target": 120_000,
|
||||
"heal-single-target": 90_000,
|
||||
"heal-spread-party": 180_000,
|
||||
"heal-stacked-party": 90_000,
|
||||
"heal-resource-endurance": 600_000,
|
||||
"mechanic-movement-boss": 180_000,
|
||||
"mechanic-stack-boss": 180_000,
|
||||
});
|
||||
|
||||
describe("benchmark scenario catalog", () => {
|
||||
it("uses unique stable ids and positive integer versions", () => {
|
||||
const ids = BENCHMARK_SCENARIOS.map((scenario) => scenario.id);
|
||||
|
||||
expect(ids).toHaveLength(Object.keys(EXPECTED_DURATIONS_MS).length);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect([...ids].sort()).toEqual(Object.keys(EXPECTED_DURATIONS_MS).sort());
|
||||
expect(BENCHMARK_SCENARIOS.every(
|
||||
(scenario) => Number.isInteger(scenario.version) && scenario.version >= 1,
|
||||
)).toBe(true);
|
||||
expect(BENCHMARK_SCENARIOS.every((scenario) => scenario.version === 1)).toBe(true);
|
||||
|
||||
for (const scenario of BENCHMARK_SCENARIOS) {
|
||||
expect(benchmarkScenarioById(scenario.id)).toBe(scenario);
|
||||
}
|
||||
expect(benchmarkScenarioById("missing-scenario")).toBeNull();
|
||||
});
|
||||
|
||||
it("covers every combat role and keeps role-specific families scoped", () => {
|
||||
const expectedRoles: readonly PartyRole[] = ["tank", "healer", "damage"];
|
||||
const coveredRoles = new Set(BENCHMARK_SCENARIOS.flatMap((scenario) => scenario.roles));
|
||||
|
||||
expect([...coveredRoles].sort()).toEqual([...expectedRoles].sort());
|
||||
expect(BENCHMARK_SCENARIOS.every((scenario) => (
|
||||
scenario.roles.length > 0
|
||||
&& scenario.roles.every((role) => expectedRoles.includes(role))
|
||||
))).toBe(true);
|
||||
expect(BENCHMARK_SCENARIOS.filter((scenario) => scenario.family === "damage")
|
||||
.every((scenario) => scenario.roles.length === 1 && scenario.roles[0] === "damage"))
|
||||
.toBe(true);
|
||||
expect(BENCHMARK_SCENARIOS.filter((scenario) => scenario.family === "tanking")
|
||||
.every((scenario) => scenario.roles.length === 1 && scenario.roles[0] === "tank"))
|
||||
.toBe(true);
|
||||
expect(BENCHMARK_SCENARIOS.filter((scenario) => (
|
||||
scenario.family === "healing" || scenario.family === "endurance"
|
||||
)).every((scenario) => scenario.roles.length === 1 && scenario.roles[0] === "healer"))
|
||||
.toBe(true);
|
||||
expect(BENCHMARK_SCENARIOS.filter((scenario) => scenario.family === "mechanics")
|
||||
.every((scenario) => expectedRoles.every((role) => scenario.roles.includes(role))))
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
it("preserves exact durations on an integral fixed-step timeline", () => {
|
||||
for (const scenario of BENCHMARK_SCENARIOS) {
|
||||
expect(scenario.durationMs).toBe(EXPECTED_DURATIONS_MS[scenario.id]);
|
||||
expect(Number.isInteger(scenario.durationMs)).toBe(true);
|
||||
expect(Number.isInteger(scenario.tickMs)).toBe(true);
|
||||
expect(scenario.durationMs).toBeGreaterThan(0);
|
||||
expect(scenario.tickMs).toBeGreaterThan(0);
|
||||
expect(scenario.durationMs % scenario.tickMs).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { BenchmarkEnemyAttackProfile, BenchmarkScenarioDefinition } from "./benchmarkTypes";
|
||||
|
||||
const roles = ["tank", "healer", "damage"] as const;
|
||||
|
||||
const attack = (
|
||||
id: string,
|
||||
name: string,
|
||||
target: BenchmarkEnemyAttackProfile["target"],
|
||||
intervalMs: number,
|
||||
damageMultiplier: number,
|
||||
options: Pick<BenchmarkEnemyAttackProfile, "school" | "radius"> = { school: "physical" },
|
||||
): BenchmarkEnemyAttackProfile => Object.freeze({
|
||||
id,
|
||||
name,
|
||||
target,
|
||||
intervalMs,
|
||||
damageMultiplier,
|
||||
school: options.school,
|
||||
...(options.radius === undefined ? {} : { radius: options.radius }),
|
||||
});
|
||||
|
||||
/** Versioned, fixed-input contracts used by the automated balance matrix. */
|
||||
export const BENCHMARK_SCENARIOS: readonly BenchmarkScenarioDefinition[] = Object.freeze([
|
||||
{
|
||||
id: "damage-single-burst",
|
||||
version: 1,
|
||||
name: "Single-target burst",
|
||||
description: "Thirty seconds on an immortal boss with all cooldowns ready.",
|
||||
family: "damage",
|
||||
roles: ["damage"],
|
||||
durationMs: 30_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "none",
|
||||
attacks: [],
|
||||
},
|
||||
{
|
||||
id: "damage-single-sustained",
|
||||
version: 1,
|
||||
name: "Single-target sustained",
|
||||
description: "Three minutes on an immortal boss, including a scripted execute window.",
|
||||
family: "damage",
|
||||
roles: ["damage"],
|
||||
durationMs: 180_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "none",
|
||||
attacks: [],
|
||||
},
|
||||
{
|
||||
id: "damage-cleave-three",
|
||||
version: 1,
|
||||
name: "Three-target cleave",
|
||||
description: "Three immortal targets packed within five yards of the priority target.",
|
||||
family: "damage",
|
||||
roles: ["damage"],
|
||||
durationMs: 90_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 3,
|
||||
partyLayout: "none",
|
||||
attacks: [],
|
||||
},
|
||||
{
|
||||
id: "damage-aoe-eight",
|
||||
version: 1,
|
||||
name: "Eight-target AoE",
|
||||
description: "A priority target surrounded by seven immortal targets on a six-yard ring.",
|
||||
family: "damage",
|
||||
roles: ["damage"],
|
||||
durationMs: 60_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 8,
|
||||
partyLayout: "none",
|
||||
attacks: [],
|
||||
},
|
||||
{
|
||||
id: "tank-mixed-boss",
|
||||
version: 1,
|
||||
name: "Mixed boss tanking",
|
||||
description: "Physical swings plus a periodic magical tank buster.",
|
||||
family: "tanking",
|
||||
roles: ["tank"],
|
||||
durationMs: 180_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "melee",
|
||||
attacks: [
|
||||
attack("benchmark-swing", "Reference Swing", "primary", 2_000, 0.08),
|
||||
attack("benchmark-buster", "Arcane Buster", "primary", 20_000, 0.42, { school: "arcane" }),
|
||||
],
|
||||
stopOnPlayerDeath: true,
|
||||
},
|
||||
{
|
||||
id: "tank-multi-target",
|
||||
version: 1,
|
||||
name: "Multi-target tanking",
|
||||
description: "Five enemies test AoE threat, mitigation, and survival under many swings.",
|
||||
family: "tanking",
|
||||
roles: ["tank"],
|
||||
durationMs: 120_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 5,
|
||||
partyLayout: "melee",
|
||||
attacks: [attack("benchmark-add-swing", "Add Swing", "primary", 2_000, 0.022)],
|
||||
stopOnPlayerDeath: true,
|
||||
},
|
||||
{
|
||||
id: "heal-single-target",
|
||||
version: 1,
|
||||
name: "Single-target healing",
|
||||
description: "A reference tank takes sustained swings and fifteen-second burst hits.",
|
||||
family: "healing",
|
||||
roles: ["healer"],
|
||||
durationMs: 90_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "melee",
|
||||
attacks: [
|
||||
attack("benchmark-tank-pressure", "Tank Pressure", "primary", 2_000, 0.12),
|
||||
attack("benchmark-tank-spike", "Tank Spike", "primary", 15_000, 0.55, { school: "shadow" }),
|
||||
],
|
||||
stopOnPartyDeath: true,
|
||||
},
|
||||
{
|
||||
id: "heal-spread-party",
|
||||
version: 1,
|
||||
name: "Spread party healing",
|
||||
description: "Party members spread apart while raid pulses and deterministic spot damage land.",
|
||||
family: "healing",
|
||||
roles: ["healer"],
|
||||
durationMs: 180_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "spread",
|
||||
attacks: [
|
||||
attack("benchmark-spread-tank", "Tank Pressure", "primary", 2_000, 0.07),
|
||||
attack("benchmark-spread-pulse", "Spread Pulse", "nearby-party", 6_000, 0.12, { school: "nature", radius: 40 }),
|
||||
attack("benchmark-spread-spot", "Spot Damage", "random-party", 12_000, 0.32, { school: "fire" }),
|
||||
],
|
||||
stopOnPartyDeath: true,
|
||||
},
|
||||
{
|
||||
id: "heal-stacked-party",
|
||||
version: 1,
|
||||
name: "Stacked party healing",
|
||||
description: "The group stacks within five yards for rapid party-wide pulses.",
|
||||
family: "healing",
|
||||
roles: ["healer"],
|
||||
durationMs: 90_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "stacked",
|
||||
attacks: [
|
||||
attack("benchmark-stack-tank", "Tank Pressure", "primary", 2_000, 0.05),
|
||||
attack("benchmark-stack-pulse", "Stack Pulse", "nearby-party", 3_000, 0.18, { school: "arcane", radius: 8 }),
|
||||
],
|
||||
stopOnPartyDeath: true,
|
||||
},
|
||||
{
|
||||
id: "heal-resource-endurance",
|
||||
version: 1,
|
||||
name: "Healing resource endurance",
|
||||
description: "Up to ten minutes of sustained tank and party pressure.",
|
||||
family: "endurance",
|
||||
roles: ["healer"],
|
||||
durationMs: 600_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "spread",
|
||||
attacks: [
|
||||
attack("benchmark-endurance-tank", "Endurance Swing", "primary", 2_000, 0.055),
|
||||
attack("benchmark-endurance-party", "Endurance Pulse", "nearby-party", 4_000, 0.045, { school: "nature", radius: 40 }),
|
||||
attack("benchmark-endurance-spot", "Endurance Spot Hit", "random-party", 15_000, 0.25, { school: "frost" }),
|
||||
],
|
||||
stopOnPartyDeath: true,
|
||||
resourceFailureGcds: 3,
|
||||
},
|
||||
{
|
||||
id: "mechanic-movement-boss",
|
||||
version: 1,
|
||||
name: "Movement boss",
|
||||
description: "Repeated outbound and return movement interrupts stationary casts and reduces uptime.",
|
||||
family: "mechanics",
|
||||
roles,
|
||||
durationMs: 180_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "melee",
|
||||
attacks: [
|
||||
attack("benchmark-movement-swing", "Movement Swing", "primary", 2_000, 0.055),
|
||||
attack("benchmark-movement-pulse", "Movement Pulse", "nearby-party", 8_000, 0.06, { school: "fire", radius: 40 }),
|
||||
],
|
||||
movement: { startsAtMs: 15_000, periodMs: 20_000, outboundMs: 4_000, returnMs: 2_000, distance: 8 },
|
||||
},
|
||||
{
|
||||
id: "mechanic-stack-boss",
|
||||
version: 1,
|
||||
name: "Stack boss",
|
||||
description: "The party remains tightly grouped so proximity healing and split-pressure tools are exercised.",
|
||||
family: "mechanics",
|
||||
roles,
|
||||
durationMs: 180_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "stacked",
|
||||
attacks: [
|
||||
attack("benchmark-stack-swing", "Stack Boss Swing", "primary", 2_000, 0.05),
|
||||
attack("benchmark-stack-mechanic", "Shared Burden", "nearby-party", 6_000, 0.15, { school: "shadow", radius: 8 }),
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
export function benchmarkScenarioById(id: string): BenchmarkScenarioDefinition | null {
|
||||
return BENCHMARK_SCENARIOS.find((scenario) => scenario.id === id) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { ClassId } from "../../app/characterCatalog";
|
||||
import type { ContentCategoryId } from "../../app/contentCategories";
|
||||
import type { EquipmentAssignments } from "../equipment";
|
||||
import type { ItemStats } from "../itemStats";
|
||||
import type { PartyRole } from "../partyRoles";
|
||||
|
||||
export type BenchmarkScenarioFamily = "damage" | "tanking" | "healing" | "mechanics" | "endurance";
|
||||
export type BenchmarkPartyLayout = "none" | "melee" | "spread" | "stacked";
|
||||
|
||||
export interface BenchmarkMovementPattern {
|
||||
readonly startsAtMs: number;
|
||||
readonly periodMs: number;
|
||||
readonly outboundMs: number;
|
||||
readonly returnMs: number;
|
||||
readonly distance: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkEnemyAttackProfile {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly target: "primary" | "random-party" | "nearby-party";
|
||||
readonly school: "physical" | "arcane" | "fire" | "frost" | "nature" | "shadow" | "holy";
|
||||
readonly intervalMs: number;
|
||||
readonly damageMultiplier: number;
|
||||
readonly radius?: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkScenarioDefinition {
|
||||
readonly id: string;
|
||||
readonly version: number;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly family: BenchmarkScenarioFamily;
|
||||
readonly roles: readonly PartyRole[];
|
||||
readonly durationMs: number;
|
||||
readonly tickMs: number;
|
||||
readonly hostileTargets: number;
|
||||
readonly partyLayout: BenchmarkPartyLayout;
|
||||
readonly attacks: readonly BenchmarkEnemyAttackProfile[];
|
||||
readonly movement?: BenchmarkMovementPattern;
|
||||
readonly stopOnPlayerDeath?: boolean;
|
||||
readonly stopOnPartyDeath?: boolean;
|
||||
readonly resourceFailureGcds?: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkBuildDefinition {
|
||||
readonly id: string;
|
||||
readonly source: ContentCategoryId;
|
||||
readonly classId: ClassId;
|
||||
readonly secondaryClassId?: ClassId | null;
|
||||
readonly role: PartyRole;
|
||||
readonly level: number;
|
||||
readonly orientation: string;
|
||||
readonly talentRanks: Readonly<Record<string, number>>;
|
||||
readonly inventory: readonly never[];
|
||||
readonly equipment: EquipmentAssignments;
|
||||
readonly gearPresetId: string;
|
||||
}
|
||||
|
||||
export interface BenchmarkRunOptions {
|
||||
readonly seed?: number;
|
||||
readonly durationScale?: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkAbilityMetrics {
|
||||
readonly abilityId: string;
|
||||
readonly castsStarted: number;
|
||||
readonly castsCompleted: number;
|
||||
readonly interrupts: number;
|
||||
readonly rawDamage: number;
|
||||
readonly effectiveDamage: number;
|
||||
readonly effectiveHealing: number;
|
||||
readonly overheal: number;
|
||||
readonly absorbsApplied: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkResourceMetrics {
|
||||
readonly id: string;
|
||||
readonly starting: number;
|
||||
readonly ending: number;
|
||||
readonly minimum: number;
|
||||
readonly maximum: number;
|
||||
readonly timeBelowTenPercentMs: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkRunMetrics {
|
||||
readonly rawDamage: number;
|
||||
readonly effectiveDamage: number;
|
||||
readonly priorityTargetDamage: number;
|
||||
readonly effectiveHealing: number;
|
||||
readonly rawHealing: number;
|
||||
readonly overheal: number;
|
||||
readonly absorbsApplied: number;
|
||||
readonly absorbsConsumed: number;
|
||||
readonly rawDamageTaken: number;
|
||||
readonly effectiveDamageTaken: number;
|
||||
readonly incomingAttacks: number;
|
||||
readonly avoidedAttacks: number;
|
||||
readonly deaths: number;
|
||||
readonly minimumPlayerHealthPercent: number;
|
||||
readonly minimumPartyHealthPercent: number;
|
||||
readonly castsStarted: number;
|
||||
readonly castsCompleted: number;
|
||||
readonly movementInterrupts: number;
|
||||
readonly invalidCasts: number;
|
||||
readonly distanceMoved: number;
|
||||
readonly movementTimeMs: number;
|
||||
readonly stackCompliancePercent: number | null;
|
||||
readonly threatControlPercent: number | null;
|
||||
readonly timeToResourceFailureMs: number | null;
|
||||
readonly timeToFirstDeathMs: number | null;
|
||||
readonly dps: number;
|
||||
readonly hps: number;
|
||||
readonly dtps: number;
|
||||
readonly overhealPercent: number;
|
||||
readonly mitigationPercent: number;
|
||||
readonly avoidancePercent: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkValidation {
|
||||
readonly valid: boolean;
|
||||
readonly reasons: readonly string[];
|
||||
}
|
||||
|
||||
export interface BenchmarkRunReport {
|
||||
readonly schemaVersion: 1;
|
||||
readonly runId: string;
|
||||
readonly policyId: string;
|
||||
readonly seed: number;
|
||||
readonly build: BenchmarkBuildDefinition;
|
||||
readonly scenario: BenchmarkScenarioDefinition;
|
||||
readonly simulatedDurationMs: number;
|
||||
readonly completed: boolean;
|
||||
readonly metrics: BenchmarkRunMetrics;
|
||||
readonly resources: readonly BenchmarkResourceMetrics[];
|
||||
readonly abilities: readonly BenchmarkAbilityMetrics[];
|
||||
readonly eventCounts: Readonly<Record<string, number>>;
|
||||
readonly validation: BenchmarkValidation;
|
||||
readonly gearStats: ItemStats;
|
||||
}
|
||||
|
||||
export interface BenchmarkMatrixReport {
|
||||
readonly schemaVersion: 1;
|
||||
readonly generatedAt: string;
|
||||
readonly appVersion: string;
|
||||
readonly cohort: {
|
||||
readonly level: number;
|
||||
readonly gearPresetId: string;
|
||||
readonly seeds: readonly number[];
|
||||
readonly durationScale: number;
|
||||
readonly policyId: string;
|
||||
};
|
||||
readonly scenarios: readonly BenchmarkScenarioDefinition[];
|
||||
readonly builds: readonly BenchmarkBuildDefinition[];
|
||||
readonly runs: readonly BenchmarkRunReport[];
|
||||
}
|
||||
@@ -25,6 +25,11 @@ const clips = [
|
||||
{ name: "Fall (ID 40 variation 0)" },
|
||||
{ name: "JumpEnd (ID 39 variation 0)" },
|
||||
{ name: "JumpLandRun (ID 133 variation 0)" },
|
||||
{ name: "Ready1H (ID 26 variation 0)" },
|
||||
{ name: "Ready2H (ID 27 variation 0)" },
|
||||
{ name: "Ready2HL (ID 28 variation 0)" },
|
||||
{ name: "ReadyBow (ID 29 variation 0)" },
|
||||
{ name: "HoldBow (ID 109 variation 0)" },
|
||||
];
|
||||
|
||||
describe("WoW character animation selection", () => {
|
||||
@@ -43,6 +48,31 @@ describe("WoW character animation selection", () => {
|
||||
expect(selectCharacterBaseClip(clips, "run")).toBe(clips[2]);
|
||||
});
|
||||
|
||||
it("uses the native ready pose for the equipped weapon stance", () => {
|
||||
expect(selectCharacterBaseClip(clips, "idle", "one-handed")).toBe(clips[15]);
|
||||
expect(selectCharacterBaseClip(clips, "idle", "two-handed")).toBe(clips[16]);
|
||||
expect(selectCharacterBaseClip(clips, "idle", "two-handed-left")).toBe(clips[17]);
|
||||
expect(selectCharacterBaseClip(clips, "idle", "bow")).toBe(clips[19]);
|
||||
expect(selectCharacterBaseClip(clips, "run", "bow")).toBe(clips[2]);
|
||||
expect(selectCharacterBaseClip([
|
||||
clips[0],
|
||||
clips[15],
|
||||
], "idle", "bow")).toBe(clips[15]);
|
||||
|
||||
const runewakerClips = [
|
||||
{ name: "Stand - stand_idle01" },
|
||||
{ name: "Idle - 1h_idle" },
|
||||
{ name: "Idle - 2h_idle" },
|
||||
{ name: "Idle - bow_idle" },
|
||||
];
|
||||
expect(selectCharacterBaseClip(runewakerClips, "idle", "one-handed"))
|
||||
.toBe(runewakerClips[1]);
|
||||
expect(selectCharacterBaseClip(runewakerClips, "idle", "two-handed-left"))
|
||||
.toBe(runewakerClips[2]);
|
||||
expect(selectCharacterBaseClip(runewakerClips, "idle", "bow"))
|
||||
.toBe(runewakerClips[3]);
|
||||
});
|
||||
|
||||
it("rotates every variation of the highest-priority available ID", () => {
|
||||
expect(selectWoWAnimationVariation(clips, [16], 0)).toBe(clips[9]);
|
||||
expect(selectWoWAnimationVariation(clips, [16], 1)).toBe(clips[10]);
|
||||
|
||||
@@ -32,6 +32,12 @@ type RunewakerCombatMotion = "melee" | "bow" | "spell";
|
||||
|
||||
export type CharacterBaseMotion = "idle" | "walk" | "run";
|
||||
|
||||
export type CharacterWeaponStance =
|
||||
| "bow"
|
||||
| "one-handed"
|
||||
| "two-handed"
|
||||
| "two-handed-left";
|
||||
|
||||
export interface CharacterClipSelection<TClip extends NamedAnimationClip> {
|
||||
readonly clip: TClip;
|
||||
readonly loop: boolean;
|
||||
@@ -108,7 +114,21 @@ export function selectWoWAnimationVariation<TClip extends NamedAnimationClip>(
|
||||
export function selectCharacterBaseClip<TClip extends NamedAnimationClip>(
|
||||
clips: readonly TClip[],
|
||||
motion: CharacterBaseMotion,
|
||||
weaponStance?: CharacterWeaponStance,
|
||||
): TClip | undefined {
|
||||
if (motion === "idle" && weaponStance) {
|
||||
const readyIds = weaponStance === "bow"
|
||||
? [109, 29, 26]
|
||||
: weaponStance === "one-handed"
|
||||
? [26]
|
||||
: weaponStance === "two-handed-left" ? [28, 27] : [27, 28];
|
||||
const runewakerIdle = weaponStance === "bow"
|
||||
? "idle - bow_idle"
|
||||
: weaponStance === "one-handed" ? "idle - 1h_idle" : "idle - 2h_idle";
|
||||
const ready = selectWoWAnimationVariation(clips, readyIds)
|
||||
?? clips.find((clip) => clip.name.toLowerCase().startsWith(runewakerIdle));
|
||||
if (ready) return ready;
|
||||
}
|
||||
const ids = motion === "run" ? [5, 4, 0] : motion === "walk" ? [4, 5, 0] : [0, 4, 5];
|
||||
return selectWoWAnimationVariation(clips, ids);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
type CombatPresentationEvent,
|
||||
} from "./combatPresentation";
|
||||
import {
|
||||
combatAudioOutputEnabled,
|
||||
configureCombatAudio,
|
||||
masterAudioOutputEnabled,
|
||||
playCombatAudio,
|
||||
playMasterAudioCue,
|
||||
} from "./combatAudio";
|
||||
|
||||
const fixtureEvent: CombatPresentationEvent = {
|
||||
id: 1,
|
||||
sessionId: 1,
|
||||
occurredAt: 1,
|
||||
phase: "impact",
|
||||
actorGroup: "player",
|
||||
sourceActorId: "player",
|
||||
targetActorId: "enemy",
|
||||
abilityId: "fixture",
|
||||
name: "Fixture",
|
||||
source: "fallback",
|
||||
school: "holy",
|
||||
delivery: "projectile",
|
||||
origin: [0, 0, 0],
|
||||
targetPosition: [1, 0, 0],
|
||||
};
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("combat audio output policy", () => {
|
||||
it("does not create or fetch audio while the master volume is disabled", async () => {
|
||||
const AudioContextConstructor = vi.fn();
|
||||
const fetch = vi.fn();
|
||||
vi.stubGlobal("window", { AudioContext: AudioContextConstructor });
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0);
|
||||
|
||||
playCombatAudio(fixtureEvent);
|
||||
await expect(playMasterAudioCue("/fixture.wav")).resolves.toBeNull();
|
||||
|
||||
expect(AudioContextConstructor).not.toHaveBeenCalled();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows explicitly enabled positive-volume output", () => {
|
||||
expect(masterAudioOutputEnabled(0)).toBe(false);
|
||||
expect(masterAudioOutputEnabled(0.6)).toBe(true);
|
||||
expect(combatAudioOutputEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0)).toBe(false);
|
||||
expect(combatAudioOutputEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not initialize audio for a zero-volume actor group", () => {
|
||||
const AudioContextConstructor = vi.fn();
|
||||
vi.stubGlobal("window", { AudioContext: AudioContextConstructor });
|
||||
configureCombatAudio({
|
||||
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
audioVolume: {
|
||||
...DEFAULT_COMBAT_PRESENTATION_SETTINGS.audioVolume,
|
||||
player: 0,
|
||||
},
|
||||
}, 0.6);
|
||||
|
||||
playCombatAudio(fixtureEvent);
|
||||
|
||||
expect(AudioContextConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+56
-5
@@ -18,11 +18,35 @@ let context: AudioContext | null = null;
|
||||
let masterGain: GainNode | null = null;
|
||||
let groupGains: Record<CombatActorGroup, GainNode> | null = null;
|
||||
let configuredSettings: CombatPresentationSettings | null = null;
|
||||
let configuredMasterVolume = 0.8;
|
||||
let configuredMasterVolume = 0;
|
||||
const activeVoices: ActiveVoice[] = [];
|
||||
const activeMasterVoices = new Set<() => void>();
|
||||
const decodedBuffers = new Map<string, Promise<AudioBuffer | null>>();
|
||||
|
||||
export function masterAudioOutputEnabled(masterVolume: number): boolean {
|
||||
return Number.isFinite(masterVolume) && masterVolume > 0;
|
||||
}
|
||||
|
||||
export function combatAudioOutputEnabled(
|
||||
settings: CombatPresentationSettings,
|
||||
masterVolume: number,
|
||||
): boolean {
|
||||
return masterAudioOutputEnabled(masterVolume)
|
||||
&& (["player", "party", "enemy"] as const).some((group) => (
|
||||
settings.audioEnabled[group] && settings.audioVolume[group] > 0
|
||||
));
|
||||
}
|
||||
|
||||
function combatAudioGroupOutputEnabled(
|
||||
settings: CombatPresentationSettings,
|
||||
masterVolume: number,
|
||||
group: CombatActorGroup,
|
||||
): boolean {
|
||||
return masterAudioOutputEnabled(masterVolume)
|
||||
&& settings.audioEnabled[group]
|
||||
&& settings.audioVolume[group] > 0;
|
||||
}
|
||||
|
||||
function audioContextConstructor(): typeof AudioContext | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return window.AudioContext
|
||||
@@ -66,7 +90,11 @@ export function configureCombatAudio(
|
||||
configuredSettings = settings;
|
||||
configuredMasterVolume = Number.isFinite(masterVolume)
|
||||
? Math.max(0, Math.min(1, masterVolume))
|
||||
: 0.8;
|
||||
: 0;
|
||||
if (!masterAudioOutputEnabled(configuredMasterVolume)) {
|
||||
resetCombatAudio();
|
||||
if (context?.state === "running") void context.suspend().catch(() => undefined);
|
||||
}
|
||||
applyCombatAudioSettings();
|
||||
}
|
||||
|
||||
@@ -113,6 +141,10 @@ const SCHOOL_FREQUENCY: Readonly<Record<CombatPresentationSchool, number>> = {
|
||||
};
|
||||
|
||||
function playSynthesizedCombatCue(event: CombatPresentationEvent): void {
|
||||
if (
|
||||
!configuredSettings
|
||||
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
|
||||
) return;
|
||||
const audioContext = ensureContext();
|
||||
if (!audioContext || !groupGains || !configuredSettings?.audioEnabled[event.actorGroup]) return;
|
||||
void audioContext.resume().catch(() => undefined);
|
||||
@@ -181,9 +213,15 @@ export async function playMasterAudioCue(
|
||||
url: string,
|
||||
volume = 1,
|
||||
): Promise<(() => void) | null> {
|
||||
if (!masterAudioOutputEnabled(configuredMasterVolume)) return null;
|
||||
const audioContext = ensureContext();
|
||||
const buffer = await decodeAudio(url);
|
||||
if (!audioContext || !buffer || !masterGain) return null;
|
||||
if (
|
||||
!masterAudioOutputEnabled(configuredMasterVolume)
|
||||
|| !audioContext
|
||||
|| !buffer
|
||||
|| !masterGain
|
||||
) return null;
|
||||
void audioContext.resume().catch(() => undefined);
|
||||
const source = audioContext.createBufferSource();
|
||||
const envelope = audioContext.createGain();
|
||||
@@ -211,9 +249,19 @@ export async function playMasterAudioCue(
|
||||
|
||||
async function playBufferedCombatCue(event: CombatPresentationEvent, sound: CombatEffectSoundAsset): Promise<boolean> {
|
||||
if (!sound.url) return false;
|
||||
if (
|
||||
!configuredSettings
|
||||
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
|
||||
) return false;
|
||||
const audioContext = ensureContext();
|
||||
const buffer = await decodeAudio(sound.url);
|
||||
if (!audioContext || !buffer || !groupGains || !configuredSettings?.audioEnabled[event.actorGroup]) return false;
|
||||
if (
|
||||
!configuredSettings
|
||||
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
|
||||
|| !audioContext
|
||||
|| !buffer
|
||||
|| !groupGains
|
||||
) return false;
|
||||
void audioContext.resume().catch(() => undefined);
|
||||
const source = audioContext.createBufferSource();
|
||||
const envelope = audioContext.createGain();
|
||||
@@ -248,7 +296,10 @@ export function playCombatAudio(event: CombatPresentationEvent): void {
|
||||
stopCombatAudioGroup(event.actorGroup);
|
||||
return;
|
||||
}
|
||||
if (!configuredSettings?.audioEnabled[event.actorGroup] || configuredSettings.audioVolume[event.actorGroup] <= 0) return;
|
||||
if (
|
||||
!configuredSettings
|
||||
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
|
||||
) return;
|
||||
const sound = weightedSound(event);
|
||||
if (!sound) {
|
||||
playSynthesizedCombatCue(event);
|
||||
|
||||
@@ -132,6 +132,8 @@ export interface AuraProcState {
|
||||
export interface AuraDefinition {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
/** Player-facing explanation shown by aura tooltips. */
|
||||
readonly description?: string;
|
||||
readonly disposition: AuraDisposition;
|
||||
/** Spell artwork used by every generic aura HUD surface. */
|
||||
readonly icon?: string;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { combatNow, installCombatClock } from "./combatClock";
|
||||
|
||||
describe("combat clock", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("installs a deterministic clock and restores wall time", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(42_000);
|
||||
const restore = installCombatClock(() => 1_234);
|
||||
|
||||
try {
|
||||
expect(combatNow()).toBe(1_234);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(combatNow()).toBe(42_000);
|
||||
});
|
||||
|
||||
it("restores nested clock installations in last-in-first-out order", () => {
|
||||
const restoreOuter = installCombatClock(() => 100);
|
||||
|
||||
try {
|
||||
expect(combatNow()).toBe(100);
|
||||
const restoreInner = installCombatClock(() => 200);
|
||||
|
||||
try {
|
||||
expect(combatNow()).toBe(200);
|
||||
} finally {
|
||||
restoreInner();
|
||||
}
|
||||
|
||||
expect(combatNow()).toBe(100);
|
||||
} finally {
|
||||
restoreOuter();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
export type CombatClock = () => number;
|
||||
|
||||
let activeCombatClock: CombatClock = () => Date.now();
|
||||
|
||||
/** Shared simulation time. Production uses wall time; deterministic tools inject a fixed-step clock. */
|
||||
export function combatNow(): number {
|
||||
return activeCombatClock();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the combat clock and returns a disposer that restores the previous
|
||||
* source. Benchmark callers must run sequentially because combat stores are
|
||||
* process-wide singletons.
|
||||
*/
|
||||
export function installCombatClock(clock: CombatClock): () => void {
|
||||
const previous = activeCombatClock;
|
||||
activeCombatClock = clock;
|
||||
return () => {
|
||||
activeCombatClock = previous;
|
||||
};
|
||||
}
|
||||
@@ -30,6 +30,26 @@ export type CombatEventInput = Omit<StructuredCombatEvent, "id">;
|
||||
|
||||
export const MAX_COMBAT_EVENT_HISTORY = 200;
|
||||
|
||||
export type CombatEventListener = (event: StructuredCombatEvent) => void;
|
||||
|
||||
const combatEventListeners = new Set<CombatEventListener>();
|
||||
|
||||
/** Lossless event stream for recorders; the in-store history remains a small HUD ring. */
|
||||
export function subscribeCombatEvents(listener: CombatEventListener): () => void {
|
||||
combatEventListeners.add(listener);
|
||||
return () => combatEventListeners.delete(listener);
|
||||
}
|
||||
|
||||
function publishCombatEvent(event: StructuredCombatEvent): void {
|
||||
for (const listener of combatEventListeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch {
|
||||
// A metrics consumer must never interrupt authoritative combat.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function appendCombatEvent(
|
||||
events: readonly StructuredCombatEvent[],
|
||||
event: CombatEventInput,
|
||||
@@ -45,5 +65,6 @@ export function appendCombatEvent(
|
||||
overheal: Math.max(0, Number.isFinite(event.overheal) ? event.overheal : 0),
|
||||
absorbed: Math.max(0, Number.isFinite(event.absorbed) ? event.absorbed : 0),
|
||||
});
|
||||
publishCombatEvent(next);
|
||||
return Object.freeze([...events, next].slice(-Math.max(1, Math.trunc(maximum))));
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MAX_COMBAT_EVENT_HISTORY,
|
||||
appendCombatEvent,
|
||||
subscribeCombatEvents,
|
||||
type CombatEventInput,
|
||||
type StructuredCombatEvent,
|
||||
} from "./combatEvents";
|
||||
|
||||
function eventInput(index: number): CombatEventInput {
|
||||
return {
|
||||
occurredAt: index * 100,
|
||||
kind: "damage",
|
||||
sourceActorId: "benchmark-source",
|
||||
targetActorId: "benchmark-target",
|
||||
abilityId: "benchmark-strike",
|
||||
school: "physical",
|
||||
rawAmount: index,
|
||||
effectiveAmount: index,
|
||||
overheal: 0,
|
||||
absorbed: 0,
|
||||
critical: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("combat event subscriptions", () => {
|
||||
it("publishes every event while retaining only the bounded HUD history", () => {
|
||||
const published: StructuredCombatEvent[] = [];
|
||||
const unsubscribe = subscribeCombatEvents((event) => published.push(event));
|
||||
let history: readonly StructuredCombatEvent[] = [];
|
||||
const eventCount = MAX_COMBAT_EVENT_HISTORY + 75;
|
||||
|
||||
try {
|
||||
for (let id = 1; id <= eventCount; id += 1) {
|
||||
history = appendCombatEvent(history, eventInput(id), id);
|
||||
}
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
expect(published).toHaveLength(eventCount);
|
||||
expect(published.map((event) => event.id)).toEqual(
|
||||
Array.from({ length: eventCount }, (_, index) => index + 1),
|
||||
);
|
||||
expect(history).toHaveLength(MAX_COMBAT_EVENT_HISTORY);
|
||||
expect(history[0]?.id).toBe(eventCount - MAX_COMBAT_EVENT_HISTORY + 1);
|
||||
expect(history.at(-1)?.id).toBe(eventCount);
|
||||
|
||||
appendCombatEvent(history, eventInput(eventCount + 1), eventCount + 1);
|
||||
expect(published).toHaveLength(eventCount);
|
||||
});
|
||||
|
||||
it("isolates authoritative recording from a failing subscriber", () => {
|
||||
const published: number[] = [];
|
||||
const unsubscribeFailing = subscribeCombatEvents(() => {
|
||||
throw new Error("metrics consumer failed");
|
||||
});
|
||||
const unsubscribeHealthy = subscribeCombatEvents((event) => published.push(event.id));
|
||||
|
||||
try {
|
||||
expect(appendCombatEvent([], eventInput(1), 1)).toHaveLength(1);
|
||||
expect(published).toEqual([1]);
|
||||
} finally {
|
||||
unsubscribeHealthy();
|
||||
unsubscribeFailing();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
interface CombatSimulationTimerState {
|
||||
readonly sharedOnline: boolean;
|
||||
readonly paused: boolean;
|
||||
readonly companionOpen: boolean;
|
||||
readonly documentHidden: boolean;
|
||||
}
|
||||
|
||||
export function combatSimulationTimerShouldRun({
|
||||
sharedOnline,
|
||||
paused,
|
||||
companionOpen,
|
||||
documentHidden,
|
||||
}: CombatSimulationTimerState): boolean {
|
||||
return sharedOnline || (!paused && !companionOpen && !documentHidden);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { abilitiesForClass, defaultActionBarForClass } from "./abilityCatalog";
|
||||
import { defaultActionBindings } from "./actionBindings";
|
||||
import { deriveCharacterStats } from "./wotlkStats";
|
||||
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
|
||||
|
||||
function allocateTalentRanks(name: string, ranks: number): void {
|
||||
const talent = TALENT_NODES.find((node) => node.name === name);
|
||||
@@ -843,6 +844,7 @@ describe("combat store", () => {
|
||||
|
||||
it("allocates the first WotLK talent point at level 10 and clamps settings", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 10 });
|
||||
expect(useCombatStore.getState().settings.masterVolume).toBe(0);
|
||||
const holy = talentNodesForTree("priest-holy")[0];
|
||||
const shadow = talentNodesForTree("priest-shadow")[0];
|
||||
expect(useCombatStore.getState().allocateTalent(holy.id)).toBe(true);
|
||||
@@ -869,12 +871,44 @@ describe("combat store", () => {
|
||||
showMobAggroRanges: true,
|
||||
threatMeterPosition: { x: 0, y: 1 },
|
||||
});
|
||||
useCombatStore.getState().updateSettings({
|
||||
gameplayFrameRate: 30,
|
||||
previewFrameRate: 15,
|
||||
});
|
||||
expect(useCombatStore.getState().settings).toMatchObject({
|
||||
gameplayFrameRate: 30,
|
||||
previewFrameRate: 15,
|
||||
});
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest" });
|
||||
expect(useCombatStore.getState().settings.showThreatMeter).toBe(false);
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "priest",
|
||||
settings: { masterVolume: 0.6 },
|
||||
});
|
||||
expect(useCombatStore.getState().settings.masterVolume).toBe(0.6);
|
||||
useCombatStore.getState().resetSettings();
|
||||
expect(useCombatStore.getState().settings).toEqual(DEFAULT_GAMEPLAY_SETTINGS);
|
||||
});
|
||||
|
||||
it("does not notify subscribers for unchanged positions or settings", () => {
|
||||
let notifications = 0;
|
||||
const unsubscribe = useCombatStore.subscribe(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
useCombatStore.getState().updateSettings({
|
||||
masterVolume: DEFAULT_GAMEPLAY_SETTINGS.masterVolume,
|
||||
gameplayFrameRate: DEFAULT_GAMEPLAY_SETTINGS.gameplayFrameRate,
|
||||
});
|
||||
useCombatStore.getState().resetSettings();
|
||||
expect(notifications).toBe(0);
|
||||
|
||||
useCombatStore.getState().updateSettings({ gameplayFrameRate: 30 });
|
||||
expect(notifications).toBe(1);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("keeps a CoA tree ability uncastable until its exact node is purchased", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "stormbringer", level: 60 });
|
||||
const ability = abilitiesForClass("stormbringer").find((candidate) => (
|
||||
@@ -897,6 +931,40 @@ describe("combat store", () => {
|
||||
expect(useCombatStore.getState().castAbility(ability.id, [0, 0, 0], 3_000).reason).not.toBe("not-learned");
|
||||
});
|
||||
|
||||
it("breaks recursive hit-proc spell cycles in the canonical Felsworn tank build", async () => {
|
||||
const build = (await loadCanonicalBenchmarkBuilds(60, ["felsworn"]))
|
||||
.find((candidate) => candidate.role === "tank")!;
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: build.classId,
|
||||
level: build.level,
|
||||
talentRanks: build.talentRanks,
|
||||
});
|
||||
registerTarget("felsworn-proc-dummy", [10, 0, 0], 1_000_000, 0);
|
||||
|
||||
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
|
||||
definition: expect.objectContaining({ name: "Doomstride (Aura 42)" }),
|
||||
}));
|
||||
const result = useCombatStore.getState().castAbility(
|
||||
"felsworn-sargeron-smite",
|
||||
[0, 0, 0],
|
||||
1,
|
||||
);
|
||||
const state = useCombatStore.getState();
|
||||
const damage = 1_000_000 - state.mobs["felsworn-proc-dummy"].health;
|
||||
const damageAbilityIds = state.combatEvents
|
||||
.filter((event) => event.kind === "damage")
|
||||
.map((event) => event.abilityId);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(damage).toBeGreaterThan(0);
|
||||
expect(damage).toBeLessThan(100_000);
|
||||
expect(damageAbilityIds.filter((abilityId) => abilityId === "coa-triggered-807962"))
|
||||
.toHaveLength(1);
|
||||
expect(state.auras).toContainEqual(expect.objectContaining({
|
||||
definition: expect.objectContaining({ name: "Felwracked (Aura 4)" }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("resets encounter health without erasing earned progression", () => {
|
||||
registerTarget("mob", [2, 0, 0], 1, 100);
|
||||
useCombatStore.getState().damageMob("mob", 5, 1000);
|
||||
|
||||
+124
-47
@@ -1,4 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import { combatNow } from "./combatClock";
|
||||
import { useGameStore } from "./store";
|
||||
import {
|
||||
baseClassFor,
|
||||
@@ -212,6 +213,14 @@ import {
|
||||
normalizeHudPosition,
|
||||
type NormalizedHudPosition,
|
||||
} from "./hudLayout";
|
||||
import {
|
||||
DEFAULT_GAMEPLAY_FRAME_RATE_LIMIT,
|
||||
DEFAULT_PREVIEW_FRAME_RATE_LIMIT,
|
||||
normalizeGameplayFrameRateLimit,
|
||||
normalizePreviewFrameRateLimit,
|
||||
type GameplayFrameRateLimit,
|
||||
type PreviewFrameRateLimit,
|
||||
} from "./frameRateSettings";
|
||||
|
||||
export type MinimapRotation = "north-up" | "player-up";
|
||||
|
||||
@@ -227,6 +236,8 @@ export interface GameplaySettings {
|
||||
readonly masterVolume: number;
|
||||
readonly uiScale: number;
|
||||
readonly minimapRotation: MinimapRotation;
|
||||
readonly gameplayFrameRate: GameplayFrameRateLimit;
|
||||
readonly previewFrameRate: PreviewFrameRateLimit;
|
||||
}
|
||||
|
||||
export const DEFAULT_GAMEPLAY_SETTINGS: GameplaySettings = Object.freeze({
|
||||
@@ -238,9 +249,11 @@ export const DEFAULT_GAMEPLAY_SETTINGS: GameplaySettings = Object.freeze({
|
||||
showFloatingCombatText: true,
|
||||
autoTargetNearest: true,
|
||||
enableScreenShake: true,
|
||||
masterVolume: 0.8,
|
||||
masterVolume: 0,
|
||||
uiScale: 1,
|
||||
minimapRotation: "player-up",
|
||||
gameplayFrameRate: DEFAULT_GAMEPLAY_FRAME_RATE_LIMIT,
|
||||
previewFrameRate: DEFAULT_PREVIEW_FRAME_RATE_LIMIT,
|
||||
});
|
||||
|
||||
/** Prevents a burst of party hits from restarting the same wound clip every frame. */
|
||||
@@ -252,6 +265,8 @@ export const MINIMUM_CORPSE_VISIBLE_MS = 1_500;
|
||||
/** Default proximity pull radii for dungeon enemies, expressed in world units. */
|
||||
export const DEFAULT_MOB_AGGRO_RANGE = 16;
|
||||
export const DEFAULT_BOSS_AGGRO_RANGE = 20;
|
||||
/** Safety ceiling for synchronous proc-to-triggered-spell chains. */
|
||||
const MAX_TRIGGERED_SPELL_CHAIN_DEPTH = 16;
|
||||
|
||||
export interface MobStatus {
|
||||
readonly kind: MobStatusKind;
|
||||
@@ -727,6 +742,8 @@ interface MutableCastWork {
|
||||
gearStats: ItemStats;
|
||||
raceId: RaceId;
|
||||
secondaryClassId: ClassId | null;
|
||||
/** Active synchronous spell/source/target keys used to break proc cycles. */
|
||||
triggeredSpellStack?: string[];
|
||||
}
|
||||
|
||||
let feedbackSequence = 0;
|
||||
@@ -1600,9 +1617,34 @@ function normalizedSettings(
|
||||
minimapRotation: patch.minimapRotation === "north-up" || patch.minimapRotation === "player-up"
|
||||
? patch.minimapRotation
|
||||
: current.minimapRotation,
|
||||
gameplayFrameRate: normalizeGameplayFrameRateLimit(
|
||||
patch.gameplayFrameRate,
|
||||
current.gameplayFrameRate,
|
||||
),
|
||||
previewFrameRate: normalizePreviewFrameRateLimit(
|
||||
patch.previewFrameRate,
|
||||
current.previewFrameRate,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function gameplaySettingsEqual(left: GameplaySettings, right: GameplaySettings): boolean {
|
||||
return left.showMobHealthBars === right.showMobHealthBars
|
||||
&& left.showMobAggroRanges === right.showMobAggroRanges
|
||||
&& left.showTargetFrame === right.showTargetFrame
|
||||
&& left.showThreatMeter === right.showThreatMeter
|
||||
&& left.threatMeterPosition.x === right.threatMeterPosition.x
|
||||
&& left.threatMeterPosition.y === right.threatMeterPosition.y
|
||||
&& left.showFloatingCombatText === right.showFloatingCombatText
|
||||
&& left.autoTargetNearest === right.autoTargetNearest
|
||||
&& left.enableScreenShake === right.enableScreenShake
|
||||
&& left.masterVolume === right.masterVolume
|
||||
&& left.uiScale === right.uiScale
|
||||
&& left.minimapRotation === right.minimapRotation
|
||||
&& left.gameplayFrameRate === right.gameplayFrameRate
|
||||
&& left.previewFrameRate === right.previewFrameRate;
|
||||
}
|
||||
|
||||
function defaultMobStats(
|
||||
playerLevel: number,
|
||||
boss: boolean,
|
||||
@@ -1829,6 +1871,7 @@ function auraDefinitionForAbility(
|
||||
): AuraDefinition {
|
||||
return {
|
||||
...definition,
|
||||
description: definition.description ?? ability.description,
|
||||
icon: definition.icon ?? ability.icon,
|
||||
durationMs,
|
||||
};
|
||||
@@ -1974,15 +2017,30 @@ function executeTriggeredAuraProcsInWork(
|
||||
&& (action.id === "wow335-trigger-spell" || action.id === "rom-trigger-skill")
|
||||
&& typeof action.data?.spellId === "number"
|
||||
) {
|
||||
executeTriggeredSpellInWork(
|
||||
work,
|
||||
action.data.spellId,
|
||||
triggered.sourceId,
|
||||
triggered.event.otherId ?? triggered.targetId,
|
||||
now,
|
||||
0,
|
||||
true,
|
||||
);
|
||||
const spellId = action.data.spellId;
|
||||
const targetId = triggered.event.otherId ?? triggered.targetId;
|
||||
const triggerKey = `${spellId}:${triggered.sourceId}:${targetId}`;
|
||||
const triggeredSpellStack = work.triggeredSpellStack ?? [];
|
||||
if (
|
||||
triggeredSpellStack.length >= MAX_TRIGGERED_SPELL_CHAIN_DEPTH
|
||||
|| triggeredSpellStack.includes(triggerKey)
|
||||
) continue;
|
||||
triggeredSpellStack.push(triggerKey);
|
||||
work.triggeredSpellStack = triggeredSpellStack;
|
||||
try {
|
||||
executeTriggeredSpellInWork(
|
||||
work,
|
||||
spellId,
|
||||
triggered.sourceId,
|
||||
targetId,
|
||||
now,
|
||||
0,
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
triggeredSpellStack.pop();
|
||||
if (triggeredSpellStack.length === 0) work.triggeredSpellStack = undefined;
|
||||
}
|
||||
} else if (
|
||||
action.kind === "custom"
|
||||
&& action.id === "classic-status"
|
||||
@@ -2140,7 +2198,7 @@ function grantMobExperience(
|
||||
work.raceId,
|
||||
work.secondaryClassId,
|
||||
);
|
||||
work.auras = syncPassiveAuras(work.auras, work.abilities, result.level, work.talentRanks, Date.now());
|
||||
work.auras = syncPassiveAuras(work.auras, work.abilities, result.level, work.talentRanks, combatNow());
|
||||
}
|
||||
return scaledAward;
|
||||
}
|
||||
@@ -2696,6 +2754,8 @@ function executeTriggeredSpellInWork(
|
||||
definition: {
|
||||
id: `coa-triggered-shield:${spellId}`,
|
||||
name: ability.name,
|
||||
description: ability.description,
|
||||
icon: ability.icon,
|
||||
disposition: "buff",
|
||||
durationMs: effect.durationMs,
|
||||
absorbs: [{ id: `coa-triggered-shield:${spellId}:pool`, amount }],
|
||||
@@ -3152,7 +3212,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
resourceName: resource.name,
|
||||
abilities,
|
||||
deathKnightRunes: classId === "death-knight" ? createDeathKnightRunes() : [],
|
||||
auras: syncPassiveAuras([], abilities, progression.level, talentRanks, Date.now()),
|
||||
auras: syncPassiveAuras([], abilities, progression.level, talentRanks, combatNow()),
|
||||
talentRanks,
|
||||
talentModifiers,
|
||||
actionBindings: normalizeActionBindings(profile.actionBindings, defaultActionBarForCharacter(classId, secondaryClassId, progression.level)),
|
||||
@@ -3338,7 +3398,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
};
|
||||
}),
|
||||
|
||||
lootMob: (id, now = Date.now()) => {
|
||||
lootMob: (id, now = combatNow()) => {
|
||||
const mob = get().mobs[id];
|
||||
if (!mob || !mob.dead || !mob.hasLoot || mob.looted || mob.despawned) return false;
|
||||
const corpseDespawnAt = Math.max(now, (mob.diedAt ?? now) + MINIMUM_CORPSE_VISIBLE_MS);
|
||||
@@ -3562,12 +3622,18 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const movedWhileCasting = Boolean(cast && !cast.canCastWhileMoving && (
|
||||
(next[0] - cast.origin[0]) ** 2 + (next[2] - cast.origin[2]) ** 2 > 0.04
|
||||
));
|
||||
if (movedWhileCasting) emitPlayerAbilityCancellation(cast!.abilityId, Date.now(), next);
|
||||
const positionUnchanged = (
|
||||
(next[0] - state.playerPosition[0]) ** 2
|
||||
+ (next[1] - state.playerPosition[1]) ** 2
|
||||
+ (next[2] - state.playerPosition[2]) ** 2
|
||||
) <= 1e-8;
|
||||
if (positionUnchanged && !movedWhileCasting) return state;
|
||||
if (movedWhileCasting) emitPlayerAbilityCancellation(cast!.abilityId, combatNow(), next);
|
||||
return {
|
||||
playerPosition: next,
|
||||
activeCast: movedWhileCasting ? null : cast,
|
||||
combatEvents: movedWhileCasting ? recordCombatEvent(state.combatEvents, {
|
||||
occurredAt: Date.now(),
|
||||
occurredAt: combatNow(),
|
||||
kind: "interrupt",
|
||||
sourceActorId: PLAYER_AURA_ENTITY_ID,
|
||||
targetActorId: PLAYER_AURA_ENTITY_ID,
|
||||
@@ -3648,7 +3714,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
};
|
||||
}),
|
||||
|
||||
confirmGroundTarget: (position, now = Date.now()) => {
|
||||
confirmGroundTarget: (position, now = combatNow()) => {
|
||||
const pending = get().pendingGroundTarget;
|
||||
if (!pending) return emptyCastResult("invalid-destination");
|
||||
const destination = position ?? pending.position;
|
||||
@@ -3691,7 +3757,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}));
|
||||
},
|
||||
|
||||
castAbility: (abilityId, requestOrOrigin, legacyNow = Date.now(), legacyCompletingCast) => {
|
||||
castAbility: (abilityId, requestOrOrigin, legacyNow = combatNow(), legacyCompletingCast) => {
|
||||
const request: CastRequest = Array.isArray(requestOrOrigin)
|
||||
? { origin: requestOrOrigin as CombatPosition, now: legacyNow, completingCast: legacyCompletingCast }
|
||||
: (requestOrOrigin as CastRequest | undefined) ?? { now: legacyNow, completingCast: legacyCompletingCast };
|
||||
@@ -4425,6 +4491,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
definition: {
|
||||
id: `shield:${ability.id}`,
|
||||
name: ability.name,
|
||||
description: ability.description,
|
||||
icon: ability.icon,
|
||||
disposition: "buff",
|
||||
durationMs,
|
||||
@@ -5090,7 +5157,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return get().castAbility(abilityId, origin, now);
|
||||
},
|
||||
|
||||
damageMob: (id, amount, now = Date.now(), source, school = "physical", rollContext) => {
|
||||
damageMob: (id, amount, now = combatNow(), source, school = "physical", rollContext) => {
|
||||
const state = get();
|
||||
const threatSource = source ?? playerThreatSource(state);
|
||||
const progression = progressionFromState(state);
|
||||
@@ -5206,7 +5273,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return { mobs };
|
||||
}),
|
||||
|
||||
tauntMob: (id, source, durationMs, now = Date.now()) => {
|
||||
tauntMob: (id, source, durationMs, now = combatNow()) => {
|
||||
const target = get().mobs[id];
|
||||
if (!target || target.dead || durationMs <= 0) return false;
|
||||
const previous = target.tauntDiminishingReturns;
|
||||
@@ -5257,7 +5324,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return effectiveDurationMs > 0;
|
||||
},
|
||||
|
||||
interruptMob: (id, sourceAbilityId, lockoutMs, now = Date.now()) => {
|
||||
interruptMob: (id, sourceAbilityId, lockoutMs, now = combatNow()) => {
|
||||
let interrupted = false;
|
||||
set((state) => {
|
||||
const mob = state.mobs[id];
|
||||
@@ -5273,7 +5340,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return interrupted;
|
||||
},
|
||||
|
||||
controlMob: (id, sourceAbilityId, status, durationMs, now = Date.now()) => {
|
||||
controlMob: (id, sourceAbilityId, status, durationMs, now = combatNow()) => {
|
||||
let controlled = false;
|
||||
set((state) => {
|
||||
const mob = state.mobs[id];
|
||||
@@ -5331,7 +5398,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
},
|
||||
|
||||
healActor: (id, amount, request) => {
|
||||
const now = request.now ?? Date.now();
|
||||
const now = request.now ?? combatNow();
|
||||
const state = get();
|
||||
const sourceAuras = aurasForEntity(state.auras, request.sourceActorId);
|
||||
const targetAuras = aurasForEntity(state.auras, id);
|
||||
@@ -5414,7 +5481,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return resolution;
|
||||
},
|
||||
|
||||
reviveActor: (id, percentMaxHealth, sourceActorId, abilityId, now = Date.now()) => {
|
||||
reviveActor: (id, percentMaxHealth, sourceActorId, abilityId, now = combatNow()) => {
|
||||
const state = get();
|
||||
const fraction = clamp(percentMaxHealth, 0.01, 1);
|
||||
if (id === PLAYER_AURA_ENTITY_ID) {
|
||||
@@ -5494,7 +5561,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return restored;
|
||||
},
|
||||
|
||||
restoreActorResource: (id, resource, amount, percentage = false, sourceActorId = id, abilityId, now = Date.now()) => {
|
||||
restoreActorResource: (id, resource, amount, percentage = false, sourceActorId = id, abilityId, now = combatNow()) => {
|
||||
const state = get();
|
||||
const actor = state.actors[id];
|
||||
if (!actor) return 0;
|
||||
@@ -5533,7 +5600,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return restored;
|
||||
},
|
||||
|
||||
shieldActor: (id, amount, durationMs, sourceActorId, ability, now = Date.now()) => {
|
||||
shieldActor: (id, amount, durationMs, sourceActorId, ability, now = combatNow()) => {
|
||||
const state = get();
|
||||
const absorb = Math.max(0, Math.round(amount));
|
||||
if (absorb <= 0 || !state.actors[id]) return 0;
|
||||
@@ -5541,6 +5608,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
definition: {
|
||||
id: `shield:${sourceActorId}:${ability.id}`,
|
||||
name: ability.name,
|
||||
description: ability.description,
|
||||
icon: ability.icon,
|
||||
disposition: "buff",
|
||||
durationMs: Math.max(1, durationMs),
|
||||
@@ -5570,7 +5638,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return absorb;
|
||||
},
|
||||
|
||||
applyActorAura: (sourceActorId, targetActorId, ability, effect, now = Date.now()) => {
|
||||
applyActorAura: (sourceActorId, targetActorId, ability, effect, now = combatNow()) => {
|
||||
const state = get();
|
||||
if (!state.actors[sourceActorId] || (!state.actors[targetActorId] && !state.mobs[targetActorId])) return false;
|
||||
const durationMs = effect.aura.durationMs;
|
||||
@@ -5600,7 +5668,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return true;
|
||||
},
|
||||
|
||||
applyTimedActorEffect: (sourceActorId, targetActorId, ability, effect, now = Date.now()) => {
|
||||
applyTimedActorEffect: (sourceActorId, targetActorId, ability, effect, now = combatNow()) => {
|
||||
const state = get();
|
||||
if (!state.actors[sourceActorId]) return false;
|
||||
if (effect.kind === "dot" && (!state.mobs[targetActorId] || state.mobs[targetActorId].dead)) return false;
|
||||
@@ -5637,7 +5705,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return true;
|
||||
},
|
||||
|
||||
summonActor: (ownerActorId, ability, effect, position, targetId = null, now = Date.now()) => {
|
||||
summonActor: (ownerActorId, ability, effect, position, targetId = null, now = combatNow()) => {
|
||||
const state = get();
|
||||
const owner = state.actors[ownerActorId];
|
||||
if (!owner?.alive) return null;
|
||||
@@ -5671,7 +5739,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return id;
|
||||
},
|
||||
|
||||
createActorAreaEffect: (sourceActorId, ability, position, now = Date.now()) => {
|
||||
createActorAreaEffect: (sourceActorId, ability, position, now = combatNow()) => {
|
||||
const state = get();
|
||||
const sourceActor = state.actors[sourceActorId];
|
||||
if (!sourceActor?.alive) return null;
|
||||
@@ -5716,7 +5784,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
|
||||
damagePlayer: (amount, school = "physical", attackerLevel) => {
|
||||
const state = get();
|
||||
const now = Date.now();
|
||||
const now = combatNow();
|
||||
const mitigated = damageAfterEquipmentMitigation(
|
||||
talentAdjustedIncomingDamage(amount, state.talentModifiers),
|
||||
state.gearStats,
|
||||
@@ -5775,6 +5843,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
definition: {
|
||||
id: "rom-state-revival-charge",
|
||||
name: "Revival",
|
||||
description: "Each stack makes Revival restore 5% of maximum health. Casting Revival consumes every stack.",
|
||||
disposition: "buff",
|
||||
durationMs: null,
|
||||
maxStacks: 10,
|
||||
@@ -5831,7 +5900,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
targetId: id,
|
||||
amount: requested,
|
||||
school: "physical",
|
||||
now: Date.now(),
|
||||
now: combatNow(),
|
||||
});
|
||||
applied = Math.min(summon.health, Math.max(0, Math.round(absorbed.remainingDamage)));
|
||||
const health = summon.health - applied;
|
||||
@@ -5846,7 +5915,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
summons,
|
||||
actors,
|
||||
combatEvents: recordCombatEvent(state.combatEvents, {
|
||||
occurredAt: Date.now(),
|
||||
occurredAt: combatNow(),
|
||||
kind: "damage",
|
||||
sourceActorId: "external",
|
||||
targetActorId: id,
|
||||
@@ -5878,12 +5947,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
subjectId: PLAYER_AURA_ENTITY_ID,
|
||||
otherId: PLAYER_AURA_ENTITY_ID,
|
||||
amount: healed,
|
||||
}, Date.now(), ({ aura, proc }) => stableCombatUnit(`${aura.instanceId}:${proc.id}:heal:${state.health}`));
|
||||
}, combatNow(), ({ aura, proc }) => stableCombatUnit(`${aura.instanceId}:${proc.id}:heal:${state.health}`));
|
||||
set({
|
||||
auras: procResult.auras,
|
||||
health: state.health + healed,
|
||||
combatEvents: recordCombatEvent(state.combatEvents, {
|
||||
occurredAt: Date.now(),
|
||||
occurredAt: combatNow(),
|
||||
kind: "healing",
|
||||
sourceActorId: PLAYER_AURA_ENTITY_ID,
|
||||
targetActorId: PLAYER_AURA_ENTITY_ID,
|
||||
@@ -5920,7 +5989,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
set((state) => ({
|
||||
activeCast: null,
|
||||
combatEvents: recordCombatEvent(state.combatEvents, {
|
||||
occurredAt: Date.now(),
|
||||
occurredAt: combatNow(),
|
||||
kind: "interrupt",
|
||||
sourceActorId: PLAYER_AURA_ENTITY_ID,
|
||||
targetActorId: PLAYER_AURA_ENTITY_ID,
|
||||
@@ -5936,11 +6005,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
playerAnimationEvent: nextAnimationEvent("ability-cancel", cast.abilityId),
|
||||
feedback: nextFeedback("error", message, { abilityId: cast.abilityId, targetId: cast.targetId ?? undefined }),
|
||||
}));
|
||||
emitPlayerAbilityCancellation(cast.abilityId, Date.now(), get().playerPosition);
|
||||
emitPlayerAbilityCancellation(cast.abilityId, combatNow(), get().playerPosition);
|
||||
return true;
|
||||
},
|
||||
|
||||
engageNearbyMobs: (now = Date.now(), playerPosition) => {
|
||||
engageNearbyMobs: (now = combatNow(), playerPosition) => {
|
||||
const state = get();
|
||||
const runtimeState = playerPosition
|
||||
? { ...state, playerPosition }
|
||||
@@ -5960,7 +6029,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return engagedGroups;
|
||||
},
|
||||
|
||||
advanceMobCombat: (now = Date.now(), playerPosition) => {
|
||||
advanceMobCombat: (now = combatNow(), playerPosition) => {
|
||||
const state = get();
|
||||
const runtimeState = playerPosition
|
||||
? { ...state, playerPosition }
|
||||
@@ -6237,6 +6306,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
definition: {
|
||||
id: `enemy-control:${id}:${attack.id}:${effect.mechanic}`,
|
||||
name: `${attack.name}: ${effect.mechanic}`,
|
||||
description: `${attack.name} inflicts ${effect.mechanic}, preventing normal actions until the effect ends.`,
|
||||
disposition: "debuff",
|
||||
durationMs: effect.durationMs,
|
||||
dispelCategory: "magic",
|
||||
@@ -6828,7 +6898,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return appliedDamage;
|
||||
},
|
||||
|
||||
tick: (deltaSeconds, now = Date.now()) => {
|
||||
tick: (deltaSeconds, now = combatNow()) => {
|
||||
const partyHotTicks: Array<{
|
||||
memberId: string;
|
||||
amount: number;
|
||||
@@ -7687,7 +7757,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
selectedTargetId: null,
|
||||
selectedFriendlyActorId: null,
|
||||
effects: [],
|
||||
auras: syncPassiveAuras([], state.abilities, state.level, state.talentRanks, Date.now()),
|
||||
auras: syncPassiveAuras([], state.abilities, state.level, state.talentRanks, combatNow()),
|
||||
summons: [],
|
||||
areaEffects: [],
|
||||
pendingGroundTarget: null,
|
||||
@@ -7746,7 +7816,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
|
||||
maxResource,
|
||||
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource),
|
||||
auras: syncPassiveAuras(state.auras, state.abilities, state.level, result.ranks, Date.now()),
|
||||
auras: syncPassiveAuras(state.auras, state.abilities, state.level, result.ranks, combatNow()),
|
||||
feedback: nextFeedback("talent", "Conquest node rank allocated."),
|
||||
});
|
||||
return true;
|
||||
@@ -7778,7 +7848,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
|
||||
maxResource,
|
||||
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource),
|
||||
auras: syncPassiveAuras(state.auras, state.abilities, state.level, talentRanks, Date.now()),
|
||||
auras: syncPassiveAuras(state.auras, state.abilities, state.level, talentRanks, combatNow()),
|
||||
feedback: nextFeedback("talent", "Talent points reset."),
|
||||
};
|
||||
}),
|
||||
@@ -7804,8 +7874,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
),
|
||||
})),
|
||||
|
||||
updateSettings: (patch) => set((state) => ({ settings: normalizedSettings(state.settings, patch) })),
|
||||
resetSettings: () => set({ settings: DEFAULT_GAMEPLAY_SETTINGS }),
|
||||
updateSettings: (patch) => set((state) => {
|
||||
const settings = normalizedSettings(state.settings, patch);
|
||||
return gameplaySettingsEqual(settings, state.settings) ? state : { settings };
|
||||
}),
|
||||
resetSettings: () => set((state) => (
|
||||
gameplaySettingsEqual(state.settings, DEFAULT_GAMEPLAY_SETTINGS)
|
||||
? state
|
||||
: { settings: DEFAULT_GAMEPLAY_SETTINGS }
|
||||
)),
|
||||
}));
|
||||
|
||||
export function selectedMob(state: CombatState): MobCombatState | null {
|
||||
@@ -7815,14 +7892,14 @@ export function selectedMob(state: CombatState): MobCombatState | null {
|
||||
export function cooldownRemainingMs(
|
||||
state: Pick<CombatState, "cooldowns">,
|
||||
abilityId: string,
|
||||
now = Date.now(),
|
||||
now = combatNow(),
|
||||
): number {
|
||||
return Math.max(0, (state.cooldowns[abilityId] ?? 0) - now);
|
||||
}
|
||||
|
||||
export function globalCooldownRemainingMs(
|
||||
state: Pick<CombatState, "globalCooldownEndsAt">,
|
||||
now = Date.now(),
|
||||
now = combatNow(),
|
||||
): number {
|
||||
return Math.max(0, state.globalCooldownEndsAt - now);
|
||||
}
|
||||
@@ -7842,7 +7919,7 @@ useCombatStore.subscribe((state) => {
|
||||
collectingBossLoot = true;
|
||||
try {
|
||||
for (const boss of lootableBosses) {
|
||||
useCombatStore.getState().lootMob(boss.id, boss.diedAt ?? Date.now());
|
||||
useCombatStore.getState().lootMob(boss.id, boss.diedAt ?? combatNow());
|
||||
}
|
||||
} finally {
|
||||
collectingBossLoot = false;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
GAMEPLAY_FRAME_RATE_LIMITS,
|
||||
PREVIEW_FRAME_RATE_LIMITS,
|
||||
frameRateLimitLabel,
|
||||
nextFrameRateLimit,
|
||||
normalizeGameplayFrameRateLimit,
|
||||
normalizePreviewFrameRateLimit,
|
||||
numericFrameRateLimit,
|
||||
} from "./frameRateSettings";
|
||||
|
||||
describe("frame-rate settings", () => {
|
||||
it("normalizes persisted gameplay and preview limits", () => {
|
||||
expect(normalizeGameplayFrameRateLimit(30)).toBe(30);
|
||||
expect(normalizeGameplayFrameRateLimit("display")).toBe("display");
|
||||
expect(normalizeGameplayFrameRateLimit(144)).toBe(60);
|
||||
expect(normalizePreviewFrameRateLimit(15)).toBe(15);
|
||||
expect(normalizePreviewFrameRateLimit("invalid", 60)).toBe(60);
|
||||
});
|
||||
|
||||
it("cycles through each menu order", () => {
|
||||
expect(nextFrameRateLimit(30, GAMEPLAY_FRAME_RATE_LIMITS)).toBe(60);
|
||||
expect(nextFrameRateLimit("display", GAMEPLAY_FRAME_RATE_LIMITS)).toBe(30);
|
||||
expect(nextFrameRateLimit(30, PREVIEW_FRAME_RATE_LIMITS)).toBe(60);
|
||||
});
|
||||
|
||||
it("formats and resolves capped versus display-refresh modes", () => {
|
||||
expect(frameRateLimitLabel(60)).toBe("60 FPS");
|
||||
expect(frameRateLimitLabel("display")).toBe("Display refresh");
|
||||
expect(numericFrameRateLimit(30)).toBe(30);
|
||||
expect(numericFrameRateLimit("display")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
export type GameplayFrameRateLimit = 30 | 60 | "display";
|
||||
export type PreviewFrameRateLimit = 15 | 30 | 60 | "display";
|
||||
|
||||
export const GAMEPLAY_FRAME_RATE_LIMITS: readonly GameplayFrameRateLimit[] = [30, 60, "display"];
|
||||
export const PREVIEW_FRAME_RATE_LIMITS: readonly PreviewFrameRateLimit[] = [15, 30, 60, "display"];
|
||||
|
||||
export const DEFAULT_GAMEPLAY_FRAME_RATE_LIMIT: GameplayFrameRateLimit = 60;
|
||||
export const DEFAULT_PREVIEW_FRAME_RATE_LIMIT: PreviewFrameRateLimit = 30;
|
||||
|
||||
export function normalizeGameplayFrameRateLimit(
|
||||
value: unknown,
|
||||
fallback: GameplayFrameRateLimit = DEFAULT_GAMEPLAY_FRAME_RATE_LIMIT,
|
||||
): GameplayFrameRateLimit {
|
||||
return value === 30 || value === 60 || value === "display" ? value : fallback;
|
||||
}
|
||||
|
||||
export function normalizePreviewFrameRateLimit(
|
||||
value: unknown,
|
||||
fallback: PreviewFrameRateLimit = DEFAULT_PREVIEW_FRAME_RATE_LIMIT,
|
||||
): PreviewFrameRateLimit {
|
||||
return value === 15 || value === 30 || value === 60 || value === "display" ? value : fallback;
|
||||
}
|
||||
|
||||
export function nextFrameRateLimit<T extends number | "display">(
|
||||
current: T,
|
||||
order: readonly T[],
|
||||
): T {
|
||||
const index = order.indexOf(current);
|
||||
return order[(index + 1) % order.length] ?? order[0] ?? current;
|
||||
}
|
||||
|
||||
export function frameRateLimitLabel(limit: number | "display"): string {
|
||||
return limit === "display" ? "Display refresh" : `${limit} FPS`;
|
||||
}
|
||||
|
||||
export function numericFrameRateLimit(limit: number | "display"): number | null {
|
||||
return limit === "display" ? null : limit;
|
||||
}
|
||||
Reference in new issue
Block a user