/** * @file Debug routes for administrative player state corrections. * @copyright nhcarrigan * @license Naomi's Public License * @author Naomi Carrigan */ /* eslint-disable max-lines-per-function -- Route handlers require many steps */ /* eslint-disable max-lines -- Multiple route handlers and helper functions in one file */ import { createHmac } from "node:crypto"; import { STORY_CHAPTERS, isStoryChapterUnlocked, type GameState, } from "@elysium/types"; import { Hono } from "hono"; import { defaultAchievements } from "../data/achievements.js"; import { defaultAdventurers } from "../data/adventurers.js"; import { defaultBosses } from "../data/bosses.js"; import { defaultEquipment } from "../data/equipment.js"; import { defaultExplorations } from "../data/explorations.js"; import { initialGameState } from "../data/initialState.js"; import { defaultQuests } from "../data/quests.js"; import { currentSchemaVersion } from "../data/schemaVersion.js"; import { defaultUpgrades } from "../data/upgrades.js"; import { defaultZones } from "../data/zones.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"; /** * Computes the HMAC-SHA256 of data using the given secret. * @param data - The data string to sign. * @param secret - The HMAC secret key. * @returns The hex-encoded HMAC digest. */ const computeHmac = (data: string, secret: string): string => { return createHmac("sha256", secret).update(data). digest("hex"); }; /** * Unlocks any zones whose required boss and quest conditions are satisfied. * @param state - The player's current game state (mutated directly). * @returns The number of zones that were unlocked. */ const applyZoneUnlocks = (state: GameState): number => { let count = 0; for (const zoneDefinition of defaultZones) { const zoneInState = state.zones.find((z) => { return z.id === zoneDefinition.id; }); if (!zoneInState || zoneInState.status !== "locked") { continue; } const requiredBossDefeated = zoneDefinition.unlockBossId === null || state.bosses.some((b) => { return b.id === zoneDefinition.unlockBossId && b.status === "defeated"; }); const requiredQuestCompleted = zoneDefinition.unlockQuestId === null || state.quests.some((q) => { return ( q.id === zoneDefinition.unlockQuestId && q.status === "completed" ); }); if (requiredBossDefeated && requiredQuestCompleted) { zoneInState.status = "unlocked"; count = count + 1; } } return count; }; interface QuestUnlockCheck { questId: string; zoneId: string; prerequisiteIds: Array; state: GameState; completedQuestIds: Set; } /** * Determines whether a quest should be made available given the current state. * @param options - The options for the quest unlock check. * @param options.questId - The ID of the quest to check. * @param options.zoneId - The zone the quest belongs to. * @param options.prerequisiteIds - The quest IDs that must be completed first. * @param options.state - The current game state. * @param options.completedQuestIds - Set of already-completed quest IDs. * @returns True when the quest should be unlocked. */ const shouldUnlockQuest = ({ questId, zoneId, prerequisiteIds, state, completedQuestIds, }: QuestUnlockCheck): boolean => { const questInState = state.quests.find((q) => { return q.id === questId; }); if (!questInState || questInState.status !== "locked") { return false; } const zoneInState = state.zones.find((z) => { return z.id === zoneId; }); if (!zoneInState || zoneInState.status === "locked") { return false; } return prerequisiteIds.every((id) => { return completedQuestIds.has(id); }); }; /** * Makes available any quests whose zone is unlocked and prerequisites are met. * @param state - The player's current game state (mutated directly). * @returns The number of quests that were made available. */ const applyQuestUnlocks = (state: GameState): number => { let count = 0; const completedQuestIds = new Set( state.quests. filter((q) => { return q.status === "completed"; }). map((q) => { return q.id; }), ); for (const questDefinition of defaultQuests) { if ( !shouldUnlockQuest({ completedQuestIds: completedQuestIds, prerequisiteIds: questDefinition.prerequisiteIds, questId: questDefinition.id, state: state, zoneId: questDefinition.zoneId, }) ) { continue; } const questInState = state.quests.find((q) => { return q.id === questDefinition.id; }); // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 4 -- @preserve */ if (questInState) { questInState.status = "available"; count = count + 1; } } return count; }; interface BossUnlockCheck { bossId: string; previousBossId: string | undefined; isFirstInZone: boolean; prestigeRequirement: number; state: GameState; prestigeCount: number; } /** * Determines whether a boss should be made available given the current state. * @param options - The options for the boss unlock check. * @param options.bossId - The ID of the boss to check. * @param options.previousBossId - The ID of the previous boss in the zone. * @param options.isFirstInZone - Whether this boss is the first in its zone. * @param options.prestigeRequirement - The prestige level required for this boss. * @param options.state - The current game state. * @param options.prestigeCount - The player's current prestige count. * @returns True when the boss should be made available. */ const shouldUnlockBoss = ({ bossId, previousBossId, isFirstInZone, prestigeRequirement, state, prestigeCount, }: BossUnlockCheck): boolean => { const bossInState = state.bosses.find((b) => { return b.id === bossId; }); if (!bossInState || bossInState.status !== "locked") { return false; } if (prestigeRequirement > prestigeCount) { return false; } if (isFirstInZone) { return true; } // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 3 -- @preserve */ if (previousBossId === undefined) { return false; } const previousBossInState = state.bosses.find((b) => { return b.id === previousBossId; }); return previousBossInState?.status === "defeated"; }; /** * Makes available any bosses that should be accessible based on zone status * and sequential defeat order within each zone. * @param state - The player's current game state (mutated directly). * @returns The number of bosses that were made available. */ const applyBossUnlocks = (state: GameState): number => { let count = 0; const prestigeCount = state.prestige.count; for (const zoneDefinition of defaultZones) { const zoneInState = state.zones.find((z) => { return z.id === zoneDefinition.id; }); if (!zoneInState || zoneInState.status === "locked") { continue; } const bossesInZone = defaultBosses.filter((b) => { return b.zoneId === zoneDefinition.id; }); for (let index = 0; index < bossesInZone.length; index = index + 1) { const bossDefinition = bossesInZone[index]; // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 3 -- @preserve */ if (!bossDefinition) { continue; } const previousBossDefinition = bossesInZone[index - 1]; const unlock = shouldUnlockBoss({ bossId: bossDefinition.id, isFirstInZone: index === 0, prestigeCount: prestigeCount, prestigeRequirement: bossDefinition.prestigeRequirement, previousBossId: previousBossDefinition?.id, state: state, }); if (unlock) { const bossInState = state.bosses.find((b) => { return b.id === bossDefinition.id; }); // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 4 -- @preserve */ if (bossInState) { bossInState.status = "available"; count = count + 1; } } } } return count; }; /** * Unlocks any adventurer tiers that were granted as rewards for completed quests * but are still locked in the player's state. * @param state - The player's current game state (mutated directly). * @returns The number of adventurer tiers that were unlocked. */ const applyAdventurerUnlocks = (state: GameState): number => { let count = 0; const completedQuestIds = new Set( state.quests. filter((q) => { return q.status === "completed"; }). map((q) => { return q.id; }), ); const earnedAdventurerIds = new Set(); for (const questDefinition of defaultQuests) { if (!completedQuestIds.has(questDefinition.id)) { continue; } for (const reward of questDefinition.rewards) { if (reward.type === "adventurer" && reward.targetId !== undefined) { earnedAdventurerIds.add(reward.targetId); } } } for (const adventurer of state.adventurers) { if (!adventurer.unlocked && earnedAdventurerIds.has(adventurer.id)) { adventurer.unlocked = true; count = count + 1; } } return count; }; /** * Collects all upgrade IDs the player has legitimately earned via boss defeats * and completed quest rewards, sourcing reward data from game definitions. * @param state - The player's current game state. * @returns A set of earned upgrade IDs. */ const collectEarnedUpgradeIds = (state: GameState): Set => { const earnedIds = new Set(); const defeatedBossIds = new Set( state.bosses. filter((b) => { return b.status === "defeated"; }). map((b) => { return b.id; }), ); const completedQuestIds = new Set( state.quests. filter((q) => { return q.status === "completed"; }). map((q) => { return q.id; }), ); for (const bossDefinition of defaultBosses) { if (!defeatedBossIds.has(bossDefinition.id)) { continue; } for (const upgradeId of bossDefinition.upgradeRewards) { earnedIds.add(upgradeId); } } for (const questDefinition of defaultQuests) { if (!completedQuestIds.has(questDefinition.id)) { continue; } for (const reward of questDefinition.rewards) { if (reward.type === "upgrade" && reward.targetId !== undefined) { earnedIds.add(reward.targetId); } } } return earnedIds; }; /** * Unlocks any upgrades that were granted as rewards for defeated bosses or * completed quests but are still locked in the player's state. * @param state - The player's current game state (mutated directly). * @returns The number of upgrades that were unlocked. */ const applyUpgradeUnlocks = (state: GameState): number => { let count = 0; const earnedUpgradeIds = collectEarnedUpgradeIds(state); for (const upgrade of state.upgrades) { if (!upgrade.unlocked && earnedUpgradeIds.has(upgrade.id)) { upgrade.unlocked = true; count = count + 1; } } return count; }; /** * Marks as owned any equipment that was granted as a reward for defeated bosses * but is still unowned in the player's state. * @param state - The player's current game state (mutated directly). * @returns The number of equipment items that were marked as owned. */ const applyEquipmentUnlocks = (state: GameState): number => { let count = 0; const defeatedBossIds = new Set( state.bosses. filter((b) => { return b.status === "defeated"; }). map((b) => { return b.id; }), ); const earnedEquipmentIds = new Set(); for (const bossDefinition of defaultBosses) { if (!defeatedBossIds.has(bossDefinition.id)) { continue; } for (const equipmentId of bossDefinition.equipmentRewards) { earnedEquipmentIds.add(equipmentId); } } for (const item of state.equipment) { if (!item.owned && earnedEquipmentIds.has(item.id)) { item.owned = true; count = count + 1; } } return count; }; /** * Unlocks any story chapters whose conditions are met by the current game state * but are still absent from the player's unlockedChapterIds list. * @param state - The player's current game state (mutated directly). * @returns The number of story chapters that were unlocked. */ const applyStoryUnlocks = (state: GameState): number => { if (state.story === undefined) { return 0; } let count = 0; const alreadyUnlocked = new Set(state.story.unlockedChapterIds); for (const chapter of STORY_CHAPTERS) { if (alreadyUnlocked.has(chapter.id)) { continue; } if (isStoryChapterUnlocked(chapter, state)) { state.story.unlockedChapterIds.push(chapter.id); count = count + 1; } } return count; }; /** * Makes available any exploration areas whose parent zone is now unlocked. * @param state - The player's current game state (mutated directly). * @returns The number of exploration areas that were made available. */ const applyExplorationUnlocks = (state: GameState): number => { if (state.exploration === undefined) { return 0; } let count = 0; const unlockedZoneIds = new Set( state.zones. filter((z) => { return z.status === "unlocked"; }). map((z) => { return z.id; }), ); for (const areaDefinition of defaultExplorations) { if (!unlockedZoneIds.has(areaDefinition.zoneId)) { continue; } const areaInState = state.exploration.areas.find((a) => { return a.id === areaDefinition.id; }); if (areaInState && areaInState.status === "locked") { areaInState.status = "available"; count = count + 1; } } return count; }; /** * Applies all missing unlock corrections to a game state in-place. * Delegates to per-category helpers and aggregates the results. * @param state - The player's current game state (mutated directly). * @returns Counts of each entity type that was corrected. */ const applyForceUnlocks = ( state: GameState, ): { adventurersUnlocked: number; bossesUnlocked: number; equipmentUnlocked: number; explorationUnlocked: number; questsUnlocked: number; storyUnlocked: number; upgradesUnlocked: number; zonesUnlocked: number; } => { const zonesUnlocked = applyZoneUnlocks(state); const questsUnlocked = applyQuestUnlocks(state); const bossesUnlocked = applyBossUnlocks(state); const explorationUnlocked = applyExplorationUnlocks(state); const adventurersUnlocked = applyAdventurerUnlocks(state); const upgradesUnlocked = applyUpgradeUnlocks(state); const equipmentUnlocked = applyEquipmentUnlocks(state); const storyUnlocked = applyStoryUnlocks(state); return { adventurersUnlocked, bossesUnlocked, equipmentUnlocked, explorationUnlocked, questsUnlocked, storyUnlocked, upgradesUnlocked, zonesUnlocked, }; }; /** * Injects any entries from a defaults array that are missing from an existing * saved array (matched by `id`), cloning each new entry before pushing. * @param existing - The player's saved array (mutated in place). * @param defaults - The current default data array to compare against. * @returns The number of entries that were added. */ const injectMissingEntries = ( existing: Array, defaults: Array, ): number => { const existingIds = new Set(existing.map((item) => { return item.id; })); let added = 0; for (const item of defaults) { if (!existingIds.has(item.id)) { existing.push(structuredClone(item)); added = added + 1; } } const defaultOrder = new Map(defaults.map((item, index) => { return [ item.id, index ] as const; })); existing.sort((itemA, itemB) => { return (defaultOrder.get(itemA.id) ?? Number.MAX_SAFE_INTEGER) - (defaultOrder.get(itemB.id) ?? Number.MAX_SAFE_INTEGER); }); return added; }; /** * Injects any exploration areas from the defaults that are missing from the * player's exploration state, seeding each new area as locked. * @param state - The player's current game state (mutated in place). * @returns The number of exploration areas that were added. */ const injectMissingExplorationAreas = (state: GameState): number => { if (state.exploration === undefined) { return 0; } const existingIds = new Set(state.exploration.areas.map((area) => { return area.id; })); let added = 0; for (const area of defaultExplorations) { if (!existingIds.has(area.id)) { state.exploration.areas.push({ id: area.id, status: "locked" }); added = added + 1; } } return added; }; /* eslint-disable stylistic/max-len -- Long function call lines cannot be shortened without losing alignment */ /** * Syncs a player's save with the current game data, injecting any content * entries that are missing because they were added after the save was created. * @param state - The player's current game state (mutated in place). * @returns Counts of how many entries were added per content type. */ const syncNewContent = ( state: GameState, ): { achievementsAdded: number; adventurersAdded: number; bossesAdded: number; equipmentAdded: number; explorationAreasAdded: number; questsAdded: number; upgradesAdded: number; zonesAdded: number; } => { return { achievementsAdded: injectMissingEntries(state.achievements, defaultAchievements), adventurersAdded: injectMissingEntries(state.adventurers, defaultAdventurers), bossesAdded: injectMissingEntries(state.bosses, defaultBosses), equipmentAdded: injectMissingEntries(state.equipment, defaultEquipment), explorationAreasAdded: injectMissingExplorationAreas(state), questsAdded: injectMissingEntries(state.quests, defaultQuests), upgradesAdded: injectMissingEntries(state.upgrades, defaultUpgrades), zonesAdded: injectMissingEntries(state.zones, defaultZones), }; }; /* eslint-enable stylistic/max-len -- Re-enable after long lines */ const debugRouter = new Hono(); debugRouter.use(authMiddleware); debugRouter.post("/force-unlocks", async(context) => { try { const discordId = context.get("discordId"); const gameStateRecord = await prisma.gameState.findUnique({ where: { discordId }, }); if (!gameStateRecord) { return context.json({ error: "No game state found" }, 404); } /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma stores state as JSON object */ const state = gameStateRecord.state as unknown as GameState; const { adventurersUnlocked, bossesUnlocked, equipmentUnlocked, explorationUnlocked, questsUnlocked, storyUnlocked, upgradesUnlocked, zonesUnlocked, } = applyForceUnlocks(state); const updatedAt = Date.now(); await prisma.gameState.update({ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ data: { state: state as object, updatedAt: updatedAt }, where: { discordId }, }); const secret = process.env.ANTI_CHEAT_SECRET; const signature = secret === undefined ? undefined : computeHmac(JSON.stringify(state), secret); return context.json({ adventurersUnlocked, bossesUnlocked, equipmentUnlocked, explorationUnlocked, questsUnlocked, signature, state, storyUnlocked, upgradesUnlocked, zonesUnlocked, }); } catch (error) { void logger.error( "debug_force_unlocks", error instanceof Error ? error : new Error(String(error)), ); return context.json({ error: "Internal server error" }, 500); } }); debugRouter.post("/sync-new-content", async(context) => { try { const discordId = context.get("discordId"); const gameStateRecord = await prisma.gameState.findUnique({ where: { discordId }, }); if (!gameStateRecord) { return context.json({ error: "No game state found" }, 404); } /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma stores state as JSON object */ const state = gameStateRecord.state as unknown as GameState; const { achievementsAdded, adventurersAdded, bossesAdded, equipmentAdded, explorationAreasAdded, questsAdded, upgradesAdded, zonesAdded, } = syncNewContent(state); const updatedAt = Date.now(); await prisma.gameState.update({ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ data: { state: state as object, updatedAt: updatedAt }, where: { discordId }, }); const secret = process.env.ANTI_CHEAT_SECRET; const signature = secret === undefined ? undefined : computeHmac(JSON.stringify(state), secret); return context.json({ achievementsAdded, adventurersAdded, bossesAdded, equipmentAdded, explorationAreasAdded, questsAdded, signature, state, upgradesAdded, zonesAdded, }); } catch (error) { void logger.error( "debug_sync_new_content", error instanceof Error ? error : new Error(String(error)), ); return context.json({ error: "Internal server error" }, 500); } }); debugRouter.post("/hard-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, 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, }); } catch (error) { void logger.error( "debug_hard_reset", error instanceof Error ? error : new Error(String(error)), ); return context.json({ error: "Internal server error" }, 500); } }); export { debugRouter };