feat: initial prototype — core game systems (#30)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m1s
CI / Lint, Build & Test (push) Successful in 1m6s

## Summary

This PR represents the full v1 prototype, implementing the core game systems for Elysium.

- Full idle/clicker RPG loop: resource collection, crafting, boss fights, exploration, and quests
- Adventurer hiring with batch size selector and progressive tier cost scaling
- Prestige, transcendence, and apotheosis systems with auto-prestige support
- Character sheet, titles, leaderboards, companion system, and daily login bonuses
- Auto-quest and auto-boss toggles
- Discord webhook notifications on prestige/transcendence/apotheosis
- Discord role awarded on apotheosis
- Responsive design and overarching story/lore system
- In-game sound effects and browser notifications for key events
- Support link button in the resource bar
- Full test coverage (100% on `apps/api` and `packages/types`)
- CI pipeline: lint → build → test

## Closes

Closes #1
Closes #2
Closes #3
Closes #4
Closes #5
Closes #6
Closes #7
Closes #8
Closes #9
Closes #10
Closes #11
Closes #12
Closes #13
Closes #14
Closes #16
Closes #19
Closes #20
Closes #21
Closes #22
Closes #23
Closes #24
Closes #25
Closes #26
Closes #27
Closes #29

 This issue was created with help from Hikari~ 🌸

Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Reviewed-on: #30
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #30.
This commit is contained in:
2026-03-08 15:53:39 -07:00
committed by Naomi Carrigan
parent c69e155de3
commit 29c817230d
172 changed files with 50706 additions and 0 deletions
+514
View File
@@ -0,0 +1,514 @@
/**
* @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<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((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.
*/
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.
*/
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;
}
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
);
};