Release Healer Man 0.1.54
This commit is contained in:
1 parent
d6976e6ef6
commit
2dbb0acfc1
26 files changed
+1893
-912
No files matched your search
@@ -1,7 +1,8 @@
|
||||
import type { ClassId } from "../app/characterCatalog";
|
||||
import type { BaseClassId, ClassId } from "../app/characterCatalog";
|
||||
import { clampPlayerLevel, MAX_PLAYER_LEVEL } from "./progression";
|
||||
import {
|
||||
allocateTalentPoint,
|
||||
spentTalentPointsInTree,
|
||||
talentDescriptionForRank,
|
||||
talentNodeById,
|
||||
talentNodesForTree,
|
||||
@@ -10,9 +11,10 @@ import {
|
||||
talentTreesForClass,
|
||||
type TalentRanks,
|
||||
} from "./talentCatalog";
|
||||
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
|
||||
|
||||
export interface PartyTalentSpecializationDefinition {
|
||||
readonly classId: ClassId;
|
||||
readonly classId: BaseClassId;
|
||||
readonly specialization: string;
|
||||
readonly primaryTreeId: string;
|
||||
}
|
||||
@@ -33,10 +35,13 @@ export const PARTY_TALENT_SPECIALIZATIONS = Object.freeze([
|
||||
{ classId: "warrior", specialization: "Arms", primaryTreeId: "warrior-arms" },
|
||||
{ classId: "warrior", specialization: "Fury", primaryTreeId: "warrior-fury" },
|
||||
{ classId: "paladin", specialization: "Retribution", primaryTreeId: "paladin-retribution" },
|
||||
{ classId: "hunter", specialization: "Beast Mastery", primaryTreeId: "hunter-beast-mastery" },
|
||||
{ classId: "hunter", specialization: "Marksmanship", primaryTreeId: "hunter-marksmanship" },
|
||||
{ classId: "hunter", specialization: "Survival", primaryTreeId: "hunter-survival" },
|
||||
{ classId: "rogue", specialization: "Combat", primaryTreeId: "rogue-combat" },
|
||||
{ classId: "rogue", specialization: "Assassination", primaryTreeId: "rogue-assassination" },
|
||||
{ classId: "rogue", specialization: "Subtlety", primaryTreeId: "rogue-subtlety" },
|
||||
{ classId: "priest", specialization: "Discipline", primaryTreeId: "priest-discipline" },
|
||||
{ classId: "priest", specialization: "Shadow", primaryTreeId: "priest-shadow" },
|
||||
{ classId: "death-knight", specialization: "Frost", primaryTreeId: "death-knight-frost" },
|
||||
{ classId: "death-knight", specialization: "Unholy", primaryTreeId: "death-knight-unholy" },
|
||||
@@ -47,7 +52,9 @@ export const PARTY_TALENT_SPECIALIZATIONS = Object.freeze([
|
||||
{ classId: "mage", specialization: "Frost", primaryTreeId: "mage-frost" },
|
||||
{ classId: "warlock", specialization: "Affliction", primaryTreeId: "warlock-affliction" },
|
||||
{ classId: "warlock", specialization: "Demonology", primaryTreeId: "warlock-demonology" },
|
||||
{ classId: "warlock", specialization: "Destruction", primaryTreeId: "warlock-destruction" },
|
||||
{ classId: "druid", specialization: "Balance", primaryTreeId: "druid-balance" },
|
||||
{ classId: "druid", specialization: "Feral (Bear)", primaryTreeId: "druid-feral-combat" },
|
||||
{ classId: "druid", specialization: "Feral (Cat)", primaryTreeId: "druid-feral-combat" },
|
||||
] as const satisfies readonly PartyTalentSpecializationDefinition[]);
|
||||
|
||||
@@ -98,31 +105,85 @@ function requireSpecialization(
|
||||
return definition;
|
||||
}
|
||||
|
||||
function orderedCandidateNodeIds(definition: PartyTalentSpecializationDefinition): readonly string[] {
|
||||
function orderedCandidateNodeIds(
|
||||
definition: PartyTalentSpecializationDefinition,
|
||||
ranks: TalentRanks,
|
||||
): readonly string[] {
|
||||
const trees = talentTreesForClass(definition.classId);
|
||||
const primary = trees.find((tree) => tree.id === definition.primaryTreeId);
|
||||
if (!primary) {
|
||||
throw new Error(`Unknown primary talent tree ${definition.primaryTreeId} for ${definition.classId}`);
|
||||
}
|
||||
return [primary, ...trees.filter((tree) => tree.id !== primary.id)]
|
||||
const primaryNodes = talentNodesForTree(primary.id);
|
||||
const deepestPrimaryRow = Math.max(...primaryNodes.map((node) => node.row));
|
||||
const reachedDeepestPrimaryRow = primaryNodes.some((node) => (
|
||||
node.row === deepestPrimaryRow && (ranks[node.id] ?? 0) > 0
|
||||
));
|
||||
const primaryComplete = spentTalentPointsInTree(ranks, primary.id) >= 51
|
||||
&& reachedDeepestPrimaryRow;
|
||||
const eligibleTrees = primaryComplete
|
||||
? trees.filter((tree) => tree.id !== primary.id)
|
||||
: [primary];
|
||||
const treeOrder = new Map(eligibleTrees.map((tree, index) => [tree.id, index]));
|
||||
const activeTalentIds = new Set(
|
||||
WOW335_ABILITIES_BY_CLASS[definition.classId]
|
||||
.flatMap((ability) => ability.talentNodeId ? [ability.talentNodeId] : []),
|
||||
);
|
||||
const activePrerequisiteIds = new Set(
|
||||
[...activeTalentIds].flatMap((nodeId) => {
|
||||
const node = talentNodeById(nodeId);
|
||||
return node?.prerequisites.map((entry) => entry.talentId) ?? [];
|
||||
}),
|
||||
);
|
||||
const capstoneRequirements = new Map<string, number>();
|
||||
const addRequirement = (nodeId: string, requiredRank: number): void => {
|
||||
capstoneRequirements.set(nodeId, Math.max(requiredRank, capstoneRequirements.get(nodeId) ?? 0));
|
||||
const node = talentNodeById(nodeId);
|
||||
for (const prerequisite of node?.prerequisites ?? []) {
|
||||
addRequirement(prerequisite.talentId, prerequisite.requiredRank);
|
||||
}
|
||||
};
|
||||
const deepestNodes = primaryNodes.filter((node) => node.row === deepestPrimaryRow);
|
||||
const capstoneCandidates = deepestNodes.some((node) => activeTalentIds.has(node.id))
|
||||
? deepestNodes.filter((node) => activeTalentIds.has(node.id))
|
||||
: deepestNodes;
|
||||
const capstoneTarget = [...capstoneCandidates].sort((left, right) => left.index - right.index)[0];
|
||||
if (capstoneTarget) addRequirement(capstoneTarget.id, 1);
|
||||
const neededCapstonePathIds = new Set(
|
||||
[...capstoneRequirements].filter(([nodeId, requiredRank]) => (
|
||||
(ranks[nodeId] ?? 0) < requiredRank
|
||||
)).map(([nodeId]) => nodeId),
|
||||
);
|
||||
return eligibleTrees
|
||||
.flatMap((tree) => talentNodesForTree(tree.id))
|
||||
.sort((left, right) => (
|
||||
Number(neededCapstonePathIds.has(right.id)) - Number(neededCapstonePathIds.has(left.id))
|
||||
|| right.row - left.row
|
||||
|| Number(activeTalentIds.has(right.id)) - Number(activeTalentIds.has(left.id))
|
||||
|| Number(activePrerequisiteIds.has(right.id)) - Number(activePrerequisiteIds.has(left.id))
|
||||
|| (treeOrder.get(left.treeId) ?? 0) - (treeOrder.get(right.treeId) ?? 0)
|
||||
|| left.index - right.index
|
||||
))
|
||||
.map((node) => node.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces a stable legal build without duplicating talent rules here. The
|
||||
* primary tree is exhausted in catalog row/index order before secondary trees;
|
||||
* every individual rank is accepted by the shared allocator at its real level.
|
||||
* primary tree advances to its deepest tier with exactly the 51 points needed
|
||||
* for a Wrath capstone, then spends the remaining 20 points in one secondary
|
||||
* tree. Within each unlocked tier, executable talents and their prerequisites
|
||||
* are preferred. This mirrors the shape of the Playerbots PvE templates while
|
||||
* keeping every rank validated against Healer Man's imported talent catalog.
|
||||
*/
|
||||
function buildAllocationPlan(
|
||||
definition: PartyTalentSpecializationDefinition,
|
||||
): readonly PartyTalentSelection[] {
|
||||
const candidates = orderedCandidateNodeIds(definition);
|
||||
let ranks: TalentRanks = {};
|
||||
const selections: PartyTalentSelection[] = [];
|
||||
|
||||
for (let level = 10; level <= MAX_PLAYER_LEVEL; level += 1) {
|
||||
let selected: PartyTalentSelection | null = null;
|
||||
const candidates = orderedCandidateNodeIds(definition, ranks);
|
||||
for (const nodeId of candidates) {
|
||||
const node = talentNodeById(nodeId);
|
||||
if (!node || (ranks[nodeId] ?? 0) >= node.maxRank) continue;
|
||||
|
||||
Reference in new issue
Block a user