generated from nhcarrigan/template
fix: adventurer unlocks not applied by force-unlock tool #93
@@ -7,6 +7,11 @@
|
|||||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||||
/* eslint-disable max-lines -- Multiple route handlers and helper functions in one file */
|
/* eslint-disable max-lines -- Multiple route handlers and helper functions in one file */
|
||||||
import { createHmac } from "node:crypto";
|
import { createHmac } from "node:crypto";
|
||||||
|
import {
|
||||||
|
STORY_CHAPTERS,
|
||||||
|
isStoryChapterUnlocked,
|
||||||
|
type GameState,
|
||||||
|
} from "@elysium/types";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { defaultBosses } from "../data/bosses.js";
|
import { defaultBosses } from "../data/bosses.js";
|
||||||
import { defaultExplorations } from "../data/explorations.js";
|
import { defaultExplorations } from "../data/explorations.js";
|
||||||
@@ -18,7 +23,6 @@ import { prisma } from "../db/client.js";
|
|||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
import { logger } from "../services/logger.js";
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
import type { GameState } from "@elysium/types";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes the HMAC-SHA256 of data using the given secret.
|
* Computes the HMAC-SHA256 of data using the given secret.
|
||||||
@@ -257,6 +261,180 @@ const applyBossUnlocks = (state: GameState): number => {
|
|||||||
return count;
|
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<string>();
|
||||||
|
|
||||||
|
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<string> => {
|
||||||
|
const earnedIds = new Set<string>();
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
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.
|
* Makes available any exploration areas whose parent zone is now unlocked.
|
||||||
* @param state - The player's current game state (mutated directly).
|
* @param state - The player's current game state (mutated directly).
|
||||||
@@ -301,16 +479,33 @@ const applyExplorationUnlocks = (state: GameState): number => {
|
|||||||
const applyForceUnlocks = (
|
const applyForceUnlocks = (
|
||||||
state: GameState,
|
state: GameState,
|
||||||
): {
|
): {
|
||||||
|
adventurersUnlocked: number;
|
||||||
bossesUnlocked: number;
|
bossesUnlocked: number;
|
||||||
|
equipmentUnlocked: number;
|
||||||
explorationUnlocked: number;
|
explorationUnlocked: number;
|
||||||
questsUnlocked: number;
|
questsUnlocked: number;
|
||||||
|
storyUnlocked: number;
|
||||||
|
upgradesUnlocked: number;
|
||||||
zonesUnlocked: number;
|
zonesUnlocked: number;
|
||||||
} => {
|
} => {
|
||||||
const zonesUnlocked = applyZoneUnlocks(state);
|
const zonesUnlocked = applyZoneUnlocks(state);
|
||||||
const questsUnlocked = applyQuestUnlocks(state);
|
const questsUnlocked = applyQuestUnlocks(state);
|
||||||
const bossesUnlocked = applyBossUnlocks(state);
|
const bossesUnlocked = applyBossUnlocks(state);
|
||||||
const explorationUnlocked = applyExplorationUnlocks(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<HonoEnvironment>();
|
const debugRouter = new Hono<HonoEnvironment>();
|
||||||
@@ -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 */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma stores state as JSON object */
|
||||||
const state = gameStateRecord.state as unknown as GameState;
|
const state = gameStateRecord.state as unknown as GameState;
|
||||||
|
|
||||||
const { bossesUnlocked, explorationUnlocked, questsUnlocked, zonesUnlocked }
|
const {
|
||||||
= applyForceUnlocks(state);
|
adventurersUnlocked,
|
||||||
|
bossesUnlocked,
|
||||||
|
equipmentUnlocked,
|
||||||
|
explorationUnlocked,
|
||||||
|
questsUnlocked,
|
||||||
|
storyUnlocked,
|
||||||
|
upgradesUnlocked,
|
||||||
|
zonesUnlocked,
|
||||||
|
} = applyForceUnlocks(state);
|
||||||
|
|
||||||
const updatedAt = Date.now();
|
const updatedAt = Date.now();
|
||||||
await prisma.gameState.update({
|
await prisma.gameState.update({
|
||||||
@@ -347,11 +550,15 @@ debugRouter.post("/force-unlocks", async(context) => {
|
|||||||
: computeHmac(JSON.stringify(state), secret);
|
: computeHmac(JSON.stringify(state), secret);
|
||||||
|
|
||||||
return context.json({
|
return context.json({
|
||||||
|
adventurersUnlocked,
|
||||||
bossesUnlocked,
|
bossesUnlocked,
|
||||||
|
equipmentUnlocked,
|
||||||
explorationUnlocked,
|
explorationUnlocked,
|
||||||
questsUnlocked,
|
questsUnlocked,
|
||||||
signature,
|
signature,
|
||||||
state,
|
state,
|
||||||
|
storyUnlocked,
|
||||||
|
upgradesUnlocked,
|
||||||
zonesUnlocked,
|
zonesUnlocked,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -366,6 +366,161 @@ describe("debug route", () => {
|
|||||||
expect(body.explorationUnlocked).toBe(0);
|
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 () => {
|
it("computes HMAC signature when ANTI_CHEAT_SECRET is set", async () => {
|
||||||
process.env.ANTI_CHEAT_SECRET = "test_secret";
|
process.env.ANTI_CHEAT_SECRET = "test_secret";
|
||||||
const state = makeState();
|
const state = makeState();
|
||||||
|
|||||||
@@ -12,6 +12,49 @@ import { ConfirmationModal } from "../ui/confirmationModal.js";
|
|||||||
|
|
||||||
type ActiveModal = "force-unlocks" | "hard-reset" | null;
|
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.
|
* Renders the debug panel with tools for fixing stuck game state.
|
||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
@@ -38,29 +81,7 @@ const DebugPanel = (): JSX.Element => {
|
|||||||
setActiveModal(null);
|
setActiveModal(null);
|
||||||
void (async(): Promise<void> => {
|
void (async(): Promise<void> => {
|
||||||
const result = await forceUnlocks();
|
const result = await forceUnlocks();
|
||||||
const parts: Array<string> = [];
|
setForceUnlocksResult(buildForceUnlocksMessage(result));
|
||||||
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);
|
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -558,9 +558,13 @@ interface GameContextValue {
|
|||||||
* @returns Counts of what was corrected.
|
* @returns Counts of what was corrected.
|
||||||
*/
|
*/
|
||||||
forceUnlocks: ()=> Promise<{
|
forceUnlocks: ()=> Promise<{
|
||||||
|
adventurersUnlocked: number;
|
||||||
bossesUnlocked: number;
|
bossesUnlocked: number;
|
||||||
|
equipmentUnlocked: number;
|
||||||
explorationUnlocked: number;
|
explorationUnlocked: number;
|
||||||
questsUnlocked: number;
|
questsUnlocked: number;
|
||||||
|
storyUnlocked: number;
|
||||||
|
upgradesUnlocked: number;
|
||||||
zonesUnlocked: number;
|
zonesUnlocked: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
@@ -2104,9 +2108,13 @@ export const GameProvider = ({
|
|||||||
localStorage.setItem("elysium_save_signature", data.signature);
|
localStorage.setItem("elysium_save_signature", data.signature);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
adventurersUnlocked: data.adventurersUnlocked,
|
||||||
bossesUnlocked: data.bossesUnlocked,
|
bossesUnlocked: data.bossesUnlocked,
|
||||||
|
equipmentUnlocked: data.equipmentUnlocked,
|
||||||
explorationUnlocked: data.explorationUnlocked,
|
explorationUnlocked: data.explorationUnlocked,
|
||||||
questsUnlocked: data.questsUnlocked,
|
questsUnlocked: data.questsUnlocked,
|
||||||
|
storyUnlocked: data.storyUnlocked,
|
||||||
|
upgradesUnlocked: data.upgradesUnlocked,
|
||||||
zonesUnlocked: data.zonesUnlocked,
|
zonesUnlocked: data.zonesUnlocked,
|
||||||
};
|
};
|
||||||
} catch (error_: unknown) {
|
} catch (error_: unknown) {
|
||||||
@@ -2116,9 +2124,13 @@ export const GameProvider = ({
|
|||||||
: "Failed to force unlocks",
|
: "Failed to force unlocks",
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
|
adventurersUnlocked: 0,
|
||||||
bossesUnlocked: 0,
|
bossesUnlocked: 0,
|
||||||
|
equipmentUnlocked: 0,
|
||||||
explorationUnlocked: 0,
|
explorationUnlocked: 0,
|
||||||
questsUnlocked: 0,
|
questsUnlocked: 0,
|
||||||
|
storyUnlocked: 0,
|
||||||
|
upgradesUnlocked: 0,
|
||||||
zonesUnlocked: 0,
|
zonesUnlocked: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -425,6 +425,26 @@ interface ForceUnlocksResponse {
|
|||||||
*/
|
*/
|
||||||
explorationUnlocked: number;
|
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.
|
* HMAC-SHA256 signature of the corrected state for anti-cheat chain continuity.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user