654 lines
25 KiB
TypeScript
654 lines
25 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 { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "./releaseFeatures";
|
|
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: () => {
|
|
if (!SHELVED_MODES_ENABLED) {
|
|
set({ notice: "Manastorms are temporarily unavailable." });
|
|
return false;
|
|
}
|
|
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: () => {
|
|
if (!SHELVED_MODES_ENABLED) {
|
|
set({ notice: "Arenas are temporarily unavailable." });
|
|
return false;
|
|
}
|
|
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 (!sessionCanUseShelvedGmTools(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 });
|
|
if (SHELVED_MODES_ENABLED) 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) => {
|
|
if (!SHELVED_MODES_ENABLED) {
|
|
set({ notice: "Manastorms are temporarily unavailable." });
|
|
return false;
|
|
}
|
|
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: () => {
|
|
if (SHELVED_MODES_ENABLED) manastormShellRuntime()?.abandonRun();
|
|
},
|
|
failActiveManastorm: () => {
|
|
if (SHELVED_MODES_ENABLED) 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) => {
|
|
if (!SHELVED_MODES_ENABLED) {
|
|
set({ notice: "Manastorms are temporarily unavailable." });
|
|
return false;
|
|
}
|
|
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.",
|
|
});
|
|
});
|
|
}
|