generated from nhcarrigan/template
feat: debug panel with force unlocks and hard reset (#65)
## Summary - Adds a new **Debug** tab to the game UI with two self-service tools for players with broken save state - **Force Unlocks**: scans the player's save and grants any zones, quests, bosses, and exploration areas they've earned but that are still locked — shows a breakdown of what was unlocked (or reports nothing needed fixing) - **Hard Reset**: wipes progress back to a fresh save (preserving lifetime stats), guarded behind a confirmation modal to prevent accidental clicks ## Files added - `apps/api/src/routes/debug.ts` — two POST endpoints (`/force-unlocks`, `/hard-reset`) - `apps/web/src/components/game/debugPanel.tsx` — the Debug tab UI - `apps/web/src/components/ui/confirmationModal.tsx` — reusable confirmation modal ## Files modified - `apps/api/src/index.ts` — registers the debug router - `packages/types/src/interfaces/api.ts` — adds `ForceUnlocksResponse` type - `packages/types/src/index.ts` — exports the new type - `apps/web/src/api/client.ts` — adds `forceUnlocks()` and `debugHardReset()` API calls - `apps/web/src/context/gameContext.tsx` — wires both functions into game context - `apps/web/src/components/game/gameLayout.tsx` — adds the Debug tab - `apps/web/src/styles.css` — styles for action buttons, cards, result messages, and confirmation modal ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #65 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #65.
This commit is contained in:
@@ -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<string>;
|
||||
state: GameState;
|
||||
completedQuestIds: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<HonoEnvironment>();
|
||||
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 };
|
||||
Reference in New Issue
Block a user