846 lines
33 KiB
TypeScript
846 lines
33 KiB
TypeScript
import { create } from "zustand";
|
|
import { DEFAULT_SETTINGS, normalizeHunterName } from "./data";
|
|
import { SaveRepository } from "./saveRepository";
|
|
import { AccountRepository, type AccountResult } from "./accountRepository";
|
|
import { OnlineApiError, onlineRepository, type OnlineSaveSlot } from "./onlineRepository";
|
|
import { leaderboardCache } from "./leaderboardCache";
|
|
import {
|
|
clearSaveSyncPending,
|
|
markSaveSyncPending,
|
|
networkAppearsOnline,
|
|
scheduleSaveSyncRetry,
|
|
} from "./saveSync";
|
|
import type {
|
|
AppScreen,
|
|
GameModeId,
|
|
GameSettings,
|
|
HunterSave,
|
|
ProfileCollectionView,
|
|
ProfileStatId,
|
|
SaveSlotId,
|
|
SaveSlotState,
|
|
} from "./types";
|
|
import type { BossGroupId } from "../game/bossCatalog";
|
|
import type { AbilitySlotId, BossId, HealerClassId, InventoryItem, RunBuffId } from "../game/types";
|
|
import { HEALER_CLASS_ORDER } from "../game/healers";
|
|
import { RUN_BUFF_ORDER, RUN_BUFFS } from "../game/roguelike";
|
|
import { upgradeGearSlot, type GearOwnerId, type GearSlotId } from "../game/progression/gear";
|
|
import {
|
|
equipActiveInfusion,
|
|
equipPassiveInfusion,
|
|
infusionsForOwner,
|
|
} from "../game/progression/infusions";
|
|
import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot";
|
|
import { bestAetherAssaultRecord, bestBlockbreakerRecords, bestHockeyHealingRecord, highestEndlessBossKillsAfterDefeat, highestRoguelikeRoundAfterDefeat, hockeyPvpRecordAfterMatch } from "../game/progression/hunterStats";
|
|
import {
|
|
cloneCharacterAppearance,
|
|
type CharacterAppearanceV1,
|
|
type CharacterModelMode,
|
|
} from "../game/characterAppearance";
|
|
import { createDefaultHealerAppearance, normalizeHealerAppearance } from "../game/healerVisuals";
|
|
|
|
const repository = new SaveRepository();
|
|
const accounts = new AccountRepository();
|
|
const SETTINGS_KEY = "i-want-to-heal:settings:v1";
|
|
const onlineSaveQueues = new Map<SaveSlotId, { updatedAt: string; promise: Promise<HunterSave> }>();
|
|
|
|
function writeServerSaveSerially(save: HunterSave): Promise<HunterSave> {
|
|
const previous = onlineSaveQueues.get(save.slotId);
|
|
if (previous?.updatedAt === save.updatedAt) return previous.promise;
|
|
const next = (previous ? previous.promise.catch(() => save) : Promise.resolve(save))
|
|
.then(() => onlineRepository.writeSave(save));
|
|
onlineSaveQueues.set(save.slotId, { updatedAt: save.updatedAt, promise: next });
|
|
void next.finally(() => {
|
|
if (onlineSaveQueues.get(save.slotId)?.promise === next) onlineSaveQueues.delete(save.slotId);
|
|
}).catch(() => undefined);
|
|
return next;
|
|
}
|
|
|
|
function loadSettings(): GameSettings {
|
|
try {
|
|
const saved = localStorage.getItem(SETTINGS_KEY);
|
|
return saved ? { ...DEFAULT_SETTINGS, ...JSON.parse(saved) } : DEFAULT_SETTINGS;
|
|
} catch {
|
|
return DEFAULT_SETTINGS;
|
|
}
|
|
}
|
|
|
|
function persistSettings(settings: GameSettings) {
|
|
try {
|
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
|
} catch {
|
|
// Settings remain active for this session when storage is unavailable.
|
|
}
|
|
}
|
|
|
|
function accountNotice(result: Extract<AccountResult, { ok: false }>, action: "sign-in" | "create") {
|
|
switch (result.reason) {
|
|
case "missing-credentials": return "Enter both username and password.";
|
|
case "account-exists": return "Account already exists. Sign in with its password.";
|
|
case "invalid-password": return "Username or password is incorrect.";
|
|
case "server-unavailable": return "Online server is unreachable. Continue offline or try again.";
|
|
case "invalid-request": return result.message ?? (action === "create" ? "Account could not be created." : "Sign-in failed.");
|
|
}
|
|
}
|
|
|
|
function refreshLocalSlots(current: readonly SaveSlotState[]): SaveSlotState[] {
|
|
return repository.listLocal().map((slot) => ({
|
|
...slot,
|
|
online: current.find((candidate) => candidate.id === slot.id)?.online ?? null,
|
|
}));
|
|
}
|
|
|
|
function mergeServerSlots(serverSlots: readonly OnlineSaveSlot[]): SaveSlotState[] {
|
|
return repository.listLocal().map((slot) => ({
|
|
...slot,
|
|
online: serverSlots.find((candidate) => candidate.slotId === slot.id)?.save ?? null,
|
|
}));
|
|
}
|
|
|
|
function replaceOnlineSlot(current: readonly SaveSlotState[], save: HunterSave): SaveSlotState[] {
|
|
return refreshLocalSlots(current).map((slot) => slot.id === save.slotId ? { ...slot, online: save } : slot);
|
|
}
|
|
|
|
export type AppearancePreviewAnimation = "idle" | "walk" | "cast";
|
|
export type AppearanceDrafts = Record<HealerClassId, CharacterAppearanceV1>;
|
|
|
|
function appearanceDraftsFor(save: HunterSave | null): AppearanceDrafts {
|
|
return Object.fromEntries(HEALER_CLASS_ORDER.map((classId) => [
|
|
classId,
|
|
cloneCharacterAppearance(save?.healers[classId].appearance ?? createDefaultHealerAppearance(classId)),
|
|
])) as AppearanceDrafts;
|
|
}
|
|
|
|
export interface FrontendState {
|
|
screen: AppScreen;
|
|
accountId: string | null;
|
|
slots: SaveSlotState[];
|
|
selectedSlotId: SaveSlotId;
|
|
activeSlotId: SaveSlotId | null;
|
|
selectedMode: GameModeId;
|
|
selectedBossId: BossId;
|
|
selectedDifficultySlug: DifficultySlug;
|
|
selectedGearOwnerId: GearOwnerId;
|
|
selectedGearSlotId: GearSlotId;
|
|
gearWorkshopMode: "upgrade" | "infusion";
|
|
selectedInfusionId: string;
|
|
selectedPassiveAbilityId: AbilitySlotId;
|
|
selectedPassiveInfusionId: RunBuffId;
|
|
guideClassId: HealerClassId;
|
|
guideAbilityId: AbilitySlotId;
|
|
profileCollectionView: ProfileCollectionView;
|
|
selectedProfileGroupId: BossGroupId;
|
|
selectedProfileStatId: ProfileStatId;
|
|
appearanceClassId: HealerClassId;
|
|
appearanceDrafts: AppearanceDrafts;
|
|
previewMode: CharacterModelMode;
|
|
previewAnimation: AppearancePreviewAnimation;
|
|
recentRewards: BossRewardAward[];
|
|
settings: GameSettings;
|
|
notice: string;
|
|
restoreSession: () => Promise<boolean>;
|
|
signIn: (username: string, password: string) => Promise<boolean>;
|
|
createAccount: (username: string, password: string) => Promise<boolean>;
|
|
continueOffline: () => void;
|
|
signOut: () => void;
|
|
navigate: (screen: AppScreen) => void;
|
|
selectSlot: (slotId: SaveSlotId) => void;
|
|
createSlot: (slotId: SaveSlotId, hunterName: string) => boolean;
|
|
playSlot: (slotId: SaveSlotId) => void;
|
|
deleteSlot: (slotId: SaveSlotId) => void;
|
|
copySlot: (sourceId: SaveSlotId, targetId: SaveSlotId) => void;
|
|
uploadSlot: (slotId: SaveSlotId) => Promise<boolean>;
|
|
downloadSlot: (slotId: SaveSlotId) => Promise<void>;
|
|
selectMode: (mode: GameModeId) => void;
|
|
selectBoss: (bossId: BossId) => void;
|
|
selectDifficulty: (difficultySlug: DifficultySlug) => void;
|
|
selectGearOwner: (ownerId: GearOwnerId) => void;
|
|
selectGearSlot: (slotId: GearSlotId) => void;
|
|
selectGearWorkshopMode: (mode: "upgrade" | "infusion") => void;
|
|
selectInfusion: (infusionId: string) => void;
|
|
selectPassiveAbility: (abilityId: AbilitySlotId) => void;
|
|
selectPassiveInfusion: (passiveId: RunBuffId) => void;
|
|
openClassHelp: () => void;
|
|
selectGuideClass: (classId: HealerClassId) => void;
|
|
selectGuideAbility: (abilityId: AbilitySlotId) => void;
|
|
selectProfileCollectionView: (view: ProfileCollectionView) => void;
|
|
selectProfileGroup: (groupId: BossGroupId) => void;
|
|
selectProfileStat: (statId: ProfileStatId) => void;
|
|
openAppearanceLab: () => void;
|
|
selectAppearanceClass: (classId: HealerClassId) => void;
|
|
updateAppearanceDraft: (appearance: CharacterAppearanceV1) => void;
|
|
resetAppearanceDraft: () => void;
|
|
saveAppearanceDraft: () => boolean;
|
|
closeAppearanceLab: () => void;
|
|
setAppearancePreviewMode: (mode: CharacterModelMode) => void;
|
|
setAppearancePreviewAnimation: (animation: AppearancePreviewAnimation) => void;
|
|
upgradeSelectedGear: () => boolean;
|
|
equipSelectedInfusion: () => boolean;
|
|
equipPassiveInfusion: (passiveId: RunBuffId) => boolean;
|
|
selectHealerClass: (classId: HealerClassId) => void;
|
|
updateActiveHealerInventory: (inventory: InventoryItem[]) => void;
|
|
updateSetting: <K extends keyof GameSettings>(key: K, value: GameSettings[K]) => void;
|
|
touchActiveSave: () => void;
|
|
recordBossVictory: (bossId: BossId, difficultySlug: DifficultySlug) => BossRewardAward | null;
|
|
recordRoguelikeDefeat: (round: number) => void;
|
|
recordRogueTrialsEndlessDefeat: (bossKills: number) => void;
|
|
recordHockeyHealingDefeat: (returns: number, durationSeconds: number) => void;
|
|
recordHockeyPvpResult: (won: boolean) => void;
|
|
recordHockeyPvpBossKill: () => void;
|
|
recordBlockbreakerDefeat: (bricks: number, durationSeconds: number, score: number) => void;
|
|
recordAetherAssaultDefeat: (score: number, wave: number, durationSeconds: number) => void;
|
|
clearRecentRewards: () => void;
|
|
clearNotice: () => void;
|
|
}
|
|
|
|
function activeSave(slots: SaveSlotState[], activeSlotId: SaveSlotId | null): HunterSave | null {
|
|
return slots.find((slot) => slot.id === activeSlotId)?.local ?? null;
|
|
}
|
|
|
|
export const useFrontendStore = create<FrontendState>((set, get) => ({
|
|
screen: "login",
|
|
accountId: null,
|
|
slots: repository.listLocal(),
|
|
selectedSlotId: 1,
|
|
activeSlotId: null,
|
|
selectedMode: "roguelike-pve",
|
|
selectedBossId: "bulldrome",
|
|
selectedDifficultySlug: "initiate",
|
|
selectedGearOwnerId: "priest",
|
|
selectedGearSlotId: "weapon",
|
|
gearWorkshopMode: "upgrade",
|
|
selectedInfusionId: infusionsForOwner("priest")[0].id,
|
|
selectedPassiveAbilityId: "ability1",
|
|
selectedPassiveInfusionId: "mend-echo",
|
|
guideClassId: "priest",
|
|
guideAbilityId: "ability1",
|
|
profileCollectionView: "stats",
|
|
selectedProfileGroupId: "charge",
|
|
selectedProfileStatId: "roguelike",
|
|
appearanceClassId: "priest",
|
|
appearanceDrafts: appearanceDraftsFor(null),
|
|
previewMode: "modular",
|
|
previewAnimation: "idle",
|
|
recentRewards: [],
|
|
settings: loadSettings(),
|
|
notice: "",
|
|
|
|
restoreSession: async () => {
|
|
try {
|
|
const account = await accounts.session();
|
|
if (!account) return false;
|
|
const serverSlots = await onlineRepository.listSaves();
|
|
set({ accountId: account.username, slots: mergeServerSlots(serverSlots), screen: "saves", notice: `Online session restored for ${account.username}.` });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
|
|
signIn: async (username, password) => {
|
|
const result = await accounts.authenticate(username, password);
|
|
if (!result.ok) {
|
|
set({ notice: accountNotice(result, "sign-in") });
|
|
return false;
|
|
}
|
|
try {
|
|
const serverSlots = await onlineRepository.listSaves();
|
|
set({ accountId: result.username, slots: mergeServerSlots(serverSlots), screen: "saves", notice: `Online sync connected as ${result.username}.` });
|
|
return true;
|
|
} catch {
|
|
set({ notice: "Signed in, but server saves could not be loaded." });
|
|
return false;
|
|
}
|
|
},
|
|
createAccount: async (username, password) => {
|
|
const result = await accounts.create(username, password);
|
|
if (!result.ok) {
|
|
set({ notice: accountNotice(result, "create") });
|
|
return false;
|
|
}
|
|
try {
|
|
const serverSlots = await onlineRepository.listSaves();
|
|
set({ accountId: result.username, slots: mergeServerSlots(serverSlots), screen: "saves", notice: `Account created. Online sync connected as ${result.username}.` });
|
|
return true;
|
|
} catch {
|
|
set({ notice: "Account created, but server saves could not be loaded." });
|
|
return false;
|
|
}
|
|
},
|
|
continueOffline: () => set({ accountId: null, slots: repository.listLocal(), screen: "saves", notice: "Offline saves ready." }),
|
|
signOut: () => {
|
|
void accounts.logout();
|
|
set({ accountId: null, slots: repository.listLocal(), activeSlotId: null, screen: "login", notice: "Signed out. Offline saves remain on this device." });
|
|
},
|
|
navigate: (screen) => set(screen === "profile"
|
|
? {
|
|
screen,
|
|
profileCollectionView: "stats",
|
|
selectedProfileGroupId: "charge",
|
|
selectedProfileStatId: "roguelike",
|
|
notice: "",
|
|
}
|
|
: { screen, notice: "" }),
|
|
selectSlot: (selectedSlotId) => set({ selectedSlotId, notice: "" }),
|
|
createSlot: (slotId, rawHunterName) => {
|
|
const hunterName = normalizeHunterName(rawHunterName);
|
|
if (!hunterName) {
|
|
set({ notice: "Enter a hunter name before creating the save." });
|
|
return false;
|
|
}
|
|
repository.create(slotId, hunterName);
|
|
leaderboardCache.clearSlot(slotId);
|
|
set((state) => ({ slots: refreshLocalSlots(state.slots), selectedSlotId: slotId, notice: `${hunterName} created in offline slot ${slotId}.` }));
|
|
return true;
|
|
},
|
|
playSlot: (slotId) => {
|
|
const local = repository.touch(slotId);
|
|
if (!local) return;
|
|
set((state) => ({ activeSlotId: slotId, selectedSlotId: slotId, slots: refreshLocalSlots(state.slots), screen: "home", notice: "Save loaded." }));
|
|
},
|
|
deleteSlot: (slotId) => {
|
|
repository.deleteLocal(slotId);
|
|
leaderboardCache.clearSlot(slotId);
|
|
clearSaveSyncPending(slotId);
|
|
set((state) => ({
|
|
slots: refreshLocalSlots(state.slots),
|
|
activeSlotId: state.activeSlotId === slotId ? null : state.activeSlotId,
|
|
notice: `Local slot ${slotId} deleted. Online copy preserved.`,
|
|
}));
|
|
},
|
|
copySlot: (sourceId, targetId) => {
|
|
const copy = repository.copyLocal(sourceId, targetId);
|
|
if (!copy) return;
|
|
leaderboardCache.clearSlot(targetId);
|
|
set((state) => ({ slots: refreshLocalSlots(state.slots), selectedSlotId: targetId, notice: `Slot ${sourceId} copied to slot ${targetId}.` }));
|
|
},
|
|
uploadSlot: async (slotId) => {
|
|
const { accountId } = get();
|
|
if (!accountId) {
|
|
set({ notice: "Sign in before syncing online." });
|
|
return false;
|
|
}
|
|
const local = repository.listLocal().find((slot) => slot.id === slotId)?.local;
|
|
if (!local) {
|
|
set({ notice: "No offline save to sync." });
|
|
return false;
|
|
}
|
|
markSaveSyncPending(slotId);
|
|
if (!networkAppearsOnline()) {
|
|
set({ notice: `Slot ${slotId} saved locally. Online sync waits for connection.` });
|
|
return false;
|
|
}
|
|
try {
|
|
const uploaded = await writeServerSaveSerially(local);
|
|
if (get().accountId === accountId) {
|
|
set((state) => ({ slots: replaceOnlineSlot(state.slots, uploaded), notice: `Slot ${slotId} synced to TrueNAS.` }));
|
|
}
|
|
clearSaveSyncPending(slotId);
|
|
return true;
|
|
} catch (error) {
|
|
const retryable = !(error instanceof OnlineApiError) || error.status === 0 || error.status >= 500;
|
|
if (retryable) scheduleSaveSyncRetry(slotId);
|
|
set({ notice: error instanceof Error ? error.message : "Save upload failed." });
|
|
return false;
|
|
}
|
|
},
|
|
downloadSlot: async (slotId) => {
|
|
const { accountId } = get();
|
|
if (!accountId) return set({ notice: "Sign in before downloading an online save." });
|
|
try {
|
|
await onlineSaveQueues.get(slotId)?.promise.catch(() => undefined);
|
|
const serverSave = await onlineRepository.readSave(slotId);
|
|
if (!serverSave) return set({ notice: "No online version exists for this slot." });
|
|
const downloaded = repository.replaceLocal(serverSave);
|
|
clearSaveSyncPending(slotId);
|
|
if (get().accountId === accountId) {
|
|
set((state) => ({ slots: replaceOnlineSlot(state.slots, downloaded), notice: `Slot ${slotId} downloaded from TrueNAS.` }));
|
|
}
|
|
} catch (error) {
|
|
set({ notice: error instanceof Error ? error.message : "Save download failed." });
|
|
}
|
|
},
|
|
selectMode: (selectedMode) => set({ selectedMode, screen: "mode", notice: "" }),
|
|
selectBoss: (selectedBossId) => set({ selectedBossId, notice: "" }),
|
|
selectDifficulty: (selectedDifficultySlug) => set({ selectedDifficultySlug: normalizeDifficultySlug(selectedDifficultySlug), notice: "" }),
|
|
selectGearOwner: (selectedGearOwnerId) => set({
|
|
selectedGearOwnerId,
|
|
selectedInfusionId: infusionsForOwner(selectedGearOwnerId)[0].id,
|
|
notice: "",
|
|
}),
|
|
selectGearSlot: (selectedGearSlotId) => set({ selectedGearSlotId, notice: "" }),
|
|
selectGearWorkshopMode: (gearWorkshopMode) => set({ gearWorkshopMode, notice: "" }),
|
|
selectInfusion: (selectedInfusionId) => set({ selectedInfusionId, notice: "" }),
|
|
selectPassiveAbility: (selectedPassiveAbilityId) => {
|
|
const selectedPassiveInfusionId = RUN_BUFF_ORDER.find((id) => RUN_BUFFS[id].abilitySlotId === selectedPassiveAbilityId) ?? "mend-echo";
|
|
set({ selectedPassiveAbilityId, selectedPassiveInfusionId, notice: "" });
|
|
},
|
|
selectPassiveInfusion: (selectedPassiveInfusionId) => set({
|
|
selectedPassiveAbilityId: RUN_BUFFS[selectedPassiveInfusionId].abilitySlotId,
|
|
selectedPassiveInfusionId,
|
|
notice: "",
|
|
}),
|
|
openClassHelp: () => {
|
|
const hunter = activeSave(get().slots, get().activeSlotId);
|
|
set({
|
|
screen: "class-help",
|
|
guideClassId: hunter?.activeClassId ?? get().guideClassId,
|
|
guideAbilityId: "ability1",
|
|
notice: "",
|
|
});
|
|
},
|
|
selectGuideClass: (guideClassId) => set({ guideClassId, guideAbilityId: "ability1" }),
|
|
selectGuideAbility: (guideAbilityId) => set({ guideAbilityId }),
|
|
selectProfileCollectionView: (profileCollectionView) => set({ profileCollectionView }),
|
|
selectProfileGroup: (selectedProfileGroupId) => set({ selectedProfileGroupId }),
|
|
selectProfileStat: (selectedProfileStatId) => set({ selectedProfileStatId }),
|
|
openAppearanceLab: () => set((state) => {
|
|
const save = activeSave(state.slots, state.activeSlotId);
|
|
return {
|
|
screen: "appearance",
|
|
appearanceClassId: save?.activeClassId ?? state.appearanceClassId,
|
|
appearanceDrafts: appearanceDraftsFor(save),
|
|
previewMode: "modular",
|
|
previewAnimation: "idle",
|
|
notice: "Appearance Lab opened. Changes remain drafts until saved.",
|
|
};
|
|
}),
|
|
selectAppearanceClass: (appearanceClassId) => set({ appearanceClassId, notice: "" }),
|
|
updateAppearanceDraft: (appearance) => set((state) => ({
|
|
appearanceDrafts: {
|
|
...state.appearanceDrafts,
|
|
[state.appearanceClassId]: cloneCharacterAppearance(appearance),
|
|
},
|
|
notice: "Preview updated. Save to keep this look.",
|
|
})),
|
|
resetAppearanceDraft: () => set((state) => ({
|
|
appearanceDrafts: {
|
|
...state.appearanceDrafts,
|
|
[state.appearanceClassId]: createDefaultHealerAppearance(state.appearanceClassId),
|
|
},
|
|
notice: "Class default restored in preview. Save to keep it.",
|
|
})),
|
|
saveAppearanceDraft: () => {
|
|
const { activeSlotId, appearanceClassId, appearanceDrafts } = get();
|
|
if (!activeSlotId) {
|
|
set({ notice: "Load a hunter save before changing appearance." });
|
|
return false;
|
|
}
|
|
const appearance = normalizeHealerAppearance(appearanceClassId, appearanceDrafts[appearanceClassId]);
|
|
const updated = repository.updateLocal(activeSlotId, (save) => ({
|
|
...save,
|
|
healers: {
|
|
...save.healers,
|
|
[appearanceClassId]: {
|
|
...save.healers[appearanceClassId],
|
|
appearance,
|
|
},
|
|
},
|
|
}));
|
|
if (!updated) {
|
|
set({ notice: "Appearance could not be saved." });
|
|
return false;
|
|
}
|
|
markSaveSyncPending(activeSlotId);
|
|
set((state) => ({
|
|
slots: refreshLocalSlots(state.slots),
|
|
appearanceDrafts: {
|
|
...state.appearanceDrafts,
|
|
[appearanceClassId]: cloneCharacterAppearance(appearance),
|
|
},
|
|
notice: `${appearanceClassId[0].toUpperCase() + appearanceClassId.slice(1)} appearance saved locally.`,
|
|
}));
|
|
return true;
|
|
},
|
|
closeAppearanceLab: () => set((state) => {
|
|
const save = activeSave(state.slots, state.activeSlotId);
|
|
return {
|
|
screen: "home",
|
|
appearanceClassId: save?.activeClassId ?? state.appearanceClassId,
|
|
appearanceDrafts: appearanceDraftsFor(save),
|
|
previewMode: "modular",
|
|
previewAnimation: "idle",
|
|
notice: "Appearance Lab closed.",
|
|
};
|
|
}),
|
|
setAppearancePreviewMode: (previewMode) => set({ previewMode }),
|
|
setAppearancePreviewAnimation: (previewAnimation) => set({ previewAnimation }),
|
|
upgradeSelectedGear: () => {
|
|
const { activeSlotId, accountId, selectedGearOwnerId, selectedGearSlotId } = get();
|
|
if (!activeSlotId) return false;
|
|
let message = "Gear upgrade failed.";
|
|
let upgraded = false;
|
|
repository.updateLocal(activeSlotId, (save) => {
|
|
try {
|
|
const result = upgradeGearSlot(save.gearProgress, save.materials, selectedGearOwnerId, selectedGearSlotId);
|
|
upgraded = true;
|
|
message = `${selectedGearOwnerId} ${selectedGearSlotId} upgraded to +${result.gearProgress[selectedGearOwnerId].slots[selectedGearSlotId].level}.`;
|
|
return { ...save, gearProgress: result.gearProgress, materials: result.inventory };
|
|
} catch (error) {
|
|
message = error instanceof Error ? error.message : message;
|
|
return save;
|
|
}
|
|
});
|
|
set((state) => ({ slots: refreshLocalSlots(state.slots), notice: message }));
|
|
return upgraded;
|
|
},
|
|
equipSelectedInfusion: () => {
|
|
const { activeSlotId, accountId, selectedGearOwnerId, selectedGearSlotId, selectedInfusionId } = get();
|
|
if (!activeSlotId) return false;
|
|
let message = "Infusion failed.";
|
|
let equipped = false;
|
|
repository.updateLocal(activeSlotId, (save) => {
|
|
try {
|
|
const wasEquipped = save.gearProgress[selectedGearOwnerId].infusionAbilityId === selectedInfusionId;
|
|
const result = equipActiveInfusion(save.gearProgress, save.materials, selectedGearOwnerId, selectedGearSlotId, selectedInfusionId);
|
|
equipped = true;
|
|
message = wasEquipped ? "Infusion already equipped." : `${selectedGearOwnerId} infusion equipped.`;
|
|
return { ...save, gearProgress: result.gearProgress, materials: result.inventory };
|
|
} catch (error) {
|
|
message = error instanceof Error ? error.message : message;
|
|
return save;
|
|
}
|
|
});
|
|
set((state) => ({ slots: refreshLocalSlots(state.slots), notice: message }));
|
|
return equipped;
|
|
},
|
|
equipPassiveInfusion: (passiveId) => {
|
|
const { activeSlotId, accountId, selectedGearOwnerId } = get();
|
|
if (!activeSlotId) return false;
|
|
let message = "Passive infusion failed.";
|
|
let equipped = false;
|
|
repository.updateLocal(activeSlotId, (save) => {
|
|
try {
|
|
const gearProgress = equipPassiveInfusion(save.gearProgress, selectedGearOwnerId, passiveId);
|
|
equipped = true;
|
|
message = "Passive infusion equipped. Applies next encounter.";
|
|
return { ...save, gearProgress };
|
|
} catch (error) {
|
|
message = error instanceof Error ? error.message : message;
|
|
return save;
|
|
}
|
|
});
|
|
set((state) => ({ slots: refreshLocalSlots(state.slots), notice: message }));
|
|
return equipped;
|
|
},
|
|
selectHealerClass: (classId) => {
|
|
const { activeSlotId, accountId } = get();
|
|
if (!activeSlotId) return;
|
|
const updated = repository.updateLocal(activeSlotId, (save) => ({ ...save, activeClassId: classId }));
|
|
if (!updated) return;
|
|
set({
|
|
slots: refreshLocalSlots(get().slots),
|
|
selectedGearOwnerId: classId,
|
|
selectedInfusionId: infusionsForOwner(classId)[0].id,
|
|
notice: `${updated.healers[classId].level > 1 ? "Level " + updated.healers[classId].level + " " : ""}${classId[0].toUpperCase() + classId.slice(1)} selected.`,
|
|
});
|
|
},
|
|
updateActiveHealerInventory: (inventory) => {
|
|
const { activeSlotId, accountId } = get();
|
|
if (!activeSlotId) return;
|
|
repository.updateLocal(activeSlotId, (save) => ({
|
|
...save,
|
|
healers: {
|
|
...save.healers,
|
|
[save.activeClassId]: { ...save.healers[save.activeClassId], inventory: structuredClone(inventory) },
|
|
},
|
|
}));
|
|
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
|
},
|
|
updateSetting: (key, value) => set((state) => {
|
|
const settings = { ...state.settings, [key]: value };
|
|
persistSettings(settings);
|
|
return { settings, notice: "Settings saved offline." };
|
|
}),
|
|
touchActiveSave: () => {
|
|
const { activeSlotId, accountId } = get();
|
|
if (!activeSlotId) return;
|
|
repository.touch(activeSlotId);
|
|
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
|
},
|
|
recordBossVictory: (bossId, difficultySlug) => {
|
|
const { activeSlotId } = get();
|
|
if (!activeSlotId) return null;
|
|
let awarded: BossRewardAward | null = null;
|
|
repository.updateLocal(activeSlotId, (save) => {
|
|
const reward = rollBossReward(bossId, difficultySlug, save.materials, save.collectionLog);
|
|
awarded = reward.award;
|
|
const bossKills = { ...save.stats.bossKills, [bossId]: (save.stats.bossKills[bossId] ?? 0) + 1 };
|
|
return {
|
|
...save,
|
|
stats: { ...save.stats, totalBossKills: save.stats.totalBossKills + 1, flawlessClears: save.stats.flawlessClears + 1, bossKills },
|
|
materials: reward.inventory,
|
|
collectionLog: reward.collectionLog,
|
|
};
|
|
});
|
|
set((state) => ({
|
|
slots: refreshLocalSlots(state.slots),
|
|
recentRewards: awarded ? [...state.recentRewards, awarded].slice(-12) : state.recentRewards,
|
|
notice: awarded ? `${awarded.drop.name} x${awarded.quantity} saved.` : "Boss clear saved.",
|
|
}));
|
|
markSaveSyncPending(activeSlotId);
|
|
return awarded;
|
|
},
|
|
recordRoguelikeDefeat: (round) => {
|
|
const { activeSlotId } = get();
|
|
if (!activeSlotId) return;
|
|
const updated = repository.updateLocal(activeSlotId, (save) => ({
|
|
...save,
|
|
stats: {
|
|
...save.stats,
|
|
highestRoguelikeRound: highestRoguelikeRoundAfterDefeat(save.stats.highestRoguelikeRound, round),
|
|
},
|
|
}));
|
|
if (!updated) return;
|
|
markSaveSyncPending(activeSlotId);
|
|
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
|
},
|
|
recordRogueTrialsEndlessDefeat: (bossKills) => {
|
|
const { activeSlotId } = get();
|
|
if (!activeSlotId) return;
|
|
const updated = repository.updateLocal(activeSlotId, (save) => ({
|
|
...save,
|
|
stats: {
|
|
...save.stats,
|
|
highestRogueTrialsEndlessKills: highestEndlessBossKillsAfterDefeat(save.stats.highestRogueTrialsEndlessKills, bossKills),
|
|
},
|
|
}));
|
|
if (!updated) return;
|
|
markSaveSyncPending(activeSlotId);
|
|
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
|
},
|
|
recordHockeyHealingDefeat: (returns, durationSeconds) => {
|
|
const { activeSlotId } = get();
|
|
if (!activeSlotId) return;
|
|
const updated = repository.updateLocal(activeSlotId, (save) => {
|
|
const record = bestHockeyHealingRecord(
|
|
save.stats.highestHockeyHealingReturns,
|
|
save.stats.longestHockeyHealingSecondsAtBest,
|
|
returns,
|
|
durationSeconds,
|
|
);
|
|
return {
|
|
...save,
|
|
stats: {
|
|
...save.stats,
|
|
highestHockeyHealingReturns: record.returns,
|
|
longestHockeyHealingSecondsAtBest: record.durationSeconds,
|
|
},
|
|
};
|
|
});
|
|
if (!updated) return;
|
|
markSaveSyncPending(activeSlotId);
|
|
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
|
},
|
|
recordHockeyPvpResult: (won) => {
|
|
const { activeSlotId } = get();
|
|
if (!activeSlotId) return;
|
|
const updated = repository.updateLocal(activeSlotId, (save) => {
|
|
const record = hockeyPvpRecordAfterMatch(
|
|
save.stats.hockeyHealingPvpWins,
|
|
save.stats.hockeyHealingPvpLosses,
|
|
won,
|
|
);
|
|
return {
|
|
...save,
|
|
stats: {
|
|
...save.stats,
|
|
hockeyHealingPvpWins: record.wins,
|
|
hockeyHealingPvpLosses: record.losses,
|
|
},
|
|
};
|
|
});
|
|
if (!updated) return;
|
|
markSaveSyncPending(activeSlotId);
|
|
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
|
},
|
|
recordHockeyPvpBossKill: () => {
|
|
const { activeSlotId } = get();
|
|
if (!activeSlotId) return;
|
|
const updated = repository.updateLocal(activeSlotId, (save) => ({
|
|
...save,
|
|
stats: {
|
|
...save.stats,
|
|
hockeyHealingPvpBossKills: save.stats.hockeyHealingPvpBossKills + 1,
|
|
},
|
|
}));
|
|
if (!updated) return;
|
|
markSaveSyncPending(activeSlotId);
|
|
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
|
},
|
|
recordBlockbreakerDefeat: (bricks, durationSeconds, score) => {
|
|
const { activeSlotId } = get();
|
|
if (!activeSlotId) return;
|
|
const updated = repository.updateLocal(activeSlotId, (save) => {
|
|
const record = bestBlockbreakerRecords(
|
|
save.stats.highestBlockbreakerBricks,
|
|
save.stats.longestBlockbreakerSeconds,
|
|
save.stats.highestBlockbreakerScore,
|
|
bricks,
|
|
durationSeconds,
|
|
score,
|
|
);
|
|
return {
|
|
...save,
|
|
stats: {
|
|
...save.stats,
|
|
highestBlockbreakerBricks: record.bricks,
|
|
longestBlockbreakerSeconds: record.durationSeconds,
|
|
highestBlockbreakerScore: record.score,
|
|
},
|
|
};
|
|
});
|
|
if (!updated) return;
|
|
markSaveSyncPending(activeSlotId);
|
|
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
|
},
|
|
recordAetherAssaultDefeat: (score, wave, durationSeconds) => {
|
|
const { activeSlotId } = get();
|
|
if (!activeSlotId) return;
|
|
const updated = repository.updateLocal(activeSlotId, (save) => {
|
|
const record = bestAetherAssaultRecord(
|
|
save.stats.highestAetherAssaultScore,
|
|
save.stats.highestAetherAssaultWaveAtBest,
|
|
save.stats.longestAetherAssaultSecondsAtBest,
|
|
score,
|
|
wave,
|
|
durationSeconds,
|
|
);
|
|
return {
|
|
...save,
|
|
stats: {
|
|
...save.stats,
|
|
highestAetherAssaultScore: record.score,
|
|
highestAetherAssaultWaveAtBest: record.wave,
|
|
longestAetherAssaultSecondsAtBest: record.durationSeconds,
|
|
},
|
|
};
|
|
});
|
|
if (!updated) return;
|
|
markSaveSyncPending(activeSlotId);
|
|
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
|
},
|
|
clearRecentRewards: () => set({ recentRewards: [] }),
|
|
clearNotice: () => set({ notice: "" }),
|
|
}));
|
|
|
|
export type FrontendSnapshot = Omit<FrontendState,
|
|
| "restoreSession"
|
|
| "signIn"
|
|
| "createAccount"
|
|
| "continueOffline"
|
|
| "signOut"
|
|
| "navigate"
|
|
| "selectSlot"
|
|
| "createSlot"
|
|
| "playSlot"
|
|
| "deleteSlot"
|
|
| "copySlot"
|
|
| "uploadSlot"
|
|
| "downloadSlot"
|
|
| "selectMode"
|
|
| "selectBoss"
|
|
| "selectDifficulty"
|
|
| "selectGearOwner"
|
|
| "selectGearSlot"
|
|
| "selectGearWorkshopMode"
|
|
| "selectInfusion"
|
|
| "selectPassiveAbility"
|
|
| "selectPassiveInfusion"
|
|
| "openClassHelp"
|
|
| "selectGuideClass"
|
|
| "selectGuideAbility"
|
|
| "selectProfileCollectionView"
|
|
| "selectProfileGroup"
|
|
| "selectProfileStat"
|
|
| "openAppearanceLab"
|
|
| "selectAppearanceClass"
|
|
| "updateAppearanceDraft"
|
|
| "resetAppearanceDraft"
|
|
| "saveAppearanceDraft"
|
|
| "closeAppearanceLab"
|
|
| "setAppearancePreviewMode"
|
|
| "setAppearancePreviewAnimation"
|
|
| "upgradeSelectedGear"
|
|
| "equipSelectedInfusion"
|
|
| "equipPassiveInfusion"
|
|
| "selectHealerClass"
|
|
| "updateActiveHealerInventory"
|
|
| "updateSetting"
|
|
| "touchActiveSave"
|
|
| "recordBossVictory"
|
|
| "recordRoguelikeDefeat"
|
|
| "recordRogueTrialsEndlessDefeat"
|
|
| "recordHockeyHealingDefeat"
|
|
| "recordHockeyPvpResult"
|
|
| "recordHockeyPvpBossKill"
|
|
| "recordBlockbreakerDefeat"
|
|
| "recordAetherAssaultDefeat"
|
|
| "clearRecentRewards"
|
|
| "clearNotice"
|
|
>;
|
|
|
|
export function getFrontendSnapshot(): FrontendSnapshot {
|
|
const {
|
|
restoreSession: _restoreSession,
|
|
signIn: _signIn,
|
|
createAccount: _createAccount,
|
|
continueOffline: _continueOffline,
|
|
signOut: _signOut,
|
|
navigate: _navigate,
|
|
selectSlot: _selectSlot,
|
|
createSlot: _createSlot,
|
|
playSlot: _playSlot,
|
|
deleteSlot: _deleteSlot,
|
|
copySlot: _copySlot,
|
|
uploadSlot: _uploadSlot,
|
|
downloadSlot: _downloadSlot,
|
|
selectMode: _selectMode,
|
|
selectBoss: _selectBoss,
|
|
selectDifficulty: _selectDifficulty,
|
|
selectGearOwner: _selectGearOwner,
|
|
selectGearSlot: _selectGearSlot,
|
|
selectGearWorkshopMode: _selectGearWorkshopMode,
|
|
selectInfusion: _selectInfusion,
|
|
selectPassiveAbility: _selectPassiveAbility,
|
|
selectPassiveInfusion: _selectPassiveInfusion,
|
|
openClassHelp: _openClassHelp,
|
|
selectGuideClass: _selectGuideClass,
|
|
selectGuideAbility: _selectGuideAbility,
|
|
selectProfileCollectionView: _selectProfileCollectionView,
|
|
selectProfileGroup: _selectProfileGroup,
|
|
selectProfileStat: _selectProfileStat,
|
|
openAppearanceLab: _openAppearanceLab,
|
|
selectAppearanceClass: _selectAppearanceClass,
|
|
updateAppearanceDraft: _updateAppearanceDraft,
|
|
resetAppearanceDraft: _resetAppearanceDraft,
|
|
saveAppearanceDraft: _saveAppearanceDraft,
|
|
closeAppearanceLab: _closeAppearanceLab,
|
|
setAppearancePreviewMode: _setAppearancePreviewMode,
|
|
setAppearancePreviewAnimation: _setAppearancePreviewAnimation,
|
|
upgradeSelectedGear: _upgradeSelectedGear,
|
|
equipSelectedInfusion: _equipSelectedInfusion,
|
|
equipPassiveInfusion: _equipPassiveInfusion,
|
|
selectHealerClass: _selectHealerClass,
|
|
updateActiveHealerInventory: _updateActiveHealerInventory,
|
|
updateSetting: _updateSetting,
|
|
touchActiveSave: _touchActiveSave,
|
|
recordBossVictory: _recordBossVictory,
|
|
recordRoguelikeDefeat: _recordRoguelikeDefeat,
|
|
recordRogueTrialsEndlessDefeat: _recordRogueTrialsEndlessDefeat,
|
|
recordHockeyHealingDefeat: _recordHockeyHealingDefeat,
|
|
recordHockeyPvpResult: _recordHockeyPvpResult,
|
|
recordHockeyPvpBossKill: _recordHockeyPvpBossKill,
|
|
recordBlockbreakerDefeat: _recordBlockbreakerDefeat,
|
|
recordAetherAssaultDefeat: _recordAetherAssaultDefeat,
|
|
clearRecentRewards: _clearRecentRewards,
|
|
clearNotice: _clearNotice,
|
|
...snapshot
|
|
} = useFrontendStore.getState();
|
|
return snapshot;
|
|
}
|
|
|
|
export function useActiveHunter(): HunterSave | null {
|
|
return useFrontendStore((state) => activeSave(state.slots, state.activeSlotId));
|
|
}
|