Files
healer-man/src/app/shellStore.ts
T
2026-09-08 10:40:26 -04:00

634 lines
24 KiB
TypeScript

import { create } from "zustand";
import {
clearSession,
assignRomSecondaryClass,
createCharacter as persistCharacter,
deleteCharacter as removeCharacter,
listCharacters,
loadCombatPresentationSettings,
restoreSession as restoreStoredSession,
saveSession,
updateCharacterActionBindings,
updateCharacterEquipment,
updateCharacterInventory,
updateCharacterManastormProgress,
updateCharacterDungeonRunCheckpoint,
updateCharacterProgression,
updateCharacterSettings,
updateCharacterSpecialization,
updateCombatPresentationSettings as persistCombatPresentationSettings,
swapRomClasses,
} from "./accountRepository";
import {
clampAppearance,
classById,
classesForRace,
createDefaultAppearance,
normalizeCharacterName,
randomAppearance,
raceSupportsCategory,
racesForCategory,
supportedGenders,
validateCharacterName,
type RaceId,
type RomClassId,
} from "./characterCatalog";
import type { ContentCategoryId } from "./contentCategories";
import type { CharacterDraft, CharacterProfile, PlayerSession, ShellPhase } from "./types";
import type { DungeonId } from "../game/dungeonRegistry";
import { DEFAULT_DUNGEON_ID } from "../game/dungeonDefaults";
import type { InventoryItem } from "../game/lootTypes";
import type { EquipmentAssignments } from "../game/equipment";
import type { GameplaySettings } from "../game/combatStore";
import {
DEFAULT_COMBAT_PRESENTATION_SETTINGS,
normalizeCombatPresentationSettings,
type CombatPresentationSettings,
} from "../game/combatPresentation";
import type { PartyRole } from "../game/partyRoles";
import type { ManastormPartySize, ManastormProgress } from "../game/manastormProgress";
import type { DungeonRunCheckpoint } from "../game/dungeonRunCheckpoint";
import { dungeonShellRuntime } from "./dungeonShellRuntimeBridge";
import { manastormShellRuntime } from "./manastormShellRuntimeBridge";
import {
activateOnlineSession,
hydrateOnlineSession,
logoutOnlineSession,
refreshOnlineSession,
} from "./onlineAccountClient";
import { isGameMasterSession } from "../game/manastormAdminConfig";
import { useGameStore } from "../game/store";
import {
normalizeSpecializationId,
specializationById,
specializationsForClass,
} from "../game/specializationCatalog";
export interface ShellState {
phase: ShellPhase;
session: PlayerSession | null;
characters: CharacterProfile[];
selectedCharacterId: string | null;
activeCharacter: CharacterProfile | null;
draft: CharacterDraft;
notice: string;
activeDungeonId: DungeonId;
activeDungeonRole: PartyRole;
combatPresentationSettings: CombatPresentationSettings;
beginSession: (session: PlayerSession) => void;
signOut: () => void;
selectCharacter: (characterId: string) => void;
openCreator: () => void;
cancelCreator: () => void;
updateDraft: (patch: Partial<CharacterDraft>) => void;
setCreatorStep: (step: CharacterDraft["step"]) => void;
randomizeDraft: () => void;
createDraftCharacter: () => boolean;
deleteSelectedCharacter: () => void;
openMainMenu: () => boolean;
openDungeons: () => boolean;
openManastorms: () => boolean;
openArenas: () => boolean;
openGmAdmin: () => boolean;
openClassHall: () => boolean;
changeSelectedSpecialization: (specializationId: string, resetTalents: boolean) => boolean;
chooseRomSecondaryClass: (classId: RomClassId) => boolean;
swapSelectedRomClasses: () => boolean;
returnToMainMenu: () => void;
enterDungeon: (dungeonId: string, playerRole?: PartyRole) => boolean;
enterManastorm: (
playerRole?: PartyRole,
partySize?: ManastormPartySize,
startingLevel?: number,
) => boolean;
abandonManastormRun: () => void;
failActiveManastorm: () => void;
/** Compatibility alias for the original single-dungeon entry point. */
enterWorld: () => boolean;
returnToCharacters: () => void;
saveActiveProgression: (level: number, experience: number, talentRanks: Record<string, number>) => void;
saveActiveActionBindings: (actionBindings: Readonly<Record<string, string | null>>) => void;
saveActiveInventory: (inventory: readonly InventoryItem[]) => void;
saveActiveEquipment: (equipment: EquipmentAssignments) => void;
saveActiveSettings: (settings: GameplaySettings) => void;
updateCombatPresentationSettings: (patch: Partial<CombatPresentationSettings>) => void;
resetCombatPresentationSettings: () => void;
saveActiveManastormProgress: (progress: ManastormProgress) => void;
saveActiveDungeonRunCheckpoint: (checkpoint: DungeonRunCheckpoint | null) => void;
saveSelectedManastormLoadout: (loadout: readonly string[]) => boolean;
setNotice: (notice: string) => void;
}
function defaultDraft(categoryId: ContentCategoryId = "wow"): CharacterDraft {
const raceId: RaceId = categoryId === "rom" ? "rom-human" : "human";
const classId = categoryId === "rom" ? "rom-priest" : categoryId === "coa" ? "barbarian" : "priest";
return {
name: "",
categoryId,
raceId,
classId,
specializationId: specializationsForClass(classId)[0]?.id ?? null,
gender: "female",
appearance: createDefaultAppearance(),
rotation: -12,
step: "identity",
};
}
function restoredState(): Pick<ShellState, "phase" | "session" | "characters" | "selectedCharacterId" | "combatPresentationSettings"> {
const session = restoreStoredSession();
if (!session) return {
phase: "login",
session: null,
characters: [],
selectedCharacterId: null,
combatPresentationSettings: DEFAULT_COMBAT_PRESENTATION_SETTINGS,
};
const characters = listCharacters(session.ownerId);
return {
phase: "characters",
session,
characters,
selectedCharacterId: characters[0]?.id ?? null,
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
};
}
const restored = restoredState();
export const useShellStore = create<ShellState>((set, get) => ({
...restored,
activeDungeonId: DEFAULT_DUNGEON_ID,
activeDungeonRole: "healer",
activeCharacter: null,
draft: defaultDraft(),
notice: "",
beginSession: (session) => {
activateOnlineSession(session);
saveSession(session);
const characters = listCharacters(session.ownerId);
set({
phase: "characters",
session,
characters,
selectedCharacterId: characters[0]?.id ?? null,
activeCharacter: null,
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
notice: session.kind === "offline" ? "Offline roster loaded." : `Welcome, ${session.displayName}.`,
});
},
signOut: () => {
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
const session = get().session;
void logoutOnlineSession(session);
clearSession();
set({
phase: "login",
session: null,
characters: [],
selectedCharacterId: null,
activeCharacter: null,
draft: defaultDraft(),
notice: "Signed out.",
combatPresentationSettings: DEFAULT_COMBAT_PRESENTATION_SETTINGS,
});
},
selectCharacter: (selectedCharacterId) => set({ selectedCharacterId, notice: "" }),
openCreator: () => set({ phase: "create-character", draft: defaultDraft(), notice: "" }),
cancelCreator: () => set({ phase: "characters", notice: "" }),
updateDraft: (patch) => set((state) => {
const categoryChanged = patch.categoryId !== undefined && patch.categoryId !== state.draft.categoryId;
const next = categoryChanged
? { ...defaultDraft(patch.categoryId), name: state.draft.name, rotation: state.draft.rotation }
: { ...state.draft, ...patch };
if (patch.name !== undefined) next.name = normalizeCharacterName(patch.name);
if (!raceSupportsCategory(next.raceId, next.categoryId)) {
const targetRace = racesForCategory(next.categoryId)[0];
next.raceId = targetRace.id;
next.classId = classesForRace(targetRace.id, next.categoryId)[0].id;
next.gender = supportedGenders(targetRace.id)[0] ?? "male";
next.appearance = createDefaultAppearance();
}
const availableGenders = supportedGenders(next.raceId);
if (!availableGenders.includes(next.gender)) next.gender = availableGenders[0] ?? "male";
const availableClasses = classesForRace(next.raceId, next.categoryId);
if (!availableClasses.some((definition) => definition.id === next.classId)) {
next.classId = availableClasses[0].id;
}
next.specializationId = next.categoryId === "rom"
? null
: normalizeSpecializationId(next.classId, next.specializationId)
?? specializationsForClass(next.classId)[0]?.id
?? null;
next.appearance = clampAppearance(next.raceId, next.gender, next.appearance);
return { draft: next, notice: "" };
}),
setCreatorStep: (step) => set((state) => ({ draft: { ...state.draft, step }, notice: "" })),
randomizeDraft: () => set((state) => ({
draft: {
...state.draft,
appearance: randomAppearance(state.draft.raceId, state.draft.gender),
rotation: Math.round(Math.random() * 48 - 24),
},
notice: "Appearance randomized.",
})),
createDraftCharacter: () => {
const { session, draft } = get();
if (!session) {
set({ phase: "login", notice: "Start a session before creating a character." });
return false;
}
const nameError = validateCharacterName(draft.name);
if (nameError) {
set({ notice: nameError });
return false;
}
if (!classesForRace(draft.raceId, draft.categoryId).some((definition) => definition.id === draft.classId)) {
set({ notice: "That class is not available to the selected race." });
return false;
}
if (!supportedGenders(draft.raceId).includes(draft.gender)) {
set({ notice: "That gender model is not available for the selected race." });
return false;
}
if (draft.categoryId !== "rom" && !normalizeSpecializationId(draft.classId, draft.specializationId)) {
set({ notice: "Choose a specialization before creating this character." });
return false;
}
const result = persistCharacter({
ownerId: session.ownerId,
name: normalizeCharacterName(draft.name),
categoryId: draft.categoryId,
raceId: draft.raceId,
classId: draft.classId,
specializationId: draft.categoryId === "rom" ? null : draft.specializationId,
gender: draft.gender,
appearance: clampAppearance(draft.raceId, draft.gender, draft.appearance),
});
if (!result.ok || !result.character) {
set({ notice: result.error ?? "Character could not be created." });
return false;
}
const characters = listCharacters(session.ownerId);
set({
phase: "characters",
characters,
selectedCharacterId: result.character.id,
draft: defaultDraft(),
notice: `${result.character.name} is ready for the expedition.`,
});
return true;
},
deleteSelectedCharacter: () => {
const { session, selectedCharacterId } = get();
if (!session || !selectedCharacterId) return;
removeCharacter(session.ownerId, selectedCharacterId);
const characters = listCharacters(session.ownerId);
set({
characters,
selectedCharacterId: characters[0]?.id ?? null,
activeCharacter: null,
notice: "Character deleted.",
});
},
openMainMenu: () => {
const { session, selectedCharacterId, characters } = get();
if (!session || !selectedCharacterId) {
set({ notice: "Select or create a character first." });
return false;
}
if (!characters.some((character) => character.id === selectedCharacterId)) {
set({ notice: "That character is no longer available." });
return false;
}
const character = characters.find((entry) => entry.id === selectedCharacterId)!;
if (character.categoryId !== "rom" && !character.specializationId) {
set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before continuing." });
return true;
}
set({ phase: "main-menu", activeCharacter: null, notice: "" });
return true;
},
openDungeons: () => {
const { session, selectedCharacterId, characters } = get();
if (!session || !selectedCharacterId || !characters.some((character) => character.id === selectedCharacterId)) {
set({ phase: session ? "characters" : "login", notice: "Select or create a character first." });
return false;
}
const character = characters.find((entry) => entry.id === selectedCharacterId)!;
if (character.categoryId !== "rom" && !character.specializationId) {
set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before entering activities." });
return false;
}
set({ phase: "dungeons", activeCharacter: null, notice: "" });
return true;
},
openManastorms: () => {
const { session, selectedCharacterId, characters } = get();
if (!session || !selectedCharacterId || !characters.some((character) => character.id === selectedCharacterId)) {
set({ phase: session ? "characters" : "login", notice: "Select or create a character first." });
return false;
}
const character = characters.find((entry) => entry.id === selectedCharacterId)!;
if (character.categoryId !== "rom" && !character.specializationId) {
set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before entering activities." });
return false;
}
set({ phase: "manastorms", activeCharacter: null, notice: "" });
return true;
},
openArenas: () => {
const { session, selectedCharacterId, characters } = get();
if (!session || !selectedCharacterId || !characters.some((character) => character.id === selectedCharacterId)) {
set({ phase: session ? "characters" : "login", notice: "Select or create a character first." });
return false;
}
set({ phase: "arenas", activeCharacter: null, notice: "" });
return true;
},
openGmAdmin: () => {
const { session, selectedCharacterId, characters } = get();
if (!isGameMasterSession(session)) {
set({ notice: "Game Master access is required." });
return false;
}
if (!selectedCharacterId || !characters.some((character) => character.id === selectedCharacterId)) {
set({ phase: "characters", notice: "Select a character before opening GM Tools." });
return false;
}
set({ phase: "gm-admin", activeCharacter: null, notice: "" });
return true;
},
openClassHall: () => {
const { session, selectedCharacterId, characters } = get();
const character = characters.find((entry) => entry.id === selectedCharacterId);
if (!session || !character) {
set({ notice: "Select a character before opening the Class Hall." });
return false;
}
set({ phase: "class-hall", activeCharacter: null, notice: "" });
return true;
},
changeSelectedSpecialization: (specializationId, resetTalents) => {
const { session, selectedCharacterId, phase } = get();
if (!session || !selectedCharacterId || phase !== "class-hall") {
set({ notice: "Specializations may only be changed inside the Class Hall." });
return false;
}
const definition = specializationById(specializationId);
if (!definition) {
set({ notice: "That specialization is unavailable." });
return false;
}
const result = updateCharacterSpecialization(
session.ownerId,
selectedCharacterId,
definition.id,
resetTalents,
);
if (!result.ok || !result.character) {
set({ notice: result.error ?? "The specialization could not be changed." });
return false;
}
set((state) => ({
characters: state.characters.map((entry) => entry.id === result.character!.id ? result.character! : entry),
notice: `${definition.displayName} selected. ${resetTalents ? "Talents reset." : "Talents kept."}`,
}));
return true;
},
chooseRomSecondaryClass: (classId) => {
const { session, selectedCharacterId } = get();
if (!session || !selectedCharacterId) return false;
const result = assignRomSecondaryClass(session.ownerId, selectedCharacterId, classId);
if (!result.ok || !result.character) {
set({ notice: result.error ?? "The secondary class could not be selected." });
return false;
}
set((state) => ({
characters: state.characters.map((entry) => entry.id === result.character?.id ? result.character : entry),
notice: `${result.character?.name} permanently learned ${result.character ? classById(result.character.secondaryClassId!).name : "the secondary class"}.`,
}));
return true;
},
swapSelectedRomClasses: () => {
const { session, selectedCharacterId, phase } = get();
if (!session || !selectedCharacterId || phase !== "class-hall") {
set({ notice: "Classes may only be swapped inside the Class Hall." });
return false;
}
const result = swapRomClasses(session.ownerId, selectedCharacterId);
if (!result.ok || !result.character) {
set({ notice: result.error ?? "The classes could not be swapped." });
return false;
}
set((state) => ({
characters: state.characters.map((entry) => entry.id === result.character?.id ? result.character : entry),
notice: `${classById(result.character!.classId).name} is now the active primary class.`,
}));
return true;
},
returnToMainMenu: () => {
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
manastormShellRuntime()?.leaveActiveGame();
const { session, characters, activeCharacter, selectedCharacterId } = get();
if (!session) {
set({ phase: "login", activeCharacter: null });
return;
}
const currentId = activeCharacter?.id ?? selectedCharacterId;
if (!currentId || !characters.some((character) => character.id === currentId)) {
set({ phase: "characters", selectedCharacterId: characters[0]?.id ?? null, activeCharacter: null, notice: "Select a character." });
return;
}
set({ phase: "main-menu", selectedCharacterId: currentId, activeCharacter: null, notice: "" });
},
enterDungeon: (dungeonId, requestedRole) => {
const runtime = dungeonShellRuntime();
if (runtime) return runtime.enterDungeon(dungeonId, requestedRole);
set({ notice: "Dungeon data is still loading. Try again in a moment." });
return false;
},
enterManastorm: (requestedRole, requestedPartySize = 5, requestedStartingLevel) => {
const runtime = manastormShellRuntime();
if (runtime) return runtime.enterManastorm(requestedRole, requestedPartySize, requestedStartingLevel);
set({ notice: "Manastorm data is still loading. Try again in a moment." });
return false;
},
abandonManastormRun: () => manastormShellRuntime()?.abandonRun(),
failActiveManastorm: () => manastormShellRuntime()?.failRun(),
enterWorld: () => get().enterDungeon(DEFAULT_DUNGEON_ID),
returnToCharacters: () => {
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
const session = get().session;
if (!session) {
set({ phase: "login", activeCharacter: null });
return;
}
const characters = listCharacters(session.ownerId);
const currentId = get().activeCharacter?.id ?? get().selectedCharacterId;
set({
phase: "characters",
characters,
selectedCharacterId: characters.some((character) => character.id === currentId)
? currentId
: characters[0]?.id ?? null,
activeCharacter: null,
notice: "",
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
});
},
saveActiveProgression: (level, experience, talentRanks) => {
const { session, activeCharacter } = get();
if (!session || !activeCharacter) return;
const updated = updateCharacterProgression(session.ownerId, activeCharacter.id, {
level,
experience,
talentRanks,
});
if (!updated) return;
set((state) => ({
activeCharacter: updated,
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
}));
},
saveActiveActionBindings: (actionBindings) => {
const { session, activeCharacter } = get();
if (!session || !activeCharacter) return;
const updated = updateCharacterActionBindings(
session.ownerId,
activeCharacter.id,
actionBindings,
);
if (!updated) return;
set((state) => ({
activeCharacter: updated,
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
}));
},
saveActiveInventory: (inventory) => {
const { session, activeCharacter } = get();
if (!session || !activeCharacter) return;
const updated = updateCharacterInventory(
session.ownerId,
activeCharacter.id,
inventory,
);
if (!updated) return;
set((state) => ({
activeCharacter: updated,
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
}));
},
saveActiveEquipment: (equipment) => {
const { session, activeCharacter } = get();
if (!session || !activeCharacter) return;
const updated = updateCharacterEquipment(
session.ownerId,
activeCharacter.id,
equipment,
);
if (!updated) return;
set((state) => ({
activeCharacter: updated,
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
}));
},
saveActiveSettings: (settings) => {
const { session, activeCharacter } = get();
if (!session || !activeCharacter) return;
const updated = updateCharacterSettings(
session.ownerId,
activeCharacter.id,
settings,
);
if (!updated) return;
set((state) => ({
activeCharacter: updated,
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
}));
},
updateCombatPresentationSettings: (patch) => {
const { session, combatPresentationSettings } = get();
const next = normalizeCombatPresentationSettings({
...combatPresentationSettings,
...patch,
}, combatPresentationSettings);
const persisted = session
? persistCombatPresentationSettings(session.ownerId, next)
: next;
set({ combatPresentationSettings: persisted });
},
resetCombatPresentationSettings: () => {
const { session } = get();
const settings = session
? persistCombatPresentationSettings(session.ownerId, DEFAULT_COMBAT_PRESENTATION_SETTINGS)
: DEFAULT_COMBAT_PRESENTATION_SETTINGS;
set({ combatPresentationSettings: settings });
},
saveActiveManastormProgress: (progress) => {
const { session, activeCharacter } = get();
if (!session || !activeCharacter) return;
const updated = updateCharacterManastormProgress(
session.ownerId,
activeCharacter.id,
progress,
);
if (!updated) return;
set((state) => ({
activeCharacter: updated,
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
}));
},
saveActiveDungeonRunCheckpoint: (checkpoint) => {
const { session, activeCharacter } = get();
if (!session || !activeCharacter) return;
const updated = updateCharacterDungeonRunCheckpoint(
session.ownerId,
activeCharacter.id,
checkpoint,
);
if (!updated) return;
set((state) => ({
activeCharacter: updated,
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
}));
},
saveSelectedManastormLoadout: (loadout) => {
const runtime = manastormShellRuntime();
if (runtime) return runtime.saveSelectedLoadout(loadout);
set({ notice: "Manastorm data is still loading. Try again in a moment." });
return false;
},
setNotice: (notice) => set({ notice }),
}));
export function selectedCharacter(state: ShellState): CharacterProfile | null {
return state.characters.find((character) => character.id === state.selectedCharacterId) ?? null;
}
if (restored.session?.accessToken) {
activateOnlineSession(restored.session);
void Promise.all([
hydrateOnlineSession(restored.session),
refreshOnlineSession(restored.session),
]).then(([characters, refreshedSession]) => {
const current = useShellStore.getState().session;
if (current?.ownerId !== restored.session?.ownerId) return;
saveSession(refreshedSession);
useShellStore.setState({
session: refreshedSession,
characters,
selectedCharacterId: characters.some((character) => character.id === useShellStore.getState().selectedCharacterId)
? useShellStore.getState().selectedCharacterId
: characters[0]?.id ?? null,
notice: "Cloud roster synchronized.",
});
}).catch((error) => {
useShellStore.setState({
notice: error instanceof Error
? `${error.message} Using the cached roster.`
: "Cloud sync failed. Using the cached roster.",
});
});
}