From b3913cef52e57d089cc24beb3caf197f6944b52c Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 24 Mar 2026 14:46:34 -0700 Subject: [PATCH] fix: patch adventurer stats on sync so rebalances apply to existing saves Sync New Content now updates baseCost, class, combatPower, essencePerSecond, goldPerSecond, level, and name for all existing adventurer entries to match the current defaults, while preserving count and unlocked state. Closes #126 --- apps/api/src/routes/debug.ts | 92 ++++++++++++++++----- apps/api/test/routes/debug.spec.ts | 34 +++++++- apps/web/src/components/game/debugPanel.tsx | 22 ++--- packages/types/src/interfaces/api.ts | 6 ++ 4 files changed, 121 insertions(+), 33 deletions(-) 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. */