Files
elysium/apps/web/src/engine/tick.ts
T
hikari e02827dbb6 feat: vampire tick engine, auto systems, and full test suite
- vampire blood production tick with thrall bloodPerSecond + multipliers
- auto-quest and auto-thrall purchase in tick engine
- computeVampireBloodPerSecond helper exposed for ResourceBar display
- ResourceBar now shows blood/s and currency balances for vampire mode
- vampire quests and thralls panels gain auto-toggle buttons
- About page updated with vampire mode how-to-play entries
- vampireEquipmentSets data file added to web
- 100% test coverage across all API routes and services:
  - siring, awakening, vampireBoss, vampireCraft, vampireExplore, vampireUpgrade
  - debug route now covers grant-apotheosis endpoint
  - vampireMaterials excluded from coverage (ID-referenced only, same as goddessMaterials)
2026-04-16 14:01:50 -07:00

1666 lines
54 KiB
TypeScript

/**
* @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<Achievement> => {
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<GoddessAchievement> => {
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<VampireAchievement> => {
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<string, number> = {
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<Equipment> = 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<Equipment> = 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
);
};