Files
healer-man/src/game/manastormSpellLoadout.ts
T
2026-08-14 15:56:39 -04:00

255 lines
8.1 KiB
TypeScript

import type { ClassId } from "../app/characterCatalog";
import {
abilitiesForClass,
abilityAtLevel,
type AbilityDefinition,
} from "./abilityCatalog";
import {
actionBindingId,
type ActionBindingControl,
} from "./actionBindings";
import { useCombatStore } from "./combatStore";
import { useGameStore } from "./store";
export const MANASTORM_ACTIVE_SPELL_LIMIT = 4;
export interface ManastormSpellLoadoutProvenance {
readonly model: "local-client-spell-selection";
readonly fidelity: "substitution";
readonly exactServerParity: false;
readonly source: "character-client-ability-catalog";
readonly note: string;
}
export const MANASTORM_SPELL_LOADOUT_PROVENANCE: ManastormSpellLoadoutProvenance = Object.freeze({
model: "local-client-spell-selection",
fidelity: "substitution",
exactServerParity: false,
source: "character-client-ability-catalog",
note: "The installed Manastorm client tables contain no authoritative spell-choice table; this local model selects from the character's imported client spell catalog.",
});
export interface ManastormActiveSpellOption {
readonly id: string;
readonly abilityId: string;
readonly dbcSpellId: number;
readonly name: string;
readonly description: string;
readonly icon: string;
readonly unlockLevel: number;
readonly source: NonNullable<AbilityDefinition["source"]>;
}
export interface ManastormActiveSpellLoadoutModel {
readonly classId: ClassId;
readonly characterLevel: number;
readonly maxSelections: number;
readonly options: readonly ManastormActiveSpellOption[];
readonly provenance: ManastormSpellLoadoutProvenance;
}
export interface ManastormActiveSpellLoadoutRequest {
readonly available: readonly ManastormActiveSpellOption[];
readonly selectedIds: readonly string[];
readonly maxSelections: number;
readonly provenance: ManastormSpellLoadoutProvenance;
}
export interface ManastormActiveSpellLoadoutState extends ManastormActiveSpellLoadoutRequest {
readonly selected: readonly ManastormActiveSpellOption[];
readonly validationError: string | null;
}
export interface ManastormActiveSpellValidation {
readonly ok: boolean;
readonly selectedIds: readonly string[];
readonly selected: readonly ManastormActiveSpellOption[];
readonly error?: string;
}
const CLIENT_GROUNDED_SOURCES = new Set<AbilityDefinition["source"]>([
"wow335-progression",
"ascension-progression",
"coa-starter",
"coa-progression",
"coa-tree",
]);
const PRIMARY_LOADOUT_CONTROLS: readonly ActionBindingControl[] = [
"face-bottom",
"face-left",
"face-top",
"face-right",
];
function learnedTalentRank(ability: AbilityDefinition, talentRanks: Readonly<Record<string, number>>): number {
return ability.talentEntryId === undefined
? 0
: Math.max(0, Math.trunc(talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0));
}
function isSelectableClientSpell(
ability: AbilityDefinition,
level: number,
talentRanks: Readonly<Record<string, number>>,
): boolean {
return !ability.passive
&& ability.unlockLevel <= level
&& CLIENT_GROUNDED_SOURCES.has(ability.source)
&& (ability.talentEntryId === undefined || learnedTalentRank(ability, talentRanks) > 0);
}
export function manastormActiveSpellLoadoutModel(
classId: ClassId,
characterLevel: number,
talentRanks: Readonly<Record<string, number>> = {},
): ManastormActiveSpellLoadoutModel {
const level = Math.max(1, Math.trunc(characterLevel));
const options = abilitiesForClass(classId)
.filter((ability) => isSelectableClientSpell(ability, level, talentRanks))
.map((ability) => {
const resolved = abilityAtLevel(ability, level, learnedTalentRank(ability, talentRanks));
return Object.freeze({
id: ability.id,
abilityId: ability.id,
dbcSpellId: resolved.dbcSpellId,
name: resolved.name,
description: resolved.description,
icon: resolved.icon,
unlockLevel: ability.unlockLevel,
source: ability.source!,
});
})
.sort((left, right) => (
left.unlockLevel - right.unlockLevel
|| left.name.localeCompare(right.name)
|| left.dbcSpellId - right.dbcSpellId
));
return Object.freeze({
classId,
characterLevel: level,
maxSelections: MANASTORM_ACTIVE_SPELL_LIMIT,
options: Object.freeze(options),
provenance: MANASTORM_SPELL_LOADOUT_PROVENANCE,
});
}
export function validateManastormActiveSpellLoadout(
value: unknown,
available: readonly ManastormActiveSpellOption[],
maxSelections = MANASTORM_ACTIVE_SPELL_LIMIT,
): ManastormActiveSpellValidation {
if (!Array.isArray(value)) {
return Object.freeze({
ok: false,
selectedIds: Object.freeze([]),
selected: Object.freeze([]),
error: "Active-spell loadout must be a list.",
});
}
const limit = Math.max(0, Math.trunc(maxSelections));
if (value.length > limit) {
return Object.freeze({
ok: false,
selectedIds: Object.freeze([]),
selected: Object.freeze([]),
error: `Select no more than ${limit} active spells.`,
});
}
const byId = new Map(available.map((option) => [option.id, option]));
const selected: ManastormActiveSpellOption[] = [];
const ids: string[] = [];
const seen = new Set<string>();
for (const candidate of value) {
if (typeof candidate !== "string" || !candidate.trim()) {
return Object.freeze({
ok: false,
selectedIds: Object.freeze([]),
selected: Object.freeze([]),
error: "Active-spell loadout contains an invalid ID.",
});
}
const id = candidate.trim();
const option = byId.get(id);
if (!option) {
return Object.freeze({
ok: false,
selectedIds: Object.freeze([]),
selected: Object.freeze([]),
error: `${id} is not an available client-grounded spell for this character.`,
});
}
if (seen.has(id)) {
return Object.freeze({
ok: false,
selectedIds: Object.freeze([]),
selected: Object.freeze([]),
error: `${id} is selected more than once.`,
});
}
seen.add(id);
ids.push(id);
selected.push(option);
}
return Object.freeze({
ok: true,
selectedIds: Object.freeze(ids),
selected: Object.freeze(selected),
});
}
export function resolveManastormActiveSpellLoadout(
request?: ManastormActiveSpellLoadoutRequest,
): ManastormActiveSpellLoadoutState {
if (!request) {
return Object.freeze({
available: Object.freeze([]),
selectedIds: Object.freeze([]),
selected: Object.freeze([]),
maxSelections: MANASTORM_ACTIVE_SPELL_LIMIT,
provenance: MANASTORM_SPELL_LOADOUT_PROVENANCE,
validationError: null,
});
}
const validation = validateManastormActiveSpellLoadout(
request.selectedIds,
request.available,
request.maxSelections,
);
return Object.freeze({
available: Object.freeze([...request.available]),
selectedIds: validation.selectedIds,
selected: validation.selected,
maxSelections: Math.max(0, Math.trunc(request.maxSelections)),
provenance: request.provenance,
validationError: validation.error ?? null,
});
}
/**
* The server-side Manastorm grant/loadout table was not present in the client.
* As a transparent local substitute, selected imported spells are pinned to
* the first four base controls for the duration of the run.
*/
export function activateManastormActiveSpellLoadout(
loadout: ManastormActiveSpellLoadoutState,
): boolean {
if (useGameStore.getState().gameMode !== "manastorm") return false;
const validation = validateManastormActiveSpellLoadout(
loadout.selectedIds,
loadout.available,
loadout.maxSelections,
);
if (!validation.ok) return false;
const combat = useCombatStore.getState();
const abilityIds = new Set(combat.abilities.map((ability) => ability.id));
if (validation.selectedIds.some((id) => !abilityIds.has(id))) return false;
if (!validation.selectedIds.length) return true;
const bindings = { ...combat.actionBindings };
PRIMARY_LOADOUT_CONTROLS.forEach((control, index) => {
bindings[actionBindingId("primary", control)] = validation.selectedIds[index] ?? null;
});
useCombatStore.setState({ actionBindings: bindings });
return true;
}