Files
healer-man/scripts/generate-party-runtime-talents.ts
T
2026-09-19 21:01:51 -04:00

279 lines
11 KiB
TypeScript

import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import type { BaseClassId } from "../src/app/characterCatalog";
import {
PARTY_TALENT_SPECIALIZATIONS,
partyTalentAllocationPlan,
} from "../src/game/partyTalents";
import {
allocateTalentPoint,
talentNodesForTree,
talentTreesForClass,
type TalentNodeDefinition,
type TalentRanks,
} from "../src/game/talentCatalog";
const outputPath = resolve("src/game/generated/partyTalentRuntimePlans.json");
const playerbotsConfigPath = resolve(
"../wow-pvt-server-backups-2/azerothcore-wotlk/modules/mod-playerbots/conf/playerbots.conf.dist",
);
const PLAYERBOTS_COMMIT = "531282e";
interface PlayerbotsSpecReference {
readonly classId: BaseClassId;
readonly classNumber: number;
readonly specNumber: number;
readonly specialization: string;
}
interface PlayerbotsMilestone {
readonly level: number;
readonly link: string;
}
interface GeneratedLevelPlan {
readonly minimumLevel: number;
readonly sourceLevels: readonly number[];
readonly sourcePointCount: number;
readonly sourcePointFlags: readonly boolean[];
readonly plan: readonly string[];
}
const PLAYERBOTS_PVE_SPECS: readonly PlayerbotsSpecReference[] = [
["warrior", 1, 0, "Arms"], ["warrior", 1, 1, "Fury"], ["warrior", 1, 2, "Protection"],
["paladin", 2, 0, "Holy"], ["paladin", 2, 1, "Protection"], ["paladin", 2, 2, "Retribution"],
["hunter", 3, 0, "Beast Mastery"], ["hunter", 3, 1, "Marksmanship"], ["hunter", 3, 2, "Survival"],
["rogue", 4, 0, "Assassination"], ["rogue", 4, 1, "Combat"], ["rogue", 4, 2, "Subtlety"],
["priest", 5, 0, "Discipline"], ["priest", 5, 1, "Holy"], ["priest", 5, 2, "Shadow"],
["death-knight", 6, 0, "Blood"], ["death-knight", 6, 1, "Frost"], ["death-knight", 6, 2, "Unholy"],
["shaman", 7, 0, "Elemental"], ["shaman", 7, 1, "Enhancement"], ["shaman", 7, 2, "Restoration"],
["mage", 8, 0, "Arcane"], ["mage", 8, 1, "Fire"], ["mage", 8, 2, "Frost"],
["warlock", 9, 0, "Affliction"], ["warlock", 9, 1, "Demonology"], ["warlock", 9, 2, "Destruction"],
["druid", 11, 0, "Balance"], ["druid", 11, 1, "Feral (Bear)"], ["druid", 11, 2, "Restoration"], ["druid", 11, 3, "Feral (Cat)"],
].map(([classId, classNumber, specNumber, specialization]) => ({
classId: classId as BaseClassId,
classNumber: classNumber as number,
specNumber: specNumber as number,
specialization: specialization as string,
}));
// Stable identities make a build recognizable in logs and leave room for
// multiple builds of the same class/spec without silently replacing one.
const CHARACTER_NAMES = [
"Aldren", "Briala", "Caelen", "Darian", "Elowen", "Faelar", "Gavran", "Helia",
"Ilyra", "Jorren", "Kaelis", "Liora", "Marek", "Nerys", "Orin", "Phaela",
"Quorin", "Rielle", "Soren", "Talia", "Ulric", "Vaela", "Wrenna", "Xavian",
"Ysara", "Zoren", "Arwyn", "Brenna", "Corvin", "Delia", "Edrin", "Fiora",
"Galen", "Hestia", "Isolde", "Jarek", "Kiera", "Lucan", "Mira", "Nolan",
"Oriana", "Perrin", "Quilla", "Ronan", "Selene", "Theron", "Una", "Varen",
"Willow", "Xara", "Yorin", "Zella", "Ansel", "Brynn", "Cyrus", "Dessa",
"Evren", "Freya", "Garrick", "Halen", "Ilara", "Jessa",
] as const;
function specKey(classId: BaseClassId, specialization: string): string {
return `${classId}:${specialization}`;
}
function slug(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
function parsePlayerbotsConfig(source: string): {
names: ReadonlyMap<string, string>;
links: ReadonlyMap<string, readonly PlayerbotsMilestone[]>;
} {
const names = new Map<string, string>();
const links = new Map<string, PlayerbotsMilestone[]>();
for (const rawLine of source.split(/\r?\n/)) {
const line = rawLine.trim();
const name = /^AiPlayerbot\.PremadeSpecName\.(\d+)\.(\d+)\s*=\s*(.+)$/.exec(line);
if (name) names.set(`${name[1]}:${name[2]}`, name[3].trim());
const link = /^AiPlayerbot\.PremadeSpecLink\.(\d+)\.(\d+)\.(\d+)\s*=\s*([0-9-]+)$/.exec(line);
if (!link) continue;
const key = `${link[1]}:${link[2]}`;
const existing = links.get(key) ?? [];
existing.push({ level: Number(link[3]), link: link[4] });
links.set(key, existing);
}
for (const milestones of links.values()) milestones.sort((left, right) => left.level - right.level);
return { names, links };
}
function orderedTreeNodes(treeId: string): readonly TalentNodeDefinition[] {
return [...talentNodesForTree(treeId)].sort((left, right) => (
left.row - right.row || left.column - right.column || left.index - right.index
));
}
function ranksFromPlayerbotsLink(classId: BaseClassId, link: string): TalentRanks {
const ranks: Record<string, number> = {};
const treeLinks = link.split("-");
for (const [treeIndex, tree] of talentTreesForClass(classId).entries()) {
const digits = treeLinks[treeIndex] ?? "";
for (const [nodeIndex, node] of orderedTreeNodes(tree.id).entries()) {
const digit = Number(digits[nodeIndex] ?? "0");
if (Number.isInteger(digit) && digit > 0) ranks[node.id] = Math.min(node.maxRank, digit);
}
}
return ranks;
}
function desiredNodeOrder(classId: BaseClassId, target: TalentRanks): readonly string[] {
const trees = talentTreesForClass(classId);
const treeScore = new Map(trees.map((tree) => [
tree.id,
orderedTreeNodes(tree.id).filter((node) => (target[node.id] ?? 0) > 0).length,
]));
const treeIndex = new Map(trees.map((tree, index) => [tree.id, index]));
return trees.flatMap((tree) => orderedTreeNodes(tree.id))
.filter((node) => (target[node.id] ?? 0) > 0)
.sort((left, right) => (
(treeScore.get(right.treeId) ?? 0) - (treeScore.get(left.treeId) ?? 0)
|| (treeIndex.get(left.treeId) ?? 0) - (treeIndex.get(right.treeId) ?? 0)
|| left.row - right.row
|| left.column - right.column
|| left.index - right.index
))
.map((node) => node.id);
}
function playerbotsAllocationPlan(
reference: PlayerbotsSpecReference,
milestones: readonly PlayerbotsMilestone[],
): {
readonly plan: readonly string[];
readonly sourcePointCount: number;
readonly sourcePointsRetained: number;
readonly fallbackPointCount: number;
readonly levelPlans: readonly GeneratedLevelPlan[];
} {
if (milestones.length === 0) throw new Error(`No Playerbots links for ${specKey(reference.classId, reference.specialization)}.`);
const targets = milestones.map((milestone) => ({
...milestone,
ranks: ranksFromPlayerbotsLink(reference.classId, milestone.link),
}));
const fallback = partyTalentAllocationPlan(reference.classId, reference.specialization).map((entry) => entry.nodeId);
const levelPlans = targets.map((_, startIndex): GeneratedLevelPlan => {
// PlayerbotFactory walks backward to the closest configured link, then
// applies that link and each later link until the bot runs out of points.
// Level 80 starts at the 80 link directly. A plan variant per configured
// starting link preserves that behavior without storing 80 duplicate plans.
const appliedTargets = targets.slice(startIndex);
const sourceCapacity: Record<string, number> = {};
for (const target of appliedTargets) {
for (const [nodeId, rank] of Object.entries(target.ranks)) {
sourceCapacity[nodeId] = Math.max(sourceCapacity[nodeId] ?? 0, rank);
}
}
const plan: string[] = [];
const sourcePointFlags: boolean[] = [];
let ranks: TalentRanks = {};
for (let point = 1; point <= 71; point += 1) {
const level = point + 9;
let allocated = false;
for (const target of appliedTargets) {
for (const nodeId of desiredNodeOrder(reference.classId, target.ranks)) {
if ((ranks[nodeId] ?? 0) >= (target.ranks[nodeId] ?? 0)) continue;
const result = allocateTalentPoint(ranks, reference.classId, nodeId, level);
if (!result.ok) continue;
ranks = result.ranks;
plan.push(nodeId);
sourcePointFlags.push(true);
allocated = true;
break;
}
if (allocated) break;
}
if (!allocated) {
for (const nodeId of fallback) {
const result = allocateTalentPoint(ranks, reference.classId, nodeId, level);
if (!result.ok) continue;
ranks = result.ranks;
plan.push(nodeId);
sourcePointFlags.push(false);
allocated = true;
break;
}
}
if (!allocated) throw new Error(`Could not allocate Playerbots point ${point} for ${specKey(reference.classId, reference.specialization)}.`);
}
return {
minimumLevel: startIndex === 0 ? 1 : targets[startIndex].level,
sourceLevels: appliedTargets.map((target) => target.level),
sourcePointCount: Object.values(sourceCapacity).reduce((total, rank) => total + rank, 0),
sourcePointFlags,
plan,
};
});
const final = levelPlans[levelPlans.length - 1];
const sourcePointsRetained = final.sourcePointFlags.filter(Boolean).length;
return {
plan: final.plan,
sourcePointCount: final.sourcePointCount,
sourcePointsRetained,
fallbackPointCount: Math.max(0, 71 - sourcePointsRetained),
levelPlans,
};
}
const config = parsePlayerbotsConfig(readFileSync(playerbotsConfigPath, "utf8"));
const playerbotsBySpec = new Map(PLAYERBOTS_PVE_SPECS.map((reference) => [
specKey(reference.classId, reference.specialization),
reference,
]));
const profiles = PARTY_TALENT_SPECIALIZATIONS.flatMap(({ classId, specialization }, index) => {
const key = specKey(classId, specialization);
const reference = playerbotsBySpec.get(key);
if (!reference) throw new Error(`No Playerbots PvE mapping for ${key}.`);
const configKey = `${reference.classNumber}:${reference.specNumber}`;
const milestones = config.links.get(configKey) ?? [];
const specSlug = `${classId}-${slug(specialization)}`;
const playerbotsPlan = playerbotsAllocationPlan(reference, milestones);
return [
{
id: `playerbots-pve-${PLAYERBOTS_COMMIT}:${specSlug}`,
classId,
specialization,
name: `${specialization} Playerbots PvE`,
characterName: CHARACTER_NAMES[index],
source: "azerothcore-playerbots",
sourceVersion: PLAYERBOTS_COMMIT,
defaultForSpecialization: true,
playerbotsSpecName: config.names.get(configKey) ?? null,
milestones,
...playerbotsPlan,
},
{
id: `healer-man-executable-v1:${specSlug}`,
classId,
specialization,
name: `${specialization} Executable`,
characterName: CHARACTER_NAMES[index + PARTY_TALENT_SPECIALIZATIONS.length],
source: "healer-man-generated",
sourceVersion: "v1",
defaultForSpecialization: false,
playerbotsSpecName: null,
milestones: [],
sourcePointCount: null,
sourcePointsRetained: null,
fallbackPointCount: 0,
plan: partyTalentAllocationPlan(classId, specialization).map((selection) => selection.nodeId),
levelPlans: [],
},
];
});
const output = {
schemaVersion: 2,
generatedFrom: [
"src/game/partyTalents.ts",
"../wow-pvt-server-backups-2/azerothcore-wotlk/modules/mod-playerbots/conf/playerbots.conf.dist",
],
playerbotsCommit: PLAYERBOTS_COMMIT,
profiles,
};
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, `${JSON.stringify(output, null, 2)}\n`, "utf8");
console.log(`Wrote ${profiles.length} named party talent profiles to ${outputPath}`);