feat: initial elysium idle game prototype

Sets up the full monorepo with pnpm workspaces. Includes shared types
package, Hono API with Discord OAuth/JWT auth, Prisma v6 + MongoDB
Atlas, and React + Vite frontend with game loop, five tabs, and
Discord-linked save/load.
This commit is contained in:
2026-03-06 11:26:19 -08:00
committed by Naomi Carrigan
parent c69e155de3
commit a3daed1683
64 changed files with 9011 additions and 0 deletions
+257
View File
@@ -0,0 +1,257 @@
import type { GameState } from "@elysium/types";
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { dealBossDamage, loadGame, saveGame } from "../api/client.js";
import { applyTick, calculateClickPower } from "../engine/tick.js";
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;
/** Start a quest */
startQuest: (questId: string) => void;
/** Attack the active boss */
attackBoss: (bossId: string) => void;
/** Reload state from the server */
reload: () => Promise<void>;
/** Offline gold earned on login */
offlineGold: number;
/** Dismiss the offline gold notification */
dismissOfflineGold: () => void;
}
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 stateRef = useRef<GameState | null>(null);
const lastSaveRef = useRef<number>(Date.now());
const rafRef = useRef<number | null>(null);
stateRef.current = state;
const reload = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await loadGame();
setState(data.state);
if (data.offlineGold > 0) {
setOfflineGold(data.offlineGold);
}
} 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;
return applyTick(prev, deltaSeconds);
});
// 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 });
}
}
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 handleClick = useCallback(() => {
setState((prev) => {
if (!prev) return prev;
const clickPower = calculateClickPower(prev);
return {
...prev,
resources: { ...prev.resources, gold: prev.resources.gold + clickPower },
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;
return {
...prev,
resources: {
...prev.resources,
gold: prev.resources.gold - upgrade.costGold,
essence: prev.resources.essence - upgrade.costEssence,
},
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 attackBoss = useCallback(async (bossId: string) => {
if (!stateRef.current) return;
const clickPower = calculateClickPower(stateRef.current);
try {
const result = await dealBossDamage({ bossId, damage: clickPower });
setState((prev) => {
if (!prev) return prev;
return {
...prev,
bosses: prev.bosses.map((b) =>
b.id === bossId
? {
...b,
status: result.defeated ? ("defeated" as const) : ("in_progress" as const),
currentHp: result.currentHp,
}
: b,
),
...(result.defeated && result.rewards
? {
resources: {
...prev.resources,
gold: prev.resources.gold + result.rewards.gold,
essence: prev.resources.essence + result.rewards.essence,
crystals: prev.resources.crystals + result.rewards.crystals,
},
player: {
...prev.player,
totalGoldEarned:
prev.player.totalGoldEarned + result.rewards.gold,
},
upgrades: prev.upgrades.map((u) =>
result.rewards!.upgradeIds.includes(u.id)
? { ...u, unlocked: true }
: u,
),
}
: {}),
};
});
} catch {
// Rate limited or other error — silently ignore
}
}, []);
const dismissOfflineGold = useCallback(() => {
setOfflineGold(0);
}, []);
return (
<GameContext.Provider
value={{
state,
isLoading,
error,
handleClick,
buyAdventurer,
buyUpgrade,
startQuest,
attackBoss,
reload,
offlineGold,
dismissOfflineGold,
}}
>
{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;
};