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 }>(); function writeServerSaveSerially(save: HunterSave): Promise { 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, 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; 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; signIn: (username: string, password: string) => Promise; createAccount: (username: string, password: string) => Promise; 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; downloadSlot: (slotId: SaveSlotId) => Promise; 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: (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((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; 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)); }