Files
elysium/apps/web/src/context/gameContext.tsx
T
hikari 5a065998b6
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint, Build & Test (push) Successful in 1m5s
fix: delay boss notifications until reveal and animate hp bar colours
- Move bossVictory sound and notification from gameContext into BattleModal,
  fired at the 5.2s reveal timeout so the animation plays before the spoiler
- Replace CSS width transition with a setInterval tick (50ms steps over 5s)
  so bossHpPercent and partyHpPercent update incrementally during the animation
- Both bars now use a shared getHpColour helper: green >50%, yellow 25-50%,
  red <25%, causing colour to shift naturally as the bar visually drains
2026-03-08 19:07:04 -07:00

2056 lines
59 KiB
TypeScript

/**
* @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,
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,
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,
} from "../engine/tick.js";
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
import { formatNumber as formatNumberUtil } from "../utils/format.js";
import { sendNotification } from "../utils/notification.js";
import { playSound } from "../utils/sound.js";
const autoSaveIntervalMs = 30_000;
const autoPrestigeThresholdBase = 1_000_000;
const autoPrestigeThresholdScale = 5;
/**
* 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 challengeUpdate
= previous.dailyChallenges === undefined
? { crystalsAwarded: 0, updatedChallenges: undefined }
: updateChallengeProgress(
previous.dailyChallenges,
"bossesDefeated",
1,
);
const rewardIds: Array<string> = result.rewards?.upgradeIds ?? [];
const rewardEquipmentIds: Array<string>
= 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;
}),
};
}
// 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;
/**
* 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<void>;
/**
* Equip an owned equipment item (auto-unequips the same slot).
*/
equipItem: (equipmentId: string)=> void;
/**
* Reload state from the server.
*/
reload: ()=> Promise<void>;
/**
* 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<void>;
/**
* 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<Achievement>;
/**
* Remove an achievement from the toast queue.
*/
dismissAchievement: (id: string)=> void;
/**
* Queue of newly completed quests (for toast notifications).
*/
completedQuestToasts: Array<Quest>;
/**
* Remove a quest from the completed toast queue.
*/
dismissCompletedQuest: (id: string)=> void;
/**
* Queue of newly failed quests (for toast notifications).
*/
failedQuestToasts: Array<Quest>;
/**
* 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;
/**
* Buy a prestige upgrade from the runestone shop.
*/
buyPrestigeUpgrade: (upgradeId: string)=> Promise<void>;
/**
* Toggle the auto-prestige setting on/off (requires auto_prestige upgrade).
*/
toggleAutoPrestige: ()=> void;
/**
* Toggle the auto-quest setting on/off.
*/
toggleAutoQuest: ()=> void;
/**
* Toggle the auto-boss setting on/off.
*/
toggleAutoBoss: ()=> void;
/**
* Queue of newly unlocked codex entry IDs (for toast notifications).
*/
unlockedCodexEntryIds: Array<string>;
/**
* Remove a codex entry ID from the notification queue.
*/
dismissCodexEntry: (id: string)=> void;
/**
* Perform a transcendence — nuclear reset, earning echoes.
*/
transcend: ()=> Promise<TranscendenceResponse>;
/**
* Buy an echo upgrade from the transcendence shop.
*/
buyEchoUpgrade: (upgradeId: string)=> Promise<void>;
/**
* Achieve Apotheosis — the ultimate nuclear reset, bragging rights only.
*/
apotheosis: ()=> Promise<ApotheosisResponse>;
/**
* Start an exploration in the given area.
*/
startExploration: (areaId: string)=> Promise<void>;
/**
* Collect results of a completed exploration.
*/
collectExploration: (areaId: string)=> Promise<ExploreCollectResponse>;
/**
* Craft a recipe using collected materials.
*/
craftRecipe: (recipeId: string)=> Promise<void>;
/**
* 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<string>;
/**
* 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<void>;
}
export interface BattleResult {
bossName: string;
result: BossChallengeResponse;
}
const GameContext = createContext<GameContextValue | null>(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<GameState | null>(null);
const [ isLoading, setIsLoading ] = useState(true);
const [ error, setError ] = useState<string | null>(null);
const [ offlineGold, setOfflineGold ] = useState(0);
const [ offlineEssence, setOfflineEssence ] = useState(0);
const [ loginBonus, setLoginBonus ] = useState<LoginBonusResult | null>(null);
const [ loginStreak, setLoginStreak ] = useState(1);
const [ battleResult, setBattleResult ] = useState<BattleResult | null>(null);
const [ unlockedAchievements, setUnlockedAchievements ] = useState<
Array<Achievement>
>([]);
const [ completedQuestToasts, setCompletedQuestToasts ] = useState<
Array<Quest>
>([]);
const [ failedQuestToasts, setFailedQuestToasts ] = useState<Array<Quest>>(
[],
);
const [ showPrestigeToast, setShowPrestigeToast ] = useState(false);
const [ showTranscendenceToast, setShowTranscendenceToast ] = useState(false);
const [ showApotheosisToast, setShowApotheosisToast ] = useState(false);
const [ lastSavedAt, setLastSavedAt ] = useState<number | null>(null);
const [ isSyncing, setIsSyncing ] = useState(false);
const [ syncError, setSyncError ] = useState<string | null>(null);
const syncErrorTimerReference = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const [ numberFormat, setNumberFormat ] = useState<NumberFormat>("suffix");
const [ enableSounds, setEnableSounds ] = useState(false);
const [ enableNotifications, setEnableNotifications ] = useState(false);
const enableSoundsReference = useRef(false);
const enableNotificationsReference = useRef(false);
const stateReference = useRef<GameState | null>(null);
const lastSaveReference = useRef<number>(Date.now());
const isSyncingReference = useRef(false);
const rafReference = useRef<number | null>(null);
const unlockedAchievementsReference = useRef<Array<Achievement>>([]);
const newlyCompletedQuestsReference = useRef<Array<Quest>>([]);
const newlyFailedQuestsReference = useRef<Array<Quest>>([]);
const signatureReference = useRef<string | null>(
localStorage.getItem("elysium_save_signature"),
);
const isAutoPrestigingReference = useRef(false);
const isAutoBossingReference = useRef(false);
const reloadReference = useRef<()=> Promise<void>>(async() => {
/* No-op placeholder */
});
const [ schemaOutdated, setSchemaOutdated ] = useState(false);
const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0);
const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0);
const [ unlockedCodexEntryIds, setUnlockedCodexEntryIds ] = useState<
Array<string>
>([]);
const codexProcessedReference = useRef<Set<string>>(new Set());
const [ unlockedStoryChapterIds, setUnlockedStoryChapterIds ] = useState<
Array<string>
>([]);
const storyProcessedReference = useRef<Set<string>>(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);
// 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;
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<string> = [];
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) {
setUnlockedCodexEntryIds((previous) => {
return [ ...previous, ...addedIds ];
});
}
}
}, [ 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<string> = [];
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 ]);
// 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) {
// eslint-disable-next-line unicorn/no-array-reduce -- Need the total!
const partyCombatPower = next.adventurers.reduce((total, a) => {
const power = total + a.combatPower;
return power * a.count;
}, 0);
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;
}),
};
}
}
}
// 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");
}
});
}
}
// Auto-prestige: fire when unlocked, enabled, and threshold is met
const autoState = stateReference.current;
if (
!isAutoPrestigingReference.current
&& autoState?.prestige.purchasedUpgradeIds.includes("auto_prestige")
=== true
&& autoState.prestige.autoPrestigeEnabled === true
&& autoState.player.totalGoldEarned
>= autoPrestigeThresholdBase
* Math.pow(autoPrestigeThresholdScale, autoState.prestige.count)
) {
isAutoPrestigingReference.current = true;
void prestigeApi({}).
then(async() => {
setShowPrestigeToast(true);
if (enableSoundsReference.current) {
playSound("prestige");
}
if (enableNotificationsReference.current) {
sendNotification("⭐ Prestige!", "You have ascended!");
}
await reloadReference.current();
}).
catch(() => {
/* Silently ignore — will retry next 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;
void challengeBossApi({ bossId }).
then((result) => {
setState((previous) => {
if (previous === null) {
return previous;
}
return applyBossResult(previous, bossId, result);
});
setBattleResult({ bossName, result });
}).
catch(() => {
/* Silently ignore — will retry next tick */
}).
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 {
// Silently ignore — server errors shouldn't crash the UI
}
}, []);
const transcend = useCallback(async() => {
const result = await transcendApi({});
setShowTranscendenceToast(true);
if (enableSoundsReference.current) {
playSound("transcendence");
}
if (enableNotificationsReference.current) {
sendNotification("🌌 Transcendence!", "You have transcended reality!");
}
await reload();
return result;
}, [ reload ]);
const apotheosis = useCallback(async() => {
const result = await achieveApotheosisApi({});
setShowApotheosisToast(true);
if (enableSoundsReference.current) {
playSound("apotheosis");
}
if (enableNotificationsReference.current) {
sendNotification("✨ Apotheosis!", "You have achieved godhood!");
}
await reload();
return result;
}, [ 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 {
// Silently ignore server errors
}
}, []);
const startExploration = useCallback(async(areaId: string) => {
const response = await startExplorationApi({ areaId });
const areaData = EXPLORATION_AREAS.find((a) => {
return a.id === areaId;
});
if (areaData === undefined) {
return;
}
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
const startedAt = response.endsAt - areaData.durationSeconds * 1000;
setState((previous) => {
if (previous?.exploration === undefined) {
return previous;
}
return {
...previous,
exploration: {
...previous.exploration,
areas: previous.exploration.areas.map((a) => {
return a.id === areaId
? { ...a, startedAt: startedAt, status: "in_progress" as const }
: a;
}),
},
};
});
}, []);
const collectExploration = useCallback(
async(areaId: string): Promise<ExploreCollectResponse> => {
const result = await collectExplorationApi({ areaId });
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;
}
const result = await craftRecipeApi({ recipeId });
setState((previous) => {
if (previous?.exploration === undefined) {
return previous;
}
let materials = [ ...previous.exploration.materials ];
for (const request of recipe.requiredMaterials) {
materials = materials.map((mat) => {
return mat.materialId === request.materialId
? { ...mat, quantity: mat.quantity - request.quantity }
: mat;
});
}
return {
...previous,
exploration: {
...previous.exploration,
craftedClickMultiplier: result.craftedClickMultiplier,
craftedCombatMultiplier: result.craftedCombatMultiplier,
craftedEssenceMultiplier: result.craftedEssenceMultiplier,
craftedGoldMultiplier: result.craftedGoldMultiplier,
craftedRecipeIds: [
...previous.exploration.craftedRecipeIds,
recipeId,
],
materials: materials,
},
};
});
}, []);
const toggleAutoPrestige = useCallback(() => {
setState((previous) => {
if (previous === null) {
return previous;
}
return {
...previous,
prestige: {
...previous.prestige,
autoPrestigeEnabled: previous.prestige.autoPrestigeEnabled !== true,
},
};
});
}, []);
const toggleAutoQuest = useCallback(() => {
setState((previous) => {
if (previous === null) {
return previous;
}
return { ...previous, autoQuest: previous.autoQuest !== true };
});
}, []);
const toggleAutoBoss = useCallback(() => {
setState((previous) => {
if (previous === null) {
return previous;
}
return { ...previous, autoBoss: previous.autoBoss !== 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;
}
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 {
// Silently ignore — server errors shouldn't crash the UI
}
}, []);
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 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 dismissLoginBonus = useCallback(() => {
setLoginBonus(null);
}, []);
const formatNumber = useCallback(
(value: number) => {
return formatNumberUtil(value, numberFormat);
},
[ numberFormat ],
);
const contextValue = useMemo<GameContextValue>(() => {
return {
apotheosis,
battleResult,
buyAdventurer,
buyEchoUpgrade,
buyEquipment,
buyPrestigeUpgrade,
buyUpgrade,
challengeBoss,
collectExploration,
completeChapter,
completedQuestToasts,
craftRecipe,
currentSchemaVersion,
dismissAchievement,
dismissApotheosisToast,
dismissBattle,
dismissCodexEntry,
dismissCompletedQuest,
dismissFailedQuest,
dismissLoginBonus,
dismissOfflineGold,
dismissPrestigeToast,
dismissStoryChapter,
dismissTranscendenceToast,
enableNotifications,
enableSounds,
equipItem,
error,
failedQuestToasts,
forceSync,
formatNumber,
handleClick,
isLoading,
isSyncing,
lastSavedAt,
loginBonus,
loginStreak,
numberFormat,
offlineEssence,
offlineGold,
reload,
resetProgress,
saveSchemaVersion,
schemaOutdated,
setActiveCompanion,
setEnableNotifications,
setEnableSounds,
setNumberFormat,
showApotheosisToast,
showPrestigeToast,
showTranscendenceToast,
startExploration,
startQuest,
state,
syncError,
toggleAutoBoss,
toggleAutoPrestige,
toggleAutoQuest,
transcend,
triggerPrestigeToast,
unlockedAchievements,
unlockedCodexEntryIds,
unlockedStoryChapterIds,
};
}, [
apotheosis,
battleResult,
completedQuestToasts,
failedQuestToasts,
formatNumber,
buyAdventurer,
buyEchoUpgrade,
buyEquipment,
buyPrestigeUpgrade,
buyUpgrade,
challengeBoss,
collectExploration,
completeChapter,
craftRecipe,
currentSchemaVersion,
dismissAchievement,
dismissApotheosisToast,
dismissBattle,
dismissCodexEntry,
dismissCompletedQuest,
dismissFailedQuest,
dismissLoginBonus,
dismissOfflineGold,
dismissPrestigeToast,
dismissStoryChapter,
dismissTranscendenceToast,
enableNotifications,
enableSounds,
equipItem,
error,
forceSync,
handleClick,
isLoading,
isSyncing,
lastSavedAt,
loginBonus,
loginStreak,
numberFormat,
offlineEssence,
offlineGold,
reload,
resetProgress,
saveSchemaVersion,
schemaOutdated,
setActiveCompanion,
setEnableNotifications,
setEnableSounds,
setNumberFormat,
showApotheosisToast,
showPrestigeToast,
showTranscendenceToast,
startExploration,
startQuest,
state,
syncError,
toggleAutoBoss,
toggleAutoPrestige,
toggleAutoQuest,
transcend,
triggerPrestigeToast,
unlockedAchievements,
unlockedCodexEntryIds,
unlockedStoryChapterIds,
]);
return (
<GameContext.Provider value={contextValue}>{children}</GameContext.Provider>
);
};
/**
* 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;
};