generated from nhcarrigan/template
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).
This commit is contained in:
@@ -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<string>();
|
||||
|
||||
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<string> => {
|
||||
const earnedIds = new Set<string>();
|
||||
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<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user