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; links: ReadonlyMap; } { const names = new Map(); const links = new Map(); 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 = {}; 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 = {}; 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}`);