/** * @file Profile routes handling player profile retrieval and updates. * @copyright nhcarrigan * @license Naomi's Public License * @author Naomi Carrigan */ /* eslint-disable max-lines-per-function -- Route handlers require many steps */ /* eslint-disable max-statements -- Route handlers require many steps */ /* eslint-disable complexity -- Route handlers have inherent complexity */ /* eslint-disable stylistic/key-spacing -- ProfileSettings keys exceed max-len when aligned */ /* eslint-disable stylistic/max-len -- ProfileSettings key names exceed line length limit */ /* eslint-disable @typescript-eslint/no-unnecessary-condition -- Defensive checks for runtime nullable fields */ import { DEFAULT_PROFILE_SETTINGS, type GameState, type ProfileSettings, type UpdateProfileRequest, } from "@elysium/types"; 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"; const profileRouter = new Hono(); const validNumberFormats = new Set([ "suffix", "scientific", "engineering" ]); /** * Parses a raw profile settings blob from the database into a typed ProfileSettings object. * @param raw - The raw value from the database. * @returns A valid ProfileSettings object with defaults for missing fields. */ const parseProfileSettings = (raw: unknown): ProfileSettings => { if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { return { ...DEFAULT_PROFILE_SETTINGS }; } /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */ const rawObject = raw as Record; /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */ const parsedNumberFormat = rawObject.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"; return { enableNotifications: rawObject.enableNotifications === true, enablePrestigeAnnouncements: rawObject.enablePrestigeAnnouncements !== false, enableSounds: rawObject.enableSounds === true, numberFormat: numberFormat, showAchievementsUnlocked: rawObject.showAchievementsUnlocked !== false, showAdventurersRecruited: rawObject.showAdventurersRecruited !== false, showApotheosis: rawObject.showApotheosis !== false, showBossesDefeated: rawObject.showBossesDefeated !== false, showCurrentClicks: rawObject.showCurrentClicks !== false, showCurrentGold: rawObject.showCurrentGold !== false, showGuildFounded: rawObject.showGuildFounded !== false, showLifetimeAchievementsUnlocked: rawObject.showLifetimeAchievementsUnlocked !== false, showLifetimeAdventurersRecruited: rawObject.showLifetimeAdventurersRecruited !== false, showLifetimeBossesDefeated: rawObject.showLifetimeBossesDefeated !== false, showLifetimeQuestsCompleted: rawObject.showLifetimeQuestsCompleted !== false, showOnLeaderboards: rawObject.showOnLeaderboards !== false, showPrestige: rawObject.showPrestige !== false, showQuestsCompleted: rawObject.showQuestsCompleted !== false, showTotalClicks: rawObject.showTotalClicks !== false, showTotalGold: rawObject.showTotalGold !== false, showTranscendence: rawObject.showTranscendence !== false, }; }; /** * Resolves a title ID to its display name. * @param id - The title ID to resolve. * @returns An object with id and name fields. */ const resolveTitle = (id: string): { id: string; name: string } => { const title = gameTitles.find((gameTitle) => { return gameTitle.id === id; }); return { id: id, name: title?.name ?? id }; }; profileRouter.get("/:discordId", async(context) => { try { const { discordId } = context.req.param(); 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); } /* 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; 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; } } // 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 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) => { 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); } const characterName = body.characterName.trim().slice(0, 32); 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) /* 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, enablePrestigeAnnouncements: body.profileSettings.enablePrestigeAnnouncements ?? true, 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 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, }); } 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 };