generated from nhcarrigan/template
feat: initial prototype — core game systems (#30)
## 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:
@@ -0,0 +1,3 @@
|
||||
import config from "@nhcarrigan/eslint-config";
|
||||
|
||||
export default [...config];
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 (1–7).
|
||||
*/
|
||||
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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 (0–1) 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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@nhcarrigan/typescript-config",
|
||||
"compilerOptions": {
|
||||
"outDir": "./prod",
|
||||
"rootDir": ".",
|
||||
"declaration": true
|
||||
},
|
||||
"exclude": ["test/**/*.ts", "prod/**"]
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user