generated from nhcarrigan/template
5a065998b6
- 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
2056 lines
59 KiB
TypeScript
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;
|
|
};
|