diff --git a/apps/api/src/routes/debug.ts b/apps/api/src/routes/debug.ts index fb65e35..080e88f 100644 --- a/apps/api/src/routes/debug.ts +++ b/apps/api/src/routes/debug.ts @@ -625,38 +625,80 @@ const patchBossUpgradeRewards = (state: GameState): number => { return added; }; +/** + * Updates the stat fields of existing adventurers to match the current defaults, + * preserving only player-state fields (count and unlocked status). + * @param state - The player's current game state (mutated in place). + * @returns The number of adventurer entries whose stats were updated. + */ +const patchAdventurerStats = (state: GameState): number => { + const defaultAdventurerMap = new Map(defaultAdventurers.map((adventurer) => { + return [ adventurer.id, adventurer ] as const; + })); + let patched = 0; + for (const savedAdventurer of state.adventurers) { + const defaultAdventurer = defaultAdventurerMap.get(savedAdventurer.id); + if (defaultAdventurer === undefined) { + continue; + } + savedAdventurer.baseCost = defaultAdventurer.baseCost; + savedAdventurer.class = defaultAdventurer.class; + savedAdventurer.combatPower = defaultAdventurer.combatPower; + savedAdventurer.essencePerSecond = defaultAdventurer.essencePerSecond; + savedAdventurer.goldPerSecond = defaultAdventurer.goldPerSecond; + savedAdventurer.level = defaultAdventurer.level; + savedAdventurer.name = defaultAdventurer.name; + patched = patched + 1; + } + return patched; +}; + /* 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. + * entries that are missing because they were added after the save was created, + * and patching stat fields on existing entries to match the current defaults. * @param state - The player's current game state (mutated in place). - * @returns Counts of how many entries were added per content type. + * @returns Counts of how many entries were added or patched per content type. */ const syncNewContent = ( state: GameState, ): { - achievementsAdded: number; - adventurersAdded: number; - bossesAdded: number; - bossRewardsPatched: number; - equipmentAdded: number; - explorationAreasAdded: number; - questRewardsPatched: number; - questsAdded: number; - upgradesAdded: number; - zonesAdded: number; + achievementsAdded: number; + adventurersAdded: number; + adventurerStatsPatched: number; + bossesAdded: number; + bossRewardsPatched: number; + equipmentAdded: number; + explorationAreasAdded: number; + questRewardsPatched: number; + questsAdded: number; + upgradesAdded: number; + zonesAdded: number; } => { + const adventurerStatsPatched = patchAdventurerStats(state); + const achievementsAdded = injectMissingEntries(state.achievements, defaultAchievements); + const adventurersAdded = injectMissingEntries(state.adventurers, defaultAdventurers); + const bossRewardsPatched = patchBossUpgradeRewards(state); + const bossesAdded = injectMissingEntries(state.bosses, defaultBosses); + const equipmentAdded = injectMissingEntries(state.equipment, defaultEquipment); + const explorationAreasAdded = injectMissingExplorationAreas(state); + const questRewardsPatched = patchQuestRewards(state); + const questsAdded = injectMissingEntries(state.quests, defaultQuests); + const upgradesAdded = injectMissingEntries(state.upgrades, defaultUpgrades); + const zonesAdded = injectMissingEntries(state.zones, defaultZones); return { - achievementsAdded: injectMissingEntries(state.achievements, defaultAchievements), - adventurersAdded: injectMissingEntries(state.adventurers, defaultAdventurers), - bossRewardsPatched: patchBossUpgradeRewards(state), - bossesAdded: injectMissingEntries(state.bosses, defaultBosses), - equipmentAdded: injectMissingEntries(state.equipment, defaultEquipment), - explorationAreasAdded: injectMissingExplorationAreas(state), - questRewardsPatched: patchQuestRewards(state), - questsAdded: injectMissingEntries(state.quests, defaultQuests), - upgradesAdded: injectMissingEntries(state.upgrades, defaultUpgrades), - zonesAdded: injectMissingEntries(state.zones, defaultZones), + achievementsAdded, + adventurerStatsPatched, + adventurersAdded, + bossRewardsPatched, + bossesAdded, + equipmentAdded, + explorationAreasAdded, + questRewardsPatched, + questsAdded, + upgradesAdded, + zonesAdded, }; }; /* eslint-enable stylistic/max-len -- Re-enable after long lines */ @@ -742,9 +784,12 @@ debugRouter.post("/sync-new-content", async(context) => { const { achievementsAdded, adventurersAdded, + adventurerStatsPatched, bossesAdded, + bossRewardsPatched, equipmentAdded, explorationAreasAdded, + questRewardsPatched, questsAdded, upgradesAdded, zonesAdded, @@ -765,10 +810,13 @@ debugRouter.post("/sync-new-content", async(context) => { return context.json({ achievementsAdded, + adventurerStatsPatched, adventurersAdded, + bossRewardsPatched, bossesAdded, equipmentAdded, explorationAreasAdded, + questRewardsPatched, questsAdded, signature, state, diff --git a/apps/api/test/routes/debug.spec.ts b/apps/api/test/routes/debug.spec.ts index 1765f59..ee2a41f 100644 --- a/apps/api/test/routes/debug.spec.ts +++ b/apps/api/test/routes/debug.spec.ts @@ -567,12 +567,44 @@ describe("debug route", () => { expect(res.status).toBe(404); }); - it("returns 200 with zero counts when state already has all content", async () => { + it("returns 200 with zero added counts when state already has all content", async () => { const state = makeState(); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); const res = await syncNewContent(); expect(res.status).toBe(200); + const body = await res.json() as { adventurerStatsPatched: number; bossRewardsPatched: number; questRewardsPatched: number }; + expect(body.adventurerStatsPatched).toBe(0); + expect(body.bossRewardsPatched).toBe(0); + expect(body.questRewardsPatched).toBe(0); + }); + + it("patches adventurer stats when saved adventurer has outdated stats", async () => { + const state = makeState({ + adventurers: [{ id: "militia", count: 5, unlocked: true, baseCost: 1, goldPerSecond: 1, essencePerSecond: 1, combatPower: 1, level: 1, name: "Old Name", class: "warrior" }] as GameState["adventurers"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await syncNewContent(); + expect(res.status).toBe(200); + const body = await res.json() as { adventurerStatsPatched: number; state: GameState }; + expect(body.adventurerStatsPatched).toBe(1); + const adventurer = body.state.adventurers.find((a) => a.id === "militia"); + expect(adventurer?.baseCost).not.toBe(1); + expect(adventurer?.count).toBe(5); + expect(adventurer?.unlocked).toBe(true); + }); + + it("skips adventurer stat patching for adventurers not in defaults", async () => { + const state = makeState({ + adventurers: [{ id: "nonexistent_adventurer", count: 0, unlocked: false, baseCost: 1, goldPerSecond: 1, essencePerSecond: 1, combatPower: 1, level: 1, name: "Ghost", class: "warrior" }] as GameState["adventurers"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await syncNewContent(); + expect(res.status).toBe(200); + const body = await res.json() as { adventurerStatsPatched: number }; + expect(body.adventurerStatsPatched).toBe(0); }); it("injects missing entries when arrays are empty", async () => { diff --git a/apps/web/src/components/game/debugPanel.tsx b/apps/web/src/components/game/debugPanel.tsx index 6953a2f..0d92d91 100644 --- a/apps/web/src/components/game/debugPanel.tsx +++ b/apps/web/src/components/game/debugPanel.tsx @@ -13,16 +13,17 @@ import { ConfirmationModal } from "../ui/confirmationModal.js"; type ActiveModal = "force-unlocks" | "hard-reset" | "sync-new-content" | null; interface SyncNewContentResult { - achievementsAdded: number | undefined; - adventurersAdded: number | undefined; - bossesAdded: number | undefined; - bossRewardsPatched: number | undefined; - equipmentAdded: number | undefined; - explorationAreasAdded: number | undefined; - questRewardsPatched: number | undefined; - questsAdded: number | undefined; - upgradesAdded: number | undefined; - zonesAdded: number | undefined; + achievementsAdded: number | undefined; + adventurersAdded: number | undefined; + adventurerStatsPatched: number | undefined; + bossesAdded: number | undefined; + bossRewardsPatched: number | undefined; + equipmentAdded: number | undefined; + explorationAreasAdded: number | undefined; + questRewardsPatched: number | undefined; + questsAdded: number | undefined; + upgradesAdded: number | undefined; + zonesAdded: number | undefined; } const safeNumber = (value: number | undefined): number => { @@ -43,6 +44,7 @@ const buildSyncNewContentMessage = (result: SyncNewContentResult): string => { [ safeNumber(result.bossRewardsPatched), "boss reward(s) patched" ], [ safeNumber(result.explorationAreasAdded), "exploration area(s)" ], [ safeNumber(result.adventurersAdded), "adventurer tier(s)" ], + [ safeNumber(result.adventurerStatsPatched), "adventurer stat(s) patched" ], [ safeNumber(result.upgradesAdded), "upgrade(s)" ], [ safeNumber(result.equipmentAdded), "equipment item(s)" ], [ safeNumber(result.achievementsAdded), "achievement(s)" ], diff --git a/packages/types/src/interfaces/api.ts b/packages/types/src/interfaces/api.ts index c099839..b90f46b 100644 --- a/packages/types/src/interfaces/api.ts +++ b/packages/types/src/interfaces/api.ts @@ -4,6 +4,7 @@ * @license Naomi's Public License * @author Naomi Carrigan */ +/* eslint-disable max-lines -- API types file grows with each new endpoint */ import type { EquipmentBonus, EquipmentRarity, @@ -468,6 +469,11 @@ interface SyncNewContentResponse { */ adventurersAdded: number; + /** + * Number of existing adventurer entries whose stats were patched to match current defaults. + */ + adventurerStatsPatched: number; + /** * Number of upgrades added to the save. */