/** * @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, type GoddessAchievement, type GoddessState, type VampireAchievement, type VampireState, computeSetBonuses, computeVampireSetBonuses, getActiveCompanionBonus, } from "@elysium/types"; import { EQUIPMENT_SETS } from "../data/equipmentSets.js"; import { EXPLORATION_AREAS } from "../data/explorations.js"; import { VAMPIRE_EQUIPMENT_SETS } from "../data/vampireEquipmentSets.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; }); }; /** * Checks all goddess achievements against a snapshot of the goddess state * and returns an updated achievements array, marking newly-met conditions * with the current timestamp. * @param goddess - The current (or projected) goddess state. * @param now - Current Unix timestamp in milliseconds. * @returns Updated goddess achievements array with newly unlocked ones timestamped. */ const checkGoddessAchievements = ( goddess: GoddessState, now: number, ): Array => { return goddess.achievements.map((achievement) => { if (achievement.unlockedAt !== null) { return achievement; } const { condition } = achievement; let met = false; switch (condition.type) { case "totalPrayersEarned": met = goddess.lifetimePrayersEarned >= condition.amount; break; case "goddessBossesDefeated": met = goddess.lifetimeBossesDefeated >= condition.amount; break; case "goddessQuestsCompleted": met = goddess.lifetimeQuestsCompleted >= condition.amount; break; case "discipleTotal": met = goddess.disciples.reduce((sum, disciple) => { return sum + disciple.count; }, 0) >= condition.amount; break; case "consecrationCount": met = goddess.consecration.count >= condition.amount; break; case "goddessEquipmentOwned": met = goddess.equipment.filter((item) => { return item.owned; }).length >= condition.amount; break; default: // eslint-disable-next-line capitalized-comments -- v8 coverage ignore directive /* v8 ignore next -- @preserve */ break; } return met ? { ...achievement, unlockedAt: now } : achievement; }); }; /** * Checks all vampire achievements against a snapshot of the vampire state * and returns an updated achievements array, marking newly-met conditions * with the current timestamp. * @param vampire - The current (or projected) vampire state. * @param now - Current Unix timestamp in milliseconds. * @returns Updated vampire achievements array with newly unlocked ones timestamped. */ const checkVampireAchievements = ( vampire: VampireState, now: number, ): Array => { return vampire.achievements.map((achievement) => { if (achievement.unlockedAt !== null) { return achievement; } const { condition } = achievement; let met = false; switch (condition.type) { case "totalBloodEarned": met = vampire.lifetimeBloodEarned >= condition.amount; break; case "vampireBossesDefeated": met = vampire.lifetimeBossesDefeated >= condition.amount; break; case "vampireQuestsCompleted": met = vampire.lifetimeQuestsCompleted >= condition.amount; break; case "thrallTotal": met = vampire.thralls.reduce((sum, thrall) => { return sum + thrall.count; }, 0) >= condition.amount; break; case "siringCount": met = vampire.siring.count >= condition.amount; break; case "vampireEquipmentOwned": met = vampire.equipment.filter((item) => { return item.owned; }).length >= condition.amount; break; default: // eslint-disable-next-line capitalized-comments -- v8 coverage ignore directive /* 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 4% (early game) to 15% (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.09, astral_void: 0.08, celestial_reaches: 0.08, cosmic_maelstrom: 0.15, crystalline_spire: 0.11, eternal_throne: 0.12, frozen_peaks: 0.05, infernal_court: 0.1, infinite_expanse: 0.14, primeval_sanctum: 0.15, primordial_chaos: 0.13, reality_forge: 0.14, shadow_marshes: 0.06, shattered_ruins: 0.05, the_absolute: 0.15, verdant_vale: 0.04, void_sanctum: 0.11, volcanic_depths: 0.07, }; /** * 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; }; /** * Computes the effective blood earned per second from all thralls, * applying all active multipliers (upgrades, siring, awakening, equipment, sets, crafting). * @param state - The current game state. * @returns Blood per second as a number. */ export const computeVampireBloodPerSecond = (state: GameState): number => { if (state.vampire === undefined) { return 0; } const { vampire } = state; const equippedItems = vampire.equipment.filter((item) => { return item.equipped; }); const equipmentBloodMultiplier = equippedItems.reduce((mult, item) => { return mult * (item.bonus.bloodMultiplier ?? 1); }, 1); const setBloodMultiplier = computeVampireSetBonuses( equippedItems.map((item) => { return item.id; }), VAMPIRE_EQUIPMENT_SETS, ).bloodMultiplier; const ichorBloodMult = vampire.siring.ichorBloodMultiplier ?? 1; const { soulShardsBloodMultiplier } = vampire.awakening; const { craftedBloodMultiplier } = vampire.exploration; let globalBloodMult = 1; let globalUpgradeMult = 1; for (const upgrade of vampire.upgrades) { if (upgrade.purchased) { if (upgrade.target === "blood") { globalBloodMult = globalBloodMult * upgrade.multiplier; } else if (upgrade.target === "global") { globalUpgradeMult = globalUpgradeMult * upgrade.multiplier; } } } let bloodPerSecond = 0; for (const thrall of vampire.thralls) { if (!thrall.unlocked || thrall.count === 0) { continue; } let thrallUpgradeMult = 1; for (const upgrade of vampire.upgrades) { if ( upgrade.purchased && upgrade.target === "thrall" && upgrade.thrallId === thrall.id ) { thrallUpgradeMult = thrallUpgradeMult * upgrade.multiplier; } } const upgradeMultiplier = thrallUpgradeMult * globalUpgradeMult; const contribution = thrall.bloodPerSecond * thrall.count * upgradeMultiplier * globalBloodMult * vampire.siring.productionMultiplier * ichorBloodMult * soulShardsBloodMultiplier * craftedBloodMultiplier * equipmentBloodMultiplier * setBloodMultiplier; bloodPerSecond = bloodPerSecond + contribution; } return bloodPerSecond; }; const basePrestigeThreshold = 1_000_000; const runestonesPerPrestigeLevelClient = 20; 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; }); // --- Goddess tick --- let prayersGained = 0; let divinityGained = 0; let stardustFromQuests = 0; // eslint-disable-next-line no-undef-init -- required by @typescript-eslint/init-declarations let updatedGoddess: GoddessState | undefined = undefined; let bloodGainedVampire = 0; // eslint-disable-next-line no-undef-init -- required by @typescript-eslint/init-declarations let updatedVampire: VampireState | undefined = undefined; if ( state.apotheosis !== undefined && state.apotheosis.count > 0 && state.goddess !== undefined ) { const { goddess } = state; // Collect all multipliers for prayers/divinity income const consecMult = goddess.consecration.productionMultiplier; const divinityPrayersMult = goddess.consecration.divinityPrayersMultiplier ?? 1; const divinityDisciplesMult = goddess.consecration.divinityDisciplesMultiplier ?? 1; const stardustPrayersMult = goddess.enlightenment.stardustPrayersMultiplier; const craftedPrayersMult = goddess.exploration.craftedPrayersMultiplier; const craftedDivinityMult = goddess.exploration.craftedDivinityMultiplier; let globalPrayersMult = 1; for (const upgrade of goddess.upgrades) { if (upgrade.purchased && upgrade.target === "prayers") { globalPrayersMult = globalPrayersMult * upgrade.multiplier; } } for (const disciple of goddess.disciples) { if (disciple.count === 0) { continue; } let discipleUpgradeMult = 1; for (const upgrade of goddess.upgrades) { if ( upgrade.purchased && upgrade.target === "disciple" && upgrade.discipleId === disciple.id ) { discipleUpgradeMult = discipleUpgradeMult * upgrade.multiplier; } } const prayersTick = disciple.prayersPerSecond * disciple.count * discipleUpgradeMult * globalPrayersMult * consecMult * divinityPrayersMult * divinityDisciplesMult * stardustPrayersMult * craftedPrayersMult * deltaSeconds; prayersGained = prayersGained + prayersTick; const divinityTick = disciple.divinityPerSecond * disciple.count * discipleUpgradeMult * consecMult * divinityDisciplesMult * craftedDivinityMult * deltaSeconds; divinityGained = divinityGained + divinityTick; } // Process goddess quest timers let goddessQuestPrayersGained = 0; let goddessQuestDivinityGained = 0; let updatedGoddessDisciples = goddess.disciples; let updatedGoddessEquipment = goddess.equipment; let updatedGoddessUpgrades = goddess.upgrades; let goddessQuestsThisTick = 0; const updatedGoddessQuests = goddess.quests.map((quest) => { const questDurationMs = quest.durationSeconds * 1000; const questExpiry = quest.startedAt === undefined ? Infinity : quest.startedAt + questDurationMs; if (quest.status !== "active" || now < questExpiry) { return quest; } goddessQuestsThisTick = goddessQuestsThisTick + 1; for (const reward of quest.rewards) { if (reward.type === "prayers" && reward.amount !== undefined) { goddessQuestPrayersGained = goddessQuestPrayersGained + reward.amount; } else if ( reward.type === "divinity" && reward.amount !== undefined ) { goddessQuestDivinityGained = goddessQuestDivinityGained + reward.amount; } else if ( reward.type === "stardust" && reward.amount !== undefined ) { stardustFromQuests = stardustFromQuests + reward.amount; } else if ( reward.type === "upgrade" && reward.targetId !== undefined ) { const { targetId } = reward; updatedGoddessUpgrades = updatedGoddessUpgrades.map((upgrade) => { return upgrade.id === targetId ? { ...upgrade, unlocked: true } : upgrade; }); } else if ( reward.type === "disciple" && reward.targetId !== undefined ) { const { targetId } = reward; updatedGoddessDisciples = updatedGoddessDisciples.map((disciple) => { return disciple.id === targetId ? { ...disciple, unlocked: true } : disciple; }); } else if ( reward.type === "equipment" && reward.targetId !== undefined ) { const rewardTargetId = reward.targetId; const currentEquipment = updatedGoddessEquipment; updatedGoddessEquipment = 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 goddess quests whose prerequisites are now all completed const completedGoddessIds = new Set( updatedGoddessQuests. filter((quest) => { return quest.status === "completed"; }). map((quest) => { return quest.id; }), ); const defeatedBossIds = new Set( goddess.bosses. filter((boss) => { return boss.status === "defeated"; }). map((boss) => { return boss.id; }), ); // Unlock goddess zones whose boss + quest requirements are now met const updatedGoddessZones = goddess.zones.map((zone) => { if (zone.status === "unlocked") { return zone; } const bossOk = zone.unlockBossId === null || defeatedBossIds.has(zone.unlockBossId); const questOk = zone.unlockQuestId === null || completedGoddessIds.has(zone.unlockQuestId); if (bossOk && questOk) { return { ...zone, status: "unlocked" as const }; } return zone; }); const fullyUpdatedGoddessZones = updatedGoddessZones; const allUnlockedGoddessZoneIds = new Set( fullyUpdatedGoddessZones. filter((zone) => { return zone.status === "unlocked"; }). map((zone) => { return zone.id; }), ); const fullyUpdatedGoddessQuests = updatedGoddessQuests.map((quest) => { if (quest.status !== "locked") { return quest; } if (!allUnlockedGoddessZoneIds.has(quest.zoneId)) { return quest; } if ( quest.prerequisiteIds.every((id) => { return completedGoddessIds.has(id); }) ) { return { ...quest, status: "available" as const }; } return quest; }); // Compute updated lifetime counters const totalPrayersThisTick = prayersGained + goddessQuestPrayersGained; const updatedTotalPrayersEarned = goddess.totalPrayersEarned + totalPrayersThisTick; const updatedLifetimePrayersEarned = goddess.lifetimePrayersEarned + totalPrayersThisTick; const updatedLifetimeQuestsCompleted = goddess.lifetimeQuestsCompleted + goddessQuestsThisTick; // Build snapshot for achievement check const goddessSnapshot: GoddessState = { ...goddess, disciples: updatedGoddessDisciples, equipment: updatedGoddessEquipment, lifetimeBossesDefeated: goddess.lifetimeBossesDefeated, lifetimePrayersEarned: updatedLifetimePrayersEarned, lifetimeQuestsCompleted: updatedLifetimeQuestsCompleted, quests: fullyUpdatedGoddessQuests, upgrades: updatedGoddessUpgrades, zones: fullyUpdatedGoddessZones, }; const updatedGoddessAchievements = checkGoddessAchievements(goddessSnapshot, now); let divinityFromAchievements = 0; let stardustFromAchievements = 0; for (const [ index, achievement ] of updatedGoddessAchievements.entries()) { if ( goddess.achievements[index]?.unlockedAt === null && achievement.unlockedAt !== null ) { divinityFromAchievements = divinityFromAchievements + (achievement.reward?.divinity ?? 0); stardustFromAchievements = stardustFromAchievements + (achievement.reward?.stardust ?? 0); } } updatedGoddess = { ...goddessSnapshot, achievements: updatedGoddessAchievements, lastTickAt: now, totalPrayersEarned: updatedTotalPrayersEarned, }; // Include quest divinity + achievement divinity in this tick's total divinityGained = divinityGained + goddessQuestDivinityGained + divinityFromAchievements; stardustFromQuests = stardustFromQuests + stardustFromAchievements; } // --- Vampire tick --- if (state.vampire !== undefined) { const { vampire } = state; // Compute vampire equipment multipliers once for the tick const vampireEquippedItems = vampire.equipment.filter((item) => { return item.equipped; }); const vampireEquipmentBloodMult = vampireEquippedItems.reduce( (mult, item) => { return mult * (item.bonus.bloodMultiplier ?? 1); }, 1, ); const vampireSetBloodMult = computeVampireSetBonuses( vampireEquippedItems.map((item) => { return item.id; }), VAMPIRE_EQUIPMENT_SETS, ).bloodMultiplier; const ichorBloodMult = vampire.siring.ichorBloodMultiplier ?? 1; const { soulShards: currentSoulShards, soulShardsBloodMultiplier, } = vampire.awakening; const { craftedBloodMultiplier } = vampire.exploration; // Compute global vampire upgrade multipliers let globalBloodMult = 1; let globalUpgradeMult = 1; for (const upgrade of vampire.upgrades) { if (upgrade.purchased) { if (upgrade.target === "blood") { globalBloodMult = globalBloodMult * upgrade.multiplier; } else if (upgrade.target === "global") { globalUpgradeMult = globalUpgradeMult * upgrade.multiplier; } } } // Passive income from thralls let bloodFromThralls = 0; let ichorFromThralls = 0; for (const thrall of vampire.thralls) { if (!thrall.unlocked || thrall.count === 0) { continue; } let thrallUpgradeMult = 1; for (const upgrade of vampire.upgrades) { if ( upgrade.purchased && upgrade.target === "thrall" && upgrade.thrallId === thrall.id ) { thrallUpgradeMult = thrallUpgradeMult * upgrade.multiplier; } } const upgradeMultiplier = thrallUpgradeMult * globalUpgradeMult; const bloodContribution = thrall.bloodPerSecond * thrall.count * upgradeMultiplier * globalBloodMult * vampire.siring.productionMultiplier * ichorBloodMult * soulShardsBloodMultiplier * craftedBloodMultiplier * vampireEquipmentBloodMult * vampireSetBloodMult * deltaSeconds; bloodFromThralls = bloodFromThralls + bloodContribution; const ichorContribution = thrall.ichorPerSecond * thrall.count * deltaSeconds; ichorFromThralls = ichorFromThralls + ichorContribution; } // Process vampire quest timers let vampireQuestBloodGained = 0; let vampireQuestIchorGained = 0; let vampireQuestSoulShardsGained = 0; let updatedVampireUpgrades = vampire.upgrades; let updatedVampireThralls = vampire.thralls; let updatedVampireEquipment = vampire.equipment; let vampireQuestsThisTick = 0; const updatedVampireQuests = vampire.quests.map((quest) => { const questDurationMs = quest.durationSeconds * 1000; const questExpiry = quest.startedAt === undefined ? Infinity : quest.startedAt + questDurationMs; if (quest.status !== "active" || now < questExpiry) { return quest; } vampireQuestsThisTick = vampireQuestsThisTick + 1; for (const reward of quest.rewards) { if (reward.type === "blood" && reward.amount !== undefined) { vampireQuestBloodGained = vampireQuestBloodGained + reward.amount; } else if (reward.type === "ichor" && reward.amount !== undefined) { vampireQuestIchorGained = vampireQuestIchorGained + reward.amount; } else if ( reward.type === "soulShards" && reward.amount !== undefined ) { vampireQuestSoulShardsGained = vampireQuestSoulShardsGained + reward.amount; } else if ( reward.type === "upgrade" && reward.targetId !== undefined ) { const { targetId } = reward; updatedVampireUpgrades = updatedVampireUpgrades.map((upgrade) => { return upgrade.id === targetId ? { ...upgrade, unlocked: true } : upgrade; }); } else if ( reward.type === "thrall" && reward.targetId !== undefined ) { const { targetId } = reward; updatedVampireThralls = updatedVampireThralls.map((thrall) => { return thrall.id === targetId ? { ...thrall, unlocked: true } : thrall; }); } else if ( reward.type === "equipment" && reward.targetId !== undefined ) { const rewardTargetId = reward.targetId; const currentEquipment = updatedVampireEquipment; updatedVampireEquipment = 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 vampire quests whose prerequisites are met and zone is unlocked const completedVampireIds = new Set( updatedVampireQuests. filter((quest) => { return quest.status === "completed"; }). map((quest) => { return quest.id; }), ); const defeatedVampireBossIds = new Set( vampire.bosses. filter((boss) => { return boss.status === "defeated"; }). map((boss) => { return boss.id; }), ); // Unlock vampire zones whose boss + quest requirements are now met const updatedVampireZones = vampire.zones.map((zone) => { if (zone.status === "unlocked") { return zone; } const bossOk = zone.unlockBossId === null || defeatedVampireBossIds.has(zone.unlockBossId); const questOk = zone.unlockQuestId === null || completedVampireIds.has(zone.unlockQuestId); if (bossOk && questOk) { return { ...zone, status: "unlocked" as const }; } return zone; }); const allUnlockedVampireZoneIds = new Set( updatedVampireZones. filter((zone) => { return zone.status === "unlocked"; }). map((zone) => { return zone.id; }), ); const fullyUpdatedVampireQuests = updatedVampireQuests.map((quest) => { if (quest.status !== "locked") { return quest; } if (!allUnlockedVampireZoneIds.has(quest.zoneId)) { return quest; } if ( quest.prerequisiteIds.every((id) => { return completedVampireIds.has(id); }) ) { return { ...quest, status: "available" as const }; } return quest; }); // Compute updated lifetime counters const totalBloodThisTick = bloodFromThralls + vampireQuestBloodGained; const updatedTotalBloodEarned = vampire.totalBloodEarned + totalBloodThisTick; const updatedLifetimeBloodEarned = vampire.lifetimeBloodEarned + totalBloodThisTick; const updatedLifetimeQuestsCompleted = vampire.lifetimeQuestsCompleted + vampireQuestsThisTick; // Build snapshot for achievement check const vampireSnapshot: VampireState = { ...vampire, equipment: updatedVampireEquipment, lifetimeBloodEarned: updatedLifetimeBloodEarned, lifetimeQuestsCompleted: updatedLifetimeQuestsCompleted, quests: fullyUpdatedVampireQuests, thralls: updatedVampireThralls, totalBloodEarned: updatedTotalBloodEarned, upgrades: updatedVampireUpgrades, zones: updatedVampireZones, }; const updatedVampireAchievements = checkVampireAchievements(vampireSnapshot, now); let ichorFromAchievements = 0; let soulShardsFromAchievements = 0; for (const [ index, achievement ] of updatedVampireAchievements.entries()) { if ( vampire.achievements[index]?.unlockedAt === null && achievement.unlockedAt !== null ) { ichorFromAchievements = ichorFromAchievements + (achievement.reward?.ichor ?? 0); soulShardsFromAchievements = soulShardsFromAchievements + (achievement.reward?.soulShards ?? 0); } } bloodGainedVampire = totalBloodThisTick; updatedVampire = { ...vampireSnapshot, achievements: updatedVampireAchievements, awakening: { ...vampire.awakening, soulShards: currentSoulShards + vampireQuestSoulShardsGained + soulShardsFromAchievements, }, lastTickAt: now, siring: { ...vampire.siring, ichor: vampire.siring.ichor + ichorFromThralls + vampireQuestIchorGained + ichorFromAchievements, }, }; } 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, blood: capResource( (state.resources.blood ?? 0) + bloodGainedVampire, ), crystals: capResource( state.resources.crystals + questCrystals + challengeCrystals, ), divinity: capResource( (state.resources.divinity ?? 0) + divinityGained, ), essence: essenceValue, gold: goldValue, prayers: capResource( (state.resources.prayers ?? 0) + prayersGained, ), stardust: capResource( (state.resources.stardust ?? 0) + stardustFromQuests, ), }, ...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; }), }, }, ...updatedGoddess === undefined ? {} : { goddess: updatedGoddess }, ...updatedVampire === undefined ? {} : { vampire: updatedVampire }, 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 ); };