/** * @file Transcendence routes handling transcendence resets and echo upgrade purchases. * @copyright nhcarrigan * @license Naomi's Public License * @author Naomi Carrigan */ /* eslint-disable max-lines-per-function -- Route handlers require many steps */ /* eslint-disable max-statements -- Route handlers require many statements */ import { Hono } from "hono"; import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js"; import { prisma } from "../db/client.js"; import { authMiddleware } from "../middleware/auth.js"; import { logger } from "../services/logger.js"; import { buildPostTranscendenceState, computeTranscendenceMultipliers, isEligibleForTranscendence, } from "../services/transcendence.js"; import { postMilestoneWebhook } from "../services/webhook.js"; import type { HonoEnvironment } from "../types/hono.js"; import type { BuyEchoUpgradeRequest, GameState } from "@elysium/types"; const transcendenceRouter = new Hono(); transcendenceRouter.use("*", authMiddleware); transcendenceRouter.post("/", async(context) => { try { const discordId = context.get("discordId"); const record = await prisma.gameState.findUnique({ where: { discordId } }); if (!record) { return context.json({ error: "No save found" }, 404); } /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */ const state = record.state as unknown as GameState; if (!isEligibleForTranscendence(state)) { return context.json( { // eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened error: "Not eligible for transcendence — defeat The Absolute One first", }, 400, ); } const { echoesEarned, transcendenceData, transcendenceState, } = buildPostTranscendenceState(state, state.player.characterName); // Capture current-run stats before the nuclear reset const runBossesDefeated = state.bosses.filter((boss) => { return boss.status === "defeated"; }).length; // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 7 -- @preserve */ const runQuestsCompleted = state.quests.filter((quest) => { return quest.status === "completed"; }).length; let runAdventurersRecruited = 0; for (const adventurer of state.adventurers) { runAdventurersRecruited = runAdventurersRecruited + adventurer.count; } // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 3 -- @preserve */ const runAchievementsUnlocked = state.achievements.filter((achievement) => { return achievement.unlockedAt !== null; }).length; const now = Date.now(); await prisma.gameState.update({ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ data: { state: transcendenceState as object, updatedAt: now }, where: { discordId }, }); await prisma.player.update({ data: { characterName: state.player.characterName, lastSavedAt: now, lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked }, lifetimeAdventurersRecruited: { increment: runAdventurersRecruited }, lifetimeBossesDefeated: { increment: runBossesDefeated }, lifetimeClicks: { increment: state.player.totalClicks }, // Accumulate into lifetime totals lifetimeGoldEarned: { increment: state.player.totalGoldEarned }, lifetimeQuestsCompleted: { increment: runQuestsCompleted }, totalClicks: 0, // Reset current-run counters (same as prestige) totalGoldEarned: 0, }, where: { discordId }, }); const transcendenceCount = transcendenceData.count; void logger.metric("transcendence", 1, { discordId, transcendenceCount }); void postMilestoneWebhook(discordId, "transcendence", { // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ apotheosis: transcendenceState.apotheosis?.count ?? 0, prestige: transcendenceState.prestige.count, transcendence: transcendenceData.count, }); return context.json({ echoes: echoesEarned, // eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client newTranscendenceCount: transcendenceData.count, }); } catch (error) { void logger.error( "transcendence", error instanceof Error ? error : new Error(String(error)), ); return context.json({ error: "Internal server error" }, 500); } }); transcendenceRouter.post("/buy-upgrade", async(context) => { try { const discordId = context.get("discordId"); const body = await context.req.json(); const { upgradeId } = body; // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation if (!upgradeId) { return context.json({ error: "upgradeId is required" }, 400); } // eslint-disable-next-line stylistic/max-len -- Variable name mirrors the data source for clarity const upgrade = defaultTranscendenceUpgrades.find((transcendenceUpgrade) => { return transcendenceUpgrade.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); } /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */ 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 updatedEchoes = echoes - upgrade.cost; const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ]; const updatedMultipliers = computeTranscendenceMultipliers(updatedPurchasedIds); const updatedState: GameState = { ...state, transcendence: { ...state.transcendence, echoes: updatedEchoes, purchasedUpgradeIds: updatedPurchasedIds, ...updatedMultipliers, }, }; await prisma.gameState.update({ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ data: { state: updatedState as object, updatedAt: Date.now() }, where: { discordId }, }); void logger.metric("transcendence_upgrade_purchased", 1, { discordId, upgradeId, }); return context.json({ echoesRemaining: updatedEchoes, purchasedUpgradeIds: updatedPurchasedIds, ...updatedMultipliers, }); } catch (error) { void logger.error( "transcendence_buy_upgrade", error instanceof Error ? error : new Error(String(error)), ); return context.json({ error: "Internal server error" }, 500); } }); export { transcendenceRouter };