generated from nhcarrigan/template
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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user