feat: add debug panel with force-unlocks and hard-reset tools
CI / Lint, Build & Test (pull_request) Failing after 1m34s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m36s

Adds a new Debug tab to the game UI exposing two admin self-service
tools: Force Unlocks (non-destructive scan and correction of any
earned-but-locked zones, quests, bosses, and exploration areas) and
Hard Reset (full progress wipe back to a fresh save, preserving
lifetime stats). Both are guarded by a confirmation modal.

Also styles the action buttons and confirmation modal danger variant,
and adds the ForceUnlocksResponse type to the shared types package.
This commit is contained in:
2026-03-18 10:49:19 -07:00
committed by Naomi Carrigan
parent a20cf3ef87
commit 00c38144e3
10 changed files with 869 additions and 1 deletions
+2
View File
@@ -13,6 +13,7 @@ import { apotheosisRouter } from "./routes/apotheosis.js";
import { authRouter } from "./routes/auth.js"; import { authRouter } from "./routes/auth.js";
import { bossRouter } from "./routes/boss.js"; import { bossRouter } from "./routes/boss.js";
import { craftRouter } from "./routes/craft.js"; import { craftRouter } from "./routes/craft.js";
import { debugRouter } from "./routes/debug.js";
import { exploreRouter } from "./routes/explore.js"; import { exploreRouter } from "./routes/explore.js";
import { frontendRouter } from "./routes/frontend.js"; import { frontendRouter } from "./routes/frontend.js";
import { gameRouter } from "./routes/game.js"; import { gameRouter } from "./routes/game.js";
@@ -35,6 +36,7 @@ app.use(
); );
app.route("/about", aboutRouter); app.route("/about", aboutRouter);
app.route("/debug", debugRouter);
app.route("/fe", frontendRouter); app.route("/fe", frontendRouter);
app.route("/auth", authRouter); app.route("/auth", authRouter);
app.route("/game", gameRouter); app.route("/game", gameRouter);
+433
View File
@@ -0,0 +1,433 @@
/**
* @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;
});
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;
}
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];
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;
});
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 };
+21
View File
@@ -21,6 +21,7 @@ import type {
ExploreCollectResponse, ExploreCollectResponse,
ExploreStartRequest, ExploreStartRequest,
ExploreStartResponse, ExploreStartResponse,
ForceUnlocksResponse,
LoadResponse, LoadResponse,
PrestigeRequest, PrestigeRequest,
PrestigeResponse, 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<ForceUnlocksResponse> => {
return await fetchJson<ForceUnlocksResponse>("/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<LoadResponse> => {
return await fetchJson<LoadResponse>("/debug/hard-reset", { method: "POST" });
};
/** /**
* Fetches a public player profile by Discord ID. * Fetches a public player profile by Discord ID.
* @param discordId - The Discord ID of the player to look up. * @param discordId - The Discord ID of the player to look up.
@@ -288,6 +307,8 @@ export {
challengeBoss, challengeBoss,
collectExploration, collectExploration,
craftRecipe, craftRecipe,
debugHardReset,
forceUnlocks,
getAbout, getAbout,
getAuthUrl, getAuthUrl,
getPublicProfile, getPublicProfile,
+145
View File
@@ -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<ActiveModal>(null);
const [ forceUnlocksResult, setForceUnlocksResult ] = useState<string | null>(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<void> => {
const result = await forceUnlocks();
const parts: Array<string> = [];
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 (
<div className="panel">
<h2>{"🔧 Debug Tools"}</h2>
<p className="panel-description">
{
"These tools are intended to fix broken game state. Use them with care — some operations are irreversible."
}
</p>
<div className="debug-actions">
<div className="debug-action-card">
<h3>{"🔓 Force Unlocks"}</h3>
<p>
{
"Scans your game state and unlocks any zones, quests, and bosses that you have earned but that are still incorrectly locked."
}
</p>
<button
className="action-button"
disabled={isLoading}
onClick={handleOpenForceUnlocks}
type="button"
>
{"Force Unlocks"}
</button>
{forceUnlocksResult !== null
&& <p className="debug-result-message">{forceUnlocksResult}</p>
}
</div>
<div className="debug-action-card">
<h3>{"💀 Hard Reset"}</h3>
<p>
{
"Completely wipes all progress and resets your account to a brand-new state. This cannot be undone."
}
</p>
<button
className="action-button action-button-danger"
disabled={isLoading}
onClick={handleOpenHardReset}
type="button"
>
{"Hard Reset"}
</button>
</div>
</div>
{activeModal === "force-unlocks"
&& <ConfirmationModal
confirmLabel="Yes, Force Unlocks"
description="This will scan your save data and grant access to any zones, quests, and bosses that you have already earned but are incorrectly locked. This operation is safe and non-destructive."
isLoading={isLoading}
onCancel={handleCancel}
onConfirm={handleConfirmForceUnlocks}
title="Force Unlocks"
/>
}
{activeModal === "hard-reset"
&& <ConfirmationModal
confirmLabel="Yes, Wipe Everything"
description="This will permanently delete all of your current progress — gold, adventurers, upgrades, bosses, quests, and zones — and reset your account to a brand-new state. Lifetime stats are preserved, but everything else will be gone forever."
isLoading={isLoading}
onCancel={handleCancel}
onConfirm={handleConfirmHardReset}
title="⚠️ Hard Reset — This Cannot Be Undone"
/>
}
</div>
);
};
export { DebugPanel };
+5 -1
View File
@@ -23,6 +23,7 @@ import { CodexToast } from "./codexToast.js";
import { CompanionPanel } from "./companionPanel.js"; import { CompanionPanel } from "./companionPanel.js";
import { CraftingPanel } from "./craftingPanel.js"; import { CraftingPanel } from "./craftingPanel.js";
import { DailyChallengePanel } from "./dailyChallengePanel.js"; import { DailyChallengePanel } from "./dailyChallengePanel.js";
import { DebugPanel } from "./debugPanel.js";
import { EditProfileModal } from "./editProfileModal.js"; import { EditProfileModal } from "./editProfileModal.js";
import { EquipmentPanel } from "./equipmentPanel.js"; import { EquipmentPanel } from "./equipmentPanel.js";
import { ExplorationPanel } from "./explorationPanel.js"; import { ExplorationPanel } from "./explorationPanel.js";
@@ -57,7 +58,8 @@ type Tab =
| "crafting" | "crafting"
| "character" | "character"
| "companions" | "companions"
| "story"; | "story"
| "debug";
const baseTabs: Array<{ id: Tab; label: string }> = [ const baseTabs: Array<{ id: Tab; label: string }> = [
{ id: "adventurers", label: "⚔️ Adventurers" }, { id: "adventurers", label: "⚔️ Adventurers" },
@@ -78,6 +80,7 @@ const baseTabs: Array<{ id: Tab; label: string }> = [
{ id: "story", label: "📖 Story" }, { id: "story", label: "📖 Story" },
{ id: "codex", label: "🗺️ Codex" }, { id: "codex", label: "🗺️ Codex" },
{ id: "about", label: "️ About" }, { id: "about", label: "️ About" },
{ id: "debug", label: "🔧 Debug" },
]; ];
/** /**
@@ -242,6 +245,7 @@ const GameLayout = (): JSX.Element => {
{activeTab === "story" && <StoryPanel />} {activeTab === "story" && <StoryPanel />}
{activeTab === "codex" && <CodexPanel />} {activeTab === "codex" && <CodexPanel />}
{activeTab === "about" && <AboutPanel />} {activeTab === "about" && <AboutPanel />}
{activeTab === "debug" && <DebugPanel />}
</div> </div>
</main> </main>
</div> </div>
@@ -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 (
<div className="modal-overlay">
<div className="modal">
<h2>{title}</h2>
<p>{description}</p>
<p className="modal-note">{"Are you sure you want to do this?"}</p>
<div className="modal-actions">
<button
className="modal-close-button modal-button-danger"
disabled={isLoading}
onClick={onConfirm}
type="button"
>
{isLoading
? "Working..."
: confirmLabel}
</button>
<button
className="modal-close-button"
disabled={isLoading}
onClick={onCancel}
type="button"
>
{"Cancel"}
</button>
</div>
</div>
</div>
);
};
export { ConfirmationModal };
+79
View File
@@ -42,6 +42,8 @@ import {
challengeBoss as challengeBossApi, challengeBoss as challengeBossApi,
collectExploration as collectExplorationApi, collectExploration as collectExplorationApi,
craftRecipe as craftRecipeApi, craftRecipe as craftRecipeApi,
debugHardReset as debugHardResetApi,
forceUnlocks as forceUnlocksApi,
loadGame, loadGame,
prestige as prestigeApi, prestige as prestigeApi,
resetProgress as resetProgressApi, resetProgress as resetProgressApi,
@@ -546,6 +548,24 @@ interface GameContextValue {
*/ */
resetProgress: ()=> Promise<void>; resetProgress: ()=> Promise<void>;
/**
* 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<void>;
/** /**
* Last auto-boss fight result — null until the first auto fight completes or * Last auto-boss fight result — null until the first auto fight completes or
* when auto-boss is toggled off. * when auto-boss is toggled off.
@@ -2006,6 +2026,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(() => { const dismissLoginBonus = useCallback(() => {
setLoginBonus(null); setLoginBonus(null);
}, []); }, []);
@@ -2034,6 +2109,7 @@ export const GameProvider = ({
completedQuestToasts, completedQuestToasts,
craftRecipe, craftRecipe,
currentSchemaVersion, currentSchemaVersion,
debugHardReset,
dismissAchievement, dismissAchievement,
dismissApotheosisToast, dismissApotheosisToast,
dismissBattle, dismissBattle,
@@ -2052,6 +2128,7 @@ export const GameProvider = ({
failedQuestToasts, failedQuestToasts,
flushBossLoreToasts, flushBossLoreToasts,
forceSync, forceSync,
forceUnlocks,
formatNumber, formatNumber,
handleClick, handleClick,
isLoading, isLoading,
@@ -2104,6 +2181,7 @@ export const GameProvider = ({
completeChapter, completeChapter,
craftRecipe, craftRecipe,
currentSchemaVersion, currentSchemaVersion,
debugHardReset,
dismissAchievement, dismissAchievement,
dismissApotheosisToast, dismissApotheosisToast,
dismissBattle, dismissBattle,
@@ -2121,6 +2199,7 @@ export const GameProvider = ({
error, error,
flushBossLoreToasts, flushBossLoreToasts,
forceSync, forceSync,
forceUnlocks,
handleClick, handleClick,
isLoading, isLoading,
isSyncing, isSyncing,
+81
View File
@@ -4515,3 +4515,84 @@ body::before {
object-fit: cover; object-fit: cover;
width: 80px; 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;
}
+1
View File
@@ -60,6 +60,7 @@ export type {
ExploreCollectResponse, ExploreCollectResponse,
ExploreStartRequest, ExploreStartRequest,
ExploreStartResponse, ExploreStartResponse,
ForceUnlocksResponse,
GiteaRelease, GiteaRelease,
LeaderboardCategory, LeaderboardCategory,
LeaderboardEntry, LeaderboardEntry,
+34
View File
@@ -398,6 +398,39 @@ interface CraftRecipeResponse {
craftedCombatMultiplier: number; 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 { export type {
AboutResponse, AboutResponse,
ApiError, ApiError,
@@ -417,6 +450,7 @@ export type {
ExploreCollectResponse, ExploreCollectResponse,
ExploreStartRequest, ExploreStartRequest,
ExploreStartResponse, ExploreStartResponse,
ForceUnlocksResponse,
GiteaRelease, GiteaRelease,
LeaderboardCategory, LeaderboardCategory,
LeaderboardEntry, LeaderboardEntry,