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
+3
View File
@@ -0,0 +1,3 @@
import { NaomisConfig } from "@nhcarrigan/eslint-config";
export default [...NaomisConfig];
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Elysium — Idle RPG</title>
<meta name="description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@elysium/web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json && vite build",
"dev": "vite",
"lint": "eslint --max-warnings 0 src",
"preview": "vite preview",
"test": "vitest run --coverage"
},
"dependencies": {
"@elysium/types": "workspace:*",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"@nhcarrigan/eslint-config": "5.2.0",
"@nhcarrigan/typescript-config": "4.0.0",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"@vitejs/plugin-react": "4.3.4",
"@vitest/coverage-v8": "3.0.8",
"eslint": "9.22.0",
"jsdom": "26.0.0",
"typescript": "5.8.2",
"vite": "6.2.1",
"vitest": "3.0.8"
}
}
+39
View File
@@ -0,0 +1,39 @@
import { useState } from "react";
import { GameProvider } from "./context/GameContext.js";
import { GameLayout } from "./components/game/GameLayout.js";
import { LoginPage } from "./components/game/LoginPage.js";
const handleAuthCallback = (): boolean => {
if (window.location.pathname !== "/auth/callback") {
return false;
}
const params = new URLSearchParams(window.location.search);
const token = params.get("token");
if (token) {
localStorage.setItem("elysium_token", token);
}
window.history.replaceState(null, "", "/");
return Boolean(token);
};
const isAuthenticated = (): boolean => {
const fromCallback = handleAuthCallback();
return fromCallback || Boolean(localStorage.getItem("elysium_token"));
};
export const App = (): React.JSX.Element => {
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
if (!loggedIn) {
return <LoginPage onLogin={() => { setLoggedIn(true); }} />;
}
return (
<GameProvider>
<GameLayout />
</GameProvider>
);
};
+81
View File
@@ -0,0 +1,81 @@
import type {
AuthResponse,
BossDamageRequest,
BossDamageResponse,
LoadResponse,
PrestigeRequest,
PrestigeResponse,
PublicProfileResponse,
SaveRequest,
SaveResponse,
} from "@elysium/types";
const BASE_URL = "/api";
const getToken = (): string | null => localStorage.getItem("elysium_token");
const headers = (): Record<string, string> => {
const token = getToken();
return {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
};
const request = async <T>(
path: string,
options?: RequestInit,
): Promise<T> => {
const response = await fetch(`${BASE_URL}${path}`, {
...options,
headers: { ...headers(), ...options?.headers },
});
if (!response.ok) {
const error = (await response.json().catch(() => ({ error: "Unknown error" }))) as {
error: string;
};
throw new Error(error.error);
}
return response.json() as Promise<T>;
};
export const getAuthUrl = async (): Promise<string> => {
const data = await request<{ url: string }>("/auth/url");
return data.url;
};
export const handleAuthCallback = async (code: string): Promise<AuthResponse> => {
const data = await request<AuthResponse>(`/auth/callback?code=${code}`);
localStorage.setItem("elysium_token", data.token);
return data;
};
export const loadGame = async (): Promise<LoadResponse> =>
request<LoadResponse>("/game/load");
export const saveGame = async (body: SaveRequest): Promise<SaveResponse> =>
request<SaveResponse>("/game/save", {
method: "POST",
body: JSON.stringify(body),
});
export const dealBossDamage = async (
body: BossDamageRequest,
): Promise<BossDamageResponse> =>
request<BossDamageResponse>("/boss/damage", {
method: "POST",
body: JSON.stringify(body),
});
export const prestige = async (body: PrestigeRequest): Promise<PrestigeResponse> =>
request<PrestigeResponse>("/prestige", {
method: "POST",
body: JSON.stringify(body),
});
export const getPublicProfile = async (
discordId: string,
): Promise<PublicProfileResponse> =>
request<PublicProfileResponse>(`/profile/${discordId}`);
@@ -0,0 +1,70 @@
import type { Adventurer } from "@elysium/types";
import { useGame } from "../../context/GameContext.js";
const CLASS_ICONS: Record<string, string> = {
warrior: "🗡️",
mage: "🔮",
rogue: "🗝️",
cleric: "✝️",
ranger: "🏹",
paladin: "🛡️",
};
const adventurerCost = (adventurer: Adventurer): number =>
Math.ceil(10 * Math.pow(1.15, adventurer.count));
interface AdventurerCardProps {
adventurer: Adventurer;
currentGold: number;
}
const AdventurerCard = ({ adventurer, currentGold }: AdventurerCardProps): React.JSX.Element => {
const { buyAdventurer } = useGame();
const cost = adventurerCost(adventurer);
const canAfford = currentGold >= cost;
return (
<div className={`adventurer-card ${!adventurer.unlocked ? "locked" : ""}`}>
<div className="adventurer-icon">{CLASS_ICONS[adventurer.class] ?? "⚔️"}</div>
<div className="adventurer-info">
<h3>{adventurer.name}</h3>
<p>{adventurer.goldPerSecond.toFixed(2)} gold/s each</p>
{adventurer.essencePerSecond > 0 && (
<p>{adventurer.essencePerSecond.toFixed(3)} essence/s each</p>
)}
</div>
<div className="adventurer-count">×{adventurer.count}</div>
<button
className="buy-button"
disabled={!canAfford || !adventurer.unlocked}
onClick={() => { buyAdventurer(adventurer.id); }}
type="button"
>
{adventurer.unlocked ? `🪙 ${cost}` : "🔒 Locked"}
</button>
</div>
);
};
export const AdventurerPanel = (): React.JSX.Element => {
const { state } = useGame();
if (!state) return <section className="panel"><p>Loading...</p></section>;
return (
<section className="panel adventurer-panel">
<h2>Adventurers</h2>
<div className="adventurer-list">
{state.adventurers
.filter((a) => a.unlocked)
.map((adventurer) => (
<AdventurerCard
key={adventurer.id}
adventurer={adventurer}
currentGold={state.resources.gold}
/>
))}
</div>
</section>
);
};
@@ -0,0 +1,80 @@
import type { Boss } from "@elysium/types";
import { useGame } from "../../context/GameContext.js";
interface BossCardProps {
boss: Boss;
prestigeCount: number;
}
const BossCard = ({ boss, prestigeCount }: BossCardProps): React.JSX.Element => {
const { attackBoss } = useGame();
const hpPercent = (boss.currentHp / boss.maxHp) * 100;
const isLocked = boss.prestigeRequirement > prestigeCount;
return (
<div className={`boss-card boss-${boss.status}`}>
<div className="boss-info">
<h3>{boss.name}</h3>
<p>{boss.description}</p>
{isLocked && boss.status === "locked" && (
<p className="prestige-lock">🔒 Requires Prestige {boss.prestigeRequirement}</p>
)}
</div>
{boss.status !== "locked" && boss.status !== "defeated" && (
<div className="boss-hp">
<div className="hp-bar">
<div
className="hp-fill"
style={{ width: `${hpPercent.toFixed(1)}%` }}
/>
</div>
<span className="hp-text">
{boss.currentHp.toLocaleString()} / {boss.maxHp.toLocaleString()} HP
</span>
</div>
)}
<div className="boss-rewards">
<span>🪙 {boss.goldReward.toLocaleString()}</span>
{boss.essenceReward > 0 && <span> {boss.essenceReward.toLocaleString()}</span>}
{boss.crystalReward > 0 && <span>💎 {boss.crystalReward.toLocaleString()}</span>}
</div>
{boss.status === "available" || boss.status === "in_progress" ? (
<button
className="attack-button"
onClick={() => { void attackBoss(boss.id); }}
type="button"
>
Attack
</button>
) : null}
{boss.status === "defeated" && (
<span className="boss-badge defeated"> Defeated</span>
)}
</div>
);
};
export const BossPanel = (): React.JSX.Element => {
const { state } = useGame();
if (!state) return <section className="panel"><p>Loading...</p></section>;
return (
<section className="panel boss-panel">
<h2>Boss Encounters</h2>
<div className="boss-list">
{state.bosses.map((boss) => (
<BossCard
key={boss.id}
boss={boss}
prestigeCount={state.prestige.count}
/>
))}
</div>
</section>
);
};
@@ -0,0 +1,25 @@
import { useGame } from "../../context/GameContext.js";
import { calculateClickPower } from "../../engine/tick.js";
export const ClickArea = (): React.JSX.Element => {
const { state, handleClick } = useGame();
if (!state) return <div className="click-area-placeholder" />;
const clickPower = calculateClickPower(state);
return (
<section className="click-area">
<h2>Guild Hall</h2>
<button
className="click-button"
onClick={handleClick}
type="button"
aria-label={`Click to earn ${clickPower.toFixed(1)} gold`}
>
</button>
<p className="click-power">+{clickPower.toFixed(1)} gold per click</p>
</section>
);
};
@@ -0,0 +1,82 @@
import { useState } from "react";
import { useGame } from "../../context/GameContext.js";
import { ResourceBar } from "../ui/ResourceBar.js";
import { AdventurerPanel } from "./AdventurerPanel.js";
import { BossPanel } from "./BossPanel.js";
import { ClickArea } from "./ClickArea.js";
import { OfflineModal } from "./OfflineModal.js";
import { PrestigePanel } from "./PrestigePanel.js";
import { QuestPanel } from "./QuestPanel.js";
import { UpgradePanel } from "./UpgradePanel.js";
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "prestige";
const TABS: { id: Tab; label: string }[] = [
{ id: "adventurers", label: "⚔️ Adventurers" },
{ id: "upgrades", label: "🔧 Upgrades" },
{ id: "quests", label: "📜 Quests" },
{ id: "bosses", label: "👹 Bosses" },
{ id: "prestige", label: "⭐ Prestige" },
];
export const GameLayout = (): React.JSX.Element => {
const { state, isLoading, error } = useGame();
const [activeTab, setActiveTab] = useState<Tab>("adventurers");
if (isLoading) {
return (
<div className="loading-screen">
<p>Loading your adventure...</p>
</div>
);
}
if (error) {
return (
<div className="error-screen">
<p>Error: {error}</p>
</div>
);
}
if (!state) return <div className="loading-screen"><p>Loading...</p></div>;
return (
<div className="game-layout">
<ResourceBar
resources={state.resources}
prestigeCount={state.prestige.count}
/>
<OfflineModal />
<div className="game-main">
<aside className="game-sidebar">
<ClickArea />
</aside>
<main className="game-content">
<nav className="tab-bar">
{TABS.map((tab) => (
<button
key={tab.id}
className={`tab-button ${activeTab === tab.id ? "active" : ""}`}
onClick={() => { setActiveTab(tab.id); }}
type="button"
>
{tab.label}
</button>
))}
</nav>
<div className="tab-content">
{activeTab === "adventurers" && <AdventurerPanel />}
{activeTab === "upgrades" && <UpgradePanel />}
{activeTab === "quests" && <QuestPanel />}
{activeTab === "bosses" && <BossPanel />}
{activeTab === "prestige" && <PrestigePanel />}
</div>
</main>
</div>
</div>
);
};
@@ -0,0 +1,87 @@
import { useEffect, useState } from "react";
import { getAuthUrl, handleAuthCallback } from "../../api/client.js";
interface LoginPageProps {
onLogin: () => void;
}
export const LoginPage = ({ onLogin }: LoginPageProps): React.JSX.Element => {
const [authUrl, setAuthUrl] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Handle OAuth callback
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
if (code) {
setIsLoading(true);
handleAuthCallback(code)
.then(() => {
window.history.replaceState({}, "", "/");
onLogin();
})
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : "Authentication failed");
setIsLoading(false);
});
return;
}
// Fetch the Discord OAuth URL
getAuthUrl()
.then((url) => {
setAuthUrl(url);
setIsLoading(false);
})
.catch(() => {
setError("Failed to load authentication URL");
setIsLoading(false);
});
}, [onLogin]);
if (isLoading) {
return (
<div className="login-page">
<div className="login-card">
<p>Loading...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="login-page">
<div className="login-card">
<p className="error">{error}</p>
<button
type="button"
onClick={() => { window.location.reload(); }}
>
Try Again
</button>
</div>
</div>
);
}
return (
<div className="login-page">
<div className="login-card">
<h1> Elysium</h1>
<p>An idle fantasy RPG. Hire adventurers, defeat bosses, and ascend to glory.</p>
<a
className="discord-login-button"
href={authUrl ?? "#"}
>
Login with Discord
</a>
<p className="login-note">
Your progress is saved to your Discord account and shareable with others!
</p>
</div>
</div>
);
};
@@ -0,0 +1,27 @@
import { useGame } from "../../context/GameContext.js";
export const OfflineModal = (): React.JSX.Element | null => {
const { offlineGold, dismissOfflineGold } = useGame();
if (offlineGold <= 0) return null;
return (
<div className="modal-overlay">
<div className="modal">
<h2>Welcome back!</h2>
<p>
Your adventurers kept working whilst you were away and earned{" "}
<strong>🪙 {offlineGold.toFixed(0)} gold</strong>!
</p>
<p className="modal-note">Offline progress is calculated up to 8 hours.</p>
<button
className="modal-close-button"
onClick={dismissOfflineGold}
type="button"
>
Collect!
</button>
</div>
</div>
);
};
@@ -0,0 +1,91 @@
import { useState } from "react";
import { prestige } from "../../api/client.js";
import { useGame } from "../../context/GameContext.js";
const PRESTIGE_THRESHOLD = 1_000_000;
export const PrestigePanel = (): React.JSX.Element => {
const { state, reload } = useGame();
const [characterName, setCharacterName] = useState("");
const [isPending, setIsPending] = useState(false);
const [result, setResult] = useState<{ runestones: number; count: number } | null>(null);
const [error, setError] = useState<string | null>(null);
if (!state) return <section className="panel"><p>Loading...</p></section>;
const isEligible = state.player.totalGoldEarned >= PRESTIGE_THRESHOLD;
const handlePrestige = async (): Promise<void> => {
if (!characterName.trim()) return;
setIsPending(true);
setError(null);
try {
const data = await prestige({ characterName: characterName.trim() });
setResult({ runestones: data.runestones, count: data.newPrestigeCount });
await reload();
} catch (err) {
setError(err instanceof Error ? err.message : "Prestige failed");
} finally {
setIsPending(false);
}
};
return (
<section className="panel prestige-panel">
<h2> Prestige</h2>
<p>
Prestige resets your progress but grants <strong>Runestones</strong> permanent
currency used for powerful upgrades. Each prestige also increases your global
production multiplier by 10%.
</p>
<div className="prestige-status">
<p>
Total gold earned:{" "}
<strong>{state.player.totalGoldEarned.toLocaleString()}</strong>
</p>
<p>
Required: <strong>{PRESTIGE_THRESHOLD.toLocaleString()}</strong>
</p>
<p>Current prestige count: <strong>{state.prestige.count}</strong></p>
<p>
Production multiplier:{" "}
<strong>×{state.prestige.productionMultiplier.toFixed(1)}</strong>
</p>
</div>
{isEligible ? (
<div className="prestige-form">
<p>You are ready to prestige! Choose your new character name:</p>
<input
type="text"
value={characterName}
onChange={(e) => { setCharacterName(e.target.value); }}
placeholder="Character name..."
maxLength={32}
disabled={isPending}
/>
<button
className="prestige-button"
onClick={() => { void handlePrestige(); }}
disabled={isPending || !characterName.trim()}
type="button"
>
{isPending ? "Ascending..." : "✨ Ascend"}
</button>
{error && <p className="error">{error}</p>}
{result && (
<p className="success">
Ascended to Prestige {result.count}! Earned {result.runestones} Runestones.
</p>
)}
</div>
) : (
<p className="prestige-locked">
Earn {(PRESTIGE_THRESHOLD - state.player.totalGoldEarned).toLocaleString()} more
gold to unlock prestige.
</p>
)}
</section>
);
};
@@ -0,0 +1,78 @@
import type { Quest } from "@elysium/types";
import { useGame } from "../../context/GameContext.js";
const formatDuration = (seconds: number): string => {
if (seconds >= 3600) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
if (seconds >= 60) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
return `${seconds}s`;
};
const questTimeRemaining = (quest: Quest): number => {
if (quest.status !== "active" || quest.startedAt == null) return 0;
const elapsed = (Date.now() - quest.startedAt) / 1000;
return Math.max(0, quest.durationSeconds - elapsed);
};
interface QuestCardProps {
quest: Quest;
}
const QuestCard = ({ quest }: QuestCardProps): React.JSX.Element => {
const { startQuest } = useGame();
return (
<div className={`quest-card quest-${quest.status}`}>
<div className="quest-info">
<h3>{quest.name}</h3>
<p>{quest.description}</p>
<div className="quest-rewards">
{quest.rewards.map((reward, index) => (
// eslint-disable-next-line react/no-array-index-key -- rewards have no unique id
<span key={index} className="reward-tag">
{reward.type === "gold" && `🪙 ${reward.amount?.toLocaleString()}`}
{reward.type === "essence" && `${reward.amount?.toLocaleString()}`}
{reward.type === "crystals" && `💎 ${reward.amount?.toLocaleString()}`}
{reward.type === "upgrade" && "🔓 Upgrade"}
{reward.type === "adventurer" && "👥 New Adventurer"}
</span>
))}
</div>
</div>
<div className="quest-action">
{quest.status === "locked" && <span className="quest-badge locked">🔒 Locked</span>}
{quest.status === "available" && (
<button
className="start-quest-button"
onClick={() => { startQuest(quest.id); }}
type="button"
>
Send Party ({formatDuration(quest.durationSeconds)})
</button>
)}
{quest.status === "active" && (
<span className="quest-badge active">
{formatDuration(Math.ceil(questTimeRemaining(quest)))} remaining
</span>
)}
{quest.status === "completed" && <span className="quest-badge completed"> Complete</span>}
</div>
</div>
);
};
export const QuestPanel = (): React.JSX.Element => {
const { state } = useGame();
if (!state) return <section className="panel"><p>Loading...</p></section>;
return (
<section className="panel quest-panel">
<h2>Quests</h2>
<div className="quest-list">
{state.quests.map((quest) => (
<QuestCard key={quest.id} quest={quest} />
))}
</div>
</section>
);
};
@@ -0,0 +1,73 @@
import type { Upgrade } from "@elysium/types";
import { useGame } from "../../context/GameContext.js";
interface UpgradeCardProps {
upgrade: Upgrade;
currentGold: number;
currentEssence: number;
}
const UpgradeCard = ({ upgrade, currentGold, currentEssence }: UpgradeCardProps): React.JSX.Element => {
const { buyUpgrade } = useGame();
const canAfford =
currentGold >= upgrade.costGold && currentEssence >= upgrade.costEssence;
if (upgrade.purchased) {
return (
<div className="upgrade-card purchased">
<span className="upgrade-name"> {upgrade.name}</span>
<span className="upgrade-desc">{upgrade.description}</span>
</div>
);
}
return (
<div className="upgrade-card">
<div className="upgrade-info">
<h3>{upgrade.name}</h3>
<p>{upgrade.description}</p>
<p className="upgrade-multiplier">×{upgrade.multiplier} multiplier</p>
</div>
<div className="upgrade-cost">
{upgrade.costGold > 0 && <span>🪙 {upgrade.costGold.toLocaleString()}</span>}
{upgrade.costEssence > 0 && <span> {upgrade.costEssence.toLocaleString()}</span>}
</div>
<button
className="buy-button"
disabled={!canAfford}
onClick={() => { buyUpgrade(upgrade.id); }}
type="button"
>
Buy
</button>
</div>
);
};
export const UpgradePanel = (): React.JSX.Element => {
const { state } = useGame();
if (!state) return <section className="panel"><p>Loading...</p></section>;
const availableUpgrades = state.upgrades.filter((u) => u.unlocked);
return (
<section className="panel upgrade-panel">
<h2>Upgrades</h2>
{availableUpgrades.length === 0 ? (
<p className="empty-state">No upgrades available yet keep adventuring!</p>
) : (
<div className="upgrade-list">
{availableUpgrades.map((upgrade) => (
<UpgradeCard
key={upgrade.id}
upgrade={upgrade}
currentGold={state.resources.gold}
currentEssence={state.resources.essence}
/>
))}
</div>
)}
</section>
);
};
@@ -0,0 +1,49 @@
import type { Resource } from "@elysium/types";
interface ResourceBarProps {
resources: Resource;
prestigeCount: number;
}
const formatNumber = (value: number): string => {
if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(2)}B`;
}
if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(2)}M`;
}
if (value >= 1_000) {
return `${(value / 1_000).toFixed(2)}K`;
}
return value.toFixed(1);
};
export const ResourceBar = ({ resources, prestigeCount }: ResourceBarProps): React.JSX.Element => (
<header className="resource-bar">
<div className="resource">
<span className="resource-icon">🪙</span>
<span className="resource-value">{formatNumber(resources.gold)}</span>
<span className="resource-label">Gold</span>
</div>
<div className="resource">
<span className="resource-icon"></span>
<span className="resource-value">{formatNumber(resources.essence)}</span>
<span className="resource-label">Essence</span>
</div>
<div className="resource">
<span className="resource-icon">💎</span>
<span className="resource-value">{formatNumber(resources.crystals)}</span>
<span className="resource-label">Crystals</span>
</div>
<div className="resource">
<span className="resource-icon">🔮</span>
<span className="resource-value">{formatNumber(resources.runestones)}</span>
<span className="resource-label">Runestones</span>
</div>
{prestigeCount > 0 && (
<div className="prestige-badge">
Prestige {prestigeCount}
</div>
)}
</header>
);
+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;
};
+96
View File
@@ -0,0 +1,96 @@
import type { GameState } from "@elysium/types";
/**
* Pure function — applies one game tick to the state.
* deltaSeconds: time elapsed since last tick.
* Returns a new GameState (does not mutate the original).
*/
export const applyTick = (state: GameState, deltaSeconds: number): GameState => {
let goldGained = 0;
let essenceGained = 0;
for (const adventurer of state.adventurers) {
if (!adventurer.unlocked || adventurer.count === 0) {
continue;
}
const upgradeMultiplier = state.upgrades
.filter(
(u) =>
u.purchased &&
(u.target === "global" ||
(u.target === "adventurer" && u.adventurerId === adventurer.id)),
)
.reduce((mult, upgrade) => mult * upgrade.multiplier, 1);
const prestige = state.prestige.productionMultiplier;
goldGained +=
adventurer.goldPerSecond * adventurer.count * upgradeMultiplier * prestige * deltaSeconds;
essenceGained +=
adventurer.essencePerSecond *
adventurer.count *
upgradeMultiplier *
prestige *
deltaSeconds;
}
// Complete active quests
const now = Date.now();
let questGold = 0;
let questEssence = 0;
let questCrystals = 0;
const updatedQuests = state.quests.map((quest) => {
if (
quest.status !== "active" ||
quest.startedAt == null ||
now < quest.startedAt + quest.durationSeconds * 1000
) {
return quest;
}
const completed = { ...quest, status: "completed" as const };
for (const reward of quest.rewards) {
if (reward.type === "gold" && reward.amount != null) {
questGold += reward.amount;
} else if (reward.type === "essence" && reward.amount != null) {
questEssence += reward.amount;
} else if (reward.type === "crystals" && reward.amount != null) {
questCrystals += reward.amount;
}
}
return completed;
});
const newGold = state.resources.gold + goldGained + questGold;
const newEssence = state.resources.essence + essenceGained + questEssence;
return {
...state,
resources: {
...state.resources,
gold: newGold,
essence: newEssence,
crystals: state.resources.crystals + questCrystals,
},
player: {
...state.player,
totalGoldEarned: state.player.totalGoldEarned + goldGained + questGold,
},
quests: updatedQuests,
lastTickAt: now,
};
};
/**
* Calculates the effective click power, including upgrades.
*/
export const calculateClickPower = (state: GameState): number => {
const clickMultiplier = state.upgrades
.filter((u) => u.purchased && u.target === "click")
.reduce((mult, upgrade) => mult * upgrade.multiplier, 1);
return state.baseClickPower * clickMultiplier * state.prestige.productionMultiplier;
};
+16
View File
@@ -0,0 +1,16 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App.js";
import "./styles.css";
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Root element not found");
}
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>,
);
+631
View File
@@ -0,0 +1,631 @@
/* ===================== RESET & BASE ===================== */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--colour-bg: #0d0d1a;
--colour-surface: #1a1a2e;
--colour-surface-2: #16213e;
--colour-border: #2a2a4a;
--colour-accent: #7c3aed;
--colour-accent-light: #a855f7;
--colour-gold: #f59e0b;
--colour-essence: #8b5cf6;
--colour-crystal: #06b6d4;
--colour-rune: #ec4899;
--colour-text: #e2e8f0;
--colour-text-muted: #94a3b8;
--colour-success: #10b981;
--colour-error: #ef4444;
--colour-warning: #f59e0b;
--radius: 8px;
--radius-lg: 12px;
--font: "Segoe UI", system-ui, sans-serif;
}
body {
background-color: var(--colour-bg);
color: var(--colour-text);
font-family: var(--font);
min-height: 100vh;
}
/* ===================== RESOURCE BAR ===================== */
.resource-bar {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0.75rem 1.5rem;
background: var(--colour-surface);
border-bottom: 1px solid var(--colour-border);
position: sticky;
top: 0;
z-index: 10;
}
.resource {
display: flex;
align-items: center;
gap: 0.35rem;
}
.resource-value {
font-weight: 700;
font-size: 1.1rem;
}
.resource-label {
color: var(--colour-text-muted);
font-size: 0.8rem;
}
.prestige-badge {
margin-left: auto;
background: linear-gradient(135deg, var(--colour-accent), var(--colour-accent-light));
padding: 0.25rem 0.75rem;
border-radius: 999px;
font-size: 0.85rem;
font-weight: 600;
}
/* ===================== GAME LAYOUT ===================== */
.game-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.game-main {
display: flex;
flex: 1;
}
.game-sidebar {
width: 220px;
padding: 1rem;
background: var(--colour-surface-2);
border-right: 1px solid var(--colour-border);
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.game-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ===================== TABS ===================== */
.tab-bar {
display: flex;
gap: 0.25rem;
padding: 0.75rem 1rem;
background: var(--colour-surface);
border-bottom: 1px solid var(--colour-border);
overflow-x: auto;
}
.tab-button {
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius);
color: var(--colour-text-muted);
cursor: pointer;
font-size: 0.9rem;
padding: 0.4rem 0.9rem;
transition: all 0.15s;
white-space: nowrap;
}
.tab-button:hover {
background: var(--colour-surface-2);
color: var(--colour-text);
}
.tab-button.active {
background: var(--colour-accent);
border-color: var(--colour-accent-light);
color: #fff;
}
.tab-content {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
/* ===================== CLICK AREA ===================== */
.click-area {
text-align: center;
}
.click-area h2 {
font-size: 0.95rem;
color: var(--colour-text-muted);
margin-bottom: 0.75rem;
}
.click-button {
background: linear-gradient(135deg, var(--colour-accent), var(--colour-accent-light));
border: none;
border-radius: 50%;
cursor: pointer;
font-size: 3rem;
height: 120px;
transition: transform 0.1s, box-shadow 0.1s;
width: 120px;
box-shadow: 0 0 20px rgba(124, 58, 237, 0.4);
}
.click-button:active {
transform: scale(0.93);
box-shadow: 0 0 10px rgba(124, 58, 237, 0.2);
}
.click-power {
color: var(--colour-text-muted);
font-size: 0.8rem;
margin-top: 0.5rem;
}
/* ===================== PANEL ===================== */
.panel {
max-width: 900px;
}
.panel h2 {
font-size: 1.3rem;
margin-bottom: 1rem;
color: var(--colour-accent-light);
}
.empty-state {
color: var(--colour-text-muted);
font-style: italic;
}
/* ===================== ADVENTURERS ===================== */
.adventurer-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.adventurer-card {
align-items: center;
background: var(--colour-surface);
border: 1px solid var(--colour-border);
border-radius: var(--radius);
display: flex;
gap: 0.75rem;
padding: 0.75rem;
}
.adventurer-card.locked {
opacity: 0.5;
}
.adventurer-icon {
font-size: 1.75rem;
min-width: 2.5rem;
text-align: center;
}
.adventurer-info {
flex: 1;
}
.adventurer-info h3 {
font-size: 1rem;
margin-bottom: 0.15rem;
}
.adventurer-info p {
color: var(--colour-text-muted);
font-size: 0.8rem;
}
.adventurer-count {
color: var(--colour-accent-light);
font-size: 1.1rem;
font-weight: 700;
min-width: 3rem;
text-align: center;
}
/* ===================== BUTTONS ===================== */
.buy-button,
.start-quest-button,
.attack-button,
.prestige-button {
background: var(--colour-accent);
border: none;
border-radius: var(--radius);
color: #fff;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
padding: 0.4rem 0.9rem;
transition: background 0.15s;
}
.buy-button:hover:not(:disabled),
.start-quest-button:hover,
.attack-button:hover,
.prestige-button:hover:not(:disabled) {
background: var(--colour-accent-light);
}
.buy-button:disabled,
.prestige-button:disabled {
background: var(--colour-border);
color: var(--colour-text-muted);
cursor: not-allowed;
}
/* ===================== UPGRADES ===================== */
.upgrade-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.upgrade-card {
align-items: center;
background: var(--colour-surface);
border: 1px solid var(--colour-border);
border-radius: var(--radius);
display: flex;
gap: 0.75rem;
padding: 0.75rem;
}
.upgrade-card.purchased {
border-color: var(--colour-success);
opacity: 0.7;
}
.upgrade-info {
flex: 1;
}
.upgrade-info h3 {
font-size: 1rem;
margin-bottom: 0.15rem;
}
.upgrade-info p {
color: var(--colour-text-muted);
font-size: 0.8rem;
}
.upgrade-multiplier {
color: var(--colour-gold) !important;
font-weight: 600;
}
.upgrade-cost {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.85rem;
}
/* ===================== QUESTS ===================== */
.quest-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.quest-card {
background: var(--colour-surface);
border: 1px solid var(--colour-border);
border-radius: var(--radius);
display: flex;
gap: 1rem;
padding: 1rem;
align-items: flex-start;
}
.quest-card.quest-completed {
border-color: var(--colour-success);
opacity: 0.7;
}
.quest-card.quest-active {
border-color: var(--colour-warning);
}
.quest-info {
flex: 1;
}
.quest-info h3 {
font-size: 1rem;
margin-bottom: 0.25rem;
}
.quest-info p {
color: var(--colour-text-muted);
font-size: 0.85rem;
margin-bottom: 0.5rem;
}
.quest-rewards {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.reward-tag {
background: var(--colour-surface-2);
border: 1px solid var(--colour-border);
border-radius: 999px;
font-size: 0.75rem;
padding: 0.15rem 0.5rem;
}
.quest-badge {
font-size: 0.85rem;
font-weight: 600;
padding: 0.3rem 0.6rem;
border-radius: var(--radius);
white-space: nowrap;
}
.quest-badge.locked { color: var(--colour-text-muted); }
.quest-badge.active { color: var(--colour-warning); }
.quest-badge.completed { color: var(--colour-success); }
/* ===================== BOSSES ===================== */
.boss-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.boss-card {
background: var(--colour-surface);
border: 1px solid var(--colour-border);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
}
.boss-card.boss-defeated {
opacity: 0.6;
border-color: var(--colour-text-muted);
}
.boss-card.boss-in_progress {
border-color: var(--colour-error);
box-shadow: 0 0 12px rgba(239, 68, 68, 0.2);
}
.boss-info h3 {
font-size: 1.1rem;
margin-bottom: 0.25rem;
}
.boss-info p {
color: var(--colour-text-muted);
font-size: 0.85rem;
}
.prestige-lock {
color: var(--colour-warning) !important;
margin-top: 0.25rem;
}
.hp-bar {
background: var(--colour-border);
border-radius: 999px;
height: 10px;
overflow: hidden;
}
.hp-fill {
background: linear-gradient(90deg, #ef4444, #f97316);
height: 100%;
transition: width 0.3s ease;
}
.hp-text {
color: var(--colour-text-muted);
font-size: 0.8rem;
}
.boss-rewards {
display: flex;
gap: 0.75rem;
font-size: 0.85rem;
}
.boss-badge.defeated {
color: var(--colour-text-muted);
font-size: 0.9rem;
}
.attack-button {
align-self: flex-start;
background: linear-gradient(135deg, #ef4444, #b91c1c);
font-size: 0.95rem;
padding: 0.5rem 1.2rem;
}
.attack-button:hover {
background: linear-gradient(135deg, #f87171, #dc2626);
}
/* ===================== PRESTIGE ===================== */
.prestige-panel p {
color: var(--colour-text-muted);
font-size: 0.9rem;
line-height: 1.6;
margin-bottom: 0.75rem;
}
.prestige-status {
background: var(--colour-surface);
border: 1px solid var(--colour-border);
border-radius: var(--radius);
padding: 1rem;
margin-bottom: 1rem;
}
.prestige-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 400px;
}
.prestige-form input {
background: var(--colour-surface);
border: 1px solid var(--colour-border);
border-radius: var(--radius);
color: var(--colour-text);
font-size: 0.95rem;
padding: 0.5rem 0.75rem;
width: 100%;
}
.prestige-button {
background: linear-gradient(135deg, #7c3aed, #a855f7);
font-size: 1rem;
padding: 0.6rem 1.5rem;
}
.prestige-locked {
color: var(--colour-text-muted) !important;
}
/* ===================== LOGIN PAGE ===================== */
.login-page {
align-items: center;
background: radial-gradient(ellipse at center, #1a1a3e 0%, #0d0d1a 100%);
display: flex;
justify-content: center;
min-height: 100vh;
}
.login-card {
background: var(--colour-surface);
border: 1px solid var(--colour-border);
border-radius: var(--radius-lg);
max-width: 480px;
padding: 2.5rem;
text-align: center;
width: 100%;
}
.login-card h1 {
font-size: 2rem;
margin-bottom: 0.75rem;
}
.login-card p {
color: var(--colour-text-muted);
margin-bottom: 1.5rem;
}
.discord-login-button {
background: #5865f2;
border-radius: var(--radius);
color: #fff;
display: inline-block;
font-size: 1rem;
font-weight: 600;
padding: 0.75rem 2rem;
text-decoration: none;
transition: background 0.15s;
}
.discord-login-button:hover {
background: #4752c4;
}
.login-note {
font-size: 0.8rem !important;
margin-top: 1rem !important;
}
/* ===================== MODAL ===================== */
.modal-overlay {
align-items: center;
background: rgba(0, 0, 0, 0.7);
display: flex;
inset: 0;
justify-content: center;
position: fixed;
z-index: 100;
}
.modal {
background: var(--colour-surface);
border: 1px solid var(--colour-border);
border-radius: var(--radius-lg);
max-width: 420px;
padding: 2rem;
text-align: center;
width: 90%;
}
.modal h2 {
font-size: 1.4rem;
margin-bottom: 0.75rem;
}
.modal p {
color: var(--colour-text-muted);
margin-bottom: 0.75rem;
}
.modal-note {
font-size: 0.8rem;
}
.modal-close-button {
background: var(--colour-accent);
border: none;
border-radius: var(--radius);
color: #fff;
cursor: pointer;
font-size: 1rem;
font-weight: 700;
margin-top: 0.5rem;
padding: 0.6rem 2rem;
transition: background 0.15s;
}
.modal-close-button:hover {
background: var(--colour-accent-light);
}
/* ===================== UTILITY ===================== */
.error {
color: var(--colour-error);
font-size: 0.85rem;
}
.success {
color: var(--colour-success);
font-size: 0.85rem;
}
.loading-screen,
.error-screen {
align-items: center;
display: flex;
height: 100vh;
justify-content: center;
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "@nhcarrigan/typescript-config",
"compilerOptions": {
"outDir": "./prod",
"rootDir": ".",
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"target": "ES2022"
},
"exclude": ["test/**/*.ts", "test/**/*.tsx"]
}
+18
View File
@@ -0,0 +1,18 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:3001",
rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},
build: {
outDir: "dist",
},
});
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
coverage: {
provider: "v8",
include: ["src/**/*.ts", "src/**/*.tsx"],
exclude: ["src/types/**/*.ts", "src/main.tsx"],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
include: ["test/**/*.spec.ts", "test/**/*.spec.tsx"],
},
});