From 715ccd3fc71eb51aa85415eee3b08e456f13145e Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 20 Mar 2026 09:44:58 -0700 Subject: [PATCH 1/3] fix: adventurer unlocks not applied by force-unlock tool The force-unlock debug route now scans completed quests for adventurer rewards and ensures those tiers are marked as unlocked in game state. The UI and API response type both surface the new adventurersUnlocked count alongside existing zone/quest/boss/exploration counts. Closes #88 --- apps/api/src/routes/debug.ts | 51 +++++++++++++++++++-- apps/web/src/components/game/debugPanel.tsx | 6 ++- apps/web/src/context/gameContext.tsx | 3 ++ packages/types/src/interfaces/api.ts | 5 ++ 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/apps/api/src/routes/debug.ts b/apps/api/src/routes/debug.ts index 3cc345e..ecf482e 100644 --- a/apps/api/src/routes/debug.ts +++ b/apps/api/src/routes/debug.ts @@ -257,6 +257,37 @@ 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 earnedAdventurerIds = new Set(); + + for (const quest of state.quests) { + if (quest.status !== "completed") { + continue; + } + for (const reward of quest.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; +}; + /** * Makes available any exploration areas whose parent zone is now unlocked. * @param state - The player's current game state (mutated directly). @@ -301,6 +332,7 @@ const applyExplorationUnlocks = (state: GameState): number => { const applyForceUnlocks = ( state: GameState, ): { + adventurersUnlocked: number; bossesUnlocked: number; explorationUnlocked: number; questsUnlocked: number; @@ -310,7 +342,14 @@ const applyForceUnlocks = ( const questsUnlocked = applyQuestUnlocks(state); const bossesUnlocked = applyBossUnlocks(state); const explorationUnlocked = applyExplorationUnlocks(state); - return { bossesUnlocked, explorationUnlocked, questsUnlocked, zonesUnlocked }; + const adventurersUnlocked = applyAdventurerUnlocks(state); + return { + adventurersUnlocked, + bossesUnlocked, + explorationUnlocked, + questsUnlocked, + zonesUnlocked, + }; }; const debugRouter = new Hono(); @@ -330,8 +369,13 @@ 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, + explorationUnlocked, + questsUnlocked, + zonesUnlocked, + } = applyForceUnlocks(state); const updatedAt = Date.now(); await prisma.gameState.update({ @@ -347,6 +391,7 @@ debugRouter.post("/force-unlocks", async(context) => { : computeHmac(JSON.stringify(state), secret); return context.json({ + adventurersUnlocked, bossesUnlocked, explorationUnlocked, questsUnlocked, diff --git a/apps/web/src/components/game/debugPanel.tsx b/apps/web/src/components/game/debugPanel.tsx index 0abc231..8bfc410 100644 --- a/apps/web/src/components/game/debugPanel.tsx +++ b/apps/web/src/components/game/debugPanel.tsx @@ -51,11 +51,15 @@ const DebugPanel = (): JSX.Element => { 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.explorationUnlocked + + result.adventurersUnlocked; const message = parts.length === 0 ? "Everything looks correct — no missing unlocks were found." diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index a33b9d1..b65db14 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -558,6 +558,7 @@ interface GameContextValue { * @returns Counts of what was corrected. */ forceUnlocks: ()=> Promise<{ + adventurersUnlocked: number; bossesUnlocked: number; explorationUnlocked: number; questsUnlocked: number; @@ -2104,6 +2105,7 @@ export const GameProvider = ({ localStorage.setItem("elysium_save_signature", data.signature); } return { + adventurersUnlocked: data.adventurersUnlocked, bossesUnlocked: data.bossesUnlocked, explorationUnlocked: data.explorationUnlocked, questsUnlocked: data.questsUnlocked, @@ -2116,6 +2118,7 @@ export const GameProvider = ({ : "Failed to force unlocks", ); return { + adventurersUnlocked: 0, bossesUnlocked: 0, explorationUnlocked: 0, questsUnlocked: 0, diff --git a/packages/types/src/interfaces/api.ts b/packages/types/src/interfaces/api.ts index 8da988e..ef5e7f0 100644 --- a/packages/types/src/interfaces/api.ts +++ b/packages/types/src/interfaces/api.ts @@ -425,6 +425,11 @@ interface ForceUnlocksResponse { */ explorationUnlocked: number; + /** + * Number of adventurer tiers that were unlocked by this operation. + */ + adventurersUnlocked: number; + /** * HMAC-SHA256 signature of the corrected state for anti-cheat chain continuity. */ -- 2.52.0 From 16c95f4fb36ef2171032e5f2c965271acc7dc16f Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 20 Mar 2026 10:00:08 -0700 Subject: [PATCH 2/3] 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. */ -- 2.52.0 From 212bd2ea276ce430d04af1b9cc7686ca9265259b Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 20 Mar 2026 10:11:15 -0700 Subject: [PATCH 3/3] fix: guard against missing reward arrays in force-unlock helpers Reward lookups now use the game definitions (defaultBosses, defaultQuests) as the source of truth rather than reading arrays directly off state objects. This avoids runtime crashes when state objects are minimal (e.g. in tests or from old save data) and is more semantically correct. Adds full test coverage for the new applyUpgradeUnlocks, applyEquipmentUnlocks, applyStoryUnlocks, and applyAdventurerUnlocks paths in debug.spec.ts (42 tests, 100% coverage). --- apps/api/src/routes/debug.ts | 74 ++++++++++---- apps/api/test/routes/debug.spec.ts | 155 +++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+), 17 deletions(-) diff --git a/apps/api/src/routes/debug.ts b/apps/api/src/routes/debug.ts index 7fa1717..59d1225 100644 --- a/apps/api/src/routes/debug.ts +++ b/apps/api/src/routes/debug.ts @@ -269,13 +269,22 @@ const applyBossUnlocks = (state: GameState): number => { */ 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 quest of state.quests) { - if (quest.status !== "completed") { + for (const questDefinition of defaultQuests) { + if (!completedQuestIds.has(questDefinition.id)) { continue; } - for (const reward of quest.rewards) { + for (const reward of questDefinition.rewards) { if (reward.type === "adventurer" && reward.targetId !== undefined) { earnedAdventurerIds.add(reward.targetId); } @@ -294,29 +303,51 @@ const applyAdventurerUnlocks = (state: GameState): number => { /** * Collects all upgrade IDs the player has legitimately earned via boss defeats - * and completed quest rewards. + * 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(); - 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") { + 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 reward of quest.rewards) { + 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; }; @@ -348,13 +379,22 @@ const applyUpgradeUnlocks = (state: GameState): number => { */ 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 boss of state.bosses) { - if (boss.status !== "defeated") { + for (const bossDefinition of defaultBosses) { + if (!defeatedBossIds.has(bossDefinition.id)) { continue; } - for (const equipmentId of boss.equipmentRewards) { + for (const equipmentId of bossDefinition.equipmentRewards) { earnedEquipmentIds.add(equipmentId); } } 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(); -- 2.52.0