/** * @file Game context providing global state and actions for the Elysium game. * @copyright nhcarrigan * @license Naomi's Public License * @author Naomi Carrigan */ /* eslint-disable max-lines -- Large context file with many state management concerns */ /* eslint-disable max-lines-per-function -- Context provider and callbacks require many declarations */ /* eslint-disable max-statements -- Context provider requires many state initialisations */ /* eslint-disable complexity -- Game logic has inherently high branching complexity */ /* eslint-disable max-nested-callbacks -- Game state callbacks require deep nesting */ /* eslint-disable import/exports-last -- BattleResult interface must be adjacent to GameContext */ /* eslint-disable import/group-exports -- Context exports are naturally spread throughout the file */ import { STORY_CHAPTERS, type Achievement, type ApotheosisResponse, type BossChallengeResponse, type ExploreCollectResponse, type GameState, type LoginBonusResult, type NumberFormat, type Quest, type TranscendenceResponse, computeUnlockedCompanionIds, isStoryChapterUnlocked, } from "@elysium/types"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type JSX, type ReactNode, } from "react"; import { achieveApotheosis as achieveApotheosisApi, buyEchoUpgrade as buyEchoUpgradeApi, buyPrestigeUpgrade as buyPrestigeUpgradeApi, challengeBoss as challengeBossApi, collectExploration as collectExplorationApi, craftRecipe as craftRecipeApi, debugHardReset as debugHardResetApi, forceUnlocks as forceUnlocksApi, syncNewContent as syncNewContentApi, loadGame, prestige as prestigeApi, resetProgress as resetProgressApi, saveGame, startExploration as startExplorationApi, transcend as transcendApi, } from "../api/client.js"; import { CODEX_ENTRIES } from "../data/codex.js"; import { EXPLORATION_AREAS } from "../data/explorations.js"; import { RECIPES } from "../data/recipes.js"; import { RESOURCE_CAP, applyTick, calculateClickPower, computePartyCombatPower, } from "../engine/tick.js"; import { updateChallengeProgress } from "../utils/dailyChallenges.js"; import { formatInteger as formatIntegerUtil, formatNumber as formatNumberUtil, } from "../utils/format.js"; import { logError } from "../utils/logError.js"; import { sendNotification } from "../utils/notification.js"; import { playSound } from "../utils/sound.js"; const autoSaveIntervalMs = 30_000; const autoPrestigeThresholdBase = 1_000_000; /** * Pure function — applies a boss challenge result to the game state. * Used by both the manual challengeBoss flow and the auto-boss tick logic. * @param previous - The current game state. * @param bossId - The ID of the boss being challenged. * @param result - The result of the boss challenge. * @returns The updated game state. */ const applyBossResult = ( previous: GameState, bossId: string, result: BossChallengeResponse, ): GameState => { if (result.won) { const defeatedBoss = previous.bosses.find((b) => { return b.id === bossId; }); const zoneBosses = previous.bosses.filter((b) => { return b.zoneId === defeatedBoss?.zoneId; }); const zoneIndex = zoneBosses.findIndex((b) => { return b.id === bossId; }); const nextZoneBossId = zoneBosses[zoneIndex + 1]?.id; const unlockedZones = previous.zones.filter((z) => { if (z.status === "locked" && z.unlockBossId === bossId) { const questOk = z.unlockQuestId === null || previous.quests.some((q) => { return q.id === z.unlockQuestId && q.status === "completed"; }); return questOk; } return false; }); const zoneFirstBossIds = new Set( unlockedZones. map((z) => { const firstBoss = previous.bosses.find((b) => { return b.zoneId === z.id; }); return firstBoss?.id; }). filter(Boolean), ); const newlyUnlockedZoneIds = new Set(unlockedZones.map((z) => { return z.id; })); const challengeUpdate = previous.dailyChallenges === undefined ? { crystalsAwarded: 0, updatedChallenges: undefined } : updateChallengeProgress( previous.dailyChallenges, "bossesDefeated", 1, ); const rewardIds: Array = result.rewards?.upgradeIds ?? []; const rewardEquipmentIds: Array = result.rewards?.equipmentIds ?? []; return { ...previous, bosses: previous.bosses.map((b) => { if (b.id === bossId) { return { ...b, currentHp: 0, status: "defeated" as const }; } if ( b.id === nextZoneBossId && b.prestigeRequirement <= previous.prestige.count ) { return { ...b, status: "available" as const }; } if ( zoneFirstBossIds.has(b.id) && b.prestigeRequirement <= previous.prestige.count ) { return { ...b, status: "available" as const }; } return b; }), zones: previous.zones.map((z) => { if (z.status === "locked" && z.unlockBossId === bossId) { const questOk = z.unlockQuestId === null || previous.quests.some((q) => { return q.id === z.unlockQuestId && q.status === "completed"; }); return questOk ? { ...z, status: "unlocked" as const } : z; } return z; }), ...challengeUpdate.updatedChallenges === undefined ? {} : { dailyChallenges: challengeUpdate.updatedChallenges }, equipment: previous.equipment.map((equip) => { if (rewardEquipmentIds.includes(equip.id)) { const slotEmpty = !previous.equipment.some((other) => { return other.type === equip.type && other.equipped; }); return { ...equip, equipped: slotEmpty || equip.equipped, owned: true, }; } return equip; }), player: result.rewards === undefined ? previous.player : { ...previous.player, totalGoldEarned: previous.player.totalGoldEarned + result.rewards.gold, }, prestige: result.rewards?.bountyRunestones === undefined ? previous.prestige : { ...previous.prestige, runestones: previous.prestige.runestones + result.rewards.bountyRunestones, }, resources: result.rewards === undefined ? { ...previous.resources, crystals: previous.resources.crystals + challengeUpdate.crystalsAwarded, } : { ...previous.resources, crystals: previous.resources.crystals + result.rewards.crystals + challengeUpdate.crystalsAwarded, essence: previous.resources.essence + result.rewards.essence, gold: previous.resources.gold + result.rewards.gold, }, upgrades: previous.upgrades.map((u) => { return rewardIds.includes(u.id) ? { ...u, unlocked: true } : u; }), ...newlyUnlockedZoneIds.size === 0 || previous.exploration === undefined ? {} : { exploration: { ...previous.exploration, areas: previous.exploration.areas.map((area) => { const areaDefinition = EXPLORATION_AREAS.find((definition) => { return definition.id === area.id; }); return areaDefinition !== undefined && newlyUnlockedZoneIds.has(areaDefinition.zoneId) && area.status === "locked" ? { ...area, status: "available" as const } : area; }), }, }, }; } // Loss: reset boss HP and apply casualties return { ...previous, adventurers: previous.adventurers.map((a) => { const casualty = result.casualties?.find((c) => { return c.adventurerId === a.id; }); if (casualty === undefined) { return a; } return { ...a, count: Math.max(0, a.count - casualty.killed) }; }), bosses: previous.bosses.map((b) => { return b.id === bossId ? { ...b, currentHp: b.maxHp, status: "available" as const } : b; }), }; }; interface GameContextValue { state: GameState | null; isLoading: boolean; error: string | null; /** * Whether the player is currently a member of the NHCarrigan Discord server. */ inGuild: boolean; /** * Click the crystal to earn gold. */ handleClick: ()=> void; /** * Buy one or more of an adventurer tier; quantity > 1 buys in batch. */ buyAdventurer: (adventurerId: string, quantity: number)=> void; /** * Buy an upgrade. */ buyUpgrade: (upgradeId: string)=> void; /** * Purchase a buyable equipment item. */ buyEquipment: (equipmentId: string)=> void; /** * Start a quest. */ startQuest: (questId: string)=> void; /** * Challenge a boss — runs full server-side simulation. */ challengeBoss: (bossId: string)=> Promise; /** * Equip an owned equipment item (auto-unequips the same slot). */ equipItem: (equipmentId: string)=> void; /** * Reload state from the server. */ reload: ()=> Promise; /** * Reload state from the server without showing the loading screen (used * after prestige to avoid the visible flash/hang). */ reloadSilent: ()=> Promise; /** * Unix timestamp of the last successful cloud save (null until first save response). */ lastSavedAt: number | null; /** * True whilst a forced save is in-flight. */ isSyncing: boolean; /** * Immediately save to the server and reset the auto-save timer. */ forceSync: ()=> Promise; /** * Error message from the last failed cloud save (null when no error). */ syncError: string | null; /** * Offline gold earned on login. */ offlineGold: number; /** * Offline essence earned on login. */ offlineEssence: number; /** * Dismiss the offline earnings notification. */ dismissOfflineGold: ()=> void; /** * Battle result to display in the modal (null when no battle pending). */ battleResult: BattleResult | null; /** * Dismiss the battle result modal. */ dismissBattle: ()=> void; /** * Queue of newly unlocked achievements (for toasts). */ unlockedAchievements: Array; /** * Remove an achievement from the toast queue. */ dismissAchievement: (id: string)=> void; /** * Queue of newly completed quests (for toast notifications). */ completedQuestToasts: Array; /** * Remove a quest from the completed toast queue. */ dismissCompletedQuest: (id: string)=> void; /** * Queue of newly failed quests (for toast notifications). */ failedQuestToasts: Array; /** * Remove a quest from the failed toast queue. */ dismissFailedQuest: (id: string)=> void; /** * Whether the prestige milestone toast is currently showing. */ showPrestigeToast: boolean; /** * Trigger the prestige milestone toast (called from prestigePanel on manual prestige). */ triggerPrestigeToast: ()=> void; /** * Dismiss the prestige milestone toast. */ dismissPrestigeToast: ()=> void; /** * Whether the transcendence milestone toast is currently showing. */ showTranscendenceToast: boolean; /** * Dismiss the transcendence milestone toast. */ dismissTranscendenceToast: ()=> void; /** * Whether the apotheosis milestone toast is currently showing. */ showApotheosisToast: boolean; /** * Dismiss the apotheosis milestone toast. */ dismissApotheosisToast: ()=> void; /** * The player's chosen number display format. */ numberFormat: NumberFormat; /** * Update the number format preference (persisted to server via profile save). */ setNumberFormat: (format: NumberFormat)=> void; /** * Whether in-game sound effects are enabled. */ enableSounds: boolean; /** * Whether browser system notifications are enabled. */ enableNotifications: boolean; /** * Update the enable-sounds preference. */ setEnableSounds: (enabled: boolean)=> void; /** * Update the enable-notifications preference. */ setEnableNotifications: (enabled: boolean)=> void; /** * Format a number using the player's chosen notation style. */ formatNumber: (value: number)=> string; /** * Format a whole-number value without decimal places. */ formatInteger: (value: number)=> string; /** * Buy a prestige upgrade from the runestone shop. */ buyPrestigeUpgrade: (upgradeId: string)=> Promise; /** * Toggle the auto-prestige setting on/off (requires auto_prestige upgrade). */ toggleAutoPrestige: ()=> void; /** * Toggle whether auto-prestige waits for maximum runestone yield before firing. */ toggleAutoPrestigeMaxRunestones: ()=> void; /** * Toggle the auto-quest setting on/off. */ toggleAutoQuest: ()=> void; /** * Toggle the auto-boss setting on/off. */ toggleAutoBoss: ()=> void; /** * Toggle the auto-adventurer setting on/off (requires auto_adventurer prestige upgrade). */ toggleAutoAdventurer: ()=> void; /** * Queue of newly unlocked codex entry IDs (for toast notifications). */ unlockedCodexEntryIds: Array; /** * Remove a codex entry ID from the notification queue. */ dismissCodexEntry: (id: string)=> void; /** * Flush pending boss lore codex toasts — call after the battle animation reveals the result. */ flushBossLoreToasts: ()=> void; /** * Perform a transcendence — nuclear reset, earning echoes. */ transcend: ()=> Promise; /** * Buy an echo upgrade from the transcendence shop. */ buyEchoUpgrade: (upgradeId: string)=> Promise; /** * Achieve Apotheosis — the ultimate nuclear reset, bragging rights only. */ apotheosis: ()=> Promise; /** * Start an exploration in the given area. */ startExploration: (areaId: string)=> Promise; /** * Collect results of a completed exploration. */ collectExploration: (areaId: string)=> Promise; /** * Craft a recipe using collected materials. */ craftRecipe: (recipeId: string)=> Promise; /** * Daily login bonus earned on this session load (null if already claimed today). */ loginBonus: LoginBonusResult | null; /** * Player's current login streak (days). */ loginStreak: number; /** * Dismiss the login bonus modal. */ dismissLoginBonus: ()=> void; /** * Set the active companion (null to deactivate). */ setActiveCompanion: (companionId: string | null)=> void; /** * Queue of newly unlocked story chapter IDs (for toast notifications). */ unlockedStoryChapterIds: Array; /** * Remove a chapter ID from the story notification queue. */ dismissStoryChapter: (id: string)=> void; /** * Record the player's choice for a story chapter. */ completeChapter: (chapterId: string, choiceId: string)=> void; /** * True when the loaded save is from an older schema version. */ schemaOutdated: boolean; /** * The save's schema version (0 if missing). */ saveSchemaVersion: number; /** * The server's current expected schema version. */ currentSchemaVersion: number; /** * Reset all progress to a fresh save state (resolves schema outdated). */ resetProgress: ()=> Promise; /** * Force-unlock any zones, quests, and bosses the player has earned but that * are still incorrectly locked due to a state bug. * @returns Counts of what was corrected. */ forceUnlocks: ()=> Promise<{ adventurersUnlocked: number; bossesUnlocked: number; equipmentUnlocked: number; explorationUnlocked: number; questsUnlocked: number; storyUnlocked: number; upgradesUnlocked: number; zonesUnlocked: number; }>; /** * Completely wipe the player's progress back to a brand-new save via the * debug endpoint. */ debugHardReset: ()=> Promise; /** * Syncs any content added to the game after the player's save was created. * @returns Counts of what was added per content type. */ syncNewContent: ()=> Promise<{ achievementsAdded: number; achievementsPatched: number; adventurerStatsPatched: number; adventurersAdded: number; bossRewardsPatched: number; bossesAdded: number; bossesPatched: number; craftingRecipesReapplied: number; equipmentAdded: number; equipmentPatched: number; explorationAreasAdded: number; questRewardsPatched: number; questsAdded: number; questsPatched: number; upgradesAdded: number; upgradesPatched: number; zonesAdded: number; zonesPatched: number; }>; /** * Last auto-boss fight result — null until the first auto fight completes or * when auto-boss is toggled off. */ autoBossLastResult: { bossName: string; won: boolean; at: number } | null; /** * Error message set when auto-boss stopped due to a critical failure (null * when no error). Cleared automatically when the player re-enables auto-boss. */ autoBossError: string | null; /** * Error message from the most recent manual boss challenge (null when no * error). Cleared automatically when a new challenge is initiated. */ bossError: string | null; } export interface BattleResult { bossName: string; result: BossChallengeResponse; } const GameContext = createContext(null); /** * Provides the game context to its children, managing all game state and actions. * @param props - The provider properties. * @param props.children - The child components that will have access to the game context. * @returns The JSX element wrapping children with the game context. */ export const GameProvider = ({ children, }: { readonly children: ReactNode; }): JSX.Element => { const [ state, setState ] = useState(null); const [ isLoading, setIsLoading ] = useState(true); const [ error, setError ] = useState(null); const [ offlineGold, setOfflineGold ] = useState(0); const [ offlineEssence, setOfflineEssence ] = useState(0); const [ loginBonus, setLoginBonus ] = useState(null); const [ loginStreak, setLoginStreak ] = useState(1); const [ battleResult, setBattleResult ] = useState(null); const [ unlockedAchievements, setUnlockedAchievements ] = useState< Array >([]); const [ completedQuestToasts, setCompletedQuestToasts ] = useState< Array >([]); const [ failedQuestToasts, setFailedQuestToasts ] = useState>( [], ); const [ showPrestigeToast, setShowPrestigeToast ] = useState(false); const [ showTranscendenceToast, setShowTranscendenceToast ] = useState(false); const [ showApotheosisToast, setShowApotheosisToast ] = useState(false); const [ lastSavedAt, setLastSavedAt ] = useState(null); const [ isSyncing, setIsSyncing ] = useState(false); const [ syncError, setSyncError ] = useState(null); const [ autoBossLastResult, setAutoBossLastResult ] = useState<{ bossName: string; won: boolean; at: number; } | null>(null); const [ autoBossError, setAutoBossError ] = useState(null); const [ bossError, setBossError ] = useState(null); const syncErrorTimerReference = useRef | null>( null, ); const [ numberFormat, setNumberFormat ] = useState("suffix"); const [ enableSounds, setEnableSounds ] = useState(false); const [ enableNotifications, setEnableNotifications ] = useState(false); const enableSoundsReference = useRef(false); const enableNotificationsReference = useRef(false); const stateReference = useRef(null); const lastSaveReference = useRef(Date.now()); const isSyncingReference = useRef(false); const rafReference = useRef(null); const unlockedAchievementsReference = useRef>([]); const newlyCompletedQuestsReference = useRef>([]); const newlyFailedQuestsReference = useRef>([]); const signatureReference = useRef( localStorage.getItem("elysium_save_signature"), ); const isAutoPrestigingReference = useRef(false); const isAutoBossingReference = useRef(false); const reloadReference = useRef<()=> Promise>(async() => { /* No-op placeholder */ }); const reloadSilentReference = useRef<()=> Promise>(async() => { /* No-op placeholder */ }); const [ schemaOutdated, setSchemaOutdated ] = useState(false); const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0); const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0); const [ inGuild, setInGuild ] = useState(false); const [ unlockedCodexEntryIds, setUnlockedCodexEntryIds ] = useState< Array >([]); const codexProcessedReference = useRef>(new Set()); const pendingBossCodexIdsReference = useRef>([]); const [ unlockedStoryChapterIds, setUnlockedStoryChapterIds ] = useState< Array >([]); const storyProcessedReference = useRef>(new Set()); const storyFirstRunReference = useRef(true); stateReference.current = state; const reload = useCallback(async() => { setIsLoading(true); setError(null); try { const data = await loadGame(); setState(data.state); setLastSavedAt(data.state.player.lastSavedAt); if (data.signature !== undefined) { signatureReference.current = data.signature; localStorage.setItem("elysium_save_signature", data.signature); } if (data.offlineGold > 0) { setOfflineGold(data.offlineGold); } if (data.offlineEssence > 0) { setOfflineEssence(data.offlineEssence); } if (data.loginBonus !== null) { setLoginBonus(data.loginBonus); } setLoginStreak(data.loginStreak); setSchemaOutdated(data.schemaOutdated); setSaveSchemaVersion(data.state.schemaVersion ?? 0); setCurrentSchemaVersion(data.currentSchemaVersion); setInGuild(data.inGuild); // Fetch number format preference from profile (fire-and-forget, non-blocking) void fetch(`/api/profile/${data.state.player.discordId}`). then(async(response) => { if (!response.ok) { return; } // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response cast const profile = (await response.json()) as { profileSettings?: { enableNotifications?: boolean; enableSounds?: boolean; numberFormat?: NumberFormat; }; }; const fmt = profile.profileSettings?.numberFormat; if ( fmt === "suffix" || fmt === "scientific" || fmt === "engineering" ) { setNumberFormat(fmt); } setEnableSounds(profile.profileSettings?.enableSounds === true); setEnableNotifications( profile.profileSettings?.enableNotifications === true, ); }). catch(() => { /* Fall back to defaults */ }); } catch (error_: unknown) { setError( error_ instanceof Error ? error_.message : "Failed to load game", ); } finally { setIsLoading(false); } }, []); reloadReference.current = reload; const reloadSilent = useCallback(async() => { setError(null); try { const data = await loadGame(); setState(data.state); setLastSavedAt(data.state.player.lastSavedAt); if (data.signature !== undefined) { signatureReference.current = data.signature; localStorage.setItem("elysium_save_signature", data.signature); } setLoginStreak(data.loginStreak); setSchemaOutdated(data.schemaOutdated); setSaveSchemaVersion(data.state.schemaVersion ?? 0); setCurrentSchemaVersion(data.currentSchemaVersion); setInGuild(data.inGuild); } catch (error_: unknown) { setError( error_ instanceof Error ? error_.message : "Failed to load game", ); } }, []); reloadSilentReference.current = reloadSilent; useEffect(() => { enableSoundsReference.current = enableSounds; }, [ enableSounds ]); useEffect(() => { enableNotificationsReference.current = enableNotifications; }, [ enableNotifications ]); useEffect(() => { void reload(); }, [ reload ]); // Detect newly defeated bosses and completed quests to unlock Codex entries useEffect(() => { if (state === null) { return; } const existingUnlocked = state.codex?.unlockedEntryIds ?? []; // On first run (empty processed set), silently unlock existing completions const isFirstRun = codexProcessedReference.current.size === 0; const addedIds: Array = []; for (const boss of state.bosses) { const codexId = `boss_${boss.id}`; if ( boss.status === "defeated" && !codexProcessedReference.current.has(codexId) ) { codexProcessedReference.current.add(codexId); if ( !existingUnlocked.includes(codexId) && CODEX_ENTRIES.some((entry) => { return entry.id === codexId; }) ) { addedIds.push(codexId); } } } for (const quest of state.quests) { const codexId = `quest_${quest.id}`; if ( quest.status === "completed" && !codexProcessedReference.current.has(codexId) ) { codexProcessedReference.current.add(codexId); if ( !existingUnlocked.includes(codexId) && CODEX_ENTRIES.some((entry) => { return entry.id === codexId; }) ) { addedIds.push(codexId); } } } for (const zone of state.zones) { const codexId = `zone_${zone.id}`; if ( zone.status === "unlocked" && !codexProcessedReference.current.has(codexId) ) { codexProcessedReference.current.add(codexId); if ( !existingUnlocked.includes(codexId) && CODEX_ENTRIES.some((entry) => { return entry.id === codexId; }) ) { addedIds.push(codexId); } } } for (const equip of state.equipment) { const codexId = `equipment_${equip.id}`; if (equip.owned && !codexProcessedReference.current.has(codexId)) { codexProcessedReference.current.add(codexId); if ( !existingUnlocked.includes(codexId) && CODEX_ENTRIES.some((entry) => { return entry.id === codexId; }) ) { addedIds.push(codexId); } } } for (const adventurer of state.adventurers) { const codexId = `adventurer_${adventurer.id}`; if ( adventurer.count > 0 && !codexProcessedReference.current.has(codexId) ) { codexProcessedReference.current.add(codexId); if ( !existingUnlocked.includes(codexId) && CODEX_ENTRIES.some((entry) => { return entry.id === codexId; }) ) { addedIds.push(codexId); } } } for (const upgrade of state.upgrades) { const codexId = `upgrade_${upgrade.id}`; if (upgrade.purchased && !codexProcessedReference.current.has(codexId)) { codexProcessedReference.current.add(codexId); if ( !existingUnlocked.includes(codexId) && CODEX_ENTRIES.some((entry) => { return entry.id === codexId; }) ) { addedIds.push(codexId); } } } for (const prestigeId of state.prestige.purchasedUpgradeIds) { const codexId = `prestige_${prestigeId}`; if (!codexProcessedReference.current.has(codexId)) { codexProcessedReference.current.add(codexId); if ( !existingUnlocked.includes(codexId) && CODEX_ENTRIES.some((entry) => { return entry.id === codexId; }) ) { addedIds.push(codexId); } } } for (const area of state.exploration?.areas ?? []) { const codexId = `explore_${area.id}`; if (area.completedOnce === true && !codexProcessedReference.current.has(codexId) ) { codexProcessedReference.current.add(codexId); if ( !existingUnlocked.includes(codexId) && CODEX_ENTRIES.some((entry) => { return entry.id === codexId; }) ) { addedIds.push(codexId); } } } for (const recipeId of state.exploration?.craftedRecipeIds ?? []) { const codexId = `recipe_${recipeId}`; if (!codexProcessedReference.current.has(codexId)) { codexProcessedReference.current.add(codexId); if ( !existingUnlocked.includes(codexId) && CODEX_ENTRIES.some((entry) => { return entry.id === codexId; }) ) { addedIds.push(codexId); } } } if (addedIds.length > 0) { setState((previous) => { if (previous === null) { return previous; } const existing = previous.codex?.unlockedEntryIds ?? []; const toAdd = addedIds.filter((id) => { return !existing.includes(id); }); if (toAdd.length === 0) { return previous; } return { ...previous, codex: { unlockedEntryIds: [ ...existing, ...toAdd ] }, }; }); if (!isFirstRun) { const bossIds = addedIds.filter((id) => { return id.startsWith("boss_"); }); const otherIds = addedIds.filter((id) => { return !id.startsWith("boss_"); }); if (bossIds.length > 0) { if (battleResult === null) { otherIds.push(...bossIds); } else { pendingBossCodexIdsReference.current = [ ...pendingBossCodexIdsReference.current, ...bossIds, ]; } } if (otherIds.length > 0) { setUnlockedCodexEntryIds((previous) => { return [ ...previous, ...otherIds ]; }); } } } }, [ battleResult, state ]); // Detect newly unlocked story chapters useEffect(() => { if (state === null) { return; } // On first run, populate the ref from existing unlocked IDs without toasting if (storyFirstRunReference.current) { storyFirstRunReference.current = false; const existing = state.story?.unlockedChapterIds ?? []; for (const id of existing) { storyProcessedReference.current.add(id); } return; } const addedChapterIds: Array = []; for (const chapter of STORY_CHAPTERS) { if (storyProcessedReference.current.has(chapter.id)) { continue; } if (isStoryChapterUnlocked(chapter, state)) { storyProcessedReference.current.add(chapter.id); addedChapterIds.push(chapter.id); } } if (addedChapterIds.length === 0) { return; } setUnlockedStoryChapterIds((previous) => { return [ ...previous, ...addedChapterIds ]; }); setState((previous) => { if (previous === null) { return previous; } const existing = previous.story?.unlockedChapterIds ?? []; const toAdd = addedChapterIds.filter((id) => { return !existing.includes(id); }); if (toAdd.length === 0) { return previous; } return { ...previous, story: { completedChapters: previous.story?.completedChapters ?? [], unlockedChapterIds: [ ...existing, ...toAdd ], }, }; }); }, [ state ]); // Detect newly unlocked companions whenever relevant state changes useEffect(() => { if (state === null) { return; } const computedUnlocks = computeUnlockedCompanionIds({ apotheosisCount: state.apotheosis?.count ?? 0, lifetimeBossesDefeated: state.player.lifetimeBossesDefeated, lifetimeGoldEarned: state.player.lifetimeGoldEarned, lifetimeQuestsCompleted: state.player.lifetimeQuestsCompleted, prestigeCount: state.prestige.count, transcendenceCount: state.transcendence?.count ?? 0, }); const currentUnlocks = state.companions?.unlockedCompanionIds ?? []; const toAdd = computedUnlocks.filter((id) => { return !currentUnlocks.includes(id); }); if (toAdd.length === 0) { return; } setState((previous) => { if (previous === null) { return previous; } const existingUnlocks = previous.companions?.unlockedCompanionIds ?? []; const addedIds = computedUnlocks.filter((id) => { return !existingUnlocks.includes(id); }); if (addedIds.length === 0) { return previous; } const updatedUnlocks = [ ...existingUnlocks, ...addedIds ]; const activeId = previous.companions?.activeCompanionId ?? null; const validatedActiveId = activeId !== null && updatedUnlocks.includes(activeId) ? activeId : null; return { ...previous, companions: { activeCompanionId: validatedActiveId, unlockedCompanionIds: updatedUnlocks, }, }; }); }, [ state ]); // Game loop via requestAnimationFrame useEffect(() => { if (state === null) { return; } let lastTime = performance.now(); const tick = (now: number): void => { const deltaSeconds = (now - lastTime) / 1000; lastTime = now; setState((previous) => { if (previous === null) { return previous; } let next = applyTick(previous, deltaSeconds); // Auto-quest: start the highest-zone available quest when none is active if (next.autoQuest === true) { const hasActiveQuest = next.quests.some((q) => { return q.status === "active"; }); if (!hasActiveQuest) { const partyCombatPower = computePartyCombatPower(next); const zoneOrder = new Map( next.zones.map((z, index) => { return [ z.id, index ]; }), ); const candidates = next.quests. filter((q) => { return ( q.status === "available" && (q.combatPowerRequired ?? 0) <= partyCombatPower ); }). sort((questA, questB) => { return ( (zoneOrder.get(questB.zoneId) ?? 0) - (zoneOrder.get(questA.zoneId) ?? 0) ); }); const [ best ] = candidates; if (best !== undefined) { next = { ...next, quests: next.quests.map((q) => { return q.id === best.id ? { ...q, startedAt: Date.now(), status: "active" as const } : q; }), }; } } } // Auto-adventurer: buy one of the highest-tier affordable unlocked adventurer per tick if ( next.autoAdventurer === true && next.prestige.purchasedUpgradeIds.includes("auto_adventurer") ) { const maxAdventurerLevel = Math.max( ...next.adventurers. filter((a) => { return a.unlocked; }). map((a) => { return a.level; }), ); const autoBuyCap = 100; const [ bestAdventurer ] = next.adventurers. filter((adventurer) => { const cost = adventurer.baseCost * Math.pow(1.15, adventurer.count); const isMaxTier = adventurer.level === maxAdventurerLevel; const withinCap = isMaxTier || adventurer.count < autoBuyCap; return ( adventurer.unlocked && next.resources.gold >= cost && withinCap ); }). sort((adventurerA, adventurerB) => { return adventurerB.level - adventurerA.level; }); if (bestAdventurer !== undefined) { const purchaseCost = bestAdventurer.baseCost * Math.pow(1.15, bestAdventurer.count); next = { ...next, adventurers: next.adventurers.map((adventurer) => { return adventurer.id === bestAdventurer.id ? { ...adventurer, count: adventurer.count + 1 } : adventurer; }), resources: { ...next.resources, gold: next.resources.gold - purchaseCost, }, }; } } // Detect newly unlocked achievements unlockedAchievementsReference.current = next.achievements.filter( (a, index) => { const wasLocked = previous.achievements[index]?.unlockedAt === null; return wasLocked && a.unlockedAt !== null; }, ); // Detect newly completed quests newlyCompletedQuestsReference.current = next.quests.filter( (q, index) => { return ( previous.quests[index]?.status === "active" && q.status === "completed" ); }, ); // Detect newly failed quests newlyFailedQuestsReference.current = next.quests.filter( (q, index) => { const previousFailedAt = previous.quests[index]?.lastFailedAt; return ( q.lastFailedAt !== undefined && q.lastFailedAt !== previousFailedAt ); }, ); return next; }); if (unlockedAchievementsReference.current.length > 0) { setUnlockedAchievements((previous) => { return [ ...previous, ...unlockedAchievementsReference.current ]; }); if (enableSoundsReference.current) { playSound("achievement"); } if (enableNotificationsReference.current) { for (const achievement of unlockedAchievementsReference.current) { sendNotification("🏆 Achievement Unlocked!", achievement.name); } } unlockedAchievementsReference.current = []; } if (newlyCompletedQuestsReference.current.length > 0) { setCompletedQuestToasts((previous) => { return [ ...previous, ...newlyCompletedQuestsReference.current ]; }); if (enableSoundsReference.current) { playSound("questCompleted"); } if (enableNotificationsReference.current) { sendNotification("📜 Quest Complete!", "A quest has been completed."); } newlyCompletedQuestsReference.current = []; } if (newlyFailedQuestsReference.current.length > 0) { setFailedQuestToasts((previous) => { return [ ...previous, ...newlyFailedQuestsReference.current ]; }); if (enableSoundsReference.current) { playSound("questFailed"); } if (enableNotificationsReference.current) { sendNotification("💀 Quest Failed!", "A quest has failed."); } newlyFailedQuestsReference.current = []; } // Auto-save every 30 seconds (skip if a force sync is in-flight to avoid signature collisions) if (Date.now() - lastSaveReference.current >= autoSaveIntervalMs) { lastSaveReference.current = Date.now(); if (stateReference.current !== null && !isSyncingReference.current) { void saveGame({ state: stateReference.current, ...signatureReference.current === null ? {} : { signature: signatureReference.current }, }). then((response) => { setLastSavedAt(response.savedAt); if (response.signature !== undefined) { signatureReference.current = response.signature; localStorage.setItem( "elysium_save_signature", response.signature, ); } }). catch((error_: unknown) => { // Silently clear a bad signature so the next auto-save can proceed if ( error_ instanceof Error && error_.message.includes("signature mismatch") ) { signatureReference.current = null; localStorage.removeItem("elysium_save_signature"); } /* * Network failures during background auto-save are expected on * flaky connections — the next tick will retry, so no telemetry needed */ }); } } // Auto-prestige: fire when unlocked, enabled, and threshold is met const autoState = stateReference.current; const autoPrestigeThreshold = autoPrestigeThresholdBase * Math.pow((autoState?.prestige.count ?? 0) + 1, 2.5) * (autoState?.transcendence?.echoPrestigeThresholdMultiplier ?? 1); const autoBaseRunestones = Math.min( Math.floor( Math.cbrt( (autoState?.player.totalGoldEarned ?? 0) / autoPrestigeThreshold, ), ) * 15, 200, ); const autoMaxRunestonesMet = autoState?.prestige.autoPrestigeMaxRunestonesOnly !== true || autoBaseRunestones >= 200; if ( !isAutoPrestigingReference.current && autoState?.prestige.purchasedUpgradeIds.includes("auto_prestige") === true && autoState.prestige.autoPrestigeEnabled === true && autoState.player.totalGoldEarned >= autoPrestigeThreshold && autoMaxRunestonesMet ) { isAutoPrestigingReference.current = true; void prestigeApi({}). then(async() => { setShowPrestigeToast(true); if (enableSoundsReference.current) { playSound("prestige"); } if (enableNotificationsReference.current) { sendNotification("⭐ Prestige!", "You have ascended!"); } await reloadSilentReference.current(); }). catch(() => { /* Silently ignore — eligibility is re-checked every tick */ }). finally(() => { isAutoPrestigingReference.current = false; }); } // Auto-boss: challenge the highest-zone available boss when not already fighting if (!isAutoBossingReference.current && autoState?.autoBoss === true) { const prestigeCount = autoState.prestige.count; const zoneOrder = new Map( autoState.zones.map((z, index) => { return [ z.id, index ]; }), ); const [ availableBoss ] = autoState.bosses. filter((b) => { return ( b.status === "available" && b.prestigeRequirement <= prestigeCount ); }). sort((bossA, bossB) => { return ( (zoneOrder.get(bossB.zoneId) ?? 0) - (zoneOrder.get(bossA.zoneId) ?? 0) ); }); if (availableBoss !== undefined) { const { id: bossId, name: bossName } = availableBoss; isAutoBossingReference.current = true; const syncBeforeBoss = stateReference.current !== null && !isSyncingReference.current ? saveGame({ state: stateReference.current, ...signatureReference.current === null ? {} : { signature: signatureReference.current }, }).then((response) => { if (response.signature !== undefined) { signatureReference.current = response.signature; localStorage.setItem( "elysium_save_signature", response.signature, ); } }) : Promise.resolve(); void syncBeforeBoss.then(async() => { return await challengeBossApi({ bossId }); }). then((result) => { setState((previous) => { if (previous === null) { return previous; } const afterBoss = applyBossResult(previous, bossId, result); // Defeat — turn off auto-boss so the player can reassess if (!result.won) { return { ...afterBoss, autoBoss: false }; } return afterBoss; }); /* * Boss fight modifies server state; clear stale signature so * the next pre-save or auto-save does not send a mismatched one. */ signatureReference.current = null; localStorage.removeItem("elysium_save_signature"); setAutoBossLastResult({ at: Date.now(), bossName: bossName, won: result.won, }); }). catch((error_: unknown) => { const message = error_ instanceof Error ? error_.message : String(error_); /* * "Boss is not currently available" is an expected race condition * when the client is ahead of the server save — silently skip and * let the next tick retry rather than halting automation. */ if (message === "Boss is not currently available") { return; } logError("auto_boss", error_); setAutoBossError(message); setState((previous) => { if (previous === null) { return previous; } return { ...previous, autoBoss: false }; }); }). finally(() => { isAutoBossingReference.current = false; }); } } rafReference.current = requestAnimationFrame(tick); }; rafReference.current = requestAnimationFrame(tick); // eslint-disable-next-line consistent-return -- useEffect cleanup requires mixed return pattern return (): void => { if (rafReference.current !== null) { cancelAnimationFrame(rafReference.current); } }; }, [ state !== null ]); const showSyncError = useCallback((message: string) => { setSyncError(message); if (syncErrorTimerReference.current !== null) { clearTimeout(syncErrorTimerReference.current); } syncErrorTimerReference.current = setTimeout(() => { setSyncError(null); }, 5000); }, []); const clearBadSignature = useCallback(() => { signatureReference.current = null; localStorage.removeItem("elysium_save_signature"); }, []); const forceSync = useCallback(async() => { if (stateReference.current === null || isSyncingReference.current) { return; } isSyncingReference.current = true; // Push auto-save timer back so it doesn't fire concurrently lastSaveReference.current = Date.now(); setIsSyncing(true); try { const response = await saveGame({ state: stateReference.current, ...signatureReference.current === null ? {} : { signature: signatureReference.current }, }); setSyncError(null); setLastSavedAt(response.savedAt); lastSaveReference.current = Date.now(); if (response.signature !== undefined) { // eslint-disable-next-line require-atomic-updates -- ref update is intentional after await signatureReference.current = response.signature; localStorage.setItem("elysium_save_signature", response.signature); } } catch (error_: unknown) { const message = error_ instanceof Error ? error_.message : "Save failed"; showSyncError(message); if (message.includes("signature mismatch")) { clearBadSignature(); } } finally { // eslint-disable-next-line require-atomic-updates -- ref update is intentional in finally block isSyncingReference.current = false; setIsSyncing(false); } }, [ showSyncError, clearBadSignature ]); const handleClick = useCallback(() => { setState((previous) => { if (previous === null) { return previous; } const clickPower = calculateClickPower(previous); const clickedGold = Math.min( previous.resources.gold + clickPower, RESOURCE_CAP, ); let updatedDailyChallenges = previous.dailyChallenges; let challengeCrystals = 0; if (updatedDailyChallenges !== undefined) { const result = updateChallengeProgress( updatedDailyChallenges, "clicks", 1, ); updatedDailyChallenges = result.updatedChallenges; challengeCrystals = result.crystalsAwarded; } return { ...previous, player: { ...previous.player, totalClicks: previous.player.totalClicks + 1, totalGoldEarned: previous.player.totalGoldEarned + clickPower, }, resources: { ...previous.resources, crystals: Math.min( previous.resources.crystals + challengeCrystals, RESOURCE_CAP, ), gold: clickedGold, }, ...updatedDailyChallenges === undefined ? {} : { dailyChallenges: updatedDailyChallenges }, }; }); }, []); const buyAdventurer = useCallback( (adventurerId: string, quantity: number) => { setState((previous) => { if (previous === null) { return previous; } const adventurer = previous.adventurers.find((a) => { return a.id === adventurerId; }); if (adventurer?.unlocked !== true) { return previous; } let { gold } = previous.resources; const { baseCost } = adventurer; let { count } = adventurer; let purchased = 0; for (let index = 0; index < quantity; index = index + 1) { const cost = baseCost * Math.pow(1.15, count); if (gold < cost) { break; } gold = gold - cost; count = count + 1; purchased = purchased + 1; } if (purchased === 0) { return previous; } return { ...previous, adventurers: previous.adventurers.map((a) => { return a.id === adventurerId ? { ...a, count } : a; }), resources: { ...previous.resources, gold }, }; }); }, [], ); const buyUpgrade = useCallback((upgradeId: string) => { setState((previous) => { if (previous === null) { return previous; } const upgrade = previous.upgrades.find((u) => { return u.id === upgradeId; }); if (upgrade === undefined || !upgrade.unlocked || upgrade.purchased) { return previous; } if (previous.resources.gold < upgrade.costGold) { return previous; } if (previous.resources.essence < upgrade.costEssence) { return previous; } if (previous.resources.crystals < upgrade.costCrystals) { return previous; } return { ...previous, resources: { ...previous.resources, crystals: previous.resources.crystals - upgrade.costCrystals, essence: previous.resources.essence - upgrade.costEssence, gold: previous.resources.gold - upgrade.costGold, }, upgrades: previous.upgrades.map((u) => { return u.id === upgradeId ? { ...u, purchased: true } : u; }), }; }); }, []); const startQuest = useCallback((questId: string) => { setState((previous) => { if (previous === null) { return previous; } const quest = previous.quests.find((q) => { return q.id === questId; }); if (quest === undefined || quest.status !== "available") { return previous; } return { ...previous, quests: previous.quests.map((q) => { return q.id === questId ? { ...q, startedAt: Date.now(), status: "active" as const } : q; }), }; }); }, []); const equipItem = useCallback((equipmentId: string) => { setState((previous) => { if (previous === null) { return previous; } const item = previous.equipment.find((equip) => { return equip.id === equipmentId; }); if (item?.owned !== true) { return previous; } return { ...previous, equipment: previous.equipment.map((equip) => { if (equip.id === equipmentId) { return { ...equip, equipped: true }; } // Unequip the previously-equipped item in the same slot if (equip.type === item.type && equip.equipped) { return { ...equip, equipped: false }; } return equip; }), }; }); }, []); const buyEquipment = useCallback((equipmentId: string) => { setState((previous) => { if (previous === null) { return previous; } const item = previous.equipment.find((equip) => { return equip.id === equipmentId; }); if (item === undefined || item.owned || item.cost === undefined) { return previous; } const { gold, essence, crystals } = item.cost; if (previous.resources.gold < gold) { return previous; } if (previous.resources.essence < essence) { return previous; } if (previous.resources.crystals < crystals) { return previous; } const slotAlreadyEquipped = previous.equipment.some((equip) => { return equip.type === item.type && equip.equipped; }); return { ...previous, equipment: previous.equipment.map((equip) => { if (equip.id === equipmentId) { return { ...equip, equipped: !slotAlreadyEquipped, owned: true }; } return equip; }), resources: { ...previous.resources, crystals: previous.resources.crystals - crystals, essence: previous.resources.essence - essence, gold: previous.resources.gold - gold, }, }; }); }, []); const buyPrestigeUpgrade = useCallback(async(upgradeId: string) => { try { const result = await buyPrestigeUpgradeApi({ upgradeId }); setState((previous) => { if (previous === null) { return previous; } return { ...previous, prestige: { ...previous.prestige, purchasedUpgradeIds: result.purchasedUpgradeIds, runestones: result.runestonesRemaining, runestonesClickMultiplier: result.runestonesClickMultiplier, runestonesCrystalMultiplier: result.runestonesCrystalMultiplier, runestonesEssenceMultiplier: result.runestonesEssenceMultiplier, runestonesIncomeMultiplier: result.runestonesIncomeMultiplier, }, }; }); } catch (error_: unknown) { logError("buy_prestige_upgrade", error_); // Silently ignore — server errors shouldn't crash the UI } }, []); const transcend = useCallback(async() => { try { const result = await transcendApi({}); setShowTranscendenceToast(true); if (enableSoundsReference.current) { playSound("transcendence"); } if (enableNotificationsReference.current) { sendNotification("🌌 Transcendence!", "You have transcended reality!"); } await reload(); return result; } catch (error_: unknown) { logError("transcend", error_); throw error_; } }, [ reload ]); const apotheosis = useCallback(async() => { try { const result = await achieveApotheosisApi({}); setShowApotheosisToast(true); if (enableSoundsReference.current) { playSound("apotheosis"); } if (enableNotificationsReference.current) { sendNotification("✨ Apotheosis!", "You have achieved godhood!"); } await reload(); return result; } catch (error_: unknown) { logError("apotheosis", error_); throw error_; } }, [ reload ]); const buyEchoUpgrade = useCallback(async(upgradeId: string) => { try { const result = await buyEchoUpgradeApi({ upgradeId }); setState((previous) => { if (previous?.transcendence === undefined) { return previous; } return { ...previous, transcendence: { ...previous.transcendence, echoCombatMultiplier: result.echoCombatMultiplier, echoIncomeMultiplier: result.echoIncomeMultiplier, echoMetaMultiplier: result.echoMetaMultiplier, echoPrestigeRunestoneMultiplier: result.echoPrestigeRunestoneMultiplier, echoPrestigeThresholdMultiplier: result.echoPrestigeThresholdMultiplier, echoes: result.echoesRemaining, purchasedUpgradeIds: result.purchasedUpgradeIds, }, }; }); } catch (error_: unknown) { logError("buy_echo_upgrade", error_); // Silently ignore — server errors shouldn't crash the UI } }, []); const startExploration = useCallback(async(areaId: string) => { const response = await startExplorationApi({ areaId }); setState((previous) => { if (previous?.exploration === undefined) { return previous; } return { ...previous, exploration: { ...previous.exploration, areas: previous.exploration.areas.map((a) => { return a.id === areaId ? { ...a, endsAt: response.endsAt, status: "in_progress" as const, } : a; }), }, }; }); }, []); const collectExploration = useCallback( async(areaId: string): Promise => { isSyncingReference.current = true; const result = await collectExplorationApi({ areaId }); /* * Collect mutates server state outside the normal save flow — clear the * stale HMAC signature and reset the timer so the next auto-save fires * after React has re-rendered with the new materials in stateReference. */ signatureReference.current = null; localStorage.removeItem("elysium_save_signature"); lastSaveReference.current = Date.now(); isSyncingReference.current = false; setState((previous) => { if (previous?.exploration === undefined) { return previous; } let materials = [ ...previous.exploration.materials ]; // Apply material drops from the random loot roll for (const drop of result.materialsFound) { const existing = materials.find((mat) => { return mat.materialId === drop.materialId; }); if (existing === undefined) { materials = [ ...materials, { materialId: drop.materialId, quantity: drop.quantity }, ]; } else { materials = materials.map((mat) => { return mat.materialId === drop.materialId ? { ...mat, quantity: mat.quantity + drop.quantity } : mat; }); } } // Apply material from event (if any) const materialGained = result.event?.materialGained; if (materialGained !== null && materialGained !== undefined) { const { materialId, quantity } = materialGained; const existing = materials.find((mat) => { return mat.materialId === materialId; }); if (existing === undefined) { materials = [ ...materials, { materialId, quantity } ]; } else { materials = materials.map((mat) => { return mat.materialId === materialId ? { ...mat, quantity: mat.quantity + quantity } : mat; }); } } return { ...previous, exploration: { ...previous.exploration, areas: previous.exploration.areas.map((a) => { return a.id === areaId ? { ...a, completedOnce: true, status: "available" as const } : a; }), materials: materials, }, player: { ...previous.player, totalGoldEarned: previous.player.totalGoldEarned + Math.max(0, result.event?.goldChange ?? 0), }, resources: { ...previous.resources, essence: previous.resources.essence + (result.event?.essenceChange ?? 0), gold: Math.max( 0, previous.resources.gold + (result.event?.goldChange ?? 0), ), }, }; }); return result; }, [], ); const craftRecipe = useCallback(async(recipeId: string) => { const recipe = RECIPES.find((r) => { return r.id === recipeId; }); if (recipe === undefined) { return; } try { const result = await craftRecipeApi({ recipeId }); setState((previous) => { if (previous?.exploration === undefined) { return previous; } return { ...previous, exploration: { ...previous.exploration, craftedClickMultiplier: result.craftedClickMultiplier, craftedCombatMultiplier: result.craftedCombatMultiplier, craftedEssenceMultiplier: result.craftedEssenceMultiplier, craftedGoldMultiplier: result.craftedGoldMultiplier, craftedRecipeIds: [ ...previous.exploration.craftedRecipeIds, recipeId, ], materials: result.materials, }, }; }); } catch (error_: unknown) { logError("craft_recipe", error_); throw error_; } }, []); const toggleAutoPrestige = useCallback(() => { setState((previous) => { if (previous === null) { return previous; } return { ...previous, prestige: { ...previous.prestige, autoPrestigeEnabled: previous.prestige.autoPrestigeEnabled !== true, }, }; }); }, []); const toggleAutoPrestigeMaxRunestones = useCallback(() => { setState((previous) => { if (previous === null) { return previous; } return { ...previous, prestige: { ...previous.prestige, autoPrestigeMaxRunestonesOnly: previous.prestige.autoPrestigeMaxRunestonesOnly !== true, }, }; }); }, []); const toggleAutoQuest = useCallback(() => { setState((previous) => { if (previous === null) { return previous; } return { ...previous, autoQuest: previous.autoQuest !== true }; }); }, []); const toggleAutoBoss = useCallback(() => { setAutoBossError(null); setAutoBossLastResult(null); setState((previous) => { if (previous === null) { return previous; } return { ...previous, autoBoss: previous.autoBoss !== true }; }); }, []); const toggleAutoAdventurer = useCallback(() => { setState((previous) => { if (previous === null) { return previous; } return { ...previous, autoAdventurer: previous.autoAdventurer !== true, }; }); }, []); const setActiveCompanion = useCallback((companionId: string | null) => { setState((previous) => { if (previous === null) { return previous; } const unlockedIds = previous.companions?.unlockedCompanionIds ?? []; const validatedId = companionId !== null && unlockedIds.includes(companionId) ? companionId : null; return { ...previous, companions: { activeCompanionId: validatedId, unlockedCompanionIds: unlockedIds, }, }; }); }, []); const challengeBoss = useCallback(async(bossId: string) => { if (stateReference.current === null) { return; } const boss = stateReference.current.bosses.find((b) => { return b.id === bossId; }); if (boss === undefined) { return; } setBossError(null); /* * Flush any pending state (e.g. newly equipped items) to the server before * the fight so the server-side calculation uses the player's live stats. */ await forceSync(); try { const result = await challengeBossApi({ bossId }); setState((previous) => { if (previous === null) { return previous; } return applyBossResult(previous, bossId, result); }); setBattleResult({ bossName: boss.name, result: result }); } catch (error_: unknown) { const bossErrorMessage = error_ instanceof Error ? error_.message : "Failed to challenge boss"; /* * "Boss is not currently available" is an expected server rejection * (race condition between UI state and server state) — suppress telemetry */ if (bossErrorMessage !== "Boss is not currently available") { logError("challenge_boss", error_); } setBossError( bossErrorMessage, ); } }, [ forceSync ]); const dismissOfflineGold = useCallback(() => { setOfflineGold(0); setOfflineEssence(0); }, []); const dismissBattle = useCallback(() => { setBattleResult(null); }, []); const dismissCompletedQuest = useCallback((id: string) => { setCompletedQuestToasts((previous) => { return previous.filter((q) => { return q.id !== id; }); }); }, []); const dismissFailedQuest = useCallback((id: string) => { setFailedQuestToasts((previous) => { return previous.filter((q) => { return q.id !== id; }); }); }, []); const triggerPrestigeToast = useCallback(() => { setShowPrestigeToast(true); }, []); const dismissPrestigeToast = useCallback(() => { setShowPrestigeToast(false); }, []); const dismissTranscendenceToast = useCallback(() => { setShowTranscendenceToast(false); }, []); const dismissApotheosisToast = useCallback(() => { setShowApotheosisToast(false); }, []); const dismissAchievement = useCallback((id: string) => { setUnlockedAchievements((previous) => { return previous.filter((a) => { return a.id !== id; }); }); }, []); const dismissCodexEntry = useCallback((id: string) => { setUnlockedCodexEntryIds((previous) => { return previous.filter((entry) => { return entry !== id; }); }); }, []); const flushBossLoreToasts = useCallback(() => { const pending = pendingBossCodexIdsReference.current; if (pending.length > 0) { pendingBossCodexIdsReference.current = []; setUnlockedCodexEntryIds((previous) => { return [ ...previous, ...pending ]; }); } }, []); const dismissStoryChapter = useCallback((id: string) => { setUnlockedStoryChapterIds((previous) => { return previous.filter((chapter) => { return chapter !== id; }); }); }, []); const completeChapter = useCallback((chapterId: string, choiceId: string) => { setState((previous) => { if (previous === null) { return previous; } const already = previous.story?.completedChapters ?? []; if ( already.some((chapter) => { return chapter.chapterId === chapterId; }) ) { return previous; } return { ...previous, story: { completedChapters: [ ...already, { chapterId, choiceId } ], unlockedChapterIds: previous.story?.unlockedChapterIds ?? [], }, }; }); }, []); const resetProgress = useCallback(async() => { setIsLoading(true); setError(null); try { const data = await resetProgressApi(); setState(data.state); setLastSavedAt(data.state.player.lastSavedAt); setSchemaOutdated(false); setOfflineGold(0); setOfflineEssence(0); setLoginBonus(null); if (data.signature !== undefined) { signatureReference.current = data.signature; localStorage.setItem("elysium_save_signature", data.signature); } } catch (error_: unknown) { setError( error_ instanceof Error ? error_.message : "Failed to reset progress", ); } finally { setIsLoading(false); } }, []); const forceUnlocks = useCallback(async() => { try { const data = await forceUnlocksApi(); setState(data.state); if (data.signature !== undefined) { signatureReference.current = data.signature; localStorage.setItem("elysium_save_signature", data.signature); } return { adventurersUnlocked: data.adventurersUnlocked, bossesUnlocked: data.bossesUnlocked, equipmentUnlocked: data.equipmentUnlocked, explorationUnlocked: data.explorationUnlocked, questsUnlocked: data.questsUnlocked, storyUnlocked: data.storyUnlocked, upgradesUnlocked: data.upgradesUnlocked, zonesUnlocked: data.zonesUnlocked, }; } catch (error_: unknown) { setError( error_ instanceof Error ? error_.message : "Failed to force unlocks", ); return { adventurersUnlocked: 0, bossesUnlocked: 0, equipmentUnlocked: 0, explorationUnlocked: 0, questsUnlocked: 0, storyUnlocked: 0, upgradesUnlocked: 0, zonesUnlocked: 0, }; } }, []); const syncNewContent = useCallback(async() => { try { const data = await syncNewContentApi(); setState(data.state); if (data.signature !== undefined) { signatureReference.current = data.signature; localStorage.setItem("elysium_save_signature", data.signature); } return { achievementsAdded: data.achievementsAdded, achievementsPatched: data.achievementsPatched, adventurerStatsPatched: data.adventurerStatsPatched, adventurersAdded: data.adventurersAdded, bossRewardsPatched: data.bossRewardsPatched, bossesAdded: data.bossesAdded, bossesPatched: data.bossesPatched, craftingRecipesReapplied: data.craftingRecipesReapplied, equipmentAdded: data.equipmentAdded, equipmentPatched: data.equipmentPatched, explorationAreasAdded: data.explorationAreasAdded, questRewardsPatched: data.questRewardsPatched, questsAdded: data.questsAdded, questsPatched: data.questsPatched, upgradesAdded: data.upgradesAdded, upgradesPatched: data.upgradesPatched, zonesAdded: data.zonesAdded, zonesPatched: data.zonesPatched, }; } catch (error_: unknown) { setError( error_ instanceof Error ? error_.message : "Failed to sync new content", ); return { achievementsAdded: 0, achievementsPatched: 0, adventurerStatsPatched: 0, adventurersAdded: 0, bossRewardsPatched: 0, bossesAdded: 0, bossesPatched: 0, craftingRecipesReapplied: 0, equipmentAdded: 0, equipmentPatched: 0, explorationAreasAdded: 0, questRewardsPatched: 0, questsAdded: 0, questsPatched: 0, upgradesAdded: 0, upgradesPatched: 0, zonesAdded: 0, zonesPatched: 0, }; } }, []); const debugHardReset = useCallback(async() => { setIsLoading(true); setError(null); try { const data = await debugHardResetApi(); setState(data.state); setLastSavedAt(data.state.player.lastSavedAt); setSchemaOutdated(false); setOfflineGold(0); setOfflineEssence(0); setLoginBonus(null); if (data.signature !== undefined) { signatureReference.current = data.signature; localStorage.setItem("elysium_save_signature", data.signature); } } catch (error_: unknown) { setError( error_ instanceof Error ? error_.message : "Failed to reset progress", ); } finally { setIsLoading(false); } }, []); const dismissLoginBonus = useCallback(() => { setLoginBonus(null); }, []); const formatNumber = useCallback( (value: number) => { return formatNumberUtil(value, numberFormat); }, [ numberFormat ], ); const formatInteger = useCallback( (value: number) => { return formatIntegerUtil(value); }, [], ); const contextValue = useMemo(() => { return { apotheosis, autoBossError, autoBossLastResult, battleResult, bossError, buyAdventurer, buyEchoUpgrade, buyEquipment, buyPrestigeUpgrade, buyUpgrade, challengeBoss, collectExploration, completeChapter, completedQuestToasts, craftRecipe, currentSchemaVersion, debugHardReset, dismissAchievement, dismissApotheosisToast, dismissBattle, dismissCodexEntry, dismissCompletedQuest, dismissFailedQuest, dismissLoginBonus, dismissOfflineGold, dismissPrestigeToast, dismissStoryChapter, dismissTranscendenceToast, enableNotifications, enableSounds, equipItem, error, failedQuestToasts, flushBossLoreToasts, forceSync, forceUnlocks, formatInteger, formatNumber, handleClick, inGuild, isLoading, isSyncing, lastSavedAt, loginBonus, loginStreak, numberFormat, offlineEssence, offlineGold, reload, reloadSilent, resetProgress, saveSchemaVersion, schemaOutdated, setActiveCompanion, setEnableNotifications, setEnableSounds, setNumberFormat, showApotheosisToast, showPrestigeToast, showTranscendenceToast, startExploration, startQuest, state, syncError, syncNewContent, toggleAutoAdventurer, toggleAutoBoss, toggleAutoPrestige, toggleAutoPrestigeMaxRunestones, toggleAutoQuest, transcend, triggerPrestigeToast, unlockedAchievements, unlockedCodexEntryIds, unlockedStoryChapterIds, }; }, [ apotheosis, autoBossError, autoBossLastResult, battleResult, bossError, completedQuestToasts, failedQuestToasts, formatInteger, formatNumber, buyAdventurer, buyEchoUpgrade, buyEquipment, buyPrestigeUpgrade, buyUpgrade, challengeBoss, collectExploration, completeChapter, craftRecipe, currentSchemaVersion, debugHardReset, dismissAchievement, dismissApotheosisToast, dismissBattle, dismissCodexEntry, dismissCompletedQuest, dismissFailedQuest, dismissLoginBonus, dismissOfflineGold, dismissPrestigeToast, dismissStoryChapter, dismissTranscendenceToast, enableNotifications, enableSounds, equipItem, error, flushBossLoreToasts, forceSync, inGuild, forceUnlocks, handleClick, isLoading, isSyncing, lastSavedAt, loginBonus, loginStreak, numberFormat, offlineEssence, offlineGold, reload, resetProgress, saveSchemaVersion, schemaOutdated, setActiveCompanion, setEnableNotifications, setEnableSounds, setNumberFormat, showApotheosisToast, showPrestigeToast, showTranscendenceToast, startExploration, startQuest, state, syncError, syncNewContent, toggleAutoAdventurer, toggleAutoBoss, toggleAutoPrestige, toggleAutoPrestigeMaxRunestones, toggleAutoQuest, transcend, triggerPrestigeToast, unlockedAchievements, unlockedCodexEntryIds, unlockedStoryChapterIds, ]); return ( {children} ); }; /** * Returns the game context value. * @returns The game context value. * @throws {Error} When used outside a GameProvider. */ export const useGame = (): GameContextValue => { const context = useContext(GameContext); if (context === null) { throw new Error("useGame must be used within a GameProvider"); } return context; };