Release Healer Man 0.1.9
This commit is contained in:
1 parent
c245a199b6
commit
d060296ade
74 files changed
+5901
-334
No files matched your search
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CLASSES } from "../../app/characterCatalog";
|
||||
import { partyRolesForClass } from "../partyRoles";
|
||||
import { runtimeNormalizeTalentRanks } from "../talentRuntimeCatalog";
|
||||
import { loadCanonicalBenchmarkBuilds } from "./benchmarkBuilds";
|
||||
|
||||
describe("canonical benchmark builds", () => {
|
||||
it("covers every declared class-role pair exactly once with legal ranks", async () => {
|
||||
const builds = await loadCanonicalBenchmarkBuilds();
|
||||
const expectedPairs = CLASSES.flatMap((definition) => (
|
||||
partyRolesForClass(definition.id).map((role) => `${definition.id}:${role}`)
|
||||
));
|
||||
const actualPairs = builds.map((build) => `${build.classId}:${build.role}`);
|
||||
|
||||
expect(builds).toHaveLength(77);
|
||||
expect(new Set(actualPairs).size).toBe(builds.length);
|
||||
expect(new Set(actualPairs)).toEqual(new Set(expectedPairs));
|
||||
for (const build of builds) {
|
||||
expect(build.level).toBe(60);
|
||||
expect(build.gearPresetId).toBe("coefficient-empty");
|
||||
expect(build.inventory).toEqual([]);
|
||||
expect(build.equipment).toEqual({});
|
||||
expect(runtimeNormalizeTalentRanks(build.classId, build.level, build.talentRanks)).toEqual(build.talentRanks);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it("filters classes without changing their canonical definitions", async () => {
|
||||
const builds = await loadCanonicalBenchmarkBuilds(60, ["mage", "rom-priest"]);
|
||||
|
||||
expect(builds.map((build) => `${build.classId}:${build.role}`)).toEqual([
|
||||
"mage:damage",
|
||||
"rom-priest:healer",
|
||||
"rom-priest:damage",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,407 @@
|
||||
import {
|
||||
BASE_CLASS_IDS,
|
||||
CLASSES,
|
||||
COA_CLASS_IDS,
|
||||
ROM_CLASS_IDS,
|
||||
contentCategoryForClass,
|
||||
type BaseClassId,
|
||||
type ClassId,
|
||||
type CoaClassId,
|
||||
type RomClassId,
|
||||
} from "../../app/characterCatalog";
|
||||
import {
|
||||
abilitiesForClass,
|
||||
preloadAbilityCatalogsForClasses,
|
||||
type AbilityAiHints,
|
||||
type AbilityDefinition,
|
||||
} from "../abilityCatalog";
|
||||
import { aiHintsForAbility } from "../abilityRules";
|
||||
import type { EquipmentAssignments } from "../equipment";
|
||||
import { partyRolesForClass, type PartyRole } from "../partyRoles";
|
||||
import { runtimePartyTalentRanksForLevel } from "../partyTalentRuntime";
|
||||
import { clampPlayerLevel } from "../progression";
|
||||
import {
|
||||
runtimeAllocateTalentPoint,
|
||||
runtimeNormalizeTalentRanks,
|
||||
type RuntimeTalentRanks,
|
||||
} from "../talentRuntimeCatalog";
|
||||
import {
|
||||
loadTalentUiCatalog,
|
||||
type TalentUiCatalog,
|
||||
type TalentUiNode,
|
||||
type TalentUiTree,
|
||||
} from "../talentUiCatalog";
|
||||
import type { BenchmarkBuildDefinition } from "./benchmarkTypes";
|
||||
|
||||
export const CANONICAL_BENCHMARK_LEVEL = 60;
|
||||
export const CANONICAL_BENCHMARK_GEAR_PRESET_ID = "coefficient-empty";
|
||||
|
||||
const EMPTY_INVENTORY: readonly never[] = Object.freeze([]);
|
||||
const EMPTY_EQUIPMENT: EquipmentAssignments = Object.freeze({});
|
||||
const EMPTY_TALENT_RANKS: RuntimeTalentRanks = Object.freeze({});
|
||||
|
||||
const BASE_CLASS_ID_SET: ReadonlySet<ClassId> = new Set(BASE_CLASS_IDS);
|
||||
const COA_CLASS_ID_SET: ReadonlySet<ClassId> = new Set(COA_CLASS_IDS);
|
||||
const ROM_CLASS_ID_SET: ReadonlySet<ClassId> = new Set(ROM_CLASS_IDS);
|
||||
|
||||
const WOW_ORIENTATION_BY_ROLE: Readonly<
|
||||
Record<BaseClassId, Readonly<Partial<Record<PartyRole, string>>>>
|
||||
> = Object.freeze({
|
||||
warrior: { tank: "Protection", damage: "Fury" },
|
||||
paladin: { tank: "Protection", healer: "Holy", damage: "Retribution" },
|
||||
hunter: { damage: "Marksmanship" },
|
||||
rogue: { damage: "Combat" },
|
||||
priest: { healer: "Holy", damage: "Shadow" },
|
||||
"death-knight": { tank: "Blood", damage: "Frost" },
|
||||
shaman: { healer: "Restoration", damage: "Elemental" },
|
||||
mage: { damage: "Fire" },
|
||||
warlock: { damage: "Affliction" },
|
||||
druid: { tank: "Feral (Cat)", healer: "Restoration", damage: "Balance" },
|
||||
});
|
||||
|
||||
type AbilityAiCategory = AbilityAiHints["categories"][number];
|
||||
|
||||
/**
|
||||
* The large gaps keep the primary responsibility of a role ahead of merely
|
||||
* having more utility buttons. These are only selection weights; allocation
|
||||
* legality and point costs remain entirely owned by the runtime talent system.
|
||||
*/
|
||||
const COA_ROLE_CATEGORY_WEIGHTS: Readonly<
|
||||
Record<PartyRole, Readonly<Partial<Record<AbilityAiCategory, number>>>>
|
||||
> = Object.freeze({
|
||||
tank: Object.freeze({
|
||||
taunt: 1_000,
|
||||
mitigation: 500,
|
||||
control: 50,
|
||||
interrupt: 50,
|
||||
heal: 20,
|
||||
"emergency-heal": 20,
|
||||
dispel: 15,
|
||||
debuff: 10,
|
||||
execute: 10,
|
||||
damage: 10,
|
||||
}),
|
||||
healer: Object.freeze({
|
||||
"emergency-heal": 800,
|
||||
"area-heal": 700,
|
||||
heal: 500,
|
||||
mitigation: 100,
|
||||
dispel: 80,
|
||||
resurrection: 60,
|
||||
control: 20,
|
||||
interrupt: 15,
|
||||
debuff: 5,
|
||||
damage: 2,
|
||||
}),
|
||||
damage: Object.freeze({
|
||||
execute: 200,
|
||||
damage: 100,
|
||||
debuff: 40,
|
||||
interrupt: 30,
|
||||
control: 20,
|
||||
mitigation: 3,
|
||||
}),
|
||||
});
|
||||
|
||||
interface CoaRoleTextPattern {
|
||||
readonly pattern: RegExp;
|
||||
readonly weight: number;
|
||||
}
|
||||
|
||||
// CoA specialization trees contain many passive nodes with no executable
|
||||
// ability (and consequently no AbilityAiHints). Catalog text is a stable
|
||||
// secondary signal for those nodes; executable ability hints still contribute
|
||||
// directly whenever the runtime catalog provides them.
|
||||
const COA_ROLE_TEXT_PATTERNS: Readonly<Record<PartyRole, readonly CoaRoleTextPattern[]>> = Object.freeze({
|
||||
tank: Object.freeze([
|
||||
{ pattern: /\b(?:taunt|threat)\b/i, weight: 300 },
|
||||
{ pattern: /\b(?:damage (?:you )?take|damage taken|incoming damage|less damage)\b/i, weight: 160 },
|
||||
{ pattern: /\b(?:armor|block|parry|dodge|deflect)\b/i, weight: 90 },
|
||||
{ pattern: /\b(?:absorb|shield|barrier)\b/i, weight: 70 },
|
||||
{ pattern: /\b(?:maximum health|max health|stamina)\b/i, weight: 50 },
|
||||
{ pattern: /\b(?:protect|guard)\w*\b/i, weight: 35 },
|
||||
{ pattern: /\b(?:resistan\w*|immune)\b/i, weight: 20 },
|
||||
{ pattern: /\b(?:healing received|heal(?:s|ing)? you|restore\w* health)\b/i, weight: 15 },
|
||||
]),
|
||||
healer: Object.freeze([
|
||||
{ pattern: /\b(?:resurrect|revive)\w*\b/i, weight: 200 },
|
||||
{ pattern: /\b(?:heal|healing|restore health|recovery)\w*\b/i, weight: 100 },
|
||||
{ pattern: /\b(?:dispel|cleanse|purif)\w*\b/i, weight: 60 },
|
||||
{ pattern: /\b(?:absorb|shield)\w*\b/i, weight: 30 },
|
||||
{ pattern: /\b(?:ally|allies|party member)\b/i, weight: 25 },
|
||||
]),
|
||||
damage: Object.freeze([
|
||||
{ pattern: /\b(?:damage|damaging)\w*\b/i, weight: 25 },
|
||||
{ pattern: /\b(?:attack power|spell power|critical strike|critical damage|haste)\b/i, weight: 12 },
|
||||
{ pattern: /\b(?:enemy|enemies)\b/i, weight: 5 },
|
||||
]),
|
||||
});
|
||||
|
||||
function isBaseClassId(classId: ClassId): classId is BaseClassId {
|
||||
return BASE_CLASS_ID_SET.has(classId);
|
||||
}
|
||||
|
||||
function isCoaClassId(classId: ClassId): classId is CoaClassId {
|
||||
return COA_CLASS_ID_SET.has(classId);
|
||||
}
|
||||
|
||||
function isRomClassId(classId: ClassId): classId is RomClassId {
|
||||
return ROM_CLASS_ID_SET.has(classId);
|
||||
}
|
||||
|
||||
function benchmarkBuildId(classId: ClassId, role: PartyRole, level: number): string {
|
||||
return `canonical-${contentCategoryForClass(classId)}-${classId}-${role}-level-${level}`;
|
||||
}
|
||||
|
||||
function commonBuildFields(classId: ClassId, role: PartyRole, level: number) {
|
||||
return {
|
||||
id: benchmarkBuildId(classId, role, level),
|
||||
source: contentCategoryForClass(classId),
|
||||
classId,
|
||||
secondaryClassId: null,
|
||||
role,
|
||||
level,
|
||||
inventory: EMPTY_INVENTORY,
|
||||
equipment: EMPTY_EQUIPMENT,
|
||||
gearPresetId: CANONICAL_BENCHMARK_GEAR_PRESET_ID,
|
||||
} as const;
|
||||
}
|
||||
|
||||
function wowBuild(
|
||||
classId: BaseClassId,
|
||||
role: PartyRole,
|
||||
level: number,
|
||||
): BenchmarkBuildDefinition {
|
||||
const orientation = WOW_ORIENTATION_BY_ROLE[classId][role];
|
||||
if (!orientation) {
|
||||
throw new Error(`No canonical WoW talent orientation exists for ${classId}:${role}.`);
|
||||
}
|
||||
return Object.freeze({
|
||||
...commonBuildFields(classId, role, level),
|
||||
orientation,
|
||||
talentRanks: runtimePartyTalentRanksForLevel(classId, orientation, level),
|
||||
});
|
||||
}
|
||||
|
||||
function isClassTree(tree: TalentUiTree): boolean {
|
||||
return tree.name.trim().toLowerCase() === "class";
|
||||
}
|
||||
|
||||
function coaNodeAbilities(classId: CoaClassId): ReadonlyMap<string, readonly AbilityDefinition[]> {
|
||||
const mutable = new Map<string, AbilityDefinition[]>();
|
||||
for (const ability of abilitiesForClass(classId)) {
|
||||
if (ability.talentEntryId === undefined) continue;
|
||||
const nodeId = `coa-entry-${ability.talentEntryId}`;
|
||||
const existing = mutable.get(nodeId);
|
||||
if (existing) existing.push(ability);
|
||||
else mutable.set(nodeId, [ability]);
|
||||
}
|
||||
return mutable;
|
||||
}
|
||||
|
||||
function coaAbilityRoleScore(ability: AbilityDefinition, role: PartyRole): number {
|
||||
const hints = aiHintsForAbility(ability);
|
||||
const weights = COA_ROLE_CATEGORY_WEIGHTS[role];
|
||||
const categoryScore = hints.categories.reduce(
|
||||
(total, category) => total + (weights[category] ?? 0),
|
||||
0,
|
||||
);
|
||||
if (categoryScore <= 0) return 0;
|
||||
const priority = Number.isFinite(hints.priority)
|
||||
? Math.max(0, Math.min(100, hints.priority ?? 0))
|
||||
: 0;
|
||||
return categoryScore + priority;
|
||||
}
|
||||
|
||||
function coaNodeRoleScore(
|
||||
nodeId: string,
|
||||
role: PartyRole,
|
||||
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
|
||||
): number {
|
||||
return (abilitiesByNode.get(nodeId) ?? []).reduce(
|
||||
(highest, ability) => Math.max(highest, coaAbilityRoleScore(ability, role)),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function coaNodeTextRoleScore(node: TalentUiNode, role: PartyRole): number {
|
||||
const searchable = `${node.name} ${node.description}`;
|
||||
return COA_ROLE_TEXT_PATTERNS[role].reduce(
|
||||
(total, entry) => total + (entry.pattern.test(searchable) ? entry.weight : 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function coaNodeSelectionScore(
|
||||
node: TalentUiNode,
|
||||
role: PartyRole,
|
||||
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
|
||||
): number {
|
||||
return coaNodeRoleScore(node.id, role, abilitiesByNode) + coaNodeTextRoleScore(node, role);
|
||||
}
|
||||
|
||||
function coaTreeRoleScore(
|
||||
tree: TalentUiTree,
|
||||
catalog: TalentUiCatalog,
|
||||
role: PartyRole,
|
||||
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
|
||||
): number {
|
||||
return catalog.nodes.reduce((total, node) => (
|
||||
node.treeId === tree.id
|
||||
? total + coaNodeSelectionScore(node, role, abilitiesByNode) * node.maxRank
|
||||
: total
|
||||
), 0);
|
||||
}
|
||||
|
||||
function preferredCoaTree(
|
||||
catalog: TalentUiCatalog,
|
||||
role: PartyRole,
|
||||
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
|
||||
): TalentUiTree {
|
||||
const specializationTrees = catalog.trees.filter((tree) => !isClassTree(tree));
|
||||
const candidates = specializationTrees.length > 0 ? specializationTrees : catalog.trees;
|
||||
const catalogOrder = new Map(catalog.trees.map((tree, index) => [tree.id, index]));
|
||||
const selected = [...candidates].sort((left, right) => (
|
||||
coaTreeRoleScore(right, catalog, role, abilitiesByNode)
|
||||
- coaTreeRoleScore(left, catalog, role, abilitiesByNode)
|
||||
|| (catalogOrder.get(left.id) ?? 0) - (catalogOrder.get(right.id) ?? 0)
|
||||
|| left.id.localeCompare(right.id)
|
||||
))[0];
|
||||
if (!selected) throw new Error(`No CoA talent trees exist for ${catalog.classId}.`);
|
||||
return selected;
|
||||
}
|
||||
|
||||
function orderedCoaAllocationCandidates(
|
||||
catalog: TalentUiCatalog,
|
||||
preferredTree: TalentUiTree,
|
||||
role: PartyRole,
|
||||
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
|
||||
): readonly TalentUiNode[] {
|
||||
const classTreeIds = new Set(catalog.trees.filter(isClassTree).map((tree) => tree.id));
|
||||
const treePriority = (node: TalentUiNode) => (
|
||||
node.treeId === preferredTree.id ? 0 : classTreeIds.has(node.treeId) ? 1 : 2
|
||||
);
|
||||
return catalog.nodes
|
||||
.filter((node) => node.treeId === preferredTree.id || classTreeIds.has(node.treeId))
|
||||
.sort((left, right) => (
|
||||
treePriority(left) - treePriority(right)
|
||||
|| coaNodeSelectionScore(right, role, abilitiesByNode)
|
||||
- coaNodeSelectionScore(left, role, abilitiesByNode)
|
||||
|| left.row - right.row
|
||||
|| left.column - right.column
|
||||
|| left.index - right.index
|
||||
|| left.id.localeCompare(right.id)
|
||||
));
|
||||
}
|
||||
|
||||
function allocateCoaTalentRanks(
|
||||
classId: CoaClassId,
|
||||
catalog: TalentUiCatalog,
|
||||
preferredTree: TalentUiTree,
|
||||
role: PartyRole,
|
||||
level: number,
|
||||
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
|
||||
): RuntimeTalentRanks {
|
||||
const candidates = orderedCoaAllocationCandidates(
|
||||
catalog,
|
||||
preferredTree,
|
||||
role,
|
||||
abilitiesByNode,
|
||||
);
|
||||
const successfulRequests: Record<string, number> = {};
|
||||
let ranks: RuntimeTalentRanks = EMPTY_TALENT_RANKS;
|
||||
|
||||
// A successful request is never repeated, even if normalization later omits
|
||||
// a free node. This keeps zero-cost prerequisite layouts finite while failed
|
||||
// gated nodes remain eligible after lower rows have been filled.
|
||||
while (true) {
|
||||
let allocated = false;
|
||||
for (const node of candidates) {
|
||||
if ((successfulRequests[node.id] ?? 0) >= node.maxRank) continue;
|
||||
const result = runtimeAllocateTalentPoint(ranks, classId, node.id, level);
|
||||
if (!result.ok) continue;
|
||||
successfulRequests[node.id] = (successfulRequests[node.id] ?? 0) + 1;
|
||||
ranks = result.ranks;
|
||||
allocated = true;
|
||||
break;
|
||||
}
|
||||
if (!allocated) break;
|
||||
}
|
||||
|
||||
return Object.freeze({ ...runtimeNormalizeTalentRanks(classId, level, ranks) });
|
||||
}
|
||||
|
||||
function coaBuild(
|
||||
classId: CoaClassId,
|
||||
role: PartyRole,
|
||||
level: number,
|
||||
catalog: TalentUiCatalog,
|
||||
): BenchmarkBuildDefinition {
|
||||
const abilitiesByNode = coaNodeAbilities(classId);
|
||||
const orientation = preferredCoaTree(catalog, role, abilitiesByNode);
|
||||
return Object.freeze({
|
||||
...commonBuildFields(classId, role, level),
|
||||
orientation: orientation.name,
|
||||
talentRanks: allocateCoaTalentRanks(
|
||||
classId,
|
||||
catalog,
|
||||
orientation,
|
||||
role,
|
||||
level,
|
||||
abilitiesByNode,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function romBuild(
|
||||
classId: RomClassId,
|
||||
role: PartyRole,
|
||||
level: number,
|
||||
): BenchmarkBuildDefinition {
|
||||
return Object.freeze({
|
||||
...commonBuildFields(classId, role, level),
|
||||
orientation: "Primary",
|
||||
talentRanks: EMPTY_TALENT_RANKS,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one stable, legal benchmark build for every declared class-role pair.
|
||||
* A filter changes only membership; output always follows the canonical CLASSES
|
||||
* and partyRolesForClass declaration order.
|
||||
*/
|
||||
export async function loadCanonicalBenchmarkBuilds(
|
||||
level = CANONICAL_BENCHMARK_LEVEL,
|
||||
classIds?: readonly ClassId[],
|
||||
): Promise<BenchmarkBuildDefinition[]> {
|
||||
const safeLevel = clampPlayerLevel(level);
|
||||
const requested = classIds ? new Set(classIds) : null;
|
||||
const selectedClassIds = CLASSES
|
||||
.map((definition) => definition.id)
|
||||
.filter((classId) => !requested || requested.has(classId));
|
||||
const selectedCoaClassIds = selectedClassIds.filter(isCoaClassId);
|
||||
|
||||
await preloadAbilityCatalogsForClasses(selectedCoaClassIds);
|
||||
const coaCatalogs = new Map<CoaClassId, TalentUiCatalog>(await Promise.all(
|
||||
selectedCoaClassIds.map(async (classId) => [
|
||||
classId,
|
||||
await loadTalentUiCatalog(classId),
|
||||
] as const),
|
||||
));
|
||||
|
||||
const builds: BenchmarkBuildDefinition[] = [];
|
||||
for (const classId of selectedClassIds) {
|
||||
for (const role of partyRolesForClass(classId)) {
|
||||
if (isBaseClassId(classId)) {
|
||||
builds.push(wowBuild(classId, role, safeLevel));
|
||||
} else if (isCoaClassId(classId)) {
|
||||
const catalog = coaCatalogs.get(classId);
|
||||
if (!catalog) throw new Error(`No loaded CoA talent catalog exists for ${classId}.`);
|
||||
builds.push(coaBuild(classId, role, safeLevel, catalog));
|
||||
} else if (isRomClassId(classId)) {
|
||||
builds.push(romBuild(classId, role, safeLevel));
|
||||
}
|
||||
}
|
||||
}
|
||||
return builds;
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import {
|
||||
abilityAtLevel,
|
||||
isAbilityUnlocked,
|
||||
type AbilityDefinition,
|
||||
} from "../abilityCatalog";
|
||||
import { aiHintsForAbility, canAbilityCastWhileMoving, targetingRulesForAbility } from "../abilityRules";
|
||||
import { PLAYER_AGGRO_ID } from "../aggro";
|
||||
import type { CastFailureReason, CombatState } from "../combatStore";
|
||||
import { useCombatStore } from "../combatStore";
|
||||
import { getMobPosition } from "../mobRuntimeRegistry";
|
||||
import type { PartyState } from "../partyStore";
|
||||
import { usePartyStore } from "../partyStore";
|
||||
import type { PartyRole } from "../partyRoles";
|
||||
import type { BenchmarkScenarioDefinition } from "./benchmarkTypes";
|
||||
|
||||
export const BENCHMARK_POLICY_ID = "baseline-v1";
|
||||
|
||||
export interface BenchmarkPolicyDecision {
|
||||
readonly made: boolean;
|
||||
readonly cast: boolean;
|
||||
readonly resourceBlocked: boolean;
|
||||
readonly invalidFailures: number;
|
||||
}
|
||||
|
||||
interface FriendlyTarget {
|
||||
readonly id: string;
|
||||
readonly health: number;
|
||||
readonly maxHealth: number;
|
||||
readonly missingPercent: number;
|
||||
}
|
||||
|
||||
interface ScoredAbility {
|
||||
readonly ability: AbilityDefinition;
|
||||
readonly score: number;
|
||||
readonly friendlyTarget: FriendlyTarget | null;
|
||||
}
|
||||
|
||||
const TRANSIENT_FAILURES = new Set<CastFailureReason>([
|
||||
"already-casting",
|
||||
"global-cooldown",
|
||||
"cooldown",
|
||||
"not-enough-resource",
|
||||
"not-enough-runes",
|
||||
"condition-not-met",
|
||||
"player-controlled",
|
||||
"school-locked",
|
||||
]);
|
||||
|
||||
function healthTarget(id: string, health: number, maxHealth: number): FriendlyTarget {
|
||||
return {
|
||||
id,
|
||||
health,
|
||||
maxHealth,
|
||||
missingPercent: maxHealth > 0 ? Math.max(0, 1 - health / maxHealth) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function friendlyTargets(combat: CombatState, party: PartyState): readonly FriendlyTarget[] {
|
||||
return [
|
||||
healthTarget(PLAYER_AGGRO_ID, combat.health, combat.maxHealth),
|
||||
...party.members.map((member) => healthTarget(member.id, member.health, member.maxHealth)),
|
||||
]
|
||||
.filter((target) => target.health > 0)
|
||||
.sort((left, right) => right.missingPercent - left.missingPercent || left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
function learnedAbility(ability: AbilityDefinition, combat: CombatState): AbilityDefinition | null {
|
||||
if (ability.passive || !isAbilityUnlocked(ability, combat.level)) return null;
|
||||
const learnedRank = ability.talentEntryId === undefined
|
||||
? undefined
|
||||
: combat.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0;
|
||||
if (ability.talentEntryId !== undefined && !learnedRank) return null;
|
||||
const ranked = abilityAtLevel(ability, combat.level, learnedRank);
|
||||
if (ranked.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")) return null;
|
||||
return ranked;
|
||||
}
|
||||
|
||||
function isAreaAbility(ability: AbilityDefinition): boolean {
|
||||
const targeting = targetingRulesForAbility(ability);
|
||||
return targeting.shape !== "unit"
|
||||
|| (targeting.maximumTargets ?? 1) > 1
|
||||
|| (targeting.radius ?? ability.radius ?? 0) > 0;
|
||||
}
|
||||
|
||||
function throughputWeight(ability: AbilityDefinition): number {
|
||||
let weight = 0;
|
||||
for (const effect of ability.effects) {
|
||||
if (effect.kind === "damage" || effect.kind === "heal" || effect.kind === "shield" || effect.kind === "dot" || effect.kind === "hot") {
|
||||
weight += Math.max(0, effect.coefficient) * 10;
|
||||
weight += Math.max(0, effect.basePoints ?? 0) * 0.01;
|
||||
} else if (effect.kind === "finisher-damage") {
|
||||
weight += Math.max(0, effect.baseCoefficient + effect.perComboCoefficient * 3) * 10;
|
||||
} else if (effect.kind === "execute") {
|
||||
weight += Math.max(0, effect.coefficient) * 10;
|
||||
}
|
||||
}
|
||||
return Math.min(40, weight);
|
||||
}
|
||||
|
||||
function scoreAbility(
|
||||
ability: AbilityDefinition,
|
||||
role: PartyRole,
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
combat: CombatState,
|
||||
targets: readonly FriendlyTarget[],
|
||||
): ScoredAbility | null {
|
||||
const hints = aiHintsForAbility(ability);
|
||||
const categories = new Set(hints.categories);
|
||||
const injured = targets.filter((target) => target.missingPercent > 0.005);
|
||||
const lowest = targets[0] ?? null;
|
||||
const area = isAreaAbility(ability);
|
||||
let score = hints.priority ?? 0;
|
||||
let friendlyTarget: FriendlyTarget | null = null;
|
||||
|
||||
if (role === "healer") {
|
||||
const canHeal = categories.has("heal") || categories.has("emergency-heal") || categories.has("area-heal");
|
||||
const canShield = categories.has("mitigation") && ability.effects.some((effect) => effect.kind === "shield");
|
||||
if ((!canHeal && !canShield) || !lowest || injured.length === 0) return null;
|
||||
friendlyTarget = ability.target === "self"
|
||||
? targets.find((target) => target.id === PLAYER_AGGRO_ID) ?? lowest
|
||||
: lowest;
|
||||
score += canHeal ? 100 : 70;
|
||||
score += friendlyTarget.missingPercent * 180;
|
||||
if (categories.has("emergency-heal")) score += friendlyTarget.missingPercent >= 0.5 ? 100 : -15;
|
||||
if (categories.has("area-heal") || area) score += injured.length >= 2 ? 35 * injured.length : -35;
|
||||
if (canShield) score += friendlyTarget.missingPercent >= 0.2 ? 45 : -20;
|
||||
} else {
|
||||
const hostileDamage = categories.has("damage") || categories.has("execute") || categories.has("debuff");
|
||||
if (role === "damage" && !hostileDamage) return null;
|
||||
if (role === "tank" && !hostileDamage && !categories.has("mitigation") && !categories.has("taunt")) return null;
|
||||
if (hostileDamage) score += 100 + throughputWeight(ability);
|
||||
if (categories.has("execute")) {
|
||||
const target = combat.selectedTargetId ? combat.mobs[combat.selectedTargetId] : null;
|
||||
const executeActive = Boolean(target && target.health / Math.max(1, target.maxHealth) <= 0.2);
|
||||
score += executeActive ? 120 : -180;
|
||||
}
|
||||
if (scenario.hostileTargets > 1) score += area ? 60 + scenario.hostileTargets * 4 : -15;
|
||||
else if (area) score -= 15;
|
||||
if (ability.effects.some((effect) => effect.kind === "dot")
|
||||
&& combat.effects.some((effect) => effect.sourceAbilityId === ability.id && effect.targetId === combat.selectedTargetId)) {
|
||||
score -= 250;
|
||||
}
|
||||
if (role === "tank") {
|
||||
const playerHealth = combat.health / Math.max(1, combat.maxHealth);
|
||||
if (categories.has("mitigation")) score += playerHealth < 0.8 ? 150 : 20;
|
||||
if (categories.has("taunt")) {
|
||||
const looseMobs = Object.values(combat.mobs).filter((mob) => !mob.dead && mob.targetActorId !== PLAYER_AGGRO_ID).length;
|
||||
score += looseMobs > 0 ? 220 : -80;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ability, score, friendlyTarget };
|
||||
}
|
||||
|
||||
/** A deterministic, shared baseline policy. It is intentionally not an optimizer. */
|
||||
export class BenchmarkController {
|
||||
#nextDecisionAt = 0;
|
||||
|
||||
act(now: number, role: PartyRole, scenario: BenchmarkScenarioDefinition, moving: boolean): BenchmarkPolicyDecision {
|
||||
const combat = useCombatStore.getState();
|
||||
const party = usePartyStore.getState();
|
||||
if (
|
||||
now < this.#nextDecisionAt
|
||||
|| combat.health <= 0
|
||||
|| combat.activeCast
|
||||
|| combat.globalCooldownEndsAt > now
|
||||
|| combat.controlledUntil > now
|
||||
) return { made: false, cast: false, resourceBlocked: false, invalidFailures: 0 };
|
||||
|
||||
const targets = friendlyTargets(combat, party);
|
||||
const candidates = combat.abilities
|
||||
.map((ability) => learnedAbility(ability, combat))
|
||||
.filter((ability): ability is AbilityDefinition => ability !== null)
|
||||
.filter((ability) => !moving || ability.castTimeMs <= 0 || canAbilityCastWhileMoving(ability))
|
||||
.map((ability) => scoreAbility(ability, role, scenario, combat, targets))
|
||||
.filter((candidate): candidate is ScoredAbility => candidate !== null)
|
||||
.sort((left, right) => right.score - left.score || left.ability.id.localeCompare(right.ability.id));
|
||||
|
||||
let resourceBlocked = false;
|
||||
let invalidFailures = 0;
|
||||
for (const candidate of candidates) {
|
||||
const { ability, friendlyTarget } = candidate;
|
||||
let targetActorId: string | null | undefined;
|
||||
if (ability.target === "friendly" || (ability.target === "any" && friendlyTarget)) {
|
||||
if (friendlyTarget?.id === PLAYER_AGGRO_ID) {
|
||||
party.selectSelf();
|
||||
targetActorId = null;
|
||||
} else if (friendlyTarget) {
|
||||
party.selectMember(friendlyTarget.id);
|
||||
targetActorId = friendlyTarget.id;
|
||||
}
|
||||
} else if (ability.target === "hostile" || ability.target === "any") {
|
||||
party.clearSelection();
|
||||
useCombatStore.getState().selectFriendlyActor(null);
|
||||
targetActorId = combat.selectedTargetId;
|
||||
}
|
||||
const hostilePosition = combat.selectedTargetId ? getMobPosition(combat.selectedTargetId) : null;
|
||||
const targeting = targetingRulesForAbility(ability);
|
||||
const result = useCombatStore.getState().castAbility(ability.id, {
|
||||
now,
|
||||
origin: combat.playerPosition,
|
||||
targetActorId,
|
||||
...(targeting.shape === "ground"
|
||||
? { groundPosition: ability.target === "hostile" || ability.target === "any"
|
||||
? hostilePosition ?? combat.playerPosition
|
||||
: combat.playerPosition }
|
||||
: {}),
|
||||
});
|
||||
if (result.ok) {
|
||||
this.#nextDecisionAt = now + 50;
|
||||
return { made: true, cast: true, resourceBlocked: false, invalidFailures };
|
||||
}
|
||||
if (result.reason === "not-enough-resource" || result.reason === "not-enough-runes") resourceBlocked = true;
|
||||
if (result.reason && !TRANSIENT_FAILURES.has(result.reason)) invalidFailures += 1;
|
||||
}
|
||||
|
||||
this.#nextDecisionAt = now + 1_500;
|
||||
return { made: true, cast: false, resourceBlocked, invalidFailures };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import { PLAYER_AGGRO_ID } from "../aggro";
|
||||
import { resourceProfileForClass, type ResourceType } from "../abilityCatalog";
|
||||
import type { CombatState } from "../combatStore";
|
||||
import { subscribeCombatEvents, type StructuredCombatEvent } from "../combatEvents";
|
||||
import type { PartyState } from "../partyStore";
|
||||
import type {
|
||||
BenchmarkAbilityMetrics,
|
||||
BenchmarkBuildDefinition,
|
||||
BenchmarkResourceMetrics,
|
||||
BenchmarkRunMetrics,
|
||||
BenchmarkRunReport,
|
||||
BenchmarkScenarioDefinition,
|
||||
} from "./benchmarkTypes";
|
||||
import { BENCHMARK_POLICY_ID } from "./benchmarkController";
|
||||
|
||||
interface MutableAbilityMetrics {
|
||||
castsStarted: number;
|
||||
castsCompleted: number;
|
||||
interrupts: number;
|
||||
rawDamage: number;
|
||||
effectiveDamage: number;
|
||||
effectiveHealing: number;
|
||||
overheal: number;
|
||||
absorbsApplied: number;
|
||||
}
|
||||
|
||||
interface MutableResourceMetrics {
|
||||
starting: number;
|
||||
ending: number;
|
||||
minimum: number;
|
||||
maximum: number;
|
||||
timeBelowTenPercentMs: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkRecorderSample {
|
||||
readonly now: number;
|
||||
readonly deltaMs: number;
|
||||
readonly combat: CombatState;
|
||||
readonly party: PartyState;
|
||||
readonly movedDistance: number;
|
||||
readonly moving: boolean;
|
||||
readonly stackCompliant: boolean | null;
|
||||
readonly heldMobs: number;
|
||||
readonly totalThreatMobs: number;
|
||||
}
|
||||
|
||||
const emptyAbilityMetrics = (): MutableAbilityMetrics => ({
|
||||
castsStarted: 0,
|
||||
castsCompleted: 0,
|
||||
interrupts: 0,
|
||||
rawDamage: 0,
|
||||
effectiveDamage: 0,
|
||||
effectiveHealing: 0,
|
||||
overheal: 0,
|
||||
absorbsApplied: 0,
|
||||
});
|
||||
|
||||
function percent(numerator: number, denominator: number): number {
|
||||
return denominator > 0 ? numerator / denominator * 100 : 0;
|
||||
}
|
||||
|
||||
/** Collects the uncapped event stream and fixed-step state samples for one run. */
|
||||
export class BenchmarkRecorder {
|
||||
readonly #build: BenchmarkBuildDefinition;
|
||||
readonly #scenario: BenchmarkScenarioDefinition;
|
||||
readonly #seed: number;
|
||||
readonly #startedAt: number;
|
||||
readonly #priorityTargetId: string;
|
||||
readonly #playerSources = new Set<string>([PLAYER_AGGRO_ID]);
|
||||
readonly #friendlyTargets = new Set<string>([PLAYER_AGGRO_ID]);
|
||||
readonly #eventCounts = new Map<string, number>();
|
||||
readonly #abilities = new Map<string, MutableAbilityMetrics>();
|
||||
readonly #resources = new Map<ResourceType, MutableResourceMetrics>();
|
||||
readonly #alive = new Map<string, boolean>();
|
||||
readonly #unsubscribe: () => void;
|
||||
|
||||
#rawDamage = 0;
|
||||
#effectiveDamage = 0;
|
||||
#priorityTargetDamage = 0;
|
||||
#effectiveHealing = 0;
|
||||
#rawHealing = 0;
|
||||
#overheal = 0;
|
||||
#absorbsApplied = 0;
|
||||
#absorbsConsumed = 0;
|
||||
#rawDamageTaken = 0;
|
||||
#effectiveDamageTaken = 0;
|
||||
#incomingFriendlyDamageEvents = 0;
|
||||
#incomingAttacks = 0;
|
||||
#avoidedAttacks = 0;
|
||||
#deaths = 0;
|
||||
#minimumPlayerHealthPercent = 100;
|
||||
#minimumPartyHealthPercent = 100;
|
||||
#castsStarted = 0;
|
||||
#castsCompleted = 0;
|
||||
#movementInterrupts = 0;
|
||||
#invalidCasts = 0;
|
||||
#distanceMoved = 0;
|
||||
#movementTimeMs = 0;
|
||||
#stackCompliantMs = 0;
|
||||
#stackObservedMs = 0;
|
||||
#threatHeldMobMs = 0;
|
||||
#threatObservedMobMs = 0;
|
||||
#timeToResourceFailureMs: number | null = null;
|
||||
#timeToFirstDeathMs: number | null = null;
|
||||
#closed = false;
|
||||
|
||||
constructor(
|
||||
build: BenchmarkBuildDefinition,
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
seed: number,
|
||||
startedAt: number,
|
||||
priorityTargetId: string,
|
||||
) {
|
||||
this.#build = build;
|
||||
this.#scenario = scenario;
|
||||
this.#seed = seed;
|
||||
this.#startedAt = startedAt;
|
||||
this.#priorityTargetId = priorityTargetId;
|
||||
this.#unsubscribe = subscribeCombatEvents((event) => this.#recordEvent(event));
|
||||
}
|
||||
|
||||
trackPlayerSummons(combat: CombatState): void {
|
||||
for (const summon of combat.summons) {
|
||||
if (summon.ownerActorId === PLAYER_AGGRO_ID) this.#playerSources.add(summon.id);
|
||||
}
|
||||
}
|
||||
|
||||
recordInvalidCast(): void {
|
||||
this.#invalidCasts += 1;
|
||||
}
|
||||
|
||||
markResourceFailure(now: number): void {
|
||||
if (this.#timeToResourceFailureMs === null) {
|
||||
this.#timeToResourceFailureMs = Math.max(0, now - this.#startedAt);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
this.#unsubscribe();
|
||||
}
|
||||
|
||||
sample(sample: BenchmarkRecorderSample): void {
|
||||
const { combat, party, deltaMs } = sample;
|
||||
this.captureGearStats(combat);
|
||||
this.trackPlayerSummons(combat);
|
||||
this.#distanceMoved += Math.max(0, sample.movedDistance);
|
||||
if (sample.moving) this.#movementTimeMs += deltaMs;
|
||||
if (sample.stackCompliant !== null) {
|
||||
this.#stackObservedMs += deltaMs;
|
||||
if (sample.stackCompliant) this.#stackCompliantMs += deltaMs;
|
||||
}
|
||||
if (sample.totalThreatMobs > 0) {
|
||||
this.#threatObservedMobMs += sample.totalThreatMobs * deltaMs;
|
||||
this.#threatHeldMobMs += sample.heldMobs * deltaMs;
|
||||
}
|
||||
|
||||
const playerHealthPercent = percent(combat.health, combat.maxHealth);
|
||||
this.#minimumPlayerHealthPercent = Math.min(this.#minimumPlayerHealthPercent, playerHealthPercent);
|
||||
const livingPartyHealth = party.members
|
||||
.filter((member) => member.health > 0)
|
||||
.map((member) => percent(member.health, member.maxHealth));
|
||||
if (livingPartyHealth.length) {
|
||||
this.#minimumPartyHealthPercent = Math.min(this.#minimumPartyHealthPercent, ...livingPartyHealth);
|
||||
} else if (party.members.length) {
|
||||
this.#minimumPartyHealthPercent = 0;
|
||||
}
|
||||
|
||||
this.#observeAlive(PLAYER_AGGRO_ID, combat.health > 0, sample.now);
|
||||
for (const member of party.members) {
|
||||
this.#friendlyTargets.add(member.id);
|
||||
this.#observeAlive(member.id, member.health > 0, sample.now);
|
||||
}
|
||||
|
||||
const activeResource = resourceProfileForClass(combat.classId).type;
|
||||
const resourceIds = new Set<ResourceType>([
|
||||
activeResource,
|
||||
...Object.keys(combat.maxResourcePools) as ResourceType[],
|
||||
]);
|
||||
for (const id of resourceIds) {
|
||||
const maximum = id === activeResource
|
||||
? combat.maxResource
|
||||
: combat.maxResourcePools[id] ?? 0;
|
||||
const current = id === activeResource
|
||||
? combat.resource
|
||||
: combat.resourcePools[id] ?? 0;
|
||||
const metrics = this.#resources.get(id) ?? {
|
||||
starting: current,
|
||||
ending: current,
|
||||
minimum: current,
|
||||
maximum,
|
||||
timeBelowTenPercentMs: 0,
|
||||
};
|
||||
metrics.ending = current;
|
||||
metrics.minimum = Math.min(metrics.minimum, current);
|
||||
metrics.maximum = Math.max(metrics.maximum, maximum);
|
||||
if (maximum > 0 && current / maximum < 0.1) metrics.timeBelowTenPercentMs += deltaMs;
|
||||
this.#resources.set(id, metrics);
|
||||
}
|
||||
}
|
||||
|
||||
finish(simulatedDurationMs: number, completed: boolean): BenchmarkRunReport {
|
||||
this.dispose();
|
||||
const seconds = Math.max(0.001, simulatedDurationMs / 1_000);
|
||||
const reasons: string[] = [];
|
||||
if (simulatedDurationMs <= 0) reasons.push("No combat time was simulated.");
|
||||
if (this.#castsCompleted <= 0 && this.#effectiveDamage <= 0) {
|
||||
reasons.push("The policy completed no abilities and dealt no damage.");
|
||||
}
|
||||
if (this.#scenario.roles.includes("damage") && this.#build.role === "damage" && this.#effectiveDamage <= 0) {
|
||||
reasons.push("The damage build produced no effective damage.");
|
||||
}
|
||||
if (this.#scenario.roles.includes("healer") && this.#build.role === "healer" && this.#incomingFriendlyDamageEvents <= 0 && this.#rawHealing <= 0) {
|
||||
reasons.push("The healing scenario produced neither incoming pressure nor healing.");
|
||||
}
|
||||
if (this.#scenario.roles.includes("tank") && this.#build.role === "tank" && this.#incomingAttacks <= 0) {
|
||||
reasons.push("The tanking scenario produced no incoming player attacks.");
|
||||
}
|
||||
|
||||
const metrics: BenchmarkRunMetrics = Object.freeze({
|
||||
rawDamage: this.#rawDamage,
|
||||
effectiveDamage: this.#effectiveDamage,
|
||||
priorityTargetDamage: this.#priorityTargetDamage,
|
||||
effectiveHealing: this.#effectiveHealing,
|
||||
rawHealing: this.#rawHealing,
|
||||
overheal: this.#overheal,
|
||||
absorbsApplied: this.#absorbsApplied,
|
||||
absorbsConsumed: this.#absorbsConsumed,
|
||||
rawDamageTaken: this.#rawDamageTaken,
|
||||
effectiveDamageTaken: this.#effectiveDamageTaken,
|
||||
incomingAttacks: this.#incomingAttacks,
|
||||
avoidedAttacks: this.#avoidedAttacks,
|
||||
deaths: this.#deaths,
|
||||
minimumPlayerHealthPercent: this.#minimumPlayerHealthPercent,
|
||||
minimumPartyHealthPercent: this.#minimumPartyHealthPercent,
|
||||
castsStarted: this.#castsStarted,
|
||||
castsCompleted: this.#castsCompleted,
|
||||
movementInterrupts: this.#movementInterrupts,
|
||||
invalidCasts: this.#invalidCasts,
|
||||
distanceMoved: this.#distanceMoved,
|
||||
movementTimeMs: this.#movementTimeMs,
|
||||
stackCompliancePercent: this.#stackObservedMs > 0
|
||||
? percent(this.#stackCompliantMs, this.#stackObservedMs)
|
||||
: null,
|
||||
threatControlPercent: this.#threatObservedMobMs > 0
|
||||
? percent(this.#threatHeldMobMs, this.#threatObservedMobMs)
|
||||
: null,
|
||||
timeToResourceFailureMs: this.#timeToResourceFailureMs,
|
||||
timeToFirstDeathMs: this.#timeToFirstDeathMs,
|
||||
dps: this.#effectiveDamage / seconds,
|
||||
hps: this.#effectiveHealing / seconds,
|
||||
dtps: this.#effectiveDamageTaken / seconds,
|
||||
overhealPercent: percent(this.#overheal, this.#rawHealing),
|
||||
mitigationPercent: percent(this.#rawDamageTaken - this.#effectiveDamageTaken, this.#rawDamageTaken),
|
||||
avoidancePercent: percent(this.#avoidedAttacks, this.#incomingAttacks),
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
runId: `${this.#build.id}__${this.#scenario.id}-v${this.#scenario.version}__seed-${this.#seed}`,
|
||||
policyId: BENCHMARK_POLICY_ID,
|
||||
seed: this.#seed,
|
||||
build: this.#build,
|
||||
scenario: this.#scenario,
|
||||
simulatedDurationMs,
|
||||
completed,
|
||||
metrics,
|
||||
resources: this.#resourceReport(),
|
||||
abilities: this.#abilityReport(),
|
||||
eventCounts: Object.freeze(Object.fromEntries([...this.#eventCounts].sort(([left], [right]) => left.localeCompare(right)))),
|
||||
validation: Object.freeze({ valid: reasons.length === 0, reasons: Object.freeze(reasons) }),
|
||||
gearStats: this.#lastGearStats,
|
||||
});
|
||||
}
|
||||
|
||||
#lastGearStats: BenchmarkRunReport["gearStats"] = {} as BenchmarkRunReport["gearStats"];
|
||||
|
||||
captureGearStats(combat: CombatState): void {
|
||||
this.#lastGearStats = combat.gearStats;
|
||||
}
|
||||
|
||||
#observeAlive(id: string, alive: boolean, now: number): void {
|
||||
const previous = this.#alive.get(id);
|
||||
this.#alive.set(id, alive);
|
||||
if (previous === undefined || !previous || alive) return;
|
||||
this.#deaths += 1;
|
||||
if (this.#timeToFirstDeathMs === null) this.#timeToFirstDeathMs = Math.max(0, now - this.#startedAt);
|
||||
}
|
||||
|
||||
#ability(id: string): MutableAbilityMetrics {
|
||||
const metrics = this.#abilities.get(id) ?? emptyAbilityMetrics();
|
||||
this.#abilities.set(id, metrics);
|
||||
return metrics;
|
||||
}
|
||||
|
||||
#recordEvent(event: StructuredCombatEvent): void {
|
||||
this.#eventCounts.set(event.kind, (this.#eventCounts.get(event.kind) ?? 0) + 1);
|
||||
const playerSource = this.#playerSources.has(event.sourceActorId)
|
||||
|| event.sourceActorId.startsWith("player-summon-")
|
||||
|| event.sourceActorId.startsWith("player-triggered-summon-")
|
||||
|| event.sourceActorId.startsWith("player-pet-")
|
||||
|| event.sourceActorId.startsWith("player-utility-")
|
||||
|| event.sourceActorId.startsWith(`${PLAYER_AGGRO_ID}:summon-`)
|
||||
|| event.sourceActorId.startsWith(`${PLAYER_AGGRO_ID}:totem-`);
|
||||
const ability = playerSource && event.abilityId ? this.#ability(event.abilityId) : null;
|
||||
if (playerSource && event.kind === "damage" && !this.#friendlyTargets.has(event.targetActorId)) {
|
||||
this.#rawDamage += event.rawAmount;
|
||||
this.#effectiveDamage += event.effectiveAmount;
|
||||
if (event.targetActorId === this.#priorityTargetId) this.#priorityTargetDamage += event.effectiveAmount;
|
||||
if (ability) {
|
||||
ability.rawDamage += event.rawAmount;
|
||||
ability.effectiveDamage += event.effectiveAmount;
|
||||
}
|
||||
}
|
||||
if (playerSource && event.kind === "healing") {
|
||||
this.#rawHealing += event.rawAmount;
|
||||
this.#effectiveHealing += event.effectiveAmount;
|
||||
this.#overheal += event.overheal;
|
||||
if (ability) {
|
||||
ability.effectiveHealing += event.effectiveAmount;
|
||||
ability.overheal += event.overheal;
|
||||
}
|
||||
}
|
||||
if (playerSource && event.kind === "absorb") {
|
||||
const amount = Math.max(event.effectiveAmount, event.absorbed);
|
||||
this.#absorbsApplied += amount;
|
||||
if (ability) ability.absorbsApplied += amount;
|
||||
}
|
||||
if (event.kind === "damage" && !playerSource && this.#friendlyTargets.has(event.targetActorId)) {
|
||||
this.#incomingFriendlyDamageEvents += 1;
|
||||
this.#absorbsConsumed += event.absorbed;
|
||||
if (event.targetActorId === PLAYER_AGGRO_ID) {
|
||||
this.#incomingAttacks += 1;
|
||||
if (["miss", "dodge", "parry", "resist"].includes(event.outcome ?? "")) this.#avoidedAttacks += 1;
|
||||
this.#rawDamageTaken += event.rawAmount;
|
||||
this.#effectiveDamageTaken += event.effectiveAmount;
|
||||
}
|
||||
}
|
||||
if (playerSource && event.kind === "cast" && ability) {
|
||||
if (event.outcome === "started") {
|
||||
ability.castsStarted += 1;
|
||||
this.#castsStarted += 1;
|
||||
} else if (event.outcome === "completed") {
|
||||
if (ability.castsStarted <= ability.castsCompleted) {
|
||||
ability.castsStarted += 1;
|
||||
this.#castsStarted += 1;
|
||||
}
|
||||
ability.castsCompleted += 1;
|
||||
this.#castsCompleted += 1;
|
||||
}
|
||||
}
|
||||
if (playerSource && event.kind === "interrupt") {
|
||||
if (ability) ability.interrupts += 1;
|
||||
if (event.outcome === "movement") this.#movementInterrupts += 1;
|
||||
}
|
||||
}
|
||||
|
||||
#resourceReport(): readonly BenchmarkResourceMetrics[] {
|
||||
return Object.freeze([...this.#resources]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([id, metrics]) => Object.freeze({ id, ...metrics })));
|
||||
}
|
||||
|
||||
#abilityReport(): readonly BenchmarkAbilityMetrics[] {
|
||||
return Object.freeze([...this.#abilities]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([abilityId, metrics]) => Object.freeze({ abilityId, ...metrics })));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EMPTY_ITEM_STATS } from "../itemStats";
|
||||
import { benchmarkScenarioById } from "./benchmarkScenarios";
|
||||
import {
|
||||
aggregateBenchmarkMatrix,
|
||||
benchmarkQuantile,
|
||||
renderBenchmarkMatrixMarkdown,
|
||||
serializeBenchmarkMatrixCsv,
|
||||
} from "./benchmarkReport";
|
||||
import type {
|
||||
BenchmarkBuildDefinition,
|
||||
BenchmarkMatrixReport,
|
||||
BenchmarkRunMetrics,
|
||||
BenchmarkRunReport,
|
||||
} from "./benchmarkTypes";
|
||||
|
||||
const scenario = benchmarkScenarioById("damage-single-burst")!;
|
||||
|
||||
const build: BenchmarkBuildDefinition = {
|
||||
id: "mage-burst",
|
||||
source: "wow",
|
||||
classId: "mage",
|
||||
secondaryClassId: null,
|
||||
role: "damage",
|
||||
level: 60,
|
||||
orientation: 'spell, "burst"',
|
||||
talentRanks: {},
|
||||
inventory: [],
|
||||
equipment: {},
|
||||
gearPresetId: "level-60-baseline",
|
||||
};
|
||||
|
||||
const defaultMetrics: BenchmarkRunMetrics = {
|
||||
rawDamage: 0,
|
||||
effectiveDamage: 0,
|
||||
priorityTargetDamage: 0,
|
||||
effectiveHealing: 0,
|
||||
rawHealing: 0,
|
||||
overheal: 0,
|
||||
absorbsApplied: 0,
|
||||
absorbsConsumed: 0,
|
||||
rawDamageTaken: 0,
|
||||
effectiveDamageTaken: 0,
|
||||
incomingAttacks: 0,
|
||||
avoidedAttacks: 0,
|
||||
deaths: 0,
|
||||
minimumPlayerHealthPercent: 100,
|
||||
minimumPartyHealthPercent: 100,
|
||||
castsStarted: 0,
|
||||
castsCompleted: 0,
|
||||
movementInterrupts: 0,
|
||||
invalidCasts: 0,
|
||||
distanceMoved: 0,
|
||||
movementTimeMs: 0,
|
||||
stackCompliancePercent: null,
|
||||
threatControlPercent: null,
|
||||
timeToResourceFailureMs: null,
|
||||
timeToFirstDeathMs: null,
|
||||
dps: 0,
|
||||
hps: 0,
|
||||
dtps: 0,
|
||||
overhealPercent: 0,
|
||||
mitigationPercent: 0,
|
||||
avoidancePercent: 0,
|
||||
};
|
||||
|
||||
function run(
|
||||
seed: number,
|
||||
metrics: Partial<BenchmarkRunMetrics>,
|
||||
valid = true,
|
||||
): BenchmarkRunReport {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
runId: `mage-burst:${seed}`,
|
||||
policyId: "baseline-v1",
|
||||
seed,
|
||||
build,
|
||||
scenario,
|
||||
simulatedDurationMs: scenario.durationMs,
|
||||
completed: seed !== 2,
|
||||
metrics: { ...defaultMetrics, ...metrics },
|
||||
resources: [],
|
||||
abilities: [],
|
||||
eventCounts: {},
|
||||
validation: { valid, reasons: valid ? [] : ["fixture invalid"] },
|
||||
gearStats: EMPTY_ITEM_STATS,
|
||||
};
|
||||
}
|
||||
|
||||
function matrix(): BenchmarkMatrixReport {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generatedAt: "2026-08-28T12:00:00.000Z",
|
||||
appVersion: "test",
|
||||
cohort: {
|
||||
level: 60,
|
||||
gearPresetId: "level-60-baseline",
|
||||
seeds: [1, 2, 3, 4],
|
||||
durationScale: 1,
|
||||
policyId: "baseline-v1",
|
||||
},
|
||||
scenarios: [scenario],
|
||||
builds: [build],
|
||||
runs: [
|
||||
run(1, { dps: 10, priorityTargetDamage: 100 }),
|
||||
run(2, { dps: 20, priorityTargetDamage: 200 }),
|
||||
run(3, { dps: 30, priorityTargetDamage: 300 }),
|
||||
run(4, { dps: 999, priorityTargetDamage: 9_999 }, false),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("benchmark reporting", () => {
|
||||
it("uses deterministic interpolated quantiles", () => {
|
||||
expect(benchmarkQuantile([30, null, 10, 20], 0.1)).toBe(12);
|
||||
expect(benchmarkQuantile([30, null, 10, 20], 0.5)).toBe(20);
|
||||
expect(benchmarkQuantile([30, null, 10, 20], 0.9)).toBe(28);
|
||||
expect(benchmarkQuantile([null], 0.5)).toBeNull();
|
||||
expect(() => benchmarkQuantile([1], 1.1)).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it("aggregates validated runs and retains null-event counts", () => {
|
||||
const summary = aggregateBenchmarkMatrix(matrix());
|
||||
const [row] = summary.rows;
|
||||
|
||||
expect(summary).toMatchObject({ runCount: 4, validRunCount: 3, invalidRunCount: 1 });
|
||||
expect(row).toMatchObject({
|
||||
runCount: 4,
|
||||
validRunCount: 3,
|
||||
invalidRunCount: 1,
|
||||
completedRunCount: 3,
|
||||
});
|
||||
expect(row.metrics.dps).toEqual({
|
||||
count: 3,
|
||||
nullCount: 0,
|
||||
p10: 12,
|
||||
median: 20,
|
||||
p90: 28,
|
||||
});
|
||||
expect(row.metrics.timeToFirstDeathMs).toEqual({
|
||||
count: 0,
|
||||
nullCount: 3,
|
||||
p10: null,
|
||||
median: null,
|
||||
p90: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes complete metric rows as newline-clean CSV", () => {
|
||||
const csv = serializeBenchmarkMatrixCsv(matrix());
|
||||
|
||||
expect(csv).toContain("scenario_id,scenario_version,scenario_name");
|
||||
expect(csv).toContain('mage-burst,wow,mage,,damage,60,"spell, ""burst"""');
|
||||
expect(csv).toContain(",dps,3,0,12,20,28\n");
|
||||
expect(csv.endsWith("\n")).toBe(true);
|
||||
expect(csv.endsWith("\n\n")).toBe(false);
|
||||
expect(csv).not.toContain("\r");
|
||||
});
|
||||
|
||||
it("renders separate role metrics instead of a composite ranking", () => {
|
||||
const markdown = renderBenchmarkMatrixMarkdown(matrix());
|
||||
|
||||
expect(markdown).toContain("## Damage");
|
||||
expect(markdown).toContain("DPS: 20 [12–28]");
|
||||
expect(markdown).toContain("1 invalid run(s) are excluded");
|
||||
expect(markdown).toContain("does not calculate a composite score");
|
||||
expect(markdown.endsWith("\n")).toBe(true);
|
||||
expect(markdown.endsWith("\n\n")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,473 @@
|
||||
import type { PartyRole } from "../partyRoles";
|
||||
import type {
|
||||
BenchmarkMatrixReport,
|
||||
BenchmarkRunMetrics,
|
||||
BenchmarkRunReport,
|
||||
BenchmarkScenarioFamily,
|
||||
} from "./benchmarkTypes";
|
||||
|
||||
export const BENCHMARK_METRIC_KEYS = Object.freeze([
|
||||
"rawDamage",
|
||||
"effectiveDamage",
|
||||
"priorityTargetDamage",
|
||||
"effectiveHealing",
|
||||
"rawHealing",
|
||||
"overheal",
|
||||
"absorbsApplied",
|
||||
"absorbsConsumed",
|
||||
"rawDamageTaken",
|
||||
"effectiveDamageTaken",
|
||||
"incomingAttacks",
|
||||
"avoidedAttacks",
|
||||
"deaths",
|
||||
"minimumPlayerHealthPercent",
|
||||
"minimumPartyHealthPercent",
|
||||
"castsStarted",
|
||||
"castsCompleted",
|
||||
"movementInterrupts",
|
||||
"invalidCasts",
|
||||
"distanceMoved",
|
||||
"movementTimeMs",
|
||||
"stackCompliancePercent",
|
||||
"threatControlPercent",
|
||||
"timeToResourceFailureMs",
|
||||
"timeToFirstDeathMs",
|
||||
"dps",
|
||||
"hps",
|
||||
"dtps",
|
||||
"overhealPercent",
|
||||
"mitigationPercent",
|
||||
"avoidancePercent",
|
||||
] as const satisfies readonly (keyof BenchmarkRunMetrics)[]);
|
||||
|
||||
export type BenchmarkMetricKey = (typeof BENCHMARK_METRIC_KEYS)[number];
|
||||
|
||||
export interface BenchmarkDistribution {
|
||||
/** Number of validated runs with a finite observation for this metric. */
|
||||
readonly count: number;
|
||||
/** Validated runs where the metric was null or non-finite. */
|
||||
readonly nullCount: number;
|
||||
readonly median: number | null;
|
||||
readonly p10: number | null;
|
||||
readonly p90: number | null;
|
||||
}
|
||||
|
||||
export interface BenchmarkMatrixSummaryRow {
|
||||
readonly scenarioId: string;
|
||||
readonly scenarioVersion: number;
|
||||
readonly scenarioName: string;
|
||||
readonly scenarioFamily: BenchmarkScenarioFamily;
|
||||
readonly buildId: string;
|
||||
readonly source: BenchmarkRunReport["build"]["source"];
|
||||
readonly classId: BenchmarkRunReport["build"]["classId"];
|
||||
readonly secondaryClassId: BenchmarkRunReport["build"]["secondaryClassId"];
|
||||
readonly role: PartyRole;
|
||||
readonly level: number;
|
||||
readonly orientation: string;
|
||||
readonly gearPresetId: string;
|
||||
readonly runCount: number;
|
||||
readonly validRunCount: number;
|
||||
readonly invalidRunCount: number;
|
||||
readonly completedRunCount: number;
|
||||
readonly metrics: Readonly<Record<BenchmarkMetricKey, BenchmarkDistribution>>;
|
||||
}
|
||||
|
||||
export interface BenchmarkMatrixSummary {
|
||||
readonly schemaVersion: 1;
|
||||
readonly generatedAt: string;
|
||||
readonly appVersion: string;
|
||||
readonly cohort: BenchmarkMatrixReport["cohort"];
|
||||
readonly runCount: number;
|
||||
readonly validRunCount: number;
|
||||
readonly invalidRunCount: number;
|
||||
readonly rows: readonly BenchmarkMatrixSummaryRow[];
|
||||
}
|
||||
|
||||
export const ROLE_BENCHMARK_METRICS = Object.freeze({
|
||||
damage: Object.freeze([
|
||||
"dps",
|
||||
"priorityTargetDamage",
|
||||
"deaths",
|
||||
"timeToFirstDeathMs",
|
||||
]),
|
||||
tank: Object.freeze([
|
||||
"dtps",
|
||||
"mitigationPercent",
|
||||
"avoidancePercent",
|
||||
"threatControlPercent",
|
||||
"minimumPlayerHealthPercent",
|
||||
"deaths",
|
||||
"timeToFirstDeathMs",
|
||||
"dps",
|
||||
]),
|
||||
healer: Object.freeze([
|
||||
"hps",
|
||||
"absorbsConsumed",
|
||||
"overhealPercent",
|
||||
"minimumPartyHealthPercent",
|
||||
"deaths",
|
||||
"timeToResourceFailureMs",
|
||||
]),
|
||||
} as const satisfies Readonly<Record<PartyRole, readonly BenchmarkMetricKey[]>>);
|
||||
|
||||
const MECHANIC_METRICS = Object.freeze([
|
||||
"movementInterrupts",
|
||||
"stackCompliancePercent",
|
||||
] as const satisfies readonly BenchmarkMetricKey[]);
|
||||
|
||||
const ROLE_ORDER: Readonly<Record<PartyRole, number>> = Object.freeze({
|
||||
tank: 0,
|
||||
healer: 1,
|
||||
damage: 2,
|
||||
});
|
||||
|
||||
const ROLE_LABELS: Readonly<Record<PartyRole, string>> = Object.freeze({
|
||||
tank: "Tank",
|
||||
healer: "Healer",
|
||||
damage: "Damage",
|
||||
});
|
||||
|
||||
const METRIC_LABELS: Readonly<Record<BenchmarkMetricKey, string>> = Object.freeze({
|
||||
rawDamage: "Raw damage",
|
||||
effectiveDamage: "Effective damage",
|
||||
priorityTargetDamage: "Priority damage",
|
||||
effectiveHealing: "Effective healing",
|
||||
rawHealing: "Raw healing",
|
||||
overheal: "Overheal",
|
||||
absorbsApplied: "Absorbs applied",
|
||||
absorbsConsumed: "Absorbs consumed",
|
||||
rawDamageTaken: "Raw damage taken",
|
||||
effectiveDamageTaken: "Effective damage taken",
|
||||
incomingAttacks: "Incoming attacks",
|
||||
avoidedAttacks: "Avoided attacks",
|
||||
deaths: "Deaths",
|
||||
minimumPlayerHealthPercent: "Minimum player health",
|
||||
minimumPartyHealthPercent: "Minimum party health",
|
||||
castsStarted: "Casts started",
|
||||
castsCompleted: "Casts completed",
|
||||
movementInterrupts: "Movement interrupts",
|
||||
invalidCasts: "Invalid casts",
|
||||
distanceMoved: "Distance moved",
|
||||
movementTimeMs: "Movement time",
|
||||
stackCompliancePercent: "Stack compliance",
|
||||
threatControlPercent: "Threat control",
|
||||
timeToResourceFailureMs: "Resource failure",
|
||||
timeToFirstDeathMs: "First death",
|
||||
dps: "DPS",
|
||||
hps: "HPS",
|
||||
dtps: "DTPS",
|
||||
overhealPercent: "Overheal",
|
||||
mitigationPercent: "Mitigation",
|
||||
avoidancePercent: "Avoidance",
|
||||
});
|
||||
|
||||
type BenchmarkReportInput = BenchmarkMatrixReport | BenchmarkMatrixSummary;
|
||||
|
||||
interface MutableAggregationGroup {
|
||||
readonly representative: BenchmarkRunReport;
|
||||
readonly runs: BenchmarkRunReport[];
|
||||
}
|
||||
|
||||
function finiteValues(values: readonly (number | null)[]): number[] {
|
||||
return values.filter((value): value is number => value !== null && Number.isFinite(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a linearly interpolated quantile after dropping null and non-finite values.
|
||||
*/
|
||||
export function benchmarkQuantile(
|
||||
values: readonly (number | null)[],
|
||||
quantile: number,
|
||||
): number | null {
|
||||
if (quantile < 0 || quantile > 1 || !Number.isFinite(quantile)) {
|
||||
throw new RangeError(`Benchmark quantile must be between 0 and 1; received ${quantile}.`);
|
||||
}
|
||||
|
||||
const sorted = finiteValues(values).sort((left, right) => left - right);
|
||||
if (sorted.length === 0) return null;
|
||||
if (sorted.length === 1) return sorted[0];
|
||||
|
||||
const rank = (sorted.length - 1) * quantile;
|
||||
const lowerIndex = Math.floor(rank);
|
||||
const upperIndex = Math.ceil(rank);
|
||||
const lower = sorted[lowerIndex];
|
||||
const upper = sorted[upperIndex];
|
||||
if (lowerIndex === upperIndex) return lower;
|
||||
return lower + ((upper - lower) * (rank - lowerIndex));
|
||||
}
|
||||
|
||||
function summarizeMetric(
|
||||
runs: readonly BenchmarkRunReport[],
|
||||
metric: BenchmarkMetricKey,
|
||||
): BenchmarkDistribution {
|
||||
const values = runs.map((run) => run.metrics[metric]);
|
||||
const observed = finiteValues(values);
|
||||
return {
|
||||
count: observed.length,
|
||||
nullCount: values.length - observed.length,
|
||||
median: benchmarkQuantile(observed, 0.5),
|
||||
p10: benchmarkQuantile(observed, 0.1),
|
||||
p90: benchmarkQuantile(observed, 0.9),
|
||||
};
|
||||
}
|
||||
|
||||
function aggregationKey(run: BenchmarkRunReport): string {
|
||||
return JSON.stringify([
|
||||
run.scenario.id,
|
||||
run.scenario.version,
|
||||
run.build.id,
|
||||
run.build.source,
|
||||
run.build.classId,
|
||||
run.build.secondaryClassId ?? null,
|
||||
run.build.role,
|
||||
run.build.level,
|
||||
run.build.orientation,
|
||||
run.build.gearPresetId,
|
||||
]);
|
||||
}
|
||||
|
||||
function compareSummaryRows(
|
||||
left: BenchmarkMatrixSummaryRow,
|
||||
right: BenchmarkMatrixSummaryRow,
|
||||
): number {
|
||||
return left.scenarioId.localeCompare(right.scenarioId)
|
||||
|| left.scenarioVersion - right.scenarioVersion
|
||||
|| ROLE_ORDER[left.role] - ROLE_ORDER[right.role]
|
||||
|| left.source.localeCompare(right.source)
|
||||
|| left.classId.localeCompare(right.classId)
|
||||
|| (left.secondaryClassId ?? "").localeCompare(right.secondaryClassId ?? "")
|
||||
|| left.buildId.localeCompare(right.buildId);
|
||||
}
|
||||
|
||||
export function aggregateBenchmarkMatrix(report: BenchmarkMatrixReport): BenchmarkMatrixSummary {
|
||||
const groups = new Map<string, MutableAggregationGroup>();
|
||||
|
||||
for (const run of report.runs) {
|
||||
const key = aggregationKey(run);
|
||||
const existing = groups.get(key);
|
||||
if (existing) {
|
||||
existing.runs.push(run);
|
||||
} else {
|
||||
groups.set(key, { representative: run, runs: [run] });
|
||||
}
|
||||
}
|
||||
|
||||
const rows = [...groups.values()].map(({ representative, runs }) => {
|
||||
const validRuns = runs.filter((run) => run.validation.valid);
|
||||
const metrics = Object.fromEntries(BENCHMARK_METRIC_KEYS.map((metric) => (
|
||||
[metric, summarizeMetric(validRuns, metric)]
|
||||
))) as unknown as Readonly<Record<BenchmarkMetricKey, BenchmarkDistribution>>;
|
||||
|
||||
return {
|
||||
scenarioId: representative.scenario.id,
|
||||
scenarioVersion: representative.scenario.version,
|
||||
scenarioName: representative.scenario.name,
|
||||
scenarioFamily: representative.scenario.family,
|
||||
buildId: representative.build.id,
|
||||
source: representative.build.source,
|
||||
classId: representative.build.classId,
|
||||
secondaryClassId: representative.build.secondaryClassId ?? null,
|
||||
role: representative.build.role,
|
||||
level: representative.build.level,
|
||||
orientation: representative.build.orientation,
|
||||
gearPresetId: representative.build.gearPresetId,
|
||||
runCount: runs.length,
|
||||
validRunCount: validRuns.length,
|
||||
invalidRunCount: runs.length - validRuns.length,
|
||||
completedRunCount: runs.filter((run) => run.completed).length,
|
||||
metrics,
|
||||
} satisfies BenchmarkMatrixSummaryRow;
|
||||
}).sort(compareSummaryRows);
|
||||
|
||||
const validRunCount = report.runs.filter((run) => run.validation.valid).length;
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generatedAt: report.generatedAt,
|
||||
appVersion: report.appVersion,
|
||||
cohort: report.cohort,
|
||||
runCount: report.runs.length,
|
||||
validRunCount,
|
||||
invalidRunCount: report.runs.length - validRunCount,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
function asSummary(input: BenchmarkReportInput): BenchmarkMatrixSummary {
|
||||
return "rows" in input ? input : aggregateBenchmarkMatrix(input);
|
||||
}
|
||||
|
||||
function csvCell(value: string | number | null): string {
|
||||
if (value === null) return "";
|
||||
const text = String(value);
|
||||
return /[",\r\n]/u.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
||||
}
|
||||
|
||||
function csvLine(values: readonly (string | number | null)[]): string {
|
||||
return values.map(csvCell).join(",");
|
||||
}
|
||||
|
||||
/** Produces one CSV row per scenario/build/metric, retaining the complete metric vector. */
|
||||
export function serializeBenchmarkMatrixCsv(input: BenchmarkReportInput): string {
|
||||
const summary = asSummary(input);
|
||||
const lines = [csvLine([
|
||||
"schema_version",
|
||||
"generated_at",
|
||||
"app_version",
|
||||
"cohort_level",
|
||||
"cohort_gear_preset_id",
|
||||
"seeds",
|
||||
"duration_scale",
|
||||
"policy_id",
|
||||
"scenario_id",
|
||||
"scenario_version",
|
||||
"scenario_name",
|
||||
"scenario_family",
|
||||
"build_id",
|
||||
"source",
|
||||
"class_id",
|
||||
"secondary_class_id",
|
||||
"role",
|
||||
"build_level",
|
||||
"orientation",
|
||||
"gear_preset_id",
|
||||
"run_count",
|
||||
"valid_run_count",
|
||||
"invalid_run_count",
|
||||
"completed_run_count",
|
||||
"metric",
|
||||
"observed_count",
|
||||
"null_count",
|
||||
"p10",
|
||||
"median",
|
||||
"p90",
|
||||
])];
|
||||
|
||||
for (const row of summary.rows) {
|
||||
for (const metric of BENCHMARK_METRIC_KEYS) {
|
||||
const distribution = row.metrics[metric];
|
||||
lines.push(csvLine([
|
||||
summary.schemaVersion,
|
||||
summary.generatedAt,
|
||||
summary.appVersion,
|
||||
summary.cohort.level,
|
||||
summary.cohort.gearPresetId,
|
||||
summary.cohort.seeds.join(";"),
|
||||
summary.cohort.durationScale,
|
||||
summary.cohort.policyId,
|
||||
row.scenarioId,
|
||||
row.scenarioVersion,
|
||||
row.scenarioName,
|
||||
row.scenarioFamily,
|
||||
row.buildId,
|
||||
row.source,
|
||||
row.classId,
|
||||
row.secondaryClassId ?? null,
|
||||
row.role,
|
||||
row.level,
|
||||
row.orientation,
|
||||
row.gearPresetId,
|
||||
row.runCount,
|
||||
row.validRunCount,
|
||||
row.invalidRunCount,
|
||||
row.completedRunCount,
|
||||
metric,
|
||||
distribution.count,
|
||||
distribution.nullCount,
|
||||
distribution.p10,
|
||||
distribution.median,
|
||||
distribution.p90,
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function markdownCell(value: string): string {
|
||||
return value.replaceAll("|", "\\|").replace(/[\r\n]+/gu, " ");
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
if (Number.isInteger(value)) return value.toLocaleString("en-US");
|
||||
return value.toLocaleString("en-US", { maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function formatMetricValue(metric: BenchmarkMetricKey, value: number): string {
|
||||
if (metric.endsWith("Percent")) return `${formatNumber(value)}%`;
|
||||
if (metric.endsWith("Ms")) return `${formatNumber(value / 1_000)}s`;
|
||||
return formatNumber(value);
|
||||
}
|
||||
|
||||
function formatDistribution(metric: BenchmarkMetricKey, value: BenchmarkDistribution): string {
|
||||
if (value.median === null || value.p10 === null || value.p90 === null) {
|
||||
return value.nullCount > 0 ? `not observed (0/${value.nullCount})` : "no valid runs";
|
||||
}
|
||||
|
||||
const range = `${formatMetricValue(metric, value.p10)}–${formatMetricValue(metric, value.p90)}`;
|
||||
const observation = value.nullCount > 0
|
||||
? `; observed ${value.count}/${value.count + value.nullCount}`
|
||||
: "";
|
||||
return `${formatMetricValue(metric, value.median)} [${range}]${observation}`;
|
||||
}
|
||||
|
||||
function roleMetricsFor(row: BenchmarkMatrixSummaryRow): readonly BenchmarkMetricKey[] {
|
||||
if (row.scenarioFamily !== "mechanics") return ROLE_BENCHMARK_METRICS[row.role];
|
||||
return [...ROLE_BENCHMARK_METRICS[row.role], ...MECHANIC_METRICS];
|
||||
}
|
||||
|
||||
function formatRoleVector(row: BenchmarkMatrixSummaryRow): string {
|
||||
return roleMetricsFor(row).map((metric) => (
|
||||
`${METRIC_LABELS[metric]}: ${formatDistribution(metric, row.metrics[metric])}`
|
||||
)).join("; ");
|
||||
}
|
||||
|
||||
/** Renders a compact, role-separated view without collapsing metrics into a score. */
|
||||
export function renderBenchmarkMatrixMarkdown(input: BenchmarkReportInput): string {
|
||||
const summary = asSummary(input);
|
||||
const lines = [
|
||||
"# Balance benchmark summary",
|
||||
"",
|
||||
`Generated ${summary.generatedAt} with app ${summary.appVersion}. `
|
||||
+ `Cohort: level ${summary.cohort.level}, gear \`${summary.cohort.gearPresetId}\`, `
|
||||
+ `${summary.cohort.seeds.length} seed(s), duration scale ${summary.cohort.durationScale}.`,
|
||||
`Policy: \`${summary.cohort.policyId}\`.`,
|
||||
"",
|
||||
`${summary.validRunCount}/${summary.runCount} runs were valid; `
|
||||
+ `${summary.invalidRunCount} invalid run(s) are excluded from distributions. `
|
||||
+ "Values are median [p10–p90].",
|
||||
];
|
||||
|
||||
for (const role of ["tank", "healer", "damage"] as const) {
|
||||
const roleRows = summary.rows.filter((row) => row.role === role);
|
||||
if (roleRows.length === 0) continue;
|
||||
|
||||
lines.push(
|
||||
"",
|
||||
`## ${ROLE_LABELS[role]}`,
|
||||
"",
|
||||
"| Scenario | Build | Runs | Role vector |",
|
||||
"| --- | --- | ---: | --- |",
|
||||
);
|
||||
for (const row of roleRows) {
|
||||
const scenario = `${row.scenarioName} (v${row.scenarioVersion})`;
|
||||
const secondary = row.secondaryClassId ? ` / ${row.secondaryClassId}` : "";
|
||||
const build = `${row.buildId} — ${row.source}:${row.classId}${secondary}`;
|
||||
const runs = `${row.validRunCount}/${row.runCount}`;
|
||||
lines.push(
|
||||
`| ${markdownCell(scenario)} | ${markdownCell(build)} | ${runs} | `
|
||||
+ `${markdownCell(formatRoleVector(row))} |`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (summary.rows.length === 0) {
|
||||
lines.push("", "No benchmark runs were supplied.");
|
||||
}
|
||||
|
||||
lines.push(
|
||||
"",
|
||||
"Compare builds only within the same scenario version and cohort. "
|
||||
+ "Role metrics remain separate; this report does not calculate a composite score.",
|
||||
);
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loadCanonicalBenchmarkBuilds } from "./benchmarkBuilds";
|
||||
import { runCombatBenchmark } from "./benchmarkRunner";
|
||||
import { benchmarkScenarioById } from "./benchmarkScenarios";
|
||||
|
||||
describe("combat benchmark runner", () => {
|
||||
it("replays the authoritative player path deterministically", async () => {
|
||||
const [build] = await loadCanonicalBenchmarkBuilds(60, ["mage"]);
|
||||
const scenario = benchmarkScenarioById("damage-single-burst")!;
|
||||
const options = { seed: 42, durationScale: 0.12 } as const;
|
||||
|
||||
const first = await runCombatBenchmark(build, scenario, options);
|
||||
const second = await runCombatBenchmark(build, scenario, options);
|
||||
|
||||
expect(second).toEqual(first);
|
||||
expect(first.validation).toEqual({ valid: true, reasons: [] });
|
||||
expect(first.metrics.effectiveDamage).toBeGreaterThan(0);
|
||||
expect(first.metrics.priorityTargetDamage).toBe(first.metrics.effectiveDamage);
|
||||
expect(first.metrics.castsCompleted).toBeGreaterThan(0);
|
||||
expect(first.eventCounts.damage).toBeGreaterThan(0);
|
||||
}, 60_000);
|
||||
|
||||
it("treats avoided reference attacks as valid tank pressure", async () => {
|
||||
const [build] = await loadCanonicalBenchmarkBuilds(60, ["guardian"]);
|
||||
const scenario = benchmarkScenarioById("tank-mixed-boss")!;
|
||||
|
||||
const report = await runCombatBenchmark(build, scenario, { seed: 1, durationScale: 0.1 });
|
||||
|
||||
expect(report.validation).toEqual({ valid: true, reasons: [] });
|
||||
expect(report.metrics.incomingAttacks).toBeGreaterThan(0);
|
||||
expect(report.metrics.avoidedAttacks).toBeGreaterThan(0);
|
||||
expect(report.metrics.avoidancePercent).toBeGreaterThan(0);
|
||||
}, 60_000);
|
||||
|
||||
it("keeps benchmark bosses attacking through player hard control", async () => {
|
||||
const [build] = await loadCanonicalBenchmarkBuilds(60, ["starcaller"]);
|
||||
const scenario = benchmarkScenarioById("tank-mixed-boss")!;
|
||||
|
||||
const report = await runCombatBenchmark(build, scenario, { seed: 1, durationScale: 0.1 });
|
||||
|
||||
expect(report.validation).toEqual({ valid: true, reasons: [] });
|
||||
expect(report.metrics.incomingAttacks).toBeGreaterThan(0);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,435 @@
|
||||
import { preloadAbilityCatalogsForClasses, type MobStatusKind } from "../abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID, type ThreatSource } from "../aggro";
|
||||
import type { AzerothCoreMechanic } from "../azerothCoreReference";
|
||||
import { installCombatClock } from "../combatClock";
|
||||
import { useCombatStore } from "../combatStore";
|
||||
import { resetCombatSpatialProvider } from "../combatSpatial";
|
||||
import type { EnemyAttackDefinition } from "../enemyAttacks";
|
||||
import { clearMobRuntimeRegistry, getMobPosition, setMobPosition } from "../mobRuntimeRegistry";
|
||||
import {
|
||||
clearPartyRuntimeRegistry,
|
||||
getPartyRuntimePosition,
|
||||
registerPartyRuntimePosition,
|
||||
} from "../partyRuntimeRegistry";
|
||||
import { resetPartyRuntimeClock } from "../partyRuntime";
|
||||
import { usePartyStore } from "../partyStore";
|
||||
import type { PartyRole } from "../partyRoles";
|
||||
import type { CombatPosition } from "../mobRuntimeRegistry";
|
||||
import { BenchmarkController } from "./benchmarkController";
|
||||
import { BenchmarkRecorder } from "./benchmarkRecorder";
|
||||
import type {
|
||||
BenchmarkBuildDefinition,
|
||||
BenchmarkRunOptions,
|
||||
BenchmarkRunReport,
|
||||
BenchmarkScenarioDefinition,
|
||||
} from "./benchmarkTypes";
|
||||
|
||||
const BENCHMARK_CLOCK_ORIGIN = 1_000_000;
|
||||
const BENCHMARK_MOB_HEALTH = 1_000_000_000;
|
||||
const BENCHMARK_BOSS_CONTROL_IMMUNITIES: readonly AzerothCoreMechanic[] = Object.freeze([
|
||||
"charm",
|
||||
"disarm",
|
||||
"disorient",
|
||||
"fear",
|
||||
"freeze",
|
||||
"grip",
|
||||
"horror",
|
||||
"knockout",
|
||||
"pacify",
|
||||
"polymorph",
|
||||
"root",
|
||||
"sap",
|
||||
"shackle",
|
||||
"silence",
|
||||
"sleep",
|
||||
"snare",
|
||||
"stun",
|
||||
"turn",
|
||||
]);
|
||||
const BENCHMARK_CONTROL_STATUS_KINDS: ReadonlySet<MobStatusKind> = new Set([
|
||||
"blind",
|
||||
"charm",
|
||||
"confuse",
|
||||
"disarm",
|
||||
"fear",
|
||||
"freeze",
|
||||
"knockdown",
|
||||
"pacify",
|
||||
"silence",
|
||||
"sleep",
|
||||
"stun",
|
||||
]);
|
||||
|
||||
function scaledDuration(scenario: BenchmarkScenarioDefinition, scale: number): number {
|
||||
const requested = scenario.durationMs * Math.max(0.001, scale);
|
||||
return Math.max(scenario.tickMs, Math.round(requested / scenario.tickMs) * scenario.tickMs);
|
||||
}
|
||||
|
||||
function partyPositions(layout: BenchmarkScenarioDefinition["partyLayout"], count: number): readonly CombatPosition[] {
|
||||
if (layout === "spread") {
|
||||
const spread: readonly CombatPosition[] = [
|
||||
[0, 0, 1],
|
||||
[-12, 0, -3],
|
||||
[12, 0, -3],
|
||||
[0, 0, -12],
|
||||
];
|
||||
return spread.slice(0, count);
|
||||
}
|
||||
if (layout === "stacked") {
|
||||
const stacked: readonly CombatPosition[] = [
|
||||
[-1, 0, 0.5],
|
||||
[1, 0, 0.5],
|
||||
[-0.5, 0, -1],
|
||||
[0.5, 0, -1],
|
||||
];
|
||||
return stacked.slice(0, count);
|
||||
}
|
||||
const melee: readonly CombatPosition[] = [
|
||||
[-1.5, 0, 1],
|
||||
[1.5, 0, 1],
|
||||
[-2.5, 0, -1],
|
||||
[2.5, 0, -1],
|
||||
];
|
||||
return melee.slice(0, count);
|
||||
}
|
||||
|
||||
function mobPosition(index: number, count: number, ringRadius = 2): CombatPosition {
|
||||
if (index === 0 || count <= 1) return [0, 0, 2.5];
|
||||
const angle = (index - 1) / Math.max(1, count - 1) * Math.PI * 2;
|
||||
return [Math.sin(angle) * ringRadius, 0, 2.5 + Math.cos(angle) * ringRadius];
|
||||
}
|
||||
|
||||
function benchmarkMobRingRadius(scenario: BenchmarkScenarioDefinition): number {
|
||||
return scenario.id === "damage-aoe-eight" ? 6 : 2;
|
||||
}
|
||||
|
||||
/** Keep scenario geometry fixed so pulls and knockbacks cannot change the pressure schedule. */
|
||||
function restoreBenchmarkMobPositions(
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
mobIds: readonly string[],
|
||||
): void {
|
||||
const ringRadius = benchmarkMobRingRadius(scenario);
|
||||
mobIds.forEach((id, index) => setMobPosition(
|
||||
id,
|
||||
mobPosition(index, mobIds.length, ringRadius),
|
||||
));
|
||||
}
|
||||
|
||||
function referenceAttackDamage(level: number): number {
|
||||
return 12 + level * 2;
|
||||
}
|
||||
|
||||
function enemyAttacks(
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
level: number,
|
||||
referenceHealth: number,
|
||||
): readonly EnemyAttackDefinition[] {
|
||||
const baseDamage = referenceAttackDamage(level);
|
||||
return scenario.attacks.map((profile) => ({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
delivery: profile.target === "primary" && profile.school === "physical" ? "melee" : "area",
|
||||
target: profile.target,
|
||||
school: profile.school,
|
||||
animation: profile.school === "physical" ? "attack" : "cast",
|
||||
range: profile.target === "primary" ? 5.5 : 45,
|
||||
cooldownMs: profile.intervalMs,
|
||||
initialCooldownMs: [profile.intervalMs, profile.intervalMs],
|
||||
repeatCooldownMs: [profile.intervalMs, profile.intervalMs],
|
||||
damageMultiplier: profile.damageMultiplier * referenceHealth / Math.max(1, baseDamage),
|
||||
...(profile.radius === undefined ? {} : { radius: profile.radius }),
|
||||
}));
|
||||
}
|
||||
|
||||
function movementPosition(
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
elapsedMs: number,
|
||||
): { readonly position: CombatPosition; readonly moving: boolean } {
|
||||
const movement = scenario.movement;
|
||||
if (!movement || elapsedMs < movement.startsAtMs) return { position: [0, 0, 0], moving: false };
|
||||
const cycle = (elapsedMs - movement.startsAtMs) % movement.periodMs;
|
||||
if (cycle < movement.outboundMs) {
|
||||
return {
|
||||
position: [movement.distance * cycle / Math.max(1, movement.outboundMs), 0, 0],
|
||||
moving: true,
|
||||
};
|
||||
}
|
||||
const returnStart = movement.outboundMs;
|
||||
if (cycle < returnStart + movement.returnMs) {
|
||||
const progress = (cycle - returnStart) / Math.max(1, movement.returnMs);
|
||||
return { position: [movement.distance * (1 - progress), 0, 0], moving: true };
|
||||
}
|
||||
return { position: [0, 0, 0], moving: false };
|
||||
}
|
||||
|
||||
function distance(left: CombatPosition, right: CombatPosition): number {
|
||||
return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]);
|
||||
}
|
||||
|
||||
function stackCompliant(scenario: BenchmarkScenarioDefinition): boolean | null {
|
||||
if (scenario.partyLayout !== "stacked") return null;
|
||||
const combat = useCombatStore.getState();
|
||||
return usePartyStore.getState().members
|
||||
.filter((member) => member.health > 0)
|
||||
.every((member) => {
|
||||
const position = getPartyRuntimePosition(member.id);
|
||||
return position !== null && distance(combat.playerPosition, position) <= 5;
|
||||
});
|
||||
}
|
||||
|
||||
function primaryThreatSource(role: PartyRole): ThreatSource {
|
||||
if (role === "tank") return { actorId: PLAYER_AGGRO_ID, role: "tank", abilityId: "benchmark-pull" };
|
||||
const tank = usePartyStore.getState().members.find((member) => member.role === "tank");
|
||||
return {
|
||||
actorId: tank?.id ?? usePartyStore.getState().members[0]?.id ?? PLAYER_AGGRO_ID,
|
||||
role: "tank",
|
||||
abilityId: "benchmark-pull",
|
||||
};
|
||||
}
|
||||
|
||||
function seedThreat(mobIds: readonly string[], role: PartyRole, now: number): void {
|
||||
const source = primaryThreatSource(role);
|
||||
for (const id of mobIds) {
|
||||
useCombatStore.getState().damageMob(id, 1, now, source, "physical");
|
||||
}
|
||||
}
|
||||
|
||||
function applyTankThreatCompetition(mobIds: readonly string[], now: number): void {
|
||||
const damageMembers = usePartyStore.getState().members.filter((member) => member.role === "damage").slice(0, 2);
|
||||
for (const member of damageMembers) {
|
||||
for (const id of mobIds) {
|
||||
useCombatStore.getState().damageMob(
|
||||
id,
|
||||
Math.max(1, Math.round(member.level * 0.9)),
|
||||
now,
|
||||
{ actorId: member.id, role: "damage", abilityId: "benchmark-reference-dps" },
|
||||
"physical",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function enforceExecuteWindow(
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
priorityTargetId: string,
|
||||
elapsedMs: number,
|
||||
durationMs: number,
|
||||
): void {
|
||||
if (scenario.id !== "damage-single-sustained" || elapsedMs < durationMs * 0.8) return;
|
||||
useCombatStore.setState((state) => {
|
||||
const target = state.mobs[priorityTargetId];
|
||||
if (!target || target.dead || target.health / target.maxHealth <= 0.15) return state;
|
||||
return {
|
||||
mobs: {
|
||||
...state.mobs,
|
||||
[priorityTargetId]: { ...target, health: Math.max(1, Math.round(target.maxHealth * 0.15)) },
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Reference bosses ignore player hard control so every tank receives the same attack schedule. */
|
||||
function clearBenchmarkControlStatuses(): void {
|
||||
useCombatStore.setState((state) => {
|
||||
let changed = false;
|
||||
const mobs = Object.fromEntries(Object.entries(state.mobs).map(([id, mob]) => {
|
||||
const statuses = mob.statuses.filter((status) => !BENCHMARK_CONTROL_STATUS_KINDS.has(status.kind));
|
||||
if (statuses.length !== mob.statuses.length) changed = true;
|
||||
return [id, statuses.length === mob.statuses.length ? mob : { ...mob, statuses }];
|
||||
}));
|
||||
return changed ? { mobs } : state;
|
||||
});
|
||||
}
|
||||
|
||||
function anyInjuredBelow(percent: number): boolean {
|
||||
const combat = useCombatStore.getState();
|
||||
if (combat.health > 0 && combat.health / Math.max(1, combat.maxHealth) < percent) return true;
|
||||
return usePartyStore.getState().members.some((member) => (
|
||||
member.health > 0 && member.health / Math.max(1, member.maxHealth) < percent
|
||||
));
|
||||
}
|
||||
|
||||
/** Runs one build/scenario pair against the real authoritative player combat path. */
|
||||
export async function runCombatBenchmark(
|
||||
build: BenchmarkBuildDefinition,
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
options: BenchmarkRunOptions = {},
|
||||
): Promise<BenchmarkRunReport> {
|
||||
if (!scenario.roles.includes(build.role)) {
|
||||
throw new Error(`Scenario ${scenario.id} does not support the ${build.role} role.`);
|
||||
}
|
||||
const seed = Math.trunc(options.seed ?? 1);
|
||||
const durationMs = scaledDuration(scenario, options.durationScale ?? 1);
|
||||
const classIds = build.secondaryClassId ? [build.classId, build.secondaryClassId] : [build.classId];
|
||||
await preloadAbilityCatalogsForClasses(classIds);
|
||||
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
resetCombatSpatialProvider();
|
||||
let now = BENCHMARK_CLOCK_ORIGIN;
|
||||
const restoreClock = installCombatClock(() => now);
|
||||
let recorder: BenchmarkRecorder | null = null;
|
||||
let simulatedDurationMs = 0;
|
||||
let completed = true;
|
||||
try {
|
||||
const combat = useCombatStore.getState();
|
||||
combat.initializeCharacter({
|
||||
classId: build.classId,
|
||||
secondaryClassId: build.secondaryClassId,
|
||||
level: build.level,
|
||||
talentRanks: build.talentRanks,
|
||||
inventory: build.inventory,
|
||||
equipment: build.equipment,
|
||||
});
|
||||
combat.setPlayerPosition([0, 0, 0]);
|
||||
const initializedCombat = useCombatStore.getState();
|
||||
usePartyStore.getState().initializeParty(
|
||||
`benchmark:${build.id}:${scenario.id}:${seed}`,
|
||||
build.level,
|
||||
initializedCombat.maxHealth,
|
||||
build.role,
|
||||
5,
|
||||
build.source,
|
||||
);
|
||||
const party = usePartyStore.getState();
|
||||
const positions = partyPositions(scenario.partyLayout, party.members.length);
|
||||
party.members.forEach((member, index) => registerPartyRuntimePosition(member.id, positions[index] ?? [0, 0, 0]));
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
const referenceTank = build.role === "tank"
|
||||
? { maxHealth: initializedCombat.maxHealth }
|
||||
: party.members.find((member) => member.role === "tank") ?? { maxHealth: initializedCombat.maxHealth };
|
||||
const attacks = enemyAttacks(scenario, build.level, referenceTank.maxHealth);
|
||||
const mobIds = Array.from({ length: scenario.hostileTargets }, (_, index) => `benchmark-${scenario.id}-${seed}-${index + 1}`);
|
||||
const ringRadius = benchmarkMobRingRadius(scenario);
|
||||
mobIds.forEach((id, index) => {
|
||||
setMobPosition(id, mobPosition(index, mobIds.length, ringRadius));
|
||||
useCombatStore.getState().registerMob(id, {
|
||||
name: index === 0 ? "Benchmark Boss" : `Benchmark Target ${index + 1}`,
|
||||
boss: index === 0,
|
||||
level: build.level,
|
||||
maxHealth: BENCHMARK_MOB_HEALTH,
|
||||
xpReward: 0,
|
||||
hasLoot: false,
|
||||
moveSpeed: 0,
|
||||
aggroRange: 45,
|
||||
leashRange: 60,
|
||||
attacks,
|
||||
mechanicImmunities: BENCHMARK_BOSS_CONTROL_IMMUNITIES,
|
||||
});
|
||||
});
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
seedThreat(mobIds, build.role, now);
|
||||
useCombatStore.getState().selectMob(mobIds[0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
recorder = new BenchmarkRecorder(build, scenario, seed, now, mobIds[0]);
|
||||
const controller = new BenchmarkController();
|
||||
recorder.sample({
|
||||
now,
|
||||
deltaMs: 0,
|
||||
combat: useCombatStore.getState(),
|
||||
party: usePartyStore.getState(),
|
||||
movedDistance: 0,
|
||||
moving: false,
|
||||
stackCompliant: stackCompliant(scenario),
|
||||
heldMobs: 0,
|
||||
totalThreatMobs: 0,
|
||||
});
|
||||
|
||||
let resourceFailureDecisions = 0;
|
||||
let previousPosition: CombatPosition = [0, 0, 0];
|
||||
let nextThreatCompetitionAt = 2_000;
|
||||
while (simulatedDurationMs < durationMs) {
|
||||
const deltaMs = Math.min(scenario.tickMs, durationMs - simulatedDurationMs);
|
||||
simulatedDurationMs += deltaMs;
|
||||
now = BENCHMARK_CLOCK_ORIGIN + simulatedDurationMs;
|
||||
const movement = movementPosition(scenario, simulatedDurationMs);
|
||||
useCombatStore.getState().setPlayerPosition(movement.position);
|
||||
const movedDistance = distance(previousPosition, movement.position);
|
||||
previousPosition = movement.position;
|
||||
|
||||
restoreBenchmarkMobPositions(scenario, mobIds);
|
||||
useCombatStore.getState().tick(deltaMs / 1_000, now);
|
||||
enforceExecuteWindow(scenario, mobIds[0], simulatedDurationMs, durationMs);
|
||||
restoreBenchmarkMobPositions(scenario, mobIds);
|
||||
if (scenario.attacks.length) {
|
||||
clearBenchmarkControlStatuses();
|
||||
useCombatStore.getState().advanceMobCombat(now, movement.position);
|
||||
}
|
||||
if (scenario.id === "tank-multi-target" && simulatedDurationMs >= nextThreatCompetitionAt) {
|
||||
applyTankThreatCompetition(mobIds, now);
|
||||
nextThreatCompetitionAt += 2_000;
|
||||
}
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
const decision = controller.act(now, build.role, scenario, movement.moving);
|
||||
for (let index = 0; index < decision.invalidFailures; index += 1) recorder.recordInvalidCast();
|
||||
if (scenario.resourceFailureGcds && decision.made && anyInjuredBelow(0.8)) {
|
||||
resourceFailureDecisions = decision.cast || !decision.resourceBlocked
|
||||
? 0
|
||||
: resourceFailureDecisions + 1;
|
||||
if (resourceFailureDecisions >= scenario.resourceFailureGcds) recorder.markResourceFailure(now);
|
||||
}
|
||||
|
||||
const currentCombat = useCombatStore.getState();
|
||||
const currentParty = usePartyStore.getState();
|
||||
const activeMobs = Object.values(currentCombat.mobs).filter((mob) => mob.engaged && !mob.dead);
|
||||
recorder.sample({
|
||||
now,
|
||||
deltaMs,
|
||||
combat: currentCombat,
|
||||
party: currentParty,
|
||||
movedDistance,
|
||||
moving: movement.moving,
|
||||
stackCompliant: stackCompliant(scenario),
|
||||
heldMobs: build.role === "tank"
|
||||
? activeMobs.filter((mob) => mob.targetActorId === PLAYER_AGGRO_ID).length
|
||||
: 0,
|
||||
totalThreatMobs: build.role === "tank" ? activeMobs.length : 0,
|
||||
});
|
||||
|
||||
const playerDied = currentCombat.health <= 0 && (scenario.stopOnPlayerDeath || build.role === "tank");
|
||||
const partyDied = currentParty.members.some((member) => member.health <= 0)
|
||||
&& (scenario.stopOnPartyDeath || build.role === "healer");
|
||||
const resourceFailed = scenario.resourceFailureGcds !== undefined
|
||||
&& resourceFailureDecisions >= scenario.resourceFailureGcds;
|
||||
if (playerDied || partyDied || resourceFailed) {
|
||||
completed = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return recorder.finish(simulatedDurationMs, completed);
|
||||
} catch (error: unknown) {
|
||||
if (!recorder) throw error;
|
||||
const partial = recorder.finish(simulatedDurationMs, false);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return Object.freeze({
|
||||
...partial,
|
||||
completed: false,
|
||||
validation: Object.freeze({
|
||||
valid: false,
|
||||
reasons: Object.freeze([...partial.validation.reasons, `Runtime error: ${message}`]),
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
recorder?.dispose();
|
||||
restoreClock();
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
resetCombatSpatialProvider();
|
||||
}
|
||||
}
|
||||
|
||||
/** Selects only scenario/build combinations that share a declared role. */
|
||||
export function compatibleBenchmarkPairs(
|
||||
builds: readonly BenchmarkBuildDefinition[],
|
||||
scenarios: readonly BenchmarkScenarioDefinition[],
|
||||
): readonly (readonly [BenchmarkBuildDefinition, BenchmarkScenarioDefinition])[] {
|
||||
return builds.flatMap((build) => scenarios
|
||||
.filter((scenario) => scenario.roles.includes(build.role))
|
||||
.map((scenario) => [build, scenario] as const));
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PartyRole } from "../partyRoles";
|
||||
import { BENCHMARK_SCENARIOS, benchmarkScenarioById } from "./benchmarkScenarios";
|
||||
|
||||
const EXPECTED_DURATIONS_MS: Readonly<Record<string, number>> = Object.freeze({
|
||||
"damage-single-burst": 30_000,
|
||||
"damage-single-sustained": 180_000,
|
||||
"damage-cleave-three": 90_000,
|
||||
"damage-aoe-eight": 60_000,
|
||||
"tank-mixed-boss": 180_000,
|
||||
"tank-multi-target": 120_000,
|
||||
"heal-single-target": 90_000,
|
||||
"heal-spread-party": 180_000,
|
||||
"heal-stacked-party": 90_000,
|
||||
"heal-resource-endurance": 600_000,
|
||||
"mechanic-movement-boss": 180_000,
|
||||
"mechanic-stack-boss": 180_000,
|
||||
});
|
||||
|
||||
describe("benchmark scenario catalog", () => {
|
||||
it("uses unique stable ids and positive integer versions", () => {
|
||||
const ids = BENCHMARK_SCENARIOS.map((scenario) => scenario.id);
|
||||
|
||||
expect(ids).toHaveLength(Object.keys(EXPECTED_DURATIONS_MS).length);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect([...ids].sort()).toEqual(Object.keys(EXPECTED_DURATIONS_MS).sort());
|
||||
expect(BENCHMARK_SCENARIOS.every(
|
||||
(scenario) => Number.isInteger(scenario.version) && scenario.version >= 1,
|
||||
)).toBe(true);
|
||||
expect(BENCHMARK_SCENARIOS.every((scenario) => scenario.version === 1)).toBe(true);
|
||||
|
||||
for (const scenario of BENCHMARK_SCENARIOS) {
|
||||
expect(benchmarkScenarioById(scenario.id)).toBe(scenario);
|
||||
}
|
||||
expect(benchmarkScenarioById("missing-scenario")).toBeNull();
|
||||
});
|
||||
|
||||
it("covers every combat role and keeps role-specific families scoped", () => {
|
||||
const expectedRoles: readonly PartyRole[] = ["tank", "healer", "damage"];
|
||||
const coveredRoles = new Set(BENCHMARK_SCENARIOS.flatMap((scenario) => scenario.roles));
|
||||
|
||||
expect([...coveredRoles].sort()).toEqual([...expectedRoles].sort());
|
||||
expect(BENCHMARK_SCENARIOS.every((scenario) => (
|
||||
scenario.roles.length > 0
|
||||
&& scenario.roles.every((role) => expectedRoles.includes(role))
|
||||
))).toBe(true);
|
||||
expect(BENCHMARK_SCENARIOS.filter((scenario) => scenario.family === "damage")
|
||||
.every((scenario) => scenario.roles.length === 1 && scenario.roles[0] === "damage"))
|
||||
.toBe(true);
|
||||
expect(BENCHMARK_SCENARIOS.filter((scenario) => scenario.family === "tanking")
|
||||
.every((scenario) => scenario.roles.length === 1 && scenario.roles[0] === "tank"))
|
||||
.toBe(true);
|
||||
expect(BENCHMARK_SCENARIOS.filter((scenario) => (
|
||||
scenario.family === "healing" || scenario.family === "endurance"
|
||||
)).every((scenario) => scenario.roles.length === 1 && scenario.roles[0] === "healer"))
|
||||
.toBe(true);
|
||||
expect(BENCHMARK_SCENARIOS.filter((scenario) => scenario.family === "mechanics")
|
||||
.every((scenario) => expectedRoles.every((role) => scenario.roles.includes(role))))
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
it("preserves exact durations on an integral fixed-step timeline", () => {
|
||||
for (const scenario of BENCHMARK_SCENARIOS) {
|
||||
expect(scenario.durationMs).toBe(EXPECTED_DURATIONS_MS[scenario.id]);
|
||||
expect(Number.isInteger(scenario.durationMs)).toBe(true);
|
||||
expect(Number.isInteger(scenario.tickMs)).toBe(true);
|
||||
expect(scenario.durationMs).toBeGreaterThan(0);
|
||||
expect(scenario.tickMs).toBeGreaterThan(0);
|
||||
expect(scenario.durationMs % scenario.tickMs).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { BenchmarkEnemyAttackProfile, BenchmarkScenarioDefinition } from "./benchmarkTypes";
|
||||
|
||||
const roles = ["tank", "healer", "damage"] as const;
|
||||
|
||||
const attack = (
|
||||
id: string,
|
||||
name: string,
|
||||
target: BenchmarkEnemyAttackProfile["target"],
|
||||
intervalMs: number,
|
||||
damageMultiplier: number,
|
||||
options: Pick<BenchmarkEnemyAttackProfile, "school" | "radius"> = { school: "physical" },
|
||||
): BenchmarkEnemyAttackProfile => Object.freeze({
|
||||
id,
|
||||
name,
|
||||
target,
|
||||
intervalMs,
|
||||
damageMultiplier,
|
||||
school: options.school,
|
||||
...(options.radius === undefined ? {} : { radius: options.radius }),
|
||||
});
|
||||
|
||||
/** Versioned, fixed-input contracts used by the automated balance matrix. */
|
||||
export const BENCHMARK_SCENARIOS: readonly BenchmarkScenarioDefinition[] = Object.freeze([
|
||||
{
|
||||
id: "damage-single-burst",
|
||||
version: 1,
|
||||
name: "Single-target burst",
|
||||
description: "Thirty seconds on an immortal boss with all cooldowns ready.",
|
||||
family: "damage",
|
||||
roles: ["damage"],
|
||||
durationMs: 30_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "none",
|
||||
attacks: [],
|
||||
},
|
||||
{
|
||||
id: "damage-single-sustained",
|
||||
version: 1,
|
||||
name: "Single-target sustained",
|
||||
description: "Three minutes on an immortal boss, including a scripted execute window.",
|
||||
family: "damage",
|
||||
roles: ["damage"],
|
||||
durationMs: 180_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "none",
|
||||
attacks: [],
|
||||
},
|
||||
{
|
||||
id: "damage-cleave-three",
|
||||
version: 1,
|
||||
name: "Three-target cleave",
|
||||
description: "Three immortal targets packed within five yards of the priority target.",
|
||||
family: "damage",
|
||||
roles: ["damage"],
|
||||
durationMs: 90_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 3,
|
||||
partyLayout: "none",
|
||||
attacks: [],
|
||||
},
|
||||
{
|
||||
id: "damage-aoe-eight",
|
||||
version: 1,
|
||||
name: "Eight-target AoE",
|
||||
description: "A priority target surrounded by seven immortal targets on a six-yard ring.",
|
||||
family: "damage",
|
||||
roles: ["damage"],
|
||||
durationMs: 60_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 8,
|
||||
partyLayout: "none",
|
||||
attacks: [],
|
||||
},
|
||||
{
|
||||
id: "tank-mixed-boss",
|
||||
version: 1,
|
||||
name: "Mixed boss tanking",
|
||||
description: "Physical swings plus a periodic magical tank buster.",
|
||||
family: "tanking",
|
||||
roles: ["tank"],
|
||||
durationMs: 180_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "melee",
|
||||
attacks: [
|
||||
attack("benchmark-swing", "Reference Swing", "primary", 2_000, 0.08),
|
||||
attack("benchmark-buster", "Arcane Buster", "primary", 20_000, 0.42, { school: "arcane" }),
|
||||
],
|
||||
stopOnPlayerDeath: true,
|
||||
},
|
||||
{
|
||||
id: "tank-multi-target",
|
||||
version: 1,
|
||||
name: "Multi-target tanking",
|
||||
description: "Five enemies test AoE threat, mitigation, and survival under many swings.",
|
||||
family: "tanking",
|
||||
roles: ["tank"],
|
||||
durationMs: 120_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 5,
|
||||
partyLayout: "melee",
|
||||
attacks: [attack("benchmark-add-swing", "Add Swing", "primary", 2_000, 0.022)],
|
||||
stopOnPlayerDeath: true,
|
||||
},
|
||||
{
|
||||
id: "heal-single-target",
|
||||
version: 1,
|
||||
name: "Single-target healing",
|
||||
description: "A reference tank takes sustained swings and fifteen-second burst hits.",
|
||||
family: "healing",
|
||||
roles: ["healer"],
|
||||
durationMs: 90_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "melee",
|
||||
attacks: [
|
||||
attack("benchmark-tank-pressure", "Tank Pressure", "primary", 2_000, 0.12),
|
||||
attack("benchmark-tank-spike", "Tank Spike", "primary", 15_000, 0.55, { school: "shadow" }),
|
||||
],
|
||||
stopOnPartyDeath: true,
|
||||
},
|
||||
{
|
||||
id: "heal-spread-party",
|
||||
version: 1,
|
||||
name: "Spread party healing",
|
||||
description: "Party members spread apart while raid pulses and deterministic spot damage land.",
|
||||
family: "healing",
|
||||
roles: ["healer"],
|
||||
durationMs: 180_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "spread",
|
||||
attacks: [
|
||||
attack("benchmark-spread-tank", "Tank Pressure", "primary", 2_000, 0.07),
|
||||
attack("benchmark-spread-pulse", "Spread Pulse", "nearby-party", 6_000, 0.12, { school: "nature", radius: 40 }),
|
||||
attack("benchmark-spread-spot", "Spot Damage", "random-party", 12_000, 0.32, { school: "fire" }),
|
||||
],
|
||||
stopOnPartyDeath: true,
|
||||
},
|
||||
{
|
||||
id: "heal-stacked-party",
|
||||
version: 1,
|
||||
name: "Stacked party healing",
|
||||
description: "The group stacks within five yards for rapid party-wide pulses.",
|
||||
family: "healing",
|
||||
roles: ["healer"],
|
||||
durationMs: 90_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "stacked",
|
||||
attacks: [
|
||||
attack("benchmark-stack-tank", "Tank Pressure", "primary", 2_000, 0.05),
|
||||
attack("benchmark-stack-pulse", "Stack Pulse", "nearby-party", 3_000, 0.18, { school: "arcane", radius: 8 }),
|
||||
],
|
||||
stopOnPartyDeath: true,
|
||||
},
|
||||
{
|
||||
id: "heal-resource-endurance",
|
||||
version: 1,
|
||||
name: "Healing resource endurance",
|
||||
description: "Up to ten minutes of sustained tank and party pressure.",
|
||||
family: "endurance",
|
||||
roles: ["healer"],
|
||||
durationMs: 600_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "spread",
|
||||
attacks: [
|
||||
attack("benchmark-endurance-tank", "Endurance Swing", "primary", 2_000, 0.055),
|
||||
attack("benchmark-endurance-party", "Endurance Pulse", "nearby-party", 4_000, 0.045, { school: "nature", radius: 40 }),
|
||||
attack("benchmark-endurance-spot", "Endurance Spot Hit", "random-party", 15_000, 0.25, { school: "frost" }),
|
||||
],
|
||||
stopOnPartyDeath: true,
|
||||
resourceFailureGcds: 3,
|
||||
},
|
||||
{
|
||||
id: "mechanic-movement-boss",
|
||||
version: 1,
|
||||
name: "Movement boss",
|
||||
description: "Repeated outbound and return movement interrupts stationary casts and reduces uptime.",
|
||||
family: "mechanics",
|
||||
roles,
|
||||
durationMs: 180_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "melee",
|
||||
attacks: [
|
||||
attack("benchmark-movement-swing", "Movement Swing", "primary", 2_000, 0.055),
|
||||
attack("benchmark-movement-pulse", "Movement Pulse", "nearby-party", 8_000, 0.06, { school: "fire", radius: 40 }),
|
||||
],
|
||||
movement: { startsAtMs: 15_000, periodMs: 20_000, outboundMs: 4_000, returnMs: 2_000, distance: 8 },
|
||||
},
|
||||
{
|
||||
id: "mechanic-stack-boss",
|
||||
version: 1,
|
||||
name: "Stack boss",
|
||||
description: "The party remains tightly grouped so proximity healing and split-pressure tools are exercised.",
|
||||
family: "mechanics",
|
||||
roles,
|
||||
durationMs: 180_000,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "stacked",
|
||||
attacks: [
|
||||
attack("benchmark-stack-swing", "Stack Boss Swing", "primary", 2_000, 0.05),
|
||||
attack("benchmark-stack-mechanic", "Shared Burden", "nearby-party", 6_000, 0.15, { school: "shadow", radius: 8 }),
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
export function benchmarkScenarioById(id: string): BenchmarkScenarioDefinition | null {
|
||||
return BENCHMARK_SCENARIOS.find((scenario) => scenario.id === id) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { ClassId } from "../../app/characterCatalog";
|
||||
import type { ContentCategoryId } from "../../app/contentCategories";
|
||||
import type { EquipmentAssignments } from "../equipment";
|
||||
import type { ItemStats } from "../itemStats";
|
||||
import type { PartyRole } from "../partyRoles";
|
||||
|
||||
export type BenchmarkScenarioFamily = "damage" | "tanking" | "healing" | "mechanics" | "endurance";
|
||||
export type BenchmarkPartyLayout = "none" | "melee" | "spread" | "stacked";
|
||||
|
||||
export interface BenchmarkMovementPattern {
|
||||
readonly startsAtMs: number;
|
||||
readonly periodMs: number;
|
||||
readonly outboundMs: number;
|
||||
readonly returnMs: number;
|
||||
readonly distance: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkEnemyAttackProfile {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly target: "primary" | "random-party" | "nearby-party";
|
||||
readonly school: "physical" | "arcane" | "fire" | "frost" | "nature" | "shadow" | "holy";
|
||||
readonly intervalMs: number;
|
||||
readonly damageMultiplier: number;
|
||||
readonly radius?: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkScenarioDefinition {
|
||||
readonly id: string;
|
||||
readonly version: number;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly family: BenchmarkScenarioFamily;
|
||||
readonly roles: readonly PartyRole[];
|
||||
readonly durationMs: number;
|
||||
readonly tickMs: number;
|
||||
readonly hostileTargets: number;
|
||||
readonly partyLayout: BenchmarkPartyLayout;
|
||||
readonly attacks: readonly BenchmarkEnemyAttackProfile[];
|
||||
readonly movement?: BenchmarkMovementPattern;
|
||||
readonly stopOnPlayerDeath?: boolean;
|
||||
readonly stopOnPartyDeath?: boolean;
|
||||
readonly resourceFailureGcds?: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkBuildDefinition {
|
||||
readonly id: string;
|
||||
readonly source: ContentCategoryId;
|
||||
readonly classId: ClassId;
|
||||
readonly secondaryClassId?: ClassId | null;
|
||||
readonly role: PartyRole;
|
||||
readonly level: number;
|
||||
readonly orientation: string;
|
||||
readonly talentRanks: Readonly<Record<string, number>>;
|
||||
readonly inventory: readonly never[];
|
||||
readonly equipment: EquipmentAssignments;
|
||||
readonly gearPresetId: string;
|
||||
}
|
||||
|
||||
export interface BenchmarkRunOptions {
|
||||
readonly seed?: number;
|
||||
readonly durationScale?: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkAbilityMetrics {
|
||||
readonly abilityId: string;
|
||||
readonly castsStarted: number;
|
||||
readonly castsCompleted: number;
|
||||
readonly interrupts: number;
|
||||
readonly rawDamage: number;
|
||||
readonly effectiveDamage: number;
|
||||
readonly effectiveHealing: number;
|
||||
readonly overheal: number;
|
||||
readonly absorbsApplied: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkResourceMetrics {
|
||||
readonly id: string;
|
||||
readonly starting: number;
|
||||
readonly ending: number;
|
||||
readonly minimum: number;
|
||||
readonly maximum: number;
|
||||
readonly timeBelowTenPercentMs: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkRunMetrics {
|
||||
readonly rawDamage: number;
|
||||
readonly effectiveDamage: number;
|
||||
readonly priorityTargetDamage: number;
|
||||
readonly effectiveHealing: number;
|
||||
readonly rawHealing: number;
|
||||
readonly overheal: number;
|
||||
readonly absorbsApplied: number;
|
||||
readonly absorbsConsumed: number;
|
||||
readonly rawDamageTaken: number;
|
||||
readonly effectiveDamageTaken: number;
|
||||
readonly incomingAttacks: number;
|
||||
readonly avoidedAttacks: number;
|
||||
readonly deaths: number;
|
||||
readonly minimumPlayerHealthPercent: number;
|
||||
readonly minimumPartyHealthPercent: number;
|
||||
readonly castsStarted: number;
|
||||
readonly castsCompleted: number;
|
||||
readonly movementInterrupts: number;
|
||||
readonly invalidCasts: number;
|
||||
readonly distanceMoved: number;
|
||||
readonly movementTimeMs: number;
|
||||
readonly stackCompliancePercent: number | null;
|
||||
readonly threatControlPercent: number | null;
|
||||
readonly timeToResourceFailureMs: number | null;
|
||||
readonly timeToFirstDeathMs: number | null;
|
||||
readonly dps: number;
|
||||
readonly hps: number;
|
||||
readonly dtps: number;
|
||||
readonly overhealPercent: number;
|
||||
readonly mitigationPercent: number;
|
||||
readonly avoidancePercent: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkValidation {
|
||||
readonly valid: boolean;
|
||||
readonly reasons: readonly string[];
|
||||
}
|
||||
|
||||
export interface BenchmarkRunReport {
|
||||
readonly schemaVersion: 1;
|
||||
readonly runId: string;
|
||||
readonly policyId: string;
|
||||
readonly seed: number;
|
||||
readonly build: BenchmarkBuildDefinition;
|
||||
readonly scenario: BenchmarkScenarioDefinition;
|
||||
readonly simulatedDurationMs: number;
|
||||
readonly completed: boolean;
|
||||
readonly metrics: BenchmarkRunMetrics;
|
||||
readonly resources: readonly BenchmarkResourceMetrics[];
|
||||
readonly abilities: readonly BenchmarkAbilityMetrics[];
|
||||
readonly eventCounts: Readonly<Record<string, number>>;
|
||||
readonly validation: BenchmarkValidation;
|
||||
readonly gearStats: ItemStats;
|
||||
}
|
||||
|
||||
export interface BenchmarkMatrixReport {
|
||||
readonly schemaVersion: 1;
|
||||
readonly generatedAt: string;
|
||||
readonly appVersion: string;
|
||||
readonly cohort: {
|
||||
readonly level: number;
|
||||
readonly gearPresetId: string;
|
||||
readonly seeds: readonly number[];
|
||||
readonly durationScale: number;
|
||||
readonly policyId: string;
|
||||
};
|
||||
readonly scenarios: readonly BenchmarkScenarioDefinition[];
|
||||
readonly builds: readonly BenchmarkBuildDefinition[];
|
||||
readonly runs: readonly BenchmarkRunReport[];
|
||||
}
|
||||
Reference in new issue
Block a user