/** * @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 { defaultRecipes } from "../data/recipes.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; }; /** * Patches rewards on existing quests whose reward lists have grown since the * save was created (e.g. A new upgrade added as a reward to an old quest). * @param state - The player's current game state (mutated in place). * @returns The total number of individual rewards that were added. */ const patchQuestRewards = (state: GameState): number => { const defaultQuestMap = new Map(defaultQuests.map((quest) => { return [ quest.id, quest ] as const; })); let added = 0; for (const savedQuest of state.quests) { const defaultQuest = defaultQuestMap.get(savedQuest.id); if (defaultQuest === undefined) { continue; } const existingKeys = new Set(savedQuest.rewards.map((reward) => { return `${reward.type}:${String(reward.targetId ?? reward.amount ?? "")}`; })); for (const reward of defaultQuest.rewards) { // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ const key = `${reward.type}:${String(reward.targetId ?? reward.amount ?? "")}`; if (!existingKeys.has(key)) { savedQuest.rewards.push(structuredClone(reward)); added = added + 1; } } } return added; }; /** * Patches upgradeRewards on existing bosses whose reward lists have grown * since the save was created. * @param state - The player's current game state (mutated in place). * @returns The total number of upgrade reward IDs that were added. */ const patchBossUpgradeRewards = (state: GameState): number => { const defaultBossMap = new Map(defaultBosses.map((boss) => { return [ boss.id, boss ] as const; })); let added = 0; for (const savedBoss of state.bosses) { const defaultBoss = defaultBossMap.get(savedBoss.id); if (defaultBoss === undefined) { continue; } const existingIds = new Set(savedBoss.upgradeRewards); for (const upgradeId of defaultBoss.upgradeRewards) { if (!existingIds.has(upgradeId)) { savedBoss.upgradeRewards.push(upgradeId); added = added + 1; } } } return added; }; /** * Updates the stat fields of existing adventurers to match the current defaults, * preserving only player-state fields (count and unlocked status). * @param state - The player's current game state (mutated in place). * @returns The number of adventurer entries whose stats were updated. */ const patchAdventurerStats = (state: GameState): number => { const defaultAdventurerMap = new Map(defaultAdventurers.map((adventurer) => { return [ adventurer.id, adventurer ] as const; })); let patched = 0; for (const savedAdventurer of state.adventurers) { const defaultAdventurer = defaultAdventurerMap.get(savedAdventurer.id); if (defaultAdventurer === undefined) { continue; } const hasChanged = savedAdventurer.baseCost !== defaultAdventurer.baseCost || savedAdventurer.class !== defaultAdventurer.class || savedAdventurer.combatPower !== defaultAdventurer.combatPower || savedAdventurer.essencePerSecond !== defaultAdventurer.essencePerSecond || savedAdventurer.goldPerSecond !== defaultAdventurer.goldPerSecond || savedAdventurer.level !== defaultAdventurer.level || savedAdventurer.name !== defaultAdventurer.name; savedAdventurer.baseCost = defaultAdventurer.baseCost; savedAdventurer.class = defaultAdventurer.class; savedAdventurer.combatPower = defaultAdventurer.combatPower; savedAdventurer.essencePerSecond = defaultAdventurer.essencePerSecond; savedAdventurer.goldPerSecond = defaultAdventurer.goldPerSecond; savedAdventurer.level = defaultAdventurer.level; savedAdventurer.name = defaultAdventurer.name; if (hasChanged) { patched = patched + 1; } } return patched; }; /** * Updates the stat fields of existing quests to match the current defaults, * preserving only player-state fields (status, startedAt, lastFailedAt, rewards). * @param state - The player's current game state (mutated in place). * @returns The number of quest entries whose stats were updated. */ const patchQuestStats = (state: GameState): number => { const defaultQuestMap = new Map(defaultQuests.map((quest) => { return [ quest.id, quest ] as const; })); let patched = 0; for (const savedQuest of state.quests) { const defaultQuest = defaultQuestMap.get(savedQuest.id); if (defaultQuest === undefined) { continue; } const savedPrereqs = JSON.stringify(savedQuest.prerequisiteIds); const defaultPrereqs = JSON.stringify(defaultQuest.prerequisiteIds); const hasChanged = savedQuest.name !== defaultQuest.name || savedQuest.description !== defaultQuest.description || savedQuest.durationSeconds !== defaultQuest.durationSeconds || savedPrereqs !== defaultPrereqs || savedQuest.zoneId !== defaultQuest.zoneId || savedQuest.combatPowerRequired !== defaultQuest.combatPowerRequired; savedQuest.name = defaultQuest.name; savedQuest.description = defaultQuest.description; savedQuest.durationSeconds = defaultQuest.durationSeconds; savedQuest.prerequisiteIds = defaultQuest.prerequisiteIds; savedQuest.zoneId = defaultQuest.zoneId; if (defaultQuest.combatPowerRequired !== undefined) { savedQuest.combatPowerRequired = defaultQuest.combatPowerRequired; } if (hasChanged) { patched = patched + 1; } } return patched; }; /** * Updates the stat fields of existing bosses to match the current defaults, * preserving only player-state fields (status, currentHp, bountyRunestonesClaimed, upgradeRewards). * @param state - The player's current game state (mutated in place). * @returns The number of boss entries whose stats were updated. */ /* eslint-disable-next-line complexity, max-statements -- Comparing many boss stat fields for change detection */ const patchBossStats = (state: GameState): number => { const defaultBossMap = new Map(defaultBosses.map((boss) => { return [ boss.id, boss ] as const; })); let patched = 0; for (const savedBoss of state.bosses) { const defaultBoss = defaultBossMap.get(savedBoss.id); if (defaultBoss === undefined) { continue; } const savedRewards = JSON.stringify(savedBoss.equipmentRewards); const defaultRewards = JSON.stringify(defaultBoss.equipmentRewards); const hasChanged = savedBoss.name !== defaultBoss.name || savedBoss.description !== defaultBoss.description || savedBoss.maxHp !== defaultBoss.maxHp || savedBoss.damagePerSecond !== defaultBoss.damagePerSecond || savedBoss.goldReward !== defaultBoss.goldReward || savedBoss.essenceReward !== defaultBoss.essenceReward || savedBoss.crystalReward !== defaultBoss.crystalReward || savedRewards !== defaultRewards || savedBoss.prestigeRequirement !== defaultBoss.prestigeRequirement || savedBoss.zoneId !== defaultBoss.zoneId || savedBoss.bountyRunestones !== defaultBoss.bountyRunestones; savedBoss.name = defaultBoss.name; savedBoss.description = defaultBoss.description; savedBoss.maxHp = defaultBoss.maxHp; savedBoss.damagePerSecond = defaultBoss.damagePerSecond; savedBoss.goldReward = defaultBoss.goldReward; savedBoss.essenceReward = defaultBoss.essenceReward; savedBoss.crystalReward = defaultBoss.crystalReward; savedBoss.equipmentRewards = [ ...defaultBoss.equipmentRewards ]; savedBoss.prestigeRequirement = defaultBoss.prestigeRequirement; savedBoss.zoneId = defaultBoss.zoneId; savedBoss.bountyRunestones = defaultBoss.bountyRunestones; if (hasChanged) { patched = patched + 1; } } return patched; }; /** * Updates the stat fields of existing zones to match the current defaults, * preserving only player-state fields (status). * @param state - The player's current game state (mutated in place). * @returns The number of zone entries whose stats were updated. */ const patchZoneStats = (state: GameState): number => { const defaultZoneMap = new Map(defaultZones.map((zone) => { return [ zone.id, zone ] as const; })); let patched = 0; for (const savedZone of state.zones) { const defaultZone = defaultZoneMap.get(savedZone.id); if (defaultZone === undefined) { continue; } const hasChanged = savedZone.name !== defaultZone.name || savedZone.description !== defaultZone.description || savedZone.emoji !== defaultZone.emoji || savedZone.unlockBossId !== defaultZone.unlockBossId || savedZone.unlockQuestId !== defaultZone.unlockQuestId; savedZone.name = defaultZone.name; savedZone.description = defaultZone.description; savedZone.emoji = defaultZone.emoji; savedZone.unlockBossId = defaultZone.unlockBossId; savedZone.unlockQuestId = defaultZone.unlockQuestId; if (hasChanged) { patched = patched + 1; } } return patched; }; /** * Updates the stat fields of existing upgrades to match the current defaults, * preserving only player-state fields (purchased, unlocked). * @param state - The player's current game state (mutated in place). * @returns The number of upgrade entries whose stats were updated. */ /* eslint-disable-next-line complexity -- Comparing many upgrade stat fields for change detection */ const patchUpgradeStats = (state: GameState): number => { const defaultUpgradeMap = new Map(defaultUpgrades.map((upgrade) => { return [ upgrade.id, upgrade ] as const; })); let patched = 0; for (const savedUpgrade of state.upgrades) { const defaultUpgrade = defaultUpgradeMap.get(savedUpgrade.id); if (defaultUpgrade === undefined) { continue; } const hasChanged = savedUpgrade.name !== defaultUpgrade.name || savedUpgrade.description !== defaultUpgrade.description || savedUpgrade.target !== defaultUpgrade.target || savedUpgrade.adventurerId !== defaultUpgrade.adventurerId || savedUpgrade.multiplier !== defaultUpgrade.multiplier || savedUpgrade.costGold !== defaultUpgrade.costGold || savedUpgrade.costEssence !== defaultUpgrade.costEssence || savedUpgrade.costCrystals !== defaultUpgrade.costCrystals; savedUpgrade.name = defaultUpgrade.name; savedUpgrade.description = defaultUpgrade.description; savedUpgrade.target = defaultUpgrade.target; if (defaultUpgrade.adventurerId !== undefined) { savedUpgrade.adventurerId = defaultUpgrade.adventurerId; } savedUpgrade.multiplier = defaultUpgrade.multiplier; savedUpgrade.costGold = defaultUpgrade.costGold; savedUpgrade.costEssence = defaultUpgrade.costEssence; savedUpgrade.costCrystals = defaultUpgrade.costCrystals; if (hasChanged) { patched = patched + 1; } } return patched; }; /** * Updates the stat fields of existing equipment items to match the current defaults, * preserving only player-state fields (owned, equipped). * @param state - The player's current game state (mutated in place). * @returns The number of equipment entries whose stats were updated. */ /* eslint-disable-next-line complexity, max-statements -- Comparing many equipment stat fields for change detection */ const patchEquipmentStats = (state: GameState): number => { const defaultEquipmentMap = new Map(defaultEquipment.map((item) => { return [ item.id, item ] as const; })); let patched = 0; for (const savedItem of state.equipment) { const defaultItem = defaultEquipmentMap.get(savedItem.id); if (defaultItem === undefined) { continue; } const savedBonus = JSON.stringify(savedItem.bonus); const defaultBonus = JSON.stringify(defaultItem.bonus); const savedCost = JSON.stringify(savedItem.cost); const defaultCost = JSON.stringify(defaultItem.cost); const hasChanged = savedItem.name !== defaultItem.name || savedItem.description !== defaultItem.description || savedItem.type !== defaultItem.type || savedItem.rarity !== defaultItem.rarity || savedBonus !== defaultBonus || savedCost !== defaultCost || savedItem.setId !== defaultItem.setId; savedItem.name = defaultItem.name; savedItem.description = defaultItem.description; savedItem.type = defaultItem.type; savedItem.rarity = defaultItem.rarity; savedItem.bonus = structuredClone(defaultItem.bonus); if (defaultItem.cost !== undefined) { savedItem.cost = { ...defaultItem.cost }; } if (defaultItem.setId !== undefined) { savedItem.setId = defaultItem.setId; } if (hasChanged) { patched = patched + 1; } } return patched; }; /** * Updates the stat fields of existing achievements to match the current defaults, * preserving only player-state fields (unlockedAt). * @param state - The player's current game state (mutated in place). * @returns The number of achievement entries whose stats were updated. */ const patchAchievementStats = (state: GameState): number => { const defaultAchievementMap = new Map(defaultAchievements.map((a) => { return [ a.id, a ] as const; })); let patched = 0; for (const savedAchievement of state.achievements) { const defaultAchievement = defaultAchievementMap.get(savedAchievement.id); if (defaultAchievement === undefined) { continue; } const savedCondition = JSON.stringify(savedAchievement.condition); const defaultCondition = JSON.stringify(defaultAchievement.condition); const savedReward = JSON.stringify(savedAchievement.reward); const defaultReward = JSON.stringify(defaultAchievement.reward); const hasChanged = savedAchievement.name !== defaultAchievement.name || savedAchievement.description !== defaultAchievement.description || savedAchievement.icon !== defaultAchievement.icon || savedCondition !== defaultCondition || savedReward !== defaultReward; savedAchievement.name = defaultAchievement.name; savedAchievement.description = defaultAchievement.description; savedAchievement.icon = defaultAchievement.icon; savedAchievement.condition = structuredClone(defaultAchievement.condition); if (defaultAchievement.reward !== undefined) { savedAchievement.reward = { ...defaultAchievement.reward }; } if (hasChanged) { patched = patched + 1; } } return patched; }; /* eslint-disable stylistic/max-len -- Filter conditions cannot be shortened without losing readability */ /** * Recomputes all four crafting multipliers from the player's craftedRecipeIds, * replacing any stale cached values with the correct product of all crafted bonuses. * @param state - The player's current game state (mutated in place). * @returns The number of crafted recipe IDs that were processed, or 0 if exploration is undefined. */ const recomputeCraftingMultipliers = (state: GameState): number => { if (state.exploration === undefined) { return 0; } const { craftedRecipeIds } = state.exploration; state.exploration.craftedGoldMultiplier = defaultRecipes.filter((recipe) => { return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "gold_income"; }).reduce((multiplier, recipe) => { return multiplier * recipe.bonus.value; }, 1); state.exploration.craftedEssenceMultiplier = defaultRecipes.filter((recipe) => { return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "essence_income"; }).reduce((multiplier, recipe) => { // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ return multiplier * recipe.bonus.value; }, 1); state.exploration.craftedClickMultiplier = defaultRecipes.filter((recipe) => { return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "click_power"; }).reduce((multiplier, recipe) => { // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ return multiplier * recipe.bonus.value; }, 1); state.exploration.craftedCombatMultiplier = defaultRecipes.filter((recipe) => { return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "combat_power"; }).reduce((multiplier, recipe) => { // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ return multiplier * recipe.bonus.value; }, 1); return craftedRecipeIds.length; }; /* eslint-enable stylistic/max-len -- Re-enable after long lines */ /* 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, * and patching stat fields on existing entries to match the current defaults. * @param state - The player's current game state (mutated in place). * @returns Counts of how many entries were added or patched per content type. */ const syncNewContent = ( state: GameState, ): { achievementsAdded: number; achievementsPatched: number; adventurersAdded: number; adventurerStatsPatched: number; bossesAdded: number; bossesPatched: number; bossRewardsPatched: number; craftingRecipesReapplied: number; equipmentAdded: number; equipmentPatched: number; explorationAreasAdded: number; questRewardsPatched: number; questsAdded: number; questsPatched: number; upgradesAdded: number; upgradesPatched: number; zonesAdded: number; zonesPatched: number; } => { const adventurerStatsPatched = patchAdventurerStats(state); const questsPatched = patchQuestStats(state); const bossesPatched = patchBossStats(state); const zonesPatched = patchZoneStats(state); const upgradesPatched = patchUpgradeStats(state); const equipmentPatched = patchEquipmentStats(state); const achievementsPatched = patchAchievementStats(state); const craftingRecipesReapplied = recomputeCraftingMultipliers(state); const achievementsAdded = injectMissingEntries(state.achievements, defaultAchievements); const adventurersAdded = injectMissingEntries(state.adventurers, defaultAdventurers); const bossRewardsPatched = patchBossUpgradeRewards(state); const bossesAdded = injectMissingEntries(state.bosses, defaultBosses); const equipmentAdded = injectMissingEntries(state.equipment, defaultEquipment); const explorationAreasAdded = injectMissingExplorationAreas(state); const questRewardsPatched = patchQuestRewards(state); const questsAdded = injectMissingEntries(state.quests, defaultQuests); const upgradesAdded = injectMissingEntries(state.upgrades, defaultUpgrades); const zonesAdded = injectMissingEntries(state.zones, defaultZones); return { achievementsAdded, achievementsPatched, adventurerStatsPatched, adventurersAdded, bossRewardsPatched, bossesAdded, bossesPatched, craftingRecipesReapplied, equipmentAdded, equipmentPatched, explorationAreasAdded, questRewardsPatched, questsAdded, questsPatched, upgradesAdded, upgradesPatched, zonesAdded, zonesPatched, }; }; /* 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, achievementsPatched, adventurersAdded, adventurerStatsPatched, bossesAdded, bossesPatched, bossRewardsPatched, craftingRecipesReapplied, equipmentAdded, equipmentPatched, explorationAreasAdded, questRewardsPatched, questsAdded, questsPatched, upgradesAdded, upgradesPatched, zonesAdded, zonesPatched, } = 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, achievementsPatched, adventurerStatsPatched, adventurersAdded, bossRewardsPatched, bossesAdded, bossesPatched, craftingRecipesReapplied, equipmentAdded, equipmentPatched, explorationAreasAdded, questRewardsPatched, questsAdded, questsPatched, signature, state, upgradesAdded, upgradesPatched, zonesAdded, zonesPatched, }); } 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 };