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:
2026-03-07 02:22:45 -08:00
committed by Naomi Carrigan
parent 298e1f4604
commit e8881a81d5
21 changed files with 1022 additions and 10 deletions
+2 -2
View File
@@ -8,7 +8,7 @@ A running list of planned features and content additions. Strike through items a
- [x] **Offline earnings** — When returning to the game, earn a percentage of what you'd have earned offline (cap at ~812 hours). Upgradeable via the prestige shop to increase the % and the time cap. Essential for an idle game!
- [ ] **Second prestige layer (Transcendence)** — Unlocked after ~10 prestiges. Sacrifice all runestones for a new currency ("Echoes"?). Echoes are permanent account-wide currency that persist across prestiges. Has its own upgrade tree with truly game-changing bonuses. Gives endgame players a long-term goal.
- [x] **Second prestige layer (Transcendence)** — Unlocked after ~10 prestiges. Sacrifice all runestones for a new currency ("Echoes"?). Echoes are permanent account-wide currency that persist across prestiges. Has its own upgrade tree with truly game-changing bonuses. Gives endgame players a long-term goal.
- [x] **Daily challenges** — Three rotating objectives each day (e.g. kill X boss, earn X gold this run, complete X quests). Reward bonus crystals. Encourages daily logins even when idling comfortably.
@@ -46,4 +46,4 @@ A running list of planned features and content additions. Strike through items a
6. ~~Equipment set bonuses~~
7. ~~Auto-prestige toggle~~
8. ~~The Codex / Lore Book~~
9. Second prestige layer / Transcendence (big feature, save for later)
9. Second prestige layer / Transcendence (big feature, save for later)
+133
View File
@@ -0,0 +1,133 @@
import type { TranscendenceUpgrade } from "@elysium/types";
export const DEFAULT_TRANSCENDENCE_UPGRADES: TranscendenceUpgrade[] = [
// ── Income multipliers ──────────────────────────────────────────────────────
{
id: "echo_income_1",
name: "Whisper of Power",
description: "The echoes of past runs linger, amplifying your guild's income by 25%.",
category: "income",
cost: 5,
multiplier: 1.25,
},
{
id: "echo_income_2",
name: "Resonance",
description: "Your transcendent experience resonates through your guild, boosting income by 50%.",
category: "income",
cost: 10,
multiplier: 1.5,
},
{
id: "echo_income_3",
name: "Harmonic Surge",
description: "The harmony of multiple timelines surges through your guild, doubling its income.",
category: "income",
cost: 20,
multiplier: 2.0,
},
{
id: "echo_income_4",
name: "Ethereal Overflow",
description: "Ethereal energy overflows from your transcendence, tripling your guild's income.",
category: "income",
cost: 40,
multiplier: 3.0,
},
{
id: "echo_income_5",
name: "Infinite Chorus",
description: "The infinite chorus of every run you've ever played amplifies your guild fivefold.",
category: "income",
cost: 80,
multiplier: 5.0,
},
// ── Combat multipliers ──────────────────────────────────────────────────────
{
id: "echo_combat_1",
name: "Battle-Hardened",
description: "Memories of countless battles harden your adventurers, increasing party DPS by 25%.",
category: "combat",
cost: 5,
multiplier: 1.25,
},
{
id: "echo_combat_2",
name: "Veteran's Edge",
description: "Veterans of transcendence know how to fight smarter, boosting party DPS by 50%.",
category: "combat",
cost: 15,
multiplier: 1.5,
},
{
id: "echo_combat_3",
name: "Transcendent Warrior",
description: "Your warriors carry the strength of every fallen timeline, doubling party DPS.",
category: "combat",
cost: 35,
multiplier: 2.0,
},
// ── Prestige threshold reductions ──────────────────────────────────────────
{
id: "echo_prestige_threshold_1",
name: "Accelerated Path",
description: "Experience from past lives shortens the road to prestige — threshold reduced by 10%.",
category: "prestige_threshold",
cost: 8,
multiplier: 0.9,
},
{
id: "echo_prestige_threshold_2",
name: "Shortcut Through Time",
description: "You've walked this path so many times you know every shortcut — threshold reduced by 20%.",
category: "prestige_threshold",
cost: 20,
multiplier: 0.8,
},
// ── Prestige runestone multipliers ─────────────────────────────────────────
{
id: "echo_prestige_runestones_1",
name: "Runic Attunement",
description: "Transcendent insight attunes you to the runestones, earning 50% more per prestige.",
category: "prestige_runestones",
cost: 8,
multiplier: 1.5,
},
{
id: "echo_prestige_runestones_2",
name: "Master Runesmith",
description: "You have mastered the art of runestone crafting, doubling your prestige runestone yield.",
category: "prestige_runestones",
cost: 20,
multiplier: 2.0,
},
// ── Echo meta multipliers ───────────────────────────────────────────────────
{
id: "echo_meta_1",
name: "Resonant Awakening",
description: "Your transcendence resonates deeper, amplifying future echo yields by 25%.",
category: "echo_meta",
cost: 10,
multiplier: 1.25,
},
{
id: "echo_meta_2",
name: "Transcendent Loop",
description: "Each loop of existence makes the next more powerful — future echo yields +50%.",
category: "echo_meta",
cost: 25,
multiplier: 1.5,
},
{
id: "echo_meta_3",
name: "Infinite Spiral",
description: "You have mastered the infinite spiral of transcendence, doubling all future echo yields.",
category: "echo_meta",
cost: 50,
multiplier: 2.0,
},
];
+2
View File
@@ -7,6 +7,7 @@ import { authRouter } from "./routes/auth.js";
import { bossRouter } from "./routes/boss.js";
import { gameRouter } from "./routes/game.js";
import { prestigeRouter } from "./routes/prestige.js";
import { transcendenceRouter } from "./routes/transcendence.js";
import { profileRouter } from "./routes/profile.js";
const app = new Hono();
@@ -26,6 +27,7 @@ app.route("/auth", authRouter);
app.route("/game", gameRouter);
app.route("/boss", bossRouter);
app.route("/prestige", prestigeRouter);
app.route("/transcendence", transcendenceRouter);
app.route("/profile", profileRouter);
app.get("/health", (context) => context.json({ status: "ok" }));
+2 -1
View File
@@ -59,7 +59,8 @@ const calculatePartyStats = (
partyMaxHp += adventurer.level * 50 * adventurer.count;
}
partyDPS *= equipmentCombatMultiplier * setCombatMultiplier;
const echoCombatMultiplier = state.transcendence?.echoCombatMultiplier ?? 1;
partyDPS *= equipmentCombatMultiplier * setCombatMultiplier * echoCombatMultiplier;
return { partyDPS, partyMaxHp };
};
+13 -1
View File
@@ -290,7 +290,19 @@ const validateAndSanitize = (incoming: GameState, previous: GameState): GameStat
const prestige =
incoming.prestige.count < previous.prestige.count ? previous.prestige : incoming.prestige;
return { ...incoming, resources, bosses, quests, achievements, prestige };
// Echoes are only granted server-side via transcendence and can only decrease between
// saves (spent on echo upgrades). Cap at the previous value to block inflation.
const cappedEchoes = Math.min(
incoming.transcendence?.echoes ?? 0,
previous.transcendence?.echoes ?? 0,
);
const transcendenceSpread = incoming.transcendence
? { transcendence: { ...incoming.transcendence, echoes: cappedEchoes } }
: previous.transcendence
? { transcendence: previous.transcendence }
: {};
return { ...incoming, resources, bosses, quests, achievements, prestige, ...transcendenceSpread };
};
export const gameRouter = new Hono<HonoEnv>();
+140
View File
@@ -0,0 +1,140 @@
import type { BuyEchoUpgradeRequest, GameState, TranscendenceRequest } from "@elysium/types";
import { Hono } from "hono";
import type { HonoEnv } from "../types/hono.js";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
import { DEFAULT_TRANSCENDENCE_UPGRADES } from "../data/transcendenceUpgrades.js";
import {
buildPostTranscendenceState,
computeTranscendenceMultipliers,
isEligibleForTranscendence,
} from "../services/transcendence.js";
export const transcendenceRouter = new Hono<HonoEnv>();
transcendenceRouter.use("*", authMiddleware);
transcendenceRouter.post("/", async (context) => {
const discordId = context.get("discordId") as string;
const body = await context.req.json<TranscendenceRequest>();
const characterName = body.characterName?.trim();
if (!characterName) {
return context.json({ error: "characterName is required" }, 400);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const state = record.state as unknown as GameState;
if (!isEligibleForTranscendence(state)) {
return context.json(
{ error: "Not eligible for transcendence — defeat The Absolute One first" },
400,
);
}
const { newState, newTranscendenceData, echoesEarned } = buildPostTranscendenceState(
state,
characterName,
);
// Capture current-run stats before the nuclear reset
const runBossesDefeated = state.bosses.filter((b) => b.status === "defeated").length;
const runQuestsCompleted = state.quests.filter((q) => q.status === "completed").length;
const runAdventurersRecruited = state.adventurers.reduce((sum, a) => sum + a.count, 0);
const runAchievementsUnlocked = (state.achievements ?? []).filter((a) => a.unlockedAt !== null).length;
const now = Date.now();
await prisma.gameState.update({
where: { discordId },
data: { state: newState as object, updatedAt: now },
});
await prisma.player.update({
where: { discordId },
data: {
characterName,
// Reset current-run counters (same as prestige)
totalGoldEarned: 0,
totalClicks: 0,
// Accumulate into lifetime totals
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
lifetimeClicks: { increment: state.player.totalClicks },
lifetimeBossesDefeated: { increment: runBossesDefeated },
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
lastSavedAt: now,
},
});
return context.json({
echoes: echoesEarned,
newTranscendenceCount: newTranscendenceData.count,
});
});
transcendenceRouter.post("/buy-upgrade", async (context) => {
const discordId = context.get("discordId") as string;
const body = await context.req.json<BuyEchoUpgradeRequest>();
const { upgradeId } = body;
if (!upgradeId) {
return context.json({ error: "upgradeId is required" }, 400);
}
const upgrade = DEFAULT_TRANSCENDENCE_UPGRADES.find((u) => u.id === upgradeId);
if (!upgrade) {
return context.json({ error: "Unknown echo upgrade" }, 404);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const state = record.state as unknown as GameState;
if (!state.transcendence) {
return context.json({ error: "No transcendence data found" }, 400);
}
const { purchasedUpgradeIds, echoes } = state.transcendence;
if (purchasedUpgradeIds.includes(upgradeId)) {
return context.json({ error: "Upgrade already purchased" }, 400);
}
if (echoes < upgrade.cost) {
return context.json({ error: "Not enough echoes" }, 400);
}
const newEchoes = echoes - upgrade.cost;
const newPurchasedIds = [...purchasedUpgradeIds, upgradeId];
const newMultipliers = computeTranscendenceMultipliers(newPurchasedIds);
const newState: GameState = {
...state,
transcendence: {
...state.transcendence,
echoes: newEchoes,
purchasedUpgradeIds: newPurchasedIds,
...newMultipliers,
},
};
await prisma.gameState.update({
where: { discordId },
data: { state: newState as object, updatedAt: Date.now() },
});
return context.json({
echoesRemaining: newEchoes,
purchasedUpgradeIds: newPurchasedIds,
...newMultipliers,
});
});
+12 -5
View File
@@ -16,11 +16,13 @@ const MILESTONE_RUNESTONES_PER_INTERVAL = 25;
* Calculates the gold threshold required for the next prestige.
* Formula: BASE * SCALE_FACTOR^prestigeCount — each prestige makes the next threshold harder.
*/
export const calculatePrestigeThreshold = (prestigeCount: number): number =>
BASE_PRESTIGE_GOLD_THRESHOLD * Math.pow(THRESHOLD_SCALE_FACTOR, prestigeCount);
export const calculatePrestigeThreshold = (prestigeCount: number, thresholdMultiplier = 1): number =>
BASE_PRESTIGE_GOLD_THRESHOLD * Math.pow(THRESHOLD_SCALE_FACTOR, prestigeCount) * thresholdMultiplier;
export const isEligibleForPrestige = (state: GameState): boolean =>
state.player.totalGoldEarned >= calculatePrestigeThreshold(state.prestige.count);
export const isEligibleForPrestige = (state: GameState): boolean => {
const thresholdMultiplier = state.transcendence?.echoPrestigeThresholdMultiplier ?? 1;
return state.player.totalGoldEarned >= calculatePrestigeThreshold(state.prestige.count, thresholdMultiplier);
};
const getCategoryMultiplier = (
purchasedUpgradeIds: string[],
@@ -52,12 +54,13 @@ export const calculateRunestones = (
totalGoldEarned: number,
prestigeCount: number,
purchasedUpgradeIds: string[],
echoRunestoneMultiplier = 1,
): number => {
const threshold = calculatePrestigeThreshold(prestigeCount);
const base =
Math.floor(Math.sqrt(totalGoldEarned / threshold)) * RUNESTONES_PER_PRESTIGE_LEVEL;
const runestoneMult = getCategoryMultiplier(purchasedUpgradeIds, "runestones");
return Math.floor(base * runestoneMult);
return Math.floor(base * runestoneMult * echoRunestoneMultiplier);
};
/**
@@ -85,10 +88,12 @@ export const buildPostPrestigeState = (
currentState: GameState,
characterName: string,
): { newState: GameState; newPrestigeData: PrestigeData; runestonesEarned: number; milestoneRunestones: number } => {
const echoRunestoneMultiplier = currentState.transcendence?.echoPrestigeRunestoneMultiplier ?? 1;
const runestonesEarned = calculateRunestones(
currentState.player.totalGoldEarned,
currentState.prestige.count,
currentState.prestige.purchasedUpgradeIds,
echoRunestoneMultiplier,
);
const newPrestigeCount = currentState.prestige.count + 1;
const { purchasedUpgradeIds } = currentState.prestige;
@@ -113,6 +118,8 @@ export const buildPostPrestigeState = (
lastTickAt: Date.now(),
// Codex lore persists across prestiges — players keep their discovered entries
...(currentState.codex ? { codex: currentState.codex } : {}),
// Transcendence data is permanent — never wiped by prestige
...(currentState.transcendence ? { transcendence: currentState.transcendence } : {}),
};
return { newState, newPrestigeData, runestonesEarned, milestoneRunestones };
+84
View File
@@ -0,0 +1,84 @@
import type { GameState, TranscendenceData, TranscendenceUpgradeCategory } from "@elysium/types";
import { INITIAL_GAME_STATE } from "../data/initialState.js";
import { DEFAULT_TRANSCENDENCE_UPGRADES } from "../data/transcendenceUpgrades.js";
/** ID of the boss that must be defeated to unlock transcendence */
const FINAL_BOSS_ID = "the_absolute_one";
/** Base constant used in the echo yield formula */
const ECHO_FORMULA_CONSTANT = 853;
const getCategoryMultiplier = (
purchasedIds: string[],
category: TranscendenceUpgradeCategory,
): number =>
DEFAULT_TRANSCENDENCE_UPGRADES
.filter((u) => u.category === category && purchasedIds.includes(u.id))
.reduce((mult, u) => mult * u.multiplier, 1);
export const computeTranscendenceMultipliers = (
purchasedUpgradeIds: string[],
): Omit<TranscendenceData, "count" | "echoes" | "purchasedUpgradeIds"> => ({
echoIncomeMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "income"),
echoCombatMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "combat"),
echoPrestigeThresholdMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "prestige_threshold"),
echoPrestigeRunestoneMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "prestige_runestones"),
echoMetaMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "echo_meta"),
});
/**
* Returns true when the player is eligible to transcend:
* they must have defeated the final boss at least once.
*/
export const isEligibleForTranscendence = (state: GameState): boolean =>
state.bosses.some((b) => b.id === FINAL_BOSS_ID && b.status === "defeated");
/**
* Calculates echo yield for a transcendence.
* Formula: floor(CONSTANT / sqrt(prestigeCount)) × echoMetaMultiplier
* Fewer prestiges = more echoes (rewards efficient play).
* Minimum prestige count of 1 is enforced to avoid division by zero.
*/
export const calculateEchoes = (
prestigeCount: number,
echoMetaMultiplier: number,
): number => {
const safeCount = Math.max(prestigeCount, 1);
return Math.floor((ECHO_FORMULA_CONSTANT / Math.sqrt(safeCount)) * echoMetaMultiplier);
};
/**
* Builds the new game state after a transcendence (nuclear reset).
* Wipes everything except codex, dailyChallenges, and transcendence data.
*/
export const buildPostTranscendenceState = (
currentState: GameState,
characterName: string,
): { newState: GameState; newTranscendenceData: TranscendenceData; echoesEarned: number } => {
const previousTranscendence = currentState.transcendence;
const echoMetaMultiplier = previousTranscendence?.echoMetaMultiplier ?? 1;
const echoesEarned = calculateEchoes(currentState.prestige.count, echoMetaMultiplier);
const previousEchoes = previousTranscendence?.echoes ?? 0;
const newCount = (previousTranscendence?.count ?? 0) + 1;
const newPurchasedIds = previousTranscendence?.purchasedUpgradeIds ?? [];
const newTranscendenceData: TranscendenceData = {
count: newCount,
echoes: previousEchoes + echoesEarned,
purchasedUpgradeIds: newPurchasedIds,
...computeTranscendenceMultipliers(newPurchasedIds),
};
const freshState = INITIAL_GAME_STATE(currentState.player, characterName);
const newState: GameState = {
...freshState,
lastTickAt: Date.now(),
// Codex lore persists through all resets
...(currentState.codex ? { codex: currentState.codex } : {}),
// Transcendence data is permanent
transcendence: newTranscendenceData,
};
return { newState, newTranscendenceData, echoesEarned };
};
+18
View File
@@ -3,6 +3,8 @@ import type {
AuthResponse,
BossChallengeRequest,
BossChallengeResponse,
BuyEchoUpgradeRequest,
BuyEchoUpgradeResponse,
BuyPrestigeUpgradeRequest,
BuyPrestigeUpgradeResponse,
LoadResponse,
@@ -11,6 +13,8 @@ import type {
PublicProfileResponse,
SaveRequest,
SaveResponse,
TranscendenceRequest,
TranscendenceResponse,
UpdateProfileRequest,
UpdateProfileResponse,
} from "@elysium/types";
@@ -91,6 +95,20 @@ export const buyPrestigeUpgrade = async (
body: JSON.stringify(body),
});
export const transcend = async (body: TranscendenceRequest): Promise<TranscendenceResponse> =>
request<TranscendenceResponse>("/transcendence", {
method: "POST",
body: JSON.stringify(body),
});
export const buyEchoUpgrade = async (
body: BuyEchoUpgradeRequest,
): Promise<BuyEchoUpgradeResponse> =>
request<BuyEchoUpgradeResponse>("/transcendence/buy-upgrade", {
method: "POST",
body: JSON.stringify(body),
});
export const getPublicProfile = async (
discordId: string,
): Promise<PublicProfileResponse> =>
@@ -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 =>
+5 -1
View File
@@ -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}
+38
View File
@@ -8,11 +8,13 @@ import {
useState,
} from "react";
import {
buyEchoUpgrade as buyEchoUpgradeApi,
buyPrestigeUpgrade as buyPrestigeUpgradeApi,
challengeBoss as challengeBossApi,
loadGame,
prestige as prestigeApi,
saveGame,
transcend as transcendApi,
} from "../api/client.js";
import { CODEX_ENTRIES } from "../data/codex.js";
import { RESOURCE_CAP, applyTick, calculateClickPower } from "../engine/tick.js";
@@ -81,6 +83,10 @@ interface GameContextValue {
newCodexEntryIds: string[];
/** Remove a codex entry ID from the notification queue */
dismissCodexEntry: (id: string) => void;
/** Perform a transcendence — nuclear reset, earning echoes */
transcend: (characterName: string) => Promise<{ echoes: number; newTranscendenceCount: number }>;
/** Buy an echo upgrade from the transcendence shop */
buyEchoUpgrade: (upgradeId: string) => Promise<void>;
}
const GameContext = createContext<GameContextValue | null>(null);
@@ -531,6 +537,36 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
}
}, []);
const transcend = useCallback(async (characterName: string) => {
const result = await transcendApi({ characterName });
await reload();
return result;
}, [reload]);
const buyEchoUpgrade = useCallback(async (upgradeId: string) => {
try {
const result = await buyEchoUpgradeApi({ upgradeId });
setState((prev) => {
if (!prev || !prev.transcendence) return prev;
return {
...prev,
transcendence: {
...prev.transcendence,
echoes: result.echoesRemaining,
purchasedUpgradeIds: result.purchasedUpgradeIds,
echoIncomeMultiplier: result.echoIncomeMultiplier,
echoCombatMultiplier: result.echoCombatMultiplier,
echoPrestigeThresholdMultiplier: result.echoPrestigeThresholdMultiplier,
echoPrestigeRunestoneMultiplier: result.echoPrestigeRunestoneMultiplier,
echoMetaMultiplier: result.echoMetaMultiplier,
},
};
});
} catch {
// Silently ignore server errors
}
}, []);
const toggleAutoPrestige = useCallback(() => {
setState((prev) => {
if (!prev) return prev;
@@ -713,6 +749,8 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
toggleAutoPrestige,
newCodexEntryIds,
dismissCodexEntry,
transcend,
buyEchoUpgrade,
}}
>
{children}
+142
View File
@@ -0,0 +1,142 @@
import type { TranscendenceUpgrade } from "@elysium/types";
export const DEFAULT_TRANSCENDENCE_UPGRADES: TranscendenceUpgrade[] = [
// ── Income multipliers ──────────────────────────────────────────────────────
{
id: "echo_income_1",
name: "Whisper of Power",
description: "The echoes of past runs linger, amplifying your guild's income by 25%.",
category: "income",
cost: 5,
multiplier: 1.25,
},
{
id: "echo_income_2",
name: "Resonance",
description: "Your transcendent experience resonates through your guild, boosting income by 50%.",
category: "income",
cost: 10,
multiplier: 1.5,
},
{
id: "echo_income_3",
name: "Harmonic Surge",
description: "The harmony of multiple timelines surges through your guild, doubling its income.",
category: "income",
cost: 20,
multiplier: 2.0,
},
{
id: "echo_income_4",
name: "Ethereal Overflow",
description: "Ethereal energy overflows from your transcendence, tripling your guild's income.",
category: "income",
cost: 40,
multiplier: 3.0,
},
{
id: "echo_income_5",
name: "Infinite Chorus",
description: "The infinite chorus of every run you've ever played amplifies your guild fivefold.",
category: "income",
cost: 80,
multiplier: 5.0,
},
// ── Combat multipliers ──────────────────────────────────────────────────────
{
id: "echo_combat_1",
name: "Battle-Hardened",
description: "Memories of countless battles harden your adventurers, increasing party DPS by 25%.",
category: "combat",
cost: 5,
multiplier: 1.25,
},
{
id: "echo_combat_2",
name: "Veteran's Edge",
description: "Veterans of transcendence know how to fight smarter, boosting party DPS by 50%.",
category: "combat",
cost: 15,
multiplier: 1.5,
},
{
id: "echo_combat_3",
name: "Transcendent Warrior",
description: "Your warriors carry the strength of every fallen timeline, doubling party DPS.",
category: "combat",
cost: 35,
multiplier: 2.0,
},
// ── Prestige threshold reductions ──────────────────────────────────────────
{
id: "echo_prestige_threshold_1",
name: "Accelerated Path",
description: "Experience from past lives shortens the road to prestige — threshold reduced by 10%.",
category: "prestige_threshold",
cost: 8,
multiplier: 0.9,
},
{
id: "echo_prestige_threshold_2",
name: "Shortcut Through Time",
description: "You've walked this path so many times you know every shortcut — threshold reduced by 20%.",
category: "prestige_threshold",
cost: 20,
multiplier: 0.8,
},
// ── Prestige runestone multipliers ─────────────────────────────────────────
{
id: "echo_prestige_runestones_1",
name: "Runic Attunement",
description: "Transcendent insight attunes you to the runestones, earning 50% more per prestige.",
category: "prestige_runestones",
cost: 8,
multiplier: 1.5,
},
{
id: "echo_prestige_runestones_2",
name: "Master Runesmith",
description: "You have mastered the art of runestone crafting, doubling your prestige runestone yield.",
category: "prestige_runestones",
cost: 20,
multiplier: 2.0,
},
// ── Echo meta multipliers ───────────────────────────────────────────────────
{
id: "echo_meta_1",
name: "Resonant Awakening",
description: "Your transcendence resonates deeper, amplifying future echo yields by 25%.",
category: "echo_meta",
cost: 10,
multiplier: 1.25,
},
{
id: "echo_meta_2",
name: "Transcendent Loop",
description: "Each loop of existence makes the next more powerful — future echo yields +50%.",
category: "echo_meta",
cost: 25,
multiplier: 1.5,
},
{
id: "echo_meta_3",
name: "Infinite Spiral",
description: "You have mastered the infinite spiral of transcendence, doubling all future echo yields.",
category: "echo_meta",
cost: 50,
multiplier: 2.0,
},
];
export const TRANSCENDENCE_UPGRADES = DEFAULT_TRANSCENDENCE_UPGRADES;
export const TRANSCENDENCE_UPGRADE_CATEGORY_LABELS: Record<string, string> = {
income: "✨ Income Multipliers",
combat: "⚔️ Combat Multipliers",
prestige_threshold: "🎯 Prestige Quality of Life — Threshold",
prestige_runestones: "🔮 Prestige Quality of Life — Runestones",
echo_meta: "🌌 Echo Meta Upgrades",
};
+4
View File
@@ -64,6 +64,7 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1;
const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1;
const runestonesCrystal = state.prestige.runestonesCrystalMultiplier ?? 1;
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
let goldGained = 0;
let essenceGained = 0;
@@ -90,6 +91,7 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
upgradeMultiplier *
prestige *
runestonesIncome *
echoIncome *
equipmentGoldMultiplier *
setGoldMultiplier *
deltaSeconds;
@@ -282,12 +284,14 @@ export const calculateClickPower = (state: GameState): number => {
const setClickMultiplier = computeSetBonuses(equippedItems.map((e) => e.id), EQUIPMENT_SETS).clickMultiplier;
const runestonesClick = state.prestige.runestonesClickMultiplier ?? 1;
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
return (
state.baseClickPower *
clickMultiplier *
state.prestige.productionMultiplier *
runestonesClick *
echoIncome *
equipmentClickMultiplier *
setClickMultiplier
);
+121
View File
@@ -2320,3 +2320,124 @@ body {
text-align: center;
vertical-align: middle;
}
/* ── Transcendence ─────────────────────────────────────────────────────── */
.transcendence-badge {
background: linear-gradient(135deg, #4c1d95, #7c3aed);
border-radius: 999px;
font-size: 0.85rem;
font-weight: 600;
padding: 0.25rem 0.75rem;
}
.transcendence-panel .transcendence-intro {
color: var(--colour-text-muted);
font-size: 0.95rem;
margin-bottom: 0.75rem;
}
.transcendence-status {
background: var(--colour-surface);
border: 1px solid #7c3aed;
border-radius: var(--radius);
margin: 1rem 0;
padding: 1rem;
}
.transcendence-status p {
margin: 0.25rem 0;
}
.echo-preview {
color: #a78bfa;
font-weight: 600;
margin-top: 0.5rem !important;
}
.echo-meta-bonus {
color: var(--colour-text-muted);
font-size: 0.85rem;
font-weight: 400;
}
.transcendence-locked {
background: rgba(124, 58, 237, 0.1);
border: 1px solid #7c3aed;
border-radius: var(--radius);
padding: 1rem;
text-align: center;
}
.transcendence-hint {
color: var(--colour-text-muted);
font-size: 0.85rem;
margin-top: 0.5rem;
}
.transcendence-button {
background: linear-gradient(135deg, #4c1d95, #7c3aed);
border: none;
border-radius: var(--radius);
color: #fff;
cursor: pointer;
font-size: 1rem;
font-weight: 700;
margin-top: 0.5rem;
padding: 0.75rem 2rem;
transition: opacity 0.2s;
width: 100%;
}
.transcendence-button:hover:not(:disabled) {
opacity: 0.85;
}
.transcendence-button:disabled {
cursor: not-allowed;
opacity: 0.4;
}
.echo-shop {
display: flex;
flex-direction: column;
gap: 1rem;
}
.echo-shop-description {
color: var(--colour-text-muted);
font-size: 0.9rem;
margin: 0;
}
.echo-upgrade-card {
border-color: #7c3aed !important;
}
.echo-upgrade-card.purchased {
border-color: #6d28d9 !important;
opacity: 0.7;
}
.echo-buy-button {
background: linear-gradient(135deg, #4c1d95, #7c3aed);
border: none;
border-radius: var(--radius);
color: #fff;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
padding: 0.5rem 1rem;
transition: opacity 0.2s;
white-space: nowrap;
}
.echo-buy-button:hover:not(:disabled) {
opacity: 0.85;
}
.echo-buy-button:disabled {
cursor: not-allowed;
opacity: 0.4;
}
+9
View File
@@ -12,6 +12,8 @@ export type {
AuthResponse,
BossChallengeRequest,
BossChallengeResponse,
BuyEchoUpgradeRequest,
BuyEchoUpgradeResponse,
BuyPrestigeUpgradeRequest,
BuyPrestigeUpgradeResponse,
GiteaRelease,
@@ -21,6 +23,8 @@ export type {
PublicProfileResponse,
SaveRequest,
SaveResponse,
TranscendenceRequest,
TranscendenceResponse,
UpdateProfileRequest,
UpdateProfileResponse,
} from "./interfaces/Api.js";
@@ -59,3 +63,8 @@ export type {
export type { Zone, ZoneStatus } from "./interfaces/Zone.js";
export type { NumberFormat, ProfileSettings } from "./interfaces/ProfileSettings.js";
export { DEFAULT_PROFILE_SETTINGS } from "./interfaces/ProfileSettings.js";
export type {
TranscendenceData,
TranscendenceUpgrade,
TranscendenceUpgradeCategory,
} from "./interfaces/Transcendence.js";
+23
View File
@@ -127,6 +127,29 @@ export interface UpdateProfileResponse {
profileSettings: ProfileSettings;
}
export interface TranscendenceRequest {
characterName: string;
}
export interface TranscendenceResponse {
echoes: number;
newTranscendenceCount: number;
}
export interface BuyEchoUpgradeRequest {
upgradeId: string;
}
export interface BuyEchoUpgradeResponse {
echoesRemaining: number;
purchasedUpgradeIds: string[];
echoIncomeMultiplier: number;
echoCombatMultiplier: number;
echoPrestigeThresholdMultiplier: number;
echoPrestigeRunestoneMultiplier: number;
echoMetaMultiplier: number;
}
export interface ApiError {
error: string;
}
@@ -3,6 +3,7 @@ import type { Adventurer } from "./Adventurer.js";
import type { Boss } from "./Boss.js";
import type { CodexState } from "./Codex.js";
import type { DailyChallengeState } from "./DailyChallenge.js";
import type { TranscendenceData } from "./Transcendence.js";
import type { Equipment } from "./Equipment.js";
import type { Player } from "./Player.js";
import type { PrestigeData } from "./Prestige.js";
@@ -30,4 +31,6 @@ export interface GameState {
dailyChallenges?: DailyChallengeState;
/** Lore codex unlock state — optional for backwards compatibility with old saves */
codex?: CodexState;
/** Transcendence (second prestige layer) state — optional for backwards compatibility */
transcendence?: TranscendenceData;
}
@@ -0,0 +1,36 @@
export type TranscendenceUpgradeCategory =
| "income"
| "combat"
| "prestige_threshold"
| "prestige_runestones"
| "echo_meta";
export interface TranscendenceUpgrade {
id: string;
name: string;
description: string;
category: TranscendenceUpgradeCategory;
/** Echo cost to purchase */
cost: number;
/** Multiplicative effect of this upgrade */
multiplier: number;
}
export interface TranscendenceData {
/** Number of times the player has transcended */
count: number;
/** Echoes accumulated across all transcendences */
echoes: number;
/** IDs of echo upgrades purchased with echoes */
purchasedUpgradeIds: string[];
/** Pre-computed: multiplier applied to all passive gold income */
echoIncomeMultiplier: number;
/** Pre-computed: multiplier applied to party DPS in boss fights */
echoCombatMultiplier: number;
/** Pre-computed: multiplier applied to the prestige gold threshold (< 1 lowers requirement) */
echoPrestigeThresholdMultiplier: number;
/** Pre-computed: multiplier applied to runestones earned per prestige */
echoPrestigeRunestoneMultiplier: number;
/** Pre-computed: multiplier applied to echo yield on future transcendences */
echoMetaMultiplier: number;
}