Files
elysium/apps/web/src/engine/tick.ts
T
hikari 69579e166a
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m6s
CI / Lint, Build & Test (pull_request) Failing after 1m13s
fix: apply crystal multiplier to boss rewards, steepen prestige threshold, fix stale upgrade race condition, and fix companion format display
Closes #221
Closes #222
Closes #201
Closes #213
2026-04-06 13:22:15 -07:00

911 lines
30 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,
computeSetBonuses,
getActiveCompanionBonus,
} from "@elysium/types";
import { EQUIPMENT_SETS } from "../data/equipmentSets.js";
import { EXPLORATION_AREAS } from "../data/explorations.js";
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
/**
* Checks all achievements against the current game state and returns an updated
* achievements array, marking newly-met conditions with the current timestamp.
* @param state - The current game state to check achievements against.
* @returns Updated achievements array with newly unlocked achievements timestamped.
*/
const checkAchievements = (state: GameState): Array<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;
});
};
/**
* Exponential base for the prestige combat multiplier: Math.pow(base, prestigeCount).
* Replaces the former linear formula (1 + count * 0.1) to enable late-game zone progression.
*/
export const PRESTIGE_COMBAT_BASE = 4;
/**
* Maximum value any resource can accumulate to. Beyond this JS floats lose all useful precision.
*/
export const RESOURCE_CAP = 1e300;
/**
* Probability of quest failure per zone — scales from 10% (early game) to 40% (end game).
* On failure the quest resets to "available" with no rewards; the player must wait the
* full duration again on their next attempt.
*/
export const zoneFailureChance: Record<string, number> = {
abyssal_trench: 0.24,
astral_void: 0.2,
celestial_reaches: 0.22,
cosmic_maelstrom: 0.4,
crystalline_spire: 0.28,
eternal_throne: 0.32,
frozen_peaks: 0.14,
infernal_court: 0.26,
infinite_expanse: 0.36,
primeval_sanctum: 0.4,
primordial_chaos: 0.34,
reality_forge: 0.38,
shadow_marshes: 0.16,
shattered_ruins: 0.12,
the_absolute: 0.4,
verdant_vale: 0.1,
void_sanctum: 0.3,
volcanic_depths: 0.18,
};
/**
* Caps a resource value at RESOURCE_CAP.
* @param value - The resource value to cap.
* @returns The capped value.
*/
const capResource = (value: number): number => {
return Math.min(value, RESOURCE_CAP);
};
/**
* Pure function — applies one game tick to the state.
* DeltaSeconds: time elapsed since last tick.
* Returns a new GameState (does not mutate the original).
* @param state - The current game state.
* @param deltaSeconds - Time elapsed since last tick in seconds.
* @returns A new GameState with the tick applied.
*/
/**
* Computes the effective gold earned per second across all adventurers,
* including all active multipliers (upgrades, prestige, equipment, etc.).
* @param state - The current game state.
* @returns Gold per second as a number.
*/
export const computeGoldPerSecond = (state: GameState): number => {
const equippedItems: Array<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;
};
const basePrestigeThreshold = 1_000_000;
const runestonesPerPrestigeLevelClient = 15;
const maxBaseRunestones = 200;
/**
* Computes the projected runestone reward if the player were to prestige right now.
* Mirrors the server-side calculateRunestones formula exactly.
* @param state - The current game state.
* @returns The number of runestones the player would earn from a prestige now.
*/
export const computeProjectedRunestones = (state: GameState): number => {
const { count, purchasedUpgradeIds } = state.prestige;
const thresholdMult: number
= state.transcendence?.echoPrestigeThresholdMultiplier ?? 1;
const threshold
= basePrestigeThreshold * Math.pow(count + 1, 2.5) * thresholdMult;
const base = Math.min(
Math.floor(Math.cbrt(state.player.totalGoldEarned / threshold))
* runestonesPerPrestigeLevelClient,
maxBaseRunestones,
);
const gain1Mult = purchasedUpgradeIds.includes("runestone_gain_1")
? 1.25
: 1;
const gain2Mult = purchasedUpgradeIds.includes("runestone_gain_2")
? 1.5
: 1;
const runestoneMult = gain1Mult * gain2Mult;
const echoMult: number
= state.transcendence?.echoPrestigeRunestoneMultiplier ?? 1;
return Math.floor(base * runestoneMult * echoMult);
};
/**
* Pure function — applies one game tick to the state.
* DeltaSeconds: time elapsed since last tick.
* Returns a new GameState (does not mutate the original).
* @param state - The current game state.
* @param deltaSeconds - Time elapsed since last tick in seconds.
* @returns A new GameState with the tick applied.
*/
export const applyTick = (
state: GameState,
deltaSeconds: number,
): GameState => {
const equippedItems: Array<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;
});
const goldValue = capResource(state.resources.gold + goldGained + questGold);
const essenceValue = capResource(
state.resources.essence + essenceGained + questEssence,
);
const totalGoldEarnedValue
= state.player.totalGoldEarned + goldGained + questGold;
const partialState: GameState = {
...state,
resources: {
...state.resources,
crystals: capResource(
state.resources.crystals + questCrystals + challengeCrystals,
),
essence: essenceValue,
gold: goldValue,
},
...updatedDailyChallenges === undefined
? {}
: { dailyChallenges: updatedDailyChallenges },
...newlyUnlockedZoneIds.size === 0 || state.exploration === undefined
? {}
: {
exploration: {
...state.exploration,
areas: state.exploration.areas.map((area) => {
const areaDefinition = EXPLORATION_AREAS.find((definition) => {
return definition.id === area.id;
});
return areaDefinition !== undefined
&& newlyUnlockedZoneIds.has(areaDefinition.zoneId)
&& area.status === "locked"
? { ...area, status: "available" as const }
: area;
}),
},
},
adventurers: updatedAdventurers,
bosses: updatedBosses,
equipment: updatedEquipmentReference,
lastTickAt: now,
player: {
...state.player,
totalGoldEarned: totalGoldEarnedValue,
},
quests: fullyUpdatedQuests,
upgrades: updatedUpgrades,
zones: updatedZones,
};
// Check achievements and apply crystal and runestone rewards for newly unlocked ones
const updatedAchievements = checkAchievements(partialState);
let crystalsFromAchievements = 0;
let runestonesFromAchievements = 0;
for (const [ index, achievement ] of updatedAchievements.entries()) {
const wasLocked = state.achievements[index]?.unlockedAt === null;
const isNowUnlocked = achievement.unlockedAt !== null;
if (wasLocked && isNowUnlocked) {
crystalsFromAchievements
= crystalsFromAchievements + (achievement.reward?.crystals ?? 0);
runestonesFromAchievements
= runestonesFromAchievements + (achievement.reward?.runestones ?? 0);
}
}
return {
...partialState,
achievements: updatedAchievements,
prestige: {
...partialState.prestige,
runestones:
partialState.prestige.runestones + runestonesFromAchievements,
},
resources: {
...partialState.resources,
crystals: capResource(
partialState.resources.crystals + crystalsFromAchievements,
),
},
};
};
/**
* Calculates the effective click power, including upgrades and equipped trinkets.
* @param state - The current game state.
* @returns The calculated click power value.
*/
export const calculateClickPower = (state: GameState): number => {
const clickMultiplier = state.upgrades.
filter((upgrade) => {
return upgrade.purchased && upgrade.target === "click";
}).
reduce((mult, upgrade) => {
return mult * upgrade.multiplier;
}, 1);
const equippedItems = state.equipment.filter((item) => {
return item.equipped;
});
const equipmentClickMultiplier = equippedItems.
filter((item) => {
return item.bonus.clickMultiplier !== undefined;
}).
reduce((mult, item) => {
return mult * (item.bonus.clickMultiplier ?? 1);
}, 1);
const setClickMultiplier = computeSetBonuses(
equippedItems.map((item) => {
return item.id;
}),
EQUIPMENT_SETS,
).clickMultiplier;
const runestonesClick = state.prestige.runestonesClickMultiplier ?? 1;
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
const craftedClickMultiplier = state.exploration?.craftedClickMultiplier ?? 1;
const companionClickBonus = getActiveCompanionBonus(
state.companions?.activeCompanionId,
state.companions?.unlockedCompanionIds ?? [],
);
const companionClickMult
= companionClickBonus?.type === "clickGold"
? 1 + companionClickBonus.value
: 1;
return (
state.baseClickPower
* clickMultiplier
* state.prestige.productionMultiplier
* runestonesClick
* echoIncome
* equipmentClickMultiplier
* setClickMultiplier
* craftedClickMultiplier
* companionClickMult
);
};