generated from nhcarrigan/template
6ddf8e0b43
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.
244 lines
9.0 KiB
TypeScript
244 lines
9.0 KiB
TypeScript
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>
|
||
);
|
||
};
|