generated from nhcarrigan/template
800 lines
24 KiB
TypeScript
800 lines
24 KiB
TypeScript
/**
|
|
* @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 {
|
|
STORY_CHAPTERS,
|
|
isStoryChapterUnlocked,
|
|
type GameState,
|
|
} from "@elysium/types";
|
|
import { Hono } from "hono";
|
|
import { defaultAchievements } from "../data/achievements.js";
|
|
import { defaultAdventurers } from "../data/adventurers.js";
|
|
import { defaultBosses } from "../data/bosses.js";
|
|
import { defaultEquipment } from "../data/equipment.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 { defaultUpgrades } from "../data/upgrades.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";
|
|
|
|
/**
|
|
* 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;
|
|
};
|
|
|
|
/**
|
|
* Unlocks any adventurer tiers that were granted as rewards for completed quests
|
|
* but are still locked in the player's state.
|
|
* @param state - The player's current game state (mutated directly).
|
|
* @returns The number of adventurer tiers that were unlocked.
|
|
*/
|
|
const applyAdventurerUnlocks = (state: GameState): number => {
|
|
let count = 0;
|
|
const completedQuestIds = new Set(
|
|
state.quests.
|
|
filter((q) => {
|
|
return q.status === "completed";
|
|
}).
|
|
map((q) => {
|
|
return q.id;
|
|
}),
|
|
);
|
|
const earnedAdventurerIds = new Set<string>();
|
|
|
|
for (const questDefinition of defaultQuests) {
|
|
if (!completedQuestIds.has(questDefinition.id)) {
|
|
continue;
|
|
}
|
|
for (const reward of questDefinition.rewards) {
|
|
if (reward.type === "adventurer" && reward.targetId !== undefined) {
|
|
earnedAdventurerIds.add(reward.targetId);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const adventurer of state.adventurers) {
|
|
if (!adventurer.unlocked && earnedAdventurerIds.has(adventurer.id)) {
|
|
adventurer.unlocked = true;
|
|
count = count + 1;
|
|
}
|
|
}
|
|
|
|
return count;
|
|
};
|
|
|
|
/**
|
|
* Collects all upgrade IDs the player has legitimately earned via boss defeats
|
|
* and completed quest rewards, sourcing reward data from game definitions.
|
|
* @param state - The player's current game state.
|
|
* @returns A set of earned upgrade IDs.
|
|
*/
|
|
const collectEarnedUpgradeIds = (state: GameState): Set<string> => {
|
|
const earnedIds = new Set<string>();
|
|
const defeatedBossIds = new Set(
|
|
state.bosses.
|
|
filter((b) => {
|
|
return b.status === "defeated";
|
|
}).
|
|
map((b) => {
|
|
return b.id;
|
|
}),
|
|
);
|
|
const completedQuestIds = new Set(
|
|
state.quests.
|
|
filter((q) => {
|
|
return q.status === "completed";
|
|
}).
|
|
map((q) => {
|
|
return q.id;
|
|
}),
|
|
);
|
|
|
|
for (const bossDefinition of defaultBosses) {
|
|
if (!defeatedBossIds.has(bossDefinition.id)) {
|
|
continue;
|
|
}
|
|
for (const upgradeId of bossDefinition.upgradeRewards) {
|
|
earnedIds.add(upgradeId);
|
|
}
|
|
}
|
|
|
|
for (const questDefinition of defaultQuests) {
|
|
if (!completedQuestIds.has(questDefinition.id)) {
|
|
continue;
|
|
}
|
|
for (const reward of questDefinition.rewards) {
|
|
if (reward.type === "upgrade" && reward.targetId !== undefined) {
|
|
earnedIds.add(reward.targetId);
|
|
}
|
|
}
|
|
}
|
|
|
|
return earnedIds;
|
|
};
|
|
|
|
/**
|
|
* Unlocks any upgrades that were granted as rewards for defeated bosses or
|
|
* completed quests but are still locked in the player's state.
|
|
* @param state - The player's current game state (mutated directly).
|
|
* @returns The number of upgrades that were unlocked.
|
|
*/
|
|
const applyUpgradeUnlocks = (state: GameState): number => {
|
|
let count = 0;
|
|
const earnedUpgradeIds = collectEarnedUpgradeIds(state);
|
|
|
|
for (const upgrade of state.upgrades) {
|
|
if (!upgrade.unlocked && earnedUpgradeIds.has(upgrade.id)) {
|
|
upgrade.unlocked = true;
|
|
count = count + 1;
|
|
}
|
|
}
|
|
|
|
return count;
|
|
};
|
|
|
|
/**
|
|
* Marks as owned any equipment that was granted as a reward for defeated bosses
|
|
* but is still unowned in the player's state.
|
|
* @param state - The player's current game state (mutated directly).
|
|
* @returns The number of equipment items that were marked as owned.
|
|
*/
|
|
const applyEquipmentUnlocks = (state: GameState): number => {
|
|
let count = 0;
|
|
const defeatedBossIds = new Set(
|
|
state.bosses.
|
|
filter((b) => {
|
|
return b.status === "defeated";
|
|
}).
|
|
map((b) => {
|
|
return b.id;
|
|
}),
|
|
);
|
|
const earnedEquipmentIds = new Set<string>();
|
|
|
|
for (const bossDefinition of defaultBosses) {
|
|
if (!defeatedBossIds.has(bossDefinition.id)) {
|
|
continue;
|
|
}
|
|
for (const equipmentId of bossDefinition.equipmentRewards) {
|
|
earnedEquipmentIds.add(equipmentId);
|
|
}
|
|
}
|
|
|
|
for (const item of state.equipment) {
|
|
if (!item.owned && earnedEquipmentIds.has(item.id)) {
|
|
item.owned = true;
|
|
count = count + 1;
|
|
}
|
|
}
|
|
|
|
return count;
|
|
};
|
|
|
|
/**
|
|
* Unlocks any story chapters whose conditions are met by the current game state
|
|
* but are still absent from the player's unlockedChapterIds list.
|
|
* @param state - The player's current game state (mutated directly).
|
|
* @returns The number of story chapters that were unlocked.
|
|
*/
|
|
const applyStoryUnlocks = (state: GameState): number => {
|
|
if (state.story === undefined) {
|
|
return 0;
|
|
}
|
|
let count = 0;
|
|
const alreadyUnlocked = new Set(state.story.unlockedChapterIds);
|
|
|
|
for (const chapter of STORY_CHAPTERS) {
|
|
if (alreadyUnlocked.has(chapter.id)) {
|
|
continue;
|
|
}
|
|
if (isStoryChapterUnlocked(chapter, state)) {
|
|
state.story.unlockedChapterIds.push(chapter.id);
|
|
count = count + 1;
|
|
}
|
|
}
|
|
|
|
return count;
|
|
};
|
|
|
|
/**
|
|
* Makes available any exploration areas whose parent zone is now unlocked.
|
|
* @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,
|
|
): {
|
|
adventurersUnlocked: number;
|
|
bossesUnlocked: number;
|
|
equipmentUnlocked: number;
|
|
explorationUnlocked: number;
|
|
questsUnlocked: number;
|
|
storyUnlocked: number;
|
|
upgradesUnlocked: number;
|
|
zonesUnlocked: number;
|
|
} => {
|
|
const zonesUnlocked = applyZoneUnlocks(state);
|
|
const questsUnlocked = applyQuestUnlocks(state);
|
|
const bossesUnlocked = applyBossUnlocks(state);
|
|
const explorationUnlocked = applyExplorationUnlocks(state);
|
|
const adventurersUnlocked = applyAdventurerUnlocks(state);
|
|
const upgradesUnlocked = applyUpgradeUnlocks(state);
|
|
const equipmentUnlocked = applyEquipmentUnlocks(state);
|
|
const storyUnlocked = applyStoryUnlocks(state);
|
|
return {
|
|
adventurersUnlocked,
|
|
bossesUnlocked,
|
|
equipmentUnlocked,
|
|
explorationUnlocked,
|
|
questsUnlocked,
|
|
storyUnlocked,
|
|
upgradesUnlocked,
|
|
zonesUnlocked,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Injects any entries from a defaults array that are missing from an existing
|
|
* saved array (matched by `id`), cloning each new entry before pushing.
|
|
* @param existing - The player's saved array (mutated in place).
|
|
* @param defaults - The current default data array to compare against.
|
|
* @returns The number of entries that were added.
|
|
*/
|
|
const injectMissingEntries = <T extends { id: string }>(
|
|
existing: Array<T>,
|
|
defaults: Array<T>,
|
|
): number => {
|
|
const existingIds = new Set(existing.map((item) => {
|
|
return item.id;
|
|
}));
|
|
let added = 0;
|
|
for (const item of defaults) {
|
|
if (!existingIds.has(item.id)) {
|
|
existing.push(structuredClone(item));
|
|
added = added + 1;
|
|
}
|
|
}
|
|
const defaultOrder = new Map(defaults.map((item, index) => {
|
|
return [ item.id, index ] as const;
|
|
}));
|
|
existing.sort((itemA, itemB) => {
|
|
return (defaultOrder.get(itemA.id) ?? Number.MAX_SAFE_INTEGER)
|
|
- (defaultOrder.get(itemB.id) ?? Number.MAX_SAFE_INTEGER);
|
|
});
|
|
return added;
|
|
};
|
|
|
|
/**
|
|
* Injects any exploration areas from the defaults that are missing from the
|
|
* player's exploration state, seeding each new area as locked.
|
|
* @param state - The player's current game state (mutated in place).
|
|
* @returns The number of exploration areas that were added.
|
|
*/
|
|
const injectMissingExplorationAreas = (state: GameState): number => {
|
|
if (state.exploration === undefined) {
|
|
return 0;
|
|
}
|
|
const existingIds = new Set(state.exploration.areas.map((area) => {
|
|
return area.id;
|
|
}));
|
|
let added = 0;
|
|
for (const area of defaultExplorations) {
|
|
if (!existingIds.has(area.id)) {
|
|
state.exploration.areas.push({ id: area.id, status: "locked" });
|
|
added = added + 1;
|
|
}
|
|
}
|
|
return added;
|
|
};
|
|
|
|
/* eslint-disable stylistic/max-len -- Long function call lines cannot be shortened without losing alignment */
|
|
/**
|
|
* Syncs a player's save with the current game data, injecting any content
|
|
* entries that are missing because they were added after the save was created.
|
|
* @param state - The player's current game state (mutated in place).
|
|
* @returns Counts of how many entries were added per content type.
|
|
*/
|
|
const syncNewContent = (
|
|
state: GameState,
|
|
): {
|
|
achievementsAdded: number;
|
|
adventurersAdded: number;
|
|
bossesAdded: number;
|
|
equipmentAdded: number;
|
|
explorationAreasAdded: number;
|
|
questsAdded: number;
|
|
upgradesAdded: number;
|
|
zonesAdded: number;
|
|
} => {
|
|
return {
|
|
achievementsAdded: injectMissingEntries(state.achievements, defaultAchievements),
|
|
adventurersAdded: injectMissingEntries(state.adventurers, defaultAdventurers),
|
|
bossesAdded: injectMissingEntries(state.bosses, defaultBosses),
|
|
equipmentAdded: injectMissingEntries(state.equipment, defaultEquipment),
|
|
explorationAreasAdded: injectMissingExplorationAreas(state),
|
|
questsAdded: injectMissingEntries(state.quests, defaultQuests),
|
|
upgradesAdded: injectMissingEntries(state.upgrades, defaultUpgrades),
|
|
zonesAdded: injectMissingEntries(state.zones, defaultZones),
|
|
};
|
|
};
|
|
/* eslint-enable stylistic/max-len -- Re-enable after long lines */
|
|
|
|
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 {
|
|
adventurersUnlocked,
|
|
bossesUnlocked,
|
|
equipmentUnlocked,
|
|
explorationUnlocked,
|
|
questsUnlocked,
|
|
storyUnlocked,
|
|
upgradesUnlocked,
|
|
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({
|
|
adventurersUnlocked,
|
|
bossesUnlocked,
|
|
equipmentUnlocked,
|
|
explorationUnlocked,
|
|
questsUnlocked,
|
|
signature,
|
|
state,
|
|
storyUnlocked,
|
|
upgradesUnlocked,
|
|
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("/sync-new-content", 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 {
|
|
achievementsAdded,
|
|
adventurersAdded,
|
|
bossesAdded,
|
|
equipmentAdded,
|
|
explorationAreasAdded,
|
|
questsAdded,
|
|
upgradesAdded,
|
|
zonesAdded,
|
|
} = syncNewContent(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({
|
|
achievementsAdded,
|
|
adventurersAdded,
|
|
bossesAdded,
|
|
equipmentAdded,
|
|
explorationAreasAdded,
|
|
questsAdded,
|
|
signature,
|
|
state,
|
|
upgradesAdded,
|
|
zonesAdded,
|
|
});
|
|
} catch (error) {
|
|
void logger.error(
|
|
"debug_sync_new_content",
|
|
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 };
|