279 lines
11 KiB
TypeScript
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}`);
|