Release Healer Man 0.1.9

This commit is contained in:
phenom committed 2026-08-30 14:16:56 -04:00
1 parent c245a199b6
commit d060296ade
74 files changed
+5901 -334

No files matched your search

+33
View File
@@ -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
View File
@@ -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",
]);
});
});
+407
View File
@@ -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;
}
+220
View File
@@ -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 };
}
}
+370
View File
@@ -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 })));
}
}
+170
View File
@@ -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);
});
});
+473
View File
@@ -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);
});
+435
View File
@@ -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);
}
});
});
+215
View File
@@ -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;
}
+156
View File
@@ -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[];
}
+30
View File
@@ -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]);
+20
View File
@@ -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);
}
+70
View File
@@ -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
View File
@@ -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);
+2
View File
@@ -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;
+39
View File
@@ -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();
}
});
});
+21
View File
@@ -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;
};
}
+21
View File
@@ -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))));
}
+68
View File
@@ -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();
}
});
});
+15
View File
@@ -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);
}
+68
View File
@@ -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
View File
@@ -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;
+33
View File
@@ -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();
});
});
+38
View File
@@ -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;
}