forked from phenom/healer-man
248 lines
7.8 KiB
TypeScript
248 lines
7.8 KiB
TypeScript
import type { ClassId } from "../app/characterCatalog";
|
|
import {
|
|
abilitiesForClass,
|
|
learnedAbilityForBuild,
|
|
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 isSelectableClientSpell(
|
|
ability: AbilityDefinition,
|
|
level: number,
|
|
talentRanks: Readonly<Record<string, number>>,
|
|
): boolean {
|
|
return !ability.passive
|
|
&& CLIENT_GROUNDED_SOURCES.has(ability.source)
|
|
&& learnedAbilityForBuild(ability, level, talentRanks) !== null;
|
|
}
|
|
|
|
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 = learnedAbilityForBuild(ability, level, 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;
|
|
}
|