feat: sync and patch all content stats on existing saves (#130)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m2s
CI / Lint, Build & Test (push) Successful in 1m10s

## Summary

- Sync New Content now **injects** missing entries AND **patches canonical fields** on all existing entries to match current defaults
- Adventurers: stats (baseCost, combatPower, goldPerSecond, essencePerSecond, name, class, level)
- Quests: duration, prerequisites, combat requirement, rewards
- Bosses: HP, damage, rewards, prestige requirement, upgrade rewards
- Zones: unlock conditions (boss/quest required)
- Upgrades: multiplier, costs
- Equipment: bonus, cost, set membership
- Achievements: condition, reward
- Crafting: multipliers recomputed from `craftedRecipeIds` so recipe balance changes apply retroactively

Closes #126

## Test plan

- [ ] On an existing save, click Sync New Content and verify the notification reports patched counts for all content types
- [ ] Verify that rebalanced adventurer/boss/upgrade stats are reflected in the UI after syncing
- [ ] Verify that player-owned state (counts, unlock status, boss HP, quest status) is preserved after syncing
- [ ] Verify crafting multipliers are correct after syncing if any recipes were previously crafted

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #130
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #130.
This commit is contained in:
2026-03-24 16:01:48 -07:00
committed by Naomi Carrigan
parent 6e573bea14
commit b48beef474
5 changed files with 715 additions and 63 deletions
+326 -22
View File
@@ -20,6 +20,7 @@ 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 { defaultRecipes } from "../data/recipes.js";
import { currentSchemaVersion } from "../data/schemaVersion.js";
import { defaultUpgrades } from "../data/upgrades.js";
import { defaultZones } from "../data/zones.js";
@@ -625,38 +626,321 @@ 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;
};
/**
* Updates the stat fields of existing quests to match the current defaults,
* preserving only player-state fields (status, startedAt, lastFailedAt, rewards).
* @param state - The player's current game state (mutated in place).
* @returns The number of quest entries whose stats were updated.
*/
const patchQuestStats = (state: GameState): number => {
const defaultQuestMap = new Map(defaultQuests.map((quest) => {
return [ quest.id, quest ] as const;
}));
let patched = 0;
for (const savedQuest of state.quests) {
const defaultQuest = defaultQuestMap.get(savedQuest.id);
if (defaultQuest === undefined) {
continue;
}
savedQuest.name = defaultQuest.name;
savedQuest.description = defaultQuest.description;
savedQuest.durationSeconds = defaultQuest.durationSeconds;
savedQuest.prerequisiteIds = defaultQuest.prerequisiteIds;
savedQuest.zoneId = defaultQuest.zoneId;
if (defaultQuest.combatPowerRequired !== undefined) {
savedQuest.combatPowerRequired = defaultQuest.combatPowerRequired;
}
patched = patched + 1;
}
return patched;
};
/**
* Updates the stat fields of existing bosses to match the current defaults,
* preserving only player-state fields (status, currentHp, bountyRunestonesClaimed, upgradeRewards).
* @param state - The player's current game state (mutated in place).
* @returns The number of boss entries whose stats were updated.
*/
const patchBossStats = (state: GameState): number => {
const defaultBossMap = new Map(defaultBosses.map((boss) => {
return [ boss.id, boss ] as const;
}));
let patched = 0;
for (const savedBoss of state.bosses) {
const defaultBoss = defaultBossMap.get(savedBoss.id);
if (defaultBoss === undefined) {
continue;
}
savedBoss.name = defaultBoss.name;
savedBoss.description = defaultBoss.description;
savedBoss.maxHp = defaultBoss.maxHp;
savedBoss.damagePerSecond = defaultBoss.damagePerSecond;
savedBoss.goldReward = defaultBoss.goldReward;
savedBoss.essenceReward = defaultBoss.essenceReward;
savedBoss.crystalReward = defaultBoss.crystalReward;
savedBoss.equipmentRewards = [ ...defaultBoss.equipmentRewards ];
savedBoss.prestigeRequirement = defaultBoss.prestigeRequirement;
savedBoss.zoneId = defaultBoss.zoneId;
savedBoss.bountyRunestones = defaultBoss.bountyRunestones;
patched = patched + 1;
}
return patched;
};
/**
* Updates the stat fields of existing zones to match the current defaults,
* preserving only player-state fields (status).
* @param state - The player's current game state (mutated in place).
* @returns The number of zone entries whose stats were updated.
*/
const patchZoneStats = (state: GameState): number => {
const defaultZoneMap = new Map(defaultZones.map((zone) => {
return [ zone.id, zone ] as const;
}));
let patched = 0;
for (const savedZone of state.zones) {
const defaultZone = defaultZoneMap.get(savedZone.id);
if (defaultZone === undefined) {
continue;
}
savedZone.name = defaultZone.name;
savedZone.description = defaultZone.description;
savedZone.emoji = defaultZone.emoji;
savedZone.unlockBossId = defaultZone.unlockBossId;
savedZone.unlockQuestId = defaultZone.unlockQuestId;
patched = patched + 1;
}
return patched;
};
/**
* Updates the stat fields of existing upgrades to match the current defaults,
* preserving only player-state fields (purchased, unlocked).
* @param state - The player's current game state (mutated in place).
* @returns The number of upgrade entries whose stats were updated.
*/
const patchUpgradeStats = (state: GameState): number => {
const defaultUpgradeMap = new Map(defaultUpgrades.map((upgrade) => {
return [ upgrade.id, upgrade ] as const;
}));
let patched = 0;
for (const savedUpgrade of state.upgrades) {
const defaultUpgrade = defaultUpgradeMap.get(savedUpgrade.id);
if (defaultUpgrade === undefined) {
continue;
}
savedUpgrade.name = defaultUpgrade.name;
savedUpgrade.description = defaultUpgrade.description;
savedUpgrade.target = defaultUpgrade.target;
if (defaultUpgrade.adventurerId !== undefined) {
savedUpgrade.adventurerId = defaultUpgrade.adventurerId;
}
savedUpgrade.multiplier = defaultUpgrade.multiplier;
savedUpgrade.costGold = defaultUpgrade.costGold;
savedUpgrade.costEssence = defaultUpgrade.costEssence;
savedUpgrade.costCrystals = defaultUpgrade.costCrystals;
patched = patched + 1;
}
return patched;
};
/**
* Updates the stat fields of existing equipment items to match the current defaults,
* preserving only player-state fields (owned, equipped).
* @param state - The player's current game state (mutated in place).
* @returns The number of equipment entries whose stats were updated.
*/
const patchEquipmentStats = (state: GameState): number => {
const defaultEquipmentMap = new Map(defaultEquipment.map((item) => {
return [ item.id, item ] as const;
}));
let patched = 0;
for (const savedItem of state.equipment) {
const defaultItem = defaultEquipmentMap.get(savedItem.id);
if (defaultItem === undefined) {
continue;
}
savedItem.name = defaultItem.name;
savedItem.description = defaultItem.description;
savedItem.type = defaultItem.type;
savedItem.rarity = defaultItem.rarity;
savedItem.bonus = structuredClone(defaultItem.bonus);
if (defaultItem.cost !== undefined) {
savedItem.cost = { ...defaultItem.cost };
}
if (defaultItem.setId !== undefined) {
savedItem.setId = defaultItem.setId;
}
patched = patched + 1;
}
return patched;
};
/**
* Updates the stat fields of existing achievements to match the current defaults,
* preserving only player-state fields (unlockedAt).
* @param state - The player's current game state (mutated in place).
* @returns The number of achievement entries whose stats were updated.
*/
const patchAchievementStats = (state: GameState): number => {
const defaultAchievementMap = new Map(defaultAchievements.map((a) => {
return [ a.id, a ] as const;
}));
let patched = 0;
for (const savedAchievement of state.achievements) {
const defaultAchievement = defaultAchievementMap.get(savedAchievement.id);
if (defaultAchievement === undefined) {
continue;
}
savedAchievement.name = defaultAchievement.name;
savedAchievement.description = defaultAchievement.description;
savedAchievement.icon = defaultAchievement.icon;
savedAchievement.condition = structuredClone(defaultAchievement.condition);
if (defaultAchievement.reward !== undefined) {
savedAchievement.reward = { ...defaultAchievement.reward };
}
patched = patched + 1;
}
return patched;
};
/* eslint-disable stylistic/max-len -- Filter conditions cannot be shortened without losing readability */
/**
* Recomputes all four crafting multipliers from the player's craftedRecipeIds,
* replacing any stale cached values with the correct product of all crafted bonuses.
* @param state - The player's current game state (mutated in place).
* @returns The number of crafted recipe IDs that were processed, or 0 if exploration is undefined.
*/
const recomputeCraftingMultipliers = (state: GameState): number => {
if (state.exploration === undefined) {
return 0;
}
const { craftedRecipeIds } = state.exploration;
state.exploration.craftedGoldMultiplier = defaultRecipes.filter((recipe) => {
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "gold_income";
}).reduce((multiplier, recipe) => {
return multiplier * recipe.bonus.value;
}, 1);
state.exploration.craftedEssenceMultiplier = defaultRecipes.filter((recipe) => {
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "essence_income";
}).reduce((multiplier, recipe) => {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
return multiplier * recipe.bonus.value;
}, 1);
state.exploration.craftedClickMultiplier = defaultRecipes.filter((recipe) => {
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "click_power";
}).reduce((multiplier, recipe) => {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
return multiplier * recipe.bonus.value;
}, 1);
state.exploration.craftedCombatMultiplier = defaultRecipes.filter((recipe) => {
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "combat_power";
}).reduce((multiplier, recipe) => {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
return multiplier * recipe.bonus.value;
}, 1);
return craftedRecipeIds.length;
};
/* eslint-enable stylistic/max-len -- Re-enable after long lines */
/* 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;
achievementsPatched: number;
adventurersAdded: number;
adventurerStatsPatched: number;
bossesAdded: number;
bossesPatched: number;
bossRewardsPatched: number;
craftingRecipesReapplied: number;
equipmentAdded: number;
equipmentPatched: number;
explorationAreasAdded: number;
questRewardsPatched: number;
questsAdded: number;
questsPatched: number;
upgradesAdded: number;
upgradesPatched: number;
zonesAdded: number;
zonesPatched: number;
} => {
const adventurerStatsPatched = patchAdventurerStats(state);
const questsPatched = patchQuestStats(state);
const bossesPatched = patchBossStats(state);
const zonesPatched = patchZoneStats(state);
const upgradesPatched = patchUpgradeStats(state);
const equipmentPatched = patchEquipmentStats(state);
const achievementsPatched = patchAchievementStats(state);
const craftingRecipesReapplied = recomputeCraftingMultipliers(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,
achievementsPatched,
adventurerStatsPatched,
adventurersAdded,
bossRewardsPatched,
bossesAdded,
bossesPatched,
craftingRecipesReapplied,
equipmentAdded,
equipmentPatched,
explorationAreasAdded,
questRewardsPatched,
questsAdded,
questsPatched,
upgradesAdded,
upgradesPatched,
zonesAdded,
zonesPatched,
};
};
/* eslint-enable stylistic/max-len -- Re-enable after long lines */
@@ -741,13 +1025,23 @@ debugRouter.post("/sync-new-content", async(context) => {
const {
achievementsAdded,
achievementsPatched,
adventurersAdded,
adventurerStatsPatched,
bossesAdded,
bossesPatched,
bossRewardsPatched,
craftingRecipesReapplied,
equipmentAdded,
equipmentPatched,
explorationAreasAdded,
questRewardsPatched,
questsAdded,
questsPatched,
upgradesAdded,
upgradesPatched,
zonesAdded,
zonesPatched,
} = syncNewContent(state);
const updatedAt = Date.now();
@@ -765,15 +1059,25 @@ debugRouter.post("/sync-new-content", async(context) => {
return context.json({
achievementsAdded,
achievementsPatched,
adventurerStatsPatched,
adventurersAdded,
bossRewardsPatched,
bossesAdded,
bossesPatched,
craftingRecipesReapplied,
equipmentAdded,
equipmentPatched,
explorationAreasAdded,
questRewardsPatched,
questsAdded,
questsPatched,
signature,
state,
upgradesAdded,
upgradesPatched,
zonesAdded,
zonesPatched,
});
} catch (error) {
void logger.error(