Files
healer-man/src/game/partyTalents.ts
T
2026-08-14 15:56:39 -04:00

211 lines
8.7 KiB
TypeScript

import type { ClassId } from "../app/characterCatalog";
import { clampPlayerLevel, MAX_PLAYER_LEVEL } from "./progression";
import {
allocateTalentPoint,
talentDescriptionForRank,
talentNodeById,
talentNodesForTree,
talentPointsForLevel,
talentTreeById,
talentTreesForClass,
type TalentRanks,
} from "./talentCatalog";
export interface PartyTalentSpecializationDefinition {
readonly classId: ClassId;
readonly specialization: string;
readonly primaryTreeId: string;
}
/**
* The exact class/spec combinations used by the deterministic dungeon-party
* roster. Keep the tree id explicit: display names are not stable identifiers
* and Feral (Cat) deliberately maps to the Feral Combat tree.
*/
export const PARTY_TALENT_SPECIALIZATIONS = Object.freeze([
{ classId: "warrior", specialization: "Protection", primaryTreeId: "warrior-protection" },
{ classId: "death-knight", specialization: "Blood", primaryTreeId: "death-knight-blood" },
{ classId: "paladin", specialization: "Protection", primaryTreeId: "paladin-protection" },
{ classId: "priest", specialization: "Holy", primaryTreeId: "priest-holy" },
{ classId: "paladin", specialization: "Holy", primaryTreeId: "paladin-holy" },
{ classId: "druid", specialization: "Restoration", primaryTreeId: "druid-restoration" },
{ classId: "shaman", specialization: "Restoration", primaryTreeId: "shaman-restoration" },
{ classId: "warrior", specialization: "Arms", primaryTreeId: "warrior-arms" },
{ classId: "warrior", specialization: "Fury", primaryTreeId: "warrior-fury" },
{ classId: "paladin", specialization: "Retribution", primaryTreeId: "paladin-retribution" },
{ 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: "priest", specialization: "Shadow", primaryTreeId: "priest-shadow" },
{ classId: "death-knight", specialization: "Frost", primaryTreeId: "death-knight-frost" },
{ classId: "death-knight", specialization: "Unholy", primaryTreeId: "death-knight-unholy" },
{ classId: "shaman", specialization: "Enhancement", primaryTreeId: "shaman-enhancement" },
{ classId: "shaman", specialization: "Elemental", primaryTreeId: "shaman-elemental" },
{ classId: "mage", specialization: "Arcane", primaryTreeId: "mage-arcane" },
{ classId: "mage", specialization: "Fire", primaryTreeId: "mage-fire" },
{ classId: "mage", specialization: "Frost", primaryTreeId: "mage-frost" },
{ classId: "warlock", specialization: "Affliction", primaryTreeId: "warlock-affliction" },
{ classId: "warlock", specialization: "Demonology", primaryTreeId: "warlock-demonology" },
{ classId: "druid", specialization: "Balance", primaryTreeId: "druid-balance" },
{ classId: "druid", specialization: "Feral (Cat)", primaryTreeId: "druid-feral-combat" },
] as const satisfies readonly PartyTalentSpecializationDefinition[]);
export interface PartyTalentSelection {
/** One-based talent-point number, from 1 at level 10 through 71 at level 80. */
readonly point: number;
readonly level: number;
readonly nodeId: string;
readonly treeId: string;
readonly treeName: string;
readonly name: string;
readonly rank: number;
readonly maxRank: number;
readonly icon: string;
readonly description: string;
readonly inPrimaryTree: boolean;
}
const SPEC_BY_KEY = new Map<string, PartyTalentSpecializationDefinition>(
PARTY_TALENT_SPECIALIZATIONS.map((definition) => [
specializationKey(definition.classId, definition.specialization),
definition,
]),
);
const PLAN_BY_KEY = new Map<string, readonly PartyTalentSelection[]>();
function specializationKey(classId: ClassId, specialization: string): string {
return `${classId}:${specialization}`;
}
export function partyTalentSpecialization(
classId: ClassId,
specialization: string,
): PartyTalentSpecializationDefinition | null {
return SPEC_BY_KEY.get(specializationKey(classId, specialization)) ?? null;
}
export function partyTalentPrimaryTreeId(classId: ClassId, specialization: string): string | null {
return partyTalentSpecialization(classId, specialization)?.primaryTreeId ?? null;
}
function requireSpecialization(
classId: ClassId,
specialization: string,
): PartyTalentSpecializationDefinition {
const definition = partyTalentSpecialization(classId, specialization);
if (!definition) throw new Error(`Unsupported party specialization: ${classId}:${specialization}`);
return definition;
}
function orderedCandidateNodeIds(definition: PartyTalentSpecializationDefinition): 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)]
.flatMap((tree) => talentNodesForTree(tree.id))
.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.
*/
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;
for (const nodeId of candidates) {
const node = talentNodeById(nodeId);
if (!node || (ranks[nodeId] ?? 0) >= node.maxRank) continue;
const allocation = allocateTalentPoint(ranks, definition.classId, nodeId, level);
if (!allocation.ok) continue;
ranks = allocation.ranks;
const rank = ranks[nodeId] ?? 0;
const tree = talentTreeById(node.treeId);
if (!tree || rank <= 0) throw new Error(`Talent allocation lost catalog data for ${nodeId}`);
selected = Object.freeze({
point: selections.length + 1,
level,
nodeId,
treeId: tree.id,
treeName: tree.name,
name: node.name,
rank,
maxRank: node.maxRank,
icon: node.icon,
description: talentDescriptionForRank(node, rank),
inPrimaryTree: tree.id === definition.primaryTreeId,
});
break;
}
if (!selected) {
throw new Error(`Could not allocate party talent point for ${definition.classId}:${definition.specialization} at level ${level}`);
}
selections.push(selected);
}
return Object.freeze(selections);
}
/** Returns the complete, catalog-validated 71-point level 10-80 plan. */
export function partyTalentAllocationPlan(
classId: ClassId,
specialization: string,
): readonly PartyTalentSelection[] {
const definition = requireSpecialization(classId, specialization);
const key = specializationKey(classId, specialization);
const cached = PLAN_BY_KEY.get(key);
if (cached) return cached;
const plan = buildAllocationPlan(definition);
PLAN_BY_KEY.set(key, plan);
return plan;
}
/** Returns current node ranks for a party member at the supplied player level. */
export function partyTalentRanksForLevel(
classId: ClassId,
specialization: string,
level: number,
): TalentRanks {
const points = talentPointsForLevel(clampPlayerLevel(level));
const ranks: Record<string, number> = {};
for (const selection of partyTalentAllocationPlan(classId, specialization).slice(0, points)) {
ranks[selection.nodeId] = selection.rank;
}
return Object.freeze(ranks);
}
/** Returns every rank learned so far, in the exact level-by-level order learned. */
export function learnedPartyTalentSelections(
classId: ClassId,
specialization: string,
level: number,
): readonly PartyTalentSelection[] {
const points = talentPointsForLevel(clampPlayerLevel(level));
return partyTalentAllocationPlan(classId, specialization).slice(0, points);
}
/**
* Returns the next planned ranks after `level`. Before level 10 this starts at
* the level-10 choice; at level 80 it is empty.
*/
export function upcomingPartyTalentSelections(
classId: ClassId,
specialization: string,
level: number,
limit = 5,
): readonly PartyTalentSelection[] {
const start = talentPointsForLevel(clampPlayerLevel(level));
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.trunc(limit)) : 0;
return partyTalentAllocationPlan(classId, specialization).slice(start, start + safeLimit);
}