Files
healer-man/src/game/partyTalentRuntime.ts
T
2026-09-19 21:01:51 -04:00

144 lines
4.9 KiB
TypeScript

import type { ClassId } from "../app/characterCatalog";
import generated from "./generated/partyTalentRuntimePlans.json";
type TalentRanks = Readonly<Record<string, number>>;
export interface PartyTalentRuntimeMilestone {
readonly level: number;
readonly link: string;
}
export interface PartyTalentRuntimeLevelPlan {
readonly minimumLevel: number;
readonly sourceLevels: readonly number[];
readonly sourcePointCount: number;
readonly sourcePointFlags: readonly boolean[];
readonly plan: readonly string[];
}
export interface PartyTalentRuntimeProfile {
readonly id: string;
readonly classId: ClassId;
readonly specialization: string;
readonly name: string;
readonly characterName: string;
readonly source: "azerothcore-playerbots" | "healer-man-generated";
readonly sourceVersion: string;
readonly defaultForSpecialization: boolean;
readonly playerbotsSpecName: string | null;
readonly milestones: readonly PartyTalentRuntimeMilestone[];
readonly sourcePointCount: number | null;
readonly sourcePointsRetained: number | null;
readonly fallbackPointCount: number;
readonly plan: readonly string[];
readonly levelPlans: readonly PartyTalentRuntimeLevelPlan[];
}
export interface PartyTalentRuntimeProvenance {
readonly sourcePointCount: number | null;
readonly sourcePointsRetained: number | null;
readonly fallbackPointCount: number;
readonly sourceLevels: readonly number[];
}
interface PartyTalentRuntimeSnapshot {
readonly schemaVersion: 2;
readonly playerbotsCommit: string;
readonly profiles: readonly PartyTalentRuntimeProfile[];
}
const snapshot = generated as PartyTalentRuntimeSnapshot;
const profileById = new Map(snapshot.profiles.map((profile) => [profile.id, profile]));
function planKey(classId: ClassId, specialization: string): string {
return `${classId}:${specialization}`;
}
export function runtimePartyTalentPlan(
classId: ClassId,
specialization: string,
profileId?: string,
): readonly string[] {
return runtimePartyTalentProfile(classId, specialization, profileId).plan;
}
export function runtimePartyTalentProfiles(
classId: ClassId,
specialization: string,
): readonly PartyTalentRuntimeProfile[] {
const key = planKey(classId, specialization);
return snapshot.profiles.filter((profile) => planKey(profile.classId, profile.specialization) === key);
}
export function runtimePartyTalentProfile(
classId: ClassId,
specialization: string,
profileId?: string,
): PartyTalentRuntimeProfile {
const profiles = runtimePartyTalentProfiles(classId, specialization);
const profile = profileId
? profileById.get(profileId)
: profiles.find((candidate) => candidate.defaultForSpecialization);
if (!profile || profile.classId !== classId || profile.specialization !== specialization) {
throw new Error(`Unsupported party talent profile: ${classId}:${specialization}:${profileId ?? "default"}`);
}
return profile;
}
export function runtimeDefaultPartyTalentProfile(
classId: ClassId,
specialization: string,
): PartyTalentRuntimeProfile {
return runtimePartyTalentProfile(classId, specialization);
}
function levelPlanFor(
profile: PartyTalentRuntimeProfile,
level: number,
): PartyTalentRuntimeLevelPlan | null {
const safeLevel = Number.isFinite(level) ? Math.max(1, Math.min(80, Math.trunc(level))) : 1;
return [...profile.levelPlans]
.reverse()
.find((candidate) => candidate.minimumLevel <= safeLevel) ?? null;
}
export function runtimePartyTalentProvenanceForLevel(
profile: PartyTalentRuntimeProfile,
level: number,
): PartyTalentRuntimeProvenance {
const selected = levelPlanFor(profile, level);
if (!selected) {
return {
sourcePointCount: profile.sourcePointCount,
sourcePointsRetained: profile.sourcePointsRetained,
fallbackPointCount: profile.fallbackPointCount,
sourceLevels: [],
};
}
const safeLevel = Number.isFinite(level) ? Math.max(1, Math.min(80, Math.trunc(level))) : 1;
const points = Math.max(0, safeLevel - 9);
const sourcePointsRetained = selected.sourcePointFlags.slice(0, points).filter(Boolean).length;
return {
sourcePointCount: selected.sourcePointCount,
sourcePointsRetained,
fallbackPointCount: Math.max(0, points - sourcePointsRetained),
sourceLevels: selected.sourceLevels,
};
}
/** Runtime-only projection of the catalog-validated 71-point party plans. */
export function runtimePartyTalentRanksForLevel(
classId: ClassId,
specialization: string,
level: number,
profileId?: string,
): TalentRanks {
const safeLevel = Number.isFinite(level) ? Math.max(1, Math.min(80, Math.trunc(level))) : 1;
const profile = runtimePartyTalentProfile(classId, specialization, profileId);
const plan = levelPlanFor(profile, safeLevel)?.plan ?? profile.plan;
const points = Math.max(0, safeLevel - 9);
const ranks: Record<string, number> = {};
for (const nodeId of plan.slice(0, points)) ranks[nodeId] = (ranks[nodeId] ?? 0) + 1;
return Object.freeze(ranks);
}