From 16c95f4fb36ef2171032e5f2c965271acc7dc16f Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 20 Mar 2026 10:00:08 -0700 Subject: [PATCH] fix: extend force-unlock to cover upgrades, equipment, and story chapters The force-unlock debug tool now also corrects three additional categories of stuck state: - Upgrades: scans defeated bosses and completed quests for upgrade rewards and marks those upgrades as unlocked - Equipment: scans defeated bosses for equipment rewards and marks those items as owned (without auto-equipping) - Story chapters: checks every chapter's unlock condition against the current game state and adds any missing IDs to unlockedChapterIds The result message in the debug panel now reports all eight corrected categories. --- apps/api/src/routes/debug.ts | 124 +++++++++++++++++++- apps/web/src/components/game/debugPanel.tsx | 71 ++++++----- apps/web/src/context/gameContext.tsx | 9 ++ packages/types/src/interfaces/api.ts | 15 +++ 4 files changed, 191 insertions(+), 28 deletions(-) diff --git a/apps/api/src/routes/debug.ts b/apps/api/src/routes/debug.ts index ecf482e..7fa1717 100644 --- a/apps/api/src/routes/debug.ts +++ b/apps/api/src/routes/debug.ts @@ -7,6 +7,11 @@ /* 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 { defaultBosses } from "../data/bosses.js"; import { defaultExplorations } from "../data/explorations.js"; @@ -18,7 +23,6 @@ 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 { GameState } from "@elysium/types"; /** * Computes the HMAC-SHA256 of data using the given secret. @@ -288,6 +292,109 @@ const applyAdventurerUnlocks = (state: GameState): number => { return count; }; +/** + * Collects all upgrade IDs the player has legitimately earned via boss defeats + * and completed quest rewards. + * @param state - The player's current game state. + * @returns A set of earned upgrade IDs. + */ +const collectEarnedUpgradeIds = (state: GameState): Set => { + const earnedIds = new Set(); + for (const boss of state.bosses) { + if (boss.status === "defeated") { + for (const upgradeId of boss.upgradeRewards) { + earnedIds.add(upgradeId); + } + } + } + for (const quest of state.quests) { + if (quest.status !== "completed") { + continue; + } + for (const reward of quest.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 earnedEquipmentIds = new Set(); + + for (const boss of state.bosses) { + if (boss.status !== "defeated") { + continue; + } + for (const equipmentId of boss.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). @@ -334,8 +441,11 @@ const applyForceUnlocks = ( ): { adventurersUnlocked: number; bossesUnlocked: number; + equipmentUnlocked: number; explorationUnlocked: number; questsUnlocked: number; + storyUnlocked: number; + upgradesUnlocked: number; zonesUnlocked: number; } => { const zonesUnlocked = applyZoneUnlocks(state); @@ -343,11 +453,17 @@ const applyForceUnlocks = ( 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, }; }; @@ -372,8 +488,11 @@ debugRouter.post("/force-unlocks", async(context) => { const { adventurersUnlocked, bossesUnlocked, + equipmentUnlocked, explorationUnlocked, questsUnlocked, + storyUnlocked, + upgradesUnlocked, zonesUnlocked, } = applyForceUnlocks(state); @@ -393,10 +512,13 @@ debugRouter.post("/force-unlocks", async(context) => { return context.json({ adventurersUnlocked, bossesUnlocked, + equipmentUnlocked, explorationUnlocked, questsUnlocked, signature, state, + storyUnlocked, + upgradesUnlocked, zonesUnlocked, }); } catch (error) { diff --git a/apps/web/src/components/game/debugPanel.tsx b/apps/web/src/components/game/debugPanel.tsx index 8bfc410..958bed4 100644 --- a/apps/web/src/components/game/debugPanel.tsx +++ b/apps/web/src/components/game/debugPanel.tsx @@ -12,6 +12,49 @@ import { ConfirmationModal } from "../ui/confirmationModal.js"; type ActiveModal = "force-unlocks" | "hard-reset" | null; +interface ForceUnlocksResult { + adventurersUnlocked: number; + bossesUnlocked: number; + equipmentUnlocked: number; + explorationUnlocked: number; + questsUnlocked: number; + storyUnlocked: number; + upgradesUnlocked: number; + zonesUnlocked: number; +} + +/** + * Builds a human-readable summary of what the force-unlock operation corrected. + * @param result - The counts returned by the force-unlock operation. + * @returns A message string describing what was fixed, or a confirmation that nothing needed fixing. + */ +const buildForceUnlocksMessage = (result: ForceUnlocksResult): string => { + const entries: Array<[ number, string ]> = [ + [ result.zonesUnlocked, "zone(s)" ], + [ result.questsUnlocked, "quest(s)" ], + [ result.bossesUnlocked, "boss(es)" ], + [ result.explorationUnlocked, "exploration area(s)" ], + [ result.adventurersUnlocked, "adventurer tier(s)" ], + [ result.upgradesUnlocked, "upgrade(s)" ], + [ result.equipmentUnlocked, "equipment item(s)" ], + [ result.storyUnlocked, "story chapter(s)" ], + ]; + const parts = entries. + filter(([ count ]) => { + return count > 0; + }). + map(([ count, label ]) => { + return `${String(count)} ${label}`; + }); + if (parts.length === 0) { + return "Everything looks correct — no missing unlocks were found."; + } + const total = entries.reduce((sum, [ count ]) => { + return sum + count; + }, 0); + return `Fixed ${String(total)} unlock(s): ${parts.join(", ")}.`; +}; + /** * Renders the debug panel with tools for fixing stuck game state. * @returns The JSX element. @@ -38,33 +81,7 @@ const DebugPanel = (): JSX.Element => { setActiveModal(null); void (async(): Promise => { const result = await forceUnlocks(); - const parts: Array = []; - if (result.zonesUnlocked > 0) { - parts.push(`${String(result.zonesUnlocked)} zone(s)`); - } - if (result.questsUnlocked > 0) { - parts.push(`${String(result.questsUnlocked)} quest(s)`); - } - if (result.bossesUnlocked > 0) { - parts.push(`${String(result.bossesUnlocked)} boss(es)`); - } - if (result.explorationUnlocked > 0) { - parts.push(`${String(result.explorationUnlocked)} exploration area(s)`); - } - if (result.adventurersUnlocked > 0) { - parts.push(`${String(result.adventurersUnlocked)} adventurer tier(s)`); - } - const total - = result.zonesUnlocked - + result.questsUnlocked - + result.bossesUnlocked - + result.explorationUnlocked - + result.adventurersUnlocked; - const message - = parts.length === 0 - ? "Everything looks correct — no missing unlocks were found." - : `Fixed ${String(total)} unlock(s): ${parts.join(", ")}.`; - setForceUnlocksResult(message); + setForceUnlocksResult(buildForceUnlocksMessage(result)); })(); } diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index b65db14..70a3001 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -560,8 +560,11 @@ interface GameContextValue { forceUnlocks: ()=> Promise<{ adventurersUnlocked: number; bossesUnlocked: number; + equipmentUnlocked: number; explorationUnlocked: number; questsUnlocked: number; + storyUnlocked: number; + upgradesUnlocked: number; zonesUnlocked: number; }>; @@ -2107,8 +2110,11 @@ export const GameProvider = ({ return { adventurersUnlocked: data.adventurersUnlocked, bossesUnlocked: data.bossesUnlocked, + equipmentUnlocked: data.equipmentUnlocked, explorationUnlocked: data.explorationUnlocked, questsUnlocked: data.questsUnlocked, + storyUnlocked: data.storyUnlocked, + upgradesUnlocked: data.upgradesUnlocked, zonesUnlocked: data.zonesUnlocked, }; } catch (error_: unknown) { @@ -2120,8 +2126,11 @@ export const GameProvider = ({ return { adventurersUnlocked: 0, bossesUnlocked: 0, + equipmentUnlocked: 0, explorationUnlocked: 0, questsUnlocked: 0, + storyUnlocked: 0, + upgradesUnlocked: 0, zonesUnlocked: 0, }; } diff --git a/packages/types/src/interfaces/api.ts b/packages/types/src/interfaces/api.ts index ef5e7f0..1c6e673 100644 --- a/packages/types/src/interfaces/api.ts +++ b/packages/types/src/interfaces/api.ts @@ -430,6 +430,21 @@ interface ForceUnlocksResponse { */ adventurersUnlocked: number; + /** + * Number of upgrades that were unlocked by this operation. + */ + upgradesUnlocked: number; + + /** + * Number of equipment items that were marked as owned by this operation. + */ + equipmentUnlocked: number; + + /** + * Number of story chapters that were unlocked by this operation. + */ + storyUnlocked: number; + /** * HMAC-SHA256 signature of the corrected state for anti-cheat chain continuity. */