diff --git a/apps/api/src/routes/debug.ts b/apps/api/src/routes/debug.ts index fb65e35..080e5f5 100644 --- a/apps/api/src/routes/debug.ts +++ b/apps/api/src/routes/debug.ts @@ -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( diff --git a/apps/api/test/routes/debug.spec.ts b/apps/api/test/routes/debug.spec.ts index 1765f59..2c8d051 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 () => { @@ -718,6 +750,32 @@ describe("debug route", () => { expect(res.status).toBe(200); }); + it("patches upgrade adventurerId when default has it set", async () => { + const state = makeState({ + upgrades: [{ id: "peasant_1", purchased: false, unlocked: false, multiplier: 0.1, name: "Old", description: "Old", target: "click", costGold: 1, costEssence: 0, costCrystals: 0 }] as GameState["upgrades"], + }); + 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 { state: GameState }; + const upgrade = body.state.upgrades.find((u) => u.id === "peasant_1"); + expect(upgrade?.adventurerId).toBe("peasant"); + }); + + it("patches equipment cost when default has it set", async () => { + const state = makeState({ + equipment: [{ id: "shadow_dagger", owned: false, equipped: false, name: "Old", description: "Old", type: "weapon", rarity: "common", bonus: {} }] as GameState["equipment"], + }); + 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 { state: GameState }; + const item = body.state.equipment.find((e) => e.id === "shadow_dagger"); + expect(item?.cost).toBeDefined(); + }); + it("computes HMAC signature when ANTI_CHEAT_SECRET is set", async () => { process.env.ANTI_CHEAT_SECRET = "test_secret"; const state = makeState(); @@ -741,6 +799,215 @@ describe("debug route", () => { const res = await syncNewContent(); expect(res.status).toBe(500); }); + + it("patches quest stats when saved quest has outdated fields", async () => { + const state = makeState({ + quests: [{ id: "first_steps", status: "available", rewards: [], durationSeconds: 1, name: "Old Name", description: "Old", prerequisiteIds: [], zoneId: "old_zone", combatPowerRequired: 0 }] as GameState["quests"], + }); + 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 { questsPatched: number; state: GameState }; + expect(body.questsPatched).toBe(1); + const quest = body.state.quests.find((q) => q.id === "first_steps"); + expect(quest?.name).not.toBe("Old Name"); + expect(quest?.durationSeconds).not.toBe(1); + expect(quest?.status).toBe("available"); + }); + + it("skips quest stat patching for quests not in defaults", async () => { + const state = makeState({ + quests: [{ id: "nonexistent_quest_xyz", status: "available", rewards: [], durationSeconds: 1, name: "Ghost", description: "Old", prerequisiteIds: [], zoneId: "old_zone", combatPowerRequired: 0 }] as GameState["quests"], + }); + 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 { questsPatched: number }; + expect(body.questsPatched).toBe(0); + }); + + it("patches boss stats when saved boss has outdated fields", async () => { + const state = makeState({ + bosses: [{ id: "troll_king", status: "available", currentHp: 100, maxHp: 1, upgradeRewards: [], bountyRunestonesClaimed: false, damagePerSecond: 1, goldReward: 1, essenceReward: 1, crystalReward: 1, equipmentRewards: [], prestigeRequirement: 0, zoneId: "old_zone", bountyRunestones: 0, name: "Old Name", description: "Old" }] as GameState["bosses"], + }); + 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 { bossesPatched: number; state: GameState }; + expect(body.bossesPatched).toBe(1); + const boss = body.state.bosses.find((b) => b.id === "troll_king"); + expect(boss?.maxHp).not.toBe(1); + expect(boss?.name).not.toBe("Old Name"); + expect(boss?.status).toBe("available"); + expect(boss?.currentHp).toBe(100); + }); + + it("skips boss stat patching for bosses not in defaults", async () => { + const state = makeState({ + bosses: [{ id: "nonexistent_boss_xyz", status: "available", currentHp: 100, maxHp: 1, upgradeRewards: [], bountyRunestonesClaimed: false, damagePerSecond: 1, goldReward: 1, essenceReward: 1, crystalReward: 1, equipmentRewards: [], prestigeRequirement: 0, zoneId: "old_zone", bountyRunestones: 0, name: "Ghost", description: "Old" }] as GameState["bosses"], + }); + 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 { bossesPatched: number }; + expect(body.bossesPatched).toBe(0); + }); + + it("patches zone stats when saved zone has outdated fields", async () => { + const state = makeState({ + zones: [{ id: "verdant_vale", status: "unlocked", name: "Old Name", description: "Old", emoji: "❓", unlockBossId: "wrong_boss", unlockQuestId: "wrong_quest" }] as GameState["zones"], + }); + 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 { zonesPatched: number; state: GameState }; + expect(body.zonesPatched).toBe(1); + const zone = body.state.zones.find((z) => z.id === "verdant_vale"); + expect(zone?.name).not.toBe("Old Name"); + expect(zone?.status).toBe("unlocked"); + }); + + it("skips zone stat patching for zones not in defaults", async () => { + const state = makeState({ + zones: [{ id: "nonexistent_zone_xyz", status: "unlocked", name: "Ghost", description: "Old", emoji: "❓", unlockBossId: null, unlockQuestId: null }] as GameState["zones"], + }); + 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 { zonesPatched: number }; + expect(body.zonesPatched).toBe(0); + }); + + it("patches upgrade stats when saved upgrade has outdated fields", async () => { + const state = makeState({ + upgrades: [{ id: "click_2", purchased: false, unlocked: true, multiplier: 0.1, name: "Old Name", description: "Old", target: "click", adventurerId: undefined, costGold: 1, costEssence: 0, costCrystals: 0 }] as GameState["upgrades"], + }); + 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 { upgradesPatched: number; state: GameState }; + expect(body.upgradesPatched).toBe(1); + const upgrade = body.state.upgrades.find((u) => u.id === "click_2"); + expect(upgrade?.multiplier).not.toBe(0.1); + expect(upgrade?.name).not.toBe("Old Name"); + expect(upgrade?.purchased).toBe(false); + expect(upgrade?.unlocked).toBe(true); + }); + + it("skips upgrade stat patching for upgrades not in defaults", async () => { + const state = makeState({ + upgrades: [{ id: "nonexistent_upgrade_xyz", purchased: false, unlocked: false, multiplier: 0.1, name: "Ghost", description: "Old", target: "click", adventurerId: undefined, costGold: 1, costEssence: 0, costCrystals: 0 }] as GameState["upgrades"], + }); + 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 { upgradesPatched: number }; + expect(body.upgradesPatched).toBe(0); + }); + + it("patches equipment stats when saved item has outdated fields", async () => { + const state = makeState({ + equipment: [{ id: "iron_sword", owned: true, equipped: false, name: "Rusty Sword", description: "Old", type: "weapon", rarity: "common", bonus: { type: "click_power", value: 0 }, cost: undefined, setId: undefined }] as GameState["equipment"], + }); + 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 { equipmentPatched: number; state: GameState }; + expect(body.equipmentPatched).toBe(1); + const item = body.state.equipment.find((e) => e.id === "iron_sword"); + expect(item?.name).not.toBe("Rusty Sword"); + expect(item?.owned).toBe(true); + expect(item?.equipped).toBe(false); + }); + + it("skips equipment stat patching for items not in defaults", async () => { + const state = makeState({ + equipment: [{ id: "nonexistent_item_xyz", owned: false, equipped: false, name: "Ghost Sword", description: "Old", type: "weapon", rarity: "common", bonus: { type: "click_power", value: 0 }, cost: undefined, setId: undefined }] as GameState["equipment"], + }); + 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 { equipmentPatched: number }; + expect(body.equipmentPatched).toBe(0); + }); + + it("patches achievement stats when saved achievement has outdated fields", async () => { + const state = makeState({ + achievements: [{ id: "first_click", unlockedAt: null, name: "Old Name", description: "Old", icon: "❓", condition: { type: "totalClicks", amount: 999 }, reward: undefined }] as GameState["achievements"], + }); + 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 { achievementsPatched: number; state: GameState }; + expect(body.achievementsPatched).toBe(1); + const achievement = body.state.achievements.find((a) => a.id === "first_click"); + expect(achievement?.name).not.toBe("Old Name"); + expect(achievement?.condition.amount).not.toBe(999); + expect(achievement?.unlockedAt).toBeNull(); + }); + + it("skips achievement stat patching for achievements not in defaults", async () => { + const state = makeState({ + achievements: [{ id: "nonexistent_achievement_xyz", unlockedAt: null, name: "Ghost", description: "Old", icon: "❓", condition: { type: "totalClicks", amount: 1 }, reward: undefined }] as GameState["achievements"], + }); + 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 { achievementsPatched: number }; + expect(body.achievementsPatched).toBe(0); + }); + + it("recomputes crafting multipliers from craftedRecipeIds", async () => { + const state = makeState({ + exploration: { ...makeExploration(), craftedRecipeIds: ["heartwood_tincture"], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 }, + }); + 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 { craftingRecipesReapplied: number; state: GameState }; + expect(body.craftingRecipesReapplied).toBe(1); + expect(body.state.exploration?.craftedGoldMultiplier).toBeGreaterThan(1); + }); + + it("returns 0 for crafting recompute when exploration is undefined", async () => { + const state = makeState({ + exploration: undefined as unknown as GameState["exploration"], + }); + 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 { craftingRecipesReapplied: number }; + expect(body.craftingRecipesReapplied).toBe(0); + }); + + it("sets multipliers to 1 when craftedRecipeIds is empty", async () => { + const state = makeState({ + exploration: { ...makeExploration(), craftedRecipeIds: [], craftedGoldMultiplier: 5, craftedEssenceMultiplier: 5, craftedClickMultiplier: 5, craftedCombatMultiplier: 5 }, + }); + 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 { state: GameState }; + expect(body.state.exploration?.craftedGoldMultiplier).toBe(1); + expect(body.state.exploration?.craftedEssenceMultiplier).toBe(1); + expect(body.state.exploration?.craftedClickMultiplier).toBe(1); + expect(body.state.exploration?.craftedCombatMultiplier).toBe(1); + }); }); describe("POST /hard-reset", () => { diff --git a/apps/web/src/components/game/debugPanel.tsx b/apps/web/src/components/game/debugPanel.tsx index 6953a2f..1755290 100644 --- a/apps/web/src/components/game/debugPanel.tsx +++ b/apps/web/src/components/game/debugPanel.tsx @@ -13,16 +13,24 @@ 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; + achievementsPatched: number | undefined; + adventurersAdded: number | undefined; + adventurerStatsPatched: number | undefined; + bossesAdded: number | undefined; + bossesPatched: number | undefined; + bossRewardsPatched: number | undefined; + craftingRecipesReapplied: number | undefined; + equipmentAdded: number | undefined; + equipmentPatched: number | undefined; + explorationAreasAdded: number | undefined; + questRewardsPatched: number | undefined; + questsAdded: number | undefined; + questsPatched: number | undefined; + upgradesAdded: number | undefined; + upgradesPatched: number | undefined; + zonesAdded: number | undefined; + zonesPatched: number | undefined; } const safeNumber = (value: number | undefined): number => { @@ -43,9 +51,17 @@ 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)" ], + [ safeNumber(result.questsPatched), "quest stat(s) patched" ], + [ safeNumber(result.bossesPatched), "boss stat(s) patched" ], + [ safeNumber(result.zonesPatched), "zone stat(s) patched" ], + [ safeNumber(result.upgradesPatched), "upgrade stat(s) patched" ], + [ safeNumber(result.equipmentPatched), "equipment stat(s) patched" ], + [ safeNumber(result.achievementsPatched), "achievement stat(s) patched" ], + [ safeNumber(result.craftingRecipesReapplied), "crafting recipe(s) reapplied" ], ]; const parts = entries. filter(([ count ]) => { diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index 786bf69..f6c23fb 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -580,16 +580,24 @@ interface GameContextValue { * @returns Counts of what was added per content type. */ syncNewContent: ()=> Promise<{ - achievementsAdded: number; - adventurersAdded: number; - bossesAdded: number; - bossRewardsPatched: number; - equipmentAdded: number; - explorationAreasAdded: number; - questRewardsPatched: number; - questsAdded: number; - upgradesAdded: number; - zonesAdded: number; + achievementsAdded: number; + achievementsPatched: number; + adventurerStatsPatched: number; + adventurersAdded: number; + bossRewardsPatched: number; + bossesAdded: number; + bossesPatched: number; + craftingRecipesReapplied: number; + equipmentAdded: number; + equipmentPatched: number; + explorationAreasAdded: number; + questRewardsPatched: number; + questsAdded: number; + questsPatched: number; + upgradesAdded: number; + upgradesPatched: number; + zonesAdded: number; + zonesPatched: number; }>; /** @@ -2170,16 +2178,24 @@ export const GameProvider = ({ localStorage.setItem("elysium_save_signature", data.signature); } return { - achievementsAdded: data.achievementsAdded, - adventurersAdded: data.adventurersAdded, - bossRewardsPatched: data.bossRewardsPatched, - bossesAdded: data.bossesAdded, - equipmentAdded: data.equipmentAdded, - explorationAreasAdded: data.explorationAreasAdded, - questRewardsPatched: data.questRewardsPatched, - questsAdded: data.questsAdded, - upgradesAdded: data.upgradesAdded, - zonesAdded: data.zonesAdded, + achievementsAdded: data.achievementsAdded, + achievementsPatched: data.achievementsPatched, + adventurerStatsPatched: data.adventurerStatsPatched, + adventurersAdded: data.adventurersAdded, + bossRewardsPatched: data.bossRewardsPatched, + bossesAdded: data.bossesAdded, + bossesPatched: data.bossesPatched, + craftingRecipesReapplied: data.craftingRecipesReapplied, + equipmentAdded: data.equipmentAdded, + equipmentPatched: data.equipmentPatched, + explorationAreasAdded: data.explorationAreasAdded, + questRewardsPatched: data.questRewardsPatched, + questsAdded: data.questsAdded, + questsPatched: data.questsPatched, + upgradesAdded: data.upgradesAdded, + upgradesPatched: data.upgradesPatched, + zonesAdded: data.zonesAdded, + zonesPatched: data.zonesPatched, }; } catch (error_: unknown) { setError( @@ -2188,16 +2204,24 @@ export const GameProvider = ({ : "Failed to sync new content", ); return { - achievementsAdded: 0, - adventurersAdded: 0, - bossRewardsPatched: 0, - bossesAdded: 0, - equipmentAdded: 0, - explorationAreasAdded: 0, - questRewardsPatched: 0, - questsAdded: 0, - upgradesAdded: 0, - zonesAdded: 0, + achievementsAdded: 0, + achievementsPatched: 0, + adventurerStatsPatched: 0, + adventurersAdded: 0, + bossRewardsPatched: 0, + bossesAdded: 0, + bossesPatched: 0, + craftingRecipesReapplied: 0, + equipmentAdded: 0, + equipmentPatched: 0, + explorationAreasAdded: 0, + questRewardsPatched: 0, + questsAdded: 0, + questsPatched: 0, + upgradesAdded: 0, + upgradesPatched: 0, + zonesAdded: 0, + zonesPatched: 0, }; } }, []); diff --git a/packages/types/src/interfaces/api.ts b/packages/types/src/interfaces/api.ts index c099839..378cd73 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. */ @@ -513,6 +519,41 @@ interface SyncNewContentResponse { */ explorationAreasAdded: number; + /** + * Number of achievements whose stats were updated to match current defaults. + */ + achievementsPatched: number; + + /** + * Number of bosses whose stats were updated to match current defaults. + */ + bossesPatched: number; + + /** + * Number of crafted recipes whose multiplier contribution was reapplied during recompute. + */ + craftingRecipesReapplied: number; + + /** + * Number of equipment items whose stats were updated to match current defaults. + */ + equipmentPatched: number; + + /** + * Number of quests whose stats were updated to match current defaults. + */ + questsPatched: number; + + /** + * Number of upgrades whose stats were updated to match current defaults. + */ + upgradesPatched: number; + + /** + * Number of zones whose stats were updated to match current defaults. + */ + zonesPatched: number; + /** * HMAC-SHA256 signature of the updated state for anti-cheat chain continuity. */