Release Healer Man 0.1.46

This commit is contained in:
phenom committed 2026-09-16 14:30:58 -04:00
1 parent f82daf1c13
commit 9ff574ac0b
73 files changed
+33486 -174

No files matched your search

+206
View File
@@ -3,6 +3,7 @@ import {
CLASSES,
COA_CLASS_IDS,
ROM_CLASS_IDS,
classById,
contentCategoryForClass,
type BaseClassId,
type ClassId,
@@ -20,6 +21,10 @@ import type { EquipmentAssignments } from "../equipment";
import { partyRolesForClass, type PartyRole } from "../partyRoles";
import { runtimePartyTalentRanksForLevel } from "../partyTalentRuntime";
import { clampPlayerLevel } from "../progression";
import {
specializationsForClass,
type SpecializationDefinition,
} from "../specializationCatalog";
import {
runtimeAllocateTalentPoint,
runtimeNormalizeTalentRanks,
@@ -153,6 +158,16 @@ function benchmarkBuildId(classId: ClassId, role: PartyRole, level: number): str
return `canonical-${contentCategoryForClass(classId)}-${classId}-${role}-level-${level}`;
}
function benchmarkVariantBuildId(
classId: ClassId,
role: PartyRole,
orientation: string,
level: number,
): string {
const variant = orientation.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
return `balance-${contentCategoryForClass(classId)}-${classId}-${role}-${variant}-level-${level}`;
}
function commonBuildFields(classId: ClassId, role: PartyRole, level: number) {
return {
id: benchmarkBuildId(classId, role, level),
@@ -365,6 +380,197 @@ function romBuild(
});
}
function specializationTree(
catalog: TalentUiCatalog,
specialization: SpecializationDefinition,
): TalentUiTree {
const normalized = (value: string) => value.toLowerCase().replace(/[^a-z0-9]+/g, "");
const names = new Set([
normalized(specialization.talentTree),
normalized(specialization.displayName),
]);
const tree = catalog.trees.find((candidate) => names.has(normalized(candidate.name)));
if (!tree) {
throw new Error(
`No talent tree matches ${classById(specialization.classId).name} ${specialization.displayName}.`,
);
}
return tree;
}
function allocateSingleTreeTalentRanks(
classId: BaseClassId,
catalog: TalentUiCatalog,
tree: TalentUiTree,
level: number,
): RuntimeTalentRanks {
const candidates = catalog.nodes
.filter((node) => node.treeId === tree.id)
.sort((left, right) => (
left.row - right.row
|| left.column - right.column
|| left.index - right.index
|| left.id.localeCompare(right.id)
));
const successfulRequests: Record<string, number> = {};
let ranks: RuntimeTalentRanks = EMPTY_TALENT_RANKS;
while (true) {
let allocated = false;
for (const node of candidates) {
if ((successfulRequests[node.id] ?? 0) >= node.maxRank) continue;
const result = runtimeAllocateTalentPoint(ranks, classId, node.id, level);
if (!result.ok) continue;
successfulRequests[node.id] = (successfulRequests[node.id] ?? 0) + 1;
ranks = result.ranks;
allocated = true;
break;
}
if (!allocated) break;
}
return Object.freeze({ ...runtimeNormalizeTalentRanks(classId, level, ranks) });
}
function selectableClassicBuild(
specialization: SpecializationDefinition,
level: number,
catalog: TalentUiCatalog | null,
): BenchmarkBuildDefinition {
const classId = specialization.classId as BaseClassId;
let talentRanks: RuntimeTalentRanks;
try {
talentRanks = runtimePartyTalentRanksForLevel(classId, specialization.displayName, level);
} catch {
if (!catalog) throw new Error(`No talent catalog was loaded for ${classId}.`);
talentRanks = allocateSingleTreeTalentRanks(
classId,
catalog,
specializationTree(catalog, specialization),
level,
);
}
return Object.freeze({
...commonBuildFields(classId, specialization.role, level),
id: benchmarkVariantBuildId(classId, specialization.role, specialization.displayName, level),
orientation: specialization.displayName,
talentRanks,
});
}
function selectableCoaBuild(
specialization: SpecializationDefinition,
level: number,
catalog: TalentUiCatalog,
): BenchmarkBuildDefinition {
const classId = specialization.classId as CoaClassId;
const tree = specializationTree(catalog, specialization);
const abilitiesByNode = coaNodeAbilities(classId);
return Object.freeze({
...commonBuildFields(classId, specialization.role, level),
id: benchmarkVariantBuildId(classId, specialization.role, specialization.displayName, level),
orientation: specialization.displayName,
talentRanks: allocateCoaTalentRanks(
classId,
catalog,
tree,
specialization.role,
level,
abilitiesByNode,
),
});
}
export interface SelectableBenchmarkBuildCatalogEntry {
readonly id: string;
readonly source: ReturnType<typeof contentCategoryForClass>;
readonly classId: ClassId;
readonly role: PartyRole;
readonly orientation: string;
readonly level: number;
}
/** Cheap metadata-only roster used by interactive tools before a build is selected. */
export function selectableBenchmarkBuildCatalog(
level = CANONICAL_BENCHMARK_LEVEL,
classIds?: readonly ClassId[],
): readonly SelectableBenchmarkBuildCatalogEntry[] {
const safeLevel = clampPlayerLevel(level);
const requested = classIds ? new Set(classIds) : null;
return CLASSES
.map((definition) => definition.id)
.filter((classId) => !requested || requested.has(classId))
.flatMap((classId): readonly SelectableBenchmarkBuildCatalogEntry[] => {
if (isRomClassId(classId)) return partyRolesForClass(classId).map((role) => ({
id: benchmarkBuildId(classId, role, safeLevel),
source: contentCategoryForClass(classId),
classId,
role,
orientation: "Primary",
level: safeLevel,
}));
return specializationsForClass(classId).map((specialization) => ({
id: benchmarkVariantBuildId(classId, specialization.role, specialization.displayName, safeLevel),
source: contentCategoryForClass(classId),
classId,
role: specialization.role,
orientation: specialization.displayName,
level: safeLevel,
}));
});
}
/**
* Builds every user-selectable class/spec/role variant for the balance lab.
* This is intentionally broader than the canonical benchmark cohort above,
* which keeps one representative orientation per class-role pair.
*/
export async function loadSelectableBenchmarkBuilds(
level = CANONICAL_BENCHMARK_LEVEL,
classIds?: readonly ClassId[],
): Promise<BenchmarkBuildDefinition[]> {
const safeLevel = clampPlayerLevel(level);
const requested = classIds ? new Set(classIds) : null;
const selectedClassIds = CLASSES
.map((definition) => definition.id)
.filter((classId) => !requested || requested.has(classId));
const catalogClassIds = selectedClassIds.filter((classId) => (
isCoaClassId(classId)
|| (isBaseClassId(classId) && specializationsForClass(classId).some((specialization) => {
try {
runtimePartyTalentRanksForLevel(classId, specialization.displayName, safeLevel);
return false;
} catch {
return true;
}
}))
));
await preloadAbilityCatalogsForClasses(selectedClassIds);
const catalogs = new Map<ClassId, TalentUiCatalog>(await Promise.all(
catalogClassIds.map(async (classId) => [
classId,
await loadTalentUiCatalog(classId),
] as const),
));
const builds: BenchmarkBuildDefinition[] = [];
for (const classId of selectedClassIds) {
if (isRomClassId(classId)) {
for (const role of partyRolesForClass(classId)) builds.push(romBuild(classId, role, safeLevel));
continue;
}
for (const specialization of specializationsForClass(classId)) {
if (isBaseClassId(classId)) {
builds.push(selectableClassicBuild(specialization, safeLevel, catalogs.get(classId) ?? null));
} else if (isCoaClassId(classId)) {
const catalog = catalogs.get(classId);
if (!catalog) throw new Error(`No loaded CoA talent catalog exists for ${classId}.`);
builds.push(selectableCoaBuild(specialization, safeLevel, catalog));
}
}
}
return builds;
}
/**
* Creates one stable, legal benchmark build for every declared class-role pair.
* A filter changes only membership; output always follows the canonical CLASSES