import type { Achievement, Equipment, GameState } from "@elysium/types"; /** * Checks all achievements against the current game state and returns an updated * achievements array, marking newly-met conditions with the current timestamp. */ const checkAchievements = (state: GameState): 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.totalGoldEarned >= condition.amount; break; case "totalClicks": met = state.player.totalClicks >= condition.amount; break; case "bossesDefeated": met = state.bosses.filter((b) => b.status === "defeated").length >= condition.amount; break; case "questsCompleted": met = state.quests.filter((q) => q.status === "completed").length >= condition.amount; break; case "adventurerTotal": met = state.adventurers.reduce((sum, a) => sum + a.count, 0) >= condition.amount; break; case "prestigeCount": met = state.prestige.count >= condition.amount; break; case "equipmentOwned": met = (state.equipment ?? []).filter((e) => e.owned).length >= condition.amount; 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; const capResource = (value: number): number => 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). */ export const applyTick = (state: GameState, deltaSeconds: number): GameState => { const equippedItems: Equipment[] = (state.equipment ?? []).filter((e) => e.equipped); const equipmentGoldMultiplier = equippedItems.reduce( (mult, e) => mult * (e.bonus.goldMultiplier ?? 1), 1, ); const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1; const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1; const runestonesCrystal = state.prestige.runestonesCrystalMultiplier ?? 1; let goldGained = 0; let essenceGained = 0; for (const adventurer of state.adventurers) { if (!adventurer.unlocked || adventurer.count === 0) { continue; } const upgradeMultiplier = state.upgrades .filter( (u) => u.purchased && (u.target === "global" || (u.target === "adventurer" && u.adventurerId === adventurer.id)), ) .reduce((mult, upgrade) => mult * upgrade.multiplier, 1); const prestige = state.prestige.productionMultiplier; goldGained += adventurer.goldPerSecond * adventurer.count * upgradeMultiplier * prestige * runestonesIncome * equipmentGoldMultiplier * deltaSeconds; essenceGained += adventurer.essencePerSecond * adventurer.count * upgradeMultiplier * prestige * runestonesEssence * deltaSeconds; } // 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 updatedEquipment = state.equipment ?? []; const updatedQuests = state.quests.map((quest) => { if ( quest.status !== "active" || quest.startedAt == null || now < quest.startedAt + quest.durationSeconds * 1000 ) { return quest; } for (const reward of quest.rewards) { if (reward.type === "gold" && reward.amount != null) { questGold += reward.amount; } else if (reward.type === "essence" && reward.amount != null) { questEssence += reward.amount; } else if (reward.type === "crystals" && reward.amount != null) { questCrystals += reward.amount * runestonesCrystal; } else if (reward.type === "upgrade" && reward.targetId != null) { updatedUpgrades = updatedUpgrades.map((u) => u.id === reward.targetId ? { ...u, unlocked: true } : u, ); } else if (reward.type === "adventurer" && reward.targetId != null) { updatedAdventurers = updatedAdventurers.map((a) => a.id === reward.targetId ? { ...a, unlocked: true } : a, ); } else if (reward.type === "equipment" && reward.targetId != null) { const targetId = reward.targetId; updatedEquipment = updatedEquipment.map((e) => { if (e.id !== targetId) return e; const slotEmpty = !updatedEquipment.some( (other) => other.type === e.type && other.equipped, ); return { ...e, owned: true, equipped: slotEmpty || e.equipped }; }); } } 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((q) => q.status === "completed").map((q) => q.id), ); const fullyUpdatedQuests = updatedQuests.map((quest) => { if (quest.status !== "locked") return quest; const zone = state.zones.find((z) => z.id === quest.zoneId); if (zone?.status === "locked") return quest; if (quest.prerequisiteIds.every((id) => 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((b) => b.id === zone.unlockBossId && b.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((z) => { const wasLocked = state.zones.find((oz) => oz.id === z.id)?.status === "locked"; return z.status === "unlocked" && wasLocked; }) .map((z) => z.id), ); let updatedBosses = state.bosses; if (newlyUnlockedZoneIds.size > 0) { updatedBosses = state.bosses.map((boss) => { if (!newlyUnlockedZoneIds.has(boss.zoneId ?? "")) return boss; const zoneBosses = state.bosses.filter((b) => b.zoneId === boss.zoneId); const firstBoss = zoneBosses[0]; if (firstBoss?.id === boss.id && boss.status === "locked") { return { ...boss, status: "available" as const }; } return boss; }); } const newGold = capResource(state.resources.gold + goldGained + questGold); const newEssence = capResource(state.resources.essence + essenceGained + questEssence); const newTotalGoldEarned = state.player.totalGoldEarned + goldGained + questGold; const partialState: GameState = { ...state, resources: { ...state.resources, gold: newGold, essence: newEssence, crystals: capResource(state.resources.crystals + questCrystals), }, player: { ...state.player, totalGoldEarned: newTotalGoldEarned, }, quests: fullyUpdatedQuests, upgrades: updatedUpgrades, adventurers: updatedAdventurers, equipment: updatedEquipment, bosses: updatedBosses, zones: updatedZones, lastTickAt: now, }; // Check achievements and apply crystal rewards for newly unlocked ones const updatedAchievements = checkAchievements(partialState); const crystalsFromAchievements = updatedAchievements.reduce((sum, a, i) => { const wasLocked = (state.achievements ?? [])[i]?.unlockedAt === null; const isNowUnlocked = a.unlockedAt !== null; if (wasLocked && isNowUnlocked) { return sum + (a.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. */ export const calculateClickPower = (state: GameState): number => { const clickMultiplier = state.upgrades .filter((u) => u.purchased && u.target === "click") .reduce((mult, upgrade) => mult * upgrade.multiplier, 1); const equipmentClickMultiplier = (state.equipment ?? []) .filter((e) => e.equipped && e.bonus.clickMultiplier != null) .reduce((mult, e) => mult * (e.bonus.clickMultiplier ?? 1), 1); const runestonesClick = state.prestige.runestonesClickMultiplier ?? 1; return ( state.baseClickPower * clickMultiplier * state.prestige.productionMultiplier * runestonesClick * equipmentClickMultiplier ); };