diff --git a/apps/api/src/routes/debug.ts b/apps/api/src/routes/debug.ts index 3cc345e..59d1225 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. @@ -257,6 +261,180 @@ const applyBossUnlocks = (state: GameState): number => { 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). @@ -301,16 +479,33 @@ const applyExplorationUnlocks = (state: GameState): number => { 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); - return { bossesUnlocked, explorationUnlocked, questsUnlocked, zonesUnlocked }; + 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, + }; }; const debugRouter = new Hono(); @@ -330,8 +525,16 @@ debugRouter.post("/force-unlocks", async(context) => { /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma stores state as JSON object */ const state = gameStateRecord.state as unknown as GameState; - const { bossesUnlocked, explorationUnlocked, questsUnlocked, zonesUnlocked } - = applyForceUnlocks(state); + const { + adventurersUnlocked, + bossesUnlocked, + equipmentUnlocked, + explorationUnlocked, + questsUnlocked, + storyUnlocked, + upgradesUnlocked, + zonesUnlocked, + } = applyForceUnlocks(state); const updatedAt = Date.now(); await prisma.gameState.update({ @@ -347,11 +550,15 @@ debugRouter.post("/force-unlocks", async(context) => { : computeHmac(JSON.stringify(state), secret); return context.json({ + adventurersUnlocked, bossesUnlocked, + equipmentUnlocked, explorationUnlocked, questsUnlocked, signature, state, + storyUnlocked, + upgradesUnlocked, zonesUnlocked, }); } catch (error) { diff --git a/apps/api/test/routes/debug.spec.ts b/apps/api/test/routes/debug.spec.ts index 7d5385b..f0dab75 100644 --- a/apps/api/test/routes/debug.spec.ts +++ b/apps/api/test/routes/debug.spec.ts @@ -366,6 +366,161 @@ describe("debug route", () => { expect(body.explorationUnlocked).toBe(0); }); + it("unlocks adventurer tier when its quest has been completed", async () => { + const state = makeState({ + adventurers: [ { id: "scout", unlocked: false } ] as GameState["adventurers"], + quests: [ { id: "haunted_mine", status: "completed" } ] as GameState["quests"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await forceUnlocks(); + const body = await res.json() as { adventurersUnlocked: number }; + expect(body.adventurersUnlocked).toBe(1); + }); + + it("does not unlock adventurer tier when it is already unlocked", async () => { + const state = makeState({ + adventurers: [ { id: "scout", unlocked: true } ] as GameState["adventurers"], + quests: [ { id: "haunted_mine", status: "completed" } ] as GameState["quests"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await forceUnlocks(); + const body = await res.json() as { adventurersUnlocked: number }; + expect(body.adventurersUnlocked).toBe(0); + }); + + it("unlocks upgrade when its boss has been defeated", async () => { + const state = makeState({ + bosses: [ { id: "troll_king", status: "defeated" } ] as GameState["bosses"], + upgrades: [ { id: "click_2", unlocked: false } ] as GameState["upgrades"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await forceUnlocks(); + const body = await res.json() as { upgradesUnlocked: number }; + expect(body.upgradesUnlocked).toBe(1); + }); + + it("does not unlock upgrade when boss is not defeated", async () => { + const state = makeState({ + bosses: [ { id: "troll_king", status: "available" } ] as GameState["bosses"], + upgrades: [ { id: "click_2", unlocked: false } ] as GameState["upgrades"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await forceUnlocks(); + const body = await res.json() as { upgradesUnlocked: number }; + expect(body.upgradesUnlocked).toBe(0); + }); + + it("does not unlock upgrade when it is already unlocked", async () => { + const state = makeState({ + bosses: [ { id: "troll_king", status: "defeated" } ] as GameState["bosses"], + upgrades: [ { id: "click_2", unlocked: true } ] as GameState["upgrades"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await forceUnlocks(); + const body = await res.json() as { upgradesUnlocked: number }; + expect(body.upgradesUnlocked).toBe(0); + }); + + it("unlocks upgrade granted as a quest reward", async () => { + const state = makeState({ + quests: [ { id: "haunted_mine", status: "completed" } ] as GameState["quests"], + upgrades: [ { id: "global_1", unlocked: false } ] as GameState["upgrades"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await forceUnlocks(); + const body = await res.json() as { upgradesUnlocked: number }; + expect(body.upgradesUnlocked).toBe(1); + }); + + it("marks equipment as owned when its boss has been defeated", async () => { + const state = makeState({ + bosses: [ { id: "troll_king", status: "defeated" } ] as GameState["bosses"], + equipment: [ { id: "iron_sword", owned: false } ] as GameState["equipment"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await forceUnlocks(); + const body = await res.json() as { equipmentUnlocked: number }; + expect(body.equipmentUnlocked).toBe(1); + }); + + it("does not mark equipment as owned when boss is not defeated", async () => { + const state = makeState({ + bosses: [ { id: "troll_king", status: "available" } ] as GameState["bosses"], + equipment: [ { id: "iron_sword", owned: false } ] as GameState["equipment"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await forceUnlocks(); + const body = await res.json() as { equipmentUnlocked: number }; + expect(body.equipmentUnlocked).toBe(0); + }); + + it("does not mark equipment as owned when it is already owned", async () => { + const state = makeState({ + bosses: [ { id: "troll_king", status: "defeated" } ] as GameState["bosses"], + equipment: [ { id: "iron_sword", owned: true } ] as GameState["equipment"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await forceUnlocks(); + const body = await res.json() as { equipmentUnlocked: number }; + expect(body.equipmentUnlocked).toBe(0); + }); + + it("returns storyUnlocked=0 when story is undefined", async () => { + const state = makeState({ + story: undefined as unknown as GameState["story"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await forceUnlocks(); + const body = await res.json() as { storyUnlocked: number }; + expect(body.storyUnlocked).toBe(0); + }); + + it("unlocks story chapter when its boss has been defeated", async () => { + const state = makeState({ + bosses: [ { id: "forest_giant", status: "defeated" } ] as GameState["bosses"], + story: { completedChapters: [], unlockedChapterIds: [] }, + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await forceUnlocks(); + const body = await res.json() as { storyUnlocked: number }; + expect(body.storyUnlocked).toBe(1); + }); + + it("does not unlock story chapter when boss is not defeated", async () => { + const state = makeState({ + bosses: [ { id: "forest_giant", status: "available" } ] as GameState["bosses"], + story: { completedChapters: [], unlockedChapterIds: [] }, + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await forceUnlocks(); + const body = await res.json() as { storyUnlocked: number }; + expect(body.storyUnlocked).toBe(0); + }); + + it("does not unlock story chapter when it is already unlocked", async () => { + const state = makeState({ + bosses: [ { id: "forest_giant", status: "defeated" } ] as GameState["bosses"], + story: { completedChapters: [], unlockedChapterIds: [ "story_ch_01" ] }, + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await forceUnlocks(); + const body = await res.json() as { storyUnlocked: number }; + expect(body.storyUnlocked).toBe(0); + }); + it("computes HMAC signature when ANTI_CHEAT_SECRET is set", async () => { process.env.ANTI_CHEAT_SECRET = "test_secret"; const state = makeState(); diff --git a/apps/web/src/components/game/debugPanel.tsx b/apps/web/src/components/game/debugPanel.tsx index 0abc231..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,29 +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)`); - } - const total - = result.zonesUnlocked - + result.questsUnlocked - + result.bossesUnlocked - + result.explorationUnlocked; - 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 a33b9d1..70a3001 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -558,9 +558,13 @@ interface GameContextValue { * @returns Counts of what was corrected. */ forceUnlocks: ()=> Promise<{ + adventurersUnlocked: number; bossesUnlocked: number; + equipmentUnlocked: number; explorationUnlocked: number; questsUnlocked: number; + storyUnlocked: number; + upgradesUnlocked: number; zonesUnlocked: number; }>; @@ -2104,9 +2108,13 @@ export const GameProvider = ({ localStorage.setItem("elysium_save_signature", data.signature); } 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) { @@ -2116,9 +2124,13 @@ export const GameProvider = ({ : "Failed to force unlocks", ); 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 8da988e..1c6e673 100644 --- a/packages/types/src/interfaces/api.ts +++ b/packages/types/src/interfaces/api.ts @@ -425,6 +425,26 @@ interface ForceUnlocksResponse { */ explorationUnlocked: number; + /** + * Number of adventurer tiers that were unlocked by this operation. + */ + 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. */