From 4c297f1ce174b7c3d083ec590e01e2758f113ffe Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 25 Mar 2026 16:47:53 -0700 Subject: [PATCH] fix: resolve sync inflation, signature mismatch, CP accuracy, auto-buy cap, unlock hints - #147: Guard all patch functions with hasChanged before incrementing sync counter to prevent inflation on no-op patches - #148: Clear stale HMAC signature after each boss fight so subsequent auto-saves do not send a mismatched signature - #146: Auto-unlock adventurer-specific upgrades in applyTick when their adventurer count > 0; show recruit hint in upgrade panel - #149: Add Essence/s row to resource bar dropdown - #150: Fix broken auto-quest CP reduce formula; centralise via computePartyCombatPower which applies all multipliers correctly - #151: Cap auto-buy at 100 for non-max-tier adventurers; max tier (highest level unlocked) remains uncapped - #152: Export computePartyCombatPower from tick, applying global upgrades, prestige, equipment, set bonuses, echo, crafted, and companion multipliers; use it in resource bar and boss panel --- apps/api/src/routes/debug.ts | 99 +++++++++++- apps/web/src/components/game/bossPanel.tsx | 85 ++-------- apps/web/src/components/game/upgradePanel.tsx | 18 +++ apps/web/src/components/ui/resourceBar.tsx | 21 ++- apps/web/src/context/gameContext.tsx | 35 ++++- apps/web/src/engine/tick.ts | 145 ++++++++++++++++++ 6 files changed, 315 insertions(+), 88 deletions(-) diff --git a/apps/api/src/routes/debug.ts b/apps/api/src/routes/debug.ts index 080e5f5..b50318f 100644 --- a/apps/api/src/routes/debug.ts +++ b/apps/api/src/routes/debug.ts @@ -642,6 +642,14 @@ const patchAdventurerStats = (state: GameState): number => { if (defaultAdventurer === undefined) { continue; } + const hasChanged + = 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; savedAdventurer.baseCost = defaultAdventurer.baseCost; savedAdventurer.class = defaultAdventurer.class; savedAdventurer.combatPower = defaultAdventurer.combatPower; @@ -649,7 +657,9 @@ const patchAdventurerStats = (state: GameState): number => { savedAdventurer.goldPerSecond = defaultAdventurer.goldPerSecond; savedAdventurer.level = defaultAdventurer.level; savedAdventurer.name = defaultAdventurer.name; - patched = patched + 1; + if (hasChanged) { + patched = patched + 1; + } } return patched; }; @@ -670,6 +680,15 @@ const patchQuestStats = (state: GameState): number => { if (defaultQuest === undefined) { continue; } + const savedPrereqs = JSON.stringify(savedQuest.prerequisiteIds); + const defaultPrereqs = JSON.stringify(defaultQuest.prerequisiteIds); + const hasChanged + = savedQuest.name !== defaultQuest.name + || savedQuest.description !== defaultQuest.description + || savedQuest.durationSeconds !== defaultQuest.durationSeconds + || savedPrereqs !== defaultPrereqs + || savedQuest.zoneId !== defaultQuest.zoneId + || savedQuest.combatPowerRequired !== defaultQuest.combatPowerRequired; savedQuest.name = defaultQuest.name; savedQuest.description = defaultQuest.description; savedQuest.durationSeconds = defaultQuest.durationSeconds; @@ -678,7 +697,9 @@ const patchQuestStats = (state: GameState): number => { if (defaultQuest.combatPowerRequired !== undefined) { savedQuest.combatPowerRequired = defaultQuest.combatPowerRequired; } - patched = patched + 1; + if (hasChanged) { + patched = patched + 1; + } } return patched; }; @@ -689,6 +710,7 @@ const patchQuestStats = (state: GameState): number => { * @param state - The player's current game state (mutated in place). * @returns The number of boss entries whose stats were updated. */ +/* eslint-disable-next-line complexity, max-statements -- Comparing many boss stat fields for change detection */ const patchBossStats = (state: GameState): number => { const defaultBossMap = new Map(defaultBosses.map((boss) => { return [ boss.id, boss ] as const; @@ -699,6 +721,20 @@ const patchBossStats = (state: GameState): number => { if (defaultBoss === undefined) { continue; } + const savedRewards = JSON.stringify(savedBoss.equipmentRewards); + const defaultRewards = JSON.stringify(defaultBoss.equipmentRewards); + const hasChanged + = 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 + || savedRewards !== defaultRewards + || savedBoss.prestigeRequirement !== defaultBoss.prestigeRequirement + || savedBoss.zoneId !== defaultBoss.zoneId + || savedBoss.bountyRunestones !== defaultBoss.bountyRunestones; savedBoss.name = defaultBoss.name; savedBoss.description = defaultBoss.description; savedBoss.maxHp = defaultBoss.maxHp; @@ -710,7 +746,9 @@ const patchBossStats = (state: GameState): number => { savedBoss.prestigeRequirement = defaultBoss.prestigeRequirement; savedBoss.zoneId = defaultBoss.zoneId; savedBoss.bountyRunestones = defaultBoss.bountyRunestones; - patched = patched + 1; + if (hasChanged) { + patched = patched + 1; + } } return patched; }; @@ -731,12 +769,20 @@ const patchZoneStats = (state: GameState): number => { if (defaultZone === undefined) { continue; } + const hasChanged + = savedZone.name !== defaultZone.name + || savedZone.description !== defaultZone.description + || savedZone.emoji !== defaultZone.emoji + || savedZone.unlockBossId !== defaultZone.unlockBossId + || savedZone.unlockQuestId !== defaultZone.unlockQuestId; savedZone.name = defaultZone.name; savedZone.description = defaultZone.description; savedZone.emoji = defaultZone.emoji; savedZone.unlockBossId = defaultZone.unlockBossId; savedZone.unlockQuestId = defaultZone.unlockQuestId; - patched = patched + 1; + if (hasChanged) { + patched = patched + 1; + } } return patched; }; @@ -747,6 +793,7 @@ const patchZoneStats = (state: GameState): number => { * @param state - The player's current game state (mutated in place). * @returns The number of upgrade entries whose stats were updated. */ +/* eslint-disable-next-line complexity -- Comparing many upgrade stat fields for change detection */ const patchUpgradeStats = (state: GameState): number => { const defaultUpgradeMap = new Map(defaultUpgrades.map((upgrade) => { return [ upgrade.id, upgrade ] as const; @@ -757,6 +804,15 @@ const patchUpgradeStats = (state: GameState): number => { if (defaultUpgrade === undefined) { continue; } + const hasChanged + = savedUpgrade.name !== defaultUpgrade.name + || savedUpgrade.description !== defaultUpgrade.description + || savedUpgrade.target !== defaultUpgrade.target + || savedUpgrade.adventurerId !== defaultUpgrade.adventurerId + || savedUpgrade.multiplier !== defaultUpgrade.multiplier + || savedUpgrade.costGold !== defaultUpgrade.costGold + || savedUpgrade.costEssence !== defaultUpgrade.costEssence + || savedUpgrade.costCrystals !== defaultUpgrade.costCrystals; savedUpgrade.name = defaultUpgrade.name; savedUpgrade.description = defaultUpgrade.description; savedUpgrade.target = defaultUpgrade.target; @@ -767,7 +823,9 @@ const patchUpgradeStats = (state: GameState): number => { savedUpgrade.costGold = defaultUpgrade.costGold; savedUpgrade.costEssence = defaultUpgrade.costEssence; savedUpgrade.costCrystals = defaultUpgrade.costCrystals; - patched = patched + 1; + if (hasChanged) { + patched = patched + 1; + } } return patched; }; @@ -778,6 +836,7 @@ const patchUpgradeStats = (state: GameState): number => { * @param state - The player's current game state (mutated in place). * @returns The number of equipment entries whose stats were updated. */ +/* eslint-disable-next-line complexity, max-statements -- Comparing many equipment stat fields for change detection */ const patchEquipmentStats = (state: GameState): number => { const defaultEquipmentMap = new Map(defaultEquipment.map((item) => { return [ item.id, item ] as const; @@ -788,6 +847,18 @@ const patchEquipmentStats = (state: GameState): number => { if (defaultItem === undefined) { continue; } + const savedBonus = JSON.stringify(savedItem.bonus); + const defaultBonus = JSON.stringify(defaultItem.bonus); + const savedCost = JSON.stringify(savedItem.cost); + const defaultCost = JSON.stringify(defaultItem.cost); + const hasChanged + = savedItem.name !== defaultItem.name + || savedItem.description !== defaultItem.description + || savedItem.type !== defaultItem.type + || savedItem.rarity !== defaultItem.rarity + || savedBonus !== defaultBonus + || savedCost !== defaultCost + || savedItem.setId !== defaultItem.setId; savedItem.name = defaultItem.name; savedItem.description = defaultItem.description; savedItem.type = defaultItem.type; @@ -799,7 +870,9 @@ const patchEquipmentStats = (state: GameState): number => { if (defaultItem.setId !== undefined) { savedItem.setId = defaultItem.setId; } - patched = patched + 1; + if (hasChanged) { + patched = patched + 1; + } } return patched; }; @@ -820,6 +893,16 @@ const patchAchievementStats = (state: GameState): number => { if (defaultAchievement === undefined) { continue; } + const savedCondition = JSON.stringify(savedAchievement.condition); + const defaultCondition = JSON.stringify(defaultAchievement.condition); + const savedReward = JSON.stringify(savedAchievement.reward); + const defaultReward = JSON.stringify(defaultAchievement.reward); + const hasChanged + = savedAchievement.name !== defaultAchievement.name + || savedAchievement.description !== defaultAchievement.description + || savedAchievement.icon !== defaultAchievement.icon + || savedCondition !== defaultCondition + || savedReward !== defaultReward; savedAchievement.name = defaultAchievement.name; savedAchievement.description = defaultAchievement.description; savedAchievement.icon = defaultAchievement.icon; @@ -827,7 +910,9 @@ const patchAchievementStats = (state: GameState): number => { if (defaultAchievement.reward !== undefined) { savedAchievement.reward = { ...defaultAchievement.reward }; } - patched = patched + 1; + if (hasChanged) { + patched = patched + 1; + } } return patched; }; diff --git a/apps/web/src/components/game/bossPanel.tsx b/apps/web/src/components/game/bossPanel.tsx index e676142..3167a28 100644 --- a/apps/web/src/components/game/bossPanel.tsx +++ b/apps/web/src/components/game/bossPanel.tsx @@ -11,10 +11,11 @@ /* eslint-disable max-lines -- Boss panel with sub-component and helper function */ import { type JSX, useState } from "react"; import { useGame } from "../../context/gameContext.js"; +import { computePartyCombatPower } from "../../engine/tick.js"; import { cdnImage } from "../../utils/cdn.js"; import { LockToggle } from "../ui/lockToggle.js"; import { ZoneSelector } from "./zoneSelector.js"; -import type { Boss, GameState } from "@elysium/types"; +import type { Boss } from "@elysium/types"; interface BossCardProperties { readonly boss: Boss; @@ -157,72 +158,6 @@ const BossCard = ({ ); }; -/** - * Computes party DPS and HP from the current game state. - * @param state - The full game state. - * @returns The computed party DPS and HP values. - */ -const computePartyStats = ( - state: GameState, -): { - partyDps: number; - partyHp: number; -} => { - const { upgrades, adventurers, equipment, prestige } = state; - let globalMultiplier = 1; - for (const upgrade of upgrades) { - const { purchased, target, multiplier } = upgrade; - if (purchased && target === "global") { - globalMultiplier = globalMultiplier * multiplier; - } - } - const prestigeBonus = prestige.count * 0.1; - const prestigeMultiplier = 1 + prestigeBonus; - const equipmentCombatMultiplier = equipment. - filter((item) => { - return item.equipped && item.bonus.combatMultiplier !== undefined; - }). - reduce((multiplier, item) => { - return multiplier * (item.bonus.combatMultiplier ?? 1); - }, 1); - - let partyDps = 0; - let partyHp = 0; - for (const adventurer of adventurers) { - const { count, id: adventurerId, combatPower, level } = adventurer; - if (count === 0) { - continue; - } - let adventurerMultiplier = 1; - for (const upgrade of upgrades) { - const { - purchased, - target, - multiplier, - adventurerId: upgradeAdventurerId, - } = upgrade; - if ( - purchased - && target === "adventurer" - && upgradeAdventurerId === adventurerId - ) { - adventurerMultiplier = adventurerMultiplier * multiplier; - } - } - const dps - = combatPower - * count - * adventurerMultiplier - * globalMultiplier - * prestigeMultiplier; - partyDps = partyDps + dps; - const hp = level * 50 * count; - partyHp = partyHp + hp; - } - partyDps = partyDps * equipmentCombatMultiplier; - return { partyDps, partyHp }; -}; - /** * Renders the boss panel with zone selection and boss list. * @returns The JSX element. @@ -266,7 +201,14 @@ const BossPanel = (): JSX.Element => { void handleChallenge(bossId); } - const { zones, bosses, quests, autoBoss, prestige: playerPrestige } = state; + const { + adventurers, + autoBoss, + bosses, + prestige: playerPrestige, + quests, + zones, + } = state; const activeZone = zones.find((zone) => { return zone.id === activeZoneId; @@ -349,7 +291,12 @@ const BossPanel = (): JSX.Element => { } const autoBossOn = autoBoss === true; - const { partyDps, partyHp } = computePartyStats(state); + const partyDps = computePartyCombatPower(state); + let partyHp = 0; + for (const { level, count } of adventurers) { + // eslint-disable-next-line stylistic/no-mixed-operators -- level * 50 * count is clear + partyHp = partyHp + level * 50 * count; + } const { count: prestigeCount } = playerPrestige; return ( diff --git a/apps/web/src/components/game/upgradePanel.tsx b/apps/web/src/components/game/upgradePanel.tsx index 19a9117..63db25c 100644 --- a/apps/web/src/components/game/upgradePanel.tsx +++ b/apps/web/src/components/game/upgradePanel.tsx @@ -7,6 +7,8 @@ /* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */ /* eslint-disable max-lines-per-function -- Complex component with many render paths */ /* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */ +/* eslint-disable max-statements -- UpgradePanel builds hints from three sources */ +/* eslint-disable max-lines -- Upgrade panel with sub-component exceeds line limit */ import { type JSX, useState } from "react"; import { useGame } from "../../context/gameContext.js"; import { cdnImage } from "../../utils/cdn.js"; @@ -238,6 +240,22 @@ const UpgradePanel = (): JSX.Element => { } } } + for (const upgrade of locked) { + if ( + !upgradeUnlockHints.has(upgrade.id) + && upgrade.adventurerId !== undefined + ) { + const adventurerForHint = adventurers.find((a) => { + return a.id === upgrade.adventurerId; + }); + if (adventurerForHint !== undefined) { + upgradeUnlockHints.set( + upgrade.id, + `🗡️ Recruit: ${adventurerForHint.name}`, + ); + } + } + } function handleToggle(): void { setShowLocked((current) => { diff --git a/apps/web/src/components/ui/resourceBar.tsx b/apps/web/src/components/ui/resourceBar.tsx index cb76ba0..1743a6f 100644 --- a/apps/web/src/components/ui/resourceBar.tsx +++ b/apps/web/src/components/ui/resourceBar.tsx @@ -10,7 +10,12 @@ /* eslint-disable complexity -- Many conditional resource and badge render paths */ import { useState, type FocusEvent, type JSX } from "react"; import { useGame } from "../../context/gameContext.js"; -import { RESOURCE_CAP, computeGoldPerSecond } from "../../engine/tick.js"; +import { + RESOURCE_CAP, + computeEssencePerSecond, + computeGoldPerSecond, + computePartyCombatPower, +} from "../../engine/tick.js"; import type { Resource } from "@elysium/types"; interface ResourceBarProperties { @@ -83,12 +88,11 @@ const ResourceBar = ({ const { gold, essence, crystals } = resources; let partyCombatPower = 0; let goldPerSecond = 0; + let essencePerSecond = 0; if (state !== null) { - for (const adventurer of state.adventurers) { - const contribution = adventurer.combatPower * adventurer.count; - partyCombatPower = partyCombatPower + contribution; - } + partyCombatPower = computePartyCombatPower(state); goldPerSecond = computeGoldPerSecond(state); + essencePerSecond = computeEssencePerSecond(state); } let avatarUrl: string | null = null; @@ -182,6 +186,13 @@ const ResourceBar = ({ {"Gold/s"} +
+ {"⚡"} + + {formatNumber(essencePerSecond)} + + {"Essence/s"} +
diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index fd89b84..22b2ec3 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -58,6 +58,7 @@ import { RESOURCE_CAP, applyTick, calculateClickPower, + computePartyCombatPower, } from "../engine/tick.js"; import { updateChallengeProgress } from "../utils/dailyChallenges.js"; import { formatNumber as formatNumberUtil } from "../utils/format.js"; @@ -1078,11 +1079,7 @@ export const GameProvider = ({ return q.status === "active"; }); if (!hasActiveQuest) { - // eslint-disable-next-line unicorn/no-array-reduce -- Need the total! - const partyCombatPower = next.adventurers.reduce((total, a) => { - const power = total + a.combatPower; - return power * a.count; - }, 0); + const partyCombatPower = computePartyCombatPower(next); const zoneOrder = new Map( next.zones.map((z, index) => { return [ z.id, index ]; @@ -1120,14 +1117,31 @@ export const GameProvider = ({ next.autoAdventurer === true && next.prestige.purchasedUpgradeIds.includes("auto_adventurer") ) { + const maxAdventurerLevel = Math.max( + ...next.adventurers. + filter((a) => { + return a.unlocked; + }). + map((a) => { + return a.level; + }), + ); + const autoBuyCap = 100; const [ bestAdventurer ] = next.adventurers. filter((adventurer) => { const cost = adventurer.baseCost * Math.pow(1.15, adventurer.count); - return adventurer.unlocked && next.resources.gold >= cost; + const isMaxTier = adventurer.level === maxAdventurerLevel; + const withinCap + = isMaxTier || adventurer.count < autoBuyCap; + return ( + adventurer.unlocked + && next.resources.gold >= cost + && withinCap + ); }). sort((adventurerA, adventurerB) => { - return adventurerB.combatPower - adventurerA.combatPower; + return adventurerB.level - adventurerA.level; }); if (bestAdventurer !== undefined) { const purchaseCost @@ -1346,6 +1360,13 @@ export const GameProvider = ({ } return afterBoss; }); + + /* + * Boss fight modifies server state; clear stale signature so + * the next pre-save or auto-save does not send a mismatched one. + */ + signatureReference.current = null; + localStorage.removeItem("elysium_save_signature"); setAutoBossLastResult({ at: Date.now(), bossName: bossName, diff --git a/apps/web/src/engine/tick.ts b/apps/web/src/engine/tick.ts index c80f8e5..c8b42a4 100644 --- a/apps/web/src/engine/tick.ts +++ b/apps/web/src/engine/tick.ts @@ -195,6 +195,138 @@ export const computeGoldPerSecond = (state: GameState): number => { return goldPerSecond; }; +/** + * Computes the current essence per second for the given game state, + * applying all relevant multipliers (upgrades, prestige, echo, crafted, companion). + * @param state - The current game state. + * @returns The total essence per second. + */ +export const computeEssencePerSecond = (state: GameState): number => { + const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1; + const craftedEssenceMultiplier + = state.exploration?.craftedEssenceMultiplier ?? 1; + const companionBonus = getActiveCompanionBonus( + state.companions?.activeCompanionId, + state.companions?.unlockedCompanionIds ?? [], + ); + const companionEssenceMult + = companionBonus?.type === "essenceIncome" + ? 1 + companionBonus.value + : 1; + + let essencePerSecond = 0; + for (const adventurer of state.adventurers) { + if (!adventurer.unlocked || adventurer.count === 0) { + continue; + } + const upgradeMultiplier = state.upgrades. + filter((upgrade) => { + const isGlobal = upgrade.target === "global"; + const isThisAdventurer + = upgrade.target === "adventurer" + && upgrade.adventurerId === adventurer.id; + return upgrade.purchased && (isGlobal || isThisAdventurer); + }). + reduce((mult, upgrade) => { + return mult * upgrade.multiplier; + }, 1); + const contribution + = adventurer.essencePerSecond + * adventurer.count + * upgradeMultiplier + * state.prestige.productionMultiplier + * runestonesEssence + * craftedEssenceMultiplier + * companionEssenceMult; + essencePerSecond = essencePerSecond + contribution; + } + return essencePerSecond; +}; + +/** + * Computes the party's total combat power, applying all active multipliers + * (upgrades, prestige, equipment, set bonuses, echo, crafted, companion). + * This mirrors the server-side calculatePartyStats in boss.ts. + * @param state - The current game state. + * @returns The total party combat power. + */ +export const computePartyCombatPower = (state: GameState): number => { + let globalMultiplier = 1; + for (const upgrade of state.upgrades) { + if (upgrade.purchased && upgrade.target === "global") { + globalMultiplier = globalMultiplier * upgrade.multiplier; + } + } + + // eslint-disable-next-line stylistic/no-mixed-operators -- prestige count * factor is clear + const prestigeMultiplier = 1 + state.prestige.count * 0.1; + + const equipmentCombatMultiplier = state.equipment. + filter((item) => { + return item.equipped && item.bonus.combatMultiplier !== undefined; + }). + reduce((mult, item) => { + return mult * (item.bonus.combatMultiplier ?? 1); + }, 1); + + const equippedItemIds = state.equipment. + filter((item) => { + return item.equipped; + }). + map((item) => { + return item.id; + }); + const { combatMultiplier: setCombatMultiplier } = computeSetBonuses( + equippedItemIds, + EQUIPMENT_SETS, + ); + + const echoCombatMultiplier + = state.transcendence?.echoCombatMultiplier ?? 1; + const craftedCombatMultiplier + = state.exploration?.craftedCombatMultiplier ?? 1; + + const companionBonus = getActiveCompanionBonus( + state.companions?.activeCompanionId, + state.companions?.unlockedCompanionIds ?? [], + ); + const companionCombatMult + = companionBonus?.type === "bossDamage" + ? 1 + companionBonus.value + : 1; + + let partyCombatPower = 0; + for (const adventurer of state.adventurers) { + if (adventurer.count === 0) { + continue; + } + let adventurerMultiplier = 1; + for (const upgrade of state.upgrades) { + if ( + upgrade.purchased + && upgrade.target === "adventurer" + && upgrade.adventurerId === adventurer.id + ) { + adventurerMultiplier = adventurerMultiplier * upgrade.multiplier; + } + } + const contribution + = adventurer.combatPower + * adventurer.count + * adventurerMultiplier + * globalMultiplier + * prestigeMultiplier; + partyCombatPower = partyCombatPower + contribution; + } + + return partyCombatPower + * equipmentCombatMultiplier + * setCombatMultiplier + * echoCombatMultiplier + * craftedCombatMultiplier + * companionCombatMult; +}; + /** * Pure function — applies one game tick to the state. * DeltaSeconds: time elapsed since last tick. @@ -469,6 +601,19 @@ export const applyTick = ( challengeCrystals = result.crystalsAwarded; } + // Auto-unlock adventurer-specific upgrades when their adventurer is recruited + updatedUpgrades = updatedUpgrades.map((upgrade) => { + if (upgrade.unlocked || upgrade.adventurerId === undefined) { + return upgrade; + } + const adventurer = updatedAdventurers.find((a) => { + return a.id === upgrade.adventurerId; + }); + return adventurer !== undefined && adventurer.count > 0 + ? { ...upgrade, unlocked: true } + : upgrade; + }); + const goldValue = capResource(state.resources.gold + goldGained + questGold); const essenceValue = capResource( state.resources.essence + essenceGained + questEssence,