generated from nhcarrigan/template
feat: add transcendence second prestige layer
Implements the full Transcendence system — the ultimate endgame mechanic, unlocked by defeating The Absolute One (requires Prestige 90). Nuclear reset model: wipes resources, prestige, runestones, upgrades, equipment, bosses, quests, zones, and achievements. Codex entries and lifetime profile stats are preserved. Transcendence data is permanent and accumulates across all future resets. Echo formula: floor(853 / sqrt(prestigeCount)) × echoMetaMultiplier Fewer prestiges = more Echoes, rewarding optimised play. 15 Echo upgrades across 5 categories: - Income multipliers (×1.25 → ×5): 5 tiers, cost 5–80 echoes - Combat multipliers (×1.25 → ×2): 3 tiers, cost 5–35 echoes - Prestige threshold reductions (×0.9, ×0.8): cost 8–20 echoes - Prestige runestone multipliers (×1.5, ×2): cost 8–20 echoes - Echo meta multipliers (×1.25 → ×2): cost 10–50 echoes New files: Transcendence.ts types, transcendence service, route, data files (API + web), TranscendencePanel.tsx component. Modified: GameState, Api, types/index, prestige service (carries transcendence through resets, applies echo multipliers), boss route (echoCombatMultiplier), game.ts anti-cheat (echo cap), tick.ts (echoIncomeMultiplier), GameContext, API client, GameLayout (new tab), ResourceBar (transcendence badge alongside prestige badge), styles.css, AboutPanel, IDEAS.md.
This commit is contained in:
@@ -59,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 };
|
||||
};
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user