feat: add sync new content debug tool
CI / Lint, Build & Test (push) Failing after 51s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m9s

Adds a new debug panel button that injects any adventurers, quests,
bosses, equipment, upgrades, achievements, zones, and exploration areas
that exist in the current game data but are missing from an existing
player save (e.g. content added after the save was first created).
This commit is contained in:
2026-03-23 18:10:39 -07:00
committed by Naomi Carrigan
parent 26d30c271d
commit e92cf3c9a1
6 changed files with 356 additions and 2 deletions
+144
View File
@@ -13,11 +13,15 @@ import {
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";
@@ -508,6 +512,85 @@ const applyForceUnlocks = (
};
};
/**
* 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;
}
}
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);
@@ -572,6 +655,67 @@ debugRouter.post("/force-unlocks", async(context) => {
}
});
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");