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
This commit is contained in:
2026-03-24 14:46:34 -07:00
committed by Naomi Carrigan
parent 6e573bea14
commit b275b7878c
4 changed files with 121 additions and 33 deletions
+70 -22
View File
@@ -625,38 +625,80 @@ const patchBossUpgradeRewards = (state: GameState): number => {
return added; 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 */ /* 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 * 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). * @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 = ( const syncNewContent = (
state: GameState, state: GameState,
): { ): {
achievementsAdded: number; achievementsAdded: number;
adventurersAdded: number; adventurersAdded: number;
bossesAdded: number; adventurerStatsPatched: number;
bossRewardsPatched: number; bossesAdded: number;
equipmentAdded: number; bossRewardsPatched: number;
explorationAreasAdded: number; equipmentAdded: number;
questRewardsPatched: number; explorationAreasAdded: number;
questsAdded: number; questRewardsPatched: number;
upgradesAdded: number; questsAdded: number;
zonesAdded: 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 { return {
achievementsAdded: injectMissingEntries(state.achievements, defaultAchievements), achievementsAdded,
adventurersAdded: injectMissingEntries(state.adventurers, defaultAdventurers), adventurerStatsPatched,
bossRewardsPatched: patchBossUpgradeRewards(state), adventurersAdded,
bossesAdded: injectMissingEntries(state.bosses, defaultBosses), bossRewardsPatched,
equipmentAdded: injectMissingEntries(state.equipment, defaultEquipment), bossesAdded,
explorationAreasAdded: injectMissingExplorationAreas(state), equipmentAdded,
questRewardsPatched: patchQuestRewards(state), explorationAreasAdded,
questsAdded: injectMissingEntries(state.quests, defaultQuests), questRewardsPatched,
upgradesAdded: injectMissingEntries(state.upgrades, defaultUpgrades), questsAdded,
zonesAdded: injectMissingEntries(state.zones, defaultZones), upgradesAdded,
zonesAdded,
}; };
}; };
/* eslint-enable stylistic/max-len -- Re-enable after long lines */ /* eslint-enable stylistic/max-len -- Re-enable after long lines */
@@ -742,9 +784,12 @@ debugRouter.post("/sync-new-content", async(context) => {
const { const {
achievementsAdded, achievementsAdded,
adventurersAdded, adventurersAdded,
adventurerStatsPatched,
bossesAdded, bossesAdded,
bossRewardsPatched,
equipmentAdded, equipmentAdded,
explorationAreasAdded, explorationAreasAdded,
questRewardsPatched,
questsAdded, questsAdded,
upgradesAdded, upgradesAdded,
zonesAdded, zonesAdded,
@@ -765,10 +810,13 @@ debugRouter.post("/sync-new-content", async(context) => {
return context.json({ return context.json({
achievementsAdded, achievementsAdded,
adventurerStatsPatched,
adventurersAdded, adventurersAdded,
bossRewardsPatched,
bossesAdded, bossesAdded,
equipmentAdded, equipmentAdded,
explorationAreasAdded, explorationAreasAdded,
questRewardsPatched,
questsAdded, questsAdded,
signature, signature,
state, state,
+33 -1
View File
@@ -567,12 +567,44 @@ describe("debug route", () => {
expect(res.status).toBe(404); 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(); const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent(); const res = await syncNewContent();
expect(res.status).toBe(200); 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 () => { it("injects missing entries when arrays are empty", async () => {
+12 -10
View File
@@ -13,16 +13,17 @@ import { ConfirmationModal } from "../ui/confirmationModal.js";
type ActiveModal = "force-unlocks" | "hard-reset" | "sync-new-content" | null; type ActiveModal = "force-unlocks" | "hard-reset" | "sync-new-content" | null;
interface SyncNewContentResult { interface SyncNewContentResult {
achievementsAdded: number | undefined; achievementsAdded: number | undefined;
adventurersAdded: number | undefined; adventurersAdded: number | undefined;
bossesAdded: number | undefined; adventurerStatsPatched: number | undefined;
bossRewardsPatched: number | undefined; bossesAdded: number | undefined;
equipmentAdded: number | undefined; bossRewardsPatched: number | undefined;
explorationAreasAdded: number | undefined; equipmentAdded: number | undefined;
questRewardsPatched: number | undefined; explorationAreasAdded: number | undefined;
questsAdded: number | undefined; questRewardsPatched: number | undefined;
upgradesAdded: number | undefined; questsAdded: number | undefined;
zonesAdded: number | undefined; upgradesAdded: number | undefined;
zonesAdded: number | undefined;
} }
const safeNumber = (value: number | undefined): number => { const safeNumber = (value: number | undefined): number => {
@@ -43,6 +44,7 @@ const buildSyncNewContentMessage = (result: SyncNewContentResult): string => {
[ safeNumber(result.bossRewardsPatched), "boss reward(s) patched" ], [ safeNumber(result.bossRewardsPatched), "boss reward(s) patched" ],
[ safeNumber(result.explorationAreasAdded), "exploration area(s)" ], [ safeNumber(result.explorationAreasAdded), "exploration area(s)" ],
[ safeNumber(result.adventurersAdded), "adventurer tier(s)" ], [ safeNumber(result.adventurersAdded), "adventurer tier(s)" ],
[ safeNumber(result.adventurerStatsPatched), "adventurer stat(s) patched" ],
[ safeNumber(result.upgradesAdded), "upgrade(s)" ], [ safeNumber(result.upgradesAdded), "upgrade(s)" ],
[ safeNumber(result.equipmentAdded), "equipment item(s)" ], [ safeNumber(result.equipmentAdded), "equipment item(s)" ],
[ safeNumber(result.achievementsAdded), "achievement(s)" ], [ safeNumber(result.achievementsAdded), "achievement(s)" ],
+6
View File
@@ -4,6 +4,7 @@
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/* eslint-disable max-lines -- API types file grows with each new endpoint */
import type { import type {
EquipmentBonus, EquipmentBonus,
EquipmentRarity, EquipmentRarity,
@@ -468,6 +469,11 @@ interface SyncNewContentResponse {
*/ */
adventurersAdded: number; adventurersAdded: number;
/**
* Number of existing adventurer entries whose stats were patched to match current defaults.
*/
adventurerStatsPatched: number;
/** /**
* Number of upgrades added to the save. * Number of upgrades added to the save.
*/ */