diff --git a/IDEAS.md b/IDEAS.md index 236834c..aa2af82 100644 --- a/IDEAS.md +++ b/IDEAS.md @@ -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 ~8–12 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) diff --git a/apps/api/src/data/transcendenceUpgrades.ts b/apps/api/src/data/transcendenceUpgrades.ts new file mode 100644 index 0000000..257bed1 --- /dev/null +++ b/apps/api/src/data/transcendenceUpgrades.ts @@ -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, + }, +]; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 8005ed5..06c3d09 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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" })); diff --git a/apps/api/src/routes/boss.ts b/apps/api/src/routes/boss.ts index 7759c12..4ef7872 100644 --- a/apps/api/src/routes/boss.ts +++ b/apps/api/src/routes/boss.ts @@ -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 }; }; diff --git a/apps/api/src/routes/game.ts b/apps/api/src/routes/game.ts index a92f64b..25f9d6c 100644 --- a/apps/api/src/routes/game.ts +++ b/apps/api/src/routes/game.ts @@ -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(); diff --git a/apps/api/src/routes/transcendence.ts b/apps/api/src/routes/transcendence.ts new file mode 100644 index 0000000..b14850e --- /dev/null +++ b/apps/api/src/routes/transcendence.ts @@ -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(); + +transcendenceRouter.use("*", authMiddleware); + +transcendenceRouter.post("/", async (context) => { + const discordId = context.get("discordId") as string; + const body = await context.req.json(); + + 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(); + + 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, + }); +}); diff --git a/apps/api/src/services/prestige.ts b/apps/api/src/services/prestige.ts index 048613b..f170400 100644 --- a/apps/api/src/services/prestige.ts +++ b/apps/api/src/services/prestige.ts @@ -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 }; diff --git a/apps/api/src/services/transcendence.ts b/apps/api/src/services/transcendence.ts new file mode 100644 index 0000000..cd36f49 --- /dev/null +++ b/apps/api/src/services/transcendence.ts @@ -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 => ({ + 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 }; +}; diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 260dbb3..93b3514 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -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 => + request("/transcendence", { + method: "POST", + body: JSON.stringify(body), + }); + +export const buyEchoUpgrade = async ( + body: BuyEchoUpgradeRequest, +): Promise => + request("/transcendence/buy-upgrade", { + method: "POST", + body: JSON.stringify(body), + }); + export const getPublicProfile = async ( discordId: string, ): Promise => diff --git a/apps/web/src/components/game/AboutPanel.tsx b/apps/web/src/components/game/AboutPanel.tsx index db7a0a9..468cc84 100644 --- a/apps/web/src/components/game/AboutPanel.tsx +++ b/apps/web/src/components/game/AboutPanel.tsx @@ -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 => diff --git a/apps/web/src/components/game/GameLayout.tsx b/apps/web/src/components/game/GameLayout.tsx index 5924b89..ced4a59 100644 --- a/apps/web/src/components/game/GameLayout.tsx +++ b/apps/web/src/components/game/GameLayout.tsx @@ -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" && } {activeTab === "achievements" && } {activeTab === "prestige" && } + {activeTab === "transcendence" && } {activeTab === "statistics" && } {activeTab === "daily" && } {activeTab === "codex" && } diff --git a/apps/web/src/components/game/TranscendencePanel.tsx b/apps/web/src/components/game/TranscendencePanel.tsx new file mode 100644 index 0000000..b779263 --- /dev/null +++ b/apps/web/src/components/game/TranscendencePanel.tsx @@ -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(null); + const [buyingId, setBuyingId] = useState(null); + const [activeTab, setActiveTab] = useState<"transcend" | "shop">("transcend"); + + if (!state) return

Loading...

; + + 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 => { + 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 => { + 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 ( +
+

🌌 Transcendence

+ +
+ + +
+ + {activeTab === "transcend" && ( + <> +

+ Transcendence is the ultimate reset. It wipes{" "} + everything — resources, prestige, runestones, upgrades, + and equipment — but grants Echoes, a permanent currency + that survives all future resets. Echoes power upgrades that permanently + amplify every run from this point forward. +

+

+ + Fewer prestiges = more Echoes. Optimise your run for maximum yield! + +

+ +
+ {transcendenceCount > 0 && ( +

Transcendence count: {transcendenceCount}

+ )} +

Current Echoes: {formatNumber(currentEchoes)}

+

Current prestige count: {prestigeData.count}

+ {hasDefeatedFinalBoss && ( +

+ Echoes on transcendence: +{formatNumber(echoPreview)} + {echoMetaMultiplier > 1 && ( + + {" "}(×{echoMetaMultiplier.toFixed(2)} meta bonus applied) + + )} +

+ )} +
+ + {!hasDefeatedFinalBoss && ( +
+

🔒 Defeat The Absolute One to unlock transcendence.

+

+ The Absolute One is the final boss of The Absolute zone, requiring + Prestige 90 to challenge. +

+
+ )} + + {hasDefeatedFinalBoss && ( +
+

You are ready to transcend. This action is irreversible.

+

Choose your new character name for the next cycle:

+ { setCharacterName(e.target.value); }} + placeholder="Character name..." + type="text" + value={characterName} + /> + + {error &&

{error}

} + {result && ( +

+ Transcended! Earned{" "} + {formatNumber(result.echoes)} Echoes. This is + Transcendence {result.count}. A new cycle begins. +

+ )} +
+ )} + + )} + + {activeTab === "shop" && ( +
+

+ Balance: {formatNumber(currentEchoes)} Echoes +

+

+ Echo upgrades are permanent — they survive all future + prestiges and transcendences. +

+ + {upgradesByCategory.map(({ category, label, upgrades }) => ( +
+

{label}

+
+ {upgrades.map((upgrade) => { + const purchased = (transcendence?.purchasedUpgradeIds ?? []).includes(upgrade.id); + const canAfford = currentEchoes >= upgrade.cost; + const isLoading = buyingId === upgrade.id; + + return ( +
+
+

{upgrade.name}

+

{upgrade.description}

+

+ {purchased + ? "✅ Purchased" + : `✨ ${formatNumber(upgrade.cost)} Echoes`} +

+
+ {!purchased && ( + + )} +
+ ); + })} +
+
+ ))} +
+ )} +
+ ); +}; diff --git a/apps/web/src/components/ui/ResourceBar.tsx b/apps/web/src/components/ui/ResourceBar.tsx index f305b70..0fb56ab 100644 --- a/apps/web/src/components/ui/ResourceBar.tsx +++ b/apps/web/src/components/ui/ResourceBar.tsx @@ -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 = ({ {formatNumber(runestones)} Runestones + {transcendenceCount > 0 && ( +
+ 🌌 Transcendence {transcendenceCount} +
+ )} {prestigeCount > 0 && (
⭐ Prestige {prestigeCount} diff --git a/apps/web/src/context/GameContext.tsx b/apps/web/src/context/GameContext.tsx index ebcddc5..e506821 100644 --- a/apps/web/src/context/GameContext.tsx +++ b/apps/web/src/context/GameContext.tsx @@ -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; } const GameContext = createContext(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} diff --git a/apps/web/src/data/transcendenceUpgrades.ts b/apps/web/src/data/transcendenceUpgrades.ts new file mode 100644 index 0000000..dcf35bd --- /dev/null +++ b/apps/web/src/data/transcendenceUpgrades.ts @@ -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 = { + 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", +}; diff --git a/apps/web/src/engine/tick.ts b/apps/web/src/engine/tick.ts index ead2aa1..6e2823e 100644 --- a/apps/web/src/engine/tick.ts +++ b/apps/web/src/engine/tick.ts @@ -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 ); diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 619520b..30770b3 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -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; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index c3e4436..36cdae7 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -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"; diff --git a/packages/types/src/interfaces/Api.ts b/packages/types/src/interfaces/Api.ts index 1a27397..b4bce79 100644 --- a/packages/types/src/interfaces/Api.ts +++ b/packages/types/src/interfaces/Api.ts @@ -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; } diff --git a/packages/types/src/interfaces/GameState.ts b/packages/types/src/interfaces/GameState.ts index f1e2539..f0de0b0 100644 --- a/packages/types/src/interfaces/GameState.ts +++ b/packages/types/src/interfaces/GameState.ts @@ -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; } diff --git a/packages/types/src/interfaces/Transcendence.ts b/packages/types/src/interfaces/Transcendence.ts new file mode 100644 index 0000000..e205859 --- /dev/null +++ b/packages/types/src/interfaces/Transcendence.ts @@ -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; +}