From a36c8e72a50dfc16f3bf088d1dfa5dc4febcf4c3 Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 9 Mar 2026 19:54:42 -0700 Subject: [PATCH] feat: error handling, logging, analytics, OG tags, and sticky sidebar (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add comprehensive try/catch error handling across all API routes, middleware, and the Hono global error handler, piping every unhandled error to the `@nhcarrigan/logger` service to prevent silent crashes and unhandled Promise rejections - Add a `logError` utility on the frontend that forwards errors through the overridden `console.error` to the backend telemetry endpoint; apply it to every silent `catch {}` block in the game context, sound, notification, and clipboard utilities, and wrap the React tree in an `ErrorBoundary` - Add Plausible analytics, Open Graph + Twitter Card meta tags, Tree-Nation widget, and Google Ads to `index.html` - Make the game sidebar sticky with a `--resource-bar-height` CSS custom property offset so it stays viewport-height without overlapping the resource bar; reset sticky behaviour in the mobile responsive override ## Test plan - [ ] Lint passes: `pnpm lint` - [ ] Build passes: `pnpm build` - [ ] Verify errors thrown in API routes appear in the logger service rather than crashing the process - [ ] Verify frontend errors appear in the `/api/fe/error` backend log - [ ] Verify Open Graph tags render correctly when sharing the URL - [ ] Verify Plausible analytics fires on page load - [ ] Verify Tree-Nation badge renders in the sidebar - [ ] Verify sidebar stays fixed while the main content scrolls on desktop - [ ] Verify mobile layout is unaffected ✨ This issue was created with help from Hikari~ 🌸 Reviewed-on: https://git.nhcarrigan.com/nhcarrigan/elysium/pulls/44 Co-authored-by: Hikari Co-committed-by: Hikari --- apps/api/package.json | 1 + apps/api/prod.env | 3 +- apps/api/src/index.ts | 32 +- apps/api/src/middleware/auth.ts | 9 +- apps/api/src/routes/about.ts | 25 +- apps/api/src/routes/apotheosis.ts | 179 +++-- apps/api/src/routes/auth.ts | 13 +- apps/api/src/routes/boss.ts | 478 +++++------ apps/api/src/routes/craft.ts | 177 +++-- apps/api/src/routes/explore.ts | 534 +++++++------ apps/api/src/routes/frontend.ts | 55 ++ apps/api/src/routes/game.ts | 748 +++++++++--------- apps/api/src/routes/leaderboards.ts | 127 +-- apps/api/src/routes/prestige.ts | 355 +++++---- apps/api/src/routes/profile.ts | 345 ++++---- apps/api/src/routes/transcendence.ts | 312 ++++---- apps/api/src/services/discord.ts | 57 +- apps/api/src/services/logger.ts | 12 + apps/api/src/services/webhook.ts | 18 +- apps/api/test/middleware/auth.spec.ts | 11 + apps/api/test/routes/apotheosis.spec.ts | 12 + apps/api/test/routes/auth.spec.ts | 9 + apps/api/test/routes/boss.spec.ts | 12 + apps/api/test/routes/craft.spec.ts | 12 + apps/api/test/routes/explore.spec.ts | 26 + apps/api/test/routes/frontend.spec.ts | 136 ++++ apps/api/test/routes/game.spec.ts | 51 ++ apps/api/test/routes/leaderboards.spec.ts | 12 + apps/api/test/routes/prestige.spec.ts | 24 + apps/api/test/routes/profile.spec.ts | 30 + apps/api/test/routes/transcendence.spec.ts | 24 + apps/api/test/services/discord.spec.ts | 17 + apps/api/test/services/webhook.spec.ts | 16 + apps/web/index.html | 33 + apps/web/src/components/errorBoundary.tsx | 70 ++ .../web/src/components/game/characterPage.tsx | 17 +- .../components/game/characterSheetPanel.tsx | 17 +- apps/web/src/components/game/gameLayout.tsx | 1 + apps/web/src/components/game/profilePage.tsx | 17 +- apps/web/src/context/gameContext.tsx | 311 ++++---- apps/web/src/main.tsx | 8 +- apps/web/src/styles.css | 8 + apps/web/src/utils/logError.ts | 19 + apps/web/src/utils/logger.ts | 68 ++ apps/web/src/utils/notification.ts | 4 +- apps/web/src/utils/sound.ts | 4 +- pnpm-lock.yaml | 8 + 47 files changed, 2733 insertions(+), 1724 deletions(-) create mode 100644 apps/api/src/routes/frontend.ts create mode 100644 apps/api/src/services/logger.ts create mode 100644 apps/api/test/routes/frontend.spec.ts create mode 100644 apps/web/src/components/errorBoundary.tsx create mode 100644 apps/web/src/utils/logError.ts create mode 100644 apps/web/src/utils/logger.ts diff --git a/apps/api/package.json b/apps/api/package.json index 7537279..a938a5d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -15,6 +15,7 @@ "dependencies": { "@elysium/types": "workspace:*", "@hono/node-server": "1.13.7", + "@nhcarrigan/logger": "1.1.1", "@prisma/client": "6.5.0", "hono": "4.7.4", "prisma": "6.5.0" diff --git a/apps/api/prod.env b/apps/api/prod.env index 0e20818..d3843dc 100644 --- a/apps/api/prod.env +++ b/apps/api/prod.env @@ -9,4 +9,5 @@ CORS_ORIGIN="op://Environment Variables - Naomi/Elysium/origin" DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord milestone webhook" DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token" DISCORD_GUILD_ID="op://Environment Variables - Naomi/Elysium/discord guild id" -DISCORD_APOTHEOSIS_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord apotheosis role id" \ No newline at end of file +DISCORD_APOTHEOSIS_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord apotheosis role id" +LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth" \ No newline at end of file diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 7deb4ec..84a7958 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -7,22 +7,24 @@ import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { cors } from "hono/cors"; -import { logger } from "hono/logger"; +import { logger as honoLogger } from "hono/logger"; import { aboutRouter } from "./routes/about.js"; import { apotheosisRouter } from "./routes/apotheosis.js"; import { authRouter } from "./routes/auth.js"; import { bossRouter } from "./routes/boss.js"; import { craftRouter } from "./routes/craft.js"; import { exploreRouter } from "./routes/explore.js"; +import { frontendRouter } from "./routes/frontend.js"; import { gameRouter } from "./routes/game.js"; import { leaderboardRouter } from "./routes/leaderboards.js"; import { prestigeRouter } from "./routes/prestige.js"; import { profileRouter } from "./routes/profile.js"; import { transcendenceRouter } from "./routes/transcendence.js"; +import { logger } from "./services/logger.js"; const app = new Hono(); -app.use("*", logger()); +app.use("*", honoLogger()); app.use( "*", cors({ @@ -33,6 +35,7 @@ app.use( ); app.route("/about", aboutRouter); +app.route("/fe", frontendRouter); app.route("/auth", authRouter); app.route("/game", gameRouter); app.route("/boss", bossRouter); @@ -48,8 +51,27 @@ app.get("/health", (context) => { return context.json({ status: "ok" }); }); +app.onError((error, context) => { + void logger.error( + "hono_unhandled_error", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); +}); + const port = Number(process.env.PORT ?? 3001); -serve({ fetch: app.fetch, port: port }, () => { - process.stdout.write(`Elysium API running on port ${String(port)}\n`); -}); +try { + serve({ fetch: app.fetch, port: port }, () => { + process.stdout.write(`Elysium API running on port ${String(port)}\n`); + }); +} catch (error) { + void logger.error( + "server_startup", + error instanceof Error + ? error + : new Error(String(error)), + ); +} diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index 279e74f..4cfa012 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -6,6 +6,7 @@ */ import { verifyToken } from "../services/jwt.js"; +import { logger } from "../services/logger.js"; import type { HonoEnvironment } from "../types/hono.js"; import type { MiddlewareHandler } from "hono"; @@ -33,7 +34,13 @@ export const authMiddleware: MiddlewareHandler = async( try { const payload = verifyToken(token); context.set("discordId", payload.discordId); - } catch { + } catch (error) { + void logger.error( + "auth_middleware", + error instanceof Error + ? error + : new Error(String(error)), + ); return context.json({ error: "Invalid or expired token" }, 401); } diff --git a/apps/api/src/routes/about.ts b/apps/api/src/routes/about.ts index 0f2ff2e..ebb2ff4 100644 --- a/apps/api/src/routes/about.ts +++ b/apps/api/src/routes/about.ts @@ -7,6 +7,7 @@ /* eslint-disable stylistic/max-len -- URL cannot be shortened */ /* eslint-disable require-atomic-updates -- Simple cache; race condition is acceptable */ import { Hono } from "hono"; +import { logger } from "../services/logger.js"; import type { AboutResponse, GiteaRelease } from "@elysium/types"; // eslint-disable-next-line capitalized-comments -- v8 ignore @@ -46,12 +47,24 @@ const fetchReleases = async(): Promise> => { const aboutRouter = new Hono(); aboutRouter.get("/", async(context) => { - const releases = await fetchReleases(); - const body: AboutResponse = { - apiVersion, - releases, - }; - return context.json(body); + try { + const releases = await fetchReleases(); + const body: AboutResponse = { + apiVersion, + releases, + }; + return context.json(body); + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 9 -- @preserve */ + } catch (error) { + void logger.error( + "about", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } }); export { aboutRouter }; diff --git a/apps/api/src/routes/apotheosis.ts b/apps/api/src/routes/apotheosis.ts index 53630c2..ec84ac5 100644 --- a/apps/api/src/routes/apotheosis.ts +++ b/apps/api/src/routes/apotheosis.ts @@ -5,6 +5,8 @@ * @author Naomi Carrigan */ /* eslint-disable max-lines-per-function -- Route handler requires many steps */ +/* eslint-disable max-statements -- Route handler requires many statements */ + /* eslint-disable stylistic/max-len -- Description string cannot be shortened */ import { Hono } from "hono"; import { prisma } from "../db/client.js"; @@ -13,6 +15,7 @@ import { buildPostApotheosisState, isEligibleForApotheosis, } from "../services/apotheosis.js"; +import { logger } from "../services/logger.js"; import { grantApotheosisRole, postMilestoneWebhook, @@ -25,94 +28,106 @@ const apotheosisRouter = new Hono(); apotheosisRouter.use("*", authMiddleware); apotheosisRouter.post("/", async(context) => { - const discordId = context.get("discordId"); + try { + const discordId = context.get("discordId"); - const record = await prisma.gameState.findUnique({ where: { discordId } }); - if (!record) { - return context.json({ error: "No save found" }, 404); - } + const record = await prisma.gameState.findUnique({ where: { discordId } }); + if (!record) { + return context.json({ error: "No save found" }, 404); + } - const rawState: unknown = record.state; - /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ - const state = rawState as GameState; + const rawState: unknown = record.state; + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ + const state = rawState as GameState; - if (!isEligibleForApotheosis(state)) { - return context.json( - { - error: - "Not eligible for Apotheosis — purchase all Transcendence upgrades first", - }, - 400, - ); - } + if (!isEligibleForApotheosis(state)) { + return context.json( + { + error: + "Not eligible for Apotheosis — purchase all Transcendence upgrades first", + }, + 400, + ); + } - // Capture current-run stats before the nuclear reset - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next 9 -- @preserve */ - const runBossesDefeated = state.bosses.filter((b) => { - return b.status === "defeated"; - }).length; - const runQuestsCompleted = state.quests.filter((q) => { - return q.status === "completed"; - }).length; - const runAdventurersRecruited = state.adventurers.reduce((sum, a) => { - return sum + a.count; - }, 0); - - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next 3 -- @preserve */ - const runAchievementsUnlocked = state.achievements.filter((a) => { - return a.unlockedAt !== null; - }).length; - - const { updatedState, updatedApotheosisData } = buildPostApotheosisState( - state, - state.player.characterName, - ); - - const now = Date.now(); - await prisma.gameState.update({ - /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ - data: { state: updatedState 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 - totalGoldEarned: 0, - }, - where: { discordId }, - }); - - void grantApotheosisRole(discordId); - void postMilestoneWebhook(discordId, "apotheosis", { - apotheosis: updatedApotheosisData.count, - prestige: updatedState.prestige.count, + // Capture current-run stats before the nuclear reset // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next -- @preserve */ - transcendence: updatedState.transcendence?.count ?? 0, - }); + /* v8 ignore next 9 -- @preserve */ + const runBossesDefeated = state.bosses.filter((b) => { + return b.status === "defeated"; + }).length; + const runQuestsCompleted = state.quests.filter((q) => { + return q.status === "completed"; + }).length; + const runAdventurersRecruited = state.adventurers.reduce((sum, a) => { + return sum + a.count; + }, 0); - return context.json({ apotheosisCount: updatedApotheosisData.count }); + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 3 -- @preserve */ + const runAchievementsUnlocked = state.achievements.filter((a) => { + return a.unlockedAt !== null; + }).length; + + const { updatedState, updatedApotheosisData } = buildPostApotheosisState( + state, + state.player.characterName, + ); + + const now = Date.now(); + await prisma.gameState.update({ + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ + data: { state: updatedState 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 + totalGoldEarned: 0, + }, + where: { discordId }, + }); + + const apotheosisCount = updatedApotheosisData.count; + void logger.metric("apotheosis", 1, { apotheosisCount, discordId }); + void grantApotheosisRole(discordId); + void postMilestoneWebhook(discordId, "apotheosis", { + apotheosis: updatedApotheosisData.count, + prestige: updatedState.prestige.count, + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next -- @preserve */ + transcendence: updatedState.transcendence?.count ?? 0, + }); + + return context.json({ apotheosisCount: updatedApotheosisData.count }); + } catch (error) { + void logger.error( + "apotheosis", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } }); export { apotheosisRouter }; diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index a820936..d974509 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -15,6 +15,7 @@ import { fetchDiscordUser, } from "../services/discord.js"; import { signToken } from "../services/jwt.js"; +import { logger } from "../services/logger.js"; import type { Player } from "@elysium/types"; const authRouter = new Hono(); @@ -92,6 +93,8 @@ authRouter.get("/callback", async(context) => { }); const jwtToken = signToken(player.discordId); + void logger.log("info", `New player registered: ${player.discordId}`); + void logger.metric("user_registered", 1, { discordId: player.discordId }); // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ @@ -111,6 +114,8 @@ authRouter.get("/callback", async(context) => { }); const jwtToken = signToken(updated.discordId); + void logger.log("info", `Player logged in: ${updated.discordId}`); + void logger.metric("user_login", 1, { discordId: updated.discordId }); // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ @@ -118,7 +123,13 @@ authRouter.get("/callback", async(context) => { return context.redirect( `${clientUrl}/auth/callback?token=${jwtToken}&isNew=false`, ); - } catch { + } catch (error) { + void logger.error( + "auth_callback", + error instanceof Error + ? error + : new Error(String(error)), + ); // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ const clientUrl = process.env.CORS_ORIGIN ?? "http://localhost:5173"; diff --git a/apps/api/src/routes/boss.ts b/apps/api/src/routes/boss.ts index d62614a..03379cf 100644 --- a/apps/api/src/routes/boss.ts +++ b/apps/api/src/routes/boss.ts @@ -20,6 +20,7 @@ import { defaultEquipmentSets } from "../data/equipmentSets.js"; import { prisma } from "../db/client.js"; import { authMiddleware } from "../middleware/auth.js"; import { updateChallengeProgress } from "../services/dailyChallenges.js"; +import { logger } from "../services/logger.js"; import type { HonoEnvironment } from "../types/hono.js"; const bossRouter = new Hono(); @@ -121,254 +122,267 @@ const calculatePartyStats = ( }; bossRouter.post("/challenge", async(context) => { - const discordId = context.get("discordId"); - const body = await context.req.json<{ bossId: string }>(); + try { + const discordId = context.get("discordId"); + const body = await context.req.json<{ bossId: string }>(); - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation - if (!body.bossId) { - return context.json({ error: "Invalid request body" }, 400); - } - - const record = await prisma.gameState.findUnique({ where: { discordId } }); - - if (!record) { - return context.json({ error: "No save found" }, 404); - } - - const rawState: unknown = record.state; - /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ - const state = rawState as GameState; - const boss = state.bosses.find((b) => { - return b.id === body.bossId; - }); - - if (!boss) { - return context.json({ error: "Boss not found" }, 404); - } - - if (boss.status !== "available" && boss.status !== "in_progress") { - return context.json({ error: "Boss is not currently available" }, 400); - } - - if (boss.prestigeRequirement > state.prestige.count) { - return context.json({ error: "Prestige requirement not met" }, 403); - } - - const { partyDPS, partyMaxHp } = calculatePartyStats(state); - - if ( - partyDPS === 0 - || partyMaxHp === 0 - || !Number.isFinite(partyDPS) - || !Number.isFinite(partyMaxHp) - ) { - return context.json( - { error: "Your party has no adventurers ready to fight" }, - 400, - ); - } - - const bossHpBefore = boss.currentHp; - const bossDPS = boss.damagePerSecond; - - const timeToKillBoss = bossHpBefore / partyDPS; - const timeToKillParty = partyMaxHp / bossDPS; - - const won = timeToKillBoss <= timeToKillParty; - - // eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches - let partyHpRemaining: number; - // eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches - let bossHpAtBattleEnd: number; - // eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches - let bossUpdatedHp: number; - // eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss - let rewards: BossChallengeResponse["rewards"]; - // eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss - let casualties: BossChallengeResponse["casualties"]; - - if (won) { - bossHpAtBattleEnd = 0; - bossUpdatedHp = 0; - const bossDamageDealt = bossDPS * timeToKillBoss; - partyHpRemaining = Math.max(0, partyMaxHp - bossDamageDealt); - - boss.status = "defeated"; - boss.currentHp = 0; - - state.resources.gold = state.resources.gold + boss.goldReward; - state.resources.essence = state.resources.essence + boss.essenceReward; - state.resources.crystals = state.resources.crystals + boss.crystalReward; - state.player.totalGoldEarned = state.player.totalGoldEarned + boss.goldReward; - - for (const upgradeId of boss.upgradeRewards) { - const upgrade = state.upgrades.find((u) => { - return u.id === upgradeId; - }); - if (upgrade) { - upgrade.unlocked = true; - } + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation + if (!body.bossId) { + return context.json({ error: "Invalid request body" }, 400); } - // Grant equipment rewards — auto-equip if the slot is currently empty - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next 14 -- @preserve */ - for (const equipmentId of boss.equipmentRewards) { - const equipment = state.equipment.find((item) => { - return item.id === equipmentId; - }); - if (equipment) { - equipment.owned = true; + const record = await prisma.gameState.findUnique({ where: { discordId } }); - const slotAlreadyEquipped = state.equipment.some((item) => { - return item.type === equipment.type && item.equipped; + if (!record) { + return context.json({ error: "No save found" }, 404); + } + + const rawState: unknown = record.state; + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ + const state = rawState as GameState; + const boss = state.bosses.find((b) => { + return b.id === body.bossId; + }); + + if (!boss) { + return context.json({ error: "Boss not found" }, 404); + } + + if (boss.status !== "available" && boss.status !== "in_progress") { + return context.json({ error: "Boss is not currently available" }, 400); + } + + if (boss.prestigeRequirement > state.prestige.count) { + return context.json({ error: "Prestige requirement not met" }, 403); + } + + const { partyDPS, partyMaxHp } = calculatePartyStats(state); + + if ( + partyDPS === 0 + || partyMaxHp === 0 + || !Number.isFinite(partyDPS) + || !Number.isFinite(partyMaxHp) + ) { + return context.json( + { error: "Your party has no adventurers ready to fight" }, + 400, + ); + } + + const bossHpBefore = boss.currentHp; + const bossDPS = boss.damagePerSecond; + + const timeToKillBoss = bossHpBefore / partyDPS; + const timeToKillParty = partyMaxHp / bossDPS; + + const won = timeToKillBoss <= timeToKillParty; + + // eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches + let partyHpRemaining: number; + // eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches + let bossHpAtBattleEnd: number; + // eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches + let bossUpdatedHp: number; + // eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss + let rewards: BossChallengeResponse["rewards"]; + // eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss + let casualties: BossChallengeResponse["casualties"]; + + if (won) { + bossHpAtBattleEnd = 0; + bossUpdatedHp = 0; + const bossDamageDealt = bossDPS * timeToKillBoss; + partyHpRemaining = Math.max(0, partyMaxHp - bossDamageDealt); + + boss.status = "defeated"; + boss.currentHp = 0; + + state.resources.gold = state.resources.gold + boss.goldReward; + state.resources.essence = state.resources.essence + boss.essenceReward; + state.resources.crystals = state.resources.crystals + boss.crystalReward; + state.player.totalGoldEarned = state.player.totalGoldEarned + boss.goldReward; + + for (const upgradeId of boss.upgradeRewards) { + const upgrade = state.upgrades.find((u) => { + return u.id === upgradeId; }); - if (!slotAlreadyEquipped) { - equipment.equipped = true; + if (upgrade) { + upgrade.unlocked = true; + } + } + + // Grant equipment rewards — auto-equip if the slot is currently empty + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 14 -- @preserve */ + for (const equipmentId of boss.equipmentRewards) { + const equipment = state.equipment.find((item) => { + return item.id === equipmentId; + }); + if (equipment) { + equipment.owned = true; + + const slotAlreadyEquipped = state.equipment.some((item) => { + return item.type === equipment.type && item.equipped; + }); + if (!slotAlreadyEquipped) { + equipment.equipped = true; + } + } + } + + // Unlock next boss in the same zone (zone-based sequential progression) + const zoneBosses = state.bosses.filter((b) => { + return b.zoneId === boss.zoneId; + }); + const zoneIndex = zoneBosses.findIndex((b) => { + return b.id === body.bossId; + }); + const [ nextZoneBoss ] = zoneBosses.slice(zoneIndex + 1); + if ( + nextZoneBoss + && nextZoneBoss.prestigeRequirement <= state.prestige.count + ) { + const nextBossInState = state.bosses.find((b) => { + return b.id === nextZoneBoss.id; + }); + if (nextBossInState) { + nextBossInState.status = "available"; + } + } + + /* + * Unlock any zone whose unlock conditions are now both satisfied + * (final boss defeated AND final quest completed) + */ + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next -- @preserve */ + for (const zone of state.zones) { + if (zone.status === "unlocked") { + continue; + } + if (zone.unlockBossId !== body.bossId) { + continue; + } + + // Boss condition just became satisfied — check the quest condition too + const questSatisfied + = zone.unlockQuestId === null + || state.quests.some((q) => { + return q.id === zone.unlockQuestId && q.status === "completed"; + }); + if (!questSatisfied) { + continue; + } + zone.status = "unlocked"; + const updatedZoneBosses = state.bosses.filter((b) => { + return b.zoneId === zone.id; + }); + const [ firstUpdatedBoss ] = updatedZoneBosses; + if ( + firstUpdatedBoss + && firstUpdatedBoss.prestigeRequirement <= state.prestige.count + ) { + firstUpdatedBoss.status = "available"; + } + } + + // Update daily boss challenge progress + if (state.dailyChallenges) { + const { crystalsAwarded, updatedChallenges } = updateChallengeProgress( + state.dailyChallenges, + "bossesDefeated", + 1, + ); + state.dailyChallenges = updatedChallenges; + state.resources.crystals = state.resources.crystals + crystalsAwarded; + } + + // First-kill bounty — look up authoritative bounty from static data + const staticBoss = defaultBosses.find((b) => { + return b.id === body.bossId; + }); + + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next -- @preserve */ + const bountyRunestones = staticBoss?.bountyRunestones ?? 0; + state.prestige.runestones = state.prestige.runestones + bountyRunestones; + + rewards = { + bountyRunestones: bountyRunestones, + crystals: boss.crystalReward, + equipmentIds: boss.equipmentRewards, + essence: boss.essenceReward, + gold: boss.goldReward, + upgradeIds: boss.upgradeRewards, + }; + } else { + const partyDamageDealt = partyDPS * timeToKillParty; + bossHpAtBattleEnd = Math.max(0, bossHpBefore - partyDamageDealt); + bossUpdatedHp = boss.maxHp; + partyHpRemaining = 0; + + boss.status = "available"; + boss.currentHp = boss.maxHp; + + // How close was the party to winning? (0 = hopeless, 1 = nearly won) + const victoryProgress = Math.min(1, timeToKillParty / timeToKillBoss); + // Casualty rate scales from ~0% (nearly won) to 60% (completely outmatched) + const casualtyFraction = (1 - victoryProgress) * 0.6; + + casualties = []; + for (const adventurer of state.adventurers) { + if (adventurer.count === 0) { + continue; + } + const killed = Math.floor(adventurer.count * casualtyFraction); + if (killed > 0) { + adventurer.count = Math.max(1, adventurer.count - killed); + casualties.push({ adventurerId: adventurer.id, killed: killed }); } } } - // Unlock next boss in the same zone (zone-based sequential progression) - const zoneBosses = state.bosses.filter((b) => { - return b.zoneId === boss.zoneId; - }); - const zoneIndex = zoneBosses.findIndex((b) => { - return b.id === body.bossId; - }); - const [ nextZoneBoss ] = zoneBosses.slice(zoneIndex + 1); - if ( - nextZoneBoss - && nextZoneBoss.prestigeRequirement <= state.prestige.count - ) { - const nextBossInState = state.bosses.find((b) => { - return b.id === nextZoneBoss.id; - }); - if (nextBossInState) { - nextBossInState.status = "available"; - } - } - - /* - * Unlock any zone whose unlock conditions are now both satisfied - * (final boss defeated AND final quest completed) - */ - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next -- @preserve */ - for (const zone of state.zones) { - if (zone.status === "unlocked") { - continue; - } - if (zone.unlockBossId !== body.bossId) { - continue; - } - - // Boss condition just became satisfied — check the quest condition too - const questSatisfied - = zone.unlockQuestId === null - || state.quests.some((q) => { - return q.id === zone.unlockQuestId && q.status === "completed"; - }); - if (!questSatisfied) { - continue; - } - zone.status = "unlocked"; - const updatedZoneBosses = state.bosses.filter((b) => { - return b.zoneId === zone.id; - }); - const [ firstUpdatedBoss ] = updatedZoneBosses; - if ( - firstUpdatedBoss - && firstUpdatedBoss.prestigeRequirement <= state.prestige.count - ) { - firstUpdatedBoss.status = "available"; - } - } - - // Update daily boss challenge progress - if (state.dailyChallenges) { - const { crystalsAwarded, updatedChallenges } = updateChallengeProgress( - state.dailyChallenges, - "bossesDefeated", - 1, - ); - state.dailyChallenges = updatedChallenges; - state.resources.crystals = state.resources.crystals + crystalsAwarded; - } - - // First-kill bounty — look up authoritative bounty from static data - const staticBoss = defaultBosses.find((b) => { - return b.id === body.bossId; + const now = Date.now(); + await prisma.gameState.update({ + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ + data: { state: state as object, updatedAt: now }, + where: { discordId }, }); - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next -- @preserve */ - const bountyRunestones = staticBoss?.bountyRunestones ?? 0; - state.prestige.runestones = state.prestige.runestones + bountyRunestones; + const { bossId } = body; + void logger.metric("boss_challenge", 1, { bossId, discordId, won }); - rewards = { - bountyRunestones: bountyRunestones, - crystals: boss.crystalReward, - equipmentIds: boss.equipmentRewards, - essence: boss.essenceReward, - gold: boss.goldReward, - upgradeIds: boss.upgradeRewards, + const bossMaxHp = boss.maxHp; + const bossNewHp = bossUpdatedHp; + const response: BossChallengeResponse = { + bossDPS, + bossHpAtBattleEnd, + bossHpBefore, + bossMaxHp, + bossNewHp, + partyDPS, + partyHpRemaining, + partyMaxHp, + won, }; - } else { - const partyDamageDealt = partyDPS * timeToKillParty; - bossHpAtBattleEnd = Math.max(0, bossHpBefore - partyDamageDealt); - bossUpdatedHp = boss.maxHp; - partyHpRemaining = 0; - - boss.status = "available"; - boss.currentHp = boss.maxHp; - - // How close was the party to winning? (0 = hopeless, 1 = nearly won) - const victoryProgress = Math.min(1, timeToKillParty / timeToKillBoss); - // Casualty rate scales from ~0% (nearly won) to 60% (completely outmatched) - const casualtyFraction = (1 - victoryProgress) * 0.6; - - casualties = []; - for (const adventurer of state.adventurers) { - if (adventurer.count === 0) { - continue; - } - const killed = Math.floor(adventurer.count * casualtyFraction); - if (killed > 0) { - adventurer.count = Math.max(1, adventurer.count - killed); - casualties.push({ adventurerId: adventurer.id, killed: killed }); - } + if (rewards !== undefined) { + response.rewards = rewards; + } + if (casualties !== undefined) { + response.casualties = casualties; } - } - const now = Date.now(); - await prisma.gameState.update({ - /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ - data: { state: state as object, updatedAt: now }, - where: { discordId }, - }); - - const bossMaxHp = boss.maxHp; - const bossNewHp = bossUpdatedHp; - const response: BossChallengeResponse = { - bossDPS, - bossHpAtBattleEnd, - bossHpBefore, - bossMaxHp, - bossNewHp, - partyDPS, - partyHpRemaining, - partyMaxHp, - won, - }; - if (rewards !== undefined) { - response.rewards = rewards; + return context.json(response); + } catch (error) { + void logger.error( + "boss_challenge", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); } - if (casualties !== undefined) { - response.casualties = casualties; - } - - return context.json(response); }); export { bossRouter }; diff --git a/apps/api/src/routes/craft.ts b/apps/api/src/routes/craft.ts index 0092c77..62b8491 100644 --- a/apps/api/src/routes/craft.ts +++ b/apps/api/src/routes/craft.ts @@ -11,6 +11,7 @@ import { Hono } from "hono"; import { defaultRecipes } from "../data/recipes.js"; import { prisma } from "../db/client.js"; import { authMiddleware } from "../middleware/auth.js"; +import { logger } from "../services/logger.js"; import type { HonoEnvironment } from "../types/hono.js"; import type { CraftRecipeRequest, @@ -63,94 +64,106 @@ const recomputeCraftedMultipliers = ( }; craftRouter.post("/", async(context) => { - const discordId = context.get("discordId"); - const body = await context.req.json(); + try { + const discordId = context.get("discordId"); + const body = await context.req.json(); - const { recipeId } = body; - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation - if (!recipeId) { - return context.json({ error: "recipeId is required" }, 400); - } - - const recipe = defaultRecipes.find((r) => { - return r.id === recipeId; - }); - if (!recipe) { - return context.json({ error: "Unknown recipe" }, 404); - } - - const record = await prisma.gameState.findUnique({ where: { discordId } }); - if (!record) { - return context.json({ error: "No save found" }, 404); - } - - const rawState: unknown = record.state; - /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ - const state = rawState as GameState; - - if (!state.exploration) { - return context.json({ error: "No exploration state found" }, 400); - } - - if (state.exploration.craftedRecipeIds.includes(recipeId)) { - return context.json({ error: "Recipe already crafted" }, 400); - } - - // Verify the player has all required materials - for (const requirement of recipe.requiredMaterials) { - const material = state.exploration.materials.find((m) => { - return m.materialId === requirement.materialId; - }); - const quantity = material?.quantity ?? 0; - if (quantity < requirement.quantity) { - return context.json( - { - error: `Not enough ${requirement.materialId} (need ${String(requirement.quantity)}, have ${String(quantity)})`, - }, - 400, - ); + const { recipeId } = body; + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation + if (!recipeId) { + return context.json({ error: "recipeId is required" }, 400); } - } - // Deduct materials - for (const requirement of recipe.requiredMaterials) { - const material = state.exploration.materials.find((m) => { - return m.materialId === requirement.materialId; + const recipe = defaultRecipes.find((r) => { + return r.id === recipeId; }); - if (material) { - material.quantity = material.quantity - requirement.quantity; + if (!recipe) { + return context.json({ error: "Unknown recipe" }, 404); } + + const record = await prisma.gameState.findUnique({ where: { discordId } }); + if (!record) { + return context.json({ error: "No save found" }, 404); + } + + const rawState: unknown = record.state; + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ + const state = rawState as GameState; + + if (!state.exploration) { + return context.json({ error: "No exploration state found" }, 400); + } + + if (state.exploration.craftedRecipeIds.includes(recipeId)) { + return context.json({ error: "Recipe already crafted" }, 400); + } + + // Verify the player has all required materials + for (const requirement of recipe.requiredMaterials) { + const material = state.exploration.materials.find((m) => { + return m.materialId === requirement.materialId; + }); + const quantity = material?.quantity ?? 0; + if (quantity < requirement.quantity) { + return context.json( + { + error: `Not enough ${requirement.materialId} (need ${String(requirement.quantity)}, have ${String(quantity)})`, + }, + 400, + ); + } + } + + // Deduct materials + for (const requirement of recipe.requiredMaterials) { + const material = state.exploration.materials.find((m) => { + return m.materialId === requirement.materialId; + }); + if (material) { + material.quantity = material.quantity - requirement.quantity; + } + } + + // Add recipe and recompute all multipliers from scratch + state.exploration.craftedRecipeIds.push(recipeId); + const updatedMultipliers = recomputeCraftedMultipliers( + state.exploration.craftedRecipeIds, + ); + state.exploration.craftedGoldMultiplier + = updatedMultipliers.craftedGoldMultiplier; + state.exploration.craftedEssenceMultiplier + = updatedMultipliers.craftedEssenceMultiplier; + state.exploration.craftedClickMultiplier + = updatedMultipliers.craftedClickMultiplier; + state.exploration.craftedCombatMultiplier + = updatedMultipliers.craftedCombatMultiplier; + + await prisma.gameState.update({ + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ + data: { state: state as object, updatedAt: Date.now() }, + where: { discordId }, + }); + + void logger.metric("recipe_crafted", 1, { discordId, recipeId }); + + const bonusType = recipe.bonus.type; + const bonusValue = recipe.bonus.value; + const response: CraftRecipeResponse = { + bonusType, + bonusValue, + recipeId, + ...updatedMultipliers, + }; + return context.json(response); + } catch (error) { + void logger.error( + "craft", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); } - - // Add recipe and recompute all multipliers from scratch - state.exploration.craftedRecipeIds.push(recipeId); - const updatedMultipliers = recomputeCraftedMultipliers( - state.exploration.craftedRecipeIds, - ); - state.exploration.craftedGoldMultiplier - = updatedMultipliers.craftedGoldMultiplier; - state.exploration.craftedEssenceMultiplier - = updatedMultipliers.craftedEssenceMultiplier; - state.exploration.craftedClickMultiplier - = updatedMultipliers.craftedClickMultiplier; - state.exploration.craftedCombatMultiplier - = updatedMultipliers.craftedCombatMultiplier; - - await prisma.gameState.update({ - /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ - data: { state: state as object, updatedAt: Date.now() }, - where: { discordId }, - }); - - const bonusType = recipe.bonus.type; - const bonusValue = recipe.bonus.value; - const response: CraftRecipeResponse = { - bonusType, - bonusValue, - recipeId, - ...updatedMultipliers, - }; - return context.json(response); }); export { craftRouter }; diff --git a/apps/api/src/routes/explore.ts b/apps/api/src/routes/explore.ts index f755472..8120f59 100644 --- a/apps/api/src/routes/explore.ts +++ b/apps/api/src/routes/explore.ts @@ -12,6 +12,7 @@ import { defaultExplorations } from "../data/explorations.js"; import { initialExploration } from "../data/initialState.js"; import { prisma } from "../db/client.js"; import { authMiddleware } from "../middleware/auth.js"; +import { logger } from "../services/logger.js"; import type { HonoEnvironment } from "../types/hono.js"; import type { ExploreCollectEventResult, @@ -49,280 +50,233 @@ const pickNothingMessage = (): string => { }; exploreRouter.post("/start", async(context) => { - const discordId = context.get("discordId"); - const body = await context.req.json(); + try { + const discordId = context.get("discordId"); + const body = await context.req.json(); - const { areaId } = body; - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation - if (!areaId) { - return context.json({ error: "areaId is required" }, 400); - } + const { areaId } = body; + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation + if (!areaId) { + return context.json({ error: "areaId is required" }, 400); + } - const explorationArea = defaultExplorations.find((a) => { - return a.id === areaId; - }); - if (!explorationArea) { - return context.json({ error: "Unknown exploration area" }, 404); - } + const explorationArea = defaultExplorations.find((a) => { + return a.id === areaId; + }); + if (!explorationArea) { + return context.json({ error: "Unknown exploration area" }, 404); + } - const record = await prisma.gameState.findUnique({ where: { discordId } }); - if (!record) { - return context.json({ error: "No save found" }, 404); - } + const record = await prisma.gameState.findUnique({ where: { discordId } }); + if (!record) { + return context.json({ error: "No save found" }, 404); + } - const rawState: unknown = record.state; - /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ - const state = rawState as GameState; + const rawState: unknown = record.state; + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ + const state = rawState as GameState; - // Backfill exploration state for old saves that predate this feature - if (!state.exploration) { - state.exploration = structuredClone(initialExploration); - // Unlock areas for zones already unlocked in this save - for (const area of state.exploration.areas) { - const areaData = defaultExplorations.find((areaItem) => { - return areaItem.id === area.id; - }); + // Backfill exploration state for old saves that predate this feature + if (!state.exploration) { + state.exploration = structuredClone(initialExploration); + // Unlock areas for zones already unlocked in this save + for (const area of state.exploration.areas) { + const areaData = defaultExplorations.find((areaItem) => { + return areaItem.id === area.id; + }); - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next 3 -- @preserve */ - if (!areaData) { - continue; - } - const zone = state.zones.find((z) => { - return z.id === areaData.zoneId; - }); - if (zone?.status === "unlocked") { - area.status = "available"; + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 3 -- @preserve */ + if (!areaData) { + continue; + } + const zone = state.zones.find((z) => { + return z.id === areaData.zoneId; + }); + if (zone?.status === "unlocked") { + area.status = "available"; + } } } - } - const zone = state.zones.find((z) => { - return z.id === explorationArea.zoneId; - }); - if (!zone || zone.status !== "unlocked") { - return context.json({ error: "Zone is not unlocked" }, 400); - } + const zone = state.zones.find((z) => { + return z.id === explorationArea.zoneId; + }); + if (!zone || zone.status !== "unlocked") { + return context.json({ error: "Zone is not unlocked" }, 400); + } - const area = state.exploration.areas.find((a) => { - return a.id === areaId; - }); - if (!area) { - return context.json({ error: "Exploration area not found in state" }, 404); - } + const area = state.exploration.areas.find((a) => { + return a.id === areaId; + }); + if (!area) { + return context.json( + { error: "Exploration area not found in state" }, + 404, + ); + } - const anyInProgress = state.exploration.areas.some((a) => { - return a.status === "in_progress"; - }); - if (anyInProgress) { - return context.json( - { error: "An exploration is already in progress" }, - 400, - ); - } + const anyInProgress = state.exploration.areas.some((a) => { + return a.status === "in_progress"; + }); + if (anyInProgress) { + return context.json( + { error: "An exploration is already in progress" }, + 400, + ); + } - if (area.status === "locked") { - return context.json({ error: "Exploration area is locked" }, 400); - } + if (area.status === "locked") { + return context.json({ error: "Exploration area is locked" }, 400); + } - const now = Date.now(); - area.status = "in_progress"; - area.startedAt = now; + const now = Date.now(); + area.status = "in_progress"; + area.startedAt = now; - await prisma.gameState.update({ - /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ - data: { state: state as object, updatedAt: now }, - where: { discordId }, - }); - - // eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear - const endsAt = now + explorationArea.durationSeconds * 1000; - const response: ExploreStartResponse = { - areaId, - endsAt, - }; - return context.json(response); -}); - -exploreRouter.post("/collect", async(context) => { - const discordId = context.get("discordId"); - const body = await context.req.json(); - - const { areaId } = body; - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation - if (!areaId) { - return context.json({ error: "areaId is required" }, 400); - } - - const explorationArea = defaultExplorations.find((a) => { - return a.id === areaId; - }); - if (!explorationArea) { - return context.json({ error: "Unknown exploration area" }, 404); - } - - const record = await prisma.gameState.findUnique({ where: { discordId } }); - if (!record) { - return context.json({ error: "No save found" }, 404); - } - - const rawState: unknown = record.state; - /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ - const state = rawState as GameState; - - if (!state.exploration) { - return context.json({ error: "No exploration state found" }, 400); - } - - const area = state.exploration.areas.find((a) => { - return a.id === areaId; - }); - if (!area) { - return context.json({ error: "Exploration area not found" }, 404); - } - - if (area.status !== "in_progress") { - return context.json({ error: "Exploration is not in progress" }, 400); - } - - const now = Date.now(); - - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next -- @preserve */ - const startedAt = area.startedAt ?? 0; - const durationMs = explorationArea.durationSeconds * 1000; - const expiresAt = startedAt + durationMs; - - if (now < expiresAt) { - return context.json({ error: "Exploration is not yet complete" }, 400); - } - - area.status = "available"; - area.completedOnce = true; - - // 20% chance of finding nothing - if (Math.random() < nothingProbability) { await prisma.gameState.update({ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ data: { state: state as object, updatedAt: now }, where: { discordId }, }); - const response: ExploreCollectResponse = { - event: null, - foundNothing: true, - materialsFound: [], - nothingMessage: pickNothingMessage(), + // eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear + const endsAt = now + explorationArea.durationSeconds * 1000; + const response: ExploreStartResponse = { + areaId, + endsAt, }; return context.json(response); + } catch (error) { + void logger.error( + "explore_start", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); } +}); - // Pick a random event - const eventIndex = Math.floor(Math.random() * explorationArea.events.length); - const event = explorationArea.events[eventIndex]; - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next 3 -- @preserve */ - if (!event) { - return context.json({ error: "No events available" }, 500); - } +exploreRouter.post("/collect", async(context) => { + try { + const discordId = context.get("discordId"); + const body = await context.req.json(); - // Apply event effects and build the result summary - let goldChange = 0; - let essenceChange = 0; - let materialGained: { materialId: string; quantity: number } | null = null; + const { areaId } = body; + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation + if (!areaId) { + return context.json({ error: "areaId is required" }, 400); + } - if (event.effect.type === "gold_gain") { - // Gold gain — amount may be undefined in edge cases - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next -- @preserve */ - const amount = event.effect.amount ?? 0; - state.resources.gold = state.resources.gold + amount; - state.player.totalGoldEarned = state.player.totalGoldEarned + amount; - goldChange = amount; - } else if (event.effect.type === "gold_loss") { - // Gold loss — amount may be undefined in edge cases - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next -- @preserve */ - const amount = Math.min(state.resources.gold, event.effect.amount ?? 0); - state.resources.gold = state.resources.gold - amount; - goldChange = -amount; - } else if (event.effect.type === "essence_gain") { - // Essence gain — amount may be undefined in edge cases - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next -- @preserve */ - const amount = event.effect.amount ?? 0; - state.resources.essence = state.resources.essence + amount; - essenceChange = amount; - } else if (event.effect.type === "material_gain") { - const { materialId } = event.effect; + const explorationArea = defaultExplorations.find((a) => { + return a.id === areaId; + }); + if (!explorationArea) { + return context.json({ error: "Unknown exploration area" }, 404); + } + + const record = await prisma.gameState.findUnique({ where: { discordId } }); + if (!record) { + return context.json({ error: "No save found" }, 404); + } + + const rawState: unknown = record.state; + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ + const state = rawState as GameState; + + if (!state.exploration) { + return context.json({ error: "No exploration state found" }, 400); + } + + const area = state.exploration.areas.find((a) => { + return a.id === areaId; + }); + if (!area) { + return context.json({ error: "Exploration area not found" }, 404); + } + + if (area.status !== "in_progress") { + return context.json({ error: "Exploration is not in progress" }, 400); + } + + const now = Date.now(); // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ - const quantity = event.effect.quantity ?? 1; - if (materialId !== undefined && materialId !== "") { - const existing = state.exploration.materials.find((m) => { - return m.materialId === materialId; + const startedAt = area.startedAt ?? 0; + const durationMs = explorationArea.durationSeconds * 1000; + const expiresAt = startedAt + durationMs; + + if (now < expiresAt) { + return context.json({ error: "Exploration is not yet complete" }, 400); + } + + area.status = "available"; + area.completedOnce = true; + + // 20% chance of finding nothing + if (Math.random() < nothingProbability) { + await prisma.gameState.update({ + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ + data: { state: state as object, updatedAt: now }, + where: { discordId }, }); - if (existing) { - existing.quantity = existing.quantity + quantity; - } else { - state.exploration.materials.push({ materialId, quantity }); - } - materialGained = { materialId, quantity }; - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next 13 -- @preserve */ + + const response: ExploreCollectResponse = { + event: null, + foundNothing: true, + materialsFound: [], + nothingMessage: pickNothingMessage(), + }; + return context.json(response); } - } else if (event.effect.type === "adventurer_loss") { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- exhausted all other union members above - // Adventurer loss — fraction and loop are defensive + + // Pick a random event + const eventIndex = Math.floor( + Math.random() * explorationArea.events.length, + ); + const event = explorationArea.events[eventIndex]; // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next 8 -- @preserve */ - const fraction = event.effect.fraction ?? 0.05; - for (const adventurer of state.adventurers) { - const lost = Math.floor(adventurer.count * fraction); - if (lost > 0) { - adventurer.count = Math.max(0, adventurer.count - lost); - } + /* v8 ignore next 3 -- @preserve */ + if (!event) { + return context.json({ error: "No events available" }, 500); } - } - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next 8 -- @preserve */ - let adventurerLostCount = 0; - if (event.effect.type === "adventurer_loss") { - const fraction = event.effect.fraction ?? 0.05; - for (const adv of state.adventurers) { - const lost = Math.floor(adv.count * fraction); - adventurerLostCount = adventurerLostCount + lost; - } - } + // Apply event effects and build the result summary + let goldChange = 0; + let essenceChange = 0; + let materialGained: { materialId: string; quantity: number } | null = null; - const eventResult: ExploreCollectEventResult = { - adventurerLostCount: adventurerLostCount, - essenceChange: essenceChange, - goldChange: goldChange, - materialGained: materialGained, - text: event.text, - }; - - // Roll for material drops from possibleMaterials (weighted random selection) - const materialsFound: Array<{ materialId: string; quantity: number }> = []; - - if (explorationArea.possibleMaterials.length > 0) { - let totalWeight = 0; - for (const materialDrop of explorationArea.possibleMaterials) { - totalWeight = totalWeight + materialDrop.weight; - } - let roll = Math.random() * totalWeight; - - for (const possible of explorationArea.possibleMaterials) { - roll = roll - possible.weight; - if (roll <= 0) { - const maxMinDiff = possible.maxQuantity - possible.minQuantity; - const range = maxMinDiff + 1; - const randomOffset = Math.floor(Math.random() * range); - const quantity = randomOffset + possible.minQuantity; - const { materialId } = possible; + if (event.effect.type === "gold_gain") { + // Gold gain — amount may be undefined in edge cases + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next -- @preserve */ + const amount = event.effect.amount ?? 0; + state.resources.gold = state.resources.gold + amount; + state.player.totalGoldEarned = state.player.totalGoldEarned + amount; + goldChange = amount; + } else if (event.effect.type === "gold_loss") { + // Gold loss — amount may be undefined in edge cases + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next -- @preserve */ + const amount = Math.min(state.resources.gold, event.effect.amount ?? 0); + state.resources.gold = state.resources.gold - amount; + goldChange = -amount; + } else if (event.effect.type === "essence_gain") { + // Essence gain — amount may be undefined in edge cases + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next -- @preserve */ + const amount = event.effect.amount ?? 0; + state.resources.essence = state.resources.essence + amount; + essenceChange = amount; + } else if (event.effect.type === "material_gain") { + const { materialId } = event.effect; + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next -- @preserve */ + const quantity = event.effect.quantity ?? 1; + if (materialId !== undefined && materialId !== "") { const existing = state.exploration.materials.find((m) => { return m.materialId === materialId; }); @@ -331,25 +285,97 @@ exploreRouter.post("/collect", async(context) => { } else { state.exploration.materials.push({ materialId, quantity }); } - - materialsFound.push({ materialId, quantity }); - break; + materialGained = { materialId, quantity }; + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 13 -- @preserve */ + } + } else if (event.effect.type === "adventurer_loss") { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- exhausted all other union members above + // Adventurer loss — fraction and loop are defensive + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 8 -- @preserve */ + const fraction = event.effect.fraction ?? 0.05; + for (const adventurer of state.adventurers) { + const lost = Math.floor(adventurer.count * fraction); + if (lost > 0) { + adventurer.count = Math.max(0, adventurer.count - lost); + } } } + + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 8 -- @preserve */ + let adventurerLostCount = 0; + if (event.effect.type === "adventurer_loss") { + const fraction = event.effect.fraction ?? 0.05; + for (const adv of state.adventurers) { + const lost = Math.floor(adv.count * fraction); + adventurerLostCount = adventurerLostCount + lost; + } + } + + const eventResult: ExploreCollectEventResult = { + adventurerLostCount: adventurerLostCount, + essenceChange: essenceChange, + goldChange: goldChange, + materialGained: materialGained, + text: event.text, + }; + + // Roll for material drops from possibleMaterials (weighted random selection) + const materialsFound: Array<{ materialId: string; quantity: number }> = []; + + if (explorationArea.possibleMaterials.length > 0) { + let totalWeight = 0; + for (const materialDrop of explorationArea.possibleMaterials) { + totalWeight = totalWeight + materialDrop.weight; + } + let roll = Math.random() * totalWeight; + + for (const possible of explorationArea.possibleMaterials) { + roll = roll - possible.weight; + if (roll <= 0) { + const maxMinDiff = possible.maxQuantity - possible.minQuantity; + const range = maxMinDiff + 1; + const randomOffset = Math.floor(Math.random() * range); + const quantity = randomOffset + possible.minQuantity; + const { materialId } = possible; + + const existing = state.exploration.materials.find((m) => { + return m.materialId === materialId; + }); + if (existing) { + existing.quantity = existing.quantity + quantity; + } else { + state.exploration.materials.push({ materialId, quantity }); + } + + materialsFound.push({ materialId, quantity }); + break; + } + } + } + + await prisma.gameState.update({ + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ + data: { state: state as object, updatedAt: now }, + where: { discordId }, + }); + + const response: ExploreCollectResponse = { + event: eventResult, + foundNothing: false, + materialsFound: materialsFound, + }; + return context.json(response); + } catch (error) { + void logger.error( + "explore_collect", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); } - - await prisma.gameState.update({ - /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ - data: { state: state as object, updatedAt: now }, - where: { discordId }, - }); - - const response: ExploreCollectResponse = { - event: eventResult, - foundNothing: false, - materialsFound: materialsFound, - }; - return context.json(response); }); export { exploreRouter }; diff --git a/apps/api/src/routes/frontend.ts b/apps/api/src/routes/frontend.ts new file mode 100644 index 0000000..af16429 --- /dev/null +++ b/apps/api/src/routes/frontend.ts @@ -0,0 +1,55 @@ +/** + * @file Frontend logging routes that pipe client-side logs to the telemetry service. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { Hono } from "hono"; +import { logger } from "../services/logger.js"; + +const validLevels = new Set([ "debug", "info", "warn" ]); + +const frontendRouter = new Hono(); + +frontendRouter.post("/log", async(context) => { + try { + const body = await context.req.json<{ level: string; message: string }>(); + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation + if (!body.level || !body.message || !validLevels.has(body.level)) { + return context.json({ error: "level and message are required" }, 400); + } + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- validated above */ + void logger.log(body.level as "debug" | "info" | "warn", `[FE] ${body.message}`); + return context.json({ ok: true }); + } catch (error) { + void logger.error( + "frontend_log", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } +}); + +frontendRouter.post("/error", async(context) => { + try { + const body = await context.req.json<{ context: string; message: string }>(); + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation + if (!body.context || !body.message) { + return context.json({ error: "context and message are required" }, 400); + } + void logger.error(`[FE] ${body.context}`, new Error(body.message)); + return context.json({ ok: true }); + } catch (error) { + void logger.error( + "frontend_error", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } +}); + +export { frontendRouter }; diff --git a/apps/api/src/routes/game.ts b/apps/api/src/routes/game.ts index 3aa2e5e..d83e046 100644 --- a/apps/api/src/routes/game.ts +++ b/apps/api/src/routes/game.ts @@ -27,6 +27,7 @@ import { currentSchemaVersion } from "../data/schemaVersion.js"; import { prisma } from "../db/client.js"; import { authMiddleware } from "../middleware/auth.js"; import { getOrResetDailyChallenges } from "../services/dailyChallenges.js"; +import { logger } from "../services/logger.js"; import { calculateOfflineEarnings } from "../services/offlineProgress.js"; import { checkAndUnlockTitles, @@ -681,18 +682,387 @@ const gameRouter = new Hono(); gameRouter.use("*", authMiddleware); gameRouter.get("/load", async(context) => { - const discordId = context.get("discordId"); + try { + const discordId = context.get("discordId"); - const [ record, playerRecord ] = await Promise.all([ - prisma.gameState.findUnique({ where: { discordId } }), - prisma.player.findUnique({ where: { discordId } }), - ]); + const [ record, playerRecord ] = await Promise.all([ + prisma.gameState.findUnique({ where: { discordId } }), + prisma.player.findUnique({ where: { discordId } }), + ]); - if (!record) { - // No save found — create a fresh state (handles nuked DB or first-time load race) + if (!record) { + // No save found — create a fresh state (handles nuked DB or first-time load race) + if (!playerRecord) { + return context.json({ error: "No player found" }, 404); + } + const freshState = initialGameState( + { + avatar: playerRecord.avatar, + characterName: playerRecord.characterName, + createdAt: playerRecord.createdAt, + discordId: playerRecord.discordId, + discriminator: playerRecord.discriminator, + lastSavedAt: Date.now(), + // eslint-disable-next-line stylistic/max-len -- Long property names exceed limit after try-block indent + lifetimeAchievementsUnlocked: playerRecord.lifetimeAchievementsUnlocked, + // eslint-disable-next-line stylistic/max-len -- Long property names exceed limit after try-block indent + lifetimeAdventurersRecruited: playerRecord.lifetimeAdventurersRecruited, + lifetimeBossesDefeated: playerRecord.lifetimeBossesDefeated, + lifetimeClicks: playerRecord.lifetimeClicks, + lifetimeGoldEarned: playerRecord.lifetimeGoldEarned, + lifetimeQuestsCompleted: playerRecord.lifetimeQuestsCompleted, + totalClicks: 0, + totalGoldEarned: 0, + username: playerRecord.username, + }, + playerRecord.characterName, + ); + const createdAt = Date.now(); + await prisma.gameState.create({ + data: { + discordId: discordId, + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ + state: freshState as object, + updatedAt: createdAt, + }, + }); + const secret = process.env.ANTI_CHEAT_SECRET; + + // Sign the state for anti-cheat verification + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 3 -- @preserve */ + const signature = secret === undefined + ? undefined + : computeHmac(JSON.stringify(freshState), secret); + return context.json({ + currentSchemaVersion: currentSchemaVersion, + loginBonus: null, + loginStreak: playerRecord.loginStreak, + offlineEssence: 0, + offlineGold: 0, + offlineSeconds: 0, + schemaOutdated: false, + signature: signature, + state: freshState, + }); + } + + const rawState: unknown = record.state; + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ + const state = rawState as GameState; + + /* + * Always sync character name from the Player record — the profile update route + * writes to Player.characterName directly, bypassing the game state blob. + */ + if (playerRecord !== null) { + state.player.characterName = playerRecord.characterName; + } + + const now = Date.now(); + + const { offlineGold, offlineEssence, offlineSeconds } + = calculateOfflineEarnings(state, now); + + if (offlineGold > 0) { + state.resources.gold = state.resources.gold + offlineGold; + state.player.totalGoldEarned = state.player.totalGoldEarned + offlineGold; + } + + if (offlineEssence > 0) { + state.resources.essence = state.resources.essence + offlineEssence; + } + + // Generate or reset daily challenges if a new day has begun + state.dailyChallenges = getOrResetDailyChallenges(state); + + // Daily login bonus — award once per calendar day (UTC) + const todayUTC = new Date().toISOString(). + slice(0, 10); + const yesterdayUTC = new Date(now - 86_400_000).toISOString(). + slice(0, 10); + let loginBonus: LoginBonusResult | null = null; + + // Default loginStreak to 1 for brand-new accounts + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next -- @preserve */ + let loginStreak = playerRecord?.loginStreak ?? 1; + + if (playerRecord && playerRecord.lastLoginDate !== todayUTC) { + const previousStreak = playerRecord.loginStreak; + const updatedStreak + = playerRecord.lastLoginDate === yesterdayUTC + ? previousStreak + 1 + : 1; + const dayIndex = (updatedStreak - 1) % 7; + const weekMultiplier = Math.floor((updatedStreak - 1) / 7) + 1; + const reward = dailyRewards[dayIndex]; + + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 2 -- @preserve */ + const goldEarned = (reward?.goldBase ?? 500) * weekMultiplier; + const crystalsEarned = (reward?.crystals ?? 0) * weekMultiplier; + + state.resources.gold = Math.min( + state.resources.gold + goldEarned, + resourceCap, + ); + state.player.totalGoldEarned = state.player.totalGoldEarned + goldEarned; + state.resources.crystals = Math.min( + state.resources.crystals + crystalsEarned, + resourceCap, + ); + + loginStreak = updatedStreak; + loginBonus = { + crystalsEarned: crystalsEarned, + day: dayIndex + 1, + goldEarned: goldEarned, + streak: updatedStreak, + weekMultiplier: weekMultiplier, + }; + + await prisma.player. + update({ + data: { lastLoginDate: todayUTC, loginStreak: updatedStreak }, + where: { discordId }, + }). + catch((error: unknown) => { + // Ignore write-conflict errors (P2034) — rethrow anything else + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 5 -- @preserve */ + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma error shape */ + const { code } = error as { code?: string }; + if (code !== "P2034") { + throw error; + } + }); + } + + state.lastTickAt = now; + + if (offlineGold > 0 || offlineEssence > 0 || loginBonus !== null) { + // Persist updated state immediately so offline/login rewards aren't double-counted. + /* + * Swallow write conflicts (P2034): offline earnings and login bonus are applied + * server-side and must be persisted immediately so they aren't double-counted. + */ + await prisma.gameState. + update({ + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ + data: { state: state as object, updatedAt: now }, + where: { discordId }, + }). + catch((error: unknown) => { + // Ignore write-conflict errors (P2034) — rethrow anything else + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 5 -- @preserve */ + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma error shape */ + const { code } = error as { code?: string }; + if (code !== "P2034") { + throw error; + } + }); + } + + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next -- @preserve */ + const schemaOutdated = (state.schemaVersion ?? 0) < currentSchemaVersion; + + const secret = process.env.ANTI_CHEAT_SECRET; + const signature = secret === undefined + ? undefined + : computeHmac(JSON.stringify(state), secret); + return context.json({ + currentSchemaVersion, + loginBonus, + loginStreak, + offlineEssence, + offlineGold, + offlineSeconds, + schemaOutdated, + signature, + state, + }); + } catch (error) { + void logger.error( + "game_load", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } +}); + +gameRouter.post("/save", async(context) => { + try { + const discordId = context.get("discordId"); + const body = await context.req.json(); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Defensive check for malformed requests + if (body.state === null || body.state === undefined) { + return context.json({ error: "Missing state in request body" }, 400); + } + + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next -- @preserve */ + if ((body.state.schemaVersion ?? 0) < currentSchemaVersion) { + return context.json( + { + // eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened + error: "Save rejected: outdated save. Reset your progress to continue.", + }, + 409, + ); + } + + const secret = process.env.ANTI_CHEAT_SECRET; + const [ record, playerRecord ] = await Promise.all([ + prisma.gameState.findUnique({ where: { discordId } }), + prisma.player.findUnique({ where: { discordId } }), + ]); + + let stateToSave = body.state; + + if (record) { + const rawPreviousState: unknown = record.state; + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ + const previousState = rawPreviousState as GameState; + + // Option D: verify HMAC signature if the secret is configured and client sent one + if (secret !== undefined && body.signature !== undefined) { + const expectedSig = computeHmac(JSON.stringify(previousState), secret); + if (body.signature !== expectedSig) { + return context.json( + { error: "Save rejected: signature mismatch" }, + 400, + ); + } + } + + // Option A: sanitise the incoming state against the previous to block rollbacks and cap cheats + stateToSave = validateAndSanitize(body.state, previousState); + } + + const now = Date.now(); + + /* + * Stamp the authoritative save timestamp into the state blob so that on the + * next load the client reads the correct value from state.player.lastSavedAt. + */ + stateToSave = { + ...stateToSave, + player: { ...stateToSave.player, lastSavedAt: now }, + }; + + /* + * Preserve the Player record's character name so that profile updates are not + * overwritten by the next auto-save (profile PUT writes to Player, not the blob). + */ + stateToSave = { + ...stateToSave, + player: { + ...stateToSave.player, + characterName: + playerRecord?.characterName ?? stateToSave.player.characterName, + }, + }; + + /* + * Recompute companion unlocks server-side using DB-authoritative player lifetime stats. + * This prevents clients from claiming companions they haven't legitimately unlocked. + */ + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 8 -- @preserve */ + const companionUnlocks = computeUnlockedCompanionIds({ + apotheosisCount: stateToSave.apotheosis?.count ?? 0, + lifetimeBossesDefeated: playerRecord?.lifetimeBossesDefeated ?? 0, + lifetimeGoldEarned: playerRecord?.lifetimeGoldEarned ?? 0, + lifetimeQuestsCompleted: playerRecord?.lifetimeQuestsCompleted ?? 0, + prestigeCount: stateToSave.prestige.count, + transcendenceCount: stateToSave.transcendence?.count ?? 0, + }); + const clientActiveCompanionId + = stateToSave.companions?.activeCompanionId ?? null; + const validatedActiveCompanionId + = clientActiveCompanionId !== null + && companionUnlocks.includes(clientActiveCompanionId) + ? clientActiveCompanionId + : null; + stateToSave = { + ...stateToSave, + companions: { + activeCompanionId: validatedActiveCompanionId, + unlockedCompanionIds: companionUnlocks, + }, + }; + + const currentUnlocked = parseUnlockedTitles(playerRecord?.unlockedTitles); + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 6 -- @preserve */ + const updatedTitles = checkAndUnlockTitles({ + createdAt: playerRecord?.createdAt ?? Date.now(), + currentUnlocked: currentUnlocked, + guildName: playerRecord?.guildName ?? "", + state: stateToSave, + }); + const updatedUnlocked + = updatedTitles.length > 0 + ? [ ...currentUnlocked, ...updatedTitles ] + : undefined; + + await prisma.player.update({ + data: { + characterName: stateToSave.player.characterName, + lastSavedAt: now, + totalClicks: stateToSave.player.totalClicks, + totalGoldEarned: stateToSave.player.totalGoldEarned, + ...updatedUnlocked + ? { unlockedTitles: updatedUnlocked } + : {}, + }, + where: { discordId }, + }); + + await prisma.gameState.upsert({ + create: { + discordId: discordId, + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never */ + state: stateToSave as unknown as never, + updatedAt: now, + }, + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never */ + update: { state: stateToSave as unknown as never, updatedAt: now }, + where: { discordId }, + }); + + const signature = secret === undefined + ? undefined + : computeHmac(JSON.stringify(stateToSave), secret); + return context.json({ savedAt: now, signature: signature }); + } catch (error) { + void logger.error( + "game_save", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } +}); + +gameRouter.post("/reset", async(context) => { + try { + const discordId = context.get("discordId"); + + const playerRecord = await prisma.player.findUnique({ + where: { discordId }, + }); if (!playerRecord) { return context.json({ error: "No player found" }, 404); } + const freshState = initialGameState( { avatar: playerRecord.avatar, @@ -713,23 +1083,25 @@ gameRouter.get("/load", async(context) => { }, playerRecord.characterName, ); + const createdAt = Date.now(); - await prisma.gameState.create({ - data: { + await prisma.gameState.upsert({ + create: { discordId: discordId, /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ state: freshState as object, updatedAt: createdAt, }, + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ + update: { state: freshState as object, updatedAt: createdAt }, + where: { discordId }, }); - const secret = process.env.ANTI_CHEAT_SECRET; - // Sign the state for anti-cheat verification - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next 3 -- @preserve */ + const secret = process.env.ANTI_CHEAT_SECRET; const signature = secret === undefined ? undefined : computeHmac(JSON.stringify(freshState), secret); + return context.json({ currentSchemaVersion: currentSchemaVersion, loginBonus: null, @@ -741,351 +1113,15 @@ gameRouter.get("/load", async(context) => { signature: signature, state: freshState, }); - } - - const rawState: unknown = record.state; - /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ - const state = rawState as GameState; - - /* - * Always sync character name from the Player record — the profile update route - * writes to Player.characterName directly, bypassing the game state blob. - */ - if (playerRecord !== null) { - state.player.characterName = playerRecord.characterName; - } - - const now = Date.now(); - - const { offlineGold, offlineEssence, offlineSeconds } - = calculateOfflineEarnings(state, now); - - if (offlineGold > 0) { - state.resources.gold = state.resources.gold + offlineGold; - state.player.totalGoldEarned = state.player.totalGoldEarned + offlineGold; - } - - if (offlineEssence > 0) { - state.resources.essence = state.resources.essence + offlineEssence; - } - - // Generate or reset daily challenges if a new day has begun - state.dailyChallenges = getOrResetDailyChallenges(state); - - // Daily login bonus — award once per calendar day (UTC) - const todayUTC = new Date().toISOString(). - slice(0, 10); - const yesterdayUTC = new Date(now - 86_400_000).toISOString(). - slice(0, 10); - let loginBonus: LoginBonusResult | null = null; - - // Default loginStreak to 1 for brand-new accounts - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next -- @preserve */ - let loginStreak = playerRecord?.loginStreak ?? 1; - - if (playerRecord && playerRecord.lastLoginDate !== todayUTC) { - const previousStreak = playerRecord.loginStreak; - const updatedStreak - = playerRecord.lastLoginDate === yesterdayUTC - ? previousStreak + 1 - : 1; - const dayIndex = (updatedStreak - 1) % 7; - const weekMultiplier = Math.floor((updatedStreak - 1) / 7) + 1; - const reward = dailyRewards[dayIndex]; - - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next 2 -- @preserve */ - const goldEarned = (reward?.goldBase ?? 500) * weekMultiplier; - const crystalsEarned = (reward?.crystals ?? 0) * weekMultiplier; - - state.resources.gold = Math.min( - state.resources.gold + goldEarned, - resourceCap, + } catch (error) { + void logger.error( + "game_reset", + error instanceof Error + ? error + : new Error(String(error)), ); - state.player.totalGoldEarned = state.player.totalGoldEarned + goldEarned; - state.resources.crystals = Math.min( - state.resources.crystals + crystalsEarned, - resourceCap, - ); - - loginStreak = updatedStreak; - loginBonus = { - crystalsEarned: crystalsEarned, - day: dayIndex + 1, - goldEarned: goldEarned, - streak: updatedStreak, - weekMultiplier: weekMultiplier, - }; - - await prisma.player. - update({ - data: { lastLoginDate: todayUTC, loginStreak: updatedStreak }, - where: { discordId }, - }). - catch((error: unknown) => { - // Ignore write-conflict errors (P2034) — rethrow anything else - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next 5 -- @preserve */ - /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma error shape */ - const { code } = error as { code?: string }; - if (code !== "P2034") { - throw error; - } - }); + return context.json({ error: "Internal server error" }, 500); } - - state.lastTickAt = now; - - if (offlineGold > 0 || offlineEssence > 0 || loginBonus !== null) { - // Persist updated state immediately so offline/login rewards aren't double-counted. - /* - * Swallow write conflicts (P2034): offline earnings and login bonus are applied - * server-side and must be persisted immediately so they aren't double-counted. - */ - await prisma.gameState. - update({ - /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ - data: { state: state as object, updatedAt: now }, - where: { discordId }, - }). - catch((error: unknown) => { - // Ignore write-conflict errors (P2034) — rethrow anything else - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next 5 -- @preserve */ - /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma error shape */ - const { code } = error as { code?: string }; - if (code !== "P2034") { - throw error; - } - }); - } - - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next -- @preserve */ - const schemaOutdated = (state.schemaVersion ?? 0) < currentSchemaVersion; - - const secret = process.env.ANTI_CHEAT_SECRET; - const signature = secret === undefined - ? undefined - : computeHmac(JSON.stringify(state), secret); - return context.json({ - currentSchemaVersion, - loginBonus, - loginStreak, - offlineEssence, - offlineGold, - offlineSeconds, - schemaOutdated, - signature, - state, - }); -}); - -gameRouter.post("/save", async(context) => { - const discordId = context.get("discordId"); - const body = await context.req.json(); - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Defensive check for malformed requests - if (body.state === null || body.state === undefined) { - return context.json({ error: "Missing state in request body" }, 400); - } - - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next -- @preserve */ - if ((body.state.schemaVersion ?? 0) < currentSchemaVersion) { - return context.json( - { - error: "Save rejected: outdated save. Reset your progress to continue.", - }, - 409, - ); - } - - const secret = process.env.ANTI_CHEAT_SECRET; - const [ record, playerRecord ] = await Promise.all([ - prisma.gameState.findUnique({ where: { discordId } }), - prisma.player.findUnique({ where: { discordId } }), - ]); - - let stateToSave = body.state; - - if (record) { - const rawPreviousState: unknown = record.state; - /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ - const previousState = rawPreviousState as GameState; - - // Option D: verify HMAC signature if the secret is configured and client sent one - if (secret !== undefined && body.signature !== undefined) { - const expectedSig = computeHmac(JSON.stringify(previousState), secret); - if (body.signature !== expectedSig) { - return context.json( - { error: "Save rejected: signature mismatch" }, - 400, - ); - } - } - - // Option A: sanitise the incoming state against the previous to block rollbacks and cap cheats - stateToSave = validateAndSanitize(body.state, previousState); - } - - const now = Date.now(); - - /* - * Stamp the authoritative save timestamp into the state blob so that on the - * next load the client reads the correct value from state.player.lastSavedAt. - */ - stateToSave = { - ...stateToSave, - player: { ...stateToSave.player, lastSavedAt: now }, - }; - - /* - * Preserve the Player record's character name so that profile updates are not - * overwritten by the next auto-save (profile PUT writes to Player, not the blob). - */ - stateToSave = { - ...stateToSave, - player: { - ...stateToSave.player, - characterName: - playerRecord?.characterName ?? stateToSave.player.characterName, - }, - }; - - /* - * Recompute companion unlocks server-side using DB-authoritative player lifetime stats. - * This prevents clients from claiming companions they haven't legitimately unlocked. - */ - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next 8 -- @preserve */ - const companionUnlocks = computeUnlockedCompanionIds({ - apotheosisCount: stateToSave.apotheosis?.count ?? 0, - lifetimeBossesDefeated: playerRecord?.lifetimeBossesDefeated ?? 0, - lifetimeGoldEarned: playerRecord?.lifetimeGoldEarned ?? 0, - lifetimeQuestsCompleted: playerRecord?.lifetimeQuestsCompleted ?? 0, - prestigeCount: stateToSave.prestige.count, - transcendenceCount: stateToSave.transcendence?.count ?? 0, - }); - const clientActiveCompanionId - = stateToSave.companions?.activeCompanionId ?? null; - const validatedActiveCompanionId - = clientActiveCompanionId !== null - && companionUnlocks.includes(clientActiveCompanionId) - ? clientActiveCompanionId - : null; - stateToSave = { - ...stateToSave, - companions: { - activeCompanionId: validatedActiveCompanionId, - unlockedCompanionIds: companionUnlocks, - }, - }; - - const currentUnlocked = parseUnlockedTitles(playerRecord?.unlockedTitles); - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next 6 -- @preserve */ - const updatedTitles = checkAndUnlockTitles({ - createdAt: playerRecord?.createdAt ?? Date.now(), - currentUnlocked: currentUnlocked, - guildName: playerRecord?.guildName ?? "", - state: stateToSave, - }); - const updatedUnlocked - = updatedTitles.length > 0 - ? [ ...currentUnlocked, ...updatedTitles ] - : undefined; - - await prisma.player.update({ - data: { - characterName: stateToSave.player.characterName, - lastSavedAt: now, - totalClicks: stateToSave.player.totalClicks, - totalGoldEarned: stateToSave.player.totalGoldEarned, - ...updatedUnlocked - ? { unlockedTitles: updatedUnlocked } - : {}, - }, - where: { discordId }, - }); - - await prisma.gameState.upsert({ - create: { - discordId: discordId, - /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never */ - state: stateToSave as unknown as never, - updatedAt: now, - }, - /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never */ - update: { state: stateToSave as unknown as never, updatedAt: now }, - where: { discordId }, - }); - - const signature = secret === undefined - ? undefined - : computeHmac(JSON.stringify(stateToSave), secret); - return context.json({ savedAt: now, signature: signature }); -}); - -gameRouter.post("/reset", async(context) => { - const discordId = context.get("discordId"); - - const playerRecord = await prisma.player.findUnique({ where: { discordId } }); - if (!playerRecord) { - return context.json({ error: "No player found" }, 404); - } - - const freshState = initialGameState( - { - avatar: playerRecord.avatar, - characterName: playerRecord.characterName, - createdAt: playerRecord.createdAt, - discordId: playerRecord.discordId, - discriminator: playerRecord.discriminator, - lastSavedAt: Date.now(), - lifetimeAchievementsUnlocked: playerRecord.lifetimeAchievementsUnlocked, - lifetimeAdventurersRecruited: playerRecord.lifetimeAdventurersRecruited, - lifetimeBossesDefeated: playerRecord.lifetimeBossesDefeated, - lifetimeClicks: playerRecord.lifetimeClicks, - lifetimeGoldEarned: playerRecord.lifetimeGoldEarned, - lifetimeQuestsCompleted: playerRecord.lifetimeQuestsCompleted, - totalClicks: 0, - totalGoldEarned: 0, - username: playerRecord.username, - }, - playerRecord.characterName, - ); - - const createdAt = Date.now(); - await prisma.gameState.upsert({ - create: { - discordId: discordId, - /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ - state: freshState as object, - updatedAt: createdAt, - }, - /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ - update: { state: freshState as object, updatedAt: createdAt }, - where: { discordId }, - }); - - const secret = process.env.ANTI_CHEAT_SECRET; - const signature = secret === undefined - ? undefined - : computeHmac(JSON.stringify(freshState), secret); - - return context.json({ - currentSchemaVersion: currentSchemaVersion, - loginBonus: null, - loginStreak: playerRecord.loginStreak, - offlineEssence: 0, - offlineGold: 0, - offlineSeconds: 0, - schemaOutdated: false, - signature: signature, - state: freshState, - }); }); export { gameRouter }; diff --git a/apps/api/src/routes/leaderboards.ts b/apps/api/src/routes/leaderboards.ts index a4e0d5c..dce68eb 100644 --- a/apps/api/src/routes/leaderboards.ts +++ b/apps/api/src/routes/leaderboards.ts @@ -9,6 +9,7 @@ import { Hono } from "hono"; import { gameTitles } from "../data/titles.js"; import { prisma } from "../db/client.js"; +import { logger } from "../services/logger.js"; import type { HonoEnvironment } from "../types/hono.js"; import type { GameState } from "@elysium/types"; @@ -58,70 +59,80 @@ const resolveTitleName = (titleId: string | null): string => { }; leaderboardRouter.get("/", async(context) => { - const category = context.req.query("category") ?? "totalGold"; - const limitRaw = Number(context.req.query("limit") ?? "100"); - const limit = Math.min(Math.max(1, limitRaw), 100); + try { + const category = context.req.query("category") ?? "totalGold"; + const limitRaw = Number(context.req.query("limit") ?? "100"); + const limit = Math.min(Math.max(1, limitRaw), 100); - if (!validCategories.has(category)) { - return context.json({ error: "Invalid category" }, 400); - } + if (!validCategories.has(category)) { + return context.json({ error: "Invalid category" }, 400); + } - const [ players, gameStates ] = await Promise.all([ - prisma.player.findMany(), - gameStateCategories.has(category) - ? prisma.gameState.findMany() - : Promise.resolve([]), - ]); + const [ players, gameStates ] = await Promise.all([ + prisma.player.findMany(), + gameStateCategories.has(category) + ? prisma.gameState.findMany() + : Promise.resolve([]), + ]); - const stateMap = new Map( - gameStates.map((gs) => { - /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */ - return [ gs.discordId, gs.state as unknown as GameState ]; - }), - ); + const stateMap = new Map( + gameStates.map((gs) => { + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */ + return [ gs.discordId, gs.state as unknown as GameState ]; + }), + ); - const entries = players. - filter((player) => { - return parseShowOnLeaderboards(player.profileSettings); - }). - map((player) => { - let value = 0; - if (category === "totalGold") { - value = player.lifetimeGoldEarned; - } else if (category === "bossesDefeated") { - value = player.lifetimeBossesDefeated; - } else if (category === "questsCompleted") { - value = player.lifetimeQuestsCompleted; - } else if (category === "achievementsUnlocked") { - value = player.lifetimeAchievementsUnlocked; - } else { - const state = stateMap.get(player.discordId); - if (category === "prestigeCount") { - value = state?.prestige.count ?? 0; - } else if (category === "transcendenceCount") { - value = state?.transcendence?.count ?? 0; - } else if (category === "apotheosisCount") { - value = state?.apotheosis?.count ?? 0; + const entries = players. + filter((player) => { + return parseShowOnLeaderboards(player.profileSettings); + }). + map((player) => { + let value = 0; + if (category === "totalGold") { + value = player.lifetimeGoldEarned; + } else if (category === "bossesDefeated") { + value = player.lifetimeBossesDefeated; + } else if (category === "questsCompleted") { + value = player.lifetimeQuestsCompleted; + } else if (category === "achievementsUnlocked") { + value = player.lifetimeAchievementsUnlocked; + } else { + const state = stateMap.get(player.discordId); + if (category === "prestigeCount") { + value = state?.prestige.count ?? 0; + } else if (category === "transcendenceCount") { + value = state?.transcendence?.count ?? 0; + } else if (category === "apotheosisCount") { + value = state?.apotheosis?.count ?? 0; + } } - } - return { - activeTitle: resolveTitleName(player.activeTitle), - avatar: player.avatar ?? null, - characterName: player.characterName, - discordId: player.discordId, - username: player.username, - value: value, - }; - }). - sort((a, b) => { - return b.value - a.value; - }). - slice(0, limit). - map((entry, index) => { - return { ...entry, rank: index + 1 }; - }); + return { + activeTitle: resolveTitleName(player.activeTitle), + avatar: player.avatar ?? null, + characterName: player.characterName, + discordId: player.discordId, + username: player.username, + value: value, + }; + }). + sort((a, b) => { + return b.value - a.value; + }). + slice(0, limit). + map((entry, index) => { + return { ...entry, rank: index + 1 }; + }); - return context.json({ category, entries }); + return context.json({ category, entries }); + } catch (error) { + void logger.error( + "leaderboards", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } }); export { leaderboardRouter }; diff --git a/apps/api/src/routes/prestige.ts b/apps/api/src/routes/prestige.ts index 6239f3e..f482d5a 100644 --- a/apps/api/src/routes/prestige.ts +++ b/apps/api/src/routes/prestige.ts @@ -6,11 +6,13 @@ */ /* eslint-disable max-lines-per-function -- Route handlers require many steps */ /* eslint-disable max-statements -- Route handlers require many statements */ +/* eslint-disable complexity -- Route handlers have inherent complexity */ import { Hono } from "hono"; import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js"; import { prisma } from "../db/client.js"; import { authMiddleware } from "../middleware/auth.js"; import { updateChallengeProgress } from "../services/dailyChallenges.js"; +import { logger } from "../services/logger.js"; import { buildPostPrestigeState, computeRunestoneMultipliers, @@ -25,190 +27,217 @@ const prestigeRouter = new Hono(); prestigeRouter.use("*", authMiddleware); prestigeRouter.post("/", async(context) => { - const discordId = context.get("discordId"); + try { + const discordId = context.get("discordId"); - const record = await prisma.gameState.findUnique({ where: { discordId } }); + const record = await prisma.gameState.findUnique({ where: { discordId } }); - if (!record) { - return context.json({ error: "No save found" }, 404); - } + 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; + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */ + const state = record.state as unknown as GameState; - if (!isEligibleForPrestige(state)) { - return context.json( - { - error: "Not eligible for prestige — collect 1,000,000 total gold first", + if (!isEligibleForPrestige(state)) { + return context.json( + { + // eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened + error: "Not eligible for prestige — collect 1,000,000 total gold first", + }, + 400, + ); + } + + // Update daily prestige challenge progress before resetting the run + let updatedDailyChallenges = state.dailyChallenges; + let challengeCrystals = 0; + if (updatedDailyChallenges) { + const result = updateChallengeProgress( + updatedDailyChallenges, + "prestige", + 1, + ); + updatedDailyChallenges = result.updatedChallenges; + challengeCrystals = result.crystalsAwarded; + } + + const { + milestoneRunestones, + prestigeData, + prestigeState, + runestonesEarned, + } = buildPostPrestigeState(state, state.player.characterName); + + // Preserve daily challenges across the prestige reset and apply any crystal rewards + const finalState: GameState = { + ...prestigeState, + ...updatedDailyChallenges === undefined + ? {} + : { dailyChallenges: updatedDailyChallenges }, + resources: { + ...prestigeState.resources, + crystals: prestigeState.resources.crystals + challengeCrystals, }, - 400, - ); - } + }; - // Update daily prestige challenge progress before resetting the run - let updatedDailyChallenges = state.dailyChallenges; - let challengeCrystals = 0; - if (updatedDailyChallenges) { - const result = updateChallengeProgress( - updatedDailyChallenges, + // Capture current-run stats to accumulate into lifetime totals before resetting + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 10 -- @preserve */ + const runBossesDefeated = state.bosses.filter((boss) => { + return boss.status === "defeated"; + }).length; + 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: finalState 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 — never reset + lifetimeGoldEarned: { increment: state.player.totalGoldEarned }, + + lifetimeQuestsCompleted: { increment: runQuestsCompleted }, + + totalClicks: 0, + // Reset current-run counters + totalGoldEarned: 0, + }, + where: { discordId }, + }); + + const prestigeCount = prestigeData.count; + void logger.metric("prestige", 1, { discordId, prestigeCount }); + void postMilestoneWebhook(discordId, "prestige", { + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next -- @preserve */ + apotheosis: prestigeState.apotheosis?.count ?? 0, + + prestige: prestigeData.count, + + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 2 -- @preserve */ + transcendence: prestigeState.transcendence?.count ?? 0, + }); + + return context.json({ + milestoneRunestones: milestoneRunestones, + newPrestigeCount: prestigeData.count, // eslint-disable-line unicorn/no-keyword-prefix -- API response field name required by client + runestones: runestonesEarned, + }); + } catch (error) { + void logger.error( "prestige", - 1, + error instanceof Error + ? error + : new Error(String(error)), ); - updatedDailyChallenges = result.updatedChallenges; - challengeCrystals = result.crystalsAwarded; + return context.json({ error: "Internal server error" }, 500); } - - const { - milestoneRunestones, - prestigeData, - prestigeState, - runestonesEarned, - } = buildPostPrestigeState(state, state.player.characterName); - - // Preserve daily challenges across the prestige reset and apply any crystal rewards - const finalState: GameState = { - ...prestigeState, - ...updatedDailyChallenges === undefined - ? {} - : { dailyChallenges: updatedDailyChallenges }, - resources: { - ...prestigeState.resources, - crystals: prestigeState.resources.crystals + challengeCrystals, - }, - }; - - // Capture current-run stats to accumulate into lifetime totals before resetting - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next 10 -- @preserve */ - const runBossesDefeated = state.bosses.filter((boss) => { - return boss.status === "defeated"; - }).length; - 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: finalState 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 — never reset - lifetimeGoldEarned: { increment: state.player.totalGoldEarned }, - - lifetimeQuestsCompleted: { increment: runQuestsCompleted }, - - totalClicks: 0, - // Reset current-run counters - totalGoldEarned: 0, - }, - where: { discordId }, - }); - - void postMilestoneWebhook(discordId, "prestige", { - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next -- @preserve */ - apotheosis: prestigeState.apotheosis?.count ?? 0, - - prestige: prestigeData.count, - - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next 2 -- @preserve */ - transcendence: prestigeState.transcendence?.count ?? 0, - }); - - return context.json({ - milestoneRunestones: milestoneRunestones, - newPrestigeCount: prestigeData.count, // eslint-disable-line unicorn/no-keyword-prefix -- API response field name required by client - runestones: runestonesEarned, - }); }); prestigeRouter.post("/buy-upgrade", async(context) => { - const discordId = context.get("discordId"); - const body = await context.req.json(); + 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); - } + 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); + } - const upgrade = defaultPrestigeUpgrades.find((prestigeUpgrade) => { - return prestigeUpgrade.id === upgradeId; - }); - if (!upgrade) { - return context.json({ error: "Unknown prestige upgrade" }, 404); - } + const upgrade = defaultPrestigeUpgrades.find((prestigeUpgrade) => { + return prestigeUpgrade.id === upgradeId; + }); + if (!upgrade) { + return context.json({ error: "Unknown prestige upgrade" }, 404); + } - const record = await prisma.gameState.findUnique({ where: { discordId } }); - if (!record) { - return context.json({ error: "No save found" }, 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; - const { purchasedUpgradeIds, runestones } = state.prestige; + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */ + const state = record.state as unknown as GameState; + const { purchasedUpgradeIds, runestones } = state.prestige; - if (purchasedUpgradeIds.includes(upgradeId)) { - return context.json({ error: "Upgrade already purchased" }, 400); - } + if (purchasedUpgradeIds.includes(upgradeId)) { + return context.json({ error: "Upgrade already purchased" }, 400); + } - if (runestones < upgrade.runestonesCost) { - return context.json({ error: "Not enough runestones" }, 400); - } + if (runestones < upgrade.runestonesCost) { + return context.json({ error: "Not enough runestones" }, 400); + } - const updatedRunestones = runestones - upgrade.runestonesCost; - const updatedPurchasedUpgradeIds = [ ...purchasedUpgradeIds, upgradeId ]; + const updatedRunestones = runestones - upgrade.runestonesCost; + const updatedPurchasedUpgradeIds = [ ...purchasedUpgradeIds, upgradeId ]; - const updatedState: GameState = { - ...state, - prestige: { - ...state.prestige, + const updatedState: GameState = { + ...state, + prestige: { + ...state.prestige, + purchasedUpgradeIds: updatedPurchasedUpgradeIds, + runestones: updatedRunestones, + ...computeRunestoneMultipliers(updatedPurchasedUpgradeIds), + }, + }; + + 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 }, + }); + + const multipliers = computeRunestoneMultipliers(updatedPurchasedUpgradeIds); + + void logger.metric("prestige_upgrade_purchased", 1, { + discordId, + upgradeId, + }); + return context.json({ purchasedUpgradeIds: updatedPurchasedUpgradeIds, - runestones: updatedRunestones, - ...computeRunestoneMultipliers(updatedPurchasedUpgradeIds), - }, - }; - - 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 }, - }); - - const multipliers = computeRunestoneMultipliers(updatedPurchasedUpgradeIds); - - return context.json({ - purchasedUpgradeIds: updatedPurchasedUpgradeIds, - runestonesRemaining: updatedRunestones, - ...multipliers, - }); + runestonesRemaining: updatedRunestones, + ...multipliers, + }); + } catch (error) { + void logger.error( + "prestige_buy_upgrade", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } }); export { prestigeRouter }; diff --git a/apps/api/src/routes/profile.ts b/apps/api/src/routes/profile.ts index a57acea..e1e6414 100644 --- a/apps/api/src/routes/profile.ts +++ b/apps/api/src/routes/profile.ts @@ -20,6 +20,7 @@ import { Hono } from "hono"; import { gameTitles } from "../data/titles.js"; import { prisma } from "../db/client.js"; import { authMiddleware } from "../middleware/auth.js"; +import { logger } from "../services/logger.js"; import { parseUnlockedTitles } from "../services/titles.js"; import type { HonoEnvironment } from "../types/hono.js"; @@ -81,190 +82,210 @@ const resolveTitle = (id: string): { id: string; name: string } => { }; profileRouter.get("/:discordId", async(context) => { - const { discordId } = context.req.param(); + try { + const { discordId } = context.req.param(); - const [ player, gameStateRecord ] = await Promise.all([ - prisma.player.findUnique({ where: { discordId } }), - prisma.gameState.findUnique({ where: { discordId } }), - ]); + const [ player, gameStateRecord ] = await Promise.all([ + prisma.player.findUnique({ where: { discordId } }), + prisma.gameState.findUnique({ where: { discordId } }), + ]); - if (!player) { - return context.json({ error: "Player not found" }, 404); - } + if (!player) { + return context.json({ error: "Player not found" }, 404); + } - /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */ - const state = gameStateRecord?.state as unknown as GameState | undefined; - const prestigeCount = state?.prestige.count ?? 0; - const transcendenceCount = state?.transcendence?.count ?? 0; - const apotheosisCount = state?.apotheosis?.count ?? 0; - const profileSettings = parseProfileSettings(player.profileSettings); + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */ + const state = gameStateRecord?.state as unknown as GameState | undefined; + const prestigeCount = state?.prestige.count ?? 0; + const transcendenceCount = state?.transcendence?.count ?? 0; + const apotheosisCount = state?.apotheosis?.count ?? 0; + const profileSettings = parseProfileSettings(player.profileSettings); - const bossesDefeated - = state?.bosses.filter((boss) => { - return boss.status === "defeated"; - }).length ?? 0; - const questsCompleted - = state?.quests.filter((quest) => { - return quest.status === "completed"; - }).length ?? 0; + const bossesDefeated + = state?.bosses.filter((boss) => { + return boss.status === "defeated"; + }).length ?? 0; + const questsCompleted + = state?.quests.filter((quest) => { + return quest.status === "completed"; + }).length ?? 0; + + let adventurersRecruited = 0; + if (state) { + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 3 -- @preserve */ + for (const adventurer of state.adventurers) { + adventurersRecruited = adventurersRecruited + adventurer.count; + } + } - let adventurersRecruited = 0; - if (state) { // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 3 -- @preserve */ - for (const adventurer of state.adventurers) { - adventurersRecruited = adventurersRecruited + adventurer.count; - } - } + const achievementsUnlocked = (state?.achievements ?? []).filter((achievement) => { + return achievement.unlockedAt !== null; + }).length; - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next 3 -- @preserve */ - const achievementsUnlocked = (state?.achievements ?? []).filter((achievement) => { - return achievement.unlockedAt !== null; - }).length; - - const unlockedTitleIds = parseUnlockedTitles(player.unlockedTitles); - const unlockedTitles = unlockedTitleIds.map((id) => { - return resolveTitle(id); - }); - - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next 12 -- @preserve */ - const equippedItems = (state?.equipment ?? []). - filter((item) => { - return item.owned && item.equipped; - }). - map((item) => { - return { - bonus: item.bonus, - name: item.name, - rarity: item.rarity, - type: item.type, - }; + const unlockedTitleIds = parseUnlockedTitles(player.unlockedTitles); + const unlockedTitles = unlockedTitleIds.map((id) => { + return resolveTitle(id); }); - const completedChapters = state?.story?.completedChapters ?? []; + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 12 -- @preserve */ + const equippedItems = (state?.equipment ?? []). + filter((item) => { + return item.owned && item.equipped; + }). + map((item) => { + return { + bonus: item.bonus, + name: item.name, + rarity: item.rarity, + type: item.type, + }; + }); - return context.json({ - achievementsUnlocked: achievementsUnlocked, - activeTitle: player.activeTitle, - adventurersRecruited: adventurersRecruited, - apotheosisCount: apotheosisCount, - avatar: player.avatar, - bio: player.bio ?? "", - bossesDefeated: bossesDefeated, - characterClass: player.characterClass, - characterName: player.characterName, - characterRace: player.characterRace ?? "", - completedChapters: completedChapters, - createdAt: player.createdAt, - currentRunClicks: state?.player.totalClicks ?? 0, - currentRunGold: state?.player.totalGoldEarned ?? 0, - equippedItems: equippedItems, - guildDescription: player.guildDescription, - guildName: player.guildName, - lifetimeAchievementsUnlocked: player.lifetimeAchievementsUnlocked, - lifetimeAdventurersRecruited: player.lifetimeAdventurersRecruited, - lifetimeBossesDefeated: player.lifetimeBossesDefeated, - lifetimeQuestsCompleted: player.lifetimeQuestsCompleted, - prestigeCount: prestigeCount, - profileSettings: profileSettings, - pronouns: player.pronouns ?? "", - questsCompleted: questsCompleted, - totalClicks: player.lifetimeClicks, - totalGoldEarned: player.lifetimeGoldEarned, - transcendenceCount: transcendenceCount, - unlockedTitles: unlockedTitles, - username: player.username, - }); + const completedChapters = state?.story?.completedChapters ?? []; + + return context.json({ + achievementsUnlocked: achievementsUnlocked, + activeTitle: player.activeTitle, + adventurersRecruited: adventurersRecruited, + apotheosisCount: apotheosisCount, + avatar: player.avatar, + bio: player.bio ?? "", + bossesDefeated: bossesDefeated, + characterClass: player.characterClass, + characterName: player.characterName, + characterRace: player.characterRace ?? "", + completedChapters: completedChapters, + createdAt: player.createdAt, + currentRunClicks: state?.player.totalClicks ?? 0, + currentRunGold: state?.player.totalGoldEarned ?? 0, + equippedItems: equippedItems, + guildDescription: player.guildDescription, + guildName: player.guildName, + lifetimeAchievementsUnlocked: player.lifetimeAchievementsUnlocked, + lifetimeAdventurersRecruited: player.lifetimeAdventurersRecruited, + lifetimeBossesDefeated: player.lifetimeBossesDefeated, + lifetimeQuestsCompleted: player.lifetimeQuestsCompleted, + prestigeCount: prestigeCount, + profileSettings: profileSettings, + pronouns: player.pronouns ?? "", + questsCompleted: questsCompleted, + totalClicks: player.lifetimeClicks, + totalGoldEarned: player.lifetimeGoldEarned, + transcendenceCount: transcendenceCount, + unlockedTitles: unlockedTitles, + username: player.username, + }); + } catch (error) { + void logger.error( + "profile_get", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } }); profileRouter.put("/", authMiddleware, async(context) => { - const discordId = context.get("discordId"); - const body = await context.req.json(); + try { + const discordId = context.get("discordId"); + const body = await context.req.json(); - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation - if (!body.characterName) { - return context.json({ error: "Character name cannot be empty" }, 400); - } + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation + if (!body.characterName) { + return context.json({ error: "Character name cannot be empty" }, 400); + } - const characterName = body.characterName.trim().slice(0, 32); + const characterName = body.characterName.trim().slice(0, 32); - if (characterName === "") { - return context.json({ error: "Character name cannot be empty" }, 400); - } + if (characterName === "") { + return context.json({ error: "Character name cannot be empty" }, 400); + } - const pronouns = (body.pronouns ?? "").trim().slice(0, 20); - const characterRace = (body.characterRace ?? "").trim().slice(0, 32); - const characterClass = (body.characterClass ?? "").trim().slice(0, 32); - const bio = (body.bio ?? "").trim().slice(0, 200); - const guildName = (body.guildName ?? "").trim().slice(0, 64); - const guildDescription = (body.guildDescription ?? "").trim().slice(0, 500); - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next 2 -- @preserve */ - /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */ - const parsedNumberFormat = (body.profileSettings.numberFormat ?? "") as string; - const numberFormat = validNumberFormats.has(parsedNumberFormat) + const pronouns = (body.pronouns ?? "").trim().slice(0, 20); + const characterRace = (body.characterRace ?? "").trim().slice(0, 32); + const characterClass = (body.characterClass ?? "").trim().slice(0, 32); + const bio = (body.bio ?? "").trim().slice(0, 200); + const guildName = (body.guildName ?? "").trim().slice(0, 64); + const guildDescription = (body.guildDescription ?? "").trim().slice(0, 500); + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 2 -- @preserve */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */ - ? (parsedNumberFormat as ProfileSettings["numberFormat"]) - : "suffix"; - const profileSettings: ProfileSettings = { - enableNotifications: body.profileSettings.enableNotifications ?? false, - enableSounds: body.profileSettings.enableSounds ?? false, - numberFormat: numberFormat, - showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true, - showAdventurersRecruited: body.profileSettings.showAdventurersRecruited ?? true, - showApotheosis: body.profileSettings.showApotheosis ?? true, - showBossesDefeated: body.profileSettings.showBossesDefeated ?? true, - showCurrentClicks: body.profileSettings.showCurrentClicks ?? true, - showCurrentGold: body.profileSettings.showCurrentGold ?? true, - showGuildFounded: body.profileSettings.showGuildFounded ?? true, - showLifetimeAchievementsUnlocked: body.profileSettings.showLifetimeAchievementsUnlocked ?? true, - showLifetimeAdventurersRecruited: body.profileSettings.showLifetimeAdventurersRecruited ?? true, - showLifetimeBossesDefeated: body.profileSettings.showLifetimeBossesDefeated ?? true, - showLifetimeQuestsCompleted: body.profileSettings.showLifetimeQuestsCompleted ?? true, - showOnLeaderboards: body.profileSettings.showOnLeaderboards ?? true, - showPrestige: body.profileSettings.showPrestige ?? true, - showQuestsCompleted: body.profileSettings.showQuestsCompleted ?? true, - showTotalClicks: body.profileSettings.showTotalClicks ?? true, - showTotalGold: body.profileSettings.showTotalGold ?? true, - showTranscendence: body.profileSettings.showTranscendence ?? true, - }; + const parsedNumberFormat = (body.profileSettings.numberFormat ?? "") as string; + const numberFormat = validNumberFormats.has(parsedNumberFormat) + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */ + ? (parsedNumberFormat as ProfileSettings["numberFormat"]) + : "suffix"; + const profileSettings: ProfileSettings = { + enableNotifications: body.profileSettings.enableNotifications ?? false, + enableSounds: body.profileSettings.enableSounds ?? false, + numberFormat: numberFormat, + showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true, + showAdventurersRecruited: body.profileSettings.showAdventurersRecruited ?? true, + showApotheosis: body.profileSettings.showApotheosis ?? true, + showBossesDefeated: body.profileSettings.showBossesDefeated ?? true, + showCurrentClicks: body.profileSettings.showCurrentClicks ?? true, + showCurrentGold: body.profileSettings.showCurrentGold ?? true, + showGuildFounded: body.profileSettings.showGuildFounded ?? true, + showLifetimeAchievementsUnlocked: body.profileSettings.showLifetimeAchievementsUnlocked ?? true, + showLifetimeAdventurersRecruited: body.profileSettings.showLifetimeAdventurersRecruited ?? true, + showLifetimeBossesDefeated: body.profileSettings.showLifetimeBossesDefeated ?? true, + showLifetimeQuestsCompleted: body.profileSettings.showLifetimeQuestsCompleted ?? true, + showOnLeaderboards: body.profileSettings.showOnLeaderboards ?? true, + showPrestige: body.profileSettings.showPrestige ?? true, + showQuestsCompleted: body.profileSettings.showQuestsCompleted ?? true, + showTotalClicks: body.profileSettings.showTotalClicks ?? true, + showTotalGold: body.profileSettings.showTotalGold ?? true, + showTranscendence: body.profileSettings.showTranscendence ?? true, + }; - const activeTitle - = typeof body.activeTitle === "string" - ? body.activeTitle.slice(0, 64) - : undefined; + const activeTitle + = typeof body.activeTitle === "string" + ? body.activeTitle.slice(0, 64) + : undefined; - const updated = await prisma.player.update({ - data: { - bio: bio, - characterClass: characterClass, - characterName: characterName, - characterRace: characterRace, - guildDescription: guildDescription, - guildName: guildName, - /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ - profileSettings: profileSettings as object, - pronouns: pronouns, - ...activeTitle === undefined - ? {} - : { activeTitle }, - }, - where: { discordId }, - }); + const updated = await prisma.player.update({ + data: { + bio: bio, + characterClass: characterClass, + characterName: characterName, + characterRace: characterRace, + guildDescription: guildDescription, + guildName: guildName, + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ + profileSettings: profileSettings as object, + pronouns: pronouns, + ...activeTitle === undefined + ? {} + : { activeTitle }, + }, + where: { discordId }, + }); - return context.json({ - activeTitle: updated.activeTitle, - bio: updated.bio, - characterClass: updated.characterClass, - characterName: updated.characterName, - characterRace: updated.characterRace, - guildDescription: updated.guildDescription, - guildName: updated.guildName, - profileSettings: profileSettings, - pronouns: updated.pronouns, - }); + return context.json({ + activeTitle: updated.activeTitle, + bio: updated.bio, + characterClass: updated.characterClass, + characterName: updated.characterName, + characterRace: updated.characterRace, + guildDescription: updated.guildDescription, + guildName: updated.guildName, + profileSettings: profileSettings, + pronouns: updated.pronouns, + }); + } catch (error) { + void logger.error( + "profile_update", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } }); export { profileRouter }; diff --git a/apps/api/src/routes/transcendence.ts b/apps/api/src/routes/transcendence.ts index 47f1dbe..c2092b6 100644 --- a/apps/api/src/routes/transcendence.ts +++ b/apps/api/src/routes/transcendence.ts @@ -6,10 +6,12 @@ */ /* 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, @@ -24,168 +26,196 @@ const transcendenceRouter = new Hono(); transcendenceRouter.use("*", authMiddleware); transcendenceRouter.post("/", async(context) => { - const discordId = context.get("discordId"); + try { + const discordId = context.get("discordId"); - const record = await prisma.gameState.findUnique({ where: { discordId } }); - if (!record) { - return context.json({ error: "No save found" }, 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; + /* 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( - { - error: "Not eligible for transcendence — defeat The Absolute One first", + 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, }, - 400, + 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); } - - 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 }, - }); - - 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, - }); }); transcendenceRouter.post("/buy-upgrade", async(context) => { - const discordId = context.get("discordId"); - const body = await context.req.json(); + 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); - } + 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); + } - const upgrade = defaultTranscendenceUpgrades.find((transcendenceUpgrade) => { - return transcendenceUpgrade.id === upgradeId; - }); - if (!upgrade) { - return context.json({ error: "Unknown echo upgrade" }, 404); - } + // 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); - } + 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; + /* 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); - } + if (!state.transcendence) { + return context.json({ error: "No transcendence data found" }, 400); + } - const { purchasedUpgradeIds, echoes } = state.transcendence; + const { purchasedUpgradeIds, echoes } = state.transcendence; - if (purchasedUpgradeIds.includes(upgradeId)) { - return context.json({ error: "Upgrade already purchased" }, 400); - } + if (purchasedUpgradeIds.includes(upgradeId)) { + return context.json({ error: "Upgrade already purchased" }, 400); + } - if (echoes < upgrade.cost) { - return context.json({ error: "Not enough echoes" }, 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 updatedEchoes = echoes - upgrade.cost; + const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ]; + const updatedMultipliers + = computeTranscendenceMultipliers(updatedPurchasedIds); - const updatedState: GameState = { - ...state, - transcendence: { - ...state.transcendence, - echoes: updatedEchoes, + 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, - }, - }; - - 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 }, - }); - - 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 }; diff --git a/apps/api/src/services/discord.ts b/apps/api/src/services/discord.ts index 12ca446..ac37348 100644 --- a/apps/api/src/services/discord.ts +++ b/apps/api/src/services/discord.ts @@ -5,6 +5,7 @@ * @author Naomi Carrigan */ /* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */ +import { logger } from "./logger.js"; interface DiscordTokenResponse { access_token: string; @@ -50,18 +51,28 @@ const exchangeCode = async( redirect_uri: redirectUri, }); - const response = await fetch("https://discord.com/api/v10/oauth2/token", { - body: parameters.toString(), - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - method: "POST", - }); + try { + const response = await fetch("https://discord.com/api/v10/oauth2/token", { + body: parameters.toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); - if (!response.ok) { - throw new Error(`Discord token exchange failed: ${response.statusText}`); + if (!response.ok) { + throw new Error(`Discord token exchange failed: ${response.statusText}`); + } + + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordTokenResponse shape */ + return await (response.json() as Promise); + } catch (error) { + void logger.error( + "discord_exchange_code", + error instanceof Error + ? error + : new Error(String(error)), + ); + throw error; } - - /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordTokenResponse shape */ - return await (response.json() as Promise); }; /** @@ -73,16 +84,26 @@ const exchangeCode = async( const fetchDiscordUser = async( accessToken: string, ): Promise => { - const response = await fetch("https://discord.com/api/v10/users/@me", { - headers: { Authorization: `Bearer ${accessToken}` }, - }); + try { + const response = await fetch("https://discord.com/api/v10/users/@me", { + headers: { Authorization: `Bearer ${accessToken}` }, + }); - if (!response.ok) { - throw new Error(`Discord user fetch failed: ${response.statusText}`); + if (!response.ok) { + throw new Error(`Discord user fetch failed: ${response.statusText}`); + } + + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordUser shape */ + return await (response.json() as Promise); + } catch (error) { + void logger.error( + "discord_fetch_user", + error instanceof Error + ? error + : new Error(String(error)), + ); + throw error; } - - /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordUser shape */ - return await (response.json() as Promise); }; /** diff --git a/apps/api/src/services/logger.ts b/apps/api/src/services/logger.ts new file mode 100644 index 0000000..4add78e --- /dev/null +++ b/apps/api/src/services/logger.ts @@ -0,0 +1,12 @@ +/** + * @file Logger service for handling logging. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { Logger } from "@nhcarrigan/logger"; + +const logger = new Logger("Elysium", process.env.LOG_TOKEN ?? ""); + +export { logger }; diff --git a/apps/api/src/services/webhook.ts b/apps/api/src/services/webhook.ts index ab63e54..72ddf1f 100644 --- a/apps/api/src/services/webhook.ts +++ b/apps/api/src/services/webhook.ts @@ -5,6 +5,8 @@ * @author Naomi Carrigan */ /* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case and Pascal-case header names */ +import { logger } from "./logger.js"; + const discordApi = "https://discord.com/api/v10"; /** @@ -34,7 +36,13 @@ const grantApotheosisRole = async(discordId: string): Promise => { method: "PUT", }, ); - } catch { + } catch (error) { + void logger.error( + "webhook_apotheosis_role", + error instanceof Error + ? error + : new Error(String(error)), + ); // Graceful degradation — role grant failure must not affect the apotheosis } }; @@ -81,7 +89,13 @@ const postMilestoneWebhook = async( headers: { "Content-Type": "application/json" }, method: "POST", }); - } catch { + } catch (error) { + void logger.error( + "webhook_milestone", + error instanceof Error + ? error + : new Error(String(error)), + ); // Graceful degradation — webhook failure must not affect the game action } }; diff --git a/apps/api/test/middleware/auth.spec.ts b/apps/api/test/middleware/auth.spec.ts index 3d2e6ea..09167db 100644 --- a/apps/api/test/middleware/auth.spec.ts +++ b/apps/api/test/middleware/auth.spec.ts @@ -55,4 +55,15 @@ describe("authMiddleware", () => { })); expect(res.status).toBe(401); }); + + it("returns 401 when verifyToken throws a non-Error value", async () => { + const { app, verifyToken } = await makeApp(); + vi.mocked(verifyToken).mockImplementationOnce(() => { + throw "raw string error"; + }); + const res = await app.fetch(new Request("http://localhost/test", { + headers: { Authorization: "Bearer bad_token" }, + })); + expect(res.status).toBe(401); + }); }); diff --git a/apps/api/test/routes/apotheosis.spec.ts b/apps/api/test/routes/apotheosis.spec.ts index edf4b6f..a84cf71 100644 --- a/apps/api/test/routes/apotheosis.spec.ts +++ b/apps/api/test/routes/apotheosis.spec.ts @@ -80,6 +80,18 @@ describe("apotheosis route", () => { expect(res.status).toBe(400); }); + it("returns 500 when the database throws", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await post(); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); + const res = await post(); + expect(res.status).toBe(500); + }); + it("returns apotheosis count on success", async () => { // Need all 15 transcendence upgrades purchased for eligibility const allUpgradeIds = [ diff --git a/apps/api/test/routes/auth.spec.ts b/apps/api/test/routes/auth.spec.ts index 4094060..bd5de25 100644 --- a/apps/api/test/routes/auth.spec.ts +++ b/apps/api/test/routes/auth.spec.ts @@ -113,5 +113,14 @@ describe("auth route", () => { const location = res.headers.get("Location") ?? ""; expect(location).toContain("error=auth_failed"); }); + + it("redirects with error when callback throws a non-Error value", async () => { + const { app, exchangeCode } = await makeApp(); + exchangeCode.mockRejectedValueOnce("raw string error"); + const res = await app.fetch(new Request("http://localhost/auth/callback?code=bad_code")); + expect(res.status).toBe(302); + const location = res.headers.get("Location") ?? ""; + expect(location).toContain("error=auth_failed"); + }); }); }); diff --git a/apps/api/test/routes/boss.spec.ts b/apps/api/test/routes/boss.spec.ts index 4272bac..37b2aca 100644 --- a/apps/api/test/routes/boss.spec.ts +++ b/apps/api/test/routes/boss.spec.ts @@ -293,4 +293,16 @@ describe("boss route", () => { const body = await res.json() as { won: boolean }; expect(body.won).toBe(true); }); + + it("returns 500 when the database throws", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await challenge({ bossId: "test_boss" }); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); + const res = await challenge({ bossId: "test_boss" }); + expect(res.status).toBe(500); + }); }); diff --git a/apps/api/test/routes/craft.spec.ts b/apps/api/test/routes/craft.spec.ts index 0831d39..9e2d5f0 100644 --- a/apps/api/test/routes/craft.spec.ts +++ b/apps/api/test/routes/craft.spec.ts @@ -143,4 +143,16 @@ describe("craft route", () => { expect(body.recipeId).toBe(TEST_RECIPE_ID); expect(body.bonusType).toBe("gold_income"); }); + + it("returns 500 when the database throws", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await post({ recipeId: TEST_RECIPE_ID }); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); + const res = await post({ recipeId: TEST_RECIPE_ID }); + expect(res.status).toBe(500); + }); }); diff --git a/apps/api/test/routes/explore.spec.ts b/apps/api/test/routes/explore.spec.ts index 780e872..396cda3 100644 --- a/apps/api/test/routes/explore.spec.ts +++ b/apps/api/test/routes/explore.spec.ts @@ -406,5 +406,31 @@ describe("explore route", () => { expect(body.materialsFound.some((m) => m.materialId === "verdant_sap")).toBe(true); mockRandom.mockRestore(); }); + + it("returns 500 when the database throws on collect", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await postCollect({ areaId: TEST_AREA_ID }); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value on collect", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); + const res = await postCollect({ areaId: TEST_AREA_ID }); + expect(res.status).toBe(500); + }); + }); + + describe("POST /start error path", () => { + it("returns 500 when the database throws on start", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await postStart({ areaId: TEST_AREA_ID }); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value on start", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); + const res = await postStart({ areaId: TEST_AREA_ID }); + expect(res.status).toBe(500); + }); }); }); diff --git a/apps/api/test/routes/frontend.spec.ts b/apps/api/test/routes/frontend.spec.ts new file mode 100644 index 0000000..5ba327a --- /dev/null +++ b/apps/api/test/routes/frontend.spec.ts @@ -0,0 +1,136 @@ +/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Hono } from "hono"; + +vi.mock("../../src/services/logger.js", () => ({ + logger: { + log: vi.fn().mockResolvedValue(undefined), + error: vi.fn().mockResolvedValue(undefined), + }, +})); + +describe("frontend route", () => { + let loggerMock: { log: ReturnType; error: ReturnType }; + + beforeEach(async () => { + vi.clearAllMocks(); + const { logger } = await import("../../src/services/logger.js"); + loggerMock = logger as typeof loggerMock; + }); + + const makeApp = async () => { + const { frontendRouter } = await import("../../src/routes/frontend.js"); + const app = new Hono(); + app.route("/frontend", frontendRouter); + return app; + }; + + const postLog = async (body: unknown, contentType = "application/json") => { + const app = await makeApp(); + return app.fetch(new Request("http://localhost/frontend/log", { + method: "POST", + headers: { "Content-Type": contentType }, + body: typeof body === "string" ? body : JSON.stringify(body), + })); + }; + + const postError = async (body: unknown, contentType = "application/json") => { + const app = await makeApp(); + return app.fetch(new Request("http://localhost/frontend/error", { + method: "POST", + headers: { "Content-Type": contentType }, + body: typeof body === "string" ? body : JSON.stringify(body), + })); + }; + + describe("POST /log", () => { + it("returns 200 when level is debug and message is present", async () => { + const res = await postLog({ level: "debug", message: "test debug" }); + expect(res.status).toBe(200); + const body = await res.json() as { ok: boolean }; + expect(body.ok).toBe(true); + }); + + it("returns 200 when level is info and message is present", async () => { + const res = await postLog({ level: "info", message: "test info" }); + expect(res.status).toBe(200); + const body = await res.json() as { ok: boolean }; + expect(body.ok).toBe(true); + }); + + it("returns 200 when level is warn and message is present", async () => { + const res = await postLog({ level: "warn", message: "test warn" }); + expect(res.status).toBe(200); + const body = await res.json() as { ok: boolean }; + expect(body.ok).toBe(true); + }); + + it("returns 400 when level is invalid", async () => { + const res = await postLog({ level: "error", message: "test" }); + expect(res.status).toBe(400); + const body = await res.json() as { error: string }; + expect(body.error).toBe("level and message are required"); + }); + + it("returns 400 when level is missing", async () => { + const res = await postLog({ message: "test" }); + expect(res.status).toBe(400); + }); + + it("returns 400 when message is missing", async () => { + const res = await postLog({ level: "info" }); + expect(res.status).toBe(400); + }); + + it("returns 500 when request body is invalid JSON", async () => { + const res = await postLog("not valid json at all", "application/json"); + expect(res.status).toBe(500); + const body = await res.json() as { error: string }; + expect(body.error).toBe("Internal server error"); + }); + + it("returns 500 and covers non-Error branch when logger throws a raw value", async () => { + loggerMock.log.mockImplementationOnce(() => { throw "raw string error"; }); + const res = await postLog({ level: "info", message: "test" }); + expect(res.status).toBe(500); + const body = await res.json() as { error: string }; + expect(body.error).toBe("Internal server error"); + }); + }); + + describe("POST /error", () => { + it("returns 200 with valid context and message", async () => { + const res = await postError({ context: "SomeComponent", message: "Something went wrong" }); + expect(res.status).toBe(200); + const body = await res.json() as { ok: boolean }; + expect(body.ok).toBe(true); + }); + + it("returns 400 when context field is missing", async () => { + const res = await postError({ message: "Something went wrong" }); + expect(res.status).toBe(400); + const body = await res.json() as { error: string }; + expect(body.error).toBe("context and message are required"); + }); + + it("returns 400 when message field is missing", async () => { + const res = await postError({ context: "SomeComponent" }); + expect(res.status).toBe(400); + }); + + it("returns 500 when request body is invalid JSON", async () => { + const res = await postError("not valid json at all", "application/json"); + expect(res.status).toBe(500); + const body = await res.json() as { error: string }; + expect(body.error).toBe("Internal server error"); + }); + + it("returns 500 and covers non-Error branch when logger throws a raw value", async () => { + loggerMock.error.mockImplementationOnce(() => { throw "raw string error"; }); + const res = await postError({ context: "SomeComponent", message: "Something went wrong" }); + expect(res.status).toBe(500); + const body = await res.json() as { error: string }; + expect(body.error).toBe("Internal server error"); + }); + }); +}); diff --git a/apps/api/test/routes/game.spec.ts b/apps/api/test/routes/game.spec.ts index fac2962..b469ecf 100644 --- a/apps/api/test/routes/game.spec.ts +++ b/apps/api/test/routes/game.spec.ts @@ -420,6 +420,45 @@ describe("game route", () => { }); }); + describe("GET /load error path", () => { + it("returns 500 when the database throws during load", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await app.fetch(new Request("http://localhost/game/load")); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value during load", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); + vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error"); + const res = await app.fetch(new Request("http://localhost/game/load")); + expect(res.status).toBe(500); + }); + }); + + describe("POST /save error path", () => { + const save = (body: Record) => + app.fetch(new Request("http://localhost/game/save", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + })); + + it("returns 500 when the database throws during save", async () => { + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await save({ state }); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value during save", async () => { + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); + const res = await save({ state }); + expect(res.status).toBe(500); + }); + }); + describe("POST /reset", () => { const reset = () => app.fetch(new Request("http://localhost/game/reset", { method: "POST" })); @@ -450,5 +489,17 @@ describe("game route", () => { const body = await res.json() as { signature: string | undefined }; expect(typeof body.signature).toBe("string"); }); + + it("returns 500 when the database throws during reset", async () => { + vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await reset(); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value during reset", async () => { + vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error"); + const res = await reset(); + expect(res.status).toBe(500); + }); }); }); diff --git a/apps/api/test/routes/leaderboards.spec.ts b/apps/api/test/routes/leaderboards.spec.ts index 799502a..4c79915 100644 --- a/apps/api/test/routes/leaderboards.spec.ts +++ b/apps/api/test/routes/leaderboards.spec.ts @@ -152,6 +152,18 @@ describe("leaderboards route", () => { expect(typeof body.entries[0]?.activeTitle).toBe("string"); }); + it("returns 500 when the database throws", async () => { + vi.mocked(prisma.player.findMany).mockRejectedValueOnce(new Error("DB error")); + const res = await get(); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value", async () => { + vi.mocked(prisma.player.findMany).mockRejectedValueOnce("raw string error"); + const res = await get(); + expect(res.status).toBe(500); + }); + it("defaults to 0 for game-state categories when state is missing", async () => { vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never); vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([] as never); diff --git a/apps/api/test/routes/prestige.spec.ts b/apps/api/test/routes/prestige.spec.ts index 57fe7e9..b7ee04d 100644 --- a/apps/api/test/routes/prestige.spec.ts +++ b/apps/api/test/routes/prestige.spec.ts @@ -93,6 +93,18 @@ describe("prestige route", () => { expect(body.runestones).toBeGreaterThanOrEqual(0); }); + it("returns 500 when the database throws during prestige", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await post(""); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value during prestige", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); + const res = await post(""); + expect(res.status).toBe(500); + }); + it("updates daily challenge progress when dailyChallenges are set", async () => { const state = makeState({ dailyChallenges: { @@ -152,5 +164,17 @@ describe("prestige route", () => { expect(body.runestonesRemaining).toBe(90); // 100 - 10 expect(body.purchasedUpgradeIds).toContain("income_1"); }); + + it("returns 500 when the database throws during buy-upgrade", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await post("/buy-upgrade", { upgradeId: "income_1" }); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value during buy-upgrade", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); + const res = await post("/buy-upgrade", { upgradeId: "income_1" }); + expect(res.status).toBe(500); + }); }); }); diff --git a/apps/api/test/routes/profile.spec.ts b/apps/api/test/routes/profile.spec.ts index 0cb3fea..e85a119 100644 --- a/apps/api/test/routes/profile.spec.ts +++ b/apps/api/test/routes/profile.spec.ts @@ -182,6 +182,18 @@ describe("profile route", () => { expect(unknown?.name).toBe("unknown_title_id"); }); + it("returns 500 when the database throws during profile get", async () => { + vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`)); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value during profile get", async () => { + vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error"); + const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`)); + expect(res.status).toBe(500); + }); + it("includes completed story chapters in profile response", async () => { const state = makeState({ story: { @@ -256,5 +268,23 @@ describe("profile route", () => { const body = await res.json() as { profileSettings: { numberFormat: string } }; expect(body.profileSettings.numberFormat).toBe("suffix"); }); + + it("returns 500 when the database throws during profile update", async () => { + vi.mocked(prisma.player.update).mockRejectedValueOnce(new Error("DB error")); + const res = await put({ + characterName: "NewName", + profileSettings: { numberFormat: "suffix" }, + }); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value during profile update", async () => { + vi.mocked(prisma.player.update).mockRejectedValueOnce("raw string error"); + const res = await put({ + characterName: "NewName", + profileSettings: { numberFormat: "suffix" }, + }); + expect(res.status).toBe(500); + }); }); }); diff --git a/apps/api/test/routes/transcendence.spec.ts b/apps/api/test/routes/transcendence.spec.ts index fcba56b..270c5e5 100644 --- a/apps/api/test/routes/transcendence.spec.ts +++ b/apps/api/test/routes/transcendence.spec.ts @@ -92,6 +92,18 @@ describe("transcendence route", () => { expect(body.newTranscendenceCount).toBe(1); expect(body.echoes).toBeGreaterThanOrEqual(0); }); + + it("returns 500 when the database throws during transcendence", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await post(""); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value during transcendence", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); + const res = await post(""); + expect(res.status).toBe(500); + }); }); describe("POST /buy-upgrade", () => { @@ -149,5 +161,17 @@ describe("transcendence route", () => { expect(body.echoesRemaining).toBe(95); // 100 - 5 expect(body.purchasedUpgradeIds).toContain("echo_income_1"); }); + + it("returns 500 when the database throws during buy-upgrade", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" }); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value during buy-upgrade", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); + const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" }); + expect(res.status).toBe(500); + }); }); }); diff --git a/apps/api/test/services/discord.spec.ts b/apps/api/test/services/discord.spec.ts index 97a9cc8..5ca4e97 100644 --- a/apps/api/test/services/discord.spec.ts +++ b/apps/api/test/services/discord.spec.ts @@ -86,5 +86,22 @@ describe("discord service", () => { expect(result.id).toBe("123"); expect(result.username).toBe("testuser"); }); + + it("re-throws when fetch rejects with a non-Error value", async () => { + mockFetch.mockRejectedValueOnce("raw string error"); + const { fetchDiscordUser } = await import("../../src/services/discord.js"); + await expect(fetchDiscordUser("some_token")).rejects.toBe("raw string error"); + }); + }); + + describe("exchangeCode non-Error throw", () => { + it("re-throws when fetch rejects with a non-Error value", async () => { + process.env["DISCORD_CLIENT_ID"] = "cid"; + process.env["DISCORD_CLIENT_SECRET"] = "secret"; + process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb"; + mockFetch.mockRejectedValueOnce("raw string error"); + const { exchangeCode } = await import("../../src/services/discord.js"); + await expect(exchangeCode("some_code")).rejects.toBe("raw string error"); + }); }); }); diff --git a/apps/api/test/services/webhook.spec.ts b/apps/api/test/services/webhook.spec.ts index e0a4f33..28680b0 100644 --- a/apps/api/test/services/webhook.spec.ts +++ b/apps/api/test/services/webhook.spec.ts @@ -69,6 +69,15 @@ describe("webhook service", () => { const { grantApotheosisRole } = await import("../../src/services/webhook.js"); await expect(grantApotheosisRole("user")).resolves.toBeUndefined(); }); + + it("swallows non-Error fetch rejections gracefully", async () => { + process.env["DISCORD_BOT_TOKEN"] = "tok"; + process.env["DISCORD_GUILD_ID"] = "g"; + process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r"; + mockFetch.mockRejectedValueOnce("raw string error"); + const { grantApotheosisRole } = await import("../../src/services/webhook.js"); + await expect(grantApotheosisRole("user")).resolves.toBeUndefined(); + }); }); describe("postMilestoneWebhook", () => { @@ -119,5 +128,12 @@ describe("webhook service", () => { const { postMilestoneWebhook } = await import("../../src/services/webhook.js"); await expect(postMilestoneWebhook("user", "prestige", counts)).resolves.toBeUndefined(); }); + + it("swallows non-Error fetch rejections gracefully", async () => { + process.env["DISCORD_MILESTONE_WEBHOOK"] = "https://discord.com/webhook/abc"; + mockFetch.mockRejectedValueOnce("raw string error"); + const { postMilestoneWebhook } = await import("../../src/services/webhook.js"); + await expect(postMilestoneWebhook("user", "prestige", counts)).resolves.toBeUndefined(); + }); }); }); diff --git a/apps/web/index.html b/apps/web/index.html index d154738..1b35069 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -5,6 +5,39 @@ Elysium — Idle RPG + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/apps/web/src/components/errorBoundary.tsx b/apps/web/src/components/errorBoundary.tsx new file mode 100644 index 0000000..4cf85a1 --- /dev/null +++ b/apps/web/src/components/errorBoundary.tsx @@ -0,0 +1,70 @@ +/** + * @file React Error Boundary for catching unhandled render-time errors. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { Component, type ErrorInfo, type ReactNode } from "react"; +import { logError } from "../utils/logError.js"; + +interface ErrorBoundaryProperties { + readonly children: ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; +} + +/** + * Catches unhandled render-time errors in the React tree, logs them to the + * backend telemetry service, and renders a fallback UI. + */ +class ErrorBoundary extends Component< + ErrorBoundaryProperties, + ErrorBoundaryState +> { + // eslint-disable-next-line jsdoc/require-jsdoc -- React Error Boundary constructor is standard boilerplate + public constructor(properties: ErrorBoundaryProperties) { + super(properties); + this.state = { hasError: false }; + } + + /** + * Updates state so the next render shows the fallback UI. + * @returns The updated error boundary state. + */ + public static getDerivedStateFromError(): ErrorBoundaryState { + return { hasError: true }; + } + + /** + * Logs the error to the backend telemetry service. + * @param error - The error that was thrown during render. + * @param info - React error info containing the component stack trace. + */ + // eslint-disable-next-line @typescript-eslint/class-methods-use-this -- React lifecycle method cannot be static + public override componentDidCatch(error: Error, info: ErrorInfo): void { + logError("react_error_boundary", error, info.componentStack); + } + + /** + * Renders the fallback UI when an error is caught, otherwise renders children. + * @returns The JSX element. + */ + public override render(): ReactNode { + const { hasError } = this.state; + const { children } = this.props; + + if (hasError) { + return ( +
+

{"Something went wrong. Please refresh the page."}

+
+ ); + } + + return children; + } +} + +export { ErrorBoundary }; diff --git a/apps/web/src/components/game/characterPage.tsx b/apps/web/src/components/game/characterPage.tsx index 2fb1f81..b498534 100644 --- a/apps/web/src/components/game/characterPage.tsx +++ b/apps/web/src/components/game/characterPage.tsx @@ -14,6 +14,7 @@ import { type PublicProfileResponse, } from "@elysium/types"; import { type JSX, useEffect, useState } from "react"; +import { logError } from "../../utils/logError.js"; interface CharacterPageProperties { readonly discordId: string; @@ -78,12 +79,16 @@ const CharacterPage = ({ discordId }: CharacterPageProperties): JSX.Element => { }, [ discordId ]); function handleCopy(): void { - void navigator.clipboard.writeText(window.location.href).then(() => { - setCopied(true); - setTimeout(() => { - setCopied(false); - }, 2000); - }); + void navigator.clipboard.writeText(window.location.href). + then(() => { + setCopied(true); + setTimeout(() => { + setCopied(false); + }, 2000); + }). + catch((error_: unknown) => { + logError("clipboard_copy", error_); + }); } if (error !== null) { diff --git a/apps/web/src/components/game/characterSheetPanel.tsx b/apps/web/src/components/game/characterSheetPanel.tsx index 34de5ff..b716d7e 100644 --- a/apps/web/src/components/game/characterSheetPanel.tsx +++ b/apps/web/src/components/game/characterSheetPanel.tsx @@ -19,6 +19,7 @@ import { import { type ChangeEvent, type JSX, useEffect, useRef, useState } from "react"; import { updateProfile } from "../../api/client.js"; import { useGame } from "../../context/gameContext.js"; +import { logError } from "../../utils/logError.js"; interface EquippedItem { name: string; @@ -205,12 +206,16 @@ const CharacterSheetPanel = (): JSX.Element => { function handleShareClick(): void { const discordId = player?.discordId ?? ""; const url = `${window.location.origin}/character/${discordId}`; - void navigator.clipboard.writeText(url).then(() => { - setCopied(true); - setTimeout(() => { - setCopied(false); - }, 2000); - }); + void navigator.clipboard.writeText(url). + then(() => { + setCopied(true); + setTimeout(() => { + setCopied(false); + }, 2000); + }). + catch((error_: unknown) => { + logError("clipboard_copy", error_); + }); } function handleNameChange(event: ChangeEvent): void { diff --git a/apps/web/src/components/game/gameLayout.tsx b/apps/web/src/components/game/gameLayout.tsx index 8e2a4d9..c08616e 100644 --- a/apps/web/src/components/game/gameLayout.tsx +++ b/apps/web/src/components/game/gameLayout.tsx @@ -189,6 +189,7 @@ const GameLayout = (): JSX.Element => {
diff --git a/apps/web/src/components/game/profilePage.tsx b/apps/web/src/components/game/profilePage.tsx index 8c1e834..60cf993 100644 --- a/apps/web/src/components/game/profilePage.tsx +++ b/apps/web/src/components/game/profilePage.tsx @@ -8,6 +8,7 @@ /* eslint-disable complexity -- Many conditional stat visibility checks */ import { useEffect, useState, type JSX } from "react"; import { formatNumber } from "../../utils/format.js"; +import { logError } from "../../utils/logError.js"; import type { PublicProfileResponse } from "@elysium/types"; interface ProfilePageProperties { @@ -52,12 +53,16 @@ const ProfilePage = ({ discordId }: ProfilePageProperties): JSX.Element => { }, [ discordId ]); function handleCopy(): void { - void navigator.clipboard.writeText(window.location.href).then(() => { - setCopied(true); - setTimeout(() => { - setCopied(false); - }, 2000); - }); + void navigator.clipboard.writeText(window.location.href). + then(() => { + setCopied(true); + setTimeout(() => { + setCopied(false); + }, 2000); + }). + catch((error_: unknown) => { + logError("clipboard_copy", error_); + }); } if (error !== null) { diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index eb72f0a..54c82ca 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -59,6 +59,7 @@ import { } from "../engine/tick.js"; import { updateChallengeProgress } from "../utils/dailyChallenges.js"; import { formatNumber as formatNumberUtil } from "../utils/format.js"; +import { logError } from "../utils/logError.js"; import { sendNotification } from "../utils/notification.js"; import { playSound } from "../utils/sound.js"; @@ -1130,6 +1131,8 @@ export const GameProvider = ({ ) { signatureReference.current = null; localStorage.removeItem("elysium_save_signature"); + } else { + logError("auto_save", error_); } }); } @@ -1158,7 +1161,8 @@ export const GameProvider = ({ } await reloadReference.current(); }). - catch(() => { + catch((error_: unknown) => { + logError("auto_prestige", error_); /* Silently ignore — will retry next tick */ }). @@ -1200,7 +1204,8 @@ export const GameProvider = ({ }); setBattleResult({ bossName, result }); }). - catch(() => { + catch((error_: unknown) => { + logError("auto_boss", error_); /* Silently ignore — will retry next tick */ }). @@ -1521,35 +1526,46 @@ export const GameProvider = ({ }, }; }); - } catch { + } catch (error_: unknown) { + logError("buy_prestige_upgrade", error_); // Silently ignore — server errors shouldn't crash the UI } }, []); const transcend = useCallback(async() => { - const result = await transcendApi({}); - setShowTranscendenceToast(true); - if (enableSoundsReference.current) { - playSound("transcendence"); + try { + const result = await transcendApi({}); + setShowTranscendenceToast(true); + if (enableSoundsReference.current) { + playSound("transcendence"); + } + if (enableNotificationsReference.current) { + sendNotification("🌌 Transcendence!", "You have transcended reality!"); + } + await reload(); + return result; + } catch (error_: unknown) { + logError("transcend", error_); + throw error_; } - if (enableNotificationsReference.current) { - sendNotification("🌌 Transcendence!", "You have transcended reality!"); - } - await reload(); - return result; }, [ reload ]); const apotheosis = useCallback(async() => { - const result = await achieveApotheosisApi({}); - setShowApotheosisToast(true); - if (enableSoundsReference.current) { - playSound("apotheosis"); + try { + const result = await achieveApotheosisApi({}); + setShowApotheosisToast(true); + if (enableSoundsReference.current) { + playSound("apotheosis"); + } + if (enableNotificationsReference.current) { + sendNotification("✨ Apotheosis!", "You have achieved godhood!"); + } + await reload(); + return result; + } catch (error_: unknown) { + logError("apotheosis", error_); + throw error_; } - if (enableNotificationsReference.current) { - sendNotification("✨ Apotheosis!", "You have achieved godhood!"); - } - await reload(); - return result; }, [ reload ]); const buyEchoUpgrade = useCallback(async(upgradeId: string) => { @@ -1575,114 +1591,125 @@ export const GameProvider = ({ }, }; }); - } catch { - // Silently ignore server errors + } catch (error_: unknown) { + logError("buy_echo_upgrade", error_); + // Silently ignore — server errors shouldn't crash the UI } }, []); const startExploration = useCallback(async(areaId: string) => { - const response = await startExplorationApi({ areaId }); - const areaData = EXPLORATION_AREAS.find((a) => { - return a.id === areaId; - }); - if (areaData === undefined) { - return; - } - // eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear - const startedAt = response.endsAt - areaData.durationSeconds * 1000; - setState((previous) => { - if (previous?.exploration === undefined) { - return previous; + try { + const response = await startExplorationApi({ areaId }); + const areaData = EXPLORATION_AREAS.find((a) => { + return a.id === areaId; + }); + if (areaData === undefined) { + return; } - return { - ...previous, - exploration: { - ...previous.exploration, - areas: previous.exploration.areas.map((a) => { - return a.id === areaId - ? { ...a, startedAt: startedAt, status: "in_progress" as const } - : a; - }), - }, - }; - }); - }, []); - - const collectExploration = useCallback( - async(areaId: string): Promise => { - const result = await collectExplorationApi({ areaId }); + // eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear + const startedAt = response.endsAt - areaData.durationSeconds * 1000; setState((previous) => { if (previous?.exploration === undefined) { return previous; } - let materials = [ ...previous.exploration.materials ]; - - // Apply material drops from the random loot roll - for (const drop of result.materialsFound) { - const existing = materials.find((mat) => { - return mat.materialId === drop.materialId; - }); - if (existing === undefined) { - materials = [ - ...materials, - { materialId: drop.materialId, quantity: drop.quantity }, - ]; - } else { - materials = materials.map((mat) => { - return mat.materialId === drop.materialId - ? { ...mat, quantity: mat.quantity + drop.quantity } - : mat; - }); - } - } - - // Apply material from event (if any) - const materialGained = result.event?.materialGained; - if (materialGained !== null && materialGained !== undefined) { - const { materialId, quantity } = materialGained; - const existing = materials.find((mat) => { - return mat.materialId === materialId; - }); - if (existing === undefined) { - materials = [ ...materials, { materialId, quantity } ]; - } else { - materials = materials.map((mat) => { - return mat.materialId === materialId - ? { ...mat, quantity: mat.quantity + quantity } - : mat; - }); - } - } - return { ...previous, exploration: { ...previous.exploration, areas: previous.exploration.areas.map((a) => { return a.id === areaId - ? { ...a, completedOnce: true, status: "available" as const } + ? { ...a, startedAt: startedAt, status: "in_progress" as const } : a; }), - materials: materials, - }, - player: { - ...previous.player, - totalGoldEarned: - previous.player.totalGoldEarned - + Math.max(0, result.event?.goldChange ?? 0), - }, - resources: { - ...previous.resources, - essence: - previous.resources.essence + (result.event?.essenceChange ?? 0), - gold: Math.max( - 0, - previous.resources.gold + (result.event?.goldChange ?? 0), - ), }, }; }); - return result; + } catch (error_: unknown) { + logError("start_exploration", error_); + throw error_; + } + }, []); + + const collectExploration = useCallback( + async(areaId: string): Promise => { + try { + const result = await collectExplorationApi({ areaId }); + setState((previous) => { + if (previous?.exploration === undefined) { + return previous; + } + let materials = [ ...previous.exploration.materials ]; + + // Apply material drops from the random loot roll + for (const drop of result.materialsFound) { + const existing = materials.find((mat) => { + return mat.materialId === drop.materialId; + }); + if (existing === undefined) { + materials = [ + ...materials, + { materialId: drop.materialId, quantity: drop.quantity }, + ]; + } else { + materials = materials.map((mat) => { + return mat.materialId === drop.materialId + ? { ...mat, quantity: mat.quantity + drop.quantity } + : mat; + }); + } + } + + // Apply material from event (if any) + const materialGained = result.event?.materialGained; + if (materialGained !== null && materialGained !== undefined) { + const { materialId, quantity } = materialGained; + const existing = materials.find((mat) => { + return mat.materialId === materialId; + }); + if (existing === undefined) { + materials = [ ...materials, { materialId, quantity } ]; + } else { + materials = materials.map((mat) => { + return mat.materialId === materialId + ? { ...mat, quantity: mat.quantity + quantity } + : mat; + }); + } + } + + return { + ...previous, + exploration: { + ...previous.exploration, + areas: previous.exploration.areas.map((a) => { + return a.id === areaId + ? { ...a, completedOnce: true, status: "available" as const } + : a; + }), + materials: materials, + }, + player: { + ...previous.player, + totalGoldEarned: + previous.player.totalGoldEarned + + Math.max(0, result.event?.goldChange ?? 0), + }, + resources: { + ...previous.resources, + essence: + previous.resources.essence + (result.event?.essenceChange ?? 0), + gold: Math.max( + 0, + previous.resources.gold + (result.event?.goldChange ?? 0), + ), + }, + }; + }); + return result; + } catch (error_: unknown) { + logError("collect_exploration", error_); + throw error_; + } }, [], ); @@ -1694,35 +1721,40 @@ export const GameProvider = ({ if (recipe === undefined) { return; } - const result = await craftRecipeApi({ recipeId }); - setState((previous) => { - if (previous?.exploration === undefined) { - return previous; - } - let materials = [ ...previous.exploration.materials ]; - for (const request of recipe.requiredMaterials) { - materials = materials.map((mat) => { - return mat.materialId === request.materialId - ? { ...mat, quantity: mat.quantity - request.quantity } - : mat; - }); - } - return { - ...previous, - exploration: { - ...previous.exploration, - craftedClickMultiplier: result.craftedClickMultiplier, - craftedCombatMultiplier: result.craftedCombatMultiplier, - craftedEssenceMultiplier: result.craftedEssenceMultiplier, - craftedGoldMultiplier: result.craftedGoldMultiplier, - craftedRecipeIds: [ - ...previous.exploration.craftedRecipeIds, - recipeId, - ], - materials: materials, - }, - }; - }); + try { + const result = await craftRecipeApi({ recipeId }); + setState((previous) => { + if (previous?.exploration === undefined) { + return previous; + } + let materials = [ ...previous.exploration.materials ]; + for (const request of recipe.requiredMaterials) { + materials = materials.map((mat) => { + return mat.materialId === request.materialId + ? { ...mat, quantity: mat.quantity - request.quantity } + : mat; + }); + } + return { + ...previous, + exploration: { + ...previous.exploration, + craftedClickMultiplier: result.craftedClickMultiplier, + craftedCombatMultiplier: result.craftedCombatMultiplier, + craftedEssenceMultiplier: result.craftedEssenceMultiplier, + craftedGoldMultiplier: result.craftedGoldMultiplier, + craftedRecipeIds: [ + ...previous.exploration.craftedRecipeIds, + recipeId, + ], + materials: materials, + }, + }; + }); + } catch (error_: unknown) { + logError("craft_recipe", error_); + throw error_; + } }, []); const toggleAutoPrestige = useCallback(() => { @@ -1798,7 +1830,8 @@ export const GameProvider = ({ return applyBossResult(previous, bossId, result); }); setBattleResult({ bossName: boss.name, result: result }); - } catch { + } catch (error_: unknown) { + logError("challenge_boss", error_); // Silently ignore — server errors shouldn't crash the UI } }, []); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 4f73ada..a6b4f6a 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -8,8 +8,12 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./app.js"; +import { ErrorBoundary } from "./components/errorBoundary.js"; +import { initialiseFrontendLogger } from "./utils/logger.js"; import "./styles.css"; +initialiseFrontendLogger(); + const rootElement = document.getElementById("root"); if (!rootElement) { @@ -18,6 +22,8 @@ if (!rootElement) { createRoot(rootElement).render( - + + + , ); diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 4045209..2e7bf50 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -26,6 +26,7 @@ --radius: 8px; --radius-lg: 12px; --font: "Segoe UI", system-ui, sans-serif; + --resource-bar-height: 3.5rem; } body { @@ -136,6 +137,10 @@ body::before { flex-direction: column; align-items: center; gap: 1rem; + position: sticky; + top: var(--resource-bar-height); + height: calc(100vh - var(--resource-bar-height)); + overflow-y: auto; } .game-content { @@ -3181,8 +3186,11 @@ body::before { border-right: none; flex-direction: row; gap: 0.75rem; + height: auto; justify-content: center; padding: 0.5rem 0.75rem; + position: static; + top: auto; width: 100%; } diff --git a/apps/web/src/utils/logError.ts b/apps/web/src/utils/logError.ts new file mode 100644 index 0000000..61b8089 --- /dev/null +++ b/apps/web/src/utils/logError.ts @@ -0,0 +1,19 @@ +/** + * @file Frontend error logging utility that forwards errors to the backend telemetry service. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable no-console -- Errors are forwarded to backend via the overridden console.error */ + +/** + * Logs an error to the backend telemetry service. + * Accepts the same arguments as console.error — conventionally a context string + * followed by the error value. + * @param logArguments - The values to log, forwarded directly to console.error. + */ +const logError = (...logArguments: Array): void => { + console.error(...logArguments); +}; + +export { logError }; diff --git a/apps/web/src/utils/logger.ts b/apps/web/src/utils/logger.ts new file mode 100644 index 0000000..f0d481f --- /dev/null +++ b/apps/web/src/utils/logger.ts @@ -0,0 +1,68 @@ +/** + * @file Frontend logger that forwards console output to the backend telemetry service. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable no-console -- This file intentionally overrides console methods */ + +type Level = "debug" | "info" | "warn"; + +const post = (path: string, body: object): void => { + void fetch(path, { + body: JSON.stringify(body), + // eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header names use kebab-case + headers: { "Content-Type": "application/json" }, + method: "POST", + }).catch(() => { + // Intentionally swallowed — we cannot log logger failures without infinite recursion. + }); +}; + +/** + * Overrides the global console.log and console.error methods so that all + * frontend log output is forwarded to the backend telemetry endpoints. + * Must be called once at application startup before any other code runs. + */ +const initialiseFrontendLogger = (): void => { + const originalLog = console.log.bind(console); + const originalError = console.error.bind(console); + + console.log = (...consoleArguments: Array): void => { + originalLog(...consoleArguments); + const level: Level = "info"; + const message = consoleArguments.map((argument) => { + return typeof argument === "string" + ? argument + : JSON.stringify(argument); + }).join(" "); + post("/api/fe/log", { level, message }); + }; + + console.error = (...consoleArguments: Array): void => { + originalError(...consoleArguments); + const message = consoleArguments.map((argument) => { + if (argument instanceof Error) { + return `${argument.message}\n${argument.stack ?? ""}`; + } + return typeof argument === "string" + ? argument + : JSON.stringify(argument); + }).join(" "); + const context = "console.error"; + post("/api/fe/error", { context, message }); + }; + + console.warn = (...consoleArguments: Array): void => { + originalLog(...consoleArguments); + const level: Level = "warn"; + const message = consoleArguments.map((argument) => { + return typeof argument === "string" + ? argument + : JSON.stringify(argument); + }).join(" "); + post("/api/fe/log", { level, message }); + }; +}; + +export { initialiseFrontendLogger }; diff --git a/apps/web/src/utils/notification.ts b/apps/web/src/utils/notification.ts index da32cbb..b689810 100644 --- a/apps/web/src/utils/notification.ts +++ b/apps/web/src/utils/notification.ts @@ -4,6 +4,7 @@ * @license Naomi's Public License * @author Naomi Carrigan */ +import { logError } from "./logError.js"; /** * Requests browser notification permission from the user. @@ -38,7 +39,8 @@ const sendNotification = (title: string, body: string): void => { try { // eslint-disable-next-line no-new -- Notification constructor has side effects new Notification(title, { body: body, icon: "/favicon.ico" }); - } catch { + } catch (error_: unknown) { + logError("send_notification", error_); // Silently ignore — notifications may fail silently } }; diff --git a/apps/web/src/utils/sound.ts b/apps/web/src/utils/sound.ts index 29dbe95..64469a4 100644 --- a/apps/web/src/utils/sound.ts +++ b/apps/web/src/utils/sound.ts @@ -4,6 +4,7 @@ * @license Naomi's Public License * @author Naomi Carrigan */ +import { logError } from "./logError.js"; type SoundEvent = | "achievement" @@ -101,7 +102,8 @@ const playSound = (event: SoundEvent): void => { oscillator.start(startTime); oscillator.stop(endTime); } - } catch { + } catch (error_: unknown) { + logError("play_sound", error_); // Silently ignore — audio may not be available in all environments } }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d684b0f..49e3d54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@hono/node-server': specifier: 1.13.7 version: 1.13.7(hono@4.7.4) + '@nhcarrigan/logger': + specifier: 1.1.1 + version: 1.1.1 '@prisma/client': specifier: 6.5.0 version: 6.5.0(prisma@6.5.0(typescript@5.8.2))(typescript@5.8.2) @@ -689,6 +692,9 @@ packages: typescript: '>=5' vitest: '>=2' + '@nhcarrigan/logger@1.1.1': + resolution: {integrity: sha512-P6OEQFHDtf6psybYGljuCxkSW6DLQCsx1aZZ3w4YKBXHBFjDbhuvpM9K1kPhVN48hakitx2WPLEoIFr6YZELYw==} + '@nhcarrigan/typescript-config@4.0.0': resolution: {integrity: sha512-969HVha7A/Sg77fuMwOm6p14a+7C5iE6g55OD71srqwKIgksQl+Ex/hAI/pyzTQFDQ/FBJbpnHlR4Ov25QV/rw==} engines: {node: '20', pnpm: '9'} @@ -3490,6 +3496,8 @@ snapshots: - eslint-import-resolver-webpack - supports-color + '@nhcarrigan/logger@1.1.1': {} + '@nhcarrigan/typescript-config@4.0.0(typescript@5.8.2)': dependencies: typescript: 5.8.2