generated from nhcarrigan/template
4e32709e07
Skip auto-save when isSyncingRef is true and reset the auto-save timer at the start of forceSync to prevent concurrent saves from sharing the same HMAC signature and causing alternating save failures.
523 lines
18 KiB
TypeScript
523 lines
18 KiB
TypeScript
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<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;
|
|
/** 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<GameContextValue | null>(null);
|
|
|
|
const AUTO_SAVE_INTERVAL_MS = 30_000;
|
|
|
|
export const GameProvider = ({ children }: { children: React.ReactNode }): React.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 [battleResult, setBattleResult] = useState<BattleResult | null>(null);
|
|
const [newAchievements, setNewAchievements] = useState<Achievement[]>([]);
|
|
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
|
|
const [isSyncing, setIsSyncing] = useState(false);
|
|
const [syncError, setSyncError] = useState<string | null>(null);
|
|
const syncErrorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const [numberFormat, setNumberFormat] = useState<NumberFormat>("suffix");
|
|
const stateRef = useRef<GameState | null>(null);
|
|
const lastSaveRef = useRef<number>(Date.now());
|
|
const isSyncingRef = useRef(false);
|
|
const rafRef = useRef<number | null>(null);
|
|
const newlyUnlockedRef = useRef<Achievement[]>([]);
|
|
const signatureRef = useRef<string | null>(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 (skip if a force sync is in-flight to avoid signature collisions)
|
|
if (Date.now() - lastSaveRef.current >= AUTO_SAVE_INTERVAL_MS) {
|
|
lastSaveRef.current = Date.now();
|
|
if (stateRef.current && !isSyncingRef.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;
|
|
lastSaveRef.current = Date.now(); // push auto-save timer back so it doesn't fire concurrently
|
|
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 (
|
|
<GameContext.Provider
|
|
value={{
|
|
state,
|
|
isLoading,
|
|
error,
|
|
handleClick,
|
|
buyAdventurer,
|
|
buyUpgrade,
|
|
buyEquipment,
|
|
startQuest,
|
|
challengeBoss,
|
|
equipItem,
|
|
reload,
|
|
lastSavedAt,
|
|
isSyncing,
|
|
forceSync,
|
|
syncError,
|
|
offlineGold,
|
|
dismissOfflineGold,
|
|
battleResult,
|
|
dismissBattle,
|
|
newAchievements,
|
|
dismissAchievement,
|
|
numberFormat,
|
|
setNumberFormat,
|
|
formatNumber: boundFormatNumber,
|
|
}}
|
|
>
|
|
{children}
|
|
</GameContext.Provider>
|
|
);
|
|
};
|
|
|
|
export const useGame = (): GameContextValue => {
|
|
const context = useContext(GameContext);
|
|
if (!context) {
|
|
throw new Error("useGame must be used within a GameProvider");
|
|
}
|
|
return context;
|
|
};
|