diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 84a7958..e579b94 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -13,6 +13,7 @@ import { apotheosisRouter } from "./routes/apotheosis.js"; import { authRouter } from "./routes/auth.js"; import { bossRouter } from "./routes/boss.js"; import { craftRouter } from "./routes/craft.js"; +import { debugRouter } from "./routes/debug.js"; import { exploreRouter } from "./routes/explore.js"; import { frontendRouter } from "./routes/frontend.js"; import { gameRouter } from "./routes/game.js"; @@ -35,6 +36,7 @@ app.use( ); app.route("/about", aboutRouter); +app.route("/debug", debugRouter); app.route("/fe", frontendRouter); app.route("/auth", authRouter); app.route("/game", gameRouter); diff --git a/apps/api/src/routes/debug.ts b/apps/api/src/routes/debug.ts new file mode 100644 index 0000000..3cc345e --- /dev/null +++ b/apps/api/src/routes/debug.ts @@ -0,0 +1,441 @@ +/** + * @file Debug routes for administrative player state corrections. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* 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 { Hono } from "hono"; +import { defaultBosses } from "../data/bosses.js"; +import { defaultExplorations } from "../data/explorations.js"; +import { initialGameState } from "../data/initialState.js"; +import { defaultQuests } from "../data/quests.js"; +import { currentSchemaVersion } from "../data/schemaVersion.js"; +import { defaultZones } from "../data/zones.js"; +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. + * @param data - The data string to sign. + * @param secret - The HMAC secret key. + * @returns The hex-encoded HMAC digest. + */ +const computeHmac = (data: string, secret: string): string => { + return createHmac("sha256", secret).update(data). + digest("hex"); +}; + +/** + * Unlocks any zones whose required boss and quest conditions are satisfied. + * @param state - The player's current game state (mutated directly). + * @returns The number of zones that were unlocked. + */ +const applyZoneUnlocks = (state: GameState): number => { + let count = 0; + for (const zoneDefinition of defaultZones) { + const zoneInState = state.zones.find((z) => { + return z.id === zoneDefinition.id; + }); + if (!zoneInState || zoneInState.status !== "locked") { + continue; + } + + const requiredBossDefeated + = zoneDefinition.unlockBossId === null + || state.bosses.some((b) => { + return b.id === zoneDefinition.unlockBossId && b.status === "defeated"; + }); + + const requiredQuestCompleted + = zoneDefinition.unlockQuestId === null + || state.quests.some((q) => { + return ( + q.id === zoneDefinition.unlockQuestId && q.status === "completed" + ); + }); + + if (requiredBossDefeated && requiredQuestCompleted) { + zoneInState.status = "unlocked"; + count = count + 1; + } + } + return count; +}; + +interface QuestUnlockCheck { + questId: string; + zoneId: string; + prerequisiteIds: Array; + state: GameState; + completedQuestIds: Set; +} + +/** + * Determines whether a quest should be made available given the current state. + * @param options - The options for the quest unlock check. + * @param options.questId - The ID of the quest to check. + * @param options.zoneId - The zone the quest belongs to. + * @param options.prerequisiteIds - The quest IDs that must be completed first. + * @param options.state - The current game state. + * @param options.completedQuestIds - Set of already-completed quest IDs. + * @returns True when the quest should be unlocked. + */ +const shouldUnlockQuest = ({ + questId, + zoneId, + prerequisiteIds, + state, + completedQuestIds, +}: QuestUnlockCheck): boolean => { + const questInState = state.quests.find((q) => { + return q.id === questId; + }); + if (!questInState || questInState.status !== "locked") { + return false; + } + const zoneInState = state.zones.find((z) => { + return z.id === zoneId; + }); + if (!zoneInState || zoneInState.status === "locked") { + return false; + } + return prerequisiteIds.every((id) => { + return completedQuestIds.has(id); + }); +}; + +/** + * Makes available any quests whose zone is unlocked and prerequisites are met. + * @param state - The player's current game state (mutated directly). + * @returns The number of quests that were made available. + */ +const applyQuestUnlocks = (state: GameState): number => { + let count = 0; + const completedQuestIds = new Set( + state.quests. + filter((q) => { + return q.status === "completed"; + }). + map((q) => { + return q.id; + }), + ); + + for (const questDefinition of defaultQuests) { + if ( + !shouldUnlockQuest({ + completedQuestIds: completedQuestIds, + prerequisiteIds: questDefinition.prerequisiteIds, + questId: questDefinition.id, + state: state, + zoneId: questDefinition.zoneId, + }) + ) { + continue; + } + const questInState = state.quests.find((q) => { + return q.id === questDefinition.id; + }); + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 4 -- @preserve */ + if (questInState) { + questInState.status = "available"; + count = count + 1; + } + } + return count; +}; + +interface BossUnlockCheck { + bossId: string; + previousBossId: string | undefined; + isFirstInZone: boolean; + prestigeRequirement: number; + state: GameState; + prestigeCount: number; +} + +/** + * Determines whether a boss should be made available given the current state. + * @param options - The options for the boss unlock check. + * @param options.bossId - The ID of the boss to check. + * @param options.previousBossId - The ID of the previous boss in the zone. + * @param options.isFirstInZone - Whether this boss is the first in its zone. + * @param options.prestigeRequirement - The prestige level required for this boss. + * @param options.state - The current game state. + * @param options.prestigeCount - The player's current prestige count. + * @returns True when the boss should be made available. + */ +const shouldUnlockBoss = ({ + bossId, + previousBossId, + isFirstInZone, + prestigeRequirement, + state, + prestigeCount, +}: BossUnlockCheck): boolean => { + const bossInState = state.bosses.find((b) => { + return b.id === bossId; + }); + if (!bossInState || bossInState.status !== "locked") { + return false; + } + if (prestigeRequirement > prestigeCount) { + return false; + } + if (isFirstInZone) { + return true; + } + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 3 -- @preserve */ + if (previousBossId === undefined) { + return false; + } + const previousBossInState = state.bosses.find((b) => { + return b.id === previousBossId; + }); + return previousBossInState?.status === "defeated"; +}; + +/** + * Makes available any bosses that should be accessible based on zone status + * and sequential defeat order within each zone. + * @param state - The player's current game state (mutated directly). + * @returns The number of bosses that were made available. + */ +const applyBossUnlocks = (state: GameState): number => { + let count = 0; + const prestigeCount = state.prestige.count; + + for (const zoneDefinition of defaultZones) { + const zoneInState = state.zones.find((z) => { + return z.id === zoneDefinition.id; + }); + if (!zoneInState || zoneInState.status === "locked") { + continue; + } + + const bossesInZone = defaultBosses.filter((b) => { + return b.zoneId === zoneDefinition.id; + }); + + for (let index = 0; index < bossesInZone.length; index = index + 1) { + const bossDefinition = bossesInZone[index]; + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 3 -- @preserve */ + if (!bossDefinition) { + continue; + } + const previousBossDefinition = bossesInZone[index - 1]; + const unlock = shouldUnlockBoss({ + bossId: bossDefinition.id, + isFirstInZone: index === 0, + prestigeCount: prestigeCount, + prestigeRequirement: bossDefinition.prestigeRequirement, + previousBossId: previousBossDefinition?.id, + state: state, + }); + if (unlock) { + const bossInState = state.bosses.find((b) => { + return b.id === bossDefinition.id; + }); + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 4 -- @preserve */ + if (bossInState) { + bossInState.status = "available"; + 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). + * @returns The number of exploration areas that were made available. + */ +const applyExplorationUnlocks = (state: GameState): number => { + if (state.exploration === undefined) { + return 0; + } + let count = 0; + const unlockedZoneIds = new Set( + state.zones. + filter((z) => { + return z.status === "unlocked"; + }). + map((z) => { + return z.id; + }), + ); + + for (const areaDefinition of defaultExplorations) { + if (!unlockedZoneIds.has(areaDefinition.zoneId)) { + continue; + } + const areaInState = state.exploration.areas.find((a) => { + return a.id === areaDefinition.id; + }); + if (areaInState && areaInState.status === "locked") { + areaInState.status = "available"; + count = count + 1; + } + } + return count; +}; + +/** + * Applies all missing unlock corrections to a game state in-place. + * Delegates to per-category helpers and aggregates the results. + * @param state - The player's current game state (mutated directly). + * @returns Counts of each entity type that was corrected. + */ +const applyForceUnlocks = ( + state: GameState, +): { + bossesUnlocked: number; + explorationUnlocked: number; + questsUnlocked: 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 debugRouter = new Hono(); +debugRouter.use(authMiddleware); + +debugRouter.post("/force-unlocks", async(context) => { + try { + const discordId = context.get("discordId"); + + const gameStateRecord = await prisma.gameState.findUnique({ + where: { discordId }, + }); + if (!gameStateRecord) { + return context.json({ error: "No game state found" }, 404); + } + + /* 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 updatedAt = Date.now(); + await prisma.gameState.update({ + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ + data: { state: state as object, updatedAt: updatedAt }, + where: { discordId }, + }); + + const secret = process.env.ANTI_CHEAT_SECRET; + const signature + = secret === undefined + ? undefined + : computeHmac(JSON.stringify(state), secret); + + return context.json({ + bossesUnlocked, + explorationUnlocked, + questsUnlocked, + signature, + state, + zonesUnlocked, + }); + } catch (error) { + void logger.error( + "debug_force_unlocks", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } +}); + +debugRouter.post("/hard-reset", async(context) => { + try { + const discordId = context.get("discordId"); + + const playerRecord = await prisma.player.findUnique({ + where: { discordId }, + }); + if (!playerRecord) { + return context.json({ error: "No player found" }, 404); + } + + const freshState = initialGameState( + { + avatar: playerRecord.avatar, + characterName: playerRecord.characterName, + createdAt: playerRecord.createdAt, + discordId: playerRecord.discordId, + discriminator: playerRecord.discriminator, + lastSavedAt: Date.now(), + lifetimeAchievementsUnlocked: playerRecord.lifetimeAchievementsUnlocked, + lifetimeAdventurersRecruited: playerRecord.lifetimeAdventurersRecruited, + lifetimeBossesDefeated: playerRecord.lifetimeBossesDefeated, + lifetimeClicks: playerRecord.lifetimeClicks, + lifetimeGoldEarned: playerRecord.lifetimeGoldEarned, + lifetimeQuestsCompleted: playerRecord.lifetimeQuestsCompleted, + totalClicks: 0, + totalGoldEarned: 0, + username: playerRecord.username, + }, + playerRecord.characterName, + ); + + const createdAt = Date.now(); + await prisma.gameState.upsert({ + create: { + discordId: discordId, + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ + state: freshState as object, + updatedAt: createdAt, + }, + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ + update: { state: freshState as object, updatedAt: createdAt }, + where: { discordId }, + }); + + const secret = process.env.ANTI_CHEAT_SECRET; + const signature + = secret === undefined + ? undefined + : computeHmac(JSON.stringify(freshState), secret); + + return context.json({ + currentSchemaVersion: currentSchemaVersion, + loginBonus: null, + loginStreak: playerRecord.loginStreak, + offlineEssence: 0, + offlineGold: 0, + offlineSeconds: 0, + schemaOutdated: false, + signature: signature, + state: freshState, + }); + } catch (error) { + void logger.error( + "debug_hard_reset", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } +}); + +export { debugRouter }; diff --git a/apps/api/test/routes/debug.spec.ts b/apps/api/test/routes/debug.spec.ts new file mode 100644 index 0000000..7d5385b --- /dev/null +++ b/apps/api/test/routes/debug.spec.ts @@ -0,0 +1,450 @@ +/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ +/* eslint-disable max-lines -- Test suites naturally have many cases */ +/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Hono } from "hono"; +import type { GameState } from "@elysium/types"; + +vi.mock("../../src/db/client.js", () => ({ + prisma: { + gameState: { findUnique: vi.fn(), update: vi.fn(), upsert: vi.fn() }, + player: { findUnique: vi.fn() }, + }, +})); + +vi.mock("../../src/middleware/auth.js", () => ({ + authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise) => { + c.set("discordId", "test_discord_id"); + await next(); + }), +})); + +vi.mock("../../src/services/logger.js", () => ({ + logger: { + error: vi.fn().mockResolvedValue(undefined), + log: vi.fn().mockResolvedValue(undefined), + }, +})); + +const DISCORD_ID = "test_discord_id"; + +const makeExploration = (areas: GameState["exploration"]["areas"] = []): GameState["exploration"] => ({ + areas: areas, + craftedCombatMultiplier: 1, + craftedClickMultiplier: 1, + craftedEssenceMultiplier: 1, + craftedGoldMultiplier: 1, + craftedRecipeIds: [], + materials: [], +}); + +const makeState = (overrides: Partial = {}): GameState => ({ + achievements: [], + adventurers: [], + baseClickPower: 1, + bosses: [], + companions: { activeCompanionId: null, unlockedCompanionIds: [] }, + equipment: [], + exploration: makeExploration(), + lastTickAt: 0, + player: { avatar: null, characterName: "T", discordId: DISCORD_ID, discriminator: "0", totalClicks: 0, totalGoldEarned: 0, username: "u" }, + prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 }, + quests: [], + resources: { crystals: 0, essence: 0, gold: 0, runestones: 0 }, + schemaVersion: 1, + upgrades: [], + zones: [], + ...overrides, +} as GameState); + +const makePlayer = (overrides: Record = {}) => ({ + avatar: null, + characterName: "TestChar", + createdAt: 0, + discordId: DISCORD_ID, + discriminator: "0", + lifetimeAchievementsUnlocked: 0, + lifetimeAdventurersRecruited: 0, + lifetimeBossesDefeated: 0, + lifetimeClicks: 0, + lifetimeGoldEarned: 0, + lifetimeQuestsCompleted: 0, + loginStreak: 1, + username: "test_user", + ...overrides, +}); + +describe("debug route", () => { + let app: Hono; + let prisma: { + gameState: { + findUnique: ReturnType; + update: ReturnType; + upsert: ReturnType; + }; + player: { findUnique: ReturnType }; + }; + + beforeEach(async () => { + vi.clearAllMocks(); + const { debugRouter } = await import("../../src/routes/debug.js"); + const { prisma: p } = await import("../../src/db/client.js"); + prisma = p as typeof prisma; + app = new Hono(); + app.route("/debug", debugRouter); + }); + + const forceUnlocks = () => + app.fetch(new Request("http://localhost/debug/force-unlocks", { method: "POST" })); + + const hardReset = () => + app.fetch(new Request("http://localhost/debug/hard-reset", { method: "POST" })); + + describe("POST /force-unlocks", () => { + it("returns 404 when no game state found", async () => { + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null); + const res = await forceUnlocks(); + expect(res.status).toBe(404); + }); + + it("returns 200 with all zeros when no stale locks exist", async () => { + const state = makeState({ + zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await forceUnlocks(); + expect(res.status).toBe(200); + const body = await res.json() as { + bossesUnlocked: number; + explorationUnlocked: number; + questsUnlocked: number; + zonesUnlocked: number; + }; + expect(body.zonesUnlocked).toBe(0); + expect(body.explorationUnlocked).toBe(0); + }); + + it("unlocks verdant_vale when it is locked and has no requirements", async () => { + const state = makeState({ + zones: [{ id: "verdant_vale", status: "locked" }] as GameState["zones"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await forceUnlocks(); + expect(res.status).toBe(200); + const body = await res.json() as { zonesUnlocked: number }; + expect(body.zonesUnlocked).toBe(1); + }); + + it("does not unlock zone when boss condition is not met", async () => { + const state = makeState({ + bosses: [{ id: "forest_giant", status: "available" }] as GameState["bosses"], + quests: [{ id: "ancient_ruins", status: "completed" }] as GameState["quests"], + zones: [{ id: "shattered_ruins", status: "locked" }] as GameState["zones"], + }); + 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 { zonesUnlocked: number }; + expect(body.zonesUnlocked).toBe(0); + }); + + it("does not unlock zone when quest condition is not met", async () => { + const state = makeState({ + bosses: [{ id: "forest_giant", status: "defeated" }] as GameState["bosses"], + quests: [{ id: "ancient_ruins", status: "active" }] as GameState["quests"], + zones: [{ id: "shattered_ruins", status: "locked" }] as GameState["zones"], + }); + 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 { zonesUnlocked: number }; + expect(body.zonesUnlocked).toBe(0); + }); + + it("unlocks zone when both boss and quest conditions are met", async () => { + const state = makeState({ + bosses: [{ id: "forest_giant", status: "defeated" }] as GameState["bosses"], + quests: [{ id: "ancient_ruins", status: "completed" }] as GameState["quests"], + zones: [{ id: "shattered_ruins", status: "locked" }] as GameState["zones"], + }); + 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 { zonesUnlocked: number }; + expect(body.zonesUnlocked).toBe(1); + }); + + it("unlocks a quest when zone is unlocked and prerequisites are met", async () => { + const state = makeState({ + quests: [{ id: "first_steps", status: "locked" }] as GameState["quests"], + zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"], + }); + 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 { questsUnlocked: number }; + expect(body.questsUnlocked).toBe(1); + }); + + it("does not unlock quest when zone is locked", async () => { + /* + * Use shattered_ruins (requires forest_giant defeated) so applyZoneUnlocks + * cannot auto-unlock it, keeping it locked when applyQuestUnlocks runs. + */ + const state = makeState({ + bosses: [{ id: "forest_giant", status: "available" }] as GameState["bosses"], + quests: [{ id: "necromancer_tower", status: "locked" }] as GameState["quests"], + zones: [{ id: "shattered_ruins", status: "locked" }] as GameState["zones"], + }); + 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 { questsUnlocked: number }; + expect(body.questsUnlocked).toBe(0); + }); + + it("does not unlock quest when zone is not in state", async () => { + const state = makeState({ + quests: [{ id: "first_steps", status: "locked" }] as GameState["quests"], + zones: [] as GameState["zones"], + }); + 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 { questsUnlocked: number }; + expect(body.questsUnlocked).toBe(0); + }); + + it("does not unlock quest when it is already available", async () => { + const state = makeState({ + quests: [{ id: "first_steps", status: "available" }] as GameState["quests"], + zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"], + }); + 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 { questsUnlocked: number }; + expect(body.questsUnlocked).toBe(0); + }); + + it("does not unlock quest when prerequisites are not completed", async () => { + const state = makeState({ + quests: [{ id: "goblin_camp", status: "locked" }] as GameState["quests"], + zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"], + }); + 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 { questsUnlocked: number }; + expect(body.questsUnlocked).toBe(0); + }); + + it("unlocks the first boss in a zone when the zone is unlocked", async () => { + const state = makeState({ + bosses: [{ id: "troll_king", status: "locked" }] as GameState["bosses"], + prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 }, + zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"], + }); + 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 { bossesUnlocked: number }; + expect(body.bossesUnlocked).toBe(1); + }); + + it("does not unlock boss when prestige requirement is not met", async () => { + const state = makeState({ + bosses: [{ id: "the_first_light", status: "locked" }] as GameState["bosses"], + prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 }, + zones: [{ id: "celestial_reaches", status: "unlocked" }] as GameState["zones"], + }); + 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 { bossesUnlocked: number }; + expect(body.bossesUnlocked).toBe(0); + }); + + it("does not unlock boss when previous boss is not defeated", async () => { + const state = makeState({ + bosses: [ + { id: "troll_king", status: "available" }, + { id: "lich_queen", status: "locked" }, + ] as GameState["bosses"], + prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 }, + zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"], + }); + 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 { bossesUnlocked: number }; + expect(body.bossesUnlocked).toBe(0); + }); + + it("does not unlock boss when previous boss is not in state", async () => { + const state = makeState({ + bosses: [{ id: "lich_queen", status: "locked" }] as GameState["bosses"], + prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 }, + zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"], + }); + 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 { bossesUnlocked: number }; + expect(body.bossesUnlocked).toBe(0); + }); + + it("unlocks next boss when previous boss is defeated", async () => { + const state = makeState({ + bosses: [ + { id: "troll_king", status: "defeated" }, + { id: "lich_queen", status: "locked" }, + ] as GameState["bosses"], + prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 }, + zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"], + }); + 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 { bossesUnlocked: number }; + expect(body.bossesUnlocked).toBe(1); + }); + + it("returns explorationUnlocked=0 when exploration is undefined", async () => { + const state = makeState({ + exploration: undefined as unknown as GameState["exploration"], + zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"], + }); + 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 { explorationUnlocked: number }; + expect(body.explorationUnlocked).toBe(0); + }); + + it("unlocks exploration area when its zone is unlocked", async () => { + const state = makeState({ + exploration: makeExploration([ + { id: "verdant_meadow", status: "locked" } as GameState["exploration"]["areas"][0], + ]), + zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"], + }); + 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 { explorationUnlocked: number }; + expect(body.explorationUnlocked).toBe(1); + }); + + it("does not unlock exploration area when zone is not unlocked", async () => { + const state = makeState({ + exploration: makeExploration([ + { id: "vm_e1", status: "locked" } as GameState["exploration"]["areas"][0], + ]), + zones: [] as GameState["zones"], + }); + 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 { explorationUnlocked: number }; + expect(body.explorationUnlocked).toBe(0); + }); + + it("does not unlock exploration area when it is already available", async () => { + const state = makeState({ + exploration: makeExploration([ + { id: "verdant_meadow", status: "available" } as GameState["exploration"]["areas"][0], + ]), + zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"], + }); + 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 { explorationUnlocked: number }; + expect(body.explorationUnlocked).toBe(0); + }); + + it("computes HMAC signature when ANTI_CHEAT_SECRET is set", async () => { + process.env.ANTI_CHEAT_SECRET = "test_secret"; + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await forceUnlocks(); + expect(res.status).toBe(200); + const body = await res.json() as { signature: string | undefined }; + expect(body.signature).toBeDefined(); + delete process.env.ANTI_CHEAT_SECRET; + }); + + it("omits signature when ANTI_CHEAT_SECRET is not set", async () => { + delete process.env.ANTI_CHEAT_SECRET; + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await forceUnlocks(); + expect(res.status).toBe(200); + const body = await res.json() as { signature: string | undefined }; + expect(body.signature).toBeUndefined(); + }); + + it("returns 500 when DB throws an Error", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await forceUnlocks(); + expect(res.status).toBe(500); + }); + + it("returns 500 when DB throws a non-Error value", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw error"); + const res = await forceUnlocks(); + expect(res.status).toBe(500); + }); + }); + + describe("POST /hard-reset", () => { + it("returns 404 when no player found", async () => { + vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null); + const res = await hardReset(); + expect(res.status).toBe(404); + }); + + it("returns 200 with a fresh state on success", async () => { + vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never); + vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never); + const res = await hardReset(); + expect(res.status).toBe(200); + const body = await res.json() as { + loginBonus: null; + loginStreak: number; + schemaOutdated: boolean; + }; + expect(body.loginBonus).toBeNull(); + expect(body.schemaOutdated).toBe(false); + expect(body.loginStreak).toBe(1); + }); + + it("computes HMAC signature when ANTI_CHEAT_SECRET is set", async () => { + process.env.ANTI_CHEAT_SECRET = "test_secret"; + vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never); + vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never); + const res = await hardReset(); + expect(res.status).toBe(200); + const body = await res.json() as { signature: string | undefined }; + expect(body.signature).toBeDefined(); + delete process.env.ANTI_CHEAT_SECRET; + }); + + it("returns 500 when DB throws an Error", async () => { + vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await hardReset(); + expect(res.status).toBe(500); + }); + + it("returns 500 when DB throws a non-Error value", async () => { + vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw error"); + const res = await hardReset(); + expect(res.status).toBe(500); + }); + }); +}); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index e046bd5..cde0470 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -21,6 +21,7 @@ import type { ExploreCollectResponse, ExploreStartRequest, ExploreStartResponse, + ForceUnlocksResponse, LoadResponse, PrestigeRequest, PrestigeResponse, @@ -256,6 +257,24 @@ const craftRecipe = async( }); }; +/** + * Sends a request to fix any missing unlocks in the player's game state. + * @returns The corrected game state and counts of what was unlocked. + */ +const forceUnlocks = async(): Promise => { + return await fetchJson("/debug/force-unlocks", { + method: "POST", + }); +}; + +/** + * Performs a complete hard reset of the player's game state via the debug endpoint. + * @returns The fresh game state as a LoadResponse. + */ +const debugHardReset = async(): Promise => { + return await fetchJson("/debug/hard-reset", { method: "POST" }); +}; + /** * Fetches a public player profile by Discord ID. * @param discordId - The Discord ID of the player to look up. @@ -288,6 +307,8 @@ export { challengeBoss, collectExploration, craftRecipe, + debugHardReset, + forceUnlocks, getAbout, getAuthUrl, getPublicProfile, diff --git a/apps/web/src/components/game/debugPanel.tsx b/apps/web/src/components/game/debugPanel.tsx new file mode 100644 index 0000000..0abc231 --- /dev/null +++ b/apps/web/src/components/game/debugPanel.tsx @@ -0,0 +1,145 @@ +/** + * @file Debug panel component with administrative tools for correcting player state. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Panel has multiple async handlers and conditional renders */ +/* eslint-disable stylistic/max-len -- Debug descriptions require full explanatory text */ +import { type JSX, useState } from "react"; +import { useGame } from "../../context/gameContext.js"; +import { ConfirmationModal } from "../ui/confirmationModal.js"; + +type ActiveModal = "force-unlocks" | "hard-reset" | null; + +/** + * Renders the debug panel with tools for fixing stuck game state. + * @returns The JSX element. + */ +const DebugPanel = (): JSX.Element => { + const { forceUnlocks, debugHardReset, isLoading } = useGame(); + const [ activeModal, setActiveModal ] = useState(null); + const [ forceUnlocksResult, setForceUnlocksResult ] = useState(null); + + function handleOpenForceUnlocks(): void { + setForceUnlocksResult(null); + setActiveModal("force-unlocks"); + } + + function handleOpenHardReset(): void { + setActiveModal("hard-reset"); + } + + function handleCancel(): void { + setActiveModal(null); + } + + function handleConfirmForceUnlocks(): void { + 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); + })(); + } + + function handleConfirmHardReset(): void { + setActiveModal(null); + void debugHardReset(); + } + + return ( +
+

{"πŸ”§ Debug Tools"}

+

+ { + "These tools are intended to fix broken game state. Use them with care β€” some operations are irreversible." + } +

+ +
+
+

{"πŸ”“ Force Unlocks"}

+

+ { + "Scans your game state and unlocks any zones, quests, and bosses that you have earned but that are still incorrectly locked." + } +

+ + {forceUnlocksResult !== null + &&

{forceUnlocksResult}

+ } +
+ +
+

{"πŸ’€ Hard Reset"}

+

+ { + "Completely wipes all progress and resets your account to a brand-new state. This cannot be undone." + } +

+ +
+
+ + {activeModal === "force-unlocks" + && + } + + {activeModal === "hard-reset" + && + } +
+ ); +}; + +export { DebugPanel }; diff --git a/apps/web/src/components/game/gameLayout.tsx b/apps/web/src/components/game/gameLayout.tsx index c08616e..23819e6 100644 --- a/apps/web/src/components/game/gameLayout.tsx +++ b/apps/web/src/components/game/gameLayout.tsx @@ -23,6 +23,7 @@ import { CodexToast } from "./codexToast.js"; import { CompanionPanel } from "./companionPanel.js"; import { CraftingPanel } from "./craftingPanel.js"; import { DailyChallengePanel } from "./dailyChallengePanel.js"; +import { DebugPanel } from "./debugPanel.js"; import { EditProfileModal } from "./editProfileModal.js"; import { EquipmentPanel } from "./equipmentPanel.js"; import { ExplorationPanel } from "./explorationPanel.js"; @@ -57,7 +58,8 @@ type Tab = | "crafting" | "character" | "companions" - | "story"; + | "story" + | "debug"; const baseTabs: Array<{ id: Tab; label: string }> = [ { id: "adventurers", label: "βš”οΈ Adventurers" }, @@ -78,6 +80,7 @@ const baseTabs: Array<{ id: Tab; label: string }> = [ { id: "story", label: "πŸ“– Story" }, { id: "codex", label: "πŸ—ΊοΈ Codex" }, { id: "about", label: "ℹ️ About" }, + { id: "debug", label: "πŸ”§ Debug" }, ]; /** @@ -242,6 +245,7 @@ const GameLayout = (): JSX.Element => { {activeTab === "story" && } {activeTab === "codex" && } {activeTab === "about" && } + {activeTab === "debug" && } diff --git a/apps/web/src/components/ui/confirmationModal.tsx b/apps/web/src/components/ui/confirmationModal.tsx new file mode 100644 index 0000000..2c2e5be --- /dev/null +++ b/apps/web/src/components/ui/confirmationModal.tsx @@ -0,0 +1,68 @@ +/** + * @file Reusable confirmation modal component for destructive operations. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import type { JSX } from "react"; + +interface ConfirmationModalProperties { + readonly title: string; + readonly description: string; + readonly confirmLabel: string; + readonly onConfirm: ()=> void; + readonly onCancel: ()=> void; + readonly isLoading: boolean; +} + +/** + * Renders a confirmation modal for destructive operations. + * @param props - The modal properties. + * @param props.title - The modal heading. + * @param props.description - Warning text explaining what the operation does. + * @param props.confirmLabel - Label for the confirm button. + * @param props.onConfirm - Callback fired when the player confirms. + * @param props.onCancel - Callback fired when the player cancels. + * @param props.isLoading - Whether the operation is currently in progress. + * @returns The JSX element. + */ +const ConfirmationModal = ({ + title, + description, + confirmLabel, + onConfirm, + onCancel, + isLoading, +}: ConfirmationModalProperties): JSX.Element => { + return ( +
+
+

{title}

+

{description}

+

{"Are you sure you want to do this?"}

+
+ + +
+
+
+ ); +}; + +export { ConfirmationModal }; diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index 0f146a8..1669589 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -42,6 +42,8 @@ import { challengeBoss as challengeBossApi, collectExploration as collectExplorationApi, craftRecipe as craftRecipeApi, + debugHardReset as debugHardResetApi, + forceUnlocks as forceUnlocksApi, loadGame, prestige as prestigeApi, resetProgress as resetProgressApi, @@ -546,6 +548,24 @@ interface GameContextValue { */ resetProgress: ()=> Promise; + /** + * Force-unlock any zones, quests, and bosses the player has earned but that + * are still incorrectly locked due to a state bug. + * @returns Counts of what was corrected. + */ + forceUnlocks: ()=> Promise<{ + bossesUnlocked: number; + explorationUnlocked: number; + questsUnlocked: number; + zonesUnlocked: number; + }>; + + /** + * Completely wipe the player's progress back to a brand-new save via the + * debug endpoint. + */ + debugHardReset: ()=> Promise; + /** * Last auto-boss fight result β€” null until the first auto fight completes or * when auto-boss is toggled off. @@ -2025,6 +2045,61 @@ export const GameProvider = ({ } }, []); + const forceUnlocks = useCallback(async() => { + try { + const data = await forceUnlocksApi(); + setState(data.state); + if (data.signature !== undefined) { + signatureReference.current = data.signature; + localStorage.setItem("elysium_save_signature", data.signature); + } + return { + bossesUnlocked: data.bossesUnlocked, + explorationUnlocked: data.explorationUnlocked, + questsUnlocked: data.questsUnlocked, + zonesUnlocked: data.zonesUnlocked, + }; + } catch (error_: unknown) { + setError( + error_ instanceof Error + ? error_.message + : "Failed to force unlocks", + ); + return { + bossesUnlocked: 0, + explorationUnlocked: 0, + questsUnlocked: 0, + zonesUnlocked: 0, + }; + } + }, []); + + const debugHardReset = useCallback(async() => { + setIsLoading(true); + setError(null); + try { + const data = await debugHardResetApi(); + setState(data.state); + setLastSavedAt(data.state.player.lastSavedAt); + setSchemaOutdated(false); + setOfflineGold(0); + setOfflineEssence(0); + setLoginBonus(null); + if (data.signature !== undefined) { + signatureReference.current = data.signature; + localStorage.setItem("elysium_save_signature", data.signature); + } + } catch (error_: unknown) { + setError( + error_ instanceof Error + ? error_.message + : "Failed to reset progress", + ); + } finally { + setIsLoading(false); + } + }, []); + const dismissLoginBonus = useCallback(() => { setLoginBonus(null); }, []); @@ -2054,6 +2129,7 @@ export const GameProvider = ({ completedQuestToasts, craftRecipe, currentSchemaVersion, + debugHardReset, dismissAchievement, dismissApotheosisToast, dismissBattle, @@ -2072,6 +2148,7 @@ export const GameProvider = ({ failedQuestToasts, flushBossLoreToasts, forceSync, + forceUnlocks, formatNumber, handleClick, isLoading, @@ -2125,6 +2202,7 @@ export const GameProvider = ({ completeChapter, craftRecipe, currentSchemaVersion, + debugHardReset, dismissAchievement, dismissApotheosisToast, dismissBattle, @@ -2142,6 +2220,7 @@ export const GameProvider = ({ error, flushBossLoreToasts, forceSync, + forceUnlocks, handleClick, isLoading, isSyncing, diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 2e7bf50..4d0ed2c 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -4515,3 +4515,84 @@ body::before { object-fit: cover; width: 80px; } + +/* ===================== ACTION BUTTONS ===================== */ +.action-button { + background: var(--colour-accent); + border: none; + border-radius: var(--radius); + color: #fff; + cursor: pointer; + font-size: 0.9rem; + font-weight: 700; + margin-top: 0.5rem; + padding: 0.55rem 1.25rem; + transition: background 0.15s; + width: 100%; +} + +.action-button:hover:not(:disabled) { + background: var(--colour-accent-light); +} + +.action-button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.action-button-danger { + background: var(--colour-error); +} + +.action-button-danger:hover:not(:disabled) { + background: #f87171; +} + +/* ===================== MODAL VARIANTS ===================== */ +.modal-button-danger { + background: var(--colour-error); +} + +.modal-button-danger:hover:not(:disabled) { + background: #f87171; +} + +/* ===================== DEBUG PANEL ===================== */ +.debug-actions { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + margin-top: 1rem; +} + +.debug-action-card { + background: var(--colour-surface); + border: 1px solid var(--colour-border); + border-radius: var(--radius-lg); + display: flex; + flex-direction: column; + padding: 1.25rem; +} + +.debug-action-card h3 { + color: var(--colour-accent-light); + font-size: 1rem; + margin: 0 0 0.5rem; +} + +.debug-action-card > p { + color: var(--colour-text-muted); + flex: 1; + font-size: 0.875rem; + margin: 0; +} + +.debug-result-message { + background: rgba(16, 185, 129, 0.1); + border: 1px solid var(--colour-success); + border-radius: var(--radius); + color: var(--colour-success); + font-size: 0.8rem; + margin-top: 0.75rem; + padding: 0.5rem 0.75rem; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index ffbbe02..ed06475 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -60,6 +60,7 @@ export type { ExploreCollectResponse, ExploreStartRequest, ExploreStartResponse, + ForceUnlocksResponse, GiteaRelease, LeaderboardCategory, LeaderboardEntry, diff --git a/packages/types/src/interfaces/api.ts b/packages/types/src/interfaces/api.ts index 3250983..8da988e 100644 --- a/packages/types/src/interfaces/api.ts +++ b/packages/types/src/interfaces/api.ts @@ -398,6 +398,39 @@ interface CraftRecipeResponse { craftedCombatMultiplier: number; } +interface ForceUnlocksResponse { + + /** + * The corrected game state after applying all missing unlocks. + */ + state: GameState; + + /** + * Number of zones that were unlocked by this operation. + */ + zonesUnlocked: number; + + /** + * Number of quests that were made available by this operation. + */ + questsUnlocked: number; + + /** + * Number of bosses that were made available by this operation. + */ + bossesUnlocked: number; + + /** + * Number of exploration areas that were made available by this operation. + */ + explorationUnlocked: number; + + /** + * HMAC-SHA256 signature of the corrected state for anti-cheat chain continuity. + */ + signature?: string; +} + export type { AboutResponse, ApiError, @@ -417,6 +450,7 @@ export type { ExploreCollectResponse, ExploreStartRequest, ExploreStartResponse, + ForceUnlocksResponse, GiteaRelease, LeaderboardCategory, LeaderboardEntry,