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
@@ -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>
);
};