Files
elysium/apps/web/src/engine/tick.ts
T
hikari 5b4661b398 feat: content expansion, prestige shop, and offline earnings improvements
- Expand content to 18 zones, 72 bosses, 95 quests, 32 adventurer tiers
- Add prestige shop with 24 runestone upgrades across 5 categories
- Add PrestigeUpgrade type, data files, API routes, and frontend panel
- Fix offline earnings to include equipment and runestone multipliers
- Add offline essence calculation alongside offline gold
- Update OfflineModal to display both gold and essence earned
- Add IDEAS.md for tracking planned features
2026-03-06 21:55:42 -08:00

269 lines
9.1 KiB
TypeScript

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
);
};