feat: content expansion, prestige shop, and offline earnings improvements

- Expand content to 18 zones, 72 bosses, 95 quests, 32 adventurer tiers
- Add prestige shop with 24 runestone upgrades across 5 categories
- Add PrestigeUpgrade type, data files, API routes, and frontend panel
- Fix offline earnings to include equipment and runestone multipliers
- Add offline essence calculation alongside offline gold
- Update OfflineModal to display both gold and essence earned
- Add IDEAS.md for tracking planned features
This commit is contained in:
2026-03-06 21:55:42 -08:00
committed by Naomi Carrigan
parent 6bc116a86a
commit 5b4661b398
23 changed files with 2288 additions and 91 deletions
+198 -52
View File
@@ -1,90 +1,236 @@
import type { PrestigeUpgradeCategory } from "@elysium/types";
import { useState } from "react";
import { prestige } from "../../api/client.js";
import { useGame } from "../../context/GameContext.js";
import {
PRESTIGE_UPGRADES,
PRESTIGE_UPGRADE_CATEGORY_LABELS,
} from "../../data/prestigeUpgrades.js";
const PRESTIGE_THRESHOLD = 1_000_000;
const BASE_THRESHOLD = 1_000_000;
const THRESHOLD_SCALE = 5;
const RUNESTONES_PER_LEVEL = 10;
const calculateThreshold = (prestigeCount: number): number =>
BASE_THRESHOLD * Math.pow(THRESHOLD_SCALE, prestigeCount);
const calculateProductionMultiplier = (prestigeCount: number): number =>
Math.pow(1.15, prestigeCount);
const calculateRunestonePreview = (
totalGoldEarned: number,
prestigeCount: number,
purchasedUpgradeIds: string[],
): number => {
const threshold = calculateThreshold(prestigeCount);
const base = Math.floor(Math.sqrt(totalGoldEarned / threshold)) * RUNESTONES_PER_LEVEL;
const runestoneMult = PRESTIGE_UPGRADES
.filter((u) => u.category === "runestones" && purchasedUpgradeIds.includes(u.id))
.reduce((mult, u) => mult * u.multiplier, 1);
return Math.floor(base * runestoneMult);
};
const CATEGORY_ORDER: PrestigeUpgradeCategory[] = [
"income",
"click",
"essence",
"crystals",
"runestones",
];
export const PrestigePanel = (): React.JSX.Element => {
const { state, reload, formatNumber } = useGame();
const { state, reload, formatNumber, buyPrestigeUpgrade } = 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);
const [prestigeError, setPrestigeError] = useState<string | null>(null);
const [buyingId, setBuyingId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<"prestige" | "shop">("prestige");
if (!state) return <section className="panel"><p>Loading...</p></section>;
const isEligible = state.player.totalGoldEarned >= PRESTIGE_THRESHOLD;
const { prestige: prestigeData, player } = state;
const threshold = calculateThreshold(prestigeData.count);
const isEligible = player.totalGoldEarned >= threshold;
const runestonePreview = calculateRunestonePreview(
player.totalGoldEarned,
prestigeData.count,
prestigeData.purchasedUpgradeIds,
);
const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1);
const handlePrestige = async (): Promise<void> => {
if (!characterName.trim()) return;
setIsPending(true);
setError(null);
setPrestigeError(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");
setPrestigeError(err instanceof Error ? err.message : "Prestige failed");
} finally {
setIsPending(false);
}
};
const handleBuyUpgrade = async (upgradeId: string): Promise<void> => {
setBuyingId(upgradeId);
try {
await buyPrestigeUpgrade(upgradeId);
} finally {
setBuyingId(null);
}
};
const upgradesByCategory = CATEGORY_ORDER.map((category) => ({
category,
label: PRESTIGE_UPGRADE_CATEGORY_LABELS[category] ?? category,
upgrades: PRESTIGE_UPGRADES.filter((u) => u.category === category),
}));
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>{formatNumber(state.player.totalGoldEarned)}</strong>
</p>
<p>
Required: <strong>{formatNumber(PRESTIGE_THRESHOLD)}</strong>
</p>
<p>Current prestige count: <strong>{state.prestige.count}</strong></p>
<p>
Production multiplier:{" "}
<strong>×{state.prestige.productionMultiplier.toFixed(1)}</strong>
</p>
<div className="prestige-tabs">
<button
className={`prestige-tab ${activeTab === "prestige" ? "active" : ""}`}
onClick={() => { setActiveTab("prestige"); }}
type="button"
>
Ascend
</button>
<button
className={`prestige-tab ${activeTab === "shop" ? "active" : ""}`}
onClick={() => { setActiveTab("shop"); }}
type="button"
>
🔮 Runestone Shop ({formatNumber(prestigeData.runestones)} stones)
</button>
</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.
{activeTab === "prestige" && (
<>
<p>
Prestige resets your progress but grants <strong>Runestones</strong> permanent
currency used for powerful upgrades. Each prestige multiplies your global production
by ×1.15 (compounding each run).
</p>
<div className="prestige-status">
<p>
Total gold this run:{" "}
<strong>{formatNumber(player.totalGoldEarned)}</strong>
</p>
<p>
Required to prestige: <strong>{formatNumber(threshold)}</strong>
</p>
<p>
Prestige count: <strong>{prestigeData.count}</strong>
</p>
<p>
Current production multiplier:{" "}
<strong>×{prestigeData.productionMultiplier.toFixed(2)}</strong>
</p>
<p>
After next prestige:{" "}
<strong>×{nextMultiplier.toFixed(2)}</strong>
</p>
<p>
Runestones: <strong>{formatNumber(prestigeData.runestones)}</strong>
</p>
{isEligible && (
<p className="runestone-preview">
Runestones on prestige: <strong>+{formatNumber(runestonePreview)}</strong>
</p>
)}
{!isEligible && (
<p className="prestige-progress">
Progress: {formatNumber(player.totalGoldEarned)} / {formatNumber(threshold)}{" "}
({((player.totalGoldEarned / threshold) * 100).toFixed(1)}%)
</p>
)}
</div>
{isEligible ? (
<div className="prestige-form">
<p>You are ready to prestige! Choose your new character name:</p>
<input
disabled={isPending}
maxLength={32}
onChange={(e) => { setCharacterName(e.target.value); }}
placeholder="Character name..."
type="text"
value={characterName}
/>
<button
className="prestige-button"
disabled={isPending || !characterName.trim()}
onClick={() => { void handlePrestige(); }}
type="button"
>
{isPending ? "Ascending..." : `✨ Ascend (+${formatNumber(runestonePreview)} Runestones)`}
</button>
{prestigeError && <p className="error">{prestigeError}</p>}
{result && (
<p className="success">
Ascended to Prestige {result.count}! Earned {formatNumber(result.runestones)} Runestones.
</p>
)}
</div>
) : (
<p className="prestige-locked">
Earn {formatNumber(threshold - player.totalGoldEarned)} more gold to unlock prestige.
</p>
)}
</>
)}
{activeTab === "shop" && (
<div className="runestone-shop">
<p className="shop-balance">
Balance: <strong>{formatNumber(prestigeData.runestones)} Runestones</strong>
</p>
{upgradesByCategory.map(({ category, label, upgrades }) => (
<div key={category} className="shop-category">
<h3>{label}</h3>
<div className="shop-upgrades">
{upgrades.map((upgrade) => {
const purchased = prestigeData.purchasedUpgradeIds.includes(upgrade.id);
const canAfford = prestigeData.runestones >= upgrade.runestonesCost;
const isLoading = buyingId === upgrade.id;
return (
<div
key={upgrade.id}
className={`shop-upgrade-card ${purchased ? "purchased" : ""} ${!canAfford && !purchased ? "unaffordable" : ""}`}
>
<div className="shop-upgrade-info">
<h4>{upgrade.name}</h4>
<p>{upgrade.description}</p>
<p className="upgrade-cost">
{purchased ? "✅ Purchased" : `🔮 ${formatNumber(upgrade.runestonesCost)} Runestones`}
</p>
</div>
{!purchased && (
<button
className="buy-upgrade-button"
disabled={!canAfford || isLoading || buyingId !== null}
onClick={() => { void handleBuyUpgrade(upgrade.id); }}
type="button"
>
{isLoading ? "Buying..." : "Buy"}
</button>
)}
</div>
);
})}
</div>
</div>
))}
</div>
) : (
<p className="prestige-locked">
Earn {formatNumber(PRESTIGE_THRESHOLD - state.player.totalGoldEarned)} more
gold to unlock prestige.
</p>
)}
</section>
);