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
+3
View File
@@ -0,0 +1,3 @@
import config from "@nhcarrigan/eslint-config";
export default [...config];
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@elysium/types",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./prod/src/index.js",
"types": "./prod/src/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
"lint": "eslint --max-warnings 0 src",
"test": "vitest run --coverage"
},
"devDependencies": {
"@nhcarrigan/eslint-config": "5.2.0",
"@nhcarrigan/typescript-config": "4.0.0",
"@vitest/coverage-v8": "3.0.8",
"eslint": "9.22.0",
"typescript": "5.8.2",
"vitest": "3.0.8"
}
}
+135
View File
@@ -0,0 +1,135 @@
/**
* @file Public API for the @elysium/types package.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
export type { ApotheosisData } from "./interfaces/apotheosis.js";
export type {
Companion,
CompanionBonus,
CompanionBonusType,
CompanionState,
CompanionUnlockCondition,
CompanionUnlockType,
} from "./interfaces/companion.js";
export {
COMPANIONS,
computeUnlockedCompanionIds,
getActiveCompanionBonus,
} from "./interfaces/companion.js";
export type {
CraftingBonusType,
CraftingMaterialRequirement,
CraftingRecipe,
} from "./interfaces/craftingRecipe.js";
export type {
ExplorationArea,
ExplorationAreaState,
ExplorationEvent,
ExplorationEventEffect,
ExplorationEventEffectType,
ExplorationMaterialDrop,
ExplorationState,
} from "./interfaces/exploration.js";
export type { Material, MaterialRarity } from "./interfaces/material.js";
export type { CodexEntry, CodexState } from "./interfaces/codex.js";
export type {
Achievement,
AchievementCondition,
AchievementConditionType,
AchievementReward,
} from "./interfaces/achievement.js";
export type { Adventurer, AdventurerClass } from "./interfaces/adventurer.js";
export type {
AboutResponse,
ApiError,
ApotheosisRequest,
ApotheosisResponse,
AuthResponse,
BossChallengeRequest,
BossChallengeResponse,
BuyEchoUpgradeRequest,
BuyEchoUpgradeResponse,
BuyPrestigeUpgradeRequest,
BuyPrestigeUpgradeResponse,
CraftRecipeRequest,
CraftRecipeResponse,
ExploreCollectEventResult,
ExploreCollectRequest,
ExploreCollectResponse,
ExploreStartRequest,
ExploreStartResponse,
GiteaRelease,
LeaderboardCategory,
LeaderboardEntry,
LeaderboardResponse,
LoginBonusResult,
LoadResponse,
PrestigeRequest,
PrestigeResponse,
PublicProfileResponse,
SaveRequest,
SaveResponse,
TranscendenceRequest,
TranscendenceResponse,
UpdateProfileRequest,
UpdateProfileResponse,
} from "./interfaces/api.js";
export type { Boss, BossStatus } from "./interfaces/boss.js";
export type {
DailyChallenge,
DailyChallengeState,
DailyChallengeType,
} from "./interfaces/dailyChallenge.js";
export type {
Equipment,
EquipmentBonus,
EquipmentRarity,
EquipmentType,
} from "./interfaces/equipment.js";
export type {
EquipmentSet,
EquipmentSetBonus,
} from "./interfaces/equipmentSet.js";
export { computeSetBonuses } from "./interfaces/equipmentSet.js";
export type { GameState } from "./interfaces/gameState.js";
export type { Player } from "./interfaces/player.js";
export type { PrestigeData } from "./interfaces/prestige.js";
export type {
PrestigeUpgrade,
PrestigeUpgradeCategory,
} from "./interfaces/prestigeUpgrade.js";
export type {
Quest,
QuestReward,
QuestRewardType,
QuestStatus,
} from "./interfaces/quest.js";
export type { Resource } from "./interfaces/resource.js";
export type { Upgrade, UpgradeTarget } from "./interfaces/upgrade.js";
export type { Zone, ZoneStatus } from "./interfaces/zone.js";
export type {
NumberFormat,
ProfileSettings,
} from "./interfaces/profileSettings.js";
export { DEFAULT_PROFILE_SETTINGS } from "./interfaces/profileSettings.js";
export type {
TranscendenceData,
TranscendenceUpgrade,
TranscendenceUpgradeCategory,
} from "./interfaces/transcendence.js";
export type {
Title,
TitleCondition,
TitleConditionType,
} from "./interfaces/title.js";
export type {
CompletedChapter,
StoryChapter,
StoryChoice,
StoryState,
StoryUnlockCondition,
StoryUnlockType,
} from "./interfaces/story.js";
export { STORY_CHAPTERS, isStoryChapterUnlocked } from "./interfaces/story.js";
@@ -0,0 +1,45 @@
/**
* @file Achievement types for the Elysium game.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
type AchievementConditionType =
| "totalGoldEarned"
| "totalClicks"
| "bossesDefeated"
| "questsCompleted"
| "adventurerTotal"
| "prestigeCount"
| "equipmentOwned";
interface AchievementCondition {
type: AchievementConditionType;
amount: number;
}
interface AchievementReward {
crystals?: number;
}
interface Achievement {
id: string;
name: string;
description: string;
icon: string;
condition: AchievementCondition;
reward?: AchievementReward;
/**
* Unix timestamp when unlocked, null if not yet unlocked.
*/
unlockedAt: number | null;
}
export type {
Achievement,
AchievementCondition,
AchievementConditionType,
AchievementReward,
};
@@ -0,0 +1,45 @@
/**
* @file Adventurer types for the Elysium game.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
type AdventurerClass =
| "warrior"
| "mage"
| "rogue"
| "cleric"
| "ranger"
| "paladin";
interface Adventurer {
id: string;
name: string;
class: AdventurerClass;
level: number;
/**
* Base cost for the first purchase of this tier (scales by 1.15× per count).
*/
baseCost: number;
/**
* Base gold generated per second.
*/
goldPerSecond: number;
/**
* Base essence generated per second.
*/
essencePerSecond: number;
/**
* Combat power per unit — used in boss battle simulation.
*/
combatPower: number;
count: number;
unlocked: boolean;
}
export type { Adventurer, AdventurerClass };
+429
View File
@@ -0,0 +1,429 @@
/**
* @file API request and response types for the Elysium game.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type {
EquipmentBonus,
EquipmentRarity,
EquipmentType,
} from "./equipment.js";
import type { GameState } from "./gameState.js";
import type { Player } from "./player.js";
import type { ProfileSettings } from "./profileSettings.js";
interface AuthResponse {
token: string;
player: Player;
isNew: boolean;
}
interface SaveRequest {
state: GameState;
/**
* HMAC-SHA256 signature of the previous save's state, for anti-cheat chain verification.
*/
signature?: string;
}
interface SaveResponse {
savedAt: number;
/**
* HMAC-SHA256 signature of the saved state — store and include in next save request.
*/
signature?: string;
}
interface LoginBonusResult {
/**
* Current login streak day count.
*/
streak: number;
/**
* Gold awarded for today's login.
*/
goldEarned: number;
/**
* Crystals awarded (day 7 bonus, scaled by week multiplier).
*/
crystalsEarned: number;
/**
* Day within the 7-day cycle (17).
*/
day: number;
/**
* Week number multiplier (week 1 = ×1, week 2 = ×2, …).
*/
weekMultiplier: number;
}
interface LoadResponse {
state: GameState;
/**
* Offline gold earned since last save (server-calculated).
*/
offlineGold: number;
/**
* Offline essence earned since last save (server-calculated).
*/
offlineEssence: number;
/**
* Seconds the player was offline (capped at 8 hours).
*/
offlineSeconds: number;
/**
* HMAC-SHA256 signature of the loaded state — store and include in next save request.
*/
signature?: string;
/**
* Daily login bonus awarded on this load (null if already claimed today).
*/
loginBonus: LoginBonusResult | null;
/**
* Current login streak (always present).
*/
loginStreak: number;
/**
* True when the player's save data is from an older schema version.
*/
schemaOutdated: boolean;
/**
* The current expected schema version from the server.
*/
currentSchemaVersion: number;
}
interface BossChallengeRequest {
bossId: string;
}
interface BossChallengeResponse {
won: boolean;
partyDPS: number;
bossDPS: number;
/**
* Boss HP immediately before the battle.
*/
bossHpBefore: number;
/**
* Boss maximum HP.
*/
bossMaxHp: number;
/**
* Boss HP at end of battle before any state reset (0 on win).
*/
bossHpAtBattleEnd: number;
/**
* Boss HP stored in game after the result (0 on win, maxHp on loss).
*/
bossNewHp: number;
/**
* Total party HP at start of battle.
*/
partyMaxHp: number;
/**
* Party HP remaining after battle (0 on loss).
*/
partyHpRemaining: number;
rewards?: {
gold: number;
essence: number;
crystals: number;
upgradeIds: Array<string>;
equipmentIds: Array<string>;
/**
* Runestone bounty awarded for defeating this boss for the very first time.
*/
bountyRunestones: number;
};
casualties?: Array<{
adventurerId: string;
killed: number;
}>;
}
type PrestigeRequest = Record<string, never>;
interface PrestigeResponse {
runestones: number;
// eslint-disable-next-line unicorn/no-keyword-prefix -- API field name matches server response
newPrestigeCount: number;
/**
* Bonus runestones awarded for reaching a milestone prestige (every 5th), 0 if not a milestone.
*/
milestoneRunestones: number;
}
interface BuyPrestigeUpgradeRequest {
upgradeId: string;
}
interface BuyPrestigeUpgradeResponse {
runestonesRemaining: number;
purchasedUpgradeIds: Array<string>;
runestonesIncomeMultiplier: number;
runestonesClickMultiplier: number;
runestonesEssenceMultiplier: number;
runestonesCrystalMultiplier: number;
}
interface PublicProfileResponse {
characterName: string;
pronouns: string;
characterRace: string;
characterClass: string;
username: string;
avatar: string | null;
bio: string;
guildName: string;
guildDescription: string;
profileSettings: ProfileSettings;
createdAt: number;
/**
* All Time stats — cumulative across all runs, never reset.
*/
totalGoldEarned: number;
totalClicks: number;
lifetimeBossesDefeated: number;
lifetimeQuestsCompleted: number;
lifetimeAdventurersRecruited: number;
lifetimeAchievementsUnlocked: number;
/**
* Current Run stats — sourced from the live GameState, reset on prestige & transcendence.
*/
currentRunGold: number;
currentRunClicks: number;
prestigeCount: number;
transcendenceCount: number;
apotheosisCount: number;
bossesDefeated: number;
questsCompleted: number;
adventurersRecruited: number;
achievementsUnlocked: number;
/**
* Titles this player has unlocked, as {id, name} pairs for display.
*/
unlockedTitles: Array<{ id: string; name: string }>;
/**
* The player's active title display name (empty string if none set).
*/
activeTitle: string;
/**
* Items the player currently has equipped.
*/
equippedItems: Array<{
name: string;
type: EquipmentType;
rarity: EquipmentRarity;
bonus: EquipmentBonus;
}>;
}
interface UpdateProfileRequest {
characterName: string;
pronouns?: string;
characterRace?: string;
characterClass?: string;
bio?: string;
guildName?: string;
guildDescription?: string;
profileSettings: ProfileSettings;
/**
* Title ID to set as active (empty string to clear).
*/
activeTitle?: string;
}
interface UpdateProfileResponse {
characterName: string;
pronouns: string;
characterRace: string;
characterClass: string;
bio: string;
guildName: string;
guildDescription: string;
activeTitle: string;
profileSettings: ProfileSettings;
}
type TranscendenceRequest = Record<string, never>;
interface TranscendenceResponse {
echoes: number;
// eslint-disable-next-line unicorn/no-keyword-prefix -- API field name matches server response
newTranscendenceCount: number;
}
interface BuyEchoUpgradeRequest {
upgradeId: string;
}
interface BuyEchoUpgradeResponse {
echoesRemaining: number;
purchasedUpgradeIds: Array<string>;
echoIncomeMultiplier: number;
echoCombatMultiplier: number;
echoPrestigeThresholdMultiplier: number;
echoPrestigeRunestoneMultiplier: number;
echoMetaMultiplier: number;
}
type ApotheosisRequest = Record<string, never>;
interface ApotheosisResponse {
// eslint-disable-next-line unicorn/no-keyword-prefix -- API field name matches server response
newApotheosisCount: number;
}
interface ApiError {
error: string;
}
type LeaderboardCategory =
| "totalGold"
| "bossesDefeated"
| "questsCompleted"
| "achievementsUnlocked"
| "prestigeCount"
| "transcendenceCount"
| "apotheosisCount";
interface LeaderboardEntry {
rank: number;
discordId: string;
characterName: string;
username: string;
avatar: string | null;
activeTitle: string;
value: number;
}
interface LeaderboardResponse {
category: LeaderboardCategory;
entries: Array<LeaderboardEntry>;
}
interface GiteaRelease {
// eslint-disable-next-line @typescript-eslint/naming-convention -- external API field name
tag_name: string;
name: string;
body: string;
// eslint-disable-next-line @typescript-eslint/naming-convention -- external API field name
published_at: string;
}
interface AboutResponse {
apiVersion: string;
releases: Array<GiteaRelease>;
}
interface ExploreStartRequest {
areaId: string;
}
interface ExploreStartResponse {
areaId: string;
endsAt: number;
}
interface ExploreCollectRequest {
areaId: string;
}
interface ExploreCollectEventResult {
text: string;
goldChange: number;
essenceChange: number;
materialGained: { materialId: string; quantity: number } | null;
adventurerLostCount: number;
}
interface ExploreCollectResponse {
foundNothing: boolean;
nothingMessage?: string;
materialsFound: Array<{ materialId: string; quantity: number }>;
event: ExploreCollectEventResult | null;
}
interface CraftRecipeRequest {
recipeId: string;
}
interface CraftRecipeResponse {
recipeId: string;
bonusType: string;
bonusValue: number;
craftedGoldMultiplier: number;
craftedEssenceMultiplier: number;
craftedClickMultiplier: number;
craftedCombatMultiplier: number;
}
export type {
AboutResponse,
ApiError,
ApotheosisRequest,
ApotheosisResponse,
AuthResponse,
BossChallengeRequest,
BossChallengeResponse,
BuyEchoUpgradeRequest,
BuyEchoUpgradeResponse,
BuyPrestigeUpgradeRequest,
BuyPrestigeUpgradeResponse,
CraftRecipeRequest,
CraftRecipeResponse,
ExploreCollectEventResult,
ExploreCollectRequest,
ExploreCollectResponse,
ExploreStartRequest,
ExploreStartResponse,
GiteaRelease,
LeaderboardCategory,
LeaderboardEntry,
LeaderboardResponse,
LoadResponse,
LoginBonusResult,
PrestigeRequest,
PrestigeResponse,
PublicProfileResponse,
SaveRequest,
SaveResponse,
TranscendenceRequest,
TranscendenceResponse,
UpdateProfileRequest,
UpdateProfileResponse,
};
@@ -0,0 +1,16 @@
/**
* @file Apotheosis types for the Elysium game.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
interface ApotheosisData {
/**
* Number of times the player has achieved Apotheosis.
*/
count: number;
}
export type { ApotheosisData };
+64
View File
@@ -0,0 +1,64 @@
/**
* @file Boss types for the Elysium game.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
type BossStatus = "locked" | "available" | "in_progress" | "defeated";
interface Boss {
id: string;
name: string;
description: string;
status: BossStatus;
maxHp: number;
currentHp: number;
/**
* Damage dealt to adventurers per second whilst the fight is active.
*/
damagePerSecond: number;
/**
* Gold reward on defeat.
*/
goldReward: number;
/**
* Essence reward on defeat.
*/
essenceReward: number;
/**
* Crystal reward on defeat.
*/
crystalReward: number;
/**
* IDs of upgrades unlocked on defeat.
*/
upgradeRewards: Array<string>;
/**
* IDs of equipment items granted on defeat.
*/
equipmentRewards: Array<string>;
/**
* Minimum prestige level required to access this boss.
*/
prestigeRequirement: number;
/**
* Zone this boss belongs to.
*/
zoneId: string;
/**
* One-time runestone bounty awarded on first-ever defeat.
*/
bountyRunestones: number;
}
export type { Boss, BossStatus };
+30
View File
@@ -0,0 +1,30 @@
/**
* @file Codex types for the Elysium game.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
interface CodexEntry {
id: string;
title: string;
content: string;
sourceType:
| "boss"
| "quest"
| "equipment"
| "adventurer"
| "upgrade"
| "prestige"
| "zone"
| "exploration"
| "recipe";
sourceId: string;
zoneId: string;
}
interface CodexState {
unlockedEntryIds: Array<string>;
}
export type { CodexEntry, CodexState };
+242
View File
@@ -0,0 +1,242 @@
/**
* @file Companion types and data for the Elysium game.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
type CompanionBonusType =
| "passiveGold"
| "clickGold"
| "bossDamage"
| "essenceIncome"
| "questTime";
interface CompanionBonus {
type: CompanionBonusType;
/**
* Fractional value: for multiplier types, adds this fraction (0.25 = +25%). For questTime,
* reduces duration by this fraction (0.15 = 15% faster).
*/
value: number;
}
type CompanionUnlockType =
| "lifetimeBosses"
| "lifetimeQuests"
| "lifetimeGold"
| "prestige"
| "transcendence"
| "apotheosis";
interface CompanionUnlockCondition {
type: CompanionUnlockType;
threshold: number;
}
interface Companion {
id: string;
name: string;
title: string;
description: string;
bonus: CompanionBonus;
unlock: CompanionUnlockCondition;
}
interface CompanionState {
/**
* Companion IDs the player has unlocked — recomputed server-side on every save.
*/
unlockedCompanionIds: Array<string>;
/**
* The ID of the currently active companion, or null for none.
*/
activeCompanionId: string | null;
}
// eslint-disable-next-line @typescript-eslint/naming-convention -- intentional constant name
const COMPANIONS: Array<Companion> = [
{
bonus: { type: "passiveGold", value: 0.25 },
description:
"A cheerful bard whose uplifting songs inspire your adventurers to work"
+ " harder for better coin.",
id: "lyra",
name: "Lyra",
title: "Wandering Minstrel",
unlock: { threshold: 100, type: "lifetimeBosses" },
},
{
bonus: { type: "clickGold", value: 0.5 },
description:
"A nimble rogue whose sleight of hand ensures far more gold lands in"
+ " your coffers with every strike.",
id: "finn",
name: "Finn",
title: "Quick-Fingered Rogue",
unlock: { threshold: 100, type: "lifetimeQuests" },
},
{
bonus: { type: "questTime", value: 0.15 },
description:
"A resourceful hedge witch who weaves minor enchantments that accelerate"
+ " your quest parties.",
id: "wren",
name: "Wren",
title: "Hedge Witch",
unlock: { threshold: 500, type: "lifetimeQuests" },
},
{
bonus: { type: "bossDamage", value: 0.2 },
description:
"A battle-hardened knight who leads your party with years of tactical"
+ " experience against fearsome foes.",
id: "aldric",
name: "Aldric",
title: "Veteran Knight",
unlock: { threshold: 200, type: "lifetimeBosses" },
},
{
bonus: { type: "essenceIncome", value: 0.3 },
description:
"A brilliant alchemist who transmutes ambient magic into pure essence,"
+ " bolstering your income.",
id: "sera",
name: "Sera",
title: "Arcane Alchemist",
unlock: { threshold: 10, type: "prestige" },
},
{
bonus: { type: "bossDamage", value: 0.4 },
description:
"A powerful battle mage whose devastating spells tear through even the"
+ " toughest boss encounters.",
id: "kael",
name: "Kael",
title: "Battle Mage",
unlock: { threshold: 720, type: "lifetimeBosses" },
},
{
bonus: { type: "questTime", value: 0.3 },
description:
"A time mage who bends the threads of time itself, significantly"
+ " hastening your quest parties.",
id: "zuri",
name: "Zuri",
title: "Chrono Weaver",
unlock: { threshold: 950, type: "lifetimeQuests" },
},
{
bonus: { type: "passiveGold", value: 0.75 },
description:
"A wealthy merchant whose golden touch and trade empire dramatically"
+ " boosts your passive earnings.",
id: "mira",
name: "Mira",
title: "Merchant Queen",
unlock: { threshold: 1e18, type: "lifetimeGold" },
},
{
bonus: { type: "essenceIncome", value: 0.75 },
description:
"A shadowy information broker who channels essence from the void through"
+ " forbidden knowledge.",
id: "vex",
name: "Vex",
title: "Shadow Broker",
unlock: { threshold: 5, type: "transcendence" },
},
{
bonus: { type: "passiveGold", value: 1 },
description:
"A divine oracle whose celestial blessing transforms the very air around"
+ " you into golden fortune.",
id: "pria",
name: "Pria",
title: "Celestial Oracle",
unlock: { threshold: 1, type: "apotheosis" },
},
];
/**
* Computes which companion IDs the player has unlocked based on their lifetime stats.
* Called server-side on every save using DB-authoritative player stats.
* @param parameters - The player's lifetime stats used to evaluate unlock conditions.
* @param parameters.lifetimeBossesDefeated - Total bosses defeated across all runs.
* @param parameters.lifetimeQuestsCompleted - Total quests completed across all runs.
* @param parameters.lifetimeGoldEarned - Total gold earned across all runs.
* @param parameters.prestigeCount - Number of prestiges performed.
* @param parameters.transcendenceCount - Number of transcendences performed.
* @param parameters.apotheosisCount - Number of apotheoses performed.
* @returns Array of companion IDs the player has unlocked.
*/
const computeUnlockedCompanionIds = (parameters: {
lifetimeBossesDefeated: number;
lifetimeQuestsCompleted: number;
lifetimeGoldEarned: number;
prestigeCount: number;
transcendenceCount: number;
apotheosisCount: number;
}): Array<string> => {
return COMPANIONS.filter((companion) => {
const { type, threshold } = companion.unlock;
switch (type) {
case "lifetimeBosses":
return parameters.lifetimeBossesDefeated >= threshold;
case "lifetimeQuests":
return parameters.lifetimeQuestsCompleted >= threshold;
case "lifetimeGold":
return parameters.lifetimeGoldEarned >= threshold;
case "prestige":
return parameters.prestigeCount >= threshold;
case "transcendence":
return parameters.transcendenceCount >= threshold;
case "apotheosis":
return parameters.apotheosisCount >= threshold;
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 2 -- @preserve */
default:
return false;
}
}).map((companion) => {
return companion.id;
});
};
/**
* Returns the bonus of the active companion if it is unlocked, otherwise null.
* Safe to call with undefined/null activeCompanionId.
* @param activeCompanionId - The ID of the currently active companion.
* @param unlockedCompanionIds - Array of companion IDs the player has unlocked.
* @returns The active companion's bonus, or null if none is active or unlocked.
*/
const getActiveCompanionBonus = (
activeCompanionId: string | null | undefined,
unlockedCompanionIds: Array<string>,
): CompanionBonus | null => {
if (activeCompanionId === null || activeCompanionId === undefined) {
return null;
}
if (!unlockedCompanionIds.includes(activeCompanionId)) {
return null;
}
return (
COMPANIONS.find((c) => {
return c.id === activeCompanionId;
})?.bonus ?? null
);
};
export type {
Companion,
CompanionBonus,
CompanionBonusType,
CompanionState,
CompanionUnlockCondition,
CompanionUnlockType,
};
export { COMPANIONS, computeUnlockedCompanionIds, getActiveCompanionBonus };
@@ -0,0 +1,35 @@
/**
* @file Crafting recipe types for the Elysium game.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
type CraftingBonusType =
| "gold_income"
| "essence_income"
| "click_power"
| "combat_power";
interface CraftingMaterialRequirement {
materialId: string;
quantity: number;
}
interface CraftingRecipe {
id: string;
name: string;
description: string;
zoneId: string;
requiredMaterials: Array<CraftingMaterialRequirement>;
bonus: {
type: CraftingBonusType;
/**
* Multiplicative bonus value, e.g. 1.1 = +10%.
*/
value: number;
};
}
export type { CraftingBonusType, CraftingMaterialRequirement, CraftingRecipe };
@@ -0,0 +1,33 @@
/**
* @file Daily challenge types for the Elysium game.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
type DailyChallengeType =
| "clicks"
| "bossesDefeated"
| "questsCompleted"
| "prestige";
interface DailyChallenge {
id: string;
type: DailyChallengeType;
label: string;
target: number;
progress: number;
completed: boolean;
rewardCrystals: number;
}
interface DailyChallengeState {
/**
* ISO date string (e.g. "2026-03-06") used to detect when to reset.
*/
date: string;
challenges: Array<DailyChallenge>;
}
export type { DailyChallenge, DailyChallengeState, DailyChallengeType };
@@ -0,0 +1,59 @@
/**
* @file Equipment types for the Elysium game.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
type EquipmentType = "weapon" | "armour" | "trinket";
type EquipmentRarity = "common" | "rare" | "epic" | "legendary";
interface EquipmentBonus {
/**
* Multiplier applied to all gold/s income (e.g. 1.1 = +10%).
*/
goldMultiplier?: number;
/**
* Multiplier applied to all combat power (e.g. 1.25 = +25%).
*/
combatMultiplier?: number;
/**
* Multiplier applied to click power (e.g. 1.5 = +50%).
*/
clickMultiplier?: number;
}
interface Equipment {
id: string;
name: string;
description: string;
type: EquipmentType;
rarity: EquipmentRarity;
bonus: EquipmentBonus;
/**
* Whether the player has acquired this item.
*/
owned: boolean;
/**
* Whether this item is currently equipped (only one per type can be equipped).
*/
equipped: boolean;
/**
* If set, this item can be purchased directly rather than obtained via boss drops.
*/
cost?: { gold: number; essence: number; crystals: number };
/**
* Equipment set this item belongs to, if any.
*/
setId?: string;
}
export type { Equipment, EquipmentBonus, EquipmentRarity, EquipmentType };
@@ -0,0 +1,68 @@
/**
* @file Equipment set types for the Elysium game.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
interface EquipmentSetBonus {
goldMultiplier?: number;
combatMultiplier?: number;
clickMultiplier?: number;
}
interface EquipmentSet {
id: string;
name: string;
description: string;
/**
* Equipment IDs that make up this set.
*/
pieces: Array<string>;
bonuses: {
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
2: EquipmentSetBonus;
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
3: EquipmentSetBonus;
};
}
/**
* Given a list of equipped item IDs and a set catalogue, returns the combined
* multiplicative bonuses granted by all active set bonuses.
* @param equippedItemIds - The IDs of items currently equipped by the player.
* @param sets - The full catalogue of equipment sets to evaluate against.
* @returns The combined gold, combat, and click multipliers from active set bonuses.
*/
const computeSetBonuses = (
equippedItemIds: Array<string>,
sets: Array<EquipmentSet>,
): {
goldMultiplier: number;
combatMultiplier: number;
clickMultiplier: number;
} => {
let goldMultiplier = 1;
let combatMultiplier = 1;
let clickMultiplier = 1;
for (const set of sets) {
const count = set.pieces.filter((id) => {
return equippedItemIds.includes(id);
}).length;
for (const threshold of [ 2, 3 ] as const) {
if (count >= threshold) {
const bonus = set.bonuses[threshold];
goldMultiplier = goldMultiplier * (bonus.goldMultiplier ?? 1);
combatMultiplier = combatMultiplier * (bonus.combatMultiplier ?? 1);
clickMultiplier = clickMultiplier * (bonus.clickMultiplier ?? 1);
}
}
}
return { clickMultiplier, combatMultiplier, goldMultiplier };
};
export type { EquipmentSet, EquipmentSetBonus };
export { computeSetBonuses };
@@ -0,0 +1,123 @@
/**
* @file Exploration types for the Elysium game.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
type ExplorationEventEffectType =
| "gold_gain"
| "gold_loss"
| "essence_gain"
| "material_gain"
| "adventurer_loss";
interface ExplorationEventEffect {
type: ExplorationEventEffectType;
/**
* Gold amount for gold_gain / gold_loss.
*/
amount?: number;
/**
* Material ID for material_gain.
*/
materialId?: string;
/**
* Quantity for material_gain.
*/
quantity?: number;
/**
* Fraction (01) of total adventurers lost for adventurer_loss.
*/
fraction?: number;
}
interface ExplorationEvent {
id: string;
text: string;
effect: ExplorationEventEffect;
}
interface ExplorationMaterialDrop {
materialId: string;
minQuantity: number;
maxQuantity: number;
/**
* Relative probability weight — higher = more likely.
*/
weight: number;
}
interface ExplorationArea {
id: string;
name: string;
description: string;
zoneId: string;
durationSeconds: number;
possibleMaterials: Array<ExplorationMaterialDrop>;
events: Array<ExplorationEvent>;
}
interface ExplorationAreaState {
id: string;
status: "locked" | "available" | "in_progress" | "completed";
/**
* Unix timestamp when exploration started (set when status becomes in_progress).
*/
startedAt?: number;
/**
* True after the first successful collect — used for codex unlock detection.
*/
completedOnce?: boolean;
}
interface ExplorationState {
areas: Array<ExplorationAreaState>;
/**
* Current material inventory.
*/
materials: Array<{ materialId: string; quantity: number }>;
/**
* IDs of crafting recipes that have been crafted (resets on prestige).
*/
craftedRecipeIds: Array<string>;
/**
* Pre-computed gold income multiplier from all crafted recipes.
*/
craftedGoldMultiplier: number;
/**
* Pre-computed essence income multiplier from all crafted recipes.
*/
craftedEssenceMultiplier: number;
/**
* Pre-computed click power multiplier from all crafted recipes.
*/
craftedClickMultiplier: number;
/**
* Pre-computed combat power multiplier from all crafted recipes.
*/
craftedCombatMultiplier: number;
}
export type {
ExplorationArea,
ExplorationAreaState,
ExplorationEvent,
ExplorationEventEffect,
ExplorationEventEffectType,
ExplorationMaterialDrop,
ExplorationState,
};
@@ -0,0 +1,98 @@
/**
* @file GameState type for the Elysium game.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Achievement } from "./achievement.js";
import type { Adventurer } from "./adventurer.js";
import type { ApotheosisData } from "./apotheosis.js";
import type { Boss } from "./boss.js";
import type { CodexState } from "./codex.js";
import type { CompanionState } from "./companion.js";
import type { DailyChallengeState } from "./dailyChallenge.js";
import type { Equipment } from "./equipment.js";
import type { ExplorationState } from "./exploration.js";
import type { Player } from "./player.js";
import type { PrestigeData } from "./prestige.js";
import type { Quest } from "./quest.js";
import type { Resource } from "./resource.js";
import type { StoryState } from "./story.js";
import type { TranscendenceData } from "./transcendence.js";
import type { Upgrade } from "./upgrade.js";
import type { Zone } from "./zone.js";
interface GameState {
player: Player;
resources: Resource;
adventurers: Array<Adventurer>;
upgrades: Array<Upgrade>;
quests: Array<Quest>;
bosses: Array<Boss>;
equipment: Array<Equipment>;
achievements: Array<Achievement>;
prestige: PrestigeData;
zones: Array<Zone>;
/**
* Click power (gold per click, before upgrades).
*/
baseClickPower: number;
/**
* Unix timestamp of the last client-side tick.
*/
lastTickAt: number;
/**
* Daily challenge progress — optional for backwards compatibility with old saves.
*/
dailyChallenges?: DailyChallengeState;
/**
* Lore codex unlock state — optional for backwards compatibility with old saves.
*/
codex?: CodexState;
/**
* Transcendence (second prestige layer) state — optional for backwards compatibility.
*/
transcendence?: TranscendenceData;
/**
* Apotheosis (third prestige layer) state — optional for backwards compatibility.
*/
apotheosis?: ApotheosisData;
/**
* Exploration and crafting state — optional for backwards compatibility.
*/
exploration?: ExplorationState;
/**
* When true, the tick engine automatically starts the highest-zone available quest.
*/
autoQuest?: boolean;
/**
* When true, the tick engine automatically challenges the highest available boss.
*/
autoBoss?: boolean;
/**
* Companion unlock and active selection state — optional for backwards compatibility.
*/
companions?: CompanionState;
/**
* Story chapter unlock and completion state — optional for backwards compatibility.
*/
story?: StoryState;
/**
* Schema version — used to detect saves from older game versions.
*/
schemaVersion?: number;
}
export type { GameState };
+18
View File
@@ -0,0 +1,18 @@
/**
* @file Material types for the Elysium game.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
type MaterialRarity = "common" | "uncommon" | "rare";
interface Material {
id: string;
name: string;
description: string;
zoneId: string;
rarity: MaterialRarity;
}
export type { Material, MaterialRarity };
+70
View File
@@ -0,0 +1,70 @@
/**
* @file Player types for the Elysium game.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
interface Player {
discordId: string;
username: string;
discriminator: string;
avatar: string | null;
/**
* Player's chosen in-game character name.
*/
characterName: string;
/**
* Unix timestamp when the account was created.
*/
createdAt: number;
/**
* Unix timestamp of the last server-side save.
*/
lastSavedAt: number;
/**
* Gold earned this run (reset on prestige; used for prestige eligibility).
*/
totalGoldEarned: number;
/**
* Clicks this run (reset on prestige).
*/
totalClicks: number;
/**
* Cumulative gold earned across all runs — never reset.
*/
lifetimeGoldEarned: number;
/**
* Cumulative clicks across all runs — never reset.
*/
lifetimeClicks: number;
/**
* Cumulative bosses defeated across all runs — never reset.
*/
lifetimeBossesDefeated: number;
/**
* Cumulative quests completed across all runs — never reset.
*/
lifetimeQuestsCompleted: number;
/**
* Cumulative adventurers recruited across all runs — never reset.
*/
lifetimeAdventurersRecruited: number;
/**
* Cumulative achievements unlocked across all runs — never reset.
*/
lifetimeAchievementsUnlocked: number;
}
export type { Player };
+61
View File
@@ -0,0 +1,61 @@
/**
* @file Prestige types for the Elysium game.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
interface PrestigeData {
/**
* Number of times the player has prestiged.
*/
count: number;
/**
* Runestones carried over between prestiges.
*/
runestones: number;
/**
* Multiplier applied to all production (based on prestige count).
*/
productionMultiplier: number;
/**
* IDs of prestige upgrades purchased with runestones.
*/
purchasedUpgradeIds: Array<string>;
/**
* Unix timestamp of last prestige.
*/
lastPrestigedAt?: number;
/**
* Pre-computed multiplier from "income" runestone upgrades.
*/
runestonesIncomeMultiplier?: number;
/**
* Pre-computed multiplier from "click" runestone upgrades.
*/
runestonesClickMultiplier?: number;
/**
* Pre-computed multiplier from "essence" runestone upgrades.
*/
runestonesEssenceMultiplier?: number;
/**
* Pre-computed multiplier from "crystals" runestone upgrades.
*/
runestonesCrystalMultiplier?: number;
/**
* Whether the auto-prestige feature is currently enabled (requires auto_prestige upgrade).
*/
autoPrestigeEnabled?: boolean;
}
export type { PrestigeData };
@@ -0,0 +1,29 @@
/**
* @file Prestige upgrade types for the Elysium game.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
type PrestigeUpgradeCategory =
| "income"
| "click"
| "essence"
| "crystals"
| "runestones"
| "utility";
interface PrestigeUpgrade {
id: string;
name: string;
description: string;
category: PrestigeUpgradeCategory;
runestonesCost: number;
/**
* Multiplier applied when this upgrade is purchased.
*/
multiplier: number;
}
export type { PrestigeUpgrade, PrestigeUpgradeCategory };
@@ -0,0 +1,78 @@
/**
* @file Profile settings types for the Elysium game.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
type NumberFormat = "suffix" | "scientific" | "engineering";
interface ProfileSettings {
/**
* All Time section.
*/
showTotalGold: boolean;
showTotalClicks: boolean;
showLifetimeBossesDefeated: boolean;
showLifetimeQuestsCompleted: boolean;
showLifetimeAdventurersRecruited: boolean;
showLifetimeAchievementsUnlocked: boolean;
showGuildFounded: boolean;
/**
* Current Run section.
*/
showCurrentGold: boolean;
showCurrentClicks: boolean;
showPrestige: boolean;
showTranscendence: boolean;
showApotheosis: boolean;
showBossesDefeated: boolean;
showQuestsCompleted: boolean;
showAdventurersRecruited: boolean;
showAchievementsUnlocked: boolean;
numberFormat: NumberFormat;
/**
* Whether this player appears on the public leaderboards.
*/
showOnLeaderboards: boolean;
/**
* Whether in-game sound effects are enabled.
*/
enableSounds: boolean;
/**
* Whether browser system notifications are enabled.
*/
enableNotifications: boolean;
}
// eslint-disable-next-line @typescript-eslint/naming-convention -- intentional constant name
const DEFAULT_PROFILE_SETTINGS: ProfileSettings = {
enableNotifications: false,
enableSounds: false,
numberFormat: "suffix",
showAchievementsUnlocked: true,
showAdventurersRecruited: true,
showApotheosis: true,
showBossesDefeated: true,
showCurrentClicks: true,
showCurrentGold: true,
showGuildFounded: true,
showLifetimeAchievementsUnlocked: true,
showLifetimeAdventurersRecruited: true,
showLifetimeBossesDefeated: true,
showLifetimeQuestsCompleted: true,
showOnLeaderboards: true,
showPrestige: true,
showQuestsCompleted: true,
showTotalClicks: true,
showTotalGold: true,
showTranscendence: true,
};
export type { NumberFormat, ProfileSettings };
export { DEFAULT_PROFILE_SETTINGS };
+66
View File
@@ -0,0 +1,66 @@
/**
* @file Quest types for the Elysium game.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
type QuestStatus = "locked" | "available" | "active" | "completed";
type QuestRewardType =
| "gold"
| "essence"
| "crystals"
| "upgrade"
| "adventurer"
| "equipment";
interface QuestReward {
type: QuestRewardType;
amount?: number;
/**
* ID of the upgrade or adventurer to unlock (if applicable).
*/
targetId?: string;
}
interface Quest {
id: string;
name: string;
description: string;
status: QuestStatus;
/**
* Unix timestamp when quest was started (if active).
*/
startedAt?: number;
/**
* Duration in seconds.
*/
durationSeconds: number;
rewards: Array<QuestReward>;
/**
* IDs of quests that must be completed before this one unlocks.
*/
prerequisiteIds: Array<string>;
/**
* Zone this quest belongs to.
*/
zoneId: string;
/**
* Minimum party combat power required to start this quest.
*/
combatPowerRequired?: number;
/**
* Unix timestamp of the most recent failed attempt (if any).
*/
lastFailedAt?: number;
}
export type { Quest, QuestReward, QuestRewardType, QuestStatus };
+15
View File
@@ -0,0 +1,15 @@
/**
* @file Resource types for the Elysium game.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
interface Resource {
gold: number;
essence: number;
crystals: number;
runestones: number;
}
export type { Resource };
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
/**
* @file Title types for the Elysium game.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
type TitleConditionType =
| "totalClicks"
| "totalGoldEarned"
| "bossesDefeated"
| "questsCompleted"
| "prestigeCount"
| "transcendenceCount"
| "apotheosisCount"
| "adventurerTotal"
| "achievementsUnlocked"
| "guildFounded"
| "playedDays";
interface TitleCondition {
type: TitleConditionType;
/**
* Threshold required to unlock (not used for guildFounded).
*/
amount?: number;
}
interface Title {
id: string;
name: string;
/**
* Human-readable description shown as the unlock hint.
*/
description: string;
condition: TitleCondition;
}
export type { Title, TitleCondition, TitleConditionType };
@@ -0,0 +1,79 @@
/**
* @file Transcendence types for the Elysium game.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
type TranscendenceUpgradeCategory =
| "income"
| "combat"
| "prestige_threshold"
| "prestige_runestones"
| "echo_meta";
interface TranscendenceUpgrade {
id: string;
name: string;
description: string;
category: TranscendenceUpgradeCategory;
/**
* Echo cost to purchase.
*/
cost: number;
/**
* Multiplicative effect of this upgrade.
*/
multiplier: number;
}
interface TranscendenceData {
/**
* Number of times the player has transcended.
*/
count: number;
/**
* Echoes accumulated across all transcendences.
*/
echoes: number;
/**
* IDs of echo upgrades purchased with echoes.
*/
purchasedUpgradeIds: Array<string>;
/**
* Pre-computed: multiplier applied to all passive gold income.
*/
echoIncomeMultiplier: number;
/**
* Pre-computed: multiplier applied to party DPS in boss fights.
*/
echoCombatMultiplier: number;
/**
* Pre-computed: multiplier applied to the prestige gold threshold (< 1 lowers requirement).
*/
echoPrestigeThresholdMultiplier: number;
/**
* Pre-computed: multiplier applied to runestones earned per prestige.
*/
echoPrestigeRunestoneMultiplier: number;
/**
* Pre-computed: multiplier applied to echo yield on future transcendences.
*/
echoMetaMultiplier: number;
}
export type {
TranscendenceData,
TranscendenceUpgrade,
TranscendenceUpgradeCategory,
};
+37
View File
@@ -0,0 +1,37 @@
/**
* @file Upgrade types for the Elysium game.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
type UpgradeTarget =
| "click"
| "adventurer"
| "global"
| "prestige"
| "boss";
interface Upgrade {
id: string;
name: string;
description: string;
target: UpgradeTarget;
/**
* ID of the adventurer this applies to (if target is "adventurer").
*/
adventurerId?: string;
/**
* Multiplier applied to the target's output.
*/
multiplier: number;
costGold: number;
costEssence: number;
costCrystals: number;
purchased: boolean;
unlocked: boolean;
}
export type { Upgrade, UpgradeTarget };
+28
View File
@@ -0,0 +1,28 @@
/**
* @file Zone types for the Elysium game.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
type ZoneStatus = "locked" | "unlocked";
interface Zone {
id: string;
name: string;
description: string;
emoji: string;
status: ZoneStatus;
/**
* Boss ID whose defeat is required to unlock this zone (null for the starter zone).
*/
unlockBossId: string | null;
/**
* Quest ID that must be completed to unlock this zone (null for the starter zone).
*/
unlockQuestId: string | null;
}
export type { Zone, ZoneStatus };
+161
View File
@@ -0,0 +1,161 @@
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
/* eslint-disable max-lines -- Test suites naturally have many cases */
import { describe, expect, it } from "vitest";
import {
computeUnlockedCompanionIds,
getActiveCompanionBonus,
} from "../src/interfaces/companion.js";
const baseParams = {
lifetimeBossesDefeated: 0,
lifetimeQuestsCompleted: 0,
lifetimeGoldEarned: 0,
prestigeCount: 0,
transcendenceCount: 0,
apotheosisCount: 0,
};
describe("computeUnlockedCompanionIds", () => {
it("returns empty array when no thresholds are met", () => {
expect(computeUnlockedCompanionIds(baseParams)).toEqual([]);
});
it("unlocks lyra at 100 lifetime bosses", () => {
const result = computeUnlockedCompanionIds({ ...baseParams, lifetimeBossesDefeated: 100 });
expect(result).toContain("lyra");
});
it("does not unlock lyra below threshold", () => {
const result = computeUnlockedCompanionIds({ ...baseParams, lifetimeBossesDefeated: 99 });
expect(result).not.toContain("lyra");
});
it("unlocks finn at 100 lifetime quests", () => {
const result = computeUnlockedCompanionIds({ ...baseParams, lifetimeQuestsCompleted: 100 });
expect(result).toContain("finn");
});
it("unlocks wren at 500 lifetime quests", () => {
const result = computeUnlockedCompanionIds({ ...baseParams, lifetimeQuestsCompleted: 500 });
expect(result).toContain("finn");
expect(result).toContain("wren");
});
it("unlocks aldric at 200 lifetime bosses", () => {
const result = computeUnlockedCompanionIds({ ...baseParams, lifetimeBossesDefeated: 200 });
expect(result).toContain("lyra");
expect(result).toContain("aldric");
});
it("unlocks sera at 10 prestiges", () => {
const result = computeUnlockedCompanionIds({ ...baseParams, prestigeCount: 10 });
expect(result).toContain("sera");
});
it("does not unlock sera below threshold", () => {
const result = computeUnlockedCompanionIds({ ...baseParams, prestigeCount: 9 });
expect(result).not.toContain("sera");
});
it("unlocks kael at 720 lifetime bosses", () => {
const result = computeUnlockedCompanionIds({ ...baseParams, lifetimeBossesDefeated: 720 });
expect(result).toContain("kael");
expect(result).toContain("lyra");
expect(result).toContain("aldric");
});
it("unlocks zuri at 950 lifetime quests", () => {
const result = computeUnlockedCompanionIds({ ...baseParams, lifetimeQuestsCompleted: 950 });
expect(result).toContain("zuri");
});
it("unlocks mira at 1e18 lifetime gold", () => {
const result = computeUnlockedCompanionIds({ ...baseParams, lifetimeGoldEarned: 1e18 });
expect(result).toContain("mira");
});
it("does not unlock mira below threshold", () => {
const result = computeUnlockedCompanionIds({ ...baseParams, lifetimeGoldEarned: 9.99e17 });
expect(result).not.toContain("mira");
});
it("unlocks vex at 5 transcendences", () => {
const result = computeUnlockedCompanionIds({ ...baseParams, transcendenceCount: 5 });
expect(result).toContain("vex");
});
it("does not unlock vex below threshold", () => {
const result = computeUnlockedCompanionIds({ ...baseParams, transcendenceCount: 4 });
expect(result).not.toContain("vex");
});
it("unlocks pria at 1 apotheosis", () => {
const result = computeUnlockedCompanionIds({ ...baseParams, apotheosisCount: 1 });
expect(result).toContain("pria");
});
it("unlocks multiple companions simultaneously", () => {
const result = computeUnlockedCompanionIds({
lifetimeBossesDefeated: 720,
lifetimeQuestsCompleted: 950,
lifetimeGoldEarned: 1e18,
prestigeCount: 10,
transcendenceCount: 5,
apotheosisCount: 1,
});
expect(result).toHaveLength(10);
expect(result).toContain("lyra");
expect(result).toContain("finn");
expect(result).toContain("wren");
expect(result).toContain("aldric");
expect(result).toContain("sera");
expect(result).toContain("kael");
expect(result).toContain("zuri");
expect(result).toContain("mira");
expect(result).toContain("vex");
expect(result).toContain("pria");
});
});
describe("getActiveCompanionBonus", () => {
it("returns null when activeCompanionId is null", () => {
expect(getActiveCompanionBonus(null, ["lyra"])).toBeNull();
});
it("returns null when activeCompanionId is undefined", () => {
expect(getActiveCompanionBonus(undefined, ["lyra"])).toBeNull();
});
it("returns null when companion is not in unlockedCompanionIds", () => {
expect(getActiveCompanionBonus("lyra", [])).toBeNull();
});
it("returns null for an unknown companion id even if in unlocked list", () => {
expect(getActiveCompanionBonus("unknown_companion", ["unknown_companion"])).toBeNull();
});
it("returns the bonus for lyra when unlocked", () => {
const bonus = getActiveCompanionBonus("lyra", ["lyra"]);
expect(bonus).toEqual({ type: "passiveGold", value: 0.25 });
});
it("returns the bonus for finn when unlocked", () => {
const bonus = getActiveCompanionBonus("finn", ["lyra", "finn"]);
expect(bonus).toEqual({ type: "clickGold", value: 0.50 });
});
it("returns the bonus for wren when unlocked", () => {
const bonus = getActiveCompanionBonus("wren", ["wren"]);
expect(bonus).toEqual({ type: "questTime", value: 0.15 });
});
it("returns the bonus for vex when unlocked", () => {
const bonus = getActiveCompanionBonus("vex", ["vex"]);
expect(bonus).toEqual({ type: "essenceIncome", value: 0.75 });
});
it("returns the bonus for pria when unlocked", () => {
const bonus = getActiveCompanionBonus("pria", ["pria"]);
expect(bonus).toEqual({ type: "passiveGold", value: 1.00 });
});
});
+97
View File
@@ -0,0 +1,97 @@
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
import { describe, expect, it } from "vitest";
import { computeSetBonuses } from "../src/interfaces/equipmentSet.js";
import type { EquipmentSet } from "../src/interfaces/equipmentSet.js";
const makeSet = (partial: Partial<EquipmentSet> & Pick<EquipmentSet, "pieces">): EquipmentSet => ({
id: "test_set",
name: "Test Set",
description: "A test equipment set",
bonuses: {
2: {},
3: {},
},
...partial,
});
describe("computeSetBonuses", () => {
it("returns all multipliers as 1 when no equipped items", () => {
const result = computeSetBonuses([], []);
expect(result).toEqual({ goldMultiplier: 1, combatMultiplier: 1, clickMultiplier: 1 });
});
it("returns all multipliers as 1 when sets array is empty", () => {
const result = computeSetBonuses(["item1", "item2"], []);
expect(result).toEqual({ goldMultiplier: 1, combatMultiplier: 1, clickMultiplier: 1 });
});
it("returns 1 for all when only 1 piece of a set is equipped", () => {
const set = makeSet({
pieces: ["sword", "shield", "helm"],
bonuses: { 2: { goldMultiplier: 1.5 }, 3: { goldMultiplier: 2.0 } },
});
const result = computeSetBonuses(["sword"], [set]);
expect(result).toEqual({ goldMultiplier: 1, combatMultiplier: 1, clickMultiplier: 1 });
});
it("applies 2-piece bonus when exactly 2 pieces are equipped", () => {
const set = makeSet({
pieces: ["sword", "shield", "helm"],
bonuses: { 2: { goldMultiplier: 1.5 }, 3: { combatMultiplier: 2.0 } },
});
const result = computeSetBonuses(["sword", "shield"], [set]);
expect(result).toEqual({ goldMultiplier: 1.5, combatMultiplier: 1, clickMultiplier: 1 });
});
it("applies both 2-piece and 3-piece bonuses when all 3 pieces are equipped", () => {
const set = makeSet({
pieces: ["sword", "shield", "helm"],
bonuses: { 2: { goldMultiplier: 1.5 }, 3: { combatMultiplier: 2.0 } },
});
const result = computeSetBonuses(["sword", "shield", "helm"], [set]);
expect(result).toEqual({ goldMultiplier: 1.5, combatMultiplier: 2.0, clickMultiplier: 1 });
});
it("applies click multiplier from set bonuses", () => {
const set = makeSet({
pieces: ["wand", "tome"],
bonuses: { 2: { clickMultiplier: 1.25 }, 3: {} },
});
const result = computeSetBonuses(["wand", "tome"], [set]);
expect(result).toEqual({ goldMultiplier: 1, combatMultiplier: 1, clickMultiplier: 1.25 });
});
it("stacks bonuses from multiple sets multiplicatively", () => {
const setA = makeSet({
id: "set_a",
pieces: ["sword", "shield"],
bonuses: { 2: { goldMultiplier: 1.5 }, 3: {} },
});
const setB = makeSet({
id: "set_b",
pieces: ["ring", "amulet"],
bonuses: { 2: { goldMultiplier: 2.0 }, 3: {} },
});
const result = computeSetBonuses(["sword", "shield", "ring", "amulet"], [setA, setB]);
expect(result.goldMultiplier).toBeCloseTo(3.0);
expect(result.combatMultiplier).toBe(1);
});
it("uses 1 as fallback when bonus property is undefined", () => {
const set = makeSet({
pieces: ["a", "b"],
bonuses: { 2: {}, 3: {} },
});
const result = computeSetBonuses(["a", "b"], [set]);
expect(result).toEqual({ goldMultiplier: 1, combatMultiplier: 1, clickMultiplier: 1 });
});
it("ignores non-equipped pieces that don't match the set", () => {
const set = makeSet({
pieces: ["sword", "shield"],
bonuses: { 2: { goldMultiplier: 1.5 }, 3: {} },
});
const result = computeSetBonuses(["bow", "quiver"], [set]);
expect(result).toEqual({ goldMultiplier: 1, combatMultiplier: 1, clickMultiplier: 1 });
});
});
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_PROFILE_SETTINGS } from "../src/interfaces/profileSettings.js";
describe("DEFAULT_PROFILE_SETTINGS", () => {
it("has all visibility flags set to true by default", () => {
expect(DEFAULT_PROFILE_SETTINGS.showTotalGold).toBe(true);
expect(DEFAULT_PROFILE_SETTINGS.showTotalClicks).toBe(true);
expect(DEFAULT_PROFILE_SETTINGS.showLifetimeBossesDefeated).toBe(true);
expect(DEFAULT_PROFILE_SETTINGS.showLifetimeQuestsCompleted).toBe(true);
expect(DEFAULT_PROFILE_SETTINGS.showLifetimeAdventurersRecruited).toBe(true);
expect(DEFAULT_PROFILE_SETTINGS.showLifetimeAchievementsUnlocked).toBe(true);
expect(DEFAULT_PROFILE_SETTINGS.showGuildFounded).toBe(true);
expect(DEFAULT_PROFILE_SETTINGS.showCurrentGold).toBe(true);
expect(DEFAULT_PROFILE_SETTINGS.showCurrentClicks).toBe(true);
expect(DEFAULT_PROFILE_SETTINGS.showPrestige).toBe(true);
expect(DEFAULT_PROFILE_SETTINGS.showTranscendence).toBe(true);
expect(DEFAULT_PROFILE_SETTINGS.showApotheosis).toBe(true);
expect(DEFAULT_PROFILE_SETTINGS.showBossesDefeated).toBe(true);
expect(DEFAULT_PROFILE_SETTINGS.showQuestsCompleted).toBe(true);
expect(DEFAULT_PROFILE_SETTINGS.showAdventurersRecruited).toBe(true);
expect(DEFAULT_PROFILE_SETTINGS.showAchievementsUnlocked).toBe(true);
expect(DEFAULT_PROFILE_SETTINGS.showOnLeaderboards).toBe(true);
expect(DEFAULT_PROFILE_SETTINGS.enableSounds).toBe(false);
expect(DEFAULT_PROFILE_SETTINGS.enableNotifications).toBe(false);
});
it("defaults numberFormat to suffix", () => {
expect(DEFAULT_PROFILE_SETTINGS.numberFormat).toBe("suffix");
});
});
+164
View File
@@ -0,0 +1,164 @@
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
import { describe, expect, it } from "vitest";
import { isStoryChapterUnlocked } from "../src/interfaces/story.js";
import type { StoryChapter } from "../src/interfaces/story.js";
import type { GameState } from "../src/interfaces/gameState.js";
const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
({
bosses: [],
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
transcendence: undefined,
apotheosis: undefined,
...overrides,
} as unknown as GameState);
const makeChapter = (unlock: StoryChapter["unlock"]): StoryChapter => ({
id: "test_chapter",
title: "Test Chapter",
content: "Test content",
choices: [
{ id: "a", label: "Choice A", outcome: "Outcome A" },
{ id: "b", label: "Choice B", outcome: "Outcome B" },
{ id: "c", label: "Choice C", outcome: "Outcome C" },
],
unlock,
});
describe("isStoryChapterUnlocked — bossDefeated", () => {
it("returns true when the boss is defeated", () => {
const chapter = makeChapter({ type: "bossDefeated", bossId: "boss_1" });
const state = makeMinimalState({
bosses: [{ id: "boss_1", status: "defeated" }] as GameState["bosses"],
});
expect(isStoryChapterUnlocked(chapter, state)).toBe(true);
});
it("returns false when the boss is available but not defeated", () => {
const chapter = makeChapter({ type: "bossDefeated", bossId: "boss_1" });
const state = makeMinimalState({
bosses: [{ id: "boss_1", status: "available" }] as GameState["bosses"],
});
expect(isStoryChapterUnlocked(chapter, state)).toBe(false);
});
it("returns false when the boss is not in the list", () => {
const chapter = makeChapter({ type: "bossDefeated", bossId: "boss_1" });
const state = makeMinimalState({ bosses: [] });
expect(isStoryChapterUnlocked(chapter, state)).toBe(false);
});
it("returns false when a different boss is defeated", () => {
const chapter = makeChapter({ type: "bossDefeated", bossId: "boss_1" });
const state = makeMinimalState({
bosses: [{ id: "boss_2", status: "defeated" }] as GameState["bosses"],
});
expect(isStoryChapterUnlocked(chapter, state)).toBe(false);
});
});
describe("isStoryChapterUnlocked — prestige", () => {
it("returns true when prestige count meets threshold", () => {
const chapter = makeChapter({ type: "prestige", threshold: 1 });
const state = makeMinimalState({
prestige: { count: 1, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
});
expect(isStoryChapterUnlocked(chapter, state)).toBe(true);
});
it("returns true when prestige count exceeds threshold", () => {
const chapter = makeChapter({ type: "prestige", threshold: 5 });
const state = makeMinimalState({
prestige: { count: 10, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
});
expect(isStoryChapterUnlocked(chapter, state)).toBe(true);
});
it("returns false when prestige count is below threshold", () => {
const chapter = makeChapter({ type: "prestige", threshold: 5 });
const state = makeMinimalState({
prestige: { count: 4, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
});
expect(isStoryChapterUnlocked(chapter, state)).toBe(false);
});
it("defaults to threshold 1 when threshold is undefined", () => {
const chapter = makeChapter({ type: "prestige" });
const state = makeMinimalState({
prestige: { count: 1, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
});
expect(isStoryChapterUnlocked(chapter, state)).toBe(true);
});
});
describe("isStoryChapterUnlocked — transcendence", () => {
it("returns true when transcendence count meets threshold", () => {
const chapter = makeChapter({ type: "transcendence", threshold: 1 });
const state = makeMinimalState({
transcendence: { count: 1, echoes: 0, purchasedUpgradeIds: [], echoIncomeMultiplier: 1, echoCombatMultiplier: 1, echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1 },
});
expect(isStoryChapterUnlocked(chapter, state)).toBe(true);
});
it("returns false when transcendence is undefined", () => {
const chapter = makeChapter({ type: "transcendence", threshold: 1 });
const state = makeMinimalState({ transcendence: undefined });
expect(isStoryChapterUnlocked(chapter, state)).toBe(false);
});
it("returns false when transcendence count is below threshold", () => {
const chapter = makeChapter({ type: "transcendence", threshold: 3 });
const state = makeMinimalState({
transcendence: { count: 2, echoes: 0, purchasedUpgradeIds: [], echoIncomeMultiplier: 1, echoCombatMultiplier: 1, echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1 },
});
expect(isStoryChapterUnlocked(chapter, state)).toBe(false);
});
});
describe("isStoryChapterUnlocked — apotheosis", () => {
it("returns true when apotheosis count meets threshold", () => {
const chapter = makeChapter({ type: "apotheosis", threshold: 1 });
const state = makeMinimalState({
apotheosis: { count: 1 },
});
expect(isStoryChapterUnlocked(chapter, state)).toBe(true);
});
it("returns false when apotheosis is undefined", () => {
const chapter = makeChapter({ type: "apotheosis", threshold: 1 });
const state = makeMinimalState({ apotheosis: undefined });
expect(isStoryChapterUnlocked(chapter, state)).toBe(false);
});
it("returns false when apotheosis count is below threshold", () => {
const chapter = makeChapter({ type: "apotheosis", threshold: 2 });
const state = makeMinimalState({ apotheosis: { count: 1 } });
expect(isStoryChapterUnlocked(chapter, state)).toBe(false);
});
});
describe("isStoryChapterUnlocked — unknown type (defensive branch)", () => {
it("returns false for unknown unlock type", () => {
/* eslint-disable @typescript-eslint/consistent-type-assertions -- testing defensive branch */
const chapter = makeChapter({ type: "unknown" as StoryChapter["unlock"]["type"] });
/* eslint-enable @typescript-eslint/consistent-type-assertions */
const state = makeMinimalState();
expect(isStoryChapterUnlocked(chapter, state)).toBe(false);
});
});
describe("isStoryChapterUnlocked — threshold defaults", () => {
it("defaults transcendence threshold to 1 when undefined", () => {
const chapter = makeChapter({ type: "transcendence" });
const state = makeMinimalState({
transcendence: { count: 1, echoes: 0, purchasedUpgradeIds: [], echoIncomeMultiplier: 1, echoCombatMultiplier: 1, echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1 },
});
expect(isStoryChapterUnlocked(chapter, state)).toBe(true);
});
it("defaults apotheosis threshold to 1 when undefined", () => {
const chapter = makeChapter({ type: "apotheosis" });
const state = makeMinimalState({ apotheosis: { count: 1 } });
expect(isStoryChapterUnlocked(chapter, state)).toBe(true);
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@nhcarrigan/typescript-config",
"compilerOptions": {
"outDir": "./prod",
"rootDir": ".",
"declaration": true
},
"exclude": ["test/**/*.ts", "prod/**"]
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
provider: "v8",
include: [
"src/interfaces/companion.ts",
"src/interfaces/equipmentSet.ts",
"src/interfaces/profileSettings.ts",
"src/interfaces/story.ts",
],
exclude: [],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
include: ["test/**/*.spec.ts"],
},
});