import type { Achievement, BossChallengeResponse, GameState, NumberFormat } from "@elysium/types"; import { createContext, useCallback, useContext, useEffect, useRef, useState, } from "react"; import { challengeBoss as challengeBossApi, loadGame, saveGame } from "../api/client.js"; import { RESOURCE_CAP, applyTick, calculateClickPower } from "../engine/tick.js"; import { formatNumber as formatNumberUtil } from "../utils/format.js"; export interface BattleResult { bossName: string; result: BossChallengeResponse; } interface GameContextValue { state: GameState | null; isLoading: boolean; error: string | null; /** Click the crystal to earn gold */ handleClick: () => void; /** Buy an adventurer */ buyAdventurer: (adventurerId: string) => void; /** Buy an upgrade */ buyUpgrade: (upgradeId: string) => void; /** Purchase a buyable equipment item */ buyEquipment: (equipmentId: string) => void; /** Start a quest */ startQuest: (questId: string) => void; /** Challenge a boss — runs full server-side simulation */ challengeBoss: (bossId: string) => Promise; /** Equip an owned equipment item (auto-unequips the same slot) */ equipItem: (equipmentId: string) => void; /** Reload state from the server */ reload: () => Promise; /** Unix timestamp of the last successful cloud save (null until first save response) */ lastSavedAt: number | null; /** True whilst a forced save is in-flight */ isSyncing: boolean; /** Immediately save to the server and reset the auto-save timer */ forceSync: () => Promise; /** Error message from the last failed cloud save (null when no error) */ syncError: string | null; /** Offline gold earned on login */ offlineGold: number; /** Dismiss the offline gold 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) */ newAchievements: Achievement[]; /** Remove an achievement from the toast queue */ dismissAchievement: (id: string) => 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; /** Format a number using the player's chosen notation style */ formatNumber: (value: number) => string; } const GameContext = createContext(null); const AUTO_SAVE_INTERVAL_MS = 30_000; export const GameProvider = ({ children }: { children: React.ReactNode }): React.JSX.Element => { const [state, setState] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [offlineGold, setOfflineGold] = useState(0); const [battleResult, setBattleResult] = useState(null); const [newAchievements, setNewAchievements] = useState([]); const [lastSavedAt, setLastSavedAt] = useState(null); const [isSyncing, setIsSyncing] = useState(false); const [syncError, setSyncError] = useState(null); const syncErrorTimerRef = useRef | null>(null); const [numberFormat, setNumberFormat] = useState("suffix"); const stateRef = useRef(null); const lastSaveRef = useRef(Date.now()); const isSyncingRef = useRef(false); const rafRef = useRef(null); const newlyUnlockedRef = useRef([]); const signatureRef = useRef(localStorage.getItem("elysium_save_signature")); stateRef.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) { signatureRef.current = data.signature; localStorage.setItem("elysium_save_signature", data.signature); } if (data.offlineGold > 0) { setOfflineGold(data.offlineGold); } // Fetch number format preference from profile (fire-and-forget, non-blocking) void fetch(`/api/profile/${data.state.player.discordId}`) .then(async (res) => { if (!res.ok) return; const profile = await res.json() as { profileSettings?: { numberFormat?: NumberFormat } }; const fmt = profile.profileSettings?.numberFormat; if (fmt === "suffix" || fmt === "scientific" || fmt === "engineering") { setNumberFormat(fmt); } }) .catch(() => { /* fall back to default "suffix" */ }); } catch (err) { setError(err instanceof Error ? err.message : "Failed to load game"); } finally { setIsLoading(false); } }, []); useEffect(() => { void reload(); }, [reload]); // Game loop via requestAnimationFrame useEffect(() => { if (!state) return; let lastTime = performance.now(); const tick = (now: number): void => { const deltaSeconds = (now - lastTime) / 1000; lastTime = now; setState((prev) => { if (!prev) return prev; const next = applyTick(prev, deltaSeconds); // Detect newly unlocked achievements newlyUnlockedRef.current = next.achievements.filter((a, i) => { const wasLocked = (prev.achievements ?? [])[i]?.unlockedAt === null; return wasLocked && a.unlockedAt !== null; }); return next; }); if (newlyUnlockedRef.current.length > 0) { setNewAchievements((prev) => [...prev, ...newlyUnlockedRef.current]); newlyUnlockedRef.current = []; } // Auto-save every 30 seconds if (Date.now() - lastSaveRef.current >= AUTO_SAVE_INTERVAL_MS) { lastSaveRef.current = Date.now(); if (stateRef.current) { void saveGame({ state: stateRef.current, signature: signatureRef.current ?? undefined, }).then((response) => { setLastSavedAt(response.savedAt); if (response.signature) { signatureRef.current = response.signature; localStorage.setItem("elysium_save_signature", response.signature); } }).catch((err: unknown) => { // Silently clear a bad signature so the next auto-save can proceed if (err instanceof Error && err.message.includes("signature mismatch")) { signatureRef.current = null; localStorage.removeItem("elysium_save_signature"); } }); } } rafRef.current = requestAnimationFrame(tick); }; rafRef.current = requestAnimationFrame(tick); return () => { if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); } }; // eslint-disable-next-line react-hooks/exhaustive-deps -- only run when state becomes available }, [state !== null]); const showSyncError = useCallback((message: string) => { setSyncError(message); if (syncErrorTimerRef.current) clearTimeout(syncErrorTimerRef.current); syncErrorTimerRef.current = setTimeout(() => { setSyncError(null); }, 5000); }, []); const clearBadSignature = useCallback(() => { signatureRef.current = null; localStorage.removeItem("elysium_save_signature"); }, []); const forceSync = useCallback(async () => { if (!stateRef.current || isSyncingRef.current) return; isSyncingRef.current = true; setIsSyncing(true); try { const response = await saveGame({ state: stateRef.current, signature: signatureRef.current ?? undefined, }); setSyncError(null); setLastSavedAt(response.savedAt); lastSaveRef.current = Date.now(); if (response.signature) { signatureRef.current = response.signature; localStorage.setItem("elysium_save_signature", response.signature); } } catch (err) { const message = err instanceof Error ? err.message : "Save failed"; showSyncError(message); if (message.includes("signature mismatch")) clearBadSignature(); } finally { isSyncingRef.current = false; setIsSyncing(false); } }, [showSyncError, clearBadSignature]); const handleClick = useCallback(() => { setState((prev) => { if (!prev) return prev; const clickPower = calculateClickPower(prev); const newGold = Math.min(prev.resources.gold + clickPower, RESOURCE_CAP); return { ...prev, resources: { ...prev.resources, gold: newGold }, player: { ...prev.player, totalGoldEarned: prev.player.totalGoldEarned + clickPower, totalClicks: prev.player.totalClicks + 1, }, }; }); }, []); const buyAdventurer = useCallback((adventurerId: string) => { setState((prev) => { if (!prev) return prev; const adventurer = prev.adventurers.find((a) => a.id === adventurerId); if (!adventurer || !adventurer.unlocked) return prev; const cost = 10 * Math.pow(1.15, adventurer.count); if (prev.resources.gold < cost) return prev; return { ...prev, resources: { ...prev.resources, gold: prev.resources.gold - cost }, adventurers: prev.adventurers.map((a) => a.id === adventurerId ? { ...a, count: a.count + 1 } : a, ), }; }); }, []); const buyUpgrade = useCallback((upgradeId: string) => { setState((prev) => { if (!prev) return prev; const upgrade = prev.upgrades.find((u) => u.id === upgradeId); if (!upgrade || !upgrade.unlocked || upgrade.purchased) return prev; if (prev.resources.gold < upgrade.costGold) return prev; if (prev.resources.essence < upgrade.costEssence) return prev; if (prev.resources.crystals < (upgrade.costCrystals ?? 0)) return prev; return { ...prev, resources: { ...prev.resources, gold: prev.resources.gold - upgrade.costGold, essence: prev.resources.essence - upgrade.costEssence, crystals: prev.resources.crystals - (upgrade.costCrystals ?? 0), }, upgrades: prev.upgrades.map((u) => u.id === upgradeId ? { ...u, purchased: true } : u, ), }; }); }, []); const startQuest = useCallback((questId: string) => { setState((prev) => { if (!prev) return prev; const quest = prev.quests.find((q) => q.id === questId); if (!quest || quest.status !== "available") return prev; return { ...prev, quests: prev.quests.map((q) => q.id === questId ? { ...q, status: "active" as const, startedAt: Date.now() } : q, ), }; }); }, []); const equipItem = useCallback((equipmentId: string) => { setState((prev) => { if (!prev) return prev; const item = (prev.equipment ?? []).find((e) => e.id === equipmentId); if (!item || !item.owned) return prev; return { ...prev, equipment: (prev.equipment ?? []).map((e) => { if (e.id === equipmentId) return { ...e, equipped: true }; // Unequip the previously-equipped item in the same slot if (e.type === item.type && e.equipped) return { ...e, equipped: false }; return e; }), }; }); }, []); const buyEquipment = useCallback((equipmentId: string) => { setState((prev) => { if (!prev) return prev; const item = (prev.equipment ?? []).find((e) => e.id === equipmentId); if (!item || item.owned || !item.cost) return prev; const { gold, essence, crystals } = item.cost; if (prev.resources.gold < gold) return prev; if (prev.resources.essence < essence) return prev; if (prev.resources.crystals < crystals) return prev; const slotAlreadyEquipped = (prev.equipment ?? []).some( (e) => e.type === item.type && e.equipped, ); return { ...prev, resources: { ...prev.resources, gold: prev.resources.gold - gold, essence: prev.resources.essence - essence, crystals: prev.resources.crystals - crystals, }, equipment: (prev.equipment ?? []).map((e) => { if (e.id === equipmentId) return { ...e, owned: true, equipped: !slotAlreadyEquipped }; return e; }), }; }); }, []); const challengeBoss = useCallback(async (bossId: string) => { if (!stateRef.current) return; const boss = stateRef.current.bosses.find((b) => b.id === bossId); if (!boss) return; try { const result = await challengeBossApi({ bossId }); // Update local state to match server result setState((prev) => { if (!prev) return prev; if (result.won) { const defeatedBoss = prev.bosses.find((b) => b.id === bossId); const zoneBosses = prev.bosses.filter((b) => b.zoneId === defeatedBoss?.zoneId); const zoneIdx = zoneBosses.findIndex((b) => b.id === bossId); const nextZoneBossId = zoneBosses[zoneIdx + 1]?.id; // Find newly unlocked zones and their first bosses // A zone unlocks when BOTH the gate boss is defeated AND the gate quest is completed const newlyUnlockedZones = (prev.zones ?? []).filter((z) => { if (z.status !== "locked" || z.unlockBossId !== bossId) return false; const questOk = z.unlockQuestId == null || prev.quests.some((q) => q.id === z.unlockQuestId && q.status === "completed"); return questOk; }); const newZoneFirstBossIds = newlyUnlockedZones.map((z) => { const firstBoss = prev.bosses.find((b) => b.zoneId === z.id); return firstBoss?.id; }).filter(Boolean); return { ...prev, bosses: prev.bosses.map((b) => { if (b.id === bossId) return { ...b, status: "defeated" as const, currentHp: 0 }; if (b.id === nextZoneBossId && b.prestigeRequirement <= prev.prestige.count) { return { ...b, status: "available" as const }; } if (newZoneFirstBossIds.includes(b.id) && b.prestigeRequirement <= prev.prestige.count) { return { ...b, status: "available" as const }; } return b; }), zones: (prev.zones ?? []).map((z) => { if (z.status !== "locked" || z.unlockBossId !== bossId) return z; const questOk = z.unlockQuestId == null || prev.quests.some((q) => q.id === z.unlockQuestId && q.status === "completed"); return questOk ? { ...z, status: "unlocked" as const } : z; }), resources: result.rewards ? { ...prev.resources, gold: prev.resources.gold + result.rewards.gold, essence: prev.resources.essence + result.rewards.essence, crystals: prev.resources.crystals + result.rewards.crystals, } : prev.resources, player: result.rewards ? { ...prev.player, totalGoldEarned: prev.player.totalGoldEarned + result.rewards.gold, } : prev.player, upgrades: result.rewards ? prev.upgrades.map((u) => result.rewards!.upgradeIds.includes(u.id) ? { ...u, unlocked: true } : u, ) : prev.upgrades, equipment: result.rewards ? (prev.equipment ?? []).map((e) => { if (!result.rewards!.equipmentIds.includes(e.id)) return e; const slotEmpty = !(prev.equipment ?? []).some( (other) => other.type === e.type && other.equipped, ); return { ...e, owned: true, equipped: slotEmpty || e.equipped }; }) : prev.equipment ?? [], }; } // Loss: reset boss HP and apply casualties return { ...prev, bosses: prev.bosses.map((b) => b.id === bossId ? { ...b, status: "available" as const, currentHp: b.maxHp } : b, ), adventurers: prev.adventurers.map((a) => { const casualty = result.casualties?.find( (c) => c.adventurerId === a.id, ); if (!casualty) return a; return { ...a, count: Math.max(0, a.count - casualty.killed) }; }), }; }); setBattleResult({ bossName: boss.name, result }); } catch { // Silently ignore — server errors shouldn't crash the UI } }, []); const dismissOfflineGold = useCallback(() => { setOfflineGold(0); }, []); const dismissBattle = useCallback(() => { setBattleResult(null); }, []); const dismissAchievement = useCallback((id: string) => { setNewAchievements((prev) => prev.filter((a) => a.id !== id)); }, []); const boundFormatNumber = useCallback( (value: number) => formatNumberUtil(value, numberFormat), [numberFormat], ); return ( {children} ); }; export const useGame = (): GameContextValue => { const context = useContext(GameContext); if (!context) { throw new Error("useGame must be used within a GameProvider"); } return context; };