Files
elysium/apps/web/src/components/game/prestigePanel.tsx
T
hikari f9c925b9fc
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint, Build & Test (push) Successful in 1m5s
feat: unify toast styles and add quest/milestone toast notifications
- Merge .codex-toast and .achievement-toast into a single .game-toast class
- Fix storyToast inner class names and replace <button> wrapper with <div>
- Add QuestCompleteToast and QuestFailedToast components
- Add MilestoneToast for prestige, transcendence, and apotheosis events
- Move shared toast container to gameLayout so all toasts stack in one column
- Wire quest detection in GameContext to store full Quest objects for toast names
- Trigger prestige toast from both auto-prestige and manual prestige panel
2026-03-08 18:47:42 -07:00

422 lines
13 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.
/**
* @file Prestige panel component for ascending and purchasing runestone upgrades.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- Many conditional render paths */
/* eslint-disable max-lines -- Large panel with prestige and shop tabs */
/* eslint-disable max-statements -- Prestige panel manages many local state variables */
import { useState, type JSX } 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";
import { sendNotification } from "../../utils/notification.js";
import { playSound } from "../../utils/sound.js";
import type { PrestigeUpgradeCategory } from "@elysium/types";
const baseThreshold = 1_000_000;
const thresholdScale = 5;
const runestonesPerLevel = 10;
/**
* Calculates the prestige threshold for a given prestige count.
* @param prestigeCount - The current prestige count.
* @returns The required gold to prestige.
*/
const calculateThreshold = (prestigeCount: number): number => {
return baseThreshold * Math.pow(thresholdScale, prestigeCount);
};
/**
* Calculates the production multiplier for a given prestige count.
* @param prestigeCount - The number of times the player has prestiged.
* @returns The compounding multiplier applied to all income sources.
*/
const calculateProductionMultiplier = (prestigeCount: number): number => {
return Math.pow(1.15, prestigeCount);
};
/**
* Calculates the runestone preview for a prestige.
* @param totalGoldEarned - Total gold earned this run.
* @param prestigeCount - The current prestige count.
* @param purchasedUpgradeIds - IDs of purchased prestige upgrades.
* @returns The predicted runestone reward.
*/
const calculateRunestonePreview = (
totalGoldEarned: number,
prestigeCount: number,
purchasedUpgradeIds: Array<string>,
): number => {
const threshold = calculateThreshold(prestigeCount);
const base
= Math.floor(Math.sqrt(totalGoldEarned / threshold)) * runestonesPerLevel;
const runestoneMult = PRESTIGE_UPGRADES.filter((upgrade) => {
return (
upgrade.category === "runestones"
&& purchasedUpgradeIds.includes(upgrade.id)
);
}).reduce((mult, upgrade) => {
return mult * upgrade.multiplier;
}, 1);
return Math.floor(base * runestoneMult);
};
const categoryOrder: Array<PrestigeUpgradeCategory> = [
"income",
"click",
"essence",
"crystals",
"runestones",
"utility",
];
/**
* Renders the prestige panel with ascension and runestone shop tabs.
* @returns The JSX element.
*/
const PrestigePanel = (): JSX.Element => {
const {
state,
reload,
formatNumber,
buyPrestigeUpgrade,
enableNotifications,
enableSounds,
toggleAutoPrestige,
triggerPrestigeToast,
} = 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 === null) {
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);
async function handlePrestige(): Promise<void> {
setIsPending(true);
setPrestigeError(null);
try {
const data = await prestige({});
setResult({
count: data.newPrestigeCount,
milestoneRunestones: data.milestoneRunestones,
runestones: data.runestones,
});
triggerPrestigeToast();
if (enableSounds) {
playSound("prestige");
}
if (enableNotifications) {
sendNotification(
"⭐ Prestige!",
`You've reached prestige level ${data.newPrestigeCount.toString()}!`,
);
}
await reload();
} catch (error_: unknown) {
setPrestigeError(
error_ instanceof Error
? error_.message
: "Prestige failed",
);
} finally {
setIsPending(false);
}
}
async function handleBuyUpgrade(upgradeId: string): Promise<void> {
setBuyingId(upgradeId);
try {
await buyPrestigeUpgrade(upgradeId);
} finally {
setBuyingId(null);
}
}
const upgradesByCategory = categoryOrder.map((categoryId) => {
const label = PRESTIGE_UPGRADE_CATEGORY_LABELS[categoryId] ?? categoryId;
const upgrades = PRESTIGE_UPGRADES.filter((upgrade) => {
return upgrade.category === categoryId;
});
return { categoryId, label, upgrades };
});
function handlePrestigeClick(): void {
void handlePrestige();
}
function handleAutoPrestigeToggle(): void {
toggleAutoPrestige();
}
function handlePrestigeTabClick(): void {
setActiveTab("prestige");
}
function handleShopTabClick(): void {
setActiveTab("shop");
}
const progressRatio = player.totalGoldEarned / threshold;
const progressPct = (progressRatio * 100).toFixed(1);
return (
<section className="panel prestige-panel">
<h2>{"⭐ Prestige"}</h2>
<div className="prestige-tabs">
<button
className={`prestige-tab ${activeTab === "prestige"
? "active"
: ""}`}
onClick={handlePrestigeTabClick}
type="button"
>
{"Ascend"}
</button>
<button
className={`prestige-tab ${activeTab === "shop"
? "active"
: ""}`}
onClick={handleShopTabClick}
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>
: null}
{isEligible
? null
: <p className="prestige-progress">
{"Progress: "}
{formatNumber(player.totalGoldEarned)}
{" / "}
{formatNumber(threshold)}
{" ("}
{progressPct}
{"%"}
{")"}
</p>
}
</div>
{isEligible
? <div className="prestige-form">
<p>{"You are ready to prestige!"}</p>
<button
className="prestige-button"
disabled={isPending}
onClick={handlePrestigeClick}
type="button"
>
{isPending
? "Ascending..."
: `✨ Ascend (+${formatNumber(runestonePreview)} Runestones)`}
</button>
{prestigeError === null
? null
: <p className="error">{prestigeError}</p>
}
{result === null
? null
: <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(({ categoryId, label, upgrades }) => {
return (
<div className="shop-category" key={categoryId}>
<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;
function handleBuyClick(): void {
void handleBuyUpgrade(upgrade.id);
}
return (
<div
className={`shop-upgrade-card ${
purchased
? "purchased"
: ""
} ${!canAfford && !purchased
? "unaffordable"
: ""}`}
key={upgrade.id}
>
<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={handleAutoPrestigeToggle}
type="button"
>
{autoPrestigeEnabled
? "⚡ Auto ON"
: "⏸ Auto OFF"}
</button>
: null}
{purchased
? null
: <button
className="buy-upgrade-button"
disabled={
!canAfford || isLoading || buyingId !== null
}
onClick={handleBuyClick}
type="button"
>
{isLoading
? "Buying..."
: "Buy"}
</button>
}
</div>
);
})}
</div>
</div>
);
})}
</div>
}
</section>
);
};
export { PrestigePanel };