Release Healer Man 0.1.9

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

No files matched your search

+407
View File
@@ -0,0 +1,407 @@
import {
BASE_CLASS_IDS,
CLASSES,
COA_CLASS_IDS,
ROM_CLASS_IDS,
contentCategoryForClass,
type BaseClassId,
type ClassId,
type CoaClassId,
type RomClassId,
} from "../../app/characterCatalog";
import {
abilitiesForClass,
preloadAbilityCatalogsForClasses,
type AbilityAiHints,
type AbilityDefinition,
} from "../abilityCatalog";
import { aiHintsForAbility } from "../abilityRules";
import type { EquipmentAssignments } from "../equipment";
import { partyRolesForClass, type PartyRole } from "../partyRoles";
import { runtimePartyTalentRanksForLevel } from "../partyTalentRuntime";
import { clampPlayerLevel } from "../progression";
import {
runtimeAllocateTalentPoint,
runtimeNormalizeTalentRanks,
type RuntimeTalentRanks,
} from "../talentRuntimeCatalog";
import {
loadTalentUiCatalog,
type TalentUiCatalog,
type TalentUiNode,
type TalentUiTree,
} from "../talentUiCatalog";
import type { BenchmarkBuildDefinition } from "./benchmarkTypes";
export const CANONICAL_BENCHMARK_LEVEL = 60;
export const CANONICAL_BENCHMARK_GEAR_PRESET_ID = "coefficient-empty";
const EMPTY_INVENTORY: readonly never[] = Object.freeze([]);
const EMPTY_EQUIPMENT: EquipmentAssignments = Object.freeze({});
const EMPTY_TALENT_RANKS: RuntimeTalentRanks = Object.freeze({});
const BASE_CLASS_ID_SET: ReadonlySet<ClassId> = new Set(BASE_CLASS_IDS);
const COA_CLASS_ID_SET: ReadonlySet<ClassId> = new Set(COA_CLASS_IDS);
const ROM_CLASS_ID_SET: ReadonlySet<ClassId> = new Set(ROM_CLASS_IDS);
const WOW_ORIENTATION_BY_ROLE: Readonly<
Record<BaseClassId, Readonly<Partial<Record<PartyRole, string>>>>
> = Object.freeze({
warrior: { tank: "Protection", damage: "Fury" },
paladin: { tank: "Protection", healer: "Holy", damage: "Retribution" },
hunter: { damage: "Marksmanship" },
rogue: { damage: "Combat" },
priest: { healer: "Holy", damage: "Shadow" },
"death-knight": { tank: "Blood", damage: "Frost" },
shaman: { healer: "Restoration", damage: "Elemental" },
mage: { damage: "Fire" },
warlock: { damage: "Affliction" },
druid: { tank: "Feral (Cat)", healer: "Restoration", damage: "Balance" },
});
type AbilityAiCategory = AbilityAiHints["categories"][number];
/**
* The large gaps keep the primary responsibility of a role ahead of merely
* having more utility buttons. These are only selection weights; allocation
* legality and point costs remain entirely owned by the runtime talent system.
*/
const COA_ROLE_CATEGORY_WEIGHTS: Readonly<
Record<PartyRole, Readonly<Partial<Record<AbilityAiCategory, number>>>>
> = Object.freeze({
tank: Object.freeze({
taunt: 1_000,
mitigation: 500,
control: 50,
interrupt: 50,
heal: 20,
"emergency-heal": 20,
dispel: 15,
debuff: 10,
execute: 10,
damage: 10,
}),
healer: Object.freeze({
"emergency-heal": 800,
"area-heal": 700,
heal: 500,
mitigation: 100,
dispel: 80,
resurrection: 60,
control: 20,
interrupt: 15,
debuff: 5,
damage: 2,
}),
damage: Object.freeze({
execute: 200,
damage: 100,
debuff: 40,
interrupt: 30,
control: 20,
mitigation: 3,
}),
});
interface CoaRoleTextPattern {
readonly pattern: RegExp;
readonly weight: number;
}
// CoA specialization trees contain many passive nodes with no executable
// ability (and consequently no AbilityAiHints). Catalog text is a stable
// secondary signal for those nodes; executable ability hints still contribute
// directly whenever the runtime catalog provides them.
const COA_ROLE_TEXT_PATTERNS: Readonly<Record<PartyRole, readonly CoaRoleTextPattern[]>> = Object.freeze({
tank: Object.freeze([
{ pattern: /\b(?:taunt|threat)\b/i, weight: 300 },
{ pattern: /\b(?:damage (?:you )?take|damage taken|incoming damage|less damage)\b/i, weight: 160 },
{ pattern: /\b(?:armor|block|parry|dodge|deflect)\b/i, weight: 90 },
{ pattern: /\b(?:absorb|shield|barrier)\b/i, weight: 70 },
{ pattern: /\b(?:maximum health|max health|stamina)\b/i, weight: 50 },
{ pattern: /\b(?:protect|guard)\w*\b/i, weight: 35 },
{ pattern: /\b(?:resistan\w*|immune)\b/i, weight: 20 },
{ pattern: /\b(?:healing received|heal(?:s|ing)? you|restore\w* health)\b/i, weight: 15 },
]),
healer: Object.freeze([
{ pattern: /\b(?:resurrect|revive)\w*\b/i, weight: 200 },
{ pattern: /\b(?:heal|healing|restore health|recovery)\w*\b/i, weight: 100 },
{ pattern: /\b(?:dispel|cleanse|purif)\w*\b/i, weight: 60 },
{ pattern: /\b(?:absorb|shield)\w*\b/i, weight: 30 },
{ pattern: /\b(?:ally|allies|party member)\b/i, weight: 25 },
]),
damage: Object.freeze([
{ pattern: /\b(?:damage|damaging)\w*\b/i, weight: 25 },
{ pattern: /\b(?:attack power|spell power|critical strike|critical damage|haste)\b/i, weight: 12 },
{ pattern: /\b(?:enemy|enemies)\b/i, weight: 5 },
]),
});
function isBaseClassId(classId: ClassId): classId is BaseClassId {
return BASE_CLASS_ID_SET.has(classId);
}
function isCoaClassId(classId: ClassId): classId is CoaClassId {
return COA_CLASS_ID_SET.has(classId);
}
function isRomClassId(classId: ClassId): classId is RomClassId {
return ROM_CLASS_ID_SET.has(classId);
}
function benchmarkBuildId(classId: ClassId, role: PartyRole, level: number): string {
return `canonical-${contentCategoryForClass(classId)}-${classId}-${role}-level-${level}`;
}
function commonBuildFields(classId: ClassId, role: PartyRole, level: number) {
return {
id: benchmarkBuildId(classId, role, level),
source: contentCategoryForClass(classId),
classId,
secondaryClassId: null,
role,
level,
inventory: EMPTY_INVENTORY,
equipment: EMPTY_EQUIPMENT,
gearPresetId: CANONICAL_BENCHMARK_GEAR_PRESET_ID,
} as const;
}
function wowBuild(
classId: BaseClassId,
role: PartyRole,
level: number,
): BenchmarkBuildDefinition {
const orientation = WOW_ORIENTATION_BY_ROLE[classId][role];
if (!orientation) {
throw new Error(`No canonical WoW talent orientation exists for ${classId}:${role}.`);
}
return Object.freeze({
...commonBuildFields(classId, role, level),
orientation,
talentRanks: runtimePartyTalentRanksForLevel(classId, orientation, level),
});
}
function isClassTree(tree: TalentUiTree): boolean {
return tree.name.trim().toLowerCase() === "class";
}
function coaNodeAbilities(classId: CoaClassId): ReadonlyMap<string, readonly AbilityDefinition[]> {
const mutable = new Map<string, AbilityDefinition[]>();
for (const ability of abilitiesForClass(classId)) {
if (ability.talentEntryId === undefined) continue;
const nodeId = `coa-entry-${ability.talentEntryId}`;
const existing = mutable.get(nodeId);
if (existing) existing.push(ability);
else mutable.set(nodeId, [ability]);
}
return mutable;
}
function coaAbilityRoleScore(ability: AbilityDefinition, role: PartyRole): number {
const hints = aiHintsForAbility(ability);
const weights = COA_ROLE_CATEGORY_WEIGHTS[role];
const categoryScore = hints.categories.reduce(
(total, category) => total + (weights[category] ?? 0),
0,
);
if (categoryScore <= 0) return 0;
const priority = Number.isFinite(hints.priority)
? Math.max(0, Math.min(100, hints.priority ?? 0))
: 0;
return categoryScore + priority;
}
function coaNodeRoleScore(
nodeId: string,
role: PartyRole,
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
): number {
return (abilitiesByNode.get(nodeId) ?? []).reduce(
(highest, ability) => Math.max(highest, coaAbilityRoleScore(ability, role)),
0,
);
}
function coaNodeTextRoleScore(node: TalentUiNode, role: PartyRole): number {
const searchable = `${node.name} ${node.description}`;
return COA_ROLE_TEXT_PATTERNS[role].reduce(
(total, entry) => total + (entry.pattern.test(searchable) ? entry.weight : 0),
0,
);
}
function coaNodeSelectionScore(
node: TalentUiNode,
role: PartyRole,
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
): number {
return coaNodeRoleScore(node.id, role, abilitiesByNode) + coaNodeTextRoleScore(node, role);
}
function coaTreeRoleScore(
tree: TalentUiTree,
catalog: TalentUiCatalog,
role: PartyRole,
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
): number {
return catalog.nodes.reduce((total, node) => (
node.treeId === tree.id
? total + coaNodeSelectionScore(node, role, abilitiesByNode) * node.maxRank
: total
), 0);
}
function preferredCoaTree(
catalog: TalentUiCatalog,
role: PartyRole,
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
): TalentUiTree {
const specializationTrees = catalog.trees.filter((tree) => !isClassTree(tree));
const candidates = specializationTrees.length > 0 ? specializationTrees : catalog.trees;
const catalogOrder = new Map(catalog.trees.map((tree, index) => [tree.id, index]));
const selected = [...candidates].sort((left, right) => (
coaTreeRoleScore(right, catalog, role, abilitiesByNode)
- coaTreeRoleScore(left, catalog, role, abilitiesByNode)
|| (catalogOrder.get(left.id) ?? 0) - (catalogOrder.get(right.id) ?? 0)
|| left.id.localeCompare(right.id)
))[0];
if (!selected) throw new Error(`No CoA talent trees exist for ${catalog.classId}.`);
return selected;
}
function orderedCoaAllocationCandidates(
catalog: TalentUiCatalog,
preferredTree: TalentUiTree,
role: PartyRole,
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
): readonly TalentUiNode[] {
const classTreeIds = new Set(catalog.trees.filter(isClassTree).map((tree) => tree.id));
const treePriority = (node: TalentUiNode) => (
node.treeId === preferredTree.id ? 0 : classTreeIds.has(node.treeId) ? 1 : 2
);
return catalog.nodes
.filter((node) => node.treeId === preferredTree.id || classTreeIds.has(node.treeId))
.sort((left, right) => (
treePriority(left) - treePriority(right)
|| coaNodeSelectionScore(right, role, abilitiesByNode)
- coaNodeSelectionScore(left, role, abilitiesByNode)
|| left.row - right.row
|| left.column - right.column
|| left.index - right.index
|| left.id.localeCompare(right.id)
));
}
function allocateCoaTalentRanks(
classId: CoaClassId,
catalog: TalentUiCatalog,
preferredTree: TalentUiTree,
role: PartyRole,
level: number,
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
): RuntimeTalentRanks {
const candidates = orderedCoaAllocationCandidates(
catalog,
preferredTree,
role,
abilitiesByNode,
);
const successfulRequests: Record<string, number> = {};
let ranks: RuntimeTalentRanks = EMPTY_TALENT_RANKS;
// A successful request is never repeated, even if normalization later omits
// a free node. This keeps zero-cost prerequisite layouts finite while failed
// gated nodes remain eligible after lower rows have been filled.
while (true) {
let allocated = false;
for (const node of candidates) {
if ((successfulRequests[node.id] ?? 0) >= node.maxRank) continue;
const result = runtimeAllocateTalentPoint(ranks, classId, node.id, level);
if (!result.ok) continue;
successfulRequests[node.id] = (successfulRequests[node.id] ?? 0) + 1;
ranks = result.ranks;
allocated = true;
break;
}
if (!allocated) break;
}
return Object.freeze({ ...runtimeNormalizeTalentRanks(classId, level, ranks) });
}
function coaBuild(
classId: CoaClassId,
role: PartyRole,
level: number,
catalog: TalentUiCatalog,
): BenchmarkBuildDefinition {
const abilitiesByNode = coaNodeAbilities(classId);
const orientation = preferredCoaTree(catalog, role, abilitiesByNode);
return Object.freeze({
...commonBuildFields(classId, role, level),
orientation: orientation.name,
talentRanks: allocateCoaTalentRanks(
classId,
catalog,
orientation,
role,
level,
abilitiesByNode,
),
});
}
function romBuild(
classId: RomClassId,
role: PartyRole,
level: number,
): BenchmarkBuildDefinition {
return Object.freeze({
...commonBuildFields(classId, role, level),
orientation: "Primary",
talentRanks: EMPTY_TALENT_RANKS,
});
}
/**
* Creates one stable, legal benchmark build for every declared class-role pair.
* A filter changes only membership; output always follows the canonical CLASSES
* and partyRolesForClass declaration order.
*/
export async function loadCanonicalBenchmarkBuilds(
level = CANONICAL_BENCHMARK_LEVEL,
classIds?: readonly ClassId[],
): Promise<BenchmarkBuildDefinition[]> {
const safeLevel = clampPlayerLevel(level);
const requested = classIds ? new Set(classIds) : null;
const selectedClassIds = CLASSES
.map((definition) => definition.id)
.filter((classId) => !requested || requested.has(classId));
const selectedCoaClassIds = selectedClassIds.filter(isCoaClassId);
await preloadAbilityCatalogsForClasses(selectedCoaClassIds);
const coaCatalogs = new Map<CoaClassId, TalentUiCatalog>(await Promise.all(
selectedCoaClassIds.map(async (classId) => [
classId,
await loadTalentUiCatalog(classId),
] as const),
));
const builds: BenchmarkBuildDefinition[] = [];
for (const classId of selectedClassIds) {
for (const role of partyRolesForClass(classId)) {
if (isBaseClassId(classId)) {
builds.push(wowBuild(classId, role, safeLevel));
} else if (isCoaClassId(classId)) {
const catalog = coaCatalogs.get(classId);
if (!catalog) throw new Error(`No loaded CoA talent catalog exists for ${classId}.`);
builds.push(coaBuild(classId, role, safeLevel, catalog));
} else if (isRomClassId(classId)) {
builds.push(romBuild(classId, role, safeLevel));
}
}
}
return builds;
}