forked from phenom/healer-man
286 lines
10 KiB
TypeScript
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;
|
|
}));
|
|
}
|