/** * @file Game tick engine for Elysium. * @copyright nhcarrigan * @license Naomi's Public License * @author Naomi Carrigan */ /* eslint-disable @typescript-eslint/naming-convention -- Snake_case IDs and SCREAMING_SNAKE are conventional for game data */ /* eslint-disable complexity -- Tick engine is inherently complex with many game systems */ /* eslint-disable max-lines-per-function -- Tick engine processes many systems in one pass for performance */ /* eslint-disable max-statements -- Tick engine requires many state variables across all game systems */ /* eslint-disable max-lines -- Engine file necessarily exceeds line limit */ /* eslint-disable import/group-exports -- Exports appear alongside their definitions for readability */ /* eslint-disable import/exports-last -- Exports appear alongside their definitions for readability */ /* eslint-disable max-nested-callbacks -- Tick engine requires nested array operations for game logic */ import { type Achievement, type Equipment, type GameState, computeSetBonuses, getActiveCompanionBonus, } from "@elysium/types"; import { EQUIPMENT_SETS } from "../data/equipmentSets.js"; import { EXPLORATION_AREAS } from "../data/explorations.js"; import { updateChallengeProgress } from "../utils/dailyChallenges.js"; /** * Checks all achievements against the current game state and returns an updated * achievements array, marking newly-met conditions with the current timestamp. * @param state - The current game state to check achievements against. * @returns Updated achievements array with newly unlocked achievements timestamped. */ const checkAchievements = (state: GameState): Array => { const now = Date.now(); return state.achievements.map((achievement) => { if (achievement.unlockedAt !== null) { return achievement; } const { condition } = achievement; let met = false; switch (condition.type) { case "totalGoldEarned": met = state.player.lifetimeGoldEarned >= condition.amount; break; case "totalClicks": met = state.player.totalClicks >= condition.amount; break; case "bossesDefeated": met = state.bosses.filter((boss) => { return boss.status === "defeated"; }).length >= condition.amount; break; case "questsCompleted": met = state.quests.filter((quest) => { return quest.status === "completed"; }).length >= condition.amount; break; case "adventurerTotal": met = state.adventurers.reduce((sum, adventurer) => { return sum + adventurer.count; }, 0) >= condition.amount; break; case "prestigeCount": met = state.prestige.count >= condition.amount; break; case "equipmentOwned": met = state.equipment.filter((item) => { return item.owned; }).length >= condition.amount; break; default: /* V8 ignore next -- @preserve */ break; } return met ? { ...achievement, unlockedAt: now } : achievement; }); }; /** * Exponential base for the prestige combat multiplier: Math.pow(base, prestigeCount). * Replaces the former linear formula (1 + count * 0.1) to enable late-game zone progression. */ export const PRESTIGE_COMBAT_BASE = 4; /** * Maximum value any resource can accumulate to. Beyond this JS floats lose all useful precision. */ export const RESOURCE_CAP = 1e300; /** * Probability of quest failure per zone — scales from 10% (early game) to 40% (end game). * On failure the quest resets to "available" with no rewards; the player must wait the * full duration again on their next attempt. */ export const zoneFailureChance: Record = { abyssal_trench: 0.24, astral_void: 0.2, celestial_reaches: 0.22, cosmic_maelstrom: 0.4, crystalline_spire: 0.28, eternal_throne: 0.32, frozen_peaks: 0.14, infernal_court: 0.26, infinite_expanse: 0.36, primeval_sanctum: 0.4, primordial_chaos: 0.34, reality_forge: 0.38, shadow_marshes: 0.16, shattered_ruins: 0.12, the_absolute: 0.4, verdant_vale: 0.1, void_sanctum: 0.3, volcanic_depths: 0.18, }; /** * Caps a resource value at RESOURCE_CAP. * @param value - The resource value to cap. * @returns The capped value. */ const capResource = (value: number): number => { return Math.min(value, RESOURCE_CAP); }; /** * Pure function — applies one game tick to the state. * DeltaSeconds: time elapsed since last tick. * Returns a new GameState (does not mutate the original). * @param state - The current game state. * @param deltaSeconds - Time elapsed since last tick in seconds. * @returns A new GameState with the tick applied. */ /** * Computes the effective gold earned per second across all adventurers, * including all active multipliers (upgrades, prestige, equipment, etc.). * @param state - The current game state. * @returns Gold per second as a number. */ export const computeGoldPerSecond = (state: GameState): number => { const equippedItems: Array = state.equipment.filter((item) => { return item.equipped; }); const equipmentGoldMultiplier = equippedItems.reduce((mult, item) => { return mult * (item.bonus.goldMultiplier ?? 1); }, 1); const setGoldMultiplier = computeSetBonuses( equippedItems.map((item) => { return item.id; }), EQUIPMENT_SETS, ).goldMultiplier; const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1; const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1; const craftedGoldMultiplier = state.exploration?.craftedGoldMultiplier ?? 1; const companionBonus = getActiveCompanionBonus( state.companions?.activeCompanionId, state.companions?.unlockedCompanionIds ?? [], ); const companionGoldMult = companionBonus?.type === "passiveGold" ? 1 + companionBonus.value : 1; let goldPerSecond = 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.goldPerSecond * adventurer.count * upgradeMultiplier * state.prestige.productionMultiplier * runestonesIncome * echoIncome * equipmentGoldMultiplier * setGoldMultiplier * craftedGoldMultiplier * companionGoldMult; goldPerSecond = goldPerSecond + contribution; } 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 effective per-unit stats for a single adventurer type, * applying all active multipliers (upgrades, prestige, equipment, echo, * crafted, companion). The returned values represent what a single * adventurer of this type currently contributes per second, matching the * per-unit contribution used by computeGoldPerSecond and * computeEssencePerSecond. * @param state - The current game state. * @param adventurerId - The ID of the adventurer to compute stats for. * @returns Effective per-unit goldPerSecond, essencePerSecond, and combatPower. */ export const computeEffectiveAdventurerStats = ( state: GameState, adventurerId: string, ): { combatPower: number; essencePerSecond: number; goldPerSecond: number } => { const adventurer = state.adventurers.find((a) => { return a.id === adventurerId; }); /* V8 ignore next 3 -- @preserve */ if (adventurer === undefined) { return { combatPower: 0, essencePerSecond: 0, goldPerSecond: 0 }; } const upgradeMultiplier = state.upgrades. filter((upgrade) => { const isGlobal = upgrade.target === "global"; const isThisAdventurer = upgrade.target === "adventurer" && upgrade.adventurerId === adventurerId; return upgrade.purchased && (isGlobal || isThisAdventurer); }). reduce((mult, upgrade) => { return mult * upgrade.multiplier; }, 1); const equippedItems = state.equipment.filter((item) => { return item.equipped; }); const equipmentGoldMultiplier = equippedItems.reduce((mult, item) => { return mult * (item.bonus.goldMultiplier ?? 1); }, 1); const equipmentCombatMultiplier = equippedItems.reduce((mult, item) => { return mult * (item.bonus.combatMultiplier ?? 1); }, 1); const equippedItemIds = equippedItems.map((item) => { return item.id; }); const setBonuses = computeSetBonuses(equippedItemIds, EQUIPMENT_SETS); const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1; const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1; const prestigeCombatMultiplier = PRESTIGE_COMBAT_BASE ** state.prestige.count; const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1; const echoCombatMultiplier = state.transcendence?.echoCombatMultiplier ?? 1; const craftedGoldMultiplier = state.exploration?.craftedGoldMultiplier ?? 1; const craftedEssenceMultiplier = state.exploration?.craftedEssenceMultiplier ?? 1; const craftedCombatMultiplier = state.exploration?.craftedCombatMultiplier ?? 1; const companionBonus = getActiveCompanionBonus( state.companions?.activeCompanionId, state.companions?.unlockedCompanionIds ?? [], ); const companionGoldMult = companionBonus?.type === "passiveGold" ? 1 + companionBonus.value : 1; const companionEssenceMult = companionBonus?.type === "essenceIncome" ? 1 + companionBonus.value : 1; const companionCombatMult = companionBonus?.type === "bossDamage" ? 1 + companionBonus.value : 1; const goldPerSecond = adventurer.goldPerSecond * upgradeMultiplier * state.prestige.productionMultiplier * runestonesIncome * echoIncome * equipmentGoldMultiplier * setBonuses.goldMultiplier * craftedGoldMultiplier * companionGoldMult; const essencePerSecond = adventurer.essencePerSecond * upgradeMultiplier * state.prestige.productionMultiplier * runestonesEssence * craftedEssenceMultiplier * companionEssenceMult; const combatPower = adventurer.combatPower * upgradeMultiplier * prestigeCombatMultiplier * equipmentCombatMultiplier * setBonuses.combatMultiplier * echoCombatMultiplier * craftedCombatMultiplier * companionCombatMult; return { combatPower, essencePerSecond, goldPerSecond }; }; /** * 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 and is the * single source of truth for all combat-power checks in the client: * - Displayed as "Combat Power" in the resource bar * - Displayed as "Party DPS" in the boss panel * - Used to gate quest availability * Note: the active companion's bossDamage bonus is intentionally included * here, as it applies to the full combat power calculation (boss fights and * quest gating alike), matching the server-side behaviour. * @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; } } const prestigeMultiplier = PRESTIGE_COMBAT_BASE ** state.prestige.count; 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; }; const basePrestigeThreshold = 1_000_000; const runestonesPerPrestigeLevelClient = 15; const maxBaseRunestones = 200; /** * Computes the projected runestone reward if the player were to prestige right now. * Mirrors the server-side calculateRunestones formula exactly. * @param state - The current game state. * @returns The number of runestones the player would earn from a prestige now. */ export const computeProjectedRunestones = (state: GameState): number => { const { count, purchasedUpgradeIds } = state.prestige; const thresholdMult: number = state.transcendence?.echoPrestigeThresholdMultiplier ?? 1; const threshold = basePrestigeThreshold * Math.pow(count + 1, 2.5) * thresholdMult; const base = Math.min( Math.floor(Math.cbrt(state.player.totalGoldEarned / threshold)) * runestonesPerPrestigeLevelClient, maxBaseRunestones, ); const gain1Mult = purchasedUpgradeIds.includes("runestone_gain_1") ? 1.25 : 1; const gain2Mult = purchasedUpgradeIds.includes("runestone_gain_2") ? 1.5 : 1; const runestoneMult = gain1Mult * gain2Mult; const echoMult: number = state.transcendence?.echoPrestigeRunestoneMultiplier ?? 1; return Math.floor(base * runestoneMult * echoMult); }; /** * Pure function — applies one game tick to the state. * DeltaSeconds: time elapsed since last tick. * Returns a new GameState (does not mutate the original). * @param state - The current game state. * @param deltaSeconds - Time elapsed since last tick in seconds. * @returns A new GameState with the tick applied. */ export const applyTick = ( state: GameState, deltaSeconds: number, ): GameState => { const equippedItems: Array = state.equipment.filter((item) => { return item.equipped; }); const equipmentGoldMultiplier = equippedItems.reduce((mult, item) => { return mult * (item.bonus.goldMultiplier ?? 1); }, 1); const setGoldMultiplier = computeSetBonuses( equippedItems.map((item) => { return item.id; }), EQUIPMENT_SETS, ).goldMultiplier; const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1; const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1; const runestonesCrystal = state.prestige.runestonesCrystalMultiplier ?? 1; const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1; const craftedGoldMultiplier = state.exploration?.craftedGoldMultiplier ?? 1; const craftedEssenceMultiplier = state.exploration?.craftedEssenceMultiplier ?? 1; const companionBonus = getActiveCompanionBonus( state.companions?.activeCompanionId, state.companions?.unlockedCompanionIds ?? [], ); const companionGoldMult = companionBonus?.type === "passiveGold" ? 1 + companionBonus.value : 1; const companionEssenceMult = companionBonus?.type === "essenceIncome" ? 1 + companionBonus.value : 1; const companionQuestTimeReduction = companionBonus?.type === "questTime" ? companionBonus.value : 0; let goldGained = 0; let essenceGained = 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 prestige = state.prestige.productionMultiplier; const goldPerTick = adventurer.goldPerSecond * adventurer.count * upgradeMultiplier * prestige * runestonesIncome * echoIncome * equipmentGoldMultiplier * setGoldMultiplier * craftedGoldMultiplier * companionGoldMult * deltaSeconds; goldGained = goldGained + goldPerTick; const essencePerTick = adventurer.essencePerSecond * adventurer.count * upgradeMultiplier * prestige * runestonesEssence * craftedEssenceMultiplier * companionEssenceMult * deltaSeconds; essenceGained = essenceGained + essencePerTick; } // Complete active quests and apply their rewards const now = Date.now(); let questGold = 0; let questEssence = 0; let questCrystals = 0; let updatedUpgrades = state.upgrades; let updatedAdventurers = state.adventurers; let updatedEquipmentReference = state.equipment; const updatedQuests = state.quests.map((quest) => { const effectiveQuestMs = quest.durationSeconds * (1 - companionQuestTimeReduction) * 1000; if ( quest.status !== "active" || quest.startedAt === undefined || now < quest.startedAt + effectiveQuestMs ) { return quest; } const failureChance = zoneFailureChance[quest.zoneId] ?? 0.2; if (Math.random() < failureChance) { const { startedAt: _dropped, ...questWithoutStartedAt } = quest; return { ...questWithoutStartedAt, lastFailedAt: now, status: "available" as const, }; } for (const reward of quest.rewards) { if (reward.type === "gold" && reward.amount !== undefined) { questGold = questGold + reward.amount; } else if (reward.type === "essence" && reward.amount !== undefined) { questEssence = questEssence + reward.amount; } else if (reward.type === "crystals" && reward.amount !== undefined) { const crystalAmount = reward.amount; const crystalGain = crystalAmount * runestonesCrystal; questCrystals = questCrystals + crystalGain; } else if (reward.type === "upgrade" && reward.targetId !== undefined) { updatedUpgrades = updatedUpgrades.map((upgrade) => { return upgrade.id === reward.targetId ? { ...upgrade, unlocked: true } : upgrade; }); } else if ( reward.type === "adventurer" && reward.targetId !== undefined ) { updatedAdventurers = updatedAdventurers.map((adventurer) => { return adventurer.id === reward.targetId ? { ...adventurer, unlocked: true } : adventurer; }); } else if (reward.type === "equipment" && reward.targetId !== undefined) { const rewardTargetId = reward.targetId; const currentEquipment = updatedEquipmentReference; updatedEquipmentReference = currentEquipment.map((item) => { if (item.id !== rewardTargetId) { return item; } const slotEmpty = !currentEquipment.some((other) => { return other.type === item.type && other.equipped; }); return { ...item, equipped: slotEmpty || item.equipped, owned: true, }; }); } } return { ...quest, status: "completed" as const }; }); // Unlock quests whose prerequisites are now all completed and whose zone is unlocked const completedIds = new Set( updatedQuests. filter((quest) => { return quest.status === "completed"; }). map((quest) => { return quest.id; }), ); const fullyUpdatedQuests = updatedQuests.map((quest) => { if (quest.status !== "locked") { return quest; } const questZone = state.zones.find((searchZone) => { return searchZone.id === quest.zoneId; }); if (questZone?.status === "locked") { return quest; } if ( quest.prerequisiteIds.every((id) => { return completedIds.has(id); }) ) { return { ...quest, status: "available" as const }; } return quest; }); /* * Unlock zones whose both conditions are now satisfied after quest completion: * (1) the gate boss has been defeated, (2) the gate quest is now completed */ const updatedZones = state.zones.map((zone) => { if (zone.status === "unlocked") { return zone; } const bossOk = zone.unlockBossId === null || state.bosses.some((boss) => { return boss.id === zone.unlockBossId && boss.status === "defeated"; }); const questOk = zone.unlockQuestId === null || completedIds.has(zone.unlockQuestId); if (bossOk && questOk) { return { ...zone, status: "unlocked" as const }; } return zone; }); // Activate the first boss in any zone that just became unlocked this tick const newlyUnlockedZoneIds = new Set( updatedZones. filter((zone) => { const wasLocked = state.zones.find((originalZone) => { return originalZone.id === zone.id; })?.status === "locked"; return zone.status === "unlocked" && wasLocked; }). map((zone) => { return zone.id; }), ); let updatedBosses = state.bosses; if (newlyUnlockedZoneIds.size > 0) { updatedBosses = state.bosses.map((boss) => { if (newlyUnlockedZoneIds.has(boss.zoneId)) { const zoneBosses = state.bosses.filter((zoneBoss) => { return zoneBoss.zoneId === boss.zoneId; }); const [ firstBoss ] = zoneBosses; if (firstBoss?.id === boss.id && boss.status === "locked") { return { ...boss, status: "available" as const }; } } return boss; }); } // Count quests newly completed this tick and update daily challenge progress const newlyCompletedQuestCount = updatedQuests.filter((quest, index) => { const wasNotCompleted = state.quests[index]?.status !== "completed"; return quest.status === "completed" && wasNotCompleted; }).length; let updatedDailyChallenges = state.dailyChallenges; let challengeCrystals = 0; if (updatedDailyChallenges !== undefined && newlyCompletedQuestCount > 0) { const result = updateChallengeProgress( updatedDailyChallenges, "questsCompleted", newlyCompletedQuestCount, ); updatedDailyChallenges = result.updatedChallenges; 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, ); const totalGoldEarnedValue = state.player.totalGoldEarned + goldGained + questGold; const partialState: GameState = { ...state, resources: { ...state.resources, crystals: capResource( state.resources.crystals + questCrystals + challengeCrystals, ), essence: essenceValue, gold: goldValue, }, ...updatedDailyChallenges === undefined ? {} : { dailyChallenges: updatedDailyChallenges }, ...newlyUnlockedZoneIds.size === 0 || state.exploration === undefined ? {} : { exploration: { ...state.exploration, areas: state.exploration.areas.map((area) => { const areaDefinition = EXPLORATION_AREAS.find((definition) => { return definition.id === area.id; }); return areaDefinition !== undefined && newlyUnlockedZoneIds.has(areaDefinition.zoneId) && area.status === "locked" ? { ...area, status: "available" as const } : area; }), }, }, adventurers: updatedAdventurers, bosses: updatedBosses, equipment: updatedEquipmentReference, lastTickAt: now, player: { ...state.player, totalGoldEarned: totalGoldEarnedValue, }, quests: fullyUpdatedQuests, upgrades: updatedUpgrades, zones: updatedZones, }; // Check achievements and apply crystal and runestone rewards for newly unlocked ones const updatedAchievements = checkAchievements(partialState); let crystalsFromAchievements = 0; let runestonesFromAchievements = 0; for (const [ index, achievement ] of updatedAchievements.entries()) { const wasLocked = state.achievements[index]?.unlockedAt === null; const isNowUnlocked = achievement.unlockedAt !== null; if (wasLocked && isNowUnlocked) { crystalsFromAchievements = crystalsFromAchievements + (achievement.reward?.crystals ?? 0); runestonesFromAchievements = runestonesFromAchievements + (achievement.reward?.runestones ?? 0); } } return { ...partialState, achievements: updatedAchievements, prestige: { ...partialState.prestige, runestones: partialState.prestige.runestones + runestonesFromAchievements, }, resources: { ...partialState.resources, crystals: capResource( partialState.resources.crystals + crystalsFromAchievements, ), }, }; }; /** * Calculates the effective click power, including upgrades and equipped trinkets. * @param state - The current game state. * @returns The calculated click power value. */ export const calculateClickPower = (state: GameState): number => { const clickMultiplier = state.upgrades. filter((upgrade) => { return upgrade.purchased && upgrade.target === "click"; }). reduce((mult, upgrade) => { return mult * upgrade.multiplier; }, 1); const equippedItems = state.equipment.filter((item) => { return item.equipped; }); const equipmentClickMultiplier = equippedItems. filter((item) => { return item.bonus.clickMultiplier !== undefined; }). reduce((mult, item) => { return mult * (item.bonus.clickMultiplier ?? 1); }, 1); const setClickMultiplier = computeSetBonuses( equippedItems.map((item) => { return item.id; }), EQUIPMENT_SETS, ).clickMultiplier; const runestonesClick = state.prestige.runestonesClickMultiplier ?? 1; const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1; const craftedClickMultiplier = state.exploration?.craftedClickMultiplier ?? 1; const companionClickBonus = getActiveCompanionBonus( state.companions?.activeCompanionId, state.companions?.unlockedCompanionIds ?? [], ); const companionClickMult = companionClickBonus?.type === "clickGold" ? 1 + companionClickBonus.value : 1; return ( state.baseClickPower * clickMultiplier * state.prestige.productionMultiplier * runestonesClick * echoIncome * equipmentClickMultiplier * setClickMultiplier * craftedClickMultiplier * companionClickMult ); };