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();