Files
elysium/apps/web/src/components/game/PrestigePanel.tsx
T
hikari 6ddf8e0b43 feat: add exploration and crafting systems
Adds two new game systems: Exploration (scouts collect materials from
timed area runs) and Crafting (combine materials into permanent
multipliers). Includes 72 exploration areas, 54 materials, 36 recipes,
and 108 new Codex lore entries. Removes unused characterName requirement
from prestige/transcendence/apotheosis reset flows.
2026-03-07 04:14:04 -08:00

244 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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",
"utility",
];
export const PrestigePanel = (): React.JSX.Element => {
const { state, reload, formatNumber, buyPrestigeUpgrade, toggleAutoPrestige } = useGame();
const [isPending, setIsPending] = useState(false);
const [result, setResult] = useState<{ runestones: number; count: number; milestoneRunestones: number } | 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 { 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> => {
setIsPending(true);
setPrestigeError(null);
try {
const data = await prestige({});
setResult({ runestones: data.runestones, count: data.newPrestigeCount, milestoneRunestones: data.milestoneRunestones });
await reload();
} catch (err) {
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>
<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>
{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!</p>
<button
className="prestige-button"
disabled={isPending}
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.
{result.milestoneRunestones > 0 && (
<> 🎉 Milestone bonus: +{formatNumber(result.milestoneRunestones)} 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;
const isAutoPrestigeToggle = upgrade.id === "auto_prestige" && purchased;
const autoPrestigeEnabled = prestigeData.autoPrestigeEnabled ?? false;
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>
{isAutoPrestigeToggle && (
<button
className={`auto-prestige-toggle ${autoPrestigeEnabled ? "enabled" : "disabled"}`}
onClick={() => { toggleAutoPrestige(); }}
type="button"
>
{autoPrestigeEnabled ? "⚡ Auto ON" : "⏸ Auto OFF"}
</button>
)}
{!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>
)}
</section>
);
};