generated from nhcarrigan/template
b886928e49
Titles are earned by reaching milestones (quests, bosses, gold, clicks, adventurers, guild, prestige, transcendence, apotheosis, achievements, longevity) and are permanent - never lost on prestige/transcendence/ apotheosis resets. 20 titles available at launch. Also fixes a pre-existing P2034 write-conflict on the load backfill path and the exactOptionalPropertyTypes violation in the quest failure handler.
337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
import type { Achievement, Equipment, GameState } from "@elysium/types";
|
|
import { computeSetBonuses } 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.
|
|
*/
|
|
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;
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
const ZONE_FAILURE_CHANCE: Record<string, number> = {
|
|
verdant_vale: 0.10,
|
|
shattered_ruins: 0.12,
|
|
frozen_peaks: 0.14,
|
|
shadow_marshes: 0.16,
|
|
volcanic_depths: 0.18,
|
|
astral_void: 0.20,
|
|
celestial_reaches: 0.22,
|
|
abyssal_trench: 0.24,
|
|
infernal_court: 0.26,
|
|
crystalline_spire: 0.28,
|
|
void_sanctum: 0.30,
|
|
eternal_throne: 0.32,
|
|
primordial_chaos: 0.34,
|
|
infinite_expanse: 0.36,
|
|
reality_forge: 0.38,
|
|
cosmic_maelstrom: 0.40,
|
|
primeval_sanctum: 0.40,
|
|
the_absolute: 0.40,
|
|
};
|
|
|
|
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 setGoldMultiplier = computeSetBonuses(equippedItems.map((e) => e.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;
|
|
|
|
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 *
|
|
echoIncome *
|
|
equipmentGoldMultiplier *
|
|
setGoldMultiplier *
|
|
craftedGoldMultiplier *
|
|
deltaSeconds;
|
|
|
|
essenceGained +=
|
|
adventurer.essencePerSecond *
|
|
adventurer.count *
|
|
upgradeMultiplier *
|
|
prestige *
|
|
runestonesEssence *
|
|
craftedEssenceMultiplier *
|
|
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;
|
|
}
|
|
|
|
const failureChance = ZONE_FAILURE_CHANCE[quest.zoneId] ?? 0.20;
|
|
if (Math.random() < failureChance) {
|
|
const { startedAt: _dropped, ...questWithoutStartedAt } = quest;
|
|
return { ...questWithoutStartedAt, status: "available" as const, lastFailedAt: now };
|
|
}
|
|
|
|
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;
|
|
});
|
|
}
|
|
|
|
// Count quests newly completed this tick and update daily challenge progress
|
|
const newlyCompletedQuestCount = updatedQuests.filter(
|
|
(q, i) => q.status === "completed" && state.quests[i]?.status !== "completed",
|
|
).length;
|
|
|
|
let updatedDailyChallenges = state.dailyChallenges;
|
|
let challengeCrystals = 0;
|
|
if (updatedDailyChallenges && newlyCompletedQuestCount > 0) {
|
|
const result = updateChallengeProgress(
|
|
updatedDailyChallenges,
|
|
"questsCompleted",
|
|
newlyCompletedQuestCount,
|
|
);
|
|
updatedDailyChallenges = result.updatedChallenges;
|
|
challengeCrystals = result.crystalsAwarded;
|
|
}
|
|
|
|
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 + challengeCrystals),
|
|
},
|
|
...(updatedDailyChallenges !== undefined ? { dailyChallenges: updatedDailyChallenges } : {}),
|
|
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 equippedItems = (state.equipment ?? []).filter((e) => e.equipped);
|
|
const equipmentClickMultiplier = equippedItems
|
|
.filter((e) => e.bonus.clickMultiplier != null)
|
|
.reduce((mult, e) => mult * (e.bonus.clickMultiplier ?? 1), 1);
|
|
const setClickMultiplier = computeSetBonuses(equippedItems.map((e) => e.id), EQUIPMENT_SETS).clickMultiplier;
|
|
|
|
const runestonesClick = state.prestige.runestonesClickMultiplier ?? 1;
|
|
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
|
|
const craftedClickMultiplier = state.exploration?.craftedClickMultiplier ?? 1;
|
|
|
|
return (
|
|
state.baseClickPower *
|
|
clickMultiplier *
|
|
state.prestige.productionMultiplier *
|
|
runestonesClick *
|
|
echoIncome *
|
|
equipmentClickMultiplier *
|
|
setClickMultiplier *
|
|
craftedClickMultiplier
|
|
);
|
|
};
|