Files
healer-man/src/game/talentRuntimeCatalog.ts
T
2026-08-22 15:53:43 -04:00

286 lines
10 KiB
TypeScript

import { CLASSES, type ClassId, type CoaClassId } from "../app/characterCatalog";
import { clampPlayerLevel } from "./progression";
import type {
TalentAllocationFailure,
TalentAllocationResult,
} from "./talentCatalog";
import generated from "./generated/talentRuntimeCatalog.json";
export type RuntimeTalentRanks = Readonly<Record<string, number>>;
interface RuntimeTalentTree {
readonly id: string;
readonly classId: ClassId;
}
export interface RuntimeTalentNode {
readonly id: string;
readonly treeId: string;
readonly index: number;
readonly row: number;
readonly maxRank: number;
readonly name: string;
readonly prerequisites: readonly { readonly talentId: string; readonly requiredRank: number }[];
readonly rankDescriptions: readonly string[];
readonly system?: "classic" | "coa";
readonly abilityEssenceCost?: number;
readonly talentEssenceCost?: number;
readonly requiredClassPoints?: number;
readonly requiredSpecPoints?: number;
readonly requiredLevel?: number;
readonly isPassive?: boolean;
}
interface RuntimeTalentSnapshot {
readonly schemaVersion: 1;
readonly maximumCoaLevel: number;
readonly coaBudgetsByClass: Readonly<Record<CoaClassId, {
readonly maximumAbility: number;
readonly maximumTalent: number;
}>>;
readonly trees: readonly RuntimeTalentTree[];
readonly nodes: readonly RuntimeTalentNode[];
}
const snapshot = generated as unknown as RuntimeTalentSnapshot;
const treeById = Object.freeze(Object.fromEntries(snapshot.trees.map((tree) => [tree.id, tree]))) as Readonly<Record<string, RuntimeTalentTree>>;
const nodeById = Object.freeze(Object.fromEntries(snapshot.nodes.map((node) => [node.id, node]))) as Readonly<Record<string, RuntimeTalentNode>>;
const nodesByTree = Object.freeze(Object.fromEntries(snapshot.trees.map((tree) => [
tree.id,
snapshot.nodes.filter((node) => node.treeId === tree.id).sort((left, right) => left.row - right.row || left.index - right.index),
]))) as Readonly<Record<string, readonly RuntimeTalentNode[]>>;
function isCoaClassId(classId: ClassId): classId is CoaClassId {
return CLASSES.find((definition) => definition.id === classId)?.mode === "conquest";
}
export function runtimeTalentNodeById(nodeId: string): RuntimeTalentNode | null {
return nodeById[nodeId] ?? null;
}
function treesForClass(classId: ClassId): readonly RuntimeTalentTree[] {
return snapshot.trees.filter((tree) => tree.classId === classId);
}
function nodesForTree(treeId: string): readonly RuntimeTalentNode[] {
return nodesByTree[treeId] ?? [];
}
export interface RuntimeActiveTalentRankEffect {
readonly talentId: string;
readonly talentName: string;
readonly treeId: string;
readonly rank: number;
readonly description: string;
}
export function runtimeActiveTalentRankEffects(
ranks: RuntimeTalentRanks,
classId?: ClassId,
): readonly RuntimeActiveTalentRankEffect[] {
const active: RuntimeActiveTalentRankEffect[] = [];
for (const [talentId, storedRank] of Object.entries(ranks)) {
const node = runtimeTalentNodeById(talentId);
const tree = node ? treeById[node.treeId] : null;
if (!node || !tree || (classId && tree.classId !== classId)) continue;
const rank = Math.min(node.maxRank, Math.max(0, Math.trunc(Number.isFinite(storedRank) ? storedRank : 0)));
if (rank < 1) continue;
active.push({
talentId: node.id,
talentName: node.name,
treeId: node.treeId,
rank,
description: node.rankDescriptions[rank - 1] ?? node.rankDescriptions.at(-1) ?? node.name,
});
}
return active;
}
export function runtimeTalentPointsForLevel(level: number): number {
return Math.max(0, clampPlayerLevel(level) - 9);
}
function coaBudget(classId: CoaClassId, level: number) {
const definition = snapshot.coaBudgetsByClass[classId];
const normalizedLevel = Math.min(snapshot.maximumCoaLevel, Math.max(1, clampPlayerLevel(level)));
const grantedPoints = Math.max(0, normalizedLevel - 9);
return {
ability: Math.min(definition.maximumAbility, Math.ceil(grantedPoints / 2)),
talent: Math.min(definition.maximumTalent, Math.floor(grantedPoints / 2)),
};
}
function nodeRankCost(node: RuntimeTalentNode, rank: number) {
const safeRank = Math.min(node.maxRank, Math.max(0, Math.trunc(rank)));
return {
ability: safeRank * (node.abilityEssenceCost ?? 0),
talent: safeRank * (node.talentEssenceCost ?? 0),
};
}
function spentCoaEssence(ranks: RuntimeTalentRanks, classId: CoaClassId, treeId?: string) {
let ability = 0;
let talent = 0;
for (const [nodeId, rank] of Object.entries(ranks)) {
const node = runtimeTalentNodeById(nodeId);
const tree = node ? treeById[node.treeId] : null;
if (!node || !tree || tree.classId !== classId || node.system !== "coa" || (treeId && tree.id !== treeId)) continue;
const cost = nodeRankCost(node, rank);
ability += cost.ability;
talent += cost.talent;
}
return { ability, talent };
}
function unspentCoaEssence(level: number, ranks: RuntimeTalentRanks, classId: CoaClassId) {
const budget = coaBudget(classId, level);
const spent = spentCoaEssence(ranks, classId);
return {
ability: Math.max(0, budget.ability - spent.ability),
talent: Math.max(0, budget.talent - spent.talent),
};
}
function spentTalentPoints(ranks: RuntimeTalentRanks, classId?: ClassId): number {
return Object.entries(ranks).reduce((total, [nodeId, rank]) => {
const node = runtimeTalentNodeById(nodeId);
const tree = node ? treeById[node.treeId] : null;
if (!node || !tree || (classId && tree.classId !== classId)) return total;
const safeRank = Math.min(node.maxRank, Math.max(0, Math.trunc(Number.isFinite(rank) ? rank : 0)));
if (node.system === "coa") {
const cost = nodeRankCost(node, safeRank);
return total + cost.ability + cost.talent;
}
return total + safeRank;
}, 0);
}
function spentTalentPointsInTree(ranks: RuntimeTalentRanks, treeId: string): number {
return nodesForTree(treeId).reduce((total, node) => {
const rank = Math.min(node.maxRank, Math.max(0, Math.trunc(ranks[node.id] ?? 0)));
if (node.system === "coa") {
const cost = nodeRankCost(node, rank);
return total + cost.ability + cost.talent;
}
return total + rank;
}, 0);
}
function unspentTalentPoints(level: number, ranks: RuntimeTalentRanks, classId?: ClassId): number {
if (classId && isCoaClassId(classId)) {
const essence = unspentCoaEssence(level, ranks, classId);
return essence.ability + essence.talent;
}
return Math.max(0, runtimeTalentPointsForLevel(level) - spentTalentPoints(ranks, classId));
}
function allocationFailure(
ranks: RuntimeTalentRanks,
classId: ClassId,
nodeId: string,
level: number,
): TalentAllocationFailure | null {
const node = runtimeTalentNodeById(nodeId);
if (!node) return "unknown-talent";
const tree = treeById[node.treeId];
if (!tree || tree.classId !== classId) return "wrong-class";
if ((ranks[node.id] ?? 0) >= node.maxRank) return "max-rank";
if (node.system === "coa") {
if (!isCoaClassId(classId)) return "wrong-class";
if ((node.requiredLevel ?? 0) > level) return "level-locked";
const remaining = unspentCoaEssence(level, ranks, classId);
if ((node.abilityEssenceCost ?? 0) > remaining.ability) return "ability-essence";
if ((node.talentEssenceCost ?? 0) > remaining.talent) return "talent-essence";
const spentInTree = spentCoaEssence(ranks, classId, node.treeId);
if (spentInTree.ability < (node.requiredClassPoints ?? 0) || spentInTree.talent < (node.requiredSpecPoints ?? 0)) return "point-gate";
} else {
if (unspentTalentPoints(level, ranks, classId) <= 0) return "no-points";
if (spentTalentPointsInTree(ranks, node.treeId) < (node.row - 1) * 5) return "tier-locked";
}
for (const required of node.prerequisites) {
const prerequisite = runtimeTalentNodeById(required.talentId);
if (!prerequisite || (ranks[prerequisite.id] ?? 0) < required.requiredRank) return "prerequisite";
}
return null;
}
export function runtimeTalentAllocationFailure(
ranks: RuntimeTalentRanks,
classId: ClassId,
nodeId: string,
level: number,
): TalentAllocationFailure | null {
return allocationFailure(ranks, classId, nodeId, level);
}
export function runtimeSpentTalentPointsInTree(
ranks: RuntimeTalentRanks,
treeId: string,
): number {
return spentTalentPointsInTree(ranks, treeId);
}
export function runtimeUnspentTalentPoints(
level: number,
ranks: RuntimeTalentRanks,
classId?: ClassId,
): number {
return unspentTalentPoints(level, ranks, classId);
}
export function runtimeUnspentCoaEssence(
level: number,
ranks: RuntimeTalentRanks,
classId: CoaClassId,
): { readonly ability: number; readonly talent: number } {
return unspentCoaEssence(level, ranks, classId);
}
export function runtimeNormalizeTalentRanks(
classId: ClassId,
level: number,
ranks: RuntimeTalentRanks,
): RuntimeTalentRanks {
let normalized: Record<string, number> = {};
const requested = new Map<string, number>();
for (const tree of treesForClass(classId)) {
for (const node of nodesForTree(tree.id)) {
requested.set(node.id, Math.min(node.maxRank, Math.max(0, Math.trunc(ranks[node.id] ?? 0))));
}
}
let changed = true;
while (changed && unspentTalentPoints(level, normalized, classId) > 0) {
changed = false;
for (const tree of treesForClass(classId)) {
for (const node of nodesForTree(tree.id)) {
while ((normalized[node.id] ?? 0) < (requested.get(node.id) ?? 0)) {
if (allocationFailure(normalized, classId, node.id, level)) break;
normalized = { ...normalized, [node.id]: (normalized[node.id] ?? 0) + 1 };
changed = true;
}
}
}
}
return normalized;
}
export function runtimeAllocateTalentPoint(
ranks: RuntimeTalentRanks,
classId: ClassId,
nodeId: string,
level: number,
): TalentAllocationResult {
const normalized = runtimeNormalizeTalentRanks(classId, level, ranks);
const reason = allocationFailure(normalized, classId, nodeId, level);
if (reason) return { ok: false, ranks: normalized, reason };
return { ok: true, ranks: { ...normalized, [nodeId]: (normalized[nodeId] ?? 0) + 1 } };
}
export function runtimeResetTalentRanks(ranks: RuntimeTalentRanks = {}, classId?: ClassId): RuntimeTalentRanks {
if (!classId) return {};
return Object.fromEntries(Object.entries(ranks).filter(([nodeId]) => {
const node = runtimeTalentNodeById(nodeId);
return !node || treeById[node.treeId]?.classId !== classId;
}));
}