Files
elysium/apps/web/src/components/game/prestigePanel.tsx
T
hikari 3d114f63d7
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m5s
CI / Lint, Build & Test (push) Successful in 1m9s
feat: post-prestige automation (auto-adventurer) (#76)
## Summary

Closes #61

- Adds the **Autonomous Recruitment** prestige upgrade (50 runestones) to both the API and web data files
- Adds `autoAdventurer?: boolean` to the `GameState` type for backwards-compatible saves
- Adds tick-loop logic in GameContext that automatically purchases the highest-tier unlocked adventurer the player can afford each frame when the toggle is enabled
- Adds `toggleAutoAdventurer` callback and exposes it through the context
- Adds toggle UI in the Prestige Shop (mirrors the existing Auto-Prestige toggle pattern)
- Updates the How to Play guide in the About panel to document the new automation feature

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #76
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-19 13:38:25 -07:00

451 lines
14 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 { cdnImage } from "../../utils/cdn.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,
toggleAutoAdventurer,
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 { autoAdventurer, 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 handleAutoAdventurerToggle(): void {
toggleAutoAdventurer();
}
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 isAutoAdventurerToggle
= upgrade.id === "auto_adventurer" && purchased;
const autoAdventurerEnabled = autoAdventurer ?? false;
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}
>
<img
alt={upgrade.name}
className="card-thumbnail"
src={cdnImage("prestige-upgrades", 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>
{isAutoAdventurerToggle
? <button
className={`auto-prestige-toggle ${
autoAdventurerEnabled
? "enabled"
: "disabled"
}`}
onClick={handleAutoAdventurerToggle}
type="button"
>
{autoAdventurerEnabled
? "⚡ Auto ON"
: "⏸ Auto OFF"}
</button>
: null}
{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 };