feat: v1 prototype — core game systems #30

Merged
naomi merged 84 commits from feat/prototype into main 2026-03-08 15:53:39 -07:00
21 changed files with 1022 additions and 10 deletions
Showing only changes of commit e8881a81d5 - Show all commits
+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;
}