generated from nhcarrigan/template
feat: add transcendence second prestige layer
Implements the full Transcendence system — the ultimate endgame mechanic, unlocked by defeating The Absolute One (requires Prestige 90). Nuclear reset model: wipes resources, prestige, runestones, upgrades, equipment, bosses, quests, zones, and achievements. Codex entries and lifetime profile stats are preserved. Transcendence data is permanent and accumulates across all future resets. Echo formula: floor(853 / sqrt(prestigeCount)) × echoMetaMultiplier Fewer prestiges = more Echoes, rewarding optimised play. 15 Echo upgrades across 5 categories: - Income multipliers (×1.25 → ×5): 5 tiers, cost 5–80 echoes - Combat multipliers (×1.25 → ×2): 3 tiers, cost 5–35 echoes - Prestige threshold reductions (×0.9, ×0.8): cost 8–20 echoes - Prestige runestone multipliers (×1.5, ×2): cost 8–20 echoes - Echo meta multipliers (×1.25 → ×2): cost 10–50 echoes New files: Transcendence.ts types, transcendence service, route, data files (API + web), TranscendencePanel.tsx component. Modified: GameState, Api, types/index, prestige service (carries transcendence through resets, applies echo multipliers), boss route (echoCombatMultiplier), game.ts anti-cheat (echo cap), tick.ts (echoIncomeMultiplier), GameContext, API client, GameLayout (new tab), ResourceBar (transcendence badge alongside prestige badge), styles.css, AboutPanel, IDEAS.md.
This commit is contained in:
@@ -59,6 +59,10 @@ const HOW_TO_PLAY = [
|
||||
title: "☁️ Cloud Saves",
|
||||
body: "Your progress is automatically saved to the cloud every 30 seconds whilst you play. You can also force a manual save at any time using the sync button in the resource bar. Your save is protected by HMAC validation to ensure data integrity.",
|
||||
},
|
||||
{
|
||||
title: "🌌 Transcendence",
|
||||
body: "Transcendence is the ultimate prestige layer, unlocked by defeating The Absolute One (requires Prestige 90). Transcending performs a nuclear reset — wiping resources, prestige, runestones, upgrades, and equipment — but grants Echoes based on your prestige count (fewer prestiges = more Echoes). Echoes are permanent and survive all future resets. Spend them in the Echo Shop on lasting multipliers: passive income, combat power, prestige quality-of-life, and Echo meta upgrades that amplify future Echo yields.",
|
||||
},
|
||||
];
|
||||
|
||||
const formatDate = (dateStr: string): string =>
|
||||
|
||||
@@ -14,12 +14,13 @@ import { EditProfileModal } from "./EditProfileModal.js";
|
||||
import { EquipmentPanel } from "./EquipmentPanel.js";
|
||||
import { OfflineModal } from "./OfflineModal.js";
|
||||
import { PrestigePanel } from "./PrestigePanel.js";
|
||||
import { TranscendencePanel } from "./TranscendencePanel.js";
|
||||
import { QuestPanel } from "./QuestPanel.js";
|
||||
import { StatisticsPanel } from "./StatisticsPanel.js";
|
||||
import { UpgradePanel } from "./UpgradePanel.js";
|
||||
import { DailyChallengePanel } from "./DailyChallengePanel.js";
|
||||
|
||||
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "statistics" | "daily" | "codex" | "about";
|
||||
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "transcendence" | "statistics" | "daily" | "codex" | "about";
|
||||
|
||||
const BASE_TABS: { id: Tab; label: string }[] = [
|
||||
{ id: "adventurers", label: "⚔️ Adventurers" },
|
||||
@@ -29,6 +30,7 @@ const BASE_TABS: { id: Tab; label: string }[] = [
|
||||
{ id: "equipment", label: "🗡️ Equipment" },
|
||||
{ id: "achievements", label: "🏆 Achievements" },
|
||||
{ id: "prestige", label: "⭐ Prestige" },
|
||||
{ id: "transcendence", label: "🌌 Transcendence" },
|
||||
{ id: "statistics", label: "📊 Statistics" },
|
||||
{ id: "daily", label: "📅 Daily" },
|
||||
{ id: "codex", label: "📖 Codex" },
|
||||
@@ -66,6 +68,7 @@ export const GameLayout = (): React.JSX.Element => {
|
||||
resources={state.resources}
|
||||
runestones={state.prestige.runestones}
|
||||
prestigeCount={state.prestige.count}
|
||||
transcendenceCount={state.transcendence?.count ?? 0}
|
||||
profileUrl={profileUrl}
|
||||
onEditProfile={() => { setEditingProfile(true); }}
|
||||
lastSavedAt={lastSavedAt}
|
||||
@@ -112,6 +115,7 @@ export const GameLayout = (): React.JSX.Element => {
|
||||
{activeTab === "equipment" && <EquipmentPanel />}
|
||||
{activeTab === "achievements" && <AchievementPanel />}
|
||||
{activeTab === "prestige" && <PrestigePanel />}
|
||||
{activeTab === "transcendence" && <TranscendencePanel />}
|
||||
{activeTab === "statistics" && <StatisticsPanel />}
|
||||
{activeTab === "daily" && <DailyChallengePanel />}
|
||||
{activeTab === "codex" && <CodexPanel />}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import type { TranscendenceUpgradeCategory } from "@elysium/types";
|
||||
import { useState } from "react";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import {
|
||||
TRANSCENDENCE_UPGRADES,
|
||||
TRANSCENDENCE_UPGRADE_CATEGORY_LABELS,
|
||||
} from "../../data/transcendenceUpgrades.js";
|
||||
|
||||
const ECHO_FORMULA_CONSTANT = 853;
|
||||
const FINAL_BOSS_ID = "the_absolute_one";
|
||||
|
||||
const calculateEchoPreview = (prestigeCount: number, echoMetaMultiplier: number): number => {
|
||||
const safeCount = Math.max(prestigeCount, 1);
|
||||
return Math.floor((ECHO_FORMULA_CONSTANT / Math.sqrt(safeCount)) * echoMetaMultiplier);
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER: TranscendenceUpgradeCategory[] = [
|
||||
"income",
|
||||
"combat",
|
||||
"prestige_threshold",
|
||||
"prestige_runestones",
|
||||
"echo_meta",
|
||||
];
|
||||
|
||||
export const TranscendencePanel = (): React.JSX.Element => {
|
||||
const { state, formatNumber, transcend, buyEchoUpgrade } = useGame();
|
||||
const [characterName, setCharacterName] = useState("");
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [result, setResult] = useState<{ echoes: number; count: number } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [buyingId, setBuyingId] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<"transcend" | "shop">("transcend");
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
const { bosses, prestige: prestigeData, transcendence } = state;
|
||||
const hasDefeatedFinalBoss = bosses.some((b) => b.id === FINAL_BOSS_ID && b.status === "defeated");
|
||||
const echoMetaMultiplier = transcendence?.echoMetaMultiplier ?? 1;
|
||||
const echoPreview = calculateEchoPreview(prestigeData.count, echoMetaMultiplier);
|
||||
const currentEchoes = transcendence?.echoes ?? 0;
|
||||
const transcendenceCount = transcendence?.count ?? 0;
|
||||
|
||||
const handleTranscend = async (): Promise<void> => {
|
||||
if (!characterName.trim()) return;
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await transcend(characterName.trim());
|
||||
setResult({ echoes: data.echoes, count: data.newTranscendenceCount });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Transcendence failed");
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBuyUpgrade = async (upgradeId: string): Promise<void> => {
|
||||
setBuyingId(upgradeId);
|
||||
try {
|
||||
await buyEchoUpgrade(upgradeId);
|
||||
} finally {
|
||||
setBuyingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const upgradesByCategory = CATEGORY_ORDER.map((category) => ({
|
||||
category,
|
||||
label: TRANSCENDENCE_UPGRADE_CATEGORY_LABELS[category] ?? category,
|
||||
upgrades: TRANSCENDENCE_UPGRADES.filter((u) => u.category === category),
|
||||
}));
|
||||
|
||||
return (
|
||||
<section className="panel transcendence-panel">
|
||||
<h2>🌌 Transcendence</h2>
|
||||
|
||||
<div className="prestige-tabs">
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "transcend" ? "active" : ""}`}
|
||||
onClick={() => { setActiveTab("transcend"); }}
|
||||
type="button"
|
||||
>
|
||||
Transcend
|
||||
</button>
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "shop" ? "active" : ""}`}
|
||||
onClick={() => { setActiveTab("shop"); }}
|
||||
type="button"
|
||||
>
|
||||
✨ Echo Shop ({formatNumber(currentEchoes)} echoes)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "transcend" && (
|
||||
<>
|
||||
<p className="transcendence-intro">
|
||||
Transcendence is the ultimate reset. It wipes{" "}
|
||||
<strong>everything</strong> — resources, prestige, runestones, upgrades,
|
||||
and equipment — but grants <strong>Echoes</strong>, a permanent currency
|
||||
that survives all future resets. Echoes power upgrades that permanently
|
||||
amplify every run from this point forward.
|
||||
</p>
|
||||
<p className="transcendence-intro">
|
||||
<em>
|
||||
Fewer prestiges = more Echoes. Optimise your run for maximum yield!
|
||||
</em>
|
||||
</p>
|
||||
|
||||
<div className="transcendence-status">
|
||||
{transcendenceCount > 0 && (
|
||||
<p>Transcendence count: <strong>{transcendenceCount}</strong></p>
|
||||
)}
|
||||
<p>Current Echoes: <strong>{formatNumber(currentEchoes)}</strong></p>
|
||||
<p>Current prestige count: <strong>{prestigeData.count}</strong></p>
|
||||
{hasDefeatedFinalBoss && (
|
||||
<p className="echo-preview">
|
||||
Echoes on transcendence: <strong>+{formatNumber(echoPreview)}</strong>
|
||||
{echoMetaMultiplier > 1 && (
|
||||
<span className="echo-meta-bonus">
|
||||
{" "}(×{echoMetaMultiplier.toFixed(2)} meta bonus applied)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasDefeatedFinalBoss && (
|
||||
<div className="transcendence-locked">
|
||||
<p>🔒 <strong>Defeat The Absolute One</strong> to unlock transcendence.</p>
|
||||
<p className="transcendence-hint">
|
||||
The Absolute One is the final boss of The Absolute zone, requiring
|
||||
Prestige 90 to challenge.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasDefeatedFinalBoss && (
|
||||
<div className="prestige-form">
|
||||
<p>You are ready to transcend. This action is <strong>irreversible</strong>.</p>
|
||||
<p>Choose your new character name for the next cycle:</p>
|
||||
<input
|
||||
disabled={isPending}
|
||||
maxLength={32}
|
||||
onChange={(e) => { setCharacterName(e.target.value); }}
|
||||
placeholder="Character name..."
|
||||
type="text"
|
||||
value={characterName}
|
||||
/>
|
||||
<button
|
||||
className="transcendence-button"
|
||||
disabled={isPending || !characterName.trim()}
|
||||
onClick={() => { void handleTranscend(); }}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Transcending..."
|
||||
: `🌌 Transcend (+${formatNumber(echoPreview)} Echoes)`}
|
||||
</button>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{result && (
|
||||
<p className="success">
|
||||
Transcended! Earned{" "}
|
||||
<strong>{formatNumber(result.echoes)} Echoes</strong>. This is
|
||||
Transcendence {result.count}. A new cycle begins.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === "shop" && (
|
||||
<div className="echo-shop">
|
||||
<p className="shop-balance">
|
||||
Balance: <strong>{formatNumber(currentEchoes)} Echoes</strong>
|
||||
</p>
|
||||
<p className="echo-shop-description">
|
||||
Echo upgrades are <strong>permanent</strong> — they survive all future
|
||||
prestiges and transcendences.
|
||||
</p>
|
||||
|
||||
{upgradesByCategory.map(({ category, label, upgrades }) => (
|
||||
<div key={category} className="shop-category">
|
||||
<h3>{label}</h3>
|
||||
<div className="shop-upgrades">
|
||||
{upgrades.map((upgrade) => {
|
||||
const purchased = (transcendence?.purchasedUpgradeIds ?? []).includes(upgrade.id);
|
||||
const canAfford = currentEchoes >= upgrade.cost;
|
||||
const isLoading = buyingId === upgrade.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={upgrade.id}
|
||||
className={`shop-upgrade-card echo-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.cost)} Echoes`}
|
||||
</p>
|
||||
</div>
|
||||
{!purchased && (
|
||||
<button
|
||||
className="buy-upgrade-button echo-buy-button"
|
||||
disabled={!canAfford || isLoading || buyingId !== null}
|
||||
onClick={() => { void handleBuyUpgrade(upgrade.id); }}
|
||||
type="button"
|
||||
>
|
||||
{isLoading ? "Buying..." : "Buy"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -6,6 +6,7 @@ interface ResourceBarProps {
|
||||
resources: Resource;
|
||||
runestones: number;
|
||||
prestigeCount: number;
|
||||
transcendenceCount: number;
|
||||
profileUrl: string;
|
||||
onEditProfile: () => void;
|
||||
lastSavedAt: number | null;
|
||||
@@ -29,6 +30,7 @@ export const ResourceBar = ({
|
||||
resources,
|
||||
runestones,
|
||||
prestigeCount,
|
||||
transcendenceCount,
|
||||
profileUrl,
|
||||
onEditProfile,
|
||||
lastSavedAt,
|
||||
@@ -63,6 +65,11 @@ export const ResourceBar = ({
|
||||
<span className="resource-value">{formatNumber(runestones)}</span>
|
||||
<span className="resource-label">Runestones</span>
|
||||
</div>
|
||||
{transcendenceCount > 0 && (
|
||||
<div className="transcendence-badge">
|
||||
🌌 Transcendence {transcendenceCount}
|
||||
</div>
|
||||
)}
|
||||
{prestigeCount > 0 && (
|
||||
<div className="prestige-badge">
|
||||
⭐ Prestige {prestigeCount}
|
||||
|
||||
Reference in New Issue
Block a user