/** * @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 unicorn/no-array-reduce -- reduce is the most readable approach for multiplier chains */ /* 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 { 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.totalGoldEarned >= 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; }); }; /** * Maximum value any resource can accumulate to. Beyond this JS floats lose all useful precision. */ export const RESOURCE_CAP = 1e300; /** * Probability of quest failure per zone — scales from 10% (early game) to 40% (end game). * On failure the quest resets to "available" with no rewards; the player must wait the * full duration again on their next attempt. */ export const zoneFailureChance: Record = { abyssal_trench: 0.24, astral_void: 0.2, celestial_reaches: 0.22, cosmic_maelstrom: 0.4, crystalline_spire: 0.28, eternal_throne: 0.32, frozen_peaks: 0.14, infernal_court: 0.26, infinite_expanse: 0.36, primeval_sanctum: 0.4, primordial_chaos: 0.34, reality_forge: 0.38, shadow_marshes: 0.16, shattered_ruins: 0.12, the_absolute: 0.4, verdant_vale: 0.1, void_sanctum: 0.3, volcanic_depths: 0.18, }; /** * Caps a resource value at RESOURCE_CAP. * @param value - The resource value to cap. * @returns The capped value. */ const capResource = (value: number): number => { return Math.min(value, RESOURCE_CAP); }; /** * Pure function — applies one game tick to the state. * DeltaSeconds: time elapsed since last tick. * Returns a new GameState (does not mutate the original). * @param state - The current game state. * @param deltaSeconds - Time elapsed since last tick in seconds. * @returns A new GameState with the tick applied. */ /** * Computes the effective gold earned per second across all adventurers, * including all active multipliers (upgrades, prestige, equipment, etc.). * @param state - The current game state. * @returns Gold per second as a number. */ export const computeGoldPerSecond = (state: GameState): number => { const equippedItems: Array = state.equipment.filter((item) => { return item.equipped; }); const equipmentGoldMultiplier = equippedItems.reduce((mult, item) => { return mult * (item.bonus.goldMultiplier ?? 1); }, 1); const setGoldMultiplier = computeSetBonuses( equippedItems.map((item) => { return item.id; }), EQUIPMENT_SETS, ).goldMultiplier; const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1; const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1; const craftedGoldMultiplier = state.exploration?.craftedGoldMultiplier ?? 1; const companionBonus = getActiveCompanionBonus( state.companions?.activeCompanionId, state.companions?.unlockedCompanionIds ?? [], ); const companionGoldMult = companionBonus?.type === "passiveGold" ? 1 + companionBonus.value : 1; let goldPerSecond = 0; for (const adventurer of state.adventurers) { if (!adventurer.unlocked || adventurer.count === 0) { continue; } const upgradeMultiplier = state.upgrades. filter((upgrade) => { const isGlobal = upgrade.target === "global"; const isThisAdventurer = upgrade.target === "adventurer" && upgrade.adventurerId === adventurer.id; return upgrade.purchased && (isGlobal || isThisAdventurer); }). reduce((mult, upgrade) => { return mult * upgrade.multiplier; }, 1); const contribution = adventurer.goldPerSecond * adventurer.count * upgradeMultiplier * state.prestige.productionMultiplier * runestonesIncome * echoIncome * equipmentGoldMultiplier * setGoldMultiplier * craftedGoldMultiplier * companionGoldMult; goldPerSecond = goldPerSecond + contribution; } return goldPerSecond; }; /** * Computes the current essence per second for the given game state, * applying all relevant multipliers (upgrades, prestige, echo, crafted, companion). * @param state - The current game state. * @returns The total essence per second. */ export const computeEssencePerSecond = (state: GameState): number => { const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1; const craftedEssenceMultiplier = state.exploration?.craftedEssenceMultiplier ?? 1; const companionBonus = getActiveCompanionBonus( state.companions?.activeCompanionId, state.companions?.unlockedCompanionIds ?? [], ); const companionEssenceMult = companionBonus?.type === "essenceIncome" ? 1 + companionBonus.value : 1; let essencePerSecond = 0; for (const adventurer of state.adventurers) { if (!adventurer.unlocked || adventurer.count === 0) { continue; } const upgradeMultiplier = state.upgrades. filter((upgrade) => { const isGlobal = upgrade.target === "global"; const isThisAdventurer = upgrade.target === "adventurer" && upgrade.adventurerId === adventurer.id; return upgrade.purchased && (isGlobal || isThisAdventurer); }). reduce((mult, upgrade) => { return mult * upgrade.multiplier; }, 1); const contribution = adventurer.essencePerSecond * adventurer.count * upgradeMultiplier * state.prestige.productionMultiplier * runestonesEssence * craftedEssenceMultiplier * companionEssenceMult; essencePerSecond = essencePerSecond + contribution; } return essencePerSecond; }; /** * 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; } 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 }, adventurers: updatedAdventurers, bosses: updatedBosses, equipment: updatedEquipmentReference, lastTickAt: now, player: { ...state.player, totalGoldEarned: totalGoldEarnedValue, }, quests: fullyUpdatedQuests, upgrades: updatedUpgrades, zones: updatedZones, }; // Check achievements and apply crystal rewards for newly unlocked ones const updatedAchievements = checkAchievements(partialState); const crystalsFromAchievements = updatedAchievements.reduce( (sum, achievement, index) => { const wasLocked = state.achievements[index]?.unlockedAt === null; const isNowUnlocked = achievement.unlockedAt !== null; if (wasLocked && isNowUnlocked) { return sum + (achievement.reward?.crystals ?? 0); } return sum; }, 0, ); return { ...partialState, achievements: updatedAchievements, 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 ); };