diff --git a/apps/api/src/routes/debug.ts b/apps/api/src/routes/debug.ts index 59d1225..7837be7 100644 --- a/apps/api/src/routes/debug.ts +++ b/apps/api/src/routes/debug.ts @@ -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 = ( + existing: Array, + defaults: Array, +): 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(); 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"); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index cde0470..c5b28ff 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -28,6 +28,7 @@ import type { PublicProfileResponse, SaveRequest, SaveResponse, + SyncNewContentResponse, TranscendenceRequest, TranscendenceResponse, UpdateProfileRequest, @@ -267,6 +268,16 @@ const forceUnlocks = async(): Promise => { }); }; +/** + * Syncs any content added after the player's save was created into their save. + * @returns The updated game state and counts of what was added per content type. + */ +const syncNewContent = async(): Promise => { + return await fetchJson("/debug/sync-new-content", { + 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. @@ -309,6 +320,7 @@ export { craftRecipe, debugHardReset, forceUnlocks, + syncNewContent, getAbout, getAuthUrl, getPublicProfile, diff --git a/apps/web/src/components/game/debugPanel.tsx b/apps/web/src/components/game/debugPanel.tsx index 958bed4..80a1f97 100644 --- a/apps/web/src/components/game/debugPanel.tsx +++ b/apps/web/src/components/game/debugPanel.tsx @@ -10,7 +10,50 @@ 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; +type ActiveModal = "force-unlocks" | "hard-reset" | "sync-new-content" | null; + +interface SyncNewContentResult { + achievementsAdded: number; + adventurersAdded: number; + bossesAdded: number; + equipmentAdded: number; + explorationAreasAdded: number; + questsAdded: number; + upgradesAdded: number; + zonesAdded: number; +} + +/** + * Builds a human-readable summary of what the sync-new-content operation added. + * @param result - The counts returned by the operation. + * @returns A message string describing what was added, or a confirmation nothing was needed. + */ +const buildSyncNewContentMessage = (result: SyncNewContentResult): string => { + const entries: Array<[ number, string ]> = [ + [ result.zonesAdded, "zone(s)" ], + [ result.questsAdded, "quest(s)" ], + [ result.bossesAdded, "boss(es)" ], + [ result.explorationAreasAdded, "exploration area(s)" ], + [ result.adventurersAdded, "adventurer tier(s)" ], + [ result.upgradesAdded, "upgrade(s)" ], + [ result.equipmentAdded, "equipment item(s)" ], + [ result.achievementsAdded, "achievement(s)" ], + ]; + const parts = entries. + filter(([ count ]) => { + return count > 0; + }). + map(([ count, label ]) => { + return `${String(count)} ${label}`; + }); + if (parts.length === 0) { + return "Your save is already up to date — no new content was found."; + } + const total = entries.reduce((sum, [ count ]) => { + return sum + count; + }, 0); + return `Added ${String(total)} new item(s) to your save: ${parts.join(", ")}.`; +}; interface ForceUnlocksResult { adventurersUnlocked: number; @@ -60,15 +103,21 @@ const buildForceUnlocksMessage = (result: ForceUnlocksResult): string => { * @returns The JSX element. */ const DebugPanel = (): JSX.Element => { - const { forceUnlocks, debugHardReset, isLoading } = useGame(); + const { forceUnlocks, debugHardReset, syncNewContent, isLoading } = useGame(); const [ activeModal, setActiveModal ] = useState(null); const [ forceUnlocksResult, setForceUnlocksResult ] = useState(null); + const [ syncNewContentResult, setSyncNewContentResult ] = useState(null); function handleOpenForceUnlocks(): void { setForceUnlocksResult(null); setActiveModal("force-unlocks"); } + function handleOpenSyncNewContent(): void { + setSyncNewContentResult(null); + setActiveModal("sync-new-content"); + } + function handleOpenHardReset(): void { setActiveModal("hard-reset"); } @@ -85,6 +134,14 @@ const DebugPanel = (): JSX.Element => { })(); } + function handleConfirmSyncNewContent(): void { + setActiveModal(null); + void (async(): Promise => { + const result = await syncNewContent(); + setSyncNewContentResult(buildSyncNewContentMessage(result)); + })(); + } + function handleConfirmHardReset(): void { setActiveModal(null); void debugHardReset(); @@ -120,6 +177,26 @@ const DebugPanel = (): JSX.Element => { } +
+

{"🔄 Sync New Content"}

+

+ { + "If the game has been updated since your save was created, this will add any missing adventurers, quests, bosses, equipment, upgrades, and more to your save without affecting your existing progress." + } +

+ + {syncNewContentResult !== null + &&

{syncNewContentResult}

+ } +
+

{"💀 Hard Reset"}

@@ -149,6 +226,17 @@ const DebugPanel = (): JSX.Element => { /> } + {activeModal === "sync-new-content" + && + } + {activeModal === "hard-reset" && Promise; + /** + * Syncs any content added to the game after the player's save was created. + * @returns Counts of what was added per content type. + */ + syncNewContent: ()=> Promise<{ + achievementsAdded: number; + adventurersAdded: number; + bossesAdded: number; + equipmentAdded: number; + explorationAreasAdded: number; + questsAdded: number; + upgradesAdded: number; + zonesAdded: number; + }>; + /** * Last auto-boss fight result — null until the first auto fight completes or * when auto-boss is toggled off. @@ -2151,6 +2167,43 @@ export const GameProvider = ({ } }, []); + const syncNewContent = useCallback(async() => { + try { + const data = await syncNewContentApi(); + setState(data.state); + if (data.signature !== undefined) { + signatureReference.current = data.signature; + localStorage.setItem("elysium_save_signature", data.signature); + } + return { + achievementsAdded: data.achievementsAdded, + adventurersAdded: data.adventurersAdded, + bossesAdded: data.bossesAdded, + equipmentAdded: data.equipmentAdded, + explorationAreasAdded: data.explorationAreasAdded, + questsAdded: data.questsAdded, + upgradesAdded: data.upgradesAdded, + zonesAdded: data.zonesAdded, + }; + } catch (error_: unknown) { + setError( + error_ instanceof Error + ? error_.message + : "Failed to sync new content", + ); + return { + achievementsAdded: 0, + adventurersAdded: 0, + bossesAdded: 0, + equipmentAdded: 0, + explorationAreasAdded: 0, + questsAdded: 0, + upgradesAdded: 0, + zonesAdded: 0, + }; + } + }, []); + const debugHardReset = useCallback(async() => { setIsLoading(true); setError(null); @@ -2260,6 +2313,7 @@ export const GameProvider = ({ unlockedAchievements, unlockedCodexEntryIds, unlockedStoryChapterIds, + syncNewContent, }; }, [ apotheosis, @@ -2323,6 +2377,7 @@ export const GameProvider = ({ startQuest, state, syncError, + syncNewContent, toggleAutoAdventurer, toggleAutoBoss, toggleAutoPrestige, diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index ed06475..3687c06 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -72,6 +72,7 @@ export type { PublicProfileResponse, SaveRequest, SaveResponse, + SyncNewContentResponse, TranscendenceRequest, TranscendenceResponse, UpdateProfileRequest, diff --git a/packages/types/src/interfaces/api.ts b/packages/types/src/interfaces/api.ts index 1c6e673..fb4074d 100644 --- a/packages/types/src/interfaces/api.ts +++ b/packages/types/src/interfaces/api.ts @@ -451,6 +451,59 @@ interface ForceUnlocksResponse { signature?: string; } +interface SyncNewContentResponse { + + /** + * The updated game state after injecting all missing content entries. + */ + state: GameState; + + /** + * Number of adventurer tiers added to the save. + */ + adventurersAdded: number; + + /** + * Number of upgrades added to the save. + */ + upgradesAdded: number; + + /** + * Number of quests added to the save. + */ + questsAdded: number; + + /** + * Number of bosses added to the save. + */ + bossesAdded: number; + + /** + * Number of equipment items added to the save. + */ + equipmentAdded: number; + + /** + * Number of achievements added to the save. + */ + achievementsAdded: number; + + /** + * Number of zones added to the save. + */ + zonesAdded: number; + + /** + * Number of exploration areas added to the save. + */ + explorationAreasAdded: number; + + /** + * HMAC-SHA256 signature of the updated state for anti-cheat chain continuity. + */ + signature?: string; +} + export type { AboutResponse, ApiError, @@ -482,6 +535,7 @@ export type { PublicProfileResponse, SaveRequest, SaveResponse, + SyncNewContentResponse, TranscendenceRequest, TranscendenceResponse, UpdateProfileRequest,