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,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;
|
||||
}
|
||||
Reference in new issue
Block a user