Release Healer Man 0.1.46
This commit is contained in:
1 parent
f82daf1c13
commit
9ff574ac0b
73 files changed
+33486
-174
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user