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; } 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([ "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>): 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>, ): 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> = {}, ): 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(); 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; }