chore: fix lint, ensure full CI pipeline passes, add verify checklist

- Fix strict-boolean-expressions in 7 route files (runtime body validation)
- Fix no-unnecessary-condition in profile.ts and offlineProgress.ts (defensive null checks)
- Extend v8 ignore next-N counts in game.ts to reach 100% coverage
- Add CI requirements to CLAUDE.md (lint + build + test must pass before commit)
- Add manual verification checklist (verify.md)
- Remove progress.md
This commit is contained in:
2026-03-08 13:59:38 -07:00
committed by Naomi Carrigan
parent b67eae9d46
commit d1d1f70c75
202 changed files with 28076 additions and 16758 deletions
+2 -2
View File
@@ -1,3 +1,3 @@
import { NaomisConfig } from "@nhcarrigan/eslint-config";
import config from "@nhcarrigan/eslint-config";
export default [...NaomisConfig];
export default [...config];
+236 -229
View File
@@ -1,359 +1,366 @@
/**
* @file Game data definitions.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines -- Data file */
import type { Achievement } from "@elysium/types";
export const DEFAULT_ACHIEVEMENTS: Achievement[] = [
export const defaultAchievements: Array<Achievement> = [
// Click milestones
{
id: "first_click",
name: "First Strike",
condition: { amount: 1, type: "totalClicks" },
description: "Click the Guild Hall for the first time.",
icon: "👆",
condition: { type: "totalClicks", amount: 1 },
reward: { crystals: 5 },
unlockedAt: null,
icon: "👆",
id: "first_click",
name: "First Strike",
reward: { crystals: 5 },
unlockedAt: null,
},
{
id: "click_enthusiast",
name: "Click Enthusiast",
condition: { amount: 100, type: "totalClicks" },
description: "Click the Guild Hall 100 times.",
icon: "🖱️",
condition: { type: "totalClicks", amount: 100 },
reward: { crystals: 25 },
unlockedAt: null,
icon: "🖱️",
id: "click_enthusiast",
name: "Click Enthusiast",
reward: { crystals: 25 },
unlockedAt: null,
},
{
id: "click_master",
name: "Click Master",
condition: { amount: 1000, type: "totalClicks" },
description: "Click the Guild Hall 1,000 times.",
icon: "⚡",
condition: { type: "totalClicks", amount: 1_000 },
reward: { crystals: 100 },
unlockedAt: null,
icon: "⚡",
id: "click_master",
name: "Click Master",
reward: { crystals: 100 },
unlockedAt: null,
},
{
id: "click_legend",
name: "Click Legend",
condition: { amount: 10_000, type: "totalClicks" },
description: "Click the Guild Hall 10,000 times.",
icon: "🌩️",
condition: { type: "totalClicks", amount: 10_000 },
reward: { crystals: 300 },
unlockedAt: null,
icon: "🌩️",
id: "click_legend",
name: "Click Legend",
reward: { crystals: 300 },
unlockedAt: null,
},
// Gold milestones
{
id: "first_gold",
name: "First Gold",
condition: { amount: 100, type: "totalGoldEarned" },
description: "Earn your first 100 gold.",
icon: "🪙",
condition: { type: "totalGoldEarned", amount: 100 },
reward: { crystals: 5 },
unlockedAt: null,
icon: "🪙",
id: "first_gold",
name: "First Gold",
reward: { crystals: 5 },
unlockedAt: null,
},
{
id: "wealthy",
name: "Wealthy",
condition: { amount: 10_000, type: "totalGoldEarned" },
description: "Earn 10,000 gold in total.",
icon: "💰",
condition: { type: "totalGoldEarned", amount: 10_000 },
reward: { crystals: 25 },
unlockedAt: null,
icon: "💰",
id: "wealthy",
name: "Wealthy",
reward: { crystals: 25 },
unlockedAt: null,
},
{
id: "rich",
name: "Rich",
condition: { amount: 1_000_000, type: "totalGoldEarned" },
description: "Earn 1,000,000 gold in total.",
icon: "👑",
condition: { type: "totalGoldEarned", amount: 1_000_000 },
reward: { crystals: 100 },
unlockedAt: null,
icon: "👑",
id: "rich",
name: "Rich",
reward: { crystals: 100 },
unlockedAt: null,
},
{
id: "billionaire",
name: "Billionaire",
condition: { amount: 1_000_000_000, type: "totalGoldEarned" },
description: "Earn 1,000,000,000 gold in total.",
icon: "🏦",
condition: { type: "totalGoldEarned", amount: 1_000_000_000 },
reward: { crystals: 500 },
unlockedAt: null,
icon: "🏦",
id: "billionaire",
name: "Billionaire",
reward: { crystals: 500 },
unlockedAt: null,
},
{
id: "trillionaire",
name: "Trillionaire",
condition: { amount: 1_000_000_000_000, type: "totalGoldEarned" },
description: "Earn 1,000,000,000,000 gold in total.",
icon: "💎",
condition: { type: "totalGoldEarned", amount: 1_000_000_000_000 },
reward: { crystals: 2_000 },
unlockedAt: null,
icon: "💎",
id: "trillionaire",
name: "Trillionaire",
reward: { crystals: 2000 },
unlockedAt: null,
},
// Quest milestones
{
id: "first_quest",
name: "Adventurous Spirit",
condition: { amount: 1, type: "questsCompleted" },
description: "Complete your first quest.",
icon: "📜",
condition: { type: "questsCompleted", amount: 1 },
reward: { crystals: 10 },
unlockedAt: null,
icon: "📜",
id: "first_quest",
name: "Adventurous Spirit",
reward: { crystals: 10 },
unlockedAt: null,
},
{
id: "quest_veteran",
name: "Quest Veteran",
condition: { amount: 5, type: "questsCompleted" },
description: "Complete 5 quests.",
icon: "📚",
condition: { type: "questsCompleted", amount: 5 },
reward: { crystals: 50 },
unlockedAt: null,
icon: "📚",
id: "quest_veteran",
name: "Quest Veteran",
reward: { crystals: 50 },
unlockedAt: null,
},
{
id: "quest_master",
name: "Quest Master",
condition: { amount: 15, type: "questsCompleted" },
description: "Complete 15 quests.",
icon: "🗺️",
condition: { type: "questsCompleted", amount: 15 },
reward: { crystals: 200 },
unlockedAt: null,
icon: "🗺️",
id: "quest_master",
name: "Quest Master",
reward: { crystals: 200 },
unlockedAt: null,
},
// Boss milestones
{
id: "boss_slayer",
name: "Boss Slayer",
condition: { amount: 1, type: "bossesDefeated" },
description: "Defeat your first boss.",
icon: "⚔️",
condition: { type: "bossesDefeated", amount: 1 },
reward: { crystals: 25 },
unlockedAt: null,
icon: "⚔️",
id: "boss_slayer",
name: "Boss Slayer",
reward: { crystals: 25 },
unlockedAt: null,
},
{
id: "boss_veteran",
name: "Boss Veteran",
condition: { amount: 5, type: "bossesDefeated" },
description: "Defeat 5 bosses.",
icon: "🗡️",
condition: { type: "bossesDefeated", amount: 5 },
reward: { crystals: 150 },
unlockedAt: null,
icon: "🗡️",
id: "boss_veteran",
name: "Boss Veteran",
reward: { crystals: 150 },
unlockedAt: null,
},
{
id: "legendary_hunter",
name: "Legendary Hunter",
condition: { amount: 10, type: "bossesDefeated" },
description: "Defeat 10 bosses.",
icon: "🏆",
condition: { type: "bossesDefeated", amount: 10 },
reward: { crystals: 500 },
unlockedAt: null,
icon: "🏆",
id: "legendary_hunter",
name: "Legendary Hunter",
reward: { crystals: 500 },
unlockedAt: null,
},
{
id: "devourer_slayer",
name: "World Saver",
condition: { amount: 18, type: "bossesDefeated" },
description: "Defeat all 18 bosses, including the Devourer of Worlds.",
icon: "🌟",
condition: { type: "bossesDefeated", amount: 18 },
reward: { crystals: 2_000 },
unlockedAt: null,
icon: "🌟",
id: "devourer_slayer",
name: "World Saver",
reward: { crystals: 2000 },
unlockedAt: null,
},
// Adventurer milestones
{
id: "guild_master",
name: "Guild Master",
condition: { amount: 50, type: "adventurerTotal" },
description: "Recruit a total of 50 adventurers.",
icon: "🏰",
condition: { type: "adventurerTotal", amount: 50 },
reward: { crystals: 50 },
unlockedAt: null,
icon: "🏰",
id: "guild_master",
name: "Guild Master",
reward: { crystals: 50 },
unlockedAt: null,
},
{
id: "army_commander",
name: "Army Commander",
condition: { amount: 500, type: "adventurerTotal" },
description: "Recruit a total of 500 adventurers.",
icon: "🛡️",
condition: { type: "adventurerTotal", amount: 500 },
reward: { crystals: 200 },
unlockedAt: null,
icon: "🛡️",
id: "army_commander",
name: "Army Commander",
reward: { crystals: 200 },
unlockedAt: null,
},
{
id: "army_legend",
name: "Legendary Commander",
condition: { amount: 5000, type: "adventurerTotal" },
description: "Recruit a total of 5,000 adventurers.",
icon: "⚜️",
condition: { type: "adventurerTotal", amount: 5_000 },
reward: { crystals: 750 },
unlockedAt: null,
icon: "⚜️",
id: "army_legend",
name: "Legendary Commander",
reward: { crystals: 750 },
unlockedAt: null,
},
// Prestige milestones
{
id: "first_prestige",
name: "Born Again",
condition: { amount: 1, type: "prestigeCount" },
description: "Prestige for the first time.",
icon: "⭐",
condition: { type: "prestigeCount", amount: 1 },
reward: { crystals: 100 },
unlockedAt: null,
icon: "⭐",
id: "first_prestige",
name: "Born Again",
reward: { crystals: 100 },
unlockedAt: null,
},
// Collection milestones
{
id: "collector",
name: "Collector",
condition: { amount: 4, type: "equipmentOwned" },
description: "Acquire your first piece of boss-dropped equipment.",
icon: "🎒",
condition: { type: "equipmentOwned", amount: 4 },
reward: { crystals: 10 },
unlockedAt: null,
icon: "🎒",
id: "collector",
name: "Collector",
reward: { crystals: 10 },
unlockedAt: null,
},
{
id: "arsenal",
name: "Arsenal",
condition: { amount: 12, type: "equipmentOwned" },
description: "Own 12 pieces of equipment.",
icon: "🗃️",
condition: { type: "equipmentOwned", amount: 12 },
reward: { crystals: 200 },
unlockedAt: null,
icon: "🗃️",
id: "arsenal",
name: "Arsenal",
reward: { crystals: 200 },
unlockedAt: null,
},
{
id: "well_armed",
name: "Well Armed",
condition: { amount: 25, type: "equipmentOwned" },
description: "Own 25 pieces of equipment.",
icon: "⚔️",
condition: { type: "equipmentOwned", amount: 25 },
reward: { crystals: 1_000 },
unlockedAt: null,
icon: "⚔️",
id: "well_armed",
name: "Well Armed",
reward: { crystals: 1000 },
unlockedAt: null,
},
{
id: "fully_equipped",
name: "Fully Equipped",
condition: { amount: 40, type: "equipmentOwned" },
description: "Own 40 pieces of equipment.",
icon: "🛡️",
condition: { type: "equipmentOwned", amount: 40 },
reward: { crystals: 10_000 },
unlockedAt: null,
icon: "🛡️",
id: "fully_equipped",
name: "Fully Equipped",
reward: { crystals: 10_000 },
unlockedAt: null,
},
// Higher click milestones
{
id: "click_obsessed",
name: "Click Obsessed",
condition: { amount: 100_000, type: "totalClicks" },
description: "Click the Guild Hall 100,000 times.",
icon: "💥",
condition: { type: "totalClicks", amount: 100_000 },
reward: { crystals: 1_000 },
unlockedAt: null,
icon: "💥",
id: "click_obsessed",
name: "Click Obsessed",
reward: { crystals: 1000 },
unlockedAt: null,
},
{
id: "click_deity",
name: "Click Deity",
condition: { amount: 1_000_000, type: "totalClicks" },
description: "Click the Guild Hall 1,000,000 times.",
icon: "☄️",
condition: { type: "totalClicks", amount: 1_000_000 },
reward: { crystals: 5_000 },
unlockedAt: null,
icon: "☄️",
id: "click_deity",
name: "Click Deity",
reward: { crystals: 5000 },
unlockedAt: null,
},
// Endgame gold milestones
{
id: "quadrillionaire",
name: "Quadrillionaire",
condition: { amount: 1e15, type: "totalGoldEarned" },
description: "Earn 1 quadrillion gold in total.",
icon: "✨",
condition: { type: "totalGoldEarned", amount: 1e15 },
reward: { crystals: 10_000 },
unlockedAt: null,
icon: "✨",
id: "quadrillionaire",
name: "Quadrillionaire",
reward: { crystals: 10_000 },
unlockedAt: null,
},
{
id: "void_hoarder",
name: "Void Hoarder",
condition: { amount: 1e18, type: "totalGoldEarned" },
description: "Earn 1 quintillion gold in total.",
icon: "🌀",
condition: { type: "totalGoldEarned", amount: 1e18 },
reward: { crystals: 50_000 },
unlockedAt: null,
icon: "🌀",
id: "void_hoarder",
name: "Void Hoarder",
reward: { crystals: 50_000 },
unlockedAt: null,
},
// Higher quest milestones
{
id: "quest_champion",
name: "Quest Champion",
condition: { amount: 30, type: "questsCompleted" },
description: "Complete 30 quests.",
icon: "🏅",
condition: { type: "questsCompleted", amount: 30 },
reward: { crystals: 1_000 },
unlockedAt: null,
icon: "🏅",
id: "quest_champion",
name: "Quest Champion",
reward: { crystals: 1000 },
unlockedAt: null,
},
{
id: "quest_grandmaster",
name: "Quest Grandmaster",
condition: { amount: 50, type: "questsCompleted" },
description: "Complete 50 quests.",
icon: "🎖️",
condition: { type: "questsCompleted", amount: 50 },
reward: { crystals: 5_000 },
unlockedAt: null,
icon: "🎖️",
id: "quest_grandmaster",
name: "Quest Grandmaster",
reward: { crystals: 5000 },
unlockedAt: null,
},
{
id: "quest_eternal",
name: "Quest Eternal",
condition: { amount: 72, type: "questsCompleted" },
description: "Complete all 72 quests across the known multiverse.",
icon: "🌌",
condition: { type: "questsCompleted", amount: 72 },
reward: { crystals: 25_000 },
unlockedAt: null,
icon: "🌌",
id: "quest_eternal",
name: "Quest Eternal",
reward: { crystals: 25_000 },
unlockedAt: null,
},
// Higher boss milestones
{
id: "boss_champion",
name: "Champion of the Realm",
condition: { amount: 20, type: "bossesDefeated" },
description: "Defeat 20 bosses.",
icon: "🦁",
condition: { type: "bossesDefeated", amount: 20 },
reward: { crystals: 1_000 },
unlockedAt: null,
icon: "🦁",
id: "boss_champion",
name: "Champion of the Realm",
reward: { crystals: 1000 },
unlockedAt: null,
},
{
id: "boss_grandmaster",
name: "Grandmaster Hunter",
condition: { amount: 30, type: "bossesDefeated" },
description: "Defeat 30 bosses.",
icon: "🔱",
condition: { type: "bossesDefeated", amount: 30 },
reward: { crystals: 5_000 },
unlockedAt: null,
icon: "🔱",
id: "boss_grandmaster",
name: "Grandmaster Hunter",
reward: { crystals: 5000 },
unlockedAt: null,
},
{
id: "boss_eternal",
name: "Eternal Vanquisher",
condition: { amount: 60, type: "bossesDefeated" },
description: "Defeat all 60 bosses across every plane of existence.",
icon: "💀",
condition: { type: "bossesDefeated", amount: 60 },
reward: { crystals: 50_000 },
unlockedAt: null,
icon: "💀",
id: "boss_eternal",
name: "Eternal Vanquisher",
reward: { crystals: 50_000 },
unlockedAt: null,
},
// Higher adventurer milestones
{
id: "army_titan",
name: "Titan Commander",
condition: { amount: 50_000, type: "adventurerTotal" },
description: "Recruit a total of 50,000 adventurers.",
icon: "⚡",
condition: { type: "adventurerTotal", amount: 50_000 },
reward: { crystals: 5_000 },
unlockedAt: null,
icon: "⚡",
id: "army_titan",
name: "Titan Commander",
reward: { crystals: 5000 },
unlockedAt: null,
},
// Higher prestige milestones
{
id: "prestige_veteran",
name: "Veteran of Ages",
condition: { amount: 5, type: "prestigeCount" },
description: "Prestige 5 times.",
icon: "🌟",
condition: { type: "prestigeCount", amount: 5 },
reward: { crystals: 1_000 },
unlockedAt: null,
icon: "🌟",
id: "prestige_veteran",
name: "Veteran of Ages",
reward: { crystals: 1000 },
unlockedAt: null,
},
{
id: "prestige_master",
name: "Master of Cycles",
condition: { amount: 10, type: "prestigeCount" },
description: "Prestige 10 times.",
icon: "💫",
condition: { type: "prestigeCount", amount: 10 },
reward: { crystals: 5_000 },
unlockedAt: null,
icon: "💫",
id: "prestige_master",
name: "Master of Cycles",
reward: { crystals: 5000 },
unlockedAt: null,
},
{
id: "prestige_legend",
name: "Legend of Eternity",
condition: { amount: 25, type: "prestigeCount" },
description: "Prestige 25 times.",
icon: "🌠",
condition: { type: "prestigeCount", amount: 25 },
reward: { crystals: 25_000 },
unlockedAt: null,
icon: "🌠",
id: "prestige_legend",
name: "Legend of Eternity",
reward: { crystals: 25_000 },
unlockedAt: null,
},
];
+298 -291
View File
@@ -1,388 +1,395 @@
/**
* @file Game data definitions.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines -- Data file */
import type { Adventurer } from "@elysium/types";
export const DEFAULT_ADVENTURERS: Adventurer[] = [
export const defaultAdventurers: Array<Adventurer> = [
{
id: "peasant",
name: "Peasant",
class: "warrior",
level: 1,
baseCost: 10,
goldPerSecond: 0.1,
baseCost: 10,
class: "warrior",
combatPower: 1,
count: 0,
essencePerSecond: 0,
combatPower: 1,
count: 0,
unlocked: true,
goldPerSecond: 0.1,
id: "peasant",
level: 1,
name: "Peasant",
unlocked: true,
},
{
id: "militia",
name: "Militia",
class: "warrior",
level: 2,
baseCost: 100,
goldPerSecond: 0.5,
baseCost: 100,
class: "warrior",
combatPower: 3,
count: 0,
essencePerSecond: 0,
combatPower: 3,
count: 0,
unlocked: false,
goldPerSecond: 0.5,
id: "militia",
level: 2,
name: "Militia",
unlocked: false,
},
{
id: "apprentice",
name: "Apprentice Mage",
class: "mage",
level: 3,
baseCost: 750,
goldPerSecond: 1.5,
baseCost: 750,
class: "mage",
combatPower: 8,
count: 0,
essencePerSecond: 0.01,
combatPower: 8,
count: 0,
unlocked: false,
goldPerSecond: 1.5,
id: "apprentice",
level: 3,
name: "Apprentice Mage",
unlocked: false,
},
{
id: "scout",
name: "Scout",
class: "rogue",
level: 4,
baseCost: 5_000,
goldPerSecond: 4,
baseCost: 5000,
class: "rogue",
combatPower: 20,
count: 0,
essencePerSecond: 0.02,
combatPower: 20,
count: 0,
unlocked: false,
goldPerSecond: 4,
id: "scout",
level: 4,
name: "Scout",
unlocked: false,
},
{
id: "acolyte",
name: "Acolyte",
class: "cleric",
level: 5,
baseCost: 35_000,
goldPerSecond: 10,
baseCost: 35_000,
class: "cleric",
combatPower: 50,
count: 0,
essencePerSecond: 0.05,
combatPower: 50,
count: 0,
unlocked: false,
goldPerSecond: 10,
id: "acolyte",
level: 5,
name: "Acolyte",
unlocked: false,
},
{
id: "ranger",
name: "Ranger",
class: "ranger",
level: 6,
baseCost: 250_000,
goldPerSecond: 25,
baseCost: 250_000,
class: "ranger",
combatPower: 120,
count: 0,
essencePerSecond: 0.1,
combatPower: 120,
count: 0,
unlocked: false,
goldPerSecond: 25,
id: "ranger",
level: 6,
name: "Ranger",
unlocked: false,
},
{
id: "knight",
name: "Knight",
class: "warrior",
level: 7,
baseCost: 1_750_000,
goldPerSecond: 75,
baseCost: 1_750_000,
class: "warrior",
combatPower: 300,
count: 0,
essencePerSecond: 0.2,
combatPower: 300,
count: 0,
unlocked: false,
goldPerSecond: 75,
id: "knight",
level: 7,
name: "Knight",
unlocked: false,
},
{
id: "archmage",
name: "Archmage",
class: "mage",
level: 8,
baseCost: 12_000_000,
goldPerSecond: 200,
baseCost: 12_000_000,
class: "mage",
combatPower: 800,
count: 0,
essencePerSecond: 0.5,
combatPower: 800,
count: 0,
unlocked: false,
goldPerSecond: 200,
id: "archmage",
level: 8,
name: "Archmage",
unlocked: false,
},
{
id: "paladin",
name: "Paladin",
class: "paladin",
level: 9,
baseCost: 85_000_000,
goldPerSecond: 600,
baseCost: 85_000_000,
class: "paladin",
combatPower: 2000,
count: 0,
essencePerSecond: 1,
combatPower: 2000,
count: 0,
unlocked: false,
goldPerSecond: 600,
id: "paladin",
level: 9,
name: "Paladin",
unlocked: false,
},
{
id: "dragon_rider",
name: "Dragon Rider",
class: "ranger",
level: 10,
baseCost: 600_000_000,
goldPerSecond: 2000,
baseCost: 600_000_000,
class: "ranger",
combatPower: 6000,
count: 0,
essencePerSecond: 3,
combatPower: 6000,
count: 0,
unlocked: false,
goldPerSecond: 2000,
id: "dragon_rider",
level: 10,
name: "Dragon Rider",
unlocked: false,
},
{
id: "shadow_assassin",
name: "Shadow Assassin",
class: "rogue",
level: 11,
baseCost: 4_000_000_000,
goldPerSecond: 5_000,
baseCost: 4_000_000_000,
class: "rogue",
combatPower: 18_000,
count: 0,
essencePerSecond: 6,
combatPower: 18_000,
count: 0,
unlocked: false,
goldPerSecond: 5000,
id: "shadow_assassin",
level: 11,
name: "Shadow Assassin",
unlocked: false,
},
{
id: "arcane_scholar",
name: "Arcane Scholar",
class: "mage",
level: 12,
baseCost: 28_000_000_000,
goldPerSecond: 14_000,
baseCost: 28_000_000_000,
class: "mage",
combatPower: 45_000,
count: 0,
essencePerSecond: 15,
combatPower: 45_000,
count: 0,
unlocked: false,
goldPerSecond: 14_000,
id: "arcane_scholar",
level: 12,
name: "Arcane Scholar",
unlocked: false,
},
{
id: "void_walker",
name: "Void Walker",
class: "rogue",
level: 13,
baseCost: 200_000_000_000,
goldPerSecond: 40_000,
baseCost: 200_000_000_000,
class: "rogue",
combatPower: 130_000,
count: 0,
essencePerSecond: 35,
combatPower: 130_000,
count: 0,
unlocked: false,
goldPerSecond: 40_000,
id: "void_walker",
level: 13,
name: "Void Walker",
unlocked: false,
},
{
id: "celestial_guard",
name: "Celestial Guard",
class: "paladin",
level: 14,
baseCost: 1_400_000_000_000,
goldPerSecond: 120_000,
baseCost: 1_400_000_000_000,
class: "paladin",
combatPower: 400_000,
count: 0,
essencePerSecond: 100,
combatPower: 400_000,
count: 0,
unlocked: false,
goldPerSecond: 120_000,
id: "celestial_guard",
level: 14,
name: "Celestial Guard",
unlocked: false,
},
{
id: "divine_champion",
name: "Divine Champion",
class: "warrior",
level: 15,
baseCost: 10_000_000_000_000,
goldPerSecond: 400_000,
baseCost: 10_000_000_000_000,
class: "warrior",
combatPower: 1_200_000,
count: 0,
essencePerSecond: 300,
combatPower: 1_200_000,
count: 0,
unlocked: false,
goldPerSecond: 400_000,
id: "divine_champion",
level: 15,
name: "Divine Champion",
unlocked: false,
},
{
id: "seraph_knight",
name: "Seraph Knight",
class: "paladin",
level: 16,
baseCost: 70_000_000_000_000,
goldPerSecond: 1_200_000,
baseCost: 70_000_000_000_000,
class: "paladin",
combatPower: 4_000_000,
count: 0,
essencePerSecond: 800,
combatPower: 4_000_000,
count: 0,
unlocked: false,
goldPerSecond: 1_200_000,
id: "seraph_knight",
level: 16,
name: "Seraph Knight",
unlocked: false,
},
{
id: "abyss_diver",
name: "Abyss Diver",
class: "rogue",
level: 17,
baseCost: 500_000_000_000_000,
goldPerSecond: 3_500_000,
essencePerSecond: 2_000,
combatPower: 12_000_000,
count: 0,
unlocked: false,
baseCost: 500_000_000_000_000,
class: "rogue",
combatPower: 12_000_000,
count: 0,
essencePerSecond: 2000,
goldPerSecond: 3_500_000,
id: "abyss_diver",
level: 17,
name: "Abyss Diver",
unlocked: false,
},
{
id: "infernal_warden",
name: "Infernal Warden",
class: "warrior",
level: 18,
baseCost: 3_500_000_000_000_000,
goldPerSecond: 10_000_000,
essencePerSecond: 5_000,
combatPower: 35_000_000,
count: 0,
unlocked: false,
baseCost: 3_500_000_000_000_000,
class: "warrior",
combatPower: 35_000_000,
count: 0,
essencePerSecond: 5000,
goldPerSecond: 10_000_000,
id: "infernal_warden",
level: 18,
name: "Infernal Warden",
unlocked: false,
},
{
id: "crystal_sage",
name: "Crystal Sage",
class: "mage",
level: 19,
baseCost: 25_000_000_000_000_000,
goldPerSecond: 30_000_000,
baseCost: 25_000_000_000_000_000,
class: "mage",
combatPower: 100_000_000,
count: 0,
essencePerSecond: 12_000,
combatPower: 100_000_000,
count: 0,
unlocked: false,
goldPerSecond: 30_000_000,
id: "crystal_sage",
level: 19,
name: "Crystal Sage",
unlocked: false,
},
{
id: "void_sentinel",
name: "Void Sentinel",
class: "rogue",
level: 20,
baseCost: 175_000_000_000_000_000,
goldPerSecond: 90_000_000,
baseCost: 175_000_000_000_000_000,
class: "rogue",
combatPower: 300_000_000,
count: 0,
essencePerSecond: 30_000,
combatPower: 300_000_000,
count: 0,
unlocked: false,
goldPerSecond: 90_000_000,
id: "void_sentinel",
level: 20,
name: "Void Sentinel",
unlocked: false,
},
{
id: "eternal_champion",
name: "Eternal Champion",
class: "warrior",
level: 21,
baseCost: 1_200_000_000_000_000_000,
goldPerSecond: 270_000_000,
baseCost: 1_200_000_000_000_000_000,
class: "warrior",
combatPower: 900_000_000,
count: 0,
essencePerSecond: 80_000,
combatPower: 900_000_000,
count: 0,
unlocked: false,
goldPerSecond: 270_000_000,
id: "eternal_champion",
level: 21,
name: "Eternal Champion",
unlocked: false,
},
{
id: "aether_weaver",
name: "Aether Weaver",
class: "mage",
level: 22,
baseCost: 8_500_000_000_000_000_000,
goldPerSecond: 800_000_000,
baseCost: 8_500_000_000_000_000_000,
class: "mage",
combatPower: 2_700_000_000,
count: 0,
essencePerSecond: 220_000,
combatPower: 2_700_000_000,
count: 0,
unlocked: false,
goldPerSecond: 800_000_000,
id: "aether_weaver",
level: 22,
name: "Aether Weaver",
unlocked: false,
},
{
id: "titan_warrior",
name: "Titan Warrior",
class: "warrior",
level: 23,
baseCost: 60_000_000_000_000_000_000,
goldPerSecond: 2_500_000_000,
baseCost: 60_000_000_000_000_000_000,
class: "warrior",
combatPower: 8_000_000_000,
count: 0,
essencePerSecond: 600_000,
combatPower: 8_000_000_000,
count: 0,
unlocked: false,
goldPerSecond: 2_500_000_000,
id: "titan_warrior",
level: 23,
name: "Titan Warrior",
unlocked: false,
},
{
id: "nexus_sage",
name: "Nexus Sage",
class: "mage",
level: 24,
baseCost: 420_000_000_000_000_000_000,
goldPerSecond: 7_500_000_000,
baseCost: 420_000_000_000_000_000_000,
class: "mage",
combatPower: 24_000_000_000,
count: 0,
essencePerSecond: 1_600_000,
combatPower: 24_000_000_000,
count: 0,
unlocked: false,
goldPerSecond: 7_500_000_000,
id: "nexus_sage",
level: 24,
name: "Nexus Sage",
unlocked: false,
},
{
id: "cosmos_knight",
name: "Cosmos Knight",
class: "paladin",
level: 25,
baseCost: 3_000_000_000_000_000_000_000,
goldPerSecond: 22_000_000_000,
baseCost: 3_000_000_000_000_000_000_000,
class: "paladin",
combatPower: 72_000_000_000,
count: 0,
essencePerSecond: 4_500_000,
combatPower: 72_000_000_000,
count: 0,
unlocked: false,
goldPerSecond: 22_000_000_000,
id: "cosmos_knight",
level: 25,
name: "Cosmos Knight",
unlocked: false,
},
{
id: "astral_sovereign",
name: "Astral Sovereign",
class: "warrior",
level: 26,
baseCost: 21_000_000_000_000_000_000_000,
goldPerSecond: 65_000_000_000,
baseCost: 21_000_000_000_000_000_000_000,
class: "warrior",
combatPower: 200_000_000_000,
count: 0,
essencePerSecond: 12_000_000,
combatPower: 200_000_000_000,
count: 0,
unlocked: false,
goldPerSecond: 65_000_000_000,
id: "astral_sovereign",
level: 26,
name: "Astral Sovereign",
unlocked: false,
},
{
id: "primordial_mage",
name: "Primordial Mage",
class: "mage",
level: 27,
baseCost: 150_000_000_000_000_000_000_000,
goldPerSecond: 200_000_000_000,
baseCost: 150_000_000_000_000_000_000_000,
class: "mage",
combatPower: 600_000_000_000,
count: 0,
essencePerSecond: 35_000_000,
combatPower: 600_000_000_000,
count: 0,
unlocked: false,
goldPerSecond: 200_000_000_000,
id: "primordial_mage",
level: 27,
name: "Primordial Mage",
unlocked: false,
},
{
id: "reality_warden",
name: "Reality Warden",
class: "paladin",
level: 28,
baseCost: 1_000_000_000_000_000_000_000_000,
goldPerSecond: 600_000_000_000,
baseCost: 1_000_000_000_000_000_000_000_000,
class: "paladin",
combatPower: 1_800_000_000_000,
count: 0,
essencePerSecond: 100_000_000,
combatPower: 1_800_000_000_000,
count: 0,
unlocked: false,
goldPerSecond: 600_000_000_000,
id: "reality_warden",
level: 28,
name: "Reality Warden",
unlocked: false,
},
{
id: "infinity_ranger",
name: "Infinity Ranger",
class: "ranger",
level: 29,
baseCost: 7_000_000_000_000_000_000_000_000,
goldPerSecond: 1_800_000_000_000,
baseCost: 7_000_000_000_000_000_000_000_000,
class: "ranger",
combatPower: 5_500_000_000_000,
count: 0,
essencePerSecond: 300_000_000,
combatPower: 5_500_000_000_000,
count: 0,
unlocked: false,
goldPerSecond: 1_800_000_000_000,
id: "infinity_ranger",
level: 29,
name: "Infinity Ranger",
unlocked: false,
},
{
id: "oblivion_paladin",
name: "Oblivion Paladin",
class: "paladin",
level: 30,
baseCost: 50_000_000_000_000_000_000_000_000,
goldPerSecond: 5_500_000_000_000,
baseCost: 50_000_000_000_000_000_000_000_000,
class: "paladin",
combatPower: 16_000_000_000_000,
count: 0,
essencePerSecond: 850_000_000,
combatPower: 16_000_000_000_000,
count: 0,
unlocked: false,
goldPerSecond: 5_500_000_000_000,
id: "oblivion_paladin",
level: 30,
name: "Oblivion Paladin",
unlocked: false,
},
{
id: "transcendent_rogue",
name: "Transcendent Rogue",
class: "rogue",
level: 31,
baseCost: 350_000_000_000_000_000_000_000_000,
goldPerSecond: 16_000_000_000_000,
baseCost: 350_000_000_000_000_000_000_000_000,
class: "rogue",
combatPower: 50_000_000_000_000,
count: 0,
essencePerSecond: 2_500_000_000,
combatPower: 50_000_000_000_000,
count: 0,
unlocked: false,
goldPerSecond: 16_000_000_000_000,
id: "transcendent_rogue",
level: 31,
name: "Transcendent Rogue",
unlocked: false,
},
{
id: "omniversal_champion",
name: "Omniversal Champion",
class: "warrior",
level: 32,
baseCost: 2_500_000_000_000_000_000_000_000_000,
goldPerSecond: 50_000_000_000_000,
baseCost: 2_500_000_000_000_000_000_000_000_000,
class: "warrior",
combatPower: 150_000_000_000_000,
count: 0,
essencePerSecond: 7_000_000_000,
combatPower: 150_000_000_000_000,
count: 0,
unlocked: false,
goldPerSecond: 50_000_000_000_000,
id: "omniversal_champion",
level: 32,
name: "Omniversal Champion",
unlocked: false,
},
];
File diff suppressed because it is too large Load Diff
+60 -14
View File
@@ -1,25 +1,71 @@
/**
* @file Game data definitions.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { DailyChallengeType } from "@elysium/types";
interface DailyChallengeTemplate {
type: DailyChallengeType;
label: string;
target: number;
type: DailyChallengeType;
label: string;
target: number;
rewardCrystals: number;
}
export const DAILY_CHALLENGE_TEMPLATES: DailyChallengeTemplate[] = [
export const dailyChallengeTemplates: Array<DailyChallengeTemplate> = [
// Clicks — always requires active play
{ type: "clicks", label: "Click 500 times", target: 500, rewardCrystals: 50 },
{ type: "clicks", label: "Click 1,000 times", target: 1_000, rewardCrystals: 100 },
{ type: "clicks", label: "Click 5,000 times", target: 5_000, rewardCrystals: 300 },
{ label: "Click 500 times", rewardCrystals: 50, target: 500, type: "clicks" },
{
label: "Click 1,000 times",
rewardCrystals: 100,
target: 1000,
type: "clicks",
},
{
label: "Click 5,000 times",
rewardCrystals: 300,
target: 5000,
type: "clicks",
},
// Boss defeats — requires active combat
{ type: "bossesDefeated", label: "Defeat 1 boss", target: 1, rewardCrystals: 75 },
{ type: "bossesDefeated", label: "Defeat 3 bosses", target: 3, rewardCrystals: 200 },
{ type: "bossesDefeated", label: "Defeat 5 bosses", target: 5, rewardCrystals: 400 },
{
label: "Defeat 1 boss",
rewardCrystals: 75,
target: 1,
type: "bossesDefeated",
},
{
label: "Defeat 3 bosses",
rewardCrystals: 200,
target: 3,
type: "bossesDefeated",
},
{
label: "Defeat 5 bosses",
rewardCrystals: 400,
target: 5,
type: "bossesDefeated",
},
// Quest completions — requires starting quests
{ type: "questsCompleted", label: "Complete 3 quests", target: 3, rewardCrystals: 100 },
{ type: "questsCompleted", label: "Complete 5 quests", target: 5, rewardCrystals: 200 },
{ type: "questsCompleted", label: "Complete 10 quests", target: 10, rewardCrystals: 400 },
{
label: "Complete 3 quests",
rewardCrystals: 100,
target: 3,
type: "questsCompleted",
},
{
label: "Complete 5 quests",
rewardCrystals: 200,
target: 5,
type: "questsCompleted",
},
{
label: "Complete 10 quests",
rewardCrystals: 400,
target: 10,
type: "questsCompleted",
},
// Prestige — the big one
{ type: "prestige", label: "Prestige once", target: 1, rewardCrystals: 750 },
{ label: "Prestige once", rewardCrystals: 750, target: 1, type: "prestige" },
];
File diff suppressed because it is too large Load Diff
+54 -37
View File
@@ -1,94 +1,111 @@
/**
* @file Game data definitions.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable stylistic/max-len -- Data content */
/* eslint-disable @typescript-eslint/naming-convention -- Numeric keys required by EquipmentSet type */
import type { EquipmentSet } from "@elysium/types";
export const DEFAULT_EQUIPMENT_SETS: EquipmentSet[] = [
export const defaultEquipmentSets: Array<EquipmentSet> = [
{
id: "iron_vanguard",
name: "Iron Vanguard",
description: "The armaments of a seasoned guild soldier — proven steel, reliable gold.",
pieces: ["iron_sword", "chainmail", "mages_focus"],
bonuses: {
2: { goldMultiplier: 1.1 },
3: { combatMultiplier: 1.1 },
},
description:
"The armaments of a seasoned guild soldier — proven steel, reliable gold.",
id: "iron_vanguard",
name: "Iron Vanguard",
pieces: [ "iron_sword", "chainmail", "mages_focus" ],
},
{
id: "shadow_infiltrator",
name: "Shadow Infiltrator",
description: "Gear forged from the Shadow Marshes themselves — unseen, unstoppable.",
pieces: ["shadow_dagger", "void_shroud", "void_compass"],
bonuses: {
2: { goldMultiplier: 1.15 },
3: { clickMultiplier: 1.2 },
},
description:
"Gear forged from the Shadow Marshes themselves — unseen, unstoppable.",
id: "shadow_infiltrator",
name: "Shadow Infiltrator",
pieces: [ "shadow_dagger", "void_shroud", "void_compass" ],
},
{
id: "volcanic_forger",
name: "Volcanic Forger",
description: "Weapons and armour tempered in the depths of the Volcanic Reaches.",
pieces: ["flame_lance", "volcanic_plate", "crystal_shard"],
bonuses: {
2: { combatMultiplier: 1.15 },
3: { goldMultiplier: 1.15 },
},
description:
"Weapons and armour tempered in the depths of the Volcanic Reaches.",
id: "volcanic_forger",
name: "Volcanic Forger",
pieces: [ "flame_lance", "volcanic_plate", "crystal_shard" ],
},
{
id: "celestial_guardian",
name: "Celestial Guardian",
description: "Relics of the Celestial Reaches — divine power made manifest.",
pieces: ["seraph_wing", "celestial_armour", "angels_halo"],
bonuses: {
2: { combatMultiplier: 1.2 },
3: { goldMultiplier: 1.2 },
},
description:
"Relics of the Celestial Reaches — divine power made manifest.",
id: "celestial_guardian",
name: "Celestial Guardian",
pieces: [ "seraph_wing", "celestial_armour", "angels_halo" ],
},
{
id: "abyssal_predator",
name: "Abyssal Predator",
description: "Trophies reclaimed from the deepest trenches of the Abyssal Reaches.",
pieces: ["depth_blade", "pressure_plate", "leviathan_eye"],
bonuses: {
2: { goldMultiplier: 1.2 },
3: { clickMultiplier: 1.25 },
},
description:
"Trophies reclaimed from the deepest trenches of the Abyssal Reaches.",
id: "abyssal_predator",
name: "Abyssal Predator",
pieces: [ "depth_blade", "pressure_plate", "leviathan_eye" ],
},
{
id: "infernal_conqueror",
name: "Infernal Conqueror",
description: "Forged in the heart of the Infernal Court from the essence of the defeated.",
pieces: ["hellfire_edge", "demon_hide", "soul_gem"],
bonuses: {
2: { combatMultiplier: 1.25 },
3: { goldMultiplier: 1.25 },
},
description:
"Forged in the heart of the Infernal Court from the essence of the defeated.",
id: "infernal_conqueror",
name: "Infernal Conqueror",
pieces: [ "hellfire_edge", "demon_hide", "soul_gem" ],
},
{
id: "crystal_domain",
name: "Crystal Domain",
description: "Instruments of the Crystalline Spire — reality refracted into absolute efficiency.",
pieces: ["prism_blade", "faceted_armour", "prism_eye"],
bonuses: {
2: { clickMultiplier: 1.25 },
3: { goldMultiplier: 1.25 },
},
description:
"Instruments of the Crystalline Spire — reality refracted into absolute efficiency.",
id: "crystal_domain",
name: "Crystal Domain",
pieces: [ "prism_blade", "faceted_armour", "prism_eye" ],
},
{
id: "void_emperor",
name: "Void Emperor",
description: "The regalia of the Void Sanctum's lord — power carved from absolute nothingness.",
pieces: ["void_annihilator", "eternal_shroud", "void_heart_gem"],
bonuses: {
2: { goldMultiplier: 1.3 },
3: { combatMultiplier: 1.3 },
},
description:
"The regalia of the Void Sanctum's lord — power carved from absolute nothingness.",
id: "void_emperor",
name: "Void Emperor",
pieces: [ "void_annihilator", "eternal_shroud", "void_heart_gem" ],
},
{
id: "eternal_throne",
name: "Eternal Throne",
description: "The armaments of the Eternal Throne — weapons and armour that have endured all of time.",
pieces: ["throne_blade", "eternal_armour", "eternity_stone"],
bonuses: {
2: { combatMultiplier: 1.35, goldMultiplier: 1.25 },
3: { clickMultiplier: 1.35 },
},
description:
"The armaments of the Eternal Throne — weapons and armour that have endured all of time.",
id: "eternal_throne",
name: "Eternal Throne",
pieces: [ "throne_blade", "eternal_armour", "eternity_stone" ],
},
];
File diff suppressed because it is too large Load Diff
+93 -62
View File
@@ -1,75 +1,106 @@
import type { ApotheosisData, ExplorationState, GameState, Player, PrestigeData, TranscendenceData } from "@elysium/types";
import { DEFAULT_ACHIEVEMENTS } from "./achievements.js";
import { CURRENT_SCHEMA_VERSION } from "./schemaVersion.js";
import { DEFAULT_ADVENTURERS } from "./adventurers.js";
import { DEFAULT_BOSSES } from "./bosses.js";
import { DEFAULT_EQUIPMENT } from "./equipment.js";
import { DEFAULT_EXPLORATIONS } from "./explorations.js";
import { DEFAULT_QUESTS } from "./quests.js";
import { DEFAULT_UPGRADES } from "./upgrades.js";
import { DEFAULT_ZONES } from "./zones.js";
/**
* @file Initial game state data.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { defaultAchievements } from "./achievements.js";
import { defaultAdventurers } from "./adventurers.js";
import { defaultBosses } from "./bosses.js";
import { defaultEquipment } from "./equipment.js";
import { defaultExplorations } from "./explorations.js";
import { defaultQuests } from "./quests.js";
import { currentSchemaVersion } from "./schemaVersion.js";
import { defaultUpgrades } from "./upgrades.js";
import { defaultZones } from "./zones.js";
import type {
ApotheosisData,
ExplorationState,
GameState,
Player,
PrestigeData,
TranscendenceData,
} from "@elysium/types";
export const INITIAL_PRESTIGE: PrestigeData = {
count: 0,
runestones: 0,
const initialPrestige: PrestigeData = {
count: 0,
productionMultiplier: 1,
purchasedUpgradeIds: [],
purchasedUpgradeIds: [],
runestones: 0,
};
export const INITIAL_TRANSCENDENCE: TranscendenceData = {
count: 0,
echoes: 0,
purchasedUpgradeIds: [],
echoIncomeMultiplier: 1,
echoCombatMultiplier: 1,
echoPrestigeThresholdMultiplier: 1,
const initialTranscendence: TranscendenceData = {
count: 0,
echoCombatMultiplier: 1,
echoIncomeMultiplier: 1,
echoMetaMultiplier: 1,
echoPrestigeRunestoneMultiplier: 1,
echoMetaMultiplier: 1,
echoPrestigeThresholdMultiplier: 1,
echoes: 0,
purchasedUpgradeIds: [],
};
export const INITIAL_APOTHEOSIS: ApotheosisData = {
const initialApotheosis: ApotheosisData = {
count: 0,
};
export const INITIAL_EXPLORATION: ExplorationState = {
areas: DEFAULT_EXPLORATIONS.map((area) => ({
id: area.id,
status: area.zoneId === "verdant_vale" ? "available" as const : "locked" as const,
})),
materials: [],
craftedRecipeIds: [],
craftedGoldMultiplier: 1,
const initialExploration: ExplorationState = {
areas: defaultExplorations.map((area) => {
return {
id: area.id,
status:
area.zoneId === "verdant_vale"
? ("available" as const)
: ("locked" as const),
};
}),
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
craftedEssenceMultiplier: 1,
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
craftedGoldMultiplier: 1,
craftedRecipeIds: [],
materials: [],
};
export const INITIAL_GAME_STATE = (player: Player, characterName: string): GameState => ({
player: {
...player,
characterName,
totalGoldEarned: 0,
totalClicks: 0,
},
resources: {
gold: 0,
essence: 0,
crystals: 0,
runestones: 0,
},
adventurers: structuredClone(DEFAULT_ADVENTURERS),
upgrades: structuredClone(DEFAULT_UPGRADES),
quests: structuredClone(DEFAULT_QUESTS),
bosses: structuredClone(DEFAULT_BOSSES),
equipment: structuredClone(DEFAULT_EQUIPMENT),
achievements: structuredClone(DEFAULT_ACHIEVEMENTS),
prestige: INITIAL_PRESTIGE,
zones: structuredClone(DEFAULT_ZONES),
baseClickPower: 1,
lastTickAt: Date.now(),
transcendence: { ...INITIAL_TRANSCENDENCE },
apotheosis: { ...INITIAL_APOTHEOSIS },
exploration: structuredClone(INITIAL_EXPLORATION),
companions: { unlockedCompanionIds: [], activeCompanionId: null },
schemaVersion: CURRENT_SCHEMA_VERSION,
});
/**
* Builds an initial game state for a new player.
* @param player - The player data from Discord OAuth.
* @param characterName - The character name chosen by the player.
* @returns A fresh GameState object.
*/
const initialGameState = (
player: Player,
characterName: string,
): GameState => {
return {
achievements: structuredClone(defaultAchievements),
adventurers: structuredClone(defaultAdventurers),
apotheosis: { ...initialApotheosis },
baseClickPower: 1,
bosses: structuredClone(defaultBosses),
companions: { activeCompanionId: null, unlockedCompanionIds: [] },
equipment: structuredClone(defaultEquipment),
exploration: structuredClone(initialExploration),
lastTickAt: Date.now(),
player: {
...player,
characterName: characterName,
totalClicks: 0,
totalGoldEarned: 0,
},
prestige: initialPrestige,
quests: structuredClone(defaultQuests),
resources: {
crystals: 0,
essence: 0,
gold: 0,
runestones: 0,
},
schemaVersion: currentSchemaVersion,
transcendence: { ...initialTranscendence },
upgrades: structuredClone(defaultUpgrades),
zones: structuredClone(defaultZones),
};
};
export { initialExploration, initialGameState };
+13 -7
View File
@@ -1,6 +1,12 @@
/**
* @file Login bonus reward data.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
export interface DayReward {
day: number;
goldBase: number;
day: number;
goldBase: number;
crystals?: number;
}
@@ -8,12 +14,12 @@ export interface DayReward {
* Rewards for days 17 of a login streak. The cycle repeats every 7 days
* with a multiplier equal to the week number (week 1 = ×1, week 2 = ×2, etc.).
*/
export const DAILY_REWARDS: DayReward[] = [
export const dailyRewards: Array<DayReward> = [
{ day: 1, goldBase: 500 },
{ day: 2, goldBase: 1_000 },
{ day: 3, goldBase: 2_500 },
{ day: 4, goldBase: 5_000 },
{ day: 2, goldBase: 1000 },
{ day: 3, goldBase: 2500 },
{ day: 4, goldBase: 5000 },
{ day: 5, goldBase: 10_000 },
{ day: 6, goldBase: 25_000 },
{ day: 7, goldBase: 50_000, crystals: 5 },
{ crystals: 5, day: 7, goldBase: 50_000 },
];
+441 -55
View File
@@ -1,93 +1,479 @@
/**
* @file Game data definitions.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines -- Data file */
/* eslint-disable stylistic/max-len -- Data content */
import type { Material } from "@elysium/types";
export const DEFAULT_MATERIALS: Material[] = [
export const defaultMaterials: Array<Material> = [
// Zone 1: verdant_vale
{ id: "verdant_sap", name: "Verdant Sap", description: "Sticky resin tapped from ancient heartwood trees. Smells faintly of spring rain and something older beneath.", zoneId: "verdant_vale", rarity: "common" },
{ id: "forest_crystal", name: "Forest Crystal", description: "A translucent gem found buried near the roots of old-growth trees. Pulses with gentle life energy when held.", zoneId: "verdant_vale", rarity: "uncommon" },
{ id: "elder_bark", name: "Elder Bark", description: "Bark from a tree that has stood since before the kingdom. Harder than iron and warmer to the touch than it has any right to be.", zoneId: "verdant_vale", rarity: "rare" },
{
description:
"Sticky resin tapped from ancient heartwood trees. Smells faintly of spring rain and something older beneath.",
id: "verdant_sap",
name: "Verdant Sap",
rarity: "common",
zoneId: "verdant_vale",
},
{
description:
"A translucent gem found buried near the roots of old-growth trees. Pulses with gentle life energy when held.",
id: "forest_crystal",
name: "Forest Crystal",
rarity: "uncommon",
zoneId: "verdant_vale",
},
{
description:
"Bark from a tree that has stood since before the kingdom. Harder than iron and warmer to the touch than it has any right to be.",
id: "elder_bark",
name: "Elder Bark",
rarity: "rare",
zoneId: "verdant_vale",
},
// Zone 2: shattered_ruins
{ id: "ruin_dust", name: "Ruin Dust", description: "Fine powder ground from fallen masonry. Still carries traces of the civilisation it once was — if you know how to read the patterns.", zoneId: "shattered_ruins", rarity: "common" },
{ id: "cursed_fragment", name: "Cursed Fragment", description: "A shard of enchanted stonework. The enchantment is broken, but something lingers in the grain of the stone, waiting.", zoneId: "shattered_ruins", rarity: "uncommon" },
{ id: "dragonscale_chip", name: "Dragonscale Chip", description: "A fragment of scale shed during the elder dragon's long reign over the ruins. Resistant to fire and magic alike.", zoneId: "shattered_ruins", rarity: "rare" },
{
description:
"Fine powder ground from fallen masonry. Still carries traces of the civilisation it once was — if you know how to read the patterns.",
id: "ruin_dust",
name: "Ruin Dust",
rarity: "common",
zoneId: "shattered_ruins",
},
{
description:
"A shard of enchanted stonework. The enchantment is broken, but something lingers in the grain of the stone, waiting.",
id: "cursed_fragment",
name: "Cursed Fragment",
rarity: "uncommon",
zoneId: "shattered_ruins",
},
{
description:
"A fragment of scale shed during the elder dragon's long reign over the ruins. Resistant to fire and magic alike.",
id: "dragonscale_chip",
name: "Dragonscale Chip",
rarity: "rare",
zoneId: "shattered_ruins",
},
// Zone 3: frozen_peaks
{ id: "glacial_ice", name: "Glacial Ice", description: "Ice from a glacier that has not moved in ten thousand years. Impossibly cold and perfectly clear, with something almost visible within.", zoneId: "frozen_peaks", rarity: "common" },
{ id: "frost_crystal", name: "Frost Crystal", description: "A crystal that formed inside the glacier itself over millennia. It never melts, not even near fire.", zoneId: "frozen_peaks", rarity: "uncommon" },
{ id: "void_shard", name: "Void Shard", description: "A fragment of the reality tear. It hums with wrongness that the fingers instinctively recognise before the mind does.", zoneId: "frozen_peaks", rarity: "rare" },
{
description:
"Ice from a glacier that has not moved in ten thousand years. Impossibly cold and perfectly clear, with something almost visible within.",
id: "glacial_ice",
name: "Glacial Ice",
rarity: "common",
zoneId: "frozen_peaks",
},
{
description:
"A crystal that formed inside the glacier itself over millennia. It never melts, not even near fire.",
id: "frost_crystal",
name: "Frost Crystal",
rarity: "uncommon",
zoneId: "frozen_peaks",
},
{
description:
"A fragment of the reality tear. It hums with wrongness that the fingers instinctively recognise before the mind does.",
id: "void_shard",
name: "Void Shard",
rarity: "rare",
zoneId: "frozen_peaks",
},
// Zone 4: shadow_marshes
{ id: "marsh_root", name: "Marsh Root", description: "Roots from the strangler plants that thrive in the fog-choked depths. Toxic without extensive preparation. Worth it, usually.", zoneId: "shadow_marshes", rarity: "common" },
{ id: "shadow_essence", name: "Shadow Essence", description: "Distilled darkness, caught in a vial before it could dissipate. Heavy and cold, and absolutely lightless.", zoneId: "shadow_marshes", rarity: "uncommon" },
{ id: "cursed_bone", name: "Cursed Bone", description: "Bone from something that died in the marsh so long ago it has become part of it. The curse runs deep through the marrow.", zoneId: "shadow_marshes", rarity: "rare" },
{
description:
"Roots from the strangler plants that thrive in the fog-choked depths. Toxic without extensive preparation. Worth it, usually.",
id: "marsh_root",
name: "Marsh Root",
rarity: "common",
zoneId: "shadow_marshes",
},
{
description:
"Distilled darkness, caught in a vial before it could dissipate. Heavy and cold, and absolutely lightless.",
id: "shadow_essence",
name: "Shadow Essence",
rarity: "uncommon",
zoneId: "shadow_marshes",
},
{
description:
"Bone from something that died in the marsh so long ago it has become part of it. The curse runs deep through the marrow.",
id: "cursed_bone",
name: "Cursed Bone",
rarity: "rare",
zoneId: "shadow_marshes",
},
// Zone 5: volcanic_depths
{ id: "magma_stone", name: "Magma Stone", description: "Cooled lava that retained its internal heat. Warm to the touch even centuries after solidifying from whatever it once was.", zoneId: "volcanic_depths", rarity: "common" },
{ id: "ember_crystal", name: "Ember Crystal", description: "A crystal grown in the heart of a cooling magma chamber. Burns without being consumed, endlessly.", zoneId: "volcanic_depths", rarity: "uncommon" },
{ id: "legendary_ore", name: "Legendary Ore", description: "Ore from a seam that the fire elementals guard jealously. What it forges into is extraordinary by any measure.", zoneId: "volcanic_depths", rarity: "rare" },
{
description:
"Cooled lava that retained its internal heat. Warm to the touch even centuries after solidifying from whatever it once was.",
id: "magma_stone",
name: "Magma Stone",
rarity: "common",
zoneId: "volcanic_depths",
},
{
description:
"A crystal grown in the heart of a cooling magma chamber. Burns without being consumed, endlessly.",
id: "ember_crystal",
name: "Ember Crystal",
rarity: "uncommon",
zoneId: "volcanic_depths",
},
{
description:
"Ore from a seam that the fire elementals guard jealously. What it forges into is extraordinary by any measure.",
id: "legendary_ore",
name: "Legendary Ore",
rarity: "rare",
zoneId: "volcanic_depths",
},
// Zone 6: astral_void
{ id: "stardust", name: "Stardust", description: "Particulate matter from dying stars, collected from the void between worlds. Glitters even in total darkness.", zoneId: "astral_void", rarity: "common" },
{ id: "astral_thread", name: "Astral Thread", description: "Filaments of solidified probability. Handle with care — they remember every possible future they passed through.", zoneId: "astral_void", rarity: "uncommon" },
{ id: "void_crystal", name: "Void Crystal", description: "A crystal that formed in the spaces between spaces. Technically exists in several places simultaneously. Don't think too hard about it.", zoneId: "astral_void", rarity: "rare" },
{
description:
"Particulate matter from dying stars, collected from the void between worlds. Glitters even in total darkness.",
id: "stardust",
name: "Stardust",
rarity: "common",
zoneId: "astral_void",
},
{
description:
"Filaments of solidified probability. Handle with care — they remember every possible future they passed through.",
id: "astral_thread",
name: "Astral Thread",
rarity: "uncommon",
zoneId: "astral_void",
},
{
description:
"A crystal that formed in the spaces between spaces. Technically exists in several places simultaneously. Don't think too hard about it.",
id: "void_crystal",
name: "Void Crystal",
rarity: "rare",
zoneId: "astral_void",
},
// Zone 7: celestial_reaches
{ id: "celestial_dust", name: "Celestial Dust", description: "Residue from the celestial host's passing. Warm as sunlight and infinitely patient, as if waiting for something to happen.", zoneId: "celestial_reaches", rarity: "common" },
{ id: "divine_fragment", name: "Divine Fragment", description: "A chip of something the celestials discarded as imperfect. By mortal standards, it is extraordinary beyond measure.", zoneId: "celestial_reaches", rarity: "uncommon" },
{ id: "choir_shard", name: "Choir Shard", description: "A crystallised harmonic from the celestial choir. Resonates with a sound felt in the chest rather than heard with the ears.", zoneId: "celestial_reaches", rarity: "rare" },
{
description:
"Residue from the celestial host's passing. Warm as sunlight and infinitely patient, as if waiting for something to happen.",
id: "celestial_dust",
name: "Celestial Dust",
rarity: "common",
zoneId: "celestial_reaches",
},
{
description:
"A chip of something the celestials discarded as imperfect. By mortal standards, it is extraordinary beyond measure.",
id: "divine_fragment",
name: "Divine Fragment",
rarity: "uncommon",
zoneId: "celestial_reaches",
},
{
description:
"A crystallised harmonic from the celestial choir. Resonates with a sound felt in the chest rather than heard with the ears.",
id: "choir_shard",
name: "Choir Shard",
rarity: "rare",
zoneId: "celestial_reaches",
},
// Zone 8: abyssal_trench
{ id: "trench_coral", name: "Trench Coral", description: "Coral from the deepest trenches where no light reaches and no warmth remains. Black as the water around it.", zoneId: "abyssal_trench", rarity: "common" },
{ id: "pressure_gem", name: "Pressure Gem", description: "A gem compressed by aeons of unimaginable pressure at the bottom of all things. Impossibly dense for its size.", zoneId: "abyssal_trench", rarity: "uncommon" },
{ id: "ancient_tooth", name: "Ancient Tooth", description: "A tooth from whatever has been waiting in the trench since before your world was made. It is very large.", zoneId: "abyssal_trench", rarity: "rare" },
{
description:
"Coral from the deepest trenches where no light reaches and no warmth remains. Black as the water around it.",
id: "trench_coral",
name: "Trench Coral",
rarity: "common",
zoneId: "abyssal_trench",
},
{
description:
"A gem compressed by aeons of unimaginable pressure at the bottom of all things. Impossibly dense for its size.",
id: "pressure_gem",
name: "Pressure Gem",
rarity: "uncommon",
zoneId: "abyssal_trench",
},
{
description:
"A tooth from whatever has been waiting in the trench since before your world was made. It is very large.",
id: "ancient_tooth",
name: "Ancient Tooth",
rarity: "rare",
zoneId: "abyssal_trench",
},
// Zone 9: infernal_court
{ id: "brimstone_flake", name: "Brimstone Flake", description: "Sulphur residue from the court's perpetual fires. The smell never fully fades, no matter how carefully it is stored.", zoneId: "infernal_court", rarity: "common" },
{ id: "demon_ichor", name: "Demon Ichor", description: "Extracted from the court's refuse. Corrosive, powerful, and deeply unpleasant in every measurable way.", zoneId: "infernal_court", rarity: "uncommon" },
{ id: "soul_residue", name: "Soul Residue", description: "What remains after a soul has been fully processed by the court. Carries faint echoes of what it was before.", zoneId: "infernal_court", rarity: "rare" },
{
description:
"Sulphur residue from the court's perpetual fires. The smell never fully fades, no matter how carefully it is stored.",
id: "brimstone_flake",
name: "Brimstone Flake",
rarity: "common",
zoneId: "infernal_court",
},
{
description:
"Extracted from the court's refuse. Corrosive, powerful, and deeply unpleasant in every measurable way.",
id: "demon_ichor",
name: "Demon Ichor",
rarity: "uncommon",
zoneId: "infernal_court",
},
{
description:
"What remains after a soul has been fully processed by the court. Carries faint echoes of what it was before.",
id: "soul_residue",
name: "Soul Residue",
rarity: "rare",
zoneId: "infernal_court",
},
// Zone 10: crystalline_spire
{ id: "prism_dust", name: "Prism Dust", description: "Ground from the spire's outer facets. Each particle contains a compressed possibility that has not yet resolved.", zoneId: "crystalline_spire", rarity: "common" },
{ id: "calculation_shard", name: "Calculation Shard", description: "A fragment of the spire's core intelligence. Still running calculations on something that may or may not have an answer.", zoneId: "crystalline_spire", rarity: "uncommon" },
{ id: "possibility_crystal", name: "Possibility Crystal", description: "A crystal that contains a future that never happened. Treat carefully. The future remembers being possible.", zoneId: "crystalline_spire", rarity: "rare" },
{
description:
"Ground from the spire's outer facets. Each particle contains a compressed possibility that has not yet resolved.",
id: "prism_dust",
name: "Prism Dust",
rarity: "common",
zoneId: "crystalline_spire",
},
{
description:
"A fragment of the spire's core intelligence. Still running calculations on something that may or may not have an answer.",
id: "calculation_shard",
name: "Calculation Shard",
rarity: "uncommon",
zoneId: "crystalline_spire",
},
{
description:
"A crystal that contains a future that never happened. Treat carefully. The future remembers being possible.",
id: "possibility_crystal",
name: "Possibility Crystal",
rarity: "rare",
zoneId: "crystalline_spire",
},
// Zone 11: void_sanctum
{ id: "null_matter", name: "Null Matter", description: "Matter that exists in the space between spaces. Lacks most standard properties in ways that should not be possible.", zoneId: "void_sanctum", rarity: "common" },
{ id: "resonance_fragment", name: "Resonance Fragment", description: "A shard of the call that drew your guild here. Still resonant, still reaching toward something none of you can name.", zoneId: "void_sanctum", rarity: "uncommon" },
{ id: "sanctum_core", name: "Sanctum Core", description: "From the heart of the sanctum itself. What it does is undefined. What it is cannot be satisfactorily described.", zoneId: "void_sanctum", rarity: "rare" },
{
description:
"Matter that exists in the space between spaces. Lacks most standard properties in ways that should not be possible.",
id: "null_matter",
name: "Null Matter",
rarity: "common",
zoneId: "void_sanctum",
},
{
description:
"A shard of the call that drew your guild here. Still resonant, still reaching toward something none of you can name.",
id: "resonance_fragment",
name: "Resonance Fragment",
rarity: "uncommon",
zoneId: "void_sanctum",
},
{
description:
"From the heart of the sanctum itself. What it does is undefined. What it is cannot be satisfactorily described.",
id: "sanctum_core",
name: "Sanctum Core",
rarity: "rare",
zoneId: "void_sanctum",
},
// Zone 12: eternal_throne
{ id: "throne_dust", name: "Throne Dust", description: "Residue from the base of the eternal throne. Old beyond any measurement that applies to things your guild understands.", zoneId: "eternal_throne", rarity: "common" },
{ id: "crown_fragment", name: "Crown Fragment", description: "A chip from one of the throne's crown-like spires. Authority made into something your hands can hold.", zoneId: "eternal_throne", rarity: "uncommon" },
{ id: "eternity_splinter", name: "Eternity Splinter", description: "From the throne's arm. Carries the weight of every decision ever made here, compressed into splinter-form.", zoneId: "eternal_throne", rarity: "rare" },
{
description:
"Residue from the base of the eternal throne. Old beyond any measurement that applies to things your guild understands.",
id: "throne_dust",
name: "Throne Dust",
rarity: "common",
zoneId: "eternal_throne",
},
{
description:
"A chip from one of the throne's crown-like spires. Authority made into something your hands can hold.",
id: "crown_fragment",
name: "Crown Fragment",
rarity: "uncommon",
zoneId: "eternal_throne",
},
{
description:
"From the throne's arm. Carries the weight of every decision ever made here, compressed into splinter-form.",
id: "eternity_splinter",
name: "Eternity Splinter",
rarity: "rare",
zoneId: "eternal_throne",
},
// Zone 13: primordial_chaos
{ id: "chaos_fragment", name: "Chaos Fragment", description: "A solidified moment of chaos. Still undecided about its own properties, which change depending on how you look at it.", zoneId: "primordial_chaos", rarity: "common" },
{ id: "creation_shard", name: "Creation Shard", description: "A fragment from when something was being made here. What was being made is unclear. Something important, probably.", zoneId: "primordial_chaos", rarity: "uncommon" },
{ id: "primordial_essence", name: "Primordial Essence", description: "The raw stuff of creation, before it became anything specific. Handle with care. It wants to become things.", zoneId: "primordial_chaos", rarity: "rare" },
{
description:
"A solidified moment of chaos. Still undecided about its own properties, which change depending on how you look at it.",
id: "chaos_fragment",
name: "Chaos Fragment",
rarity: "common",
zoneId: "primordial_chaos",
},
{
description:
"A fragment from when something was being made here. What was being made is unclear. Something important, probably.",
id: "creation_shard",
name: "Creation Shard",
rarity: "uncommon",
zoneId: "primordial_chaos",
},
{
description:
"The raw stuff of creation, before it became anything specific. Handle with care. It wants to become things.",
id: "primordial_essence",
name: "Primordial Essence",
rarity: "rare",
zoneId: "primordial_chaos",
},
// Zone 14: infinite_expanse
{ id: "expanse_dust", name: "Expanse Dust", description: "Gathered from somewhere in the expanse. Direction is uncertain. Distance from the collection point is uncertain.", zoneId: "infinite_expanse", rarity: "common" },
{ id: "distance_crystal", name: "Distance Crystal", description: "A crystal that contains compressed distance. It weighs more than its size suggests. Much more. Do not drop it.", zoneId: "infinite_expanse", rarity: "uncommon" },
{ id: "infinity_shard", name: "Infinity Shard", description: "A fragment of the expanse's edge, which the expanse does not technically have. This should not exist. It does anyway.", zoneId: "infinite_expanse", rarity: "rare" },
{
description:
"Gathered from somewhere in the expanse. Direction is uncertain. Distance from the collection point is uncertain.",
id: "expanse_dust",
name: "Expanse Dust",
rarity: "common",
zoneId: "infinite_expanse",
},
{
description:
"A crystal that contains compressed distance. It weighs more than its size suggests. Much more. Do not drop it.",
id: "distance_crystal",
name: "Distance Crystal",
rarity: "uncommon",
zoneId: "infinite_expanse",
},
{
description:
"A fragment of the expanse's edge, which the expanse does not technically have. This should not exist. It does anyway.",
id: "infinity_shard",
name: "Infinity Shard",
rarity: "rare",
zoneId: "infinite_expanse",
},
// Zone 15: reality_forge
{ id: "forge_ash", name: "Forge Ash", description: "Ash from the forge's fires. Contains fragments of unrealised realities that never quite made it to existence.", zoneId: "reality_forge", rarity: "common" },
{ id: "creation_tool", name: "Creation Tool", description: "A worn tool left by whatever worked here before your universe existed. Still functional in ways that are difficult to explain.", zoneId: "reality_forge", rarity: "uncommon" },
{ id: "reality_shard", name: "Reality Shard", description: "A flawed reality, discarded by the forge as below standard. Still contains everything a universe needs.", zoneId: "reality_forge", rarity: "rare" },
{
description:
"Ash from the forge's fires. Contains fragments of unrealised realities that never quite made it to existence.",
id: "forge_ash",
name: "Forge Ash",
rarity: "common",
zoneId: "reality_forge",
},
{
description:
"A worn tool left by whatever worked here before your universe existed. Still functional in ways that are difficult to explain.",
id: "creation_tool",
name: "Creation Tool",
rarity: "uncommon",
zoneId: "reality_forge",
},
{
description:
"A flawed reality, discarded by the forge as below standard. Still contains everything a universe needs.",
id: "reality_shard",
name: "Reality Shard",
rarity: "rare",
zoneId: "reality_forge",
},
// Zone 16: cosmic_maelstrom
{ id: "maelstrom_debris", name: "Maelstrom Debris", description: "Debris from a galaxy that got too close to the maelstrom. Compressed to a size your guild can actually carry.", zoneId: "cosmic_maelstrom", rarity: "common" },
{ id: "force_crystal", name: "Force Crystal", description: "A crystal that formed at the intersection of several fundamental forces that should never have been in the same place.", zoneId: "cosmic_maelstrom", rarity: "uncommon" },
{ id: "cosmic_fragment", name: "Cosmic Fragment", description: "A fragment from the maelstrom's eye. Impossibly calm. Whatever is at the centre has been there since the beginning.", zoneId: "cosmic_maelstrom", rarity: "rare" },
{
description:
"Debris from a galaxy that got too close to the maelstrom. Compressed to a size your guild can actually carry.",
id: "maelstrom_debris",
name: "Maelstrom Debris",
rarity: "common",
zoneId: "cosmic_maelstrom",
},
{
description:
"A crystal that formed at the intersection of several fundamental forces that should never have been in the same place.",
id: "force_crystal",
name: "Force Crystal",
rarity: "uncommon",
zoneId: "cosmic_maelstrom",
},
{
description:
"A fragment from the maelstrom's eye. Impossibly calm. Whatever is at the centre has been there since the beginning.",
id: "cosmic_fragment",
name: "Cosmic Fragment",
rarity: "rare",
zoneId: "cosmic_maelstrom",
},
// Zone 17: primeval_sanctum
{ id: "ancient_dust", name: "Ancient Dust", description: "Dust from the oldest place. Has been here since before the concept of 'here' had been invented.", zoneId: "primeval_sanctum", rarity: "common" },
{ id: "memory_shard", name: "Memory Shard", description: "A shard of something that remembers the moment before the first moment. The memory is in the material itself.", zoneId: "primeval_sanctum", rarity: "uncommon" },
{ id: "primeval_relic", name: "Primeval Relic", description: "An artefact from the first thing to exist in this place. What it did is unknown. That it mattered is beyond doubt.", zoneId: "primeval_sanctum", rarity: "rare" },
{
description:
"Dust from the oldest place. Has been here since before the concept of 'here' had been invented.",
id: "ancient_dust",
name: "Ancient Dust",
rarity: "common",
zoneId: "primeval_sanctum",
},
{
description:
"A shard of something that remembers the moment before the first moment. The memory is in the material itself.",
id: "memory_shard",
name: "Memory Shard",
rarity: "uncommon",
zoneId: "primeval_sanctum",
},
{
description:
"An artefact from the first thing to exist in this place. What it did is unknown. That it mattered is beyond doubt.",
id: "primeval_relic",
name: "Primeval Relic",
rarity: "rare",
zoneId: "primeval_sanctum",
},
// Zone 18: the_absolute
{ id: "absolute_fragment", name: "Absolute Fragment", description: "A fragment of the final truth. It is difficult to look at directly, and impossible to look away from once you start.", zoneId: "the_absolute", rarity: "common" },
{ id: "boundary_shard", name: "Boundary Shard", description: "From the edge of everything. On one side: everything. On the other: nothing. This is from the very boundary between them.", zoneId: "the_absolute", rarity: "uncommon" },
{ id: "omega_crystal", name: "Omega Crystal", description: "The last crystal. After this, there are no more. It knows this. You can tell from the way it sits in your hand.", zoneId: "the_absolute", rarity: "rare" },
{
description:
"A fragment of the final truth. It is difficult to look at directly, and impossible to look away from once you start.",
id: "absolute_fragment",
name: "Absolute Fragment",
rarity: "common",
zoneId: "the_absolute",
},
{
description:
"From the edge of everything. On one side: everything. On the other: nothing. This is from the very boundary between them.",
id: "boundary_shard",
name: "Boundary Shard",
rarity: "uncommon",
zoneId: "the_absolute",
},
{
description:
"The last crystal. After this, there are no more. It knows this. You can tell from the way it sits in your hand.",
id: "omega_crystal",
name: "Omega Crystal",
rarity: "rare",
zoneId: "the_absolute",
},
];
+132 -107
View File
@@ -1,216 +1,241 @@
/**
* @file Game data definitions.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable stylistic/max-len -- Data content */
import type { PrestigeUpgrade } from "@elysium/types";
export const DEFAULT_PRESTIGE_UPGRADES: PrestigeUpgrade[] = [
export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
// ── Global Income Tiers ───────────────────────────────────────────────────
{
id: "income_1",
name: "Runestone Blessing I",
description: "The first runestone awakens dormant power in your guild. All production ×1.25.",
category: "income",
description:
"The first runestone awakens dormant power in your guild. All production ×1.25.",
id: "income_1",
multiplier: 1.25,
name: "Runestone Blessing I",
runestonesCost: 10,
multiplier: 1.25,
},
{
id: "income_2",
name: "Runestone Blessing II",
description: "Deeper runestone resonance amplifies your workforce. All production ×1.5.",
category: "income",
description:
"Deeper runestone resonance amplifies your workforce. All production ×1.5.",
id: "income_2",
multiplier: 1.5,
name: "Runestone Blessing II",
runestonesCost: 25,
multiplier: 1.5,
},
{
id: "income_3",
name: "Runestone Blessing III",
description: "The runes sing with accumulated wisdom. All production ×2.",
category: "income",
category: "income",
description: "The runes sing with accumulated wisdom. All production ×2.",
id: "income_3",
multiplier: 2,
name: "Runestone Blessing III",
runestonesCost: 60,
multiplier: 2,
},
{
id: "income_4",
name: "Runic Surge I",
description: "Runestone energy surges through your guild's operations. All production ×3.",
category: "income",
description:
"Runestone energy surges through your guild's operations. All production ×3.",
id: "income_4",
multiplier: 3,
name: "Runic Surge I",
runestonesCost: 150,
multiplier: 3,
},
{
id: "income_5",
name: "Runic Surge II",
description: "The surge intensifies, pushing limits thought impossible. All production ×5.",
category: "income",
description:
"The surge intensifies, pushing limits thought impossible. All production ×5.",
id: "income_5",
multiplier: 5,
name: "Runic Surge II",
runestonesCost: 350,
multiplier: 5,
},
{
id: "income_6",
name: "Runic Surge III",
description: "An overwhelming tide of runic energy floods your operations. All production ×10.",
category: "income",
description:
"An overwhelming tide of runic energy floods your operations. All production ×10.",
id: "income_6",
multiplier: 10,
name: "Runic Surge III",
runestonesCost: 800,
multiplier: 10,
},
{
id: "income_7",
name: "Ancient Inscription I",
category: "income",
description:
"You decipher ancient runic inscriptions that unlock vast potential. All production ×25.",
category: "income",
runestonesCost: 2_000,
multiplier: 25,
id: "income_7",
multiplier: 25,
name: "Ancient Inscription I",
runestonesCost: 2000,
},
{
id: "income_8",
name: "Ancient Inscription II",
description: "Deeper inscriptions reveal secrets of primordial power. All production ×50.",
category: "income",
runestonesCost: 5_000,
multiplier: 50,
description:
"Deeper inscriptions reveal secrets of primordial power. All production ×50.",
id: "income_8",
multiplier: 50,
name: "Ancient Inscription II",
runestonesCost: 5000,
},
{
id: "income_9",
name: "Ancient Inscription III",
description: "The full inscription blazes with world-shaping power. All production ×100.",
category: "income",
description:
"The full inscription blazes with world-shaping power. All production ×100.",
id: "income_9",
multiplier: 100,
name: "Ancient Inscription III",
runestonesCost: 12_000,
multiplier: 100,
},
{
id: "income_10",
name: "Eternal Rune I",
category: "income",
description:
"The oldest runes, carved before memory began, yield their secrets at last. All production ×500.",
category: "income",
id: "income_10",
multiplier: 500,
name: "Eternal Rune I",
runestonesCost: 30_000,
multiplier: 500,
},
{
id: "income_11",
name: "Eternal Rune II",
category: "income",
description:
"Eternal runes resonate with the heartbeat of creation itself. All production ×1,000.",
category: "income",
id: "income_11",
multiplier: 1000,
name: "Eternal Rune II",
runestonesCost: 80_000,
multiplier: 1_000,
},
// ── Click Power ───────────────────────────────────────────────────────────
{
id: "click_power_1",
name: "Runic Strike I",
description: "Infuse your personal strikes with runestone energy. Click power ×2.",
category: "click",
description:
"Infuse your personal strikes with runestone energy. Click power ×2.",
id: "click_power_1",
multiplier: 2,
name: "Runic Strike I",
runestonesCost: 15,
multiplier: 2,
},
{
id: "click_power_2",
name: "Runic Strike II",
description: "Your strikes crackle with compounded runic force. Click power ×5.",
category: "click",
description:
"Your strikes crackle with compounded runic force. Click power ×5.",
id: "click_power_2",
multiplier: 5,
name: "Runic Strike II",
runestonesCost: 75,
multiplier: 5,
},
{
id: "click_power_3",
name: "Runic Strike III",
description: "Every click channels the weight of all your past lives. Click power ×20.",
category: "click",
description:
"Every click channels the weight of all your past lives. Click power ×20.",
id: "click_power_3",
multiplier: 20,
name: "Runic Strike III",
runestonesCost: 400,
multiplier: 20,
},
{
id: "click_power_4",
name: "World-Breaker Click",
description: "A single click now carries the force of a falling empire. Click power ×100.",
category: "click",
runestonesCost: 2_500,
multiplier: 100,
description:
"A single click now carries the force of a falling empire. Click power ×100.",
id: "click_power_4",
multiplier: 100,
name: "World-Breaker Click",
runestonesCost: 2500,
},
// ── Essence Production ────────────────────────────────────────────────────
{
id: "essence_1",
name: "Essence Attunement I",
description: "Runestone resonance amplifies your essence gathering. Essence production ×2.",
category: "essence",
description:
"Runestone resonance amplifies your essence gathering. Essence production ×2.",
id: "essence_1",
multiplier: 2,
name: "Essence Attunement I",
runestonesCost: 20,
multiplier: 2,
},
{
id: "essence_2",
name: "Essence Attunement II",
description: "Deep attunement draws essence from previously invisible sources. Essence production ×5.",
category: "essence",
description:
"Deep attunement draws essence from previously invisible sources. Essence production ×5.",
id: "essence_2",
multiplier: 5,
name: "Essence Attunement II",
runestonesCost: 120,
multiplier: 5,
},
{
id: "essence_3",
name: "Essence Attunement III",
description: "Your guild breathes essence as naturally as air. Essence production ×20.",
category: "essence",
description:
"Your guild breathes essence as naturally as air. Essence production ×20.",
id: "essence_3",
multiplier: 20,
name: "Essence Attunement III",
runestonesCost: 700,
multiplier: 20,
},
{
id: "essence_4",
name: "Essence Attunement IV",
description: "Essence flows in torrents from every corner of every world. Essence production ×100.",
category: "essence",
runestonesCost: 4_000,
multiplier: 100,
description:
"Essence flows in torrents from every corner of every world. Essence production ×100.",
id: "essence_4",
multiplier: 100,
name: "Essence Attunement IV",
runestonesCost: 4000,
},
// ── Crystal Production ────────────────────────────────────────────────────
{
id: "crystal_1",
name: "Crystal Resonance I",
description: "Runestones vibrate in harmony with crystal structures. Crystal rewards ×2.",
category: "crystals",
description:
"Runestones vibrate in harmony with crystal structures. Crystal rewards ×2.",
id: "crystal_1",
multiplier: 2,
name: "Crystal Resonance I",
runestonesCost: 30,
multiplier: 2,
},
{
id: "crystal_2",
name: "Crystal Resonance II",
description: "The resonance deepens, shattering crystal barriers. Crystal rewards ×5.",
category: "crystals",
description:
"The resonance deepens, shattering crystal barriers. Crystal rewards ×5.",
id: "crystal_2",
multiplier: 5,
name: "Crystal Resonance II",
runestonesCost: 200,
multiplier: 5,
},
{
id: "crystal_3",
name: "Crystal Resonance III",
description: "Pure resonance crystallises reality into abundance. Crystal rewards ×25.",
category: "crystals",
runestonesCost: 1_200,
multiplier: 25,
description:
"Pure resonance crystallises reality into abundance. Crystal rewards ×25.",
id: "crystal_3",
multiplier: 25,
name: "Crystal Resonance III",
runestonesCost: 1200,
},
// ── Utility Unlocks ───────────────────────────────────────────────────────
{
id: "auto_prestige",
name: "Autonomous Ascension",
category: "utility",
description:
"Unlock the Auto-Prestige toggle. When enabled, you will automatically ascend the moment you reach the prestige threshold — using your current character name.",
category: "utility",
id: "auto_prestige",
multiplier: 1,
name: "Autonomous Ascension",
runestonesCost: 100,
multiplier: 1,
},
// ── Runestone Meta-Upgrade ────────────────────────────────────────────────
{
id: "runestone_gain_1",
name: "Runic Legacy",
category: "runestones",
description:
"Your runestone attunement grows with each prestige. Earn 25% more runestones from future prestiges.",
category: "runestones",
id: "runestone_gain_1",
multiplier: 1.25,
name: "Runic Legacy",
runestonesCost: 50,
multiplier: 1.25,
},
{
id: "runestone_gain_2",
name: "Eternal Legacy",
category: "runestones",
description:
"Your legend transcends individual lifetimes. Earn 50% more runestones from future prestiges.",
category: "runestones",
id: "runestone_gain_2",
multiplier: 1.5,
name: "Eternal Legacy",
runestonesCost: 500,
multiplier: 1.5,
},
];
File diff suppressed because it is too large Load Diff
+333 -181
View File
@@ -1,327 +1,479 @@
/**
* @file Game data definitions.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines -- Data file */
/* eslint-disable stylistic/max-len -- Data content */
import type { CraftingRecipe } from "@elysium/types";
export const DEFAULT_RECIPES: CraftingRecipe[] = [
export const defaultRecipes: Array<CraftingRecipe> = [
// Zone 1: verdant_vale
{
id: "heartwood_tincture",
name: "Heartwood Tincture",
description: "Sap from ancient heartwood trees, refined and bound with forest crystal. The resulting tincture accelerates the flow of wealth through your guild in ways the alchemists cannot fully explain.",
zoneId: "verdant_vale",
requiredMaterials: [{ materialId: "verdant_sap", quantity: 5 }, { materialId: "forest_crystal", quantity: 3 }],
bonus: { type: "gold_income", value: 1.05 },
description:
"Sap from ancient heartwood trees, refined and bound with forest crystal. The resulting tincture accelerates the flow of wealth through your guild in ways the alchemists cannot fully explain.",
id: "heartwood_tincture",
name: "Heartwood Tincture",
requiredMaterials: [
{ materialId: "verdant_sap", quantity: 5 },
{ materialId: "forest_crystal", quantity: 3 },
],
zoneId: "verdant_vale",
},
{
id: "elder_bark_shield",
name: "Elder Bark Shield",
description: "A ward fashioned from bark older than the kingdom. Its presence on the battlefield is not merely physical — something ancient watches through it.",
zoneId: "verdant_vale",
requiredMaterials: [{ materialId: "elder_bark", quantity: 2 }, { materialId: "verdant_sap", quantity: 8 }],
bonus: { type: "combat_power", value: 1.08 },
description:
"A ward fashioned from bark older than the kingdom. Its presence on the battlefield is not merely physical — something ancient watches through it.",
id: "elder_bark_shield",
name: "Elder Bark Shield",
requiredMaterials: [
{ materialId: "elder_bark", quantity: 2 },
{ materialId: "verdant_sap", quantity: 8 },
],
zoneId: "verdant_vale",
},
// Zone 2: shattered_ruins
{
id: "runic_binding",
name: "Runic Binding",
description: "The ruin dust and cursed fragments, carefully worked into a binding that borrows the essence-drawing power of the fallen civilisation's final enchantments.",
zoneId: "shattered_ruins",
requiredMaterials: [{ materialId: "ruin_dust", quantity: 8 }, { materialId: "cursed_fragment", quantity: 4 }],
bonus: { type: "essence_income", value: 1.05 },
description:
"The ruin dust and cursed fragments, carefully worked into a binding that borrows the essence-drawing power of the fallen civilisation's final enchantments.",
id: "runic_binding",
name: "Runic Binding",
requiredMaterials: [
{ materialId: "ruin_dust", quantity: 8 },
{ materialId: "cursed_fragment", quantity: 4 },
],
zoneId: "shattered_ruins",
},
{
id: "dragon_scale_charm",
name: "Dragon Scale Charm",
description: "A charm set with a chip of the elder dragon's scale. The dragon would be furious if he knew. He would also be impressed.",
zoneId: "shattered_ruins",
requiredMaterials: [{ materialId: "dragonscale_chip", quantity: 2 }, { materialId: "ruin_dust", quantity: 10 }],
bonus: { type: "gold_income", value: 1.08 },
description:
"A charm set with a chip of the elder dragon's scale. The dragon would be furious if he knew. He would also be impressed.",
id: "dragon_scale_charm",
name: "Dragon Scale Charm",
requiredMaterials: [
{ materialId: "dragonscale_chip", quantity: 2 },
{ materialId: "ruin_dust", quantity: 10 },
],
zoneId: "shattered_ruins",
},
// Zone 3: frozen_peaks
{
id: "glacial_lens",
name: "Glacial Lens",
description: "Glacial ice ground and shaped into a lens that clarifies and focuses. Holding it, your guild's actions become sharper, more precise, more effective per motion.",
zoneId: "frozen_peaks",
requiredMaterials: [{ materialId: "glacial_ice", quantity: 8 }, { materialId: "frost_crystal", quantity: 4 }],
bonus: { type: "click_power", value: 1.08 },
description:
"Glacial ice ground and shaped into a lens that clarifies and focuses. Holding it, your guild's actions become sharper, more precise, more effective per motion.",
id: "glacial_lens",
name: "Glacial Lens",
requiredMaterials: [
{ materialId: "glacial_ice", quantity: 8 },
{ materialId: "frost_crystal", quantity: 4 },
],
zoneId: "frozen_peaks",
},
{
id: "void_fragment_amulet",
name: "Void Fragment Amulet",
description: "The void shard set in frost crystal, creating something that should not be possible: a stable window into the spaces between spaces. Profitable beyond what physics suggests.",
bonus: { type: "gold_income", value: 1.1 },
description:
"The void shard set in frost crystal, creating something that should not be possible: a stable window into the spaces between spaces. Profitable beyond what physics suggests.",
id: "void_fragment_amulet",
name: "Void Fragment Amulet",
requiredMaterials: [
{ materialId: "void_shard", quantity: 2 },
{ materialId: "frost_crystal", quantity: 6 },
],
zoneId: "frozen_peaks",
requiredMaterials: [{ materialId: "void_shard", quantity: 2 }, { materialId: "frost_crystal", quantity: 6 }],
bonus: { type: "gold_income", value: 1.10 },
},
// Zone 4: shadow_marshes
{
id: "shadow_extract",
name: "Shadow Extract",
description: "Marsh roots processed with shadow essence into a refined compound that somehow makes the essence of things flow more freely toward your guild hall.",
zoneId: "shadow_marshes",
requiredMaterials: [{ materialId: "marsh_root", quantity: 8 }, { materialId: "shadow_essence", quantity: 4 }],
bonus: { type: "essence_income", value: 1.08 },
description:
"Marsh roots processed with shadow essence into a refined compound that somehow makes the essence of things flow more freely toward your guild hall.",
id: "shadow_extract",
name: "Shadow Extract",
requiredMaterials: [
{ materialId: "marsh_root", quantity: 8 },
{ materialId: "shadow_essence", quantity: 4 },
],
zoneId: "shadow_marshes",
},
{
id: "cursed_focus",
name: "Cursed Focus",
description: "The cursed bone and shadow essence combined into a focus that doesn't so much help your party fight as make things afraid of them before the battle begins.",
bonus: { type: "combat_power", value: 1.1 },
description:
"The cursed bone and shadow essence combined into a focus that doesn't so much help your party fight as make things afraid of them before the battle begins.",
id: "cursed_focus",
name: "Cursed Focus",
requiredMaterials: [
{ materialId: "cursed_bone", quantity: 2 },
{ materialId: "shadow_essence", quantity: 6 },
],
zoneId: "shadow_marshes",
requiredMaterials: [{ materialId: "cursed_bone", quantity: 2 }, { materialId: "shadow_essence", quantity: 6 }],
bonus: { type: "combat_power", value: 1.10 },
},
// Zone 5: volcanic_depths
{
id: "magma_core_seal",
name: "Magma Core Seal",
description: "A seal forged in the volcanic depths, using the eternal heat of the magma stone and ember crystal to create something that burns wealth into existence continuously.",
bonus: { type: "gold_income", value: 1.1 },
description:
"A seal forged in the volcanic depths, using the eternal heat of the magma stone and ember crystal to create something that burns wealth into existence continuously.",
id: "magma_core_seal",
name: "Magma Core Seal",
requiredMaterials: [
{ materialId: "magma_stone", quantity: 8 },
{ materialId: "ember_crystal", quantity: 4 },
],
zoneId: "volcanic_depths",
requiredMaterials: [{ materialId: "magma_stone", quantity: 8 }, { materialId: "ember_crystal", quantity: 4 }],
bonus: { type: "gold_income", value: 1.10 },
},
{
id: "elemental_ore_ingot",
name: "Elemental Ore Ingot",
description: "The legendary ore, smelted in the volcanic forges with magma stone as fuel. What emerges is something the fire elementals recognise — and fear slightly.",
zoneId: "volcanic_depths",
requiredMaterials: [{ materialId: "legendary_ore", quantity: 2 }, { materialId: "magma_stone", quantity: 10 }],
bonus: { type: "combat_power", value: 1.12 },
description:
"The legendary ore, smelted in the volcanic forges with magma stone as fuel. What emerges is something the fire elementals recognise — and fear slightly.",
id: "elemental_ore_ingot",
name: "Elemental Ore Ingot",
requiredMaterials: [
{ materialId: "legendary_ore", quantity: 2 },
{ materialId: "magma_stone", quantity: 10 },
],
zoneId: "volcanic_depths",
},
// Zone 6: astral_void
{
id: "star_chart",
name: "Star Chart",
description: "Stardust arranged along astral threads into a map of the void that somehow, impossibly, shows your guild where to press and how to press it for maximum effect.",
zoneId: "astral_void",
requiredMaterials: [{ materialId: "stardust", quantity: 10 }, { materialId: "astral_thread", quantity: 4 }],
bonus: { type: "click_power", value: 1.12 },
description:
"Stardust arranged along astral threads into a map of the void that somehow, impossibly, shows your guild where to press and how to press it for maximum effect.",
id: "star_chart",
name: "Star Chart",
requiredMaterials: [
{ materialId: "stardust", quantity: 10 },
{ materialId: "astral_thread", quantity: 4 },
],
zoneId: "astral_void",
},
{
id: "void_crystal_matrix",
name: "Void Crystal Matrix",
description: "A void crystal suspended in a matrix of stardust — something that exists in several places simultaneously and draws gold from all of them at once.",
zoneId: "astral_void",
requiredMaterials: [{ materialId: "void_crystal", quantity: 2 }, { materialId: "stardust", quantity: 12 }],
bonus: { type: "gold_income", value: 1.12 },
description:
"A void crystal suspended in a matrix of stardust — something that exists in several places simultaneously and draws gold from all of them at once.",
id: "void_crystal_matrix",
name: "Void Crystal Matrix",
requiredMaterials: [
{ materialId: "void_crystal", quantity: 2 },
{ materialId: "stardust", quantity: 12 },
],
zoneId: "astral_void",
},
// Zone 7: celestial_reaches
{
id: "celestial_lens",
name: "Celestial Lens",
description: "Celestial dust and divine fragments ground into a lens that sees the essence in all things and draws a portion of it — gently, as the celestials would prefer.",
zoneId: "celestial_reaches",
requiredMaterials: [{ materialId: "celestial_dust", quantity: 10 }, { materialId: "divine_fragment", quantity: 4 }],
bonus: { type: "essence_income", value: 1.12 },
description:
"Celestial dust and divine fragments ground into a lens that sees the essence in all things and draws a portion of it — gently, as the celestials would prefer.",
id: "celestial_lens",
name: "Celestial Lens",
requiredMaterials: [
{ materialId: "celestial_dust", quantity: 10 },
{ materialId: "divine_fragment", quantity: 4 },
],
zoneId: "celestial_reaches",
},
{
id: "choir_resonator",
name: "Choir Resonator",
description: "A choir shard set in divine fragments, still humming with the celestial harmonic. The resonance makes gold flow in its direction — not compelled, simply invited.",
zoneId: "celestial_reaches",
requiredMaterials: [{ materialId: "choir_shard", quantity: 2 }, { materialId: "divine_fragment", quantity: 6 }],
bonus: { type: "gold_income", value: 1.15 },
description:
"A choir shard set in divine fragments, still humming with the celestial harmonic. The resonance makes gold flow in its direction — not compelled, simply invited.",
id: "choir_resonator",
name: "Choir Resonator",
requiredMaterials: [
{ materialId: "choir_shard", quantity: 2 },
{ materialId: "divine_fragment", quantity: 6 },
],
zoneId: "celestial_reaches",
},
// Zone 8: abyssal_trench
{
id: "pressure_forged_core",
name: "Pressure-Forged Core",
description: "Trench coral and pressure gem combined under conditions that should have destroyed both. The result is something that survived conditions nothing should survive, which is ideal for combat.",
zoneId: "abyssal_trench",
requiredMaterials: [{ materialId: "trench_coral", quantity: 10 }, { materialId: "pressure_gem", quantity: 4 }],
bonus: { type: "combat_power", value: 1.15 },
description:
"Trench coral and pressure gem combined under conditions that should have destroyed both. The result is something that survived conditions nothing should survive, which is ideal for combat.",
id: "pressure_forged_core",
name: "Pressure-Forged Core",
requiredMaterials: [
{ materialId: "trench_coral", quantity: 10 },
{ materialId: "pressure_gem", quantity: 4 },
],
zoneId: "abyssal_trench",
},
{
id: "ancient_fang_talisman",
name: "Ancient Fang Talisman",
description: "A talisman set with the ancient tooth, suspended in trench coral carvings. Your party fights differently with this at their chest. More deliberately. More completely.",
zoneId: "abyssal_trench",
requiredMaterials: [{ materialId: "ancient_tooth", quantity: 2 }, { materialId: "trench_coral", quantity: 12 }],
bonus: { type: "click_power", value: 1.15 },
description:
"A talisman set with the ancient tooth, suspended in trench coral carvings. Your party fights differently with this at their chest. More deliberately. More completely.",
id: "ancient_fang_talisman",
name: "Ancient Fang Talisman",
requiredMaterials: [
{ materialId: "ancient_tooth", quantity: 2 },
{ materialId: "trench_coral", quantity: 12 },
],
zoneId: "abyssal_trench",
},
// Zone 9: infernal_court
{
id: "court_seal",
name: "Court Seal",
description: "A seal of infernal court authority, forged from brimstone and ichor. The court doesn't know you have this. It's better that way. It does make trade extremely efficient.",
zoneId: "infernal_court",
requiredMaterials: [{ materialId: "brimstone_flake", quantity: 10 }, { materialId: "demon_ichor", quantity: 5 }],
bonus: { type: "gold_income", value: 1.15 },
description:
"A seal of infernal court authority, forged from brimstone and ichor. The court doesn't know you have this. It's better that way. It does make trade extremely efficient.",
id: "court_seal",
name: "Court Seal",
requiredMaterials: [
{ materialId: "brimstone_flake", quantity: 10 },
{ materialId: "demon_ichor", quantity: 5 },
],
zoneId: "infernal_court",
},
{
id: "soul_bound_catalyst",
name: "Soul-Bound Catalyst",
description: "Soul residue and demon ichor worked into a catalyst that draws the essence from everything around it — gently, without drawing the court's attention. Usually.",
zoneId: "infernal_court",
requiredMaterials: [{ materialId: "soul_residue", quantity: 2 }, { materialId: "demon_ichor", quantity: 8 }],
bonus: { type: "essence_income", value: 1.15 },
description:
"Soul residue and demon ichor worked into a catalyst that draws the essence from everything around it — gently, without drawing the court's attention. Usually.",
id: "soul_bound_catalyst",
name: "Soul-Bound Catalyst",
requiredMaterials: [
{ materialId: "soul_residue", quantity: 2 },
{ materialId: "demon_ichor", quantity: 8 },
],
zoneId: "infernal_court",
},
// Zone 10: crystalline_spire
{
id: "prism_array",
name: "Prism Array",
description: "Prism dust and calculation shards assembled into an array that the spire's intelligence would call elegant, if it had aesthetic preferences, which it might.",
zoneId: "crystalline_spire",
requiredMaterials: [{ materialId: "prism_dust", quantity: 10 }, { materialId: "calculation_shard", quantity: 4 }],
bonus: { type: "click_power", value: 1.18 },
description:
"Prism dust and calculation shards assembled into an array that the spire's intelligence would call elegant, if it had aesthetic preferences, which it might.",
id: "prism_array",
name: "Prism Array",
requiredMaterials: [
{ materialId: "prism_dust", quantity: 10 },
{ materialId: "calculation_shard", quantity: 4 },
],
zoneId: "crystalline_spire",
},
{
id: "possibility_engine",
name: "Possibility Engine",
description: "A possibility crystal contained within a calculation shard framework. It runs through every possible outcome of every guild action and finds the one with the highest gold yield.",
zoneId: "crystalline_spire",
requiredMaterials: [{ materialId: "possibility_crystal", quantity: 2 }, { materialId: "calculation_shard", quantity: 6 }],
bonus: { type: "gold_income", value: 1.18 },
description:
"A possibility crystal contained within a calculation shard framework. It runs through every possible outcome of every guild action and finds the one with the highest gold yield.",
id: "possibility_engine",
name: "Possibility Engine",
requiredMaterials: [
{ materialId: "possibility_crystal", quantity: 2 },
{ materialId: "calculation_shard", quantity: 6 },
],
zoneId: "crystalline_spire",
},
// Zone 11: void_sanctum
{
id: "null_field_generator",
name: "Null Field Generator",
description: "Null matter and resonance fragments assembled into something that generates a field where the laws governing how hard things are to kill become negotiable.",
zoneId: "void_sanctum",
requiredMaterials: [{ materialId: "null_matter", quantity: 10 }, { materialId: "resonance_fragment", quantity: 4 }],
bonus: { type: "combat_power", value: 1.18 },
description:
"Null matter and resonance fragments assembled into something that generates a field where the laws governing how hard things are to kill become negotiable.",
id: "null_field_generator",
name: "Null Field Generator",
requiredMaterials: [
{ materialId: "null_matter", quantity: 10 },
{ materialId: "resonance_fragment", quantity: 4 },
],
zoneId: "void_sanctum",
},
{
id: "sanctum_key",
name: "Sanctum Key",
description: "A sanctum core and resonance fragments shaped into a key to something. The essence flows through it like it was designed to carry essence, which it may have been.",
zoneId: "void_sanctum",
requiredMaterials: [{ materialId: "sanctum_core", quantity: 2 }, { materialId: "resonance_fragment", quantity: 6 }],
bonus: { type: "essence_income", value: 1.18 },
description:
"A sanctum core and resonance fragments shaped into a key to something. The essence flows through it like it was designed to carry essence, which it may have been.",
id: "sanctum_key",
name: "Sanctum Key",
requiredMaterials: [
{ materialId: "sanctum_core", quantity: 2 },
{ materialId: "resonance_fragment", quantity: 6 },
],
zoneId: "void_sanctum",
},
// Zone 12: eternal_throne
{
id: "crown_circlet",
name: "Crown Circlet",
description: "Throne dust pressed into throne dust-lacquered crown fragments, shaped into a circlet. Wearing it — metaphorically — makes gold accumulate with the inevitability of authority.",
bonus: { type: "gold_income", value: 1.2 },
description:
"Throne dust pressed into throne dust-lacquered crown fragments, shaped into a circlet. Wearing it — metaphorically — makes gold accumulate with the inevitability of authority.",
id: "crown_circlet",
name: "Crown Circlet",
requiredMaterials: [
{ materialId: "throne_dust", quantity: 10 },
{ materialId: "crown_fragment", quantity: 4 },
],
zoneId: "eternal_throne",
requiredMaterials: [{ materialId: "throne_dust", quantity: 10 }, { materialId: "crown_fragment", quantity: 4 }],
bonus: { type: "gold_income", value: 1.20 },
},
{
id: "eternity_bound_ring",
name: "Eternity-Bound Ring",
description: "An eternity splinter set into crown fragments, shaped into a ring. What it binds is unclear. Whatever it is, it makes your party significantly harder to kill.",
bonus: { type: "combat_power", value: 1.2 },
description:
"An eternity splinter set into crown fragments, shaped into a ring. What it binds is unclear. Whatever it is, it makes your party significantly harder to kill.",
id: "eternity_bound_ring",
name: "Eternity-Bound Ring",
requiredMaterials: [
{ materialId: "eternity_splinter", quantity: 2 },
{ materialId: "crown_fragment", quantity: 6 },
],
zoneId: "eternal_throne",
requiredMaterials: [{ materialId: "eternity_splinter", quantity: 2 }, { materialId: "crown_fragment", quantity: 6 }],
bonus: { type: "combat_power", value: 1.20 },
},
// Zone 13: primordial_chaos
{
id: "chaos_lens",
name: "Chaos Lens",
description: "Chaos fragments and creation shards arranged into a lens that hasn't decided what it wants to focus on yet, which somehow makes every click land harder than it should.",
bonus: { type: "click_power", value: 1.2 },
description:
"Chaos fragments and creation shards arranged into a lens that hasn't decided what it wants to focus on yet, which somehow makes every click land harder than it should.",
id: "chaos_lens",
name: "Chaos Lens",
requiredMaterials: [
{ materialId: "chaos_fragment", quantity: 10 },
{ materialId: "creation_shard", quantity: 4 },
],
zoneId: "primordial_chaos",
requiredMaterials: [{ materialId: "chaos_fragment", quantity: 10 }, { materialId: "creation_shard", quantity: 4 }],
bonus: { type: "click_power", value: 1.20 },
},
{
id: "creation_core",
name: "Creation Core",
description: "Primordial essence held in a creation shard framework. It hums constantly. Gold flows toward it with the enthusiasm of something that wants to become something.",
zoneId: "primordial_chaos",
requiredMaterials: [{ materialId: "primordial_essence", quantity: 2 }, { materialId: "creation_shard", quantity: 6 }],
bonus: { type: "gold_income", value: 1.22 },
description:
"Primordial essence held in a creation shard framework. It hums constantly. Gold flows toward it with the enthusiasm of something that wants to become something.",
id: "creation_core",
name: "Creation Core",
requiredMaterials: [
{ materialId: "primordial_essence", quantity: 2 },
{ materialId: "creation_shard", quantity: 6 },
],
zoneId: "primordial_chaos",
},
// Zone 14: infinite_expanse
{
id: "distance_coil",
name: "Distance Coil",
description: "Expanse dust wound around distance crystals into a coil that draws essence from distances too vast to measure, compressing it into something your guild can actually use.",
bonus: { type: "essence_income", value: 1.2 },
description:
"Expanse dust wound around distance crystals into a coil that draws essence from distances too vast to measure, compressing it into something your guild can actually use.",
id: "distance_coil",
name: "Distance Coil",
requiredMaterials: [
{ materialId: "expanse_dust", quantity: 10 },
{ materialId: "distance_crystal", quantity: 4 },
],
zoneId: "infinite_expanse",
requiredMaterials: [{ materialId: "expanse_dust", quantity: 10 }, { materialId: "distance_crystal", quantity: 4 }],
bonus: { type: "essence_income", value: 1.20 },
},
{
id: "infinity_prism",
name: "Infinity Prism",
description: "An infinity shard mounted in a distance crystal frame. The prism reflects gold from an infinite number of directions simultaneously. The math works out favourably.",
zoneId: "infinite_expanse",
requiredMaterials: [{ materialId: "infinity_shard", quantity: 2 }, { materialId: "distance_crystal", quantity: 6 }],
bonus: { type: "gold_income", value: 1.22 },
description:
"An infinity shard mounted in a distance crystal frame. The prism reflects gold from an infinite number of directions simultaneously. The math works out favourably.",
id: "infinity_prism",
name: "Infinity Prism",
requiredMaterials: [
{ materialId: "infinity_shard", quantity: 2 },
{ materialId: "distance_crystal", quantity: 6 },
],
zoneId: "infinite_expanse",
},
// Zone 15: reality_forge
{
id: "reality_ingot",
name: "Reality Ingot",
description: "Forge ash smelted with creation tools into an ingot of compressed reality. Your party hits harder when carrying it. Reality does not enjoy being concentrated like this.",
zoneId: "reality_forge",
requiredMaterials: [{ materialId: "forge_ash", quantity: 10 }, { materialId: "creation_tool", quantity: 4 }],
bonus: { type: "combat_power", value: 1.22 },
description:
"Forge ash smelted with creation tools into an ingot of compressed reality. Your party hits harder when carrying it. Reality does not enjoy being concentrated like this.",
id: "reality_ingot",
name: "Reality Ingot",
requiredMaterials: [
{ materialId: "forge_ash", quantity: 10 },
{ materialId: "creation_tool", quantity: 4 },
],
zoneId: "reality_forge",
},
{
id: "universe_seed",
name: "Universe Seed",
description: "A reality shard carefully shaped with creation tools into something that could, theoretically, become a universe. Instead it makes your clicks unreasonably effective.",
zoneId: "reality_forge",
requiredMaterials: [{ materialId: "reality_shard", quantity: 2 }, { materialId: "creation_tool", quantity: 6 }],
bonus: { type: "click_power", value: 1.22 },
description:
"A reality shard carefully shaped with creation tools into something that could, theoretically, become a universe. Instead it makes your clicks unreasonably effective.",
id: "universe_seed",
name: "Universe Seed",
requiredMaterials: [
{ materialId: "reality_shard", quantity: 2 },
{ materialId: "creation_tool", quantity: 6 },
],
zoneId: "reality_forge",
},
// Zone 16: cosmic_maelstrom
{
id: "force_lens",
name: "Force Lens",
description: "Maelstrom debris and force crystals ground into a lens at the intersection of fundamental forces. Gold flows toward it with the same inevitability that galaxies flow toward gravity.",
zoneId: "cosmic_maelstrom",
requiredMaterials: [{ materialId: "maelstrom_debris", quantity: 10 }, { materialId: "force_crystal", quantity: 4 }],
bonus: { type: "gold_income", value: 1.25 },
description:
"Maelstrom debris and force crystals ground into a lens at the intersection of fundamental forces. Gold flows toward it with the same inevitability that galaxies flow toward gravity.",
id: "force_lens",
name: "Force Lens",
requiredMaterials: [
{ materialId: "maelstrom_debris", quantity: 10 },
{ materialId: "force_crystal", quantity: 4 },
],
zoneId: "cosmic_maelstrom",
},
{
id: "maelstrom_eye",
name: "Maelstrom Eye",
description: "A cosmic fragment suspended in a force crystal matrix — a piece of the maelstrom's impossible calm, holding the eye of the storm. Essence accumulates in its vicinity.",
zoneId: "cosmic_maelstrom",
requiredMaterials: [{ materialId: "cosmic_fragment", quantity: 2 }, { materialId: "force_crystal", quantity: 6 }],
bonus: { type: "essence_income", value: 1.22 },
description:
"A cosmic fragment suspended in a force crystal matrix — a piece of the maelstrom's impossible calm, holding the eye of the storm. Essence accumulates in its vicinity.",
id: "maelstrom_eye",
name: "Maelstrom Eye",
requiredMaterials: [
{ materialId: "cosmic_fragment", quantity: 2 },
{ materialId: "force_crystal", quantity: 6 },
],
zoneId: "cosmic_maelstrom",
},
// Zone 17: primeval_sanctum
{
id: "ancient_memory_array",
name: "Ancient Memory Array",
description: "Ancient dust and memory shards arranged into something that remembers how to fight before fighting was invented. Your party benefits from this instinct enormously.",
zoneId: "primeval_sanctum",
requiredMaterials: [{ materialId: "ancient_dust", quantity: 10 }, { materialId: "memory_shard", quantity: 4 }],
bonus: { type: "combat_power", value: 1.25 },
description:
"Ancient dust and memory shards arranged into something that remembers how to fight before fighting was invented. Your party benefits from this instinct enormously.",
id: "ancient_memory_array",
name: "Ancient Memory Array",
requiredMaterials: [
{ materialId: "ancient_dust", quantity: 10 },
{ materialId: "memory_shard", quantity: 4 },
],
zoneId: "primeval_sanctum",
},
{
id: "first_artefact",
name: "First Artefact",
description: "The primeval relic, set into a memory shard framework. What function it originally served is unknowable. In your guild's hands, it makes every action more deliberate and more powerful.",
zoneId: "primeval_sanctum",
requiredMaterials: [{ materialId: "primeval_relic", quantity: 2 }, { materialId: "memory_shard", quantity: 6 }],
bonus: { type: "click_power", value: 1.25 },
description:
"The primeval relic, set into a memory shard framework. What function it originally served is unknowable. In your guild's hands, it makes every action more deliberate and more powerful.",
id: "first_artefact",
name: "First Artefact",
requiredMaterials: [
{ materialId: "primeval_relic", quantity: 2 },
{ materialId: "memory_shard", quantity: 6 },
],
zoneId: "primeval_sanctum",
},
// Zone 18: the_absolute
{
id: "final_truth_lens",
name: "Final Truth Lens",
description: "Absolute fragments and boundary shards ground into a lens that sees to the end of all things — and in seeing, draws the wealth inherent in finality toward your guild.",
bonus: { type: "gold_income", value: 1.3 },
description:
"Absolute fragments and boundary shards ground into a lens that sees to the end of all things — and in seeing, draws the wealth inherent in finality toward your guild.",
id: "final_truth_lens",
name: "Final Truth Lens",
requiredMaterials: [
{ materialId: "absolute_fragment", quantity: 10 },
{ materialId: "boundary_shard", quantity: 4 },
],
zoneId: "the_absolute",
requiredMaterials: [{ materialId: "absolute_fragment", quantity: 10 }, { materialId: "boundary_shard", quantity: 4 }],
bonus: { type: "gold_income", value: 1.30 },
},
{
id: "omega_convergence",
name: "Omega Convergence",
description: "The last omega crystal, set at the convergence of boundary shards in the precise arrangement of an ending. What it does to combat is what endings do to everything: make it final.",
bonus: { type: "combat_power", value: 1.3 },
description:
"The last omega crystal, set at the convergence of boundary shards in the precise arrangement of an ending. What it does to combat is what endings do to everything: make it final.",
id: "omega_convergence",
name: "Omega Convergence",
requiredMaterials: [
{ materialId: "omega_crystal", quantity: 2 },
{ materialId: "boundary_shard", quantity: 6 },
],
zoneId: "the_absolute",
requiredMaterials: [{ materialId: "omega_crystal", quantity: 2 }, { materialId: "boundary_shard", quantity: 6 }],
bonus: { type: "combat_power", value: 1.30 },
},
];
+11 -2
View File
@@ -1,2 +1,11 @@
/** The current game state schema version. Bump this whenever a breaking change is made to GameState. */
export const CURRENT_SCHEMA_VERSION = 1;
/**
* @file Schema version tracking for game state.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/**
* The current game state schema version. Bump this whenever a breaking change is made to GameState.
*/
export const currentSchemaVersion = 1;
+67 -61
View File
@@ -1,135 +1,141 @@
/**
* @file Game title definitions.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Title } from "@elysium/types";
export const TITLES: Title[] = [
export const gameTitles: Array<Title> = [
// Quest milestones
{
id: "the_adventurous",
name: "The Adventurous",
condition: { amount: 1, type: "questsCompleted" },
description: "Complete your first quest.",
condition: { type: "questsCompleted", amount: 1 },
id: "the_adventurous",
name: "The Adventurous",
},
{
id: "the_persistent",
name: "The Persistent",
condition: { amount: 100, type: "questsCompleted" },
description: "Complete 100 quests in a single run.",
condition: { type: "questsCompleted", amount: 100 },
id: "the_persistent",
name: "The Persistent",
},
// Boss milestones
{
id: "boss_slayer",
name: "Boss Slayer",
condition: { amount: 1, type: "bossesDefeated" },
description: "Defeat your first boss.",
condition: { type: "bossesDefeated", amount: 1 },
id: "boss_slayer",
name: "Boss Slayer",
},
{
id: "dungeon_master",
name: "Dungeon Master",
condition: { amount: 10, type: "bossesDefeated" },
description: "Defeat 10 bosses in a single run.",
condition: { type: "bossesDefeated", amount: 10 },
id: "dungeon_master",
name: "Dungeon Master",
},
// Gold milestones
{
id: "the_wealthy",
name: "The Wealthy",
condition: { amount: 1_000_000, type: "totalGoldEarned" },
description: "Earn 1,000,000 gold in a single run.",
condition: { type: "totalGoldEarned", amount: 1_000_000 },
id: "the_wealthy",
name: "The Wealthy",
},
{
id: "the_rich",
name: "The Rich",
condition: { amount: 1_000_000_000, type: "totalGoldEarned" },
description: "Earn 1,000,000,000 gold in a single run.",
condition: { type: "totalGoldEarned", amount: 1_000_000_000 },
id: "the_rich",
name: "The Rich",
},
// Click milestones
{
id: "click_maniac",
name: "Click Maniac",
condition: { amount: 10_000, type: "totalClicks" },
description: "Click the Guild Hall 10,000 times in a single run.",
condition: { type: "totalClicks", amount: 10_000 },
id: "click_maniac",
name: "Click Maniac",
},
// Adventurer milestones
{
id: "commander",
name: "Commander",
condition: { amount: 100, type: "adventurerTotal" },
description: "Recruit 100 adventurers.",
condition: { type: "adventurerTotal", amount: 100 },
id: "commander",
name: "Commander",
},
{
id: "warlord",
name: "Warlord",
condition: { amount: 1000, type: "adventurerTotal" },
description: "Recruit 1,000 adventurers.",
condition: { type: "adventurerTotal", amount: 1_000 },
id: "warlord",
name: "Warlord",
},
// Social
{
id: "guild_founder",
name: "Guild Founder",
condition: { type: "guildFounded" },
description: "Give your guild a name.",
condition: { type: "guildFounded" },
id: "guild_founder",
name: "Guild Founder",
},
// Prestige milestones
{
id: "the_undying",
name: "The Undying",
condition: { amount: 1, type: "prestigeCount" },
description: "Achieve your first Prestige.",
condition: { type: "prestigeCount", amount: 1 },
id: "the_undying",
name: "The Undying",
},
{
id: "battle_hardened",
name: "Battle Hardened",
condition: { amount: 5, type: "prestigeCount" },
description: "Achieve 5 Prestiges.",
condition: { type: "prestigeCount", amount: 5 },
id: "battle_hardened",
name: "Battle Hardened",
},
{
id: "legend",
name: "Legend",
condition: { amount: 25, type: "prestigeCount" },
description: "Achieve 25 Prestiges.",
condition: { type: "prestigeCount", amount: 25 },
id: "legend",
name: "Legend",
},
// Transcendence milestones
{
id: "transcendent",
name: "Transcendent",
condition: { amount: 1, type: "transcendenceCount" },
description: "Achieve your first Transcendence.",
condition: { type: "transcendenceCount", amount: 1 },
id: "transcendent",
name: "Transcendent",
},
{
id: "beyond_mortal",
name: "Beyond Mortal",
condition: { amount: 5, type: "transcendenceCount" },
description: "Achieve 5 Transcendences.",
condition: { type: "transcendenceCount", amount: 5 },
id: "beyond_mortal",
name: "Beyond Mortal",
},
// Apotheosis milestones
{
id: "apotheosised",
name: "Apotheosised",
condition: { amount: 1, type: "apotheosisCount" },
description: "Achieve your first Apotheosis.",
condition: { type: "apotheosisCount", amount: 1 },
id: "apotheosised",
name: "Apotheosised",
},
{
id: "ascendant",
name: "Ascendant",
condition: { amount: 5, type: "apotheosisCount" },
description: "Achieve 5 Apotheoses.",
condition: { type: "apotheosisCount", amount: 5 },
id: "ascendant",
name: "Ascendant",
},
// Achievement milestone
{
id: "completionist",
name: "Completionist",
condition: { amount: 40, type: "achievementsUnlocked" },
description: "Unlock all achievements.",
condition: { type: "achievementsUnlocked", amount: 40 },
id: "completionist",
name: "Completionist",
},
// Longevity
{
id: "veteran",
name: "Veteran",
condition: { amount: 30, type: "playedDays" },
description: "Play Elysium for 30 days.",
condition: { type: "playedDays", amount: 30 },
id: "veteran",
name: "Veteran",
},
{
id: "timeless",
name: "Timeless",
condition: { amount: 365, type: "playedDays" },
description: "Play Elysium for a full year.",
condition: { type: "playedDays", amount: 365 },
id: "timeless",
name: "Timeless",
},
];
+89 -67
View File
@@ -1,133 +1,155 @@
/**
* @file Game data definitions.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable stylistic/max-len -- Data content */
import type { TranscendenceUpgrade } from "@elysium/types";
export const DEFAULT_TRANSCENDENCE_UPGRADES: TranscendenceUpgrade[] = [
export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
// ── Income multipliers ──────────────────────────────────────────────────────
{
id: "echo_income_1",
name: "Whisper of Power",
description: "The echoes of past runs linger, amplifying your guild's income by 25%.",
category: "income",
cost: 5,
cost: 5,
description:
"The echoes of past runs linger, amplifying your guild's income by 25%.",
id: "echo_income_1",
multiplier: 1.25,
name: "Whisper of Power",
},
{
id: "echo_income_2",
name: "Resonance",
description: "Your transcendent experience resonates through your guild, boosting income by 50%.",
category: "income",
cost: 10,
cost: 10,
description:
"Your transcendent experience resonates through your guild, boosting income by 50%.",
id: "echo_income_2",
multiplier: 1.5,
name: "Resonance",
},
{
id: "echo_income_3",
name: "Harmonic Surge",
description: "The harmony of multiple timelines surges through your guild, doubling its income.",
category: "income",
cost: 20,
multiplier: 2.0,
cost: 20,
description:
"The harmony of multiple timelines surges through your guild, doubling its income.",
id: "echo_income_3",
multiplier: 2,
name: "Harmonic Surge",
},
{
id: "echo_income_4",
name: "Ethereal Overflow",
description: "Ethereal energy overflows from your transcendence, tripling your guild's income.",
category: "income",
cost: 40,
multiplier: 3.0,
cost: 40,
description:
"Ethereal energy overflows from your transcendence, tripling your guild's income.",
id: "echo_income_4",
multiplier: 3,
name: "Ethereal Overflow",
},
{
id: "echo_income_5",
name: "Infinite Chorus",
description: "The infinite chorus of every run you've ever played amplifies your guild fivefold.",
category: "income",
cost: 80,
multiplier: 5.0,
cost: 80,
description:
"The infinite chorus of every run you've ever played amplifies your guild fivefold.",
id: "echo_income_5",
multiplier: 5,
name: "Infinite Chorus",
},
// ── Combat multipliers ──────────────────────────────────────────────────────
{
id: "echo_combat_1",
name: "Battle-Hardened",
description: "Memories of countless battles harden your adventurers, increasing party DPS by 25%.",
category: "combat",
cost: 5,
cost: 5,
description:
"Memories of countless battles harden your adventurers, increasing party DPS by 25%.",
id: "echo_combat_1",
multiplier: 1.25,
name: "Battle-Hardened",
},
{
id: "echo_combat_2",
name: "Veteran's Edge",
description: "Veterans of transcendence know how to fight smarter, boosting party DPS by 50%.",
category: "combat",
cost: 15,
cost: 15,
description:
"Veterans of transcendence know how to fight smarter, boosting party DPS by 50%.",
id: "echo_combat_2",
multiplier: 1.5,
name: "Veteran's Edge",
},
{
id: "echo_combat_3",
name: "Transcendent Warrior",
description: "Your warriors carry the strength of every fallen timeline, doubling party DPS.",
category: "combat",
cost: 35,
multiplier: 2.0,
cost: 35,
description:
"Your warriors carry the strength of every fallen timeline, doubling party DPS.",
id: "echo_combat_3",
multiplier: 2,
name: "Transcendent Warrior",
},
// ── Prestige threshold reductions ──────────────────────────────────────────
{
id: "echo_prestige_threshold_1",
name: "Accelerated Path",
description: "Experience from past lives shortens the road to prestige — threshold reduced by 10%.",
category: "prestige_threshold",
cost: 8,
cost: 8,
description:
"Experience from past lives shortens the road to prestige — threshold reduced by 10%.",
id: "echo_prestige_threshold_1",
multiplier: 0.9,
name: "Accelerated Path",
},
{
id: "echo_prestige_threshold_2",
name: "Shortcut Through Time",
description: "You've walked this path so many times you know every shortcut — threshold reduced by 20%.",
category: "prestige_threshold",
cost: 20,
cost: 20,
description:
"You've walked this path so many times you know every shortcut — threshold reduced by 20%.",
id: "echo_prestige_threshold_2",
multiplier: 0.8,
name: "Shortcut Through Time",
},
// ── Prestige runestone multipliers ─────────────────────────────────────────
{
id: "echo_prestige_runestones_1",
name: "Runic Attunement",
description: "Transcendent insight attunes you to the runestones, earning 50% more per prestige.",
category: "prestige_runestones",
cost: 8,
cost: 8,
description:
"Transcendent insight attunes you to the runestones, earning 50% more per prestige.",
id: "echo_prestige_runestones_1",
multiplier: 1.5,
name: "Runic Attunement",
},
{
id: "echo_prestige_runestones_2",
name: "Master Runesmith",
description: "You have mastered the art of runestone crafting, doubling your prestige runestone yield.",
category: "prestige_runestones",
cost: 20,
multiplier: 2.0,
cost: 20,
description:
"You have mastered the art of runestone crafting, doubling your prestige runestone yield.",
id: "echo_prestige_runestones_2",
multiplier: 2,
name: "Master Runesmith",
},
// ── Echo meta multipliers ───────────────────────────────────────────────────
{
id: "echo_meta_1",
name: "Resonant Awakening",
description: "Your transcendence resonates deeper, amplifying future echo yields by 25%.",
category: "echo_meta",
cost: 10,
cost: 10,
description:
"Your transcendence resonates deeper, amplifying future echo yields by 25%.",
id: "echo_meta_1",
multiplier: 1.25,
name: "Resonant Awakening",
},
{
id: "echo_meta_2",
name: "Transcendent Loop",
description: "Each loop of existence makes the next more powerful — future echo yields +50%.",
category: "echo_meta",
cost: 25,
cost: 25,
description:
"Each loop of existence makes the next more powerful — future echo yields +50%.",
id: "echo_meta_2",
multiplier: 1.5,
name: "Transcendent Loop",
},
{
id: "echo_meta_3",
name: "Infinite Spiral",
description: "You have mastered the infinite spiral of transcendence, doubling all future echo yields.",
category: "echo_meta",
cost: 50,
multiplier: 2.0,
cost: 50,
description:
"You have mastered the infinite spiral of transcendence, doubling all future echo yields.",
id: "echo_meta_3",
multiplier: 2,
name: "Infinite Spiral",
},
];
File diff suppressed because it is too large Load Diff
+98 -91
View File
@@ -1,184 +1,191 @@
/**
* @file Game data definitions.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable stylistic/max-len -- Data content */
import type { Zone } from "@elysium/types";
export const DEFAULT_ZONES: Zone[] = [
export const defaultZones: Array<Zone> = [
{
id: "verdant_vale",
name: "The Verdant Vale",
description:
"Rolling green hills and ancient forests stretch to the horizon. This is where your guild takes its first steps — trade roads in need of clearing, goblin camps to rout, and an undead queen stirring in the north.",
emoji: "🌿",
status: "unlocked",
unlockBossId: null,
emoji: "🌿",
id: "verdant_vale",
name: "The Verdant Vale",
status: "unlocked",
unlockBossId: null,
unlockQuestId: null,
},
{
id: "shattered_ruins",
name: "The Shattered Ruins",
description:
"The remnants of a civilisation long lost to war and dragonfire. Crumbling towers and cursed lakes hide treasures — and an elder dragon who claims these lands as his own.",
emoji: "🏛️",
status: "locked",
unlockBossId: "forest_giant",
emoji: "🏛️",
id: "shattered_ruins",
name: "The Shattered Ruins",
status: "locked",
unlockBossId: "forest_giant",
unlockQuestId: "ancient_ruins",
},
{
id: "frozen_peaks",
name: "The Frozen Peaks",
description:
"At the edge of the world, where the sun barely rises and the cold is a living thing, a tear in reality has drawn something ancient and terrible. Only the mightiest guilds dare tread here.",
emoji: "❄️",
status: "locked",
unlockBossId: "elder_dragon",
emoji: "❄️",
id: "frozen_peaks",
name: "The Frozen Peaks",
status: "locked",
unlockBossId: "elder_dragon",
unlockQuestId: "dragon_lair",
},
{
id: "shadow_marshes",
name: "The Shadow Marshes",
description:
"A vast, fog-choked wetland where the sun never fully rises. Dark magic seeps from the earth itself, and things far older than the kingdom lurk beneath the murky waters.",
emoji: "🌑",
status: "locked",
unlockBossId: "void_titan",
emoji: "🌑",
id: "shadow_marshes",
name: "The Shadow Marshes",
status: "locked",
unlockBossId: "void_titan",
unlockQuestId: "storm_citadel",
},
{
id: "volcanic_depths",
name: "The Volcanic Depths",
description:
"A chain of active volcanoes whose caverns plunge deep into the earth's molten heart. Legendary forges burn here, tended by fire elementals who serve no master — yet.",
emoji: "🌋",
status: "locked",
unlockBossId: "mud_kraken",
emoji: "🌋",
id: "volcanic_depths",
name: "The Volcanic Depths",
status: "locked",
unlockBossId: "mud_kraken",
unlockQuestId: "plague_ruins",
},
{
id: "astral_void",
name: "The Astral Void",
description:
"Beyond the veil of the mortal world lies a realm of pure possibility and absolute terror. Stars are born and die here in moments, and the beings that call this place home have never known mortality.",
emoji: "🌌",
status: "locked",
unlockBossId: "phoenix_lord",
emoji: "🌌",
id: "astral_void",
name: "The Astral Void",
status: "locked",
unlockBossId: "phoenix_lord",
unlockQuestId: "the_forge",
},
{
id: "celestial_reaches",
name: "The Celestial Reaches",
description:
"Beyond the astral void, where reality gives way to pure divinity. The celestial host holds court here in towers of light older than stars, but their idea of order is as alien and terrifying as the chaos below.",
emoji: "✨",
status: "locked",
unlockBossId: "the_devourer",
emoji: "✨",
id: "celestial_reaches",
name: "The Celestial Reaches",
status: "locked",
unlockBossId: "the_devourer",
unlockQuestId: "the_end",
},
{
id: "abyssal_trench",
name: "The Abyssal Trench",
description:
"At the bottom of all things, where no light reaches and pressure could crush continents, something old and patient waits. It has been waiting since before your world was made — and it has never been interrupted.",
emoji: "🌊",
status: "locked",
unlockBossId: "the_first_light",
emoji: "🌊",
id: "abyssal_trench",
name: "The Abyssal Trench",
status: "locked",
unlockBossId: "the_first_light",
unlockQuestId: "celestial_archive",
},
{
id: "infernal_court",
name: "The Infernal Court",
description:
"The courts of the underworld, where demon lords scheme across aeons. Power here is measured in souls and suffering — your guild deals in neither, but you will have to speak their language before this is over.",
emoji: "👿",
status: "locked",
unlockBossId: "elder_abomination",
emoji: "👿",
id: "infernal_court",
name: "The Infernal Court",
status: "locked",
unlockBossId: "elder_abomination",
unlockQuestId: "abyssal_chronicle",
},
{
id: "crystalline_spire",
name: "The Crystalline Spire",
description:
"A tower of living crystal that pierces every boundary between planes. Its facets reflect possibilities that have never existed and futures that cannot be. The intelligence at its core has been calculating since before this universe existed.",
emoji: "💎",
status: "locked",
unlockBossId: "the_fallen",
emoji: "💎",
id: "crystalline_spire",
name: "The Crystalline Spire",
status: "locked",
unlockBossId: "the_fallen",
unlockQuestId: "infernal_codex",
},
{
id: "void_sanctum",
name: "The Void Sanctum",
description:
"Not a place but a state of being — the space between the spaces between things. Existence grows thin here. Your guild is the first to find it, drawn by a power that should not be able to call to anything that lives.",
emoji: "🌀",
status: "locked",
unlockBossId: "crystal_sovereign",
emoji: "🌀",
id: "void_sanctum",
name: "The Void Sanctum",
status: "locked",
unlockBossId: "crystal_sovereign",
unlockQuestId: "the_prism_vault",
},
{
id: "eternal_throne",
name: "The Eternal Throne",
description:
"The seat of ultimate power at the centre of all creation. Whoever sits here has sat here since the beginning. They have watched countless guilds rise and fall across uncounted ages. Your guild has come to take the throne. It does not yield.",
emoji: "👑",
status: "locked",
unlockBossId: "void_emperor",
emoji: "👑",
id: "eternal_throne",
name: "The Eternal Throne",
status: "locked",
unlockBossId: "void_emperor",
unlockQuestId: "heart_of_void",
},
{
id: "primordial_chaos",
name: "The Primordial Chaos",
description:
"Beyond the throne lies the raw stuff of creation itself — not a place but an ongoing argument between existence and non-existence that has never been resolved. Your guild enters the argument.",
emoji: "🌪️",
status: "locked",
unlockBossId: "the_apex",
emoji: "🌪️",
id: "primordial_chaos",
name: "The Primordial Chaos",
status: "locked",
unlockBossId: "the_apex",
unlockQuestId: "eternal_dominion",
},
{
id: "infinite_expanse",
name: "The Infinite Expanse",
description:
"A realm without edges, without centre, without reference — where distance is a concept that does not apply and your guild must define their own coordinates to navigate at all. Everything here is further than it looks.",
emoji: "♾️",
status: "locked",
unlockBossId: "primordial_titan",
emoji: "♾️",
id: "infinite_expanse",
name: "The Infinite Expanse",
status: "locked",
unlockBossId: "primordial_titan",
unlockQuestId: "chaos_chronicle",
},
{
id: "reality_forge",
name: "The Reality Forge",
description:
"The workshop where the original universe was hammered into shape — still hot, still humming, still producing realities as a byproduct of its idle operation. The things that work here have never stopped.",
emoji: "⚒️",
status: "locked",
unlockBossId: "expanse_sovereign",
emoji: "⚒️",
id: "reality_forge",
name: "The Reality Forge",
status: "locked",
unlockBossId: "expanse_sovereign",
unlockQuestId: "expanse_codex",
},
{
id: "cosmic_maelstrom",
name: "The Cosmic Maelstrom",
description:
"A confluence of every force in existence, spinning in patterns that reduce galaxies to debris. Your guild navigates currents of energy that, on a good day, merely shatter planets.",
emoji: "🌀",
status: "locked",
unlockBossId: "reality_architect",
emoji: "🌀",
id: "cosmic_maelstrom",
name: "The Cosmic Maelstrom",
status: "locked",
unlockBossId: "reality_architect",
unlockQuestId: "forge_chronicle",
},
{
id: "primeval_sanctum",
name: "The Primeval Sanctum",
description:
"The oldest place that has ever existed — older than time, older than space, older than the concept of age itself. It holds something that remembers the moment before the first moment.",
emoji: "🗿",
status: "locked",
unlockBossId: "cosmic_annihilator",
emoji: "🗿",
id: "primeval_sanctum",
name: "The Primeval Sanctum",
status: "locked",
unlockBossId: "cosmic_annihilator",
unlockQuestId: "maelstrom_codex",
},
{
id: "the_absolute",
name: "The Absolute",
description:
"There is nothing beyond this. Not because nothing has been found — because nothing exists to find. The Absolute is the final truth: the end of all things that are and the beginning of all things that never were. Your guild stands at the edge of everything.",
emoji: "⚫",
status: "locked",
unlockBossId: "primeval_god",
emoji: "⚫",
id: "the_absolute",
name: "The Absolute",
status: "locked",
unlockBossId: "primeval_god",
unlockQuestId: "sanctum_chronicle",
},
];
+6
View File
@@ -1,3 +1,9 @@
/**
* @file Prisma database client singleton.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
+18 -10
View File
@@ -1,3 +1,9 @@
/**
* @file Entry point for the Elysium API server.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { cors } from "hono/cors";
@@ -11,8 +17,8 @@ import { exploreRouter } from "./routes/explore.js";
import { gameRouter } from "./routes/game.js";
import { leaderboardRouter } from "./routes/leaderboards.js";
import { prestigeRouter } from "./routes/prestige.js";
import { transcendenceRouter } from "./routes/transcendence.js";
import { profileRouter } from "./routes/profile.js";
import { transcendenceRouter } from "./routes/transcendence.js";
const app = new Hono();
@@ -20,9 +26,9 @@ app.use("*", logger());
app.use(
"*",
cors({
origin: process.env["CORS_ORIGIN"] ?? "http://localhost:5173",
allowHeaders: ["Authorization", "Content-Type"],
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowHeaders: [ "Authorization", "Content-Type" ],
allowMethods: [ "GET", "POST", "PUT", "DELETE", "OPTIONS" ],
origin: process.env.CORS_ORIGIN ?? "http://localhost:5173",
}),
);
@@ -38,10 +44,12 @@ app.route("/apotheosis", apotheosisRouter);
app.route("/leaderboards", leaderboardRouter);
app.route("/profile", profileRouter);
app.get("/health", (context) => context.json({ status: "ok" }));
const port = Number(process.env["PORT"] ?? 3001);
serve({ fetch: app.fetch, port }, () => {
console.log(`Elysium API running on port ${port}`);
app.get("/health", (context) => {
return context.json({ status: "ok" });
});
const port = Number(process.env.PORT ?? 3001);
serve({ fetch: app.fetch, port: port }, () => {
process.stdout.write(`Elysium API running on port ${String(port)}\n`);
});
+28 -7
View File
@@ -1,12 +1,31 @@
import type { MiddlewareHandler } from "hono";
import type { HonoEnv } from "../types/hono.js";
import { verifyToken } from "../services/jwt.js";
/**
* @file Authentication middleware for validating JWT tokens.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
export const authMiddleware: MiddlewareHandler<HonoEnv> = async (context, next) => {
import { verifyToken } from "../services/jwt.js";
import type { HonoEnvironment } from "../types/hono.js";
import type { MiddlewareHandler } from "hono";
/**
* Validates the Authorization Bearer token on each request and attaches the discordId to context.
* @param context - The Hono context object.
* @param next - The next middleware handler.
* @returns A JSON error response if authentication fails, otherwise calls next.
*/
export const authMiddleware: MiddlewareHandler<HonoEnvironment> = async(
context,
next,
) => {
const authorization = context.req.header("Authorization");
if (!authorization?.startsWith("Bearer ")) {
return context.json({ error: "Missing or invalid Authorization header" }, 401);
if (authorization?.startsWith("Bearer ") !== true) {
return context.json(
{ error: "Missing or invalid Authorization header" },
401,
);
}
const token = authorization.slice(7);
@@ -14,8 +33,10 @@ export const authMiddleware: MiddlewareHandler<HonoEnv> = async (context, next)
try {
const payload = verifyToken(token);
context.set("discordId", payload.discordId);
await next();
} catch {
return context.json({ error: "Invalid or expired token" }, 401);
}
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression -- Need the consistent return!
return await next();
};
+34 -18
View File
@@ -1,41 +1,57 @@
/**
* @file About route providing API version and release information.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable stylistic/max-len -- URL cannot be shortened */
/* eslint-disable require-atomic-updates -- Simple cache; race condition is acceptable */
import { Hono } from "hono";
import type { AboutResponse, GiteaRelease } from "@elysium/types";
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const API_VERSION = process.env.npm_package_version ?? "unknown";
const apiVersion = process.env.npm_package_version ?? "unknown";
const GITEA_RELEASES_URL =
"https://git.nhcarrigan.com/api/v1/repos/nhcarrigan-ideation/elysium/releases";
const CACHE_TTL_MS = 5 * 60 * 1000;
const giteaReleasesUrl = "https://git.nhcarrigan.com/api/v1/repos/nhcarrigan-ideation/elysium/releases";
const cacheTtlMs = 5 * 60 * 1000;
let releasesCache: GiteaRelease[] = [];
let cacheTimestamp = 0;
interface ReleasesCache {
data: Array<GiteaRelease>;
timestamp: number;
}
const fetchReleases = async (): Promise<GiteaRelease[]> => {
let releasesCache: ReleasesCache = { data: [], timestamp: 0 };
const fetchReleases = async(): Promise<Array<GiteaRelease>> => {
const now = Date.now();
if (releasesCache.length > 0 && now - cacheTimestamp < CACHE_TTL_MS) {
return releasesCache;
if (releasesCache.data.length > 0 && now - releasesCache.timestamp < cacheTtlMs) {
return releasesCache.data;
}
try {
const response = await fetch(GITEA_RELEASES_URL);
const response = await fetch(giteaReleasesUrl);
if (!response.ok) {
return releasesCache;
return releasesCache.data;
}
releasesCache = (await response.json()) as GiteaRelease[];
cacheTimestamp = now;
return releasesCache;
const rawData: unknown = await response.json();
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- External API response */
const data = rawData as Array<GiteaRelease>;
releasesCache = { data: data, timestamp: now };
return releasesCache.data;
} catch {
return releasesCache;
return releasesCache.data;
}
};
export const aboutRouter = new Hono();
const aboutRouter = new Hono();
aboutRouter.get("/", async (context) => {
aboutRouter.get("/", async(context) => {
const releases = await fetchReleases();
const body: AboutResponse = {
apiVersion: API_VERSION,
apiVersion,
releases,
};
return context.json(body);
});
export { aboutRouter };
+71 -28
View File
@@ -1,75 +1,118 @@
import type { GameState } from "@elysium/types";
/**
* @file Apotheosis route handling the apotheosis reset mechanic.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Route handler requires many steps */
/* eslint-disable stylistic/max-len -- Description string cannot be shortened */
import { Hono } from "hono";
import type { HonoEnv } from "../types/hono.js";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
import {
buildPostApotheosisState,
isEligibleForApotheosis,
} from "../services/apotheosis.js";
import { grantApotheosisRole, postMilestoneWebhook } from "../services/webhook.js";
import {
grantApotheosisRole,
postMilestoneWebhook,
} from "../services/webhook.js";
import type { HonoEnvironment } from "../types/hono.js";
import type { GameState } from "@elysium/types";
export const apotheosisRouter = new Hono<HonoEnv>();
const apotheosisRouter = new Hono<HonoEnvironment>();
apotheosisRouter.use("*", authMiddleware);
apotheosisRouter.post("/", async (context) => {
const discordId = context.get("discordId") as string;
apotheosisRouter.post("/", async(context) => {
const discordId = context.get("discordId");
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const state = record.state as unknown as GameState;
const rawState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState;
if (!isEligibleForApotheosis(state)) {
return context.json(
{ error: "Not eligible for Apotheosis — purchase all Transcendence upgrades first" },
{
error:
"Not eligible for Apotheosis — purchase all Transcendence upgrades first",
},
400,
);
}
// Capture current-run stats before the nuclear reset
const runBossesDefeated = state.bosses.filter((b) => b.status === "defeated").length;
const runQuestsCompleted = state.quests.filter((q) => q.status === "completed").length;
const runAdventurersRecruited = state.adventurers.reduce((sum, a) => sum + a.count, 0);
/* v8 ignore next -- @preserve */
const runAchievementsUnlocked = (state.achievements ?? []).filter((a) => a.unlockedAt !== null).length;
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 9 -- @preserve */
const runBossesDefeated = state.bosses.filter((b) => {
return b.status === "defeated";
}).length;
const runQuestsCompleted = state.quests.filter((q) => {
return q.status === "completed";
}).length;
const runAdventurersRecruited = state.adventurers.reduce((sum, a) => {
return sum + a.count;
}, 0);
const { newState, newApotheosisData } = buildPostApotheosisState(state, state.player.characterName);
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
const runAchievementsUnlocked = state.achievements.filter((a) => {
return a.unlockedAt !== null;
}).length;
const { updatedState, updatedApotheosisData } = buildPostApotheosisState(
state,
state.player.characterName,
);
const now = Date.now();
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: updatedState as object, updatedAt: now },
where: { discordId },
data: { state: newState as object, updatedAt: now },
});
await prisma.player.update({
where: { discordId },
data: {
characterName: state.player.characterName,
// Reset current-run counters
totalGoldEarned: 0,
totalClicks: 0,
lastSavedAt: now,
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
lifetimeBossesDefeated: { increment: runBossesDefeated },
lifetimeClicks: { increment: state.player.totalClicks },
// Accumulate into lifetime totals
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
lifetimeClicks: { increment: state.player.totalClicks },
lifetimeBossesDefeated: { increment: runBossesDefeated },
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
lastSavedAt: now,
totalClicks: 0,
// Reset current-run counters
totalGoldEarned: 0,
},
where: { discordId },
});
void grantApotheosisRole(discordId);
void postMilestoneWebhook(discordId, "apotheosis", {
prestige: newState.prestige.count,
apotheosis: updatedApotheosisData.count,
prestige: updatedState.prestige.count,
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
transcendence: newState.transcendence?.count ?? 0,
apotheosis: newApotheosisData.count,
transcendence: updatedState.transcendence?.count ?? 0,
});
return context.json({ newApotheosisCount: newApotheosisData.count });
return context.json({ apotheosisCount: updatedApotheosisData.count });
});
export { apotheosisRouter };
+60 -37
View File
@@ -1,15 +1,23 @@
import type { Player } from "@elysium/types";
/**
* @file Authentication routes for Discord OAuth.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Auth callback requires many steps */
/* eslint-disable max-statements -- Auth callback requires many statements */
import { Hono } from "hono";
import { initialGameState } from "../data/initialState.js";
import { prisma } from "../db/client.js";
import { INITIAL_GAME_STATE } from "../data/initialState.js";
import {
buildOAuthUrl,
exchangeCode,
fetchDiscordUser,
} from "../services/discord.js";
import { signToken } from "../services/jwt.js";
import type { Player } from "@elysium/types";
export const authRouter = new Hono();
const authRouter = new Hono();
authRouter.get("/url", (context) => {
try {
@@ -20,10 +28,10 @@ authRouter.get("/url", (context) => {
}
});
authRouter.get("/callback", async (context) => {
authRouter.get("/callback", async(context) => {
const code = context.req.query("code");
if (!code) {
if (code === undefined || code === "") {
return context.json({ error: "Missing code parameter" }, 400);
}
@@ -40,67 +48,82 @@ authRouter.get("/callback", async (context) => {
if (!existing) {
const player = await prisma.player.create({
data: {
discordId: discordUser.id,
username: discordUser.username,
discriminator: discordUser.discriminator,
avatar: discordUser.avatar,
characterName: discordUser.username,
createdAt: now,
lastSavedAt: now,
avatar: discordUser.avatar,
characterName: discordUser.username,
createdAt: now,
discordId: discordUser.id,
discriminator: discordUser.discriminator,
lastSavedAt: now,
totalClicks: 0,
totalGoldEarned: 0,
totalClicks: 0,
username: discordUser.username,
},
});
const playerShape: Player = {
discordId: player.discordId,
username: player.username,
discriminator: player.discriminator,
avatar: player.avatar ?? null,
characterName: player.characterName,
createdAt: player.createdAt,
lastSavedAt: player.lastSavedAt,
totalGoldEarned: player.totalGoldEarned,
totalClicks: player.totalClicks,
lifetimeGoldEarned: player.lifetimeGoldEarned,
lifetimeClicks: player.lifetimeClicks,
lifetimeBossesDefeated: player.lifetimeBossesDefeated,
lifetimeQuestsCompleted: player.lifetimeQuestsCompleted,
lifetimeAdventurersRecruited: player.lifetimeAdventurersRecruited,
avatar: player.avatar ?? null,
characterName: player.characterName,
createdAt: player.createdAt,
discordId: player.discordId,
discriminator: player.discriminator,
lastSavedAt: player.lastSavedAt,
lifetimeAchievementsUnlocked: player.lifetimeAchievementsUnlocked,
lifetimeAdventurersRecruited: player.lifetimeAdventurersRecruited,
lifetimeBossesDefeated: player.lifetimeBossesDefeated,
lifetimeClicks: player.lifetimeClicks,
lifetimeGoldEarned: player.lifetimeGoldEarned,
lifetimeQuestsCompleted: player.lifetimeQuestsCompleted,
totalClicks: player.totalClicks,
totalGoldEarned: player.totalGoldEarned,
username: player.username,
};
const initialState = INITIAL_GAME_STATE(playerShape, playerShape.characterName);
const freshState = initialGameState(
playerShape,
playerShape.characterName,
);
await prisma.gameState.create({
data: {
discordId: player.discordId,
state: initialState as unknown as never,
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never type */
state: freshState as unknown as never,
updatedAt: now,
},
});
const jwtToken = signToken(player.discordId);
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const clientUrl = process.env["CORS_ORIGIN"] ?? "http://localhost:5173";
return context.redirect(`${clientUrl}/auth/callback?token=${jwtToken}&isNew=true`);
const clientUrl = process.env.CORS_ORIGIN ?? "http://localhost:5173";
return context.redirect(
`${clientUrl}/auth/callback?token=${jwtToken}&isNew=true`,
);
}
const updated = await prisma.player.update({
where: { discordId: discordUser.id },
data: {
username: discordUser.username,
avatar: discordUser.avatar,
discriminator: discordUser.discriminator,
avatar: discordUser.avatar,
username: discordUser.username,
},
where: { discordId: discordUser.id },
});
const jwtToken = signToken(updated.discordId);
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const clientUrl = process.env["CORS_ORIGIN"] ?? "http://localhost:5173";
return context.redirect(`${clientUrl}/auth/callback?token=${jwtToken}&isNew=false`);
const clientUrl = process.env.CORS_ORIGIN ?? "http://localhost:5173";
return context.redirect(
`${clientUrl}/auth/callback?token=${jwtToken}&isNew=false`,
);
} catch {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const clientUrl = process.env["CORS_ORIGIN"] ?? "http://localhost:5173";
const clientUrl = process.env.CORS_ORIGIN ?? "http://localhost:5173";
return context.redirect(`${clientUrl}/auth/callback?error=auth_failed`);
}
});
export { authRouter };
+201 -100
View File
@@ -1,14 +1,28 @@
import type { BossChallengeResponse, GameState } from "@elysium/types";
import { computeSetBonuses, getActiveCompanionBonus } from "@elysium/types";
/**
* @file Boss challenge route handling combat mechanics.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Boss handler requires many steps */
/* eslint-disable max-statements -- Boss handler requires many statements */
/* eslint-disable complexity -- Boss handler has inherent complexity */
/* eslint-disable stylistic/max-len -- Long lines in combat logic */
import {
computeSetBonuses,
getActiveCompanionBonus,
type BossChallengeResponse,
type GameState,
} from "@elysium/types";
import { Hono } from "hono";
import type { HonoEnv } from "../types/hono.js";
import { defaultBosses } from "../data/bosses.js";
import { defaultEquipmentSets } from "../data/equipmentSets.js";
import { prisma } from "../db/client.js";
import { DEFAULT_BOSSES } from "../data/bosses.js";
import { DEFAULT_EQUIPMENT_SETS } from "../data/equipmentSets.js";
import { authMiddleware } from "../middleware/auth.js";
import { updateChallengeProgress } from "../services/dailyChallenges.js";
import type { HonoEnvironment } from "../types/hono.js";
export const bossRouter = new Hono<HonoEnv>();
const bossRouter = new Hono<HonoEnvironment>();
bossRouter.use("*", authMiddleware);
@@ -18,68 +32,99 @@ const calculatePartyStats = (
let globalMultiplier = 1;
for (const upgrade of state.upgrades) {
if (upgrade.purchased && upgrade.target === "global") {
globalMultiplier *= upgrade.multiplier;
globalMultiplier = globalMultiplier * upgrade.multiplier;
}
}
// eslint-disable-next-line stylistic/no-mixed-operators -- prestige count * factor is clear
const prestigeMultiplier = 1 + state.prestige.count * 0.1;
// Apply equipped weapon's combat bonus
/* v8 ignore next 3 -- @preserve */
const equipmentCombatMultiplier = (state.equipment ?? [])
.filter((e) => e.equipped && e.bonus.combatMultiplier != null)
.reduce((mult, e) => mult * (e.bonus.combatMultiplier ?? 1), 1);
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 7 -- @preserve */
const equipmentCombatMultiplier = state.equipment.
filter((item) => {
return item.equipped && item.bonus.combatMultiplier !== undefined;
}).
reduce((mult, item) => {
return mult * (item.bonus.combatMultiplier ?? 1);
}, 1);
/* v8 ignore next -- @preserve */
const equippedItemIds = (state.equipment ?? []).filter((e) => e.equipped).map((e) => e.id);
const setCombatMultiplier = computeSetBonuses(equippedItemIds, DEFAULT_EQUIPMENT_SETS).combatMultiplier;
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 7 -- @preserve */
const equippedItemIds = state.equipment.
filter((item) => {
return item.equipped;
}).
map((item) => {
return item.id;
});
const { combatMultiplier: setCombatMultiplier } = computeSetBonuses(
equippedItemIds,
defaultEquipmentSets,
);
let partyDPS = 0;
let partyMaxHp = 0;
for (const adventurer of state.adventurers) {
if (adventurer.count === 0) continue;
if (adventurer.count === 0) {
continue;
}
let adventurerMultiplier = 1;
for (const upgrade of state.upgrades) {
if (
upgrade.purchased &&
upgrade.target === "adventurer" &&
upgrade.adventurerId === adventurer.id
upgrade.purchased
&& upgrade.target === "adventurer"
&& upgrade.adventurerId === adventurer.id
) {
adventurerMultiplier *= upgrade.multiplier;
adventurerMultiplier = adventurerMultiplier * upgrade.multiplier;
}
}
partyDPS +=
adventurer.combatPower *
adventurer.count *
adventurerMultiplier *
globalMultiplier *
prestigeMultiplier;
const adventurerContribution
= adventurer.combatPower
* adventurer.count
* adventurerMultiplier
* globalMultiplier
* prestigeMultiplier;
partyDPS = partyDPS + adventurerContribution;
partyMaxHp += adventurer.level * 50 * adventurer.count;
const adventurerHp = adventurer.level * 50 * adventurer.count;
partyMaxHp = partyMaxHp + adventurerHp;
}
/* v8 ignore next 8 -- @preserve */
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 12 -- @preserve */
const echoCombatMultiplier = state.transcendence?.echoCombatMultiplier ?? 1;
const craftedCombatMultiplier = state.exploration?.craftedCombatMultiplier ?? 1;
const craftedCombatMultiplier
= state.exploration?.craftedCombatMultiplier ?? 1;
const companionBonus = getActiveCompanionBonus(
state.companions?.activeCompanionId,
state.companions?.activeCompanionId ?? null,
state.companions?.unlockedCompanionIds ?? [],
);
const companionCombatMult = companionBonus?.type === "bossDamage" ? 1 + companionBonus.value : 1;
const companionCombatMult
= companionBonus?.type === "bossDamage"
? 1 + companionBonus.value
: 1;
partyDPS *= equipmentCombatMultiplier * setCombatMultiplier * echoCombatMultiplier * craftedCombatMultiplier * companionCombatMult;
partyDPS = partyDPS
* equipmentCombatMultiplier
* setCombatMultiplier
* echoCombatMultiplier
* craftedCombatMultiplier
* companionCombatMult;
return { partyDPS, partyMaxHp };
};
bossRouter.post("/challenge", async (context) => {
const discordId = context.get("discordId") as string;
bossRouter.post("/challenge", async(context) => {
const discordId = context.get("discordId");
const body = await context.req.json<{ bossId: string }>();
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!body.bossId) {
return context.json({ error: "Invalid request body" }, 400);
}
@@ -90,8 +135,12 @@ bossRouter.post("/challenge", async (context) => {
return context.json({ error: "No save found" }, 404);
}
const state = record.state as unknown as GameState;
const boss = state.bosses.find((b) => b.id === body.bossId);
const rawState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState;
const boss = state.bosses.find((b) => {
return b.id === body.bossId;
});
if (!boss) {
return context.json({ error: "Boss not found" }, 404);
@@ -107,7 +156,12 @@ bossRouter.post("/challenge", async (context) => {
const { partyDPS, partyMaxHp } = calculatePartyStats(state);
if (partyDPS === 0 || partyMaxHp === 0 || !isFinite(partyDPS) || !isFinite(partyMaxHp)) {
if (
partyDPS === 0
|| partyMaxHp === 0
|| !Number.isFinite(partyDPS)
|| !Number.isFinite(partyMaxHp)
) {
return context.json(
{ error: "Your party has no adventurers ready to fight" },
400,
@@ -122,47 +176,53 @@ bossRouter.post("/challenge", async (context) => {
const won = timeToKillBoss <= timeToKillParty;
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
let partyHpRemaining: number;
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
let bossHpAtBattleEnd: number;
let bossNewHp: number;
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
let bossUpdatedHp: number;
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss
let rewards: BossChallengeResponse["rewards"];
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss
let casualties: BossChallengeResponse["casualties"];
if (won) {
bossHpAtBattleEnd = 0;
bossNewHp = 0;
partyHpRemaining = Math.max(
0,
partyMaxHp - bossDPS * timeToKillBoss,
);
bossUpdatedHp = 0;
const bossDamageDealt = bossDPS * timeToKillBoss;
partyHpRemaining = Math.max(0, partyMaxHp - bossDamageDealt);
boss.status = "defeated";
boss.currentHp = 0;
state.resources.gold += boss.goldReward;
state.resources.essence += boss.essenceReward;
state.resources.crystals += boss.crystalReward;
state.player.totalGoldEarned += boss.goldReward;
state.resources.gold = state.resources.gold + boss.goldReward;
state.resources.essence = state.resources.essence + boss.essenceReward;
state.resources.crystals = state.resources.crystals + boss.crystalReward;
state.player.totalGoldEarned = state.player.totalGoldEarned + boss.goldReward;
for (const upgradeId of boss.upgradeRewards) {
const upgrade = state.upgrades.find((u) => u.id === upgradeId);
const upgrade = state.upgrades.find((u) => {
return u.id === upgradeId;
});
if (upgrade) {
upgrade.unlocked = true;
}
}
// Grant equipment rewards — auto-equip if the slot is currently empty
/* v8 ignore next -- @preserve */
const equipmentRewards = boss.equipmentRewards ?? [];
for (const equipmentId of equipmentRewards) {
/* v8 ignore next -- @preserve */
const equipment = (state.equipment ?? []).find((e) => e.id === equipmentId);
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 14 -- @preserve */
for (const equipmentId of boss.equipmentRewards) {
const equipment = state.equipment.find((item) => {
return item.id === equipmentId;
});
if (equipment) {
equipment.owned = true;
/* v8 ignore next 3 -- @preserve */
const slotAlreadyEquipped = (state.equipment ?? []).some(
(e) => e.type === equipment.type && e.equipped,
);
const slotAlreadyEquipped = state.equipment.some((item) => {
return item.type === equipment.type && item.equipped;
});
if (!slotAlreadyEquipped) {
equipment.equipped = true;
}
@@ -170,64 +230,94 @@ bossRouter.post("/challenge", async (context) => {
}
// Unlock next boss in the same zone (zone-based sequential progression)
const zoneBosses = state.bosses.filter((b) => b.zoneId === boss.zoneId);
const zoneIndex = zoneBosses.findIndex((b) => b.id === body.bossId);
const nextZoneBoss = zoneBosses[zoneIndex + 1];
if (nextZoneBoss && nextZoneBoss.prestigeRequirement <= state.prestige.count) {
const nextBossInState = state.bosses.find((b) => b.id === nextZoneBoss.id);
if (nextBossInState) nextBossInState.status = "available";
const zoneBosses = state.bosses.filter((b) => {
return b.zoneId === boss.zoneId;
});
const zoneIndex = zoneBosses.findIndex((b) => {
return b.id === body.bossId;
});
const [ nextZoneBoss ] = zoneBosses.slice(zoneIndex + 1);
if (
nextZoneBoss
&& nextZoneBoss.prestigeRequirement <= state.prestige.count
) {
const nextBossInState = state.bosses.find((b) => {
return b.id === nextZoneBoss.id;
});
if (nextBossInState) {
nextBossInState.status = "available";
}
}
// Unlock any zone whose unlock conditions are now both satisfied
// (final boss defeated AND final quest completed)
/*
* Unlock any zone whose unlock conditions are now both satisfied
* (final boss defeated AND final quest completed)
*/
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
for (const zone of (state.zones ?? [])) {
if (zone.status === "unlocked") continue;
if (zone.unlockBossId !== body.bossId) continue;
for (const zone of state.zones) {
if (zone.status === "unlocked") {
continue;
}
if (zone.unlockBossId !== body.bossId) {
continue;
}
// Boss condition just became satisfied — check the quest condition too
const questSatisfied =
zone.unlockQuestId == null ||
state.quests.some((q) => q.id === zone.unlockQuestId && q.status === "completed");
if (!questSatisfied) continue;
const questSatisfied
= zone.unlockQuestId === null
|| state.quests.some((q) => {
return q.id === zone.unlockQuestId && q.status === "completed";
});
if (!questSatisfied) {
continue;
}
zone.status = "unlocked";
const newZoneBosses = state.bosses.filter((b) => b.zoneId === zone.id);
const firstNewBoss = newZoneBosses[0];
if (firstNewBoss && firstNewBoss.prestigeRequirement <= state.prestige.count) {
firstNewBoss.status = "available";
const updatedZoneBosses = state.bosses.filter((b) => {
return b.zoneId === zone.id;
});
const [ firstUpdatedBoss ] = updatedZoneBosses;
if (
firstUpdatedBoss
&& firstUpdatedBoss.prestigeRequirement <= state.prestige.count
) {
firstUpdatedBoss.status = "available";
}
}
// Update daily boss challenge progress
if (state.dailyChallenges) {
const { updatedChallenges, crystalsAwarded } = updateChallengeProgress(
const { crystalsAwarded, updatedChallenges } = updateChallengeProgress(
state.dailyChallenges,
"bossesDefeated",
1,
);
state.dailyChallenges = updatedChallenges;
state.resources.crystals += crystalsAwarded;
state.resources.crystals = state.resources.crystals + crystalsAwarded;
}
// First-kill bounty — look up authoritative bounty from static data
const staticBoss = DEFAULT_BOSSES.find((b) => b.id === body.bossId);
const staticBoss = defaultBosses.find((b) => {
return b.id === body.bossId;
});
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const bountyRunestones = staticBoss?.bountyRunestones ?? 0;
state.prestige.runestones += bountyRunestones;
state.prestige.runestones = state.prestige.runestones + bountyRunestones;
rewards = {
gold: boss.goldReward,
essence: boss.essenceReward,
crystals: boss.crystalReward,
upgradeIds: boss.upgradeRewards,
equipmentIds: equipmentRewards,
bountyRunestones,
bountyRunestones: bountyRunestones,
crystals: boss.crystalReward,
equipmentIds: boss.equipmentRewards,
essence: boss.essenceReward,
gold: boss.goldReward,
upgradeIds: boss.upgradeRewards,
};
} else {
bossHpAtBattleEnd = Math.max(
0,
bossHpBefore - partyDPS * timeToKillParty,
);
bossNewHp = boss.maxHp;
const partyDamageDealt = partyDPS * timeToKillParty;
bossHpAtBattleEnd = Math.max(0, bossHpBefore - partyDamageDealt);
bossUpdatedHp = boss.maxHp;
partyHpRemaining = 0;
boss.status = "available";
@@ -240,34 +330,45 @@ bossRouter.post("/challenge", async (context) => {
casualties = [];
for (const adventurer of state.adventurers) {
if (adventurer.count === 0) continue;
if (adventurer.count === 0) {
continue;
}
const killed = Math.floor(adventurer.count * casualtyFraction);
if (killed > 0) {
adventurer.count = Math.max(1, adventurer.count - killed);
casualties.push({ adventurerId: adventurer.id, killed });
casualties.push({ adventurerId: adventurer.id, killed: killed });
}
}
}
const now = Date.now();
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: now },
where: { discordId },
data: { state: state as object, updatedAt: now },
});
const bossMaxHp = boss.maxHp;
const bossNewHp = bossUpdatedHp;
const response: BossChallengeResponse = {
won,
partyDPS,
bossDPS,
bossHpBefore,
bossMaxHp: boss.maxHp,
bossHpAtBattleEnd,
bossHpBefore,
bossMaxHp,
bossNewHp,
partyMaxHp,
partyDPS,
partyHpRemaining,
partyMaxHp,
won,
};
if (rewards !== undefined) response.rewards = rewards;
if (casualties !== undefined) response.casualties = casualties;
if (rewards !== undefined) {
response.rewards = rewards;
}
if (casualties !== undefined) {
response.casualties = casualties;
}
return context.json(response);
});
export { bossRouter };
+92 -39
View File
@@ -1,46 +1,80 @@
import type { CraftRecipeRequest, CraftRecipeResponse, GameState } from "@elysium/types";
/**
* @file Crafting route handling recipe crafting mechanics.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Route handler requires many steps */
/* eslint-disable max-statements -- Route handler requires many statements */
/* eslint-disable complexity -- Route handler has inherent complexity */
import { Hono } from "hono";
import type { HonoEnv } from "../types/hono.js";
import { defaultRecipes } from "../data/recipes.js";
import { prisma } from "../db/client.js";
import { DEFAULT_RECIPES } from "../data/recipes.js";
import { authMiddleware } from "../middleware/auth.js";
import type { HonoEnvironment } from "../types/hono.js";
import type {
CraftRecipeRequest,
CraftRecipeResponse,
GameState,
} from "@elysium/types";
export const craftRouter = new Hono<HonoEnv>();
const craftRouter = new Hono<HonoEnvironment>();
craftRouter.use("*", authMiddleware);
const recomputeCraftedMultipliers = (
craftedRecipeIds: string[],
craftedRecipeIds: Array<string>,
): {
craftedGoldMultiplier: number;
craftedGoldMultiplier: number;
craftedEssenceMultiplier: number;
craftedClickMultiplier: number;
craftedCombatMultiplier: number;
} => ({
craftedGoldMultiplier: DEFAULT_RECIPES
.filter((r) => craftedRecipeIds.includes(r.id) && r.bonus.type === "gold_income")
.reduce((mult, r) => mult * r.bonus.value, 1),
craftedEssenceMultiplier: DEFAULT_RECIPES
.filter((r) => craftedRecipeIds.includes(r.id) && r.bonus.type === "essence_income")
.reduce((mult, r) => mult * r.bonus.value, 1),
craftedClickMultiplier: DEFAULT_RECIPES
.filter((r) => craftedRecipeIds.includes(r.id) && r.bonus.type === "click_power")
.reduce((mult, r) => mult * r.bonus.value, 1),
craftedCombatMultiplier: DEFAULT_RECIPES
.filter((r) => craftedRecipeIds.includes(r.id) && r.bonus.type === "combat_power")
.reduce((mult, r) => mult * r.bonus.value, 1),
});
craftedClickMultiplier: number;
craftedCombatMultiplier: number;
} => {
return {
craftedClickMultiplier: defaultRecipes.filter((r) => {
return craftedRecipeIds.includes(r.id) && r.bonus.type === "click_power";
}).reduce((mult, r) => {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
return mult * r.bonus.value;
}, 1),
craftedCombatMultiplier: defaultRecipes.filter((r) => {
return craftedRecipeIds.includes(r.id) && r.bonus.type === "combat_power";
}).reduce((mult, r) => {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
return mult * r.bonus.value;
}, 1),
craftedEssenceMultiplier: defaultRecipes.filter((r) => {
return (
craftedRecipeIds.includes(r.id) && r.bonus.type === "essence_income"
);
}).reduce((mult, r) => {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
return mult * r.bonus.value;
}, 1),
craftedGoldMultiplier: defaultRecipes.filter((r) => {
return craftedRecipeIds.includes(r.id) && r.bonus.type === "gold_income";
}).reduce((mult, r) => {
return mult * r.bonus.value;
}, 1),
};
};
craftRouter.post("/", async (context) => {
const discordId = context.get("discordId") as string;
craftRouter.post("/", async(context) => {
const discordId = context.get("discordId");
const body = await context.req.json<CraftRecipeRequest>();
const { recipeId } = body;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!recipeId) {
return context.json({ error: "recipeId is required" }, 400);
}
const recipe = DEFAULT_RECIPES.find((r) => r.id === recipeId);
const recipe = defaultRecipes.find((r) => {
return r.id === recipeId;
});
if (!recipe) {
return context.json({ error: "Unknown recipe" }, 404);
}
@@ -50,7 +84,9 @@ craftRouter.post("/", async (context) => {
return context.json({ error: "No save found" }, 404);
}
const state = record.state as unknown as GameState;
const rawState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState;
if (!state.exploration) {
return context.json({ error: "No exploration state found" }, 400);
@@ -62,11 +98,15 @@ craftRouter.post("/", async (context) => {
// Verify the player has all required materials
for (const requirement of recipe.requiredMaterials) {
const material = state.exploration.materials.find((m) => m.materialId === requirement.materialId);
const material = state.exploration.materials.find((m) => {
return m.materialId === requirement.materialId;
});
const quantity = material?.quantity ?? 0;
if (quantity < requirement.quantity) {
return context.json(
{ error: `Not enough ${requirement.materialId} (need ${String(requirement.quantity)}, have ${String(quantity)})` },
{
error: `Not enough ${requirement.materialId} (need ${String(requirement.quantity)}, have ${String(quantity)})`,
},
400,
);
}
@@ -74,30 +114,43 @@ craftRouter.post("/", async (context) => {
// Deduct materials
for (const requirement of recipe.requiredMaterials) {
const material = state.exploration.materials.find((m) => m.materialId === requirement.materialId);
const material = state.exploration.materials.find((m) => {
return m.materialId === requirement.materialId;
});
if (material) {
material.quantity -= requirement.quantity;
material.quantity = material.quantity - requirement.quantity;
}
}
// Add recipe and recompute all multipliers from scratch
state.exploration.craftedRecipeIds.push(recipeId);
const newMultipliers = recomputeCraftedMultipliers(state.exploration.craftedRecipeIds);
state.exploration.craftedGoldMultiplier = newMultipliers.craftedGoldMultiplier;
state.exploration.craftedEssenceMultiplier = newMultipliers.craftedEssenceMultiplier;
state.exploration.craftedClickMultiplier = newMultipliers.craftedClickMultiplier;
state.exploration.craftedCombatMultiplier = newMultipliers.craftedCombatMultiplier;
const updatedMultipliers = recomputeCraftedMultipliers(
state.exploration.craftedRecipeIds,
);
state.exploration.craftedGoldMultiplier
= updatedMultipliers.craftedGoldMultiplier;
state.exploration.craftedEssenceMultiplier
= updatedMultipliers.craftedEssenceMultiplier;
state.exploration.craftedClickMultiplier
= updatedMultipliers.craftedClickMultiplier;
state.exploration.craftedCombatMultiplier
= updatedMultipliers.craftedCombatMultiplier;
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: Date.now() },
where: { discordId },
data: { state: state as object, updatedAt: Date.now() },
});
const bonusType = recipe.bonus.type;
const bonusValue = recipe.bonus.value;
const response: CraftRecipeResponse = {
bonusType,
bonusValue,
recipeId,
bonusType: recipe.bonus.type,
bonusValue: recipe.bonus.value,
...newMultipliers,
...updatedMultipliers,
};
return context.json(response);
});
export { craftRouter };
+159 -77
View File
@@ -1,3 +1,18 @@
/**
* @file Exploration routes handling area exploration mechanics.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
/* eslint-disable max-statements -- Route handlers require many statements */
/* eslint-disable complexity -- Route handlers have inherent complexity */
import { Hono } from "hono";
import { defaultExplorations } from "../data/explorations.js";
import { initialExploration } from "../data/initialState.js";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
import type { HonoEnvironment } from "../types/hono.js";
import type {
ExploreCollectEventResult,
ExploreCollectRequest,
@@ -6,20 +21,14 @@ import type {
ExploreStartResponse,
GameState,
} from "@elysium/types";
import { Hono } from "hono";
import type { HonoEnv } from "../types/hono.js";
import { prisma } from "../db/client.js";
import { DEFAULT_EXPLORATIONS } from "../data/explorations.js";
import { INITIAL_EXPLORATION } from "../data/initialState.js";
import { authMiddleware } from "../middleware/auth.js";
export const exploreRouter = new Hono<HonoEnv>();
const exploreRouter = new Hono<HonoEnvironment>();
exploreRouter.use("*", authMiddleware);
const NOTHING_PROBABILITY = 0.2;
const nothingProbability = 0.2;
const NOTHING_MESSAGES = [
const nothingMessages = [
"Your scouts searched thoroughly but found nothing of value.",
"The area yielded nothing remarkable this time.",
"Your scouts returned empty-handed.",
@@ -27,20 +36,31 @@ const NOTHING_MESSAGES = [
"Nothing to show for the effort. Perhaps next time.",
];
/* v8 ignore next 2 -- @preserve */
const pickNothingMessage = (): string =>
NOTHING_MESSAGES[Math.floor(Math.random() * NOTHING_MESSAGES.length)] ?? NOTHING_MESSAGES[0]!;
/**
* Returns a random "nothing found" message.
* V8 ignore next 2 -- @preserve.
* @returns A random message string.
*/
const pickNothingMessage = (): string => {
const index = Math.floor(Math.random() * nothingMessages.length);
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
return nothingMessages[index] ?? nothingMessages[0] ?? "";
};
exploreRouter.post("/start", async (context) => {
const discordId = context.get("discordId") as string;
exploreRouter.post("/start", async(context) => {
const discordId = context.get("discordId");
const body = await context.req.json<ExploreStartRequest>();
const { areaId } = body;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!areaId) {
return context.json({ error: "areaId is required" }, 400);
}
const explorationArea = DEFAULT_EXPLORATIONS.find((a) => a.id === areaId);
const explorationArea = defaultExplorations.find((a) => {
return a.id === areaId;
});
if (!explorationArea) {
return context.json({ error: "Unknown exploration area" }, 404);
}
@@ -50,36 +70,55 @@ exploreRouter.post("/start", async (context) => {
return context.json({ error: "No save found" }, 404);
}
const state = record.state as unknown as GameState;
const rawState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState;
// Backfill exploration state for old saves that predate this feature
if (!state.exploration) {
state.exploration = structuredClone(INITIAL_EXPLORATION);
state.exploration = structuredClone(initialExploration);
// Unlock areas for zones already unlocked in this save
for (const area of state.exploration.areas) {
const areaData = DEFAULT_EXPLORATIONS.find((e) => e.id === area.id);
/* v8 ignore next -- @preserve */
if (!areaData) continue;
const zone = state.zones.find((z) => z.id === areaData.zoneId);
const areaData = defaultExplorations.find((areaItem) => {
return areaItem.id === area.id;
});
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
if (!areaData) {
continue;
}
const zone = state.zones.find((z) => {
return z.id === areaData.zoneId;
});
if (zone?.status === "unlocked") {
area.status = "available";
}
}
}
const zone = state.zones.find((z) => z.id === explorationArea.zoneId);
const zone = state.zones.find((z) => {
return z.id === explorationArea.zoneId;
});
if (!zone || zone.status !== "unlocked") {
return context.json({ error: "Zone is not unlocked" }, 400);
}
const area = state.exploration.areas.find((a) => a.id === areaId);
const area = state.exploration.areas.find((a) => {
return a.id === areaId;
});
if (!area) {
return context.json({ error: "Exploration area not found in state" }, 404);
}
const anyInProgress = state.exploration.areas.some((a) => a.status === "in_progress");
const anyInProgress = state.exploration.areas.some((a) => {
return a.status === "in_progress";
});
if (anyInProgress) {
return context.json({ error: "An exploration is already in progress" }, 400);
return context.json(
{ error: "An exploration is already in progress" },
400,
);
}
if (area.status === "locked") {
@@ -91,27 +130,33 @@ exploreRouter.post("/start", async (context) => {
area.startedAt = now;
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: now },
where: { discordId },
data: { state: state as object, updatedAt: now },
});
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
const endsAt = now + explorationArea.durationSeconds * 1000;
const response: ExploreStartResponse = {
areaId,
endsAt: now + explorationArea.durationSeconds * 1000,
endsAt,
};
return context.json(response);
});
exploreRouter.post("/collect", async (context) => {
const discordId = context.get("discordId") as string;
exploreRouter.post("/collect", async(context) => {
const discordId = context.get("discordId");
const body = await context.req.json<ExploreCollectRequest>();
const { areaId } = body;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!areaId) {
return context.json({ error: "areaId is required" }, 400);
}
const explorationArea = DEFAULT_EXPLORATIONS.find((a) => a.id === areaId);
const explorationArea = defaultExplorations.find((a) => {
return a.id === areaId;
});
if (!explorationArea) {
return context.json({ error: "Unknown exploration area" }, 404);
}
@@ -121,13 +166,17 @@ exploreRouter.post("/collect", async (context) => {
return context.json({ error: "No save found" }, 404);
}
const state = record.state as unknown as GameState;
const rawState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState;
if (!state.exploration) {
return context.json({ error: "No exploration state found" }, 400);
}
const area = state.exploration.areas.find((a) => a.id === areaId);
const area = state.exploration.areas.find((a) => {
return a.id === areaId;
});
if (!area) {
return context.json({ error: "Exploration area not found" }, 404);
}
@@ -137,11 +186,14 @@ exploreRouter.post("/collect", async (context) => {
}
const now = Date.now();
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const startedAt = area.startedAt ?? 0;
const durationMs = explorationArea.durationSeconds * 1000;
const expiresAt = startedAt + durationMs;
if (now < startedAt + durationMs) {
if (now < expiresAt) {
return context.json({ error: "Exploration is not yet complete" }, 400);
}
@@ -149,23 +201,30 @@ exploreRouter.post("/collect", async (context) => {
area.completedOnce = true;
// 20% chance of finding nothing
if (Math.random() < NOTHING_PROBABILITY) {
if (Math.random() < nothingProbability) {
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: now },
where: { discordId },
data: { state: state as object, updatedAt: now },
});
const response: ExploreCollectResponse = {
foundNothing: true,
nothingMessage: pickNothingMessage(),
event: null,
foundNothing: true,
materialsFound: [],
event: null,
nothingMessage: pickNothingMessage(),
};
return context.json(response);
}
// Pick a random event
const event = explorationArea.events[Math.floor(Math.random() * explorationArea.events.length)]!;
const eventIndex = Math.floor(Math.random() * explorationArea.events.length);
const event = explorationArea.events[eventIndex];
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
if (!event) {
return context.json({ error: "No events available" }, 500);
}
// Apply event effects and build the result summary
let goldChange = 0;
@@ -173,101 +232,124 @@ exploreRouter.post("/collect", async (context) => {
let materialGained: { materialId: string; quantity: number } | null = null;
if (event.effect.type === "gold_gain") {
// Gold gain — amount may be undefined in edge cases
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const amount = event.effect.amount ?? 0;
state.resources.gold += amount;
state.player.totalGoldEarned += amount;
state.resources.gold = state.resources.gold + amount;
state.player.totalGoldEarned = state.player.totalGoldEarned + amount;
goldChange = amount;
} else if (event.effect.type === "gold_loss") {
// Gold loss — amount may be undefined in edge cases
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const amount = Math.min(state.resources.gold, event.effect.amount ?? 0);
state.resources.gold -= amount;
state.resources.gold = state.resources.gold - amount;
goldChange = -amount;
} else if (event.effect.type === "essence_gain") {
// Essence gain — amount may be undefined in edge cases
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const amount = event.effect.amount ?? 0;
state.resources.essence += amount;
state.resources.essence = state.resources.essence + amount;
essenceChange = amount;
} else if (event.effect.type === "material_gain") {
const materialId = event.effect.materialId;
const { materialId } = event.effect;
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const quantity = event.effect.quantity ?? 1;
if (materialId) {
const existing = state.exploration.materials.find((m) => m.materialId === materialId);
if (materialId !== undefined && materialId !== "") {
const existing = state.exploration.materials.find((m) => {
return m.materialId === materialId;
});
if (existing) {
existing.quantity += quantity;
existing.quantity = existing.quantity + quantity;
} else {
state.exploration.materials.push({ materialId, quantity });
}
materialGained = { materialId, quantity };
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 13 -- @preserve */
}
/* v8 ignore next 12 -- @preserve */
} else if (event.effect.type === "adventurer_loss") {
} else if (event.effect.type === "adventurer_loss") { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- exhausted all other union members above
// Adventurer loss — fraction and loop are defensive
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 8 -- @preserve */
const fraction = event.effect.fraction ?? 0.05;
let totalLost = 0;
for (const adventurer of state.adventurers) {
const lost = Math.floor(adventurer.count * fraction);
if (lost > 0) {
adventurer.count = Math.max(0, adventurer.count - lost);
totalLost += lost;
}
}
// adventurerLostCount captured below
}
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 8 -- @preserve */
const adventurerLostCount =
event.effect.type === "adventurer_loss"
? state.adventurers.reduce((sum, a) => {
const fraction = event.effect.fraction ?? 0.05;
return sum + Math.floor(a.count * fraction);
}, 0)
: 0;
let adventurerLostCount = 0;
if (event.effect.type === "adventurer_loss") {
const fraction = event.effect.fraction ?? 0.05;
for (const adv of state.adventurers) {
const lost = Math.floor(adv.count * fraction);
adventurerLostCount = adventurerLostCount + lost;
}
}
const eventResult: ExploreCollectEventResult = {
text: event.text,
goldChange,
essenceChange,
materialGained,
adventurerLostCount,
adventurerLostCount: adventurerLostCount,
essenceChange: essenceChange,
goldChange: goldChange,
materialGained: materialGained,
text: event.text,
};
// Roll for material drops from possibleMaterials (weighted random selection)
const materialsFound: Array<{ materialId: string; quantity: number }> = [];
if (explorationArea.possibleMaterials.length > 0) {
const totalWeight = explorationArea.possibleMaterials.reduce((sum, m) => sum + m.weight, 0);
let totalWeight = 0;
for (const materialDrop of explorationArea.possibleMaterials) {
totalWeight = totalWeight + materialDrop.weight;
}
let roll = Math.random() * totalWeight;
for (const possible of explorationArea.possibleMaterials) {
roll -= possible.weight;
roll = roll - possible.weight;
if (roll <= 0) {
const quantity =
Math.floor(Math.random() * (possible.maxQuantity - possible.minQuantity + 1)) +
possible.minQuantity;
const maxMinDiff = possible.maxQuantity - possible.minQuantity;
const range = maxMinDiff + 1;
const randomOffset = Math.floor(Math.random() * range);
const quantity = randomOffset + possible.minQuantity;
const { materialId } = possible;
const existing = state.exploration.materials.find((m) => m.materialId === possible.materialId);
const existing = state.exploration.materials.find((m) => {
return m.materialId === materialId;
});
if (existing) {
existing.quantity += quantity;
existing.quantity = existing.quantity + quantity;
} else {
state.exploration.materials.push({ materialId: possible.materialId, quantity });
state.exploration.materials.push({ materialId, quantity });
}
materialsFound.push({ materialId: possible.materialId, quantity });
materialsFound.push({ materialId, quantity });
break;
}
}
}
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: now },
where: { discordId },
data: { state: state as object, updatedAt: now },
});
const response: ExploreCollectResponse = {
foundNothing: false,
materialsFound,
event: eventResult,
event: eventResult,
foundNothing: false,
materialsFound: materialsFound,
};
return context.json(response);
});
export { exploreRouter };
File diff suppressed because it is too large Load Diff
+75 -34
View File
@@ -1,12 +1,20 @@
import type { GameState } from "@elysium/types";
/**
* @file Leaderboard routes for retrieving ranked player statistics.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Route handler requires many steps */
/* eslint-disable complexity -- Leaderboard handler has inherent complexity */
import { Hono } from "hono";
import type { HonoEnv } from "../types/hono.js";
import { gameTitles } from "../data/titles.js";
import { prisma } from "../db/client.js";
import { TITLES } from "../data/titles.js";
import type { HonoEnvironment } from "../types/hono.js";
import type { GameState } from "@elysium/types";
export const leaderboardRouter = new Hono<HonoEnv>();
const leaderboardRouter = new Hono<HonoEnvironment>();
const VALID_CATEGORIES = new Set([
const validCategories = new Set([
"totalGold",
"bossesDefeated",
"questsCompleted",
@@ -16,71 +24,104 @@ const VALID_CATEGORIES = new Set([
"apotheosisCount",
]);
const GAMESTATE_CATEGORIES = new Set(["prestigeCount", "transcendenceCount", "apotheosisCount"]);
const gameStateCategories = new Set([
"prestigeCount",
"transcendenceCount",
"apotheosisCount",
]);
/**
* Parses the showOnLeaderboards flag from a player's profile settings blob.
* @param raw - The raw profile settings value from the database.
* @returns True if the player should appear on leaderboards, false otherwise.
*/
const parseShowOnLeaderboards = (raw: unknown): boolean => {
if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime profile shape */
return (raw as Record<string, unknown>).showOnLeaderboards !== false;
}
return true;
};
leaderboardRouter.get("/", async (context) => {
/**
* Resolves the display title name for a given title ID.
* @param titleId - The player's active title ID.
* @returns The human-readable title name, or empty string if no title.
*/
const resolveTitleName = (titleId: string | null): string => {
if (titleId === null || titleId === "") {
return "";
}
return gameTitles.find((title) => {
return title.id === titleId;
})?.name ?? titleId;
};
leaderboardRouter.get("/", async(context) => {
const category = context.req.query("category") ?? "totalGold";
const limitRaw = Number(context.req.query("limit") ?? "100");
const limit = Math.min(Math.max(1, limitRaw), 100);
if (!VALID_CATEGORIES.has(category)) {
if (!validCategories.has(category)) {
return context.json({ error: "Invalid category" }, 400);
}
const [players, gameStates] = await Promise.all([
const [ players, gameStates ] = await Promise.all([
prisma.player.findMany(),
GAMESTATE_CATEGORIES.has(category) ? prisma.gameState.findMany() : Promise.resolve([]),
gameStateCategories.has(category)
? prisma.gameState.findMany()
: Promise.resolve([]),
]);
const stateMap = new Map(
gameStates.map((gs) => [gs.discordId, gs.state as unknown as GameState]),
gameStates.map((gs) => {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
return [ gs.discordId, gs.state as unknown as GameState ];
}),
);
const entries = players
.filter((p) => parseShowOnLeaderboards(p.profileSettings))
.map((p) => {
const entries = players.
filter((player) => {
return parseShowOnLeaderboards(player.profileSettings);
}).
map((player) => {
let value = 0;
if (category === "totalGold") {
value = p.lifetimeGoldEarned;
value = player.lifetimeGoldEarned;
} else if (category === "bossesDefeated") {
value = p.lifetimeBossesDefeated;
value = player.lifetimeBossesDefeated;
} else if (category === "questsCompleted") {
value = p.lifetimeQuestsCompleted;
value = player.lifetimeQuestsCompleted;
} else if (category === "achievementsUnlocked") {
value = p.lifetimeAchievementsUnlocked;
value = player.lifetimeAchievementsUnlocked;
} else {
const state = stateMap.get(p.discordId);
const state = stateMap.get(player.discordId);
if (category === "prestigeCount") {
value = state?.prestige?.count ?? 0;
value = state?.prestige.count ?? 0;
} else if (category === "transcendenceCount") {
value = state?.transcendence?.count ?? 0;
} else if (category === "apotheosisCount") {
value = state?.apotheosis?.count ?? 0;
}
}
const titleId = p.activeTitle ?? "";
const titleName = titleId
? (TITLES.find((t) => t.id === titleId)?.name ?? titleId)
: "";
return {
discordId: p.discordId,
characterName: p.characterName,
username: p.username,
avatar: p.avatar ?? null,
activeTitle: titleName,
value,
activeTitle: resolveTitleName(player.activeTitle),
avatar: player.avatar ?? null,
characterName: player.characterName,
discordId: player.discordId,
username: player.username,
value: value,
};
})
.sort((a, b) => b.value - a.value)
.slice(0, limit)
.map((entry, index) => ({ ...entry, rank: index + 1 }));
}).
sort((a, b) => {
return b.value - a.value;
}).
slice(0, limit).
map((entry, index) => {
return { ...entry, rank: index + 1 };
});
return context.json({ category, entries });
});
export { leaderboardRouter };
+103 -50
View File
@@ -1,9 +1,15 @@
import type { BuyPrestigeUpgradeRequest, GameState } from "@elysium/types";
/**
* @file Prestige routes handling prestige resets and upgrade purchases.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
/* eslint-disable max-statements -- Route handlers require many statements */
import { Hono } from "hono";
import type { HonoEnv } from "../types/hono.js";
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
import { DEFAULT_PRESTIGE_UPGRADES } from "../data/prestigeUpgrades.js";
import { updateChallengeProgress } from "../services/dailyChallenges.js";
import {
buildPostPrestigeState,
@@ -11,13 +17,15 @@ import {
isEligibleForPrestige,
} from "../services/prestige.js";
import { postMilestoneWebhook } from "../services/webhook.js";
import type { HonoEnvironment } from "../types/hono.js";
import type { BuyPrestigeUpgradeRequest, GameState } from "@elysium/types";
export const prestigeRouter = new Hono<HonoEnv>();
const prestigeRouter = new Hono<HonoEnvironment>();
prestigeRouter.use("*", authMiddleware);
prestigeRouter.post("/", async (context) => {
const discordId = context.get("discordId") as string;
prestigeRouter.post("/", async(context) => {
const discordId = context.get("discordId");
const record = await prisma.gameState.findUnique({ where: { discordId } });
@@ -25,11 +33,14 @@ prestigeRouter.post("/", async (context) => {
return context.json({ error: "No save found" }, 404);
}
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = record.state as unknown as GameState;
if (!isEligibleForPrestige(state)) {
return context.json(
{ error: "Not eligible for prestige — collect 1,000,000 total gold first" },
{
error: "Not eligible for prestige — collect 1,000,000 total gold first",
},
400,
);
}
@@ -38,81 +49,119 @@ prestigeRouter.post("/", async (context) => {
let updatedDailyChallenges = state.dailyChallenges;
let challengeCrystals = 0;
if (updatedDailyChallenges) {
const result = updateChallengeProgress(updatedDailyChallenges, "prestige", 1);
const result = updateChallengeProgress(
updatedDailyChallenges,
"prestige",
1,
);
updatedDailyChallenges = result.updatedChallenges;
challengeCrystals = result.crystalsAwarded;
}
const { newState, newPrestigeData, runestonesEarned, milestoneRunestones } = buildPostPrestigeState(
state,
state.player.characterName,
);
const {
milestoneRunestones,
prestigeData,
prestigeState,
runestonesEarned,
} = buildPostPrestigeState(state, state.player.characterName);
// Preserve daily challenges across the prestige reset and apply any crystal rewards
const finalState: GameState = {
...newState,
...(updatedDailyChallenges !== undefined ? { dailyChallenges: updatedDailyChallenges } : {}),
...prestigeState,
...updatedDailyChallenges === undefined
? {}
: { dailyChallenges: updatedDailyChallenges },
resources: {
...newState.resources,
crystals: newState.resources.crystals + challengeCrystals,
...prestigeState.resources,
crystals: prestigeState.resources.crystals + challengeCrystals,
},
};
// Capture current-run stats to accumulate into lifetime totals before resetting
const runBossesDefeated = state.bosses.filter((b) => b.status === "defeated").length;
const runQuestsCompleted = state.quests.filter((q) => q.status === "completed").length;
const runAdventurersRecruited = state.adventurers.reduce((sum, a) => sum + a.count, 0);
/* v8 ignore next -- @preserve */
const runAchievementsUnlocked = (state.achievements ?? []).filter((a) => a.unlockedAt !== null).length;
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 10 -- @preserve */
const runBossesDefeated = state.bosses.filter((boss) => {
return boss.status === "defeated";
}).length;
const runQuestsCompleted = state.quests.filter((quest) => {
return quest.status === "completed";
}).length;
let runAdventurersRecruited = 0;
for (const adventurer of state.adventurers) {
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
}
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
const runAchievementsUnlocked = state.achievements.filter((achievement) => {
return achievement.unlockedAt !== null;
}).length;
const now = Date.now();
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: finalState as object, updatedAt: now },
where: { discordId },
data: { state: finalState as object, updatedAt: now },
});
await prisma.player.update({
where: { discordId },
data: {
characterName: state.player.characterName,
// Reset current-run counters
totalGoldEarned: 0,
totalClicks: 0,
lastSavedAt: now,
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
lifetimeBossesDefeated: { increment: runBossesDefeated },
lifetimeClicks: { increment: state.player.totalClicks },
// Accumulate into lifetime totals — never reset
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
lifetimeClicks: { increment: state.player.totalClicks },
lifetimeBossesDefeated: { increment: runBossesDefeated },
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
lastSavedAt: now,
totalClicks: 0,
// Reset current-run counters
totalGoldEarned: 0,
},
where: { discordId },
});
void postMilestoneWebhook(discordId, "prestige", {
prestige: newPrestigeData.count,
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
apotheosis: prestigeState.apotheosis?.count ?? 0,
prestige: prestigeData.count,
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 2 -- @preserve */
transcendence: newState.transcendence?.count ?? 0,
apotheosis: newState.apotheosis?.count ?? 0,
transcendence: prestigeState.transcendence?.count ?? 0,
});
return context.json({
runestones: runestonesEarned,
newPrestigeCount: newPrestigeData.count,
milestoneRunestones,
milestoneRunestones: milestoneRunestones,
newPrestigeCount: prestigeData.count, // eslint-disable-line unicorn/no-keyword-prefix -- API response field name required by client
runestones: runestonesEarned,
});
});
prestigeRouter.post("/buy-upgrade", async (context) => {
const discordId = context.get("discordId") as string;
prestigeRouter.post("/buy-upgrade", async(context) => {
const discordId = context.get("discordId");
const body = await context.req.json<BuyPrestigeUpgradeRequest>();
const { upgradeId } = body;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!upgradeId) {
return context.json({ error: "upgradeId is required" }, 400);
}
const upgrade = DEFAULT_PRESTIGE_UPGRADES.find((u) => u.id === upgradeId);
const upgrade = defaultPrestigeUpgrades.find((prestigeUpgrade) => {
return prestigeUpgrade.id === upgradeId;
});
if (!upgrade) {
return context.json({ error: "Unknown prestige upgrade" }, 404);
}
@@ -122,6 +171,7 @@ prestigeRouter.post("/buy-upgrade", async (context) => {
return context.json({ error: "No save found" }, 404);
}
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = record.state as unknown as GameState;
const { purchasedUpgradeIds, runestones } = state.prestige;
@@ -133,29 +183,32 @@ prestigeRouter.post("/buy-upgrade", async (context) => {
return context.json({ error: "Not enough runestones" }, 400);
}
const newRunestones = runestones - upgrade.runestonesCost;
const newPurchasedUpgradeIds = [...purchasedUpgradeIds, upgradeId];
const updatedRunestones = runestones - upgrade.runestonesCost;
const updatedPurchasedUpgradeIds = [ ...purchasedUpgradeIds, upgradeId ];
const newState: GameState = {
const updatedState: GameState = {
...state,
prestige: {
...state.prestige,
runestones: newRunestones,
purchasedUpgradeIds: newPurchasedUpgradeIds,
...computeRunestoneMultipliers(newPurchasedUpgradeIds),
purchasedUpgradeIds: updatedPurchasedUpgradeIds,
runestones: updatedRunestones,
...computeRunestoneMultipliers(updatedPurchasedUpgradeIds),
},
};
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: updatedState as object, updatedAt: Date.now() },
where: { discordId },
data: { state: newState as object, updatedAt: Date.now() },
});
const multipliers = computeRunestoneMultipliers(newPurchasedUpgradeIds);
const multipliers = computeRunestoneMultipliers(updatedPurchasedUpgradeIds);
return context.json({
runestonesRemaining: newRunestones,
purchasedUpgradeIds: newPurchasedUpgradeIds,
purchasedUpgradeIds: updatedPurchasedUpgradeIds,
runestonesRemaining: updatedRunestones,
...multipliers,
});
});
export { prestigeRouter };
+196 -119
View File
@@ -1,54 +1,86 @@
import type {
GameState,
ProfileSettings,
UpdateProfileRequest,
/**
* @file Profile routes handling player profile retrieval and updates.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
/* eslint-disable complexity -- Route handlers have inherent complexity */
/* eslint-disable stylistic/key-spacing -- ProfileSettings keys exceed max-len when aligned */
/* eslint-disable stylistic/max-len -- ProfileSettings key names exceed line length limit */
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- Defensive checks for runtime nullable fields */
import {
DEFAULT_PROFILE_SETTINGS,
type GameState,
type ProfileSettings,
type UpdateProfileRequest,
} from "@elysium/types";
import { DEFAULT_PROFILE_SETTINGS } from "@elysium/types";
import { Hono } from "hono";
import type { HonoEnv } from "../types/hono.js";
import { gameTitles } from "../data/titles.js";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
import { TITLES } from "../data/titles.js";
import { parseUnlockedTitles } from "../services/titles.js";
import type { HonoEnvironment } from "../types/hono.js";
export const profileRouter = new Hono<HonoEnv>();
const profileRouter = new Hono<HonoEnvironment>();
const VALID_NUMBER_FORMATS = new Set(["suffix", "scientific", "engineering"]);
const validNumberFormats = new Set([ "suffix", "scientific", "engineering" ]);
/**
* Parses a raw profile settings blob from the database into a typed ProfileSettings object.
* @param raw - The raw value from the database.
* @returns A valid ProfileSettings object with defaults for missing fields.
*/
const parseProfileSettings = (raw: unknown): ProfileSettings => {
if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
const obj = raw as Record<string, unknown>;
const numberFormat = VALID_NUMBER_FORMATS.has(obj.numberFormat as string)
? (obj.numberFormat as ProfileSettings["numberFormat"])
: "suffix";
return {
showTotalGold: obj.showTotalGold !== false,
showTotalClicks: obj.showTotalClicks !== false,
showLifetimeBossesDefeated: obj.showLifetimeBossesDefeated !== false,
showLifetimeQuestsCompleted: obj.showLifetimeQuestsCompleted !== false,
showLifetimeAdventurersRecruited: obj.showLifetimeAdventurersRecruited !== false,
showLifetimeAchievementsUnlocked: obj.showLifetimeAchievementsUnlocked !== false,
showGuildFounded: obj.showGuildFounded !== false,
showCurrentGold: obj.showCurrentGold !== false,
showCurrentClicks: obj.showCurrentClicks !== false,
showPrestige: obj.showPrestige !== false,
showTranscendence: obj.showTranscendence !== false,
showApotheosis: obj.showApotheosis !== false,
showBossesDefeated: obj.showBossesDefeated !== false,
showQuestsCompleted: obj.showQuestsCompleted !== false,
showAdventurersRecruited: obj.showAdventurersRecruited !== false,
showAchievementsUnlocked: obj.showAchievementsUnlocked !== false,
numberFormat,
showOnLeaderboards: obj.showOnLeaderboards !== false,
};
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return { ...DEFAULT_PROFILE_SETTINGS };
}
return { ...DEFAULT_PROFILE_SETTINGS };
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
const rawObject = raw as Record<string, unknown>;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
const parsedNumberFormat = rawObject.numberFormat as string;
const numberFormat = validNumberFormats.has(parsedNumberFormat)
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
? (parsedNumberFormat as ProfileSettings["numberFormat"])
: "suffix";
return {
numberFormat: numberFormat,
showAchievementsUnlocked: rawObject.showAchievementsUnlocked !== false,
showAdventurersRecruited: rawObject.showAdventurersRecruited !== false,
showApotheosis: rawObject.showApotheosis !== false,
showBossesDefeated: rawObject.showBossesDefeated !== false,
showCurrentClicks: rawObject.showCurrentClicks !== false,
showCurrentGold: rawObject.showCurrentGold !== false,
showGuildFounded: rawObject.showGuildFounded !== false,
showLifetimeAchievementsUnlocked: rawObject.showLifetimeAchievementsUnlocked !== false,
showLifetimeAdventurersRecruited: rawObject.showLifetimeAdventurersRecruited !== false,
showLifetimeBossesDefeated: rawObject.showLifetimeBossesDefeated !== false,
showLifetimeQuestsCompleted: rawObject.showLifetimeQuestsCompleted !== false,
showOnLeaderboards: rawObject.showOnLeaderboards !== false,
showPrestige: rawObject.showPrestige !== false,
showQuestsCompleted: rawObject.showQuestsCompleted !== false,
showTotalClicks: rawObject.showTotalClicks !== false,
showTotalGold: rawObject.showTotalGold !== false,
showTranscendence: rawObject.showTranscendence !== false,
};
};
profileRouter.get("/:discordId", async (context) => {
/**
* Resolves a title ID to its display name.
* @param id - The title ID to resolve.
* @returns An object with id and name fields.
*/
const resolveTitle = (id: string): { id: string; name: string } => {
const title = gameTitles.find((gameTitle) => {
return gameTitle.id === id;
});
return { id: id, name: title?.name ?? id };
};
profileRouter.get("/:discordId", async(context) => {
const { discordId } = context.req.param();
const [player, gameStateRecord] = await Promise.all([
const [ player, gameStateRecord ] = await Promise.all([
prisma.player.findUnique({ where: { discordId } }),
prisma.gameState.findUnique({ where: { discordId } }),
]);
@@ -57,129 +89,174 @@ profileRouter.get("/:discordId", async (context) => {
return context.json({ error: "Player not found" }, 404);
}
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = gameStateRecord?.state as unknown as GameState | undefined;
const prestigeCount = state?.prestige.count ?? 0;
const transcendenceCount = state?.transcendence?.count ?? 0;
const apotheosisCount = state?.apotheosis?.count ?? 0;
const profileSettings = parseProfileSettings(player.profileSettings);
const bossesDefeated = state?.bosses.filter((b) => b.status === "defeated").length ?? 0;
const questsCompleted = state?.quests.filter((q) => q.status === "completed").length ?? 0;
const adventurersRecruited =
state?.adventurers.reduce((sum, a) => sum + a.count, 0) ?? 0;
const achievementsUnlocked =
(state?.achievements ?? []).filter((a) => a.unlockedAt !== null).length;
const bossesDefeated
= state?.bosses.filter((boss) => {
return boss.status === "defeated";
}).length ?? 0;
const questsCompleted
= state?.quests.filter((quest) => {
return quest.status === "completed";
}).length ?? 0;
let adventurersRecruited = 0;
if (state) {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
for (const adventurer of state.adventurers) {
adventurersRecruited = adventurersRecruited + adventurer.count;
}
}
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
const achievementsUnlocked = (state?.achievements ?? []).filter((achievement) => {
return achievement.unlockedAt !== null;
}).length;
const unlockedTitleIds = parseUnlockedTitles(player.unlockedTitles);
const unlockedTitles = unlockedTitleIds.map((id) => {
const title = TITLES.find((t) => t.id === id);
return { id, name: title?.name ?? id };
return resolveTitle(id);
});
const equippedItems = (state?.equipment ?? [])
.filter((e) => e.owned && e.equipped)
.map(({ name, type, rarity, bonus }) => ({ name, type, rarity, bonus }));
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 12 -- @preserve */
const equippedItems = (state?.equipment ?? []).
filter((item) => {
return item.owned && item.equipped;
}).
map((item) => {
return {
bonus: item.bonus,
name: item.name,
rarity: item.rarity,
type: item.type,
};
});
return context.json({
characterName: player.characterName,
pronouns: player.pronouns ?? "",
characterRace: player.characterRace ?? "",
characterClass: player.characterClass ?? "",
username: player.username,
avatar: player.avatar ?? null,
achievementsUnlocked: achievementsUnlocked,
activeTitle: player.activeTitle,
adventurersRecruited: adventurersRecruited,
apotheosisCount: apotheosisCount,
avatar: player.avatar,
bio: player.bio ?? "",
guildName: player.guildName ?? "",
guildDescription: player.guildDescription ?? "",
profileSettings,
bossesDefeated: bossesDefeated,
characterClass: player.characterClass,
characterName: player.characterName,
characterRace: player.characterRace ?? "",
createdAt: player.createdAt,
// All Time stats — cumulative across all runs, never reset
totalGoldEarned: player.lifetimeGoldEarned,
totalClicks: player.lifetimeClicks,
currentRunClicks: state?.player.totalClicks ?? 0,
currentRunGold: state?.player.totalGoldEarned ?? 0,
equippedItems: equippedItems,
guildDescription: player.guildDescription,
guildName: player.guildName,
lifetimeAchievementsUnlocked: player.lifetimeAchievementsUnlocked,
lifetimeAdventurersRecruited: player.lifetimeAdventurersRecruited,
lifetimeBossesDefeated: player.lifetimeBossesDefeated,
lifetimeQuestsCompleted: player.lifetimeQuestsCompleted,
lifetimeAdventurersRecruited: player.lifetimeAdventurersRecruited,
lifetimeAchievementsUnlocked: player.lifetimeAchievementsUnlocked,
// Current Run stats — from live GameState, reset on prestige & transcendence
currentRunGold: state?.player.totalGoldEarned ?? 0,
currentRunClicks: state?.player.totalClicks ?? 0,
prestigeCount,
transcendenceCount,
apotheosisCount,
bossesDefeated,
questsCompleted,
adventurersRecruited,
achievementsUnlocked,
unlockedTitles,
activeTitle: player.activeTitle ?? "",
equippedItems,
prestigeCount: prestigeCount,
profileSettings: profileSettings,
pronouns: player.pronouns ?? "",
questsCompleted: questsCompleted,
totalClicks: player.lifetimeClicks,
totalGoldEarned: player.lifetimeGoldEarned,
transcendenceCount: transcendenceCount,
unlockedTitles: unlockedTitles,
username: player.username,
});
});
profileRouter.put("/", authMiddleware, async (context) => {
const discordId = context.get("discordId") as string;
profileRouter.put("/", authMiddleware, async(context) => {
const discordId = context.get("discordId");
const body = await context.req.json<UpdateProfileRequest>();
const characterName = (body.characterName ?? "").trim().slice(0, 32);
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!body.characterName) {
return context.json({ error: "Character name cannot be empty" }, 400);
}
const characterName = body.characterName.trim().slice(0, 32);
if (characterName === "") {
return context.json({ error: "Character name cannot be empty" }, 400);
}
const pronouns = (body.pronouns ?? "").trim().slice(0, 20);
const characterRace = (body.characterRace ?? "").trim().slice(0, 32);
const characterClass = (body.characterClass ?? "").trim().slice(0, 32);
const bio = (body.bio ?? "").trim().slice(0, 200);
const guildName = (body.guildName ?? "").trim().slice(0, 64);
const guildDescription = (body.guildDescription ?? "").trim().slice(0, 500);
const numberFormat = VALID_NUMBER_FORMATS.has(body.profileSettings?.numberFormat as string)
? (body.profileSettings?.numberFormat as ProfileSettings["numberFormat"])
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 2 -- @preserve */
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
const parsedNumberFormat = (body.profileSettings.numberFormat ?? "") as string;
const numberFormat = validNumberFormats.has(parsedNumberFormat)
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
? (parsedNumberFormat as ProfileSettings["numberFormat"])
: "suffix";
const profileSettings: ProfileSettings = {
showTotalGold: body.profileSettings?.showTotalGold !== false,
showTotalClicks: body.profileSettings?.showTotalClicks !== false,
showLifetimeBossesDefeated: body.profileSettings?.showLifetimeBossesDefeated !== false,
showLifetimeQuestsCompleted: body.profileSettings?.showLifetimeQuestsCompleted !== false,
showLifetimeAdventurersRecruited: body.profileSettings?.showLifetimeAdventurersRecruited !== false,
showLifetimeAchievementsUnlocked: body.profileSettings?.showLifetimeAchievementsUnlocked !== false,
showGuildFounded: body.profileSettings?.showGuildFounded !== false,
showCurrentGold: body.profileSettings?.showCurrentGold !== false,
showCurrentClicks: body.profileSettings?.showCurrentClicks !== false,
showPrestige: body.profileSettings?.showPrestige !== false,
showTranscendence: body.profileSettings?.showTranscendence !== false,
showApotheosis: body.profileSettings?.showApotheosis !== false,
showBossesDefeated: body.profileSettings?.showBossesDefeated !== false,
showQuestsCompleted: body.profileSettings?.showQuestsCompleted !== false,
showAdventurersRecruited: body.profileSettings?.showAdventurersRecruited !== false,
showAchievementsUnlocked: body.profileSettings?.showAchievementsUnlocked !== false,
numberFormat,
showOnLeaderboards: body.profileSettings?.showOnLeaderboards !== false,
numberFormat: numberFormat,
showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true,
showAdventurersRecruited: body.profileSettings.showAdventurersRecruited ?? true,
showApotheosis: body.profileSettings.showApotheosis ?? true,
showBossesDefeated: body.profileSettings.showBossesDefeated ?? true,
showCurrentClicks: body.profileSettings.showCurrentClicks ?? true,
showCurrentGold: body.profileSettings.showCurrentGold ?? true,
showGuildFounded: body.profileSettings.showGuildFounded ?? true,
showLifetimeAchievementsUnlocked: body.profileSettings.showLifetimeAchievementsUnlocked ?? true,
showLifetimeAdventurersRecruited: body.profileSettings.showLifetimeAdventurersRecruited ?? true,
showLifetimeBossesDefeated: body.profileSettings.showLifetimeBossesDefeated ?? true,
showLifetimeQuestsCompleted: body.profileSettings.showLifetimeQuestsCompleted ?? true,
showOnLeaderboards: body.profileSettings.showOnLeaderboards ?? true,
showPrestige: body.profileSettings.showPrestige ?? true,
showQuestsCompleted: body.profileSettings.showQuestsCompleted ?? true,
showTotalClicks: body.profileSettings.showTotalClicks ?? true,
showTotalGold: body.profileSettings.showTotalGold ?? true,
showTranscendence: body.profileSettings.showTranscendence ?? true,
};
if (!characterName) {
return context.json({ error: "Character name cannot be empty" }, 400);
}
const activeTitle = typeof body.activeTitle === "string" ? body.activeTitle.slice(0, 64) : undefined;
const activeTitle
= typeof body.activeTitle === "string"
? body.activeTitle.slice(0, 64)
: undefined;
const updated = await prisma.player.update({
where: { discordId },
data: {
characterName,
pronouns,
characterRace,
characterClass,
bio,
guildName,
guildDescription,
bio: bio,
characterClass: characterClass,
characterName: characterName,
characterRace: characterRace,
guildDescription: guildDescription,
guildName: guildName,
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
profileSettings: profileSettings as object,
...(activeTitle !== undefined ? { activeTitle } : {}),
pronouns: pronouns,
...activeTitle === undefined
? {}
: { activeTitle },
},
where: { discordId },
});
return context.json({
characterName: updated.characterName,
pronouns: updated.pronouns,
characterRace: updated.characterRace,
characterClass: updated.characterClass,
activeTitle: updated.activeTitle,
bio: updated.bio,
guildName: updated.guildName,
characterClass: updated.characterClass,
characterName: updated.characterName,
characterRace: updated.characterRace,
guildDescription: updated.guildDescription,
profileSettings,
activeTitle: updated.activeTitle ?? "",
guildName: updated.guildName,
profileSettings: profileSettings,
pronouns: updated.pronouns,
});
});
export { profileRouter };
+93 -45
View File
@@ -1,96 +1,139 @@
import type { BuyEchoUpgradeRequest, GameState } from "@elysium/types";
/**
* @file Transcendence routes handling transcendence resets and echo upgrade purchases.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
/* eslint-disable max-statements -- Route handlers require many statements */
import { Hono } from "hono";
import type { HonoEnv } from "../types/hono.js";
import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
import { DEFAULT_TRANSCENDENCE_UPGRADES } from "../data/transcendenceUpgrades.js";
import {
buildPostTranscendenceState,
computeTranscendenceMultipliers,
isEligibleForTranscendence,
} from "../services/transcendence.js";
import { postMilestoneWebhook } from "../services/webhook.js";
import type { HonoEnvironment } from "../types/hono.js";
import type { BuyEchoUpgradeRequest, GameState } from "@elysium/types";
export const transcendenceRouter = new Hono<HonoEnv>();
const transcendenceRouter = new Hono<HonoEnvironment>();
transcendenceRouter.use("*", authMiddleware);
transcendenceRouter.post("/", async (context) => {
const discordId = context.get("discordId") as string;
transcendenceRouter.post("/", async(context) => {
const discordId = context.get("discordId");
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = record.state as unknown as GameState;
if (!isEligibleForTranscendence(state)) {
return context.json(
{ error: "Not eligible for transcendence — defeat The Absolute One first" },
{
error: "Not eligible for transcendence — defeat The Absolute One first",
},
400,
);
}
const { newState, newTranscendenceData, echoesEarned } = buildPostTranscendenceState(
state,
state.player.characterName,
);
const {
echoesEarned,
transcendenceData,
transcendenceState,
} = buildPostTranscendenceState(state, state.player.characterName);
// Capture current-run stats before the nuclear reset
const runBossesDefeated = state.bosses.filter((b) => b.status === "defeated").length;
const runQuestsCompleted = state.quests.filter((q) => q.status === "completed").length;
const runAdventurersRecruited = state.adventurers.reduce((sum, a) => sum + a.count, 0);
/* v8 ignore next -- @preserve */
const runAchievementsUnlocked = (state.achievements ?? []).filter((a) => a.unlockedAt !== null).length;
const runBossesDefeated = state.bosses.filter((boss) => {
return boss.status === "defeated";
}).length;
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 7 -- @preserve */
const runQuestsCompleted = state.quests.filter((quest) => {
return quest.status === "completed";
}).length;
let runAdventurersRecruited = 0;
for (const adventurer of state.adventurers) {
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
}
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
const runAchievementsUnlocked = state.achievements.filter((achievement) => {
return achievement.unlockedAt !== null;
}).length;
const now = Date.now();
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: transcendenceState as object, updatedAt: now },
where: { discordId },
data: { state: newState as object, updatedAt: now },
});
await prisma.player.update({
where: { discordId },
data: {
characterName: state.player.characterName,
// Reset current-run counters (same as prestige)
totalGoldEarned: 0,
totalClicks: 0,
lastSavedAt: now,
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
lifetimeBossesDefeated: { increment: runBossesDefeated },
lifetimeClicks: { increment: state.player.totalClicks },
// Accumulate into lifetime totals
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
lifetimeClicks: { increment: state.player.totalClicks },
lifetimeBossesDefeated: { increment: runBossesDefeated },
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
lastSavedAt: now,
totalClicks: 0,
// Reset current-run counters (same as prestige)
totalGoldEarned: 0,
},
where: { discordId },
});
void postMilestoneWebhook(discordId, "transcendence", {
prestige: newState.prestige.count,
transcendence: newTranscendenceData.count,
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
apotheosis: newState.apotheosis?.count ?? 0,
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
apotheosis: transcendenceState.apotheosis?.count ?? 0,
prestige: transcendenceState.prestige.count,
transcendence: transcendenceData.count,
});
return context.json({
echoes: echoesEarned,
newTranscendenceCount: newTranscendenceData.count,
echoes: echoesEarned,
// eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client
newTranscendenceCount: transcendenceData.count,
});
});
transcendenceRouter.post("/buy-upgrade", async (context) => {
const discordId = context.get("discordId") as string;
transcendenceRouter.post("/buy-upgrade", async(context) => {
const discordId = context.get("discordId");
const body = await context.req.json<BuyEchoUpgradeRequest>();
const { upgradeId } = body;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!upgradeId) {
return context.json({ error: "upgradeId is required" }, 400);
}
const upgrade = DEFAULT_TRANSCENDENCE_UPGRADES.find((u) => u.id === upgradeId);
const upgrade = defaultTranscendenceUpgrades.find((transcendenceUpgrade) => {
return transcendenceUpgrade.id === upgradeId;
});
if (!upgrade) {
return context.json({ error: "Unknown echo upgrade" }, 404);
}
@@ -100,6 +143,7 @@ transcendenceRouter.post("/buy-upgrade", async (context) => {
return context.json({ error: "No save found" }, 404);
}
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = record.state as unknown as GameState;
if (!state.transcendence) {
@@ -116,28 +160,32 @@ transcendenceRouter.post("/buy-upgrade", async (context) => {
return context.json({ error: "Not enough echoes" }, 400);
}
const newEchoes = echoes - upgrade.cost;
const newPurchasedIds = [...purchasedUpgradeIds, upgradeId];
const newMultipliers = computeTranscendenceMultipliers(newPurchasedIds);
const updatedEchoes = echoes - upgrade.cost;
const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ];
const updatedMultipliers
= computeTranscendenceMultipliers(updatedPurchasedIds);
const newState: GameState = {
const updatedState: GameState = {
...state,
transcendence: {
...state.transcendence,
echoes: newEchoes,
purchasedUpgradeIds: newPurchasedIds,
...newMultipliers,
echoes: updatedEchoes,
purchasedUpgradeIds: updatedPurchasedIds,
...updatedMultipliers,
},
};
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: updatedState as object, updatedAt: Date.now() },
where: { discordId },
data: { state: newState as object, updatedAt: Date.now() },
});
return context.json({
echoesRemaining: newEchoes,
purchasedUpgradeIds: newPurchasedIds,
...newMultipliers,
echoesRemaining: updatedEchoes,
purchasedUpgradeIds: updatedPurchasedIds,
...updatedMultipliers,
});
});
export { transcendenceRouter };
+43 -18
View File
@@ -1,43 +1,68 @@
/**
* @file Apotheosis service handling eligibility checks and state building.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { initialGameState } from "../data/initialState.js";
import {
defaultTranscendenceUpgrades,
} from "../data/transcendenceUpgrades.js";
import type { ApotheosisData, GameState } from "@elysium/types";
import { INITIAL_GAME_STATE } from "../data/initialState.js";
import { DEFAULT_TRANSCENDENCE_UPGRADES } from "../data/transcendenceUpgrades.js";
/** Total number of echo upgrades — all must be purchased to unlock Apotheosis */
const TOTAL_ECHO_UPGRADES = DEFAULT_TRANSCENDENCE_UPGRADES.length;
/**
* Total number of echo upgrades — all must be purchased to unlock Apotheosis.
*/
const totalEchoUpgrades = defaultTranscendenceUpgrades.length;
/**
* Returns true when the player is eligible to achieve Apotheosis:
* all Transcendence echo upgrades must be purchased.
* @param state - The current game state.
* @returns Whether the player is eligible for Apotheosis.
*/
export const isEligibleForApotheosis = (state: GameState): boolean => {
const isEligibleForApotheosis = (state: GameState): boolean => {
const purchasedIds = state.transcendence?.purchasedUpgradeIds ?? [];
return purchasedIds.length >= TOTAL_ECHO_UPGRADES &&
DEFAULT_TRANSCENDENCE_UPGRADES.every((u) => purchasedIds.includes(u.id));
return (
purchasedIds.length >= totalEchoUpgrades
&& defaultTranscendenceUpgrades.every((u) => {
return purchasedIds.includes(u.id);
})
);
};
/**
* Builds the new game state after Apotheosis — the ultimate nuclear reset.
* Builds the updated game state after Apotheosis — the ultimate nuclear reset.
* Wipes absolutely everything including prestige and transcendence.
* Only codex lore entries and the apotheosis count itself are preserved.
* @param currentState - The current game state before apotheosis.
* @param characterName - The character name to carry over.
* @returns The updated game state and apotheosis data.
*/
export const buildPostApotheosisState = (
const buildPostApotheosisState = (
currentState: GameState,
characterName: string,
): { newState: GameState; newApotheosisData: ApotheosisData } => {
const newCount = (currentState.apotheosis?.count ?? 0) + 1;
const newApotheosisData: ApotheosisData = { count: newCount };
): { updatedApotheosisData: ApotheosisData; updatedState: GameState } => {
const apotheosisCount = (currentState.apotheosis?.count ?? 0) + 1;
const updatedApotheosisData: ApotheosisData = { count: apotheosisCount };
const freshState = INITIAL_GAME_STATE(currentState.player, characterName);
const newState: GameState = {
const freshState = initialGameState(currentState.player, characterName);
const updatedState: GameState = {
...freshState,
lastTickAt: Date.now(),
// Codex lore persists through all resets — players keep their discovered entries
...(currentState.codex ? { codex: currentState.codex } : {}),
...currentState.codex
? { codex: currentState.codex }
: {},
// Apotheosis data is eternal — never wiped by any reset
apotheosis: newApotheosisData,
apotheosis: updatedApotheosisData,
// Story chapter progress is permanent — survives all resets
...(currentState.story ? { story: currentState.story } : {}),
...currentState.story
? { story: currentState.story }
: {},
};
return { newState, newApotheosisData };
return { updatedApotheosisData, updatedState };
};
export { buildPostApotheosisState, isEligibleForApotheosis };
+114 -37
View File
@@ -1,36 +1,77 @@
/**
* @file Daily challenge generation and progress tracking utilities.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { dailyChallengeTemplates } from "../data/dailyChallenges.js";
import type {
DailyChallenge,
DailyChallengeState,
DailyChallengeType,
GameState,
} from "@elysium/types";
import { DAILY_CHALLENGE_TEMPLATES } from "../data/dailyChallenges.js";
// Use the server's PST/PDT timezone so challenges roll over at PST midnight
const getTodayString = (): string =>
new Intl.DateTimeFormat("en-CA", { timeZone: "America/Los_Angeles" }).format(new Date());
/**
* Returns today's date string in PST/PDT so challenges roll over at midnight Pacific.
* @returns A date string in YYYY-MM-DD format.
*/
const getTodayString = (): string => {
return new Intl.DateTimeFormat("en-CA", {
timeZone: "America/Los_Angeles",
}).format(new Date());
};
/** Simple deterministic pseudo-random based on a numeric seed. */
/**
* Simple deterministic pseudo-random number based on a numeric seed.
* @param seed - The numeric seed value.
* @returns A pseudo-random float in [0, 1).
*/
const seededRandom = (seed: number): number => {
const x = Math.sin(seed + 1) * 10_000;
return x - Math.floor(x);
};
/** Converts a date string into a stable numeric seed. */
const dateSeed = (dateStr: string): number =>
dateStr.split("").reduce((acc, char, i) => acc + char.charCodeAt(0) * (i + 1), 0);
/**
* Converts a date string into a stable numeric seed.
* @param dateString - A date string such as "2025-01-01".
* @returns A numeric seed derived from the date characters.
*/
const dateSeed = (dateString: string): number => {
let accumulator = 0;
let index = 0;
for (const char of dateString) {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const charValue = char.codePointAt(0) ?? 0;
const contribution = charValue * (index + 1);
accumulator = accumulator + contribution;
index = index + 1;
}
return accumulator;
};
/** Deterministically shuffles an array using a numeric seed. */
const shuffleWithSeed = <T>(arr: T[], seed: number): T[] => {
const result = [...arr];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(seededRandom(seed + i) * (i + 1));
[result[i], result[j]] = [result[j]!, result[i]!];
/**
* Deterministically shuffles an array using a numeric seed (Fisher-Yates).
* @param array - The array to shuffle.
* @param seed - The seed controlling shuffle order.
* @returns A new shuffled array.
*/
const shuffleWithSeed = <T>(array: Array<T>, seed: number): Array<T> => {
const result = [ ...array ];
for (let index = result.length - 1; index > 0; index = index - 1) {
const swapIndex = Math.floor(seededRandom(seed + index) * (index + 1));
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- index and swapIndex are always in bounds */
const fromSwap = result[swapIndex]!;
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- index and swapIndex are always in bounds */
const fromIndex = result[index]!;
result[index] = fromSwap;
result[swapIndex] = fromIndex;
}
return result;
};
const CHALLENGE_TYPES: DailyChallengeType[] = [
const challengeTypes: Array<DailyChallengeType> = [
"clicks",
"bossesDefeated",
"questsCompleted",
@@ -40,45 +81,64 @@ const CHALLENGE_TYPES: DailyChallengeType[] = [
/**
* Generates 3 daily challenges for the given date string, deterministically.
* Picks one challenge from 3 different randomly-selected types.
* @param dateString - The date string (YYYY-MM-DD) to generate challenges for.
* @returns An array of 3 DailyChallenge objects.
*/
export const generateDailyChallenges = (dateStr: string): DailyChallenge[] => {
const seed = dateSeed(dateStr);
const selectedTypes = shuffleWithSeed([...CHALLENGE_TYPES], seed).slice(0, 3);
const generateDailyChallenges = (
dateString: string,
): Array<DailyChallenge> => {
const seed = dateSeed(dateString);
const selectedTypes = shuffleWithSeed([ ...challengeTypes ], seed).
slice(0, 3);
return selectedTypes.map((type, index) => {
const templates = DAILY_CHALLENGE_TEMPLATES.filter((t) => t.type === type);
const templateIndex = Math.floor(seededRandom(seed + index * 100) * templates.length);
const templates = dailyChallengeTemplates.filter((template) => {
return template.type === type;
});
const indexOffset = index * 100;
const templateIndex = Math.floor(
seededRandom(seed + indexOffset) * templates.length,
);
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- templateIndex is always valid: seededRandom returns [0,1) so floor * length is always in bounds */
const template = templates[templateIndex]!;
return {
id: `${dateStr}_${type}`,
type: template.type,
label: template.label,
target: template.target,
progress: 0,
completed: false,
completed: false,
id: `${dateString}_${type}`,
label: template.label,
progress: 0,
rewardCrystals: template.rewardCrystals,
target: template.target,
type: template.type,
};
});
};
/**
* Returns the current daily challenge state, generating fresh challenges if
* the stored date doesn't match today (i.e. a new day has begun).
* Returns the current daily challenge state, generating fresh challenges when
* the stored date does not match today.
* @param state - The current game state.
* @returns The current or freshly-generated DailyChallengeState.
*/
export const getOrResetDailyChallenges = (state: GameState): DailyChallengeState => {
const getOrResetDailyChallenges = (
state: GameState,
): DailyChallengeState => {
const today = getTodayString();
if (state.dailyChallenges?.date === today) {
return state.dailyChallenges;
}
return { date: today, challenges: generateDailyChallenges(today) };
return { challenges: generateDailyChallenges(today), date: today };
};
/**
* Increments progress for challenges matching the given type.
* Returns the updated challenge state and total crystals awarded for newly completed challenges.
* @param challengeState - The current daily challenge state.
* @param type - The challenge type to increment progress for.
* @param amount - The amount to increment progress by.
* @returns The updated challenge state and total crystals awarded.
*/
export const updateChallengeProgress = (
const updateChallengeProgress = (
challengeState: DailyChallengeState,
type: DailyChallengeType,
amount: number,
@@ -88,16 +148,33 @@ export const updateChallengeProgress = (
const updatedChallenges: DailyChallengeState = {
...challengeState,
challenges: challengeState.challenges.map((challenge) => {
if (challenge.type !== type || challenge.completed) return challenge;
if (challenge.type !== type || challenge.completed) {
return challenge;
}
const newProgress = Math.min(challenge.progress + amount, challenge.target);
const nowCompleted = newProgress >= challenge.target;
const updatedProgress = Math.min(
challenge.progress + amount,
challenge.target,
);
const nowCompleted = updatedProgress >= challenge.target;
if (nowCompleted) crystalsAwarded += challenge.rewardCrystals;
if (nowCompleted) {
crystalsAwarded = crystalsAwarded + challenge.rewardCrystals;
}
return { ...challenge, progress: newProgress, completed: nowCompleted };
return {
...challenge,
completed: nowCompleted,
progress: updatedProgress,
};
}),
};
return { updatedChallenges, crystalsAwarded };
return { crystalsAwarded, updatedChallenges };
};
export {
generateDailyChallenges,
getOrResetDailyChallenges,
updateChallengeProgress,
};
+74 -33
View File
@@ -1,49 +1,78 @@
export interface DiscordTokenResponse {
access_token: string;
token_type: string;
expires_in: number;
/**
* @file Discord OAuth helpers for token exchange, user fetching, and URL building.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */
interface DiscordTokenResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
scope: string;
scope: string;
}
export interface DiscordUser {
id: string;
username: string;
interface DiscordUser {
id: string;
username: string;
discriminator: string;
avatar: string | null;
avatar: string | null;
}
export const exchangeCode = async (code: string): Promise<DiscordTokenResponse> => {
const clientId = process.env["DISCORD_CLIENT_ID"];
const clientSecret = process.env["DISCORD_CLIENT_SECRET"];
const redirectUri = process.env["DISCORD_REDIRECT_URI"];
/**
* Exchanges a Discord OAuth authorisation code for an access token.
* @param code - The authorisation code received from Discord's OAuth callback.
* @returns The Discord token response containing the access token.
* @throws {Error} If OAuth environment variables are missing or the exchange fails.
*/
const exchangeCode = async(
code: string,
): Promise<DiscordTokenResponse> => {
const clientId = process.env.DISCORD_CLIENT_ID;
const clientSecret = process.env.DISCORD_CLIENT_SECRET;
const redirectUri = process.env.DISCORD_REDIRECT_URI;
if (!clientId || !clientSecret || !redirectUri) {
if (
clientId === undefined || clientId === ""
|| clientSecret === undefined || clientSecret === ""
|| redirectUri === undefined || redirectUri === ""
) {
throw new Error("Discord OAuth environment variables are required");
}
const params = new URLSearchParams({
client_id: clientId,
const parameters = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
code: code,
grant_type: "authorization_code",
redirect_uri: redirectUri,
});
const response = await fetch("https://discord.com/api/v10/oauth2/token", {
method: "POST",
body: parameters.toString(),
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
method: "POST",
});
if (!response.ok) {
throw new Error(`Discord token exchange failed: ${response.statusText}`);
}
return response.json() as Promise<DiscordTokenResponse>;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordTokenResponse shape */
return await (response.json() as Promise<DiscordTokenResponse>);
};
export const fetchDiscordUser = async (accessToken: string): Promise<DiscordUser> => {
/**
* Fetches the Discord user profile for the given access token.
* @param accessToken - A valid Discord OAuth access token.
* @returns The Discord user object.
* @throws {Error} If the user fetch fails.
*/
const fetchDiscordUser = async(
accessToken: string,
): Promise<DiscordUser> => {
const response = await fetch("https://discord.com/api/v10/users/@me", {
headers: { Authorization: `Bearer ${accessToken}` },
});
@@ -52,23 +81,35 @@ export const fetchDiscordUser = async (accessToken: string): Promise<DiscordUser
throw new Error(`Discord user fetch failed: ${response.statusText}`);
}
return response.json() as Promise<DiscordUser>;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordUser shape */
return await (response.json() as Promise<DiscordUser>);
};
export const buildOAuthUrl = (): string => {
const clientId = process.env["DISCORD_CLIENT_ID"];
const redirectUri = process.env["DISCORD_REDIRECT_URI"];
/**
* Builds the Discord OAuth authorisation URL.
* @returns The full OAuth URL to redirect the user to.
* @throws {Error} If OAuth environment variables are missing.
*/
const buildOAuthUrl = (): string => {
const clientId = process.env.DISCORD_CLIENT_ID;
const redirectUri = process.env.DISCORD_REDIRECT_URI;
if (!clientId || !redirectUri) {
if (
clientId === undefined || clientId === ""
|| redirectUri === undefined || redirectUri === ""
) {
throw new Error("Discord OAuth environment variables are required");
}
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
const parameters = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: "code",
scope: "identify",
scope: "identify",
});
return `https://discord.com/api/oauth2/authorize?${params.toString()}`;
return `https://discord.com/api/oauth2/authorize?${parameters.toString()}`;
};
export type { DiscordTokenResponse, DiscordUser };
export { buildOAuthUrl, exchangeCode, fetchDiscordUser };
+50 -23
View File
@@ -1,42 +1,65 @@
import { createHmac } from "crypto";
/**
* @file JWT token signing and verification utilities.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { createHmac } from "node:crypto";
interface JwtPayload {
discordId: string;
iat: number;
exp: number;
iat: number;
exp: number;
}
const base64UrlEncode = (data: string): string =>
Buffer.from(data).toString("base64url");
const base64UrlEncode = (data: string): string => {
return Buffer.from(data).toString("base64url");
};
const base64UrlDecode = (data: string): string =>
Buffer.from(data, "base64url").toString("utf8");
const base64UrlDecode = (data: string): string => {
return Buffer.from(data, "base64url").toString("utf8");
};
export const signToken = (discordId: string): string => {
const secret = process.env["JWT_SECRET"];
if (!secret) {
/**
* Signs a JWT token for the given Discord ID.
* @param discordId - The Discord user ID to encode in the token.
* @returns A signed JWT string valid for 30 days.
* @throws {Error} If the JWT_SECRET environment variable is not set.
*/
const signToken = (discordId: string): string => {
const secret = process.env.JWT_SECRET;
if (secret === undefined || secret === "") {
throw new Error("JWT_SECRET environment variable is required");
}
const header = base64UrlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" }));
// 30 days expiry
const thirtyDaysInSeconds = 60 * 60 * 24 * 30;
const payload = base64UrlEncode(
JSON.stringify({
discordId,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30, // 30 days
discordId: discordId,
exp: Math.floor(Date.now() / 1000) + thirtyDaysInSeconds,
iat: Math.floor(Date.now() / 1000),
}),
);
const signature = createHmac("sha256", secret)
.update(`${header}.${payload}`)
.digest("base64url");
const signature = createHmac("sha256", secret).
update(`${header}.${payload}`).
digest("base64url");
return `${header}.${payload}.${signature}`;
};
export const verifyToken = (token: string): JwtPayload => {
const secret = process.env["JWT_SECRET"];
if (!secret) {
/**
* Verifies a JWT token and returns the decoded payload.
* @param token - The JWT string to verify.
* @returns The decoded JWT payload containing discordId, iat, and exp.
* @throws {Error} If the JWT_SECRET is missing, the token is malformed, the
* signature is invalid, or the token has expired.
*/
const verifyToken = (token: string): JwtPayload => {
const secret = process.env.JWT_SECRET;
if (secret === undefined || secret === "") {
throw new Error("JWT_SECRET environment variable is required");
}
@@ -45,16 +68,18 @@ export const verifyToken = (token: string): JwtPayload => {
throw new Error("Invalid token format");
}
const [header, payload, signature] = parts as [string, string, string];
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Array destructure of known-length tuple */
const [ header, payload, signature ] = parts as [string, string, string];
const expectedSignature = createHmac("sha256", secret)
.update(`${header}.${payload}`)
.digest("base64url");
const expectedSignature = createHmac("sha256", secret).
update(`${header}.${payload}`).
digest("base64url");
if (signature !== expectedSignature) {
throw new Error("Invalid token signature");
}
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Parsed JSON from trusted base64url payload */
const decoded = JSON.parse(base64UrlDecode(payload)) as JwtPayload;
if (decoded.exp < Math.floor(Date.now() / 1000)) {
@@ -63,3 +88,5 @@ export const verifyToken = (token: string): JwtPayload => {
return decoded;
};
export { signToken, verifyToken };
+54 -28
View File
@@ -1,24 +1,42 @@
/**
* @file Offline earnings calculator for gold and essence accrued while logged out.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Offline earnings calculation requires iterating all adventurers with multi-step math */
import type { GameState } from "@elysium/types";
const MAX_OFFLINE_SECONDS = 8 * 60 * 60; // 8 hours
/**
* Maximum offline accrual cap: 8 hours.
*/
const maxOfflineSeconds = 8 * 60 * 60;
/**
* Calculates the gold and essence earned whilst the player was offline.
* Capped at 8 hours to prevent exploit via system clock manipulation.
* Applies the same multipliers as the client-side tick engine.
* @param state - The current game state to calculate offline earnings from.
* @param nowMs - The current timestamp in milliseconds.
* @returns The gold, essence, and elapsed seconds earned offline.
*/
export const calculateOfflineEarnings = (
const calculateOfflineEarnings = (
state: GameState,
nowMs: number,
): { offlineGold: number; offlineEssence: number; offlineSeconds: number } => {
const elapsedSeconds = Math.min(
(nowMs - state.lastTickAt) / 1000,
MAX_OFFLINE_SECONDS,
maxOfflineSeconds,
);
const equipmentGoldMultiplier = (state.equipment ?? [])
.filter((e) => e.equipped)
.reduce((mult, e) => mult * (e.bonus.goldMultiplier ?? 1), 1);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Defensive check for runtime nullable fields
const equipmentGoldMultiplier = (state.equipment ?? []).
filter((item) => {
return item.equipped;
}).
reduce((mult, item) => {
return mult * (item.bonus.goldMultiplier ?? 1);
}, 1);
const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1;
const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1;
@@ -31,36 +49,44 @@ export const calculateOfflineEarnings = (
continue;
}
const upgradeMultiplier = state.upgrades
.filter(
(u) =>
u.purchased &&
(u.target === "global" ||
(u.target === "adventurer" && u.adventurerId === adventurer.id)),
)
.reduce((mult, u) => mult * u.multiplier, 1);
const upgradeMultiplier = state.upgrades.
filter((upgrade) => {
const isGlobal = upgrade.target === "global";
const isForAdventurer
= upgrade.target === "adventurer"
&& upgrade.adventurerId === adventurer.id;
const affectsAdventurer = isGlobal || isForAdventurer;
return upgrade.purchased && affectsAdventurer;
}).
reduce((mult, upgrade) => {
return mult * upgrade.multiplier;
}, 1);
const prestige = state.prestige.productionMultiplier;
goldPerSecond +=
adventurer.goldPerSecond *
adventurer.count *
upgradeMultiplier *
prestige *
runestonesIncome *
equipmentGoldMultiplier;
const goldContribution
= adventurer.goldPerSecond
* adventurer.count
* upgradeMultiplier
* prestige
* runestonesIncome
* equipmentGoldMultiplier;
goldPerSecond = goldPerSecond + goldContribution;
essencePerSecond +=
adventurer.essencePerSecond *
adventurer.count *
upgradeMultiplier *
prestige *
runestonesEssence;
const essenceContribution
= adventurer.essencePerSecond
* adventurer.count
* upgradeMultiplier
* prestige
* runestonesEssence;
essencePerSecond = essencePerSecond + essenceContribution;
}
return {
offlineGold: goldPerSecond * elapsedSeconds,
offlineEssence: essencePerSecond * elapsedSeconds,
offlineGold: goldPerSecond * elapsedSeconds,
offlineSeconds: elapsedSeconds,
};
};
export { calculateOfflineEarnings };
+188 -72
View File
@@ -1,130 +1,246 @@
/**
* @file Prestige eligibility checks, runestone calculations, and post-prestige state builder.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- buildPostPrestigeState requires constructing a large composite state object */
import { initialGameState } from "../data/initialState.js";
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
import type {
GameState,
PrestigeData,
PrestigeUpgradeCategory,
} from "@elysium/types";
import { INITIAL_GAME_STATE } from "../data/initialState.js";
import { DEFAULT_PRESTIGE_UPGRADES } from "../data/prestigeUpgrades.js";
const BASE_PRESTIGE_GOLD_THRESHOLD = 1_000_000;
const THRESHOLD_SCALE_FACTOR = 5;
const RUNESTONES_PER_PRESTIGE_LEVEL = 10;
const MILESTONE_INTERVAL = 5;
const MILESTONE_RUNESTONES_PER_INTERVAL = 25;
const basePrestigeGoldThreshold = 1_000_000;
const thresholdScaleFactor = 5;
const runestonesPerPrestigeLevel = 10;
const milestoneInterval = 5;
const milestoneRunestonesPerInterval = 25;
/**
* Calculates the gold threshold required for the next prestige.
* Formula: BASE * SCALE_FACTOR^prestigeCount — each prestige makes the next threshold harder.
* @param prestigeCount - The current number of prestiges completed.
* @param thresholdMultiplier - An optional echo-upgrade multiplier applied to the threshold.
* @returns The gold amount required to prestige.
*/
export const calculatePrestigeThreshold = (prestigeCount: number, thresholdMultiplier = 1): number =>
BASE_PRESTIGE_GOLD_THRESHOLD * Math.pow(THRESHOLD_SCALE_FACTOR, prestigeCount) * thresholdMultiplier;
const calculatePrestigeThreshold = (
prestigeCount: number,
thresholdMultiplier = 1,
): number => {
return (
basePrestigeGoldThreshold
* Math.pow(thresholdScaleFactor, prestigeCount)
* thresholdMultiplier
);
};
export const isEligibleForPrestige = (state: GameState): boolean => {
const thresholdMultiplier = state.transcendence?.echoPrestigeThresholdMultiplier ?? 1;
return state.player.totalGoldEarned >= calculatePrestigeThreshold(state.prestige.count, thresholdMultiplier);
/**
* Returns true if the player has earned enough gold to prestige.
* @param state - The current game state.
* @returns Whether the player is eligible for a prestige reset.
*/
const isEligibleForPrestige = (state: GameState): boolean => {
const thresholdMultiplier
= state.transcendence?.echoPrestigeThresholdMultiplier ?? 1;
return (
state.player.totalGoldEarned
>= calculatePrestigeThreshold(state.prestige.count, thresholdMultiplier)
);
};
const getCategoryMultiplier = (
purchasedUpgradeIds: string[],
purchasedUpgradeIds: Array<string>,
category: PrestigeUpgradeCategory,
): number =>
DEFAULT_PRESTIGE_UPGRADES.filter(
(u) => u.category === category && purchasedUpgradeIds.includes(u.id),
).reduce((mult, u) => mult * u.multiplier, 1);
): number => {
return defaultPrestigeUpgrades.filter((upgrade) => {
const matchesCategory = upgrade.category === category;
const isPurchased = purchasedUpgradeIds.includes(upgrade.id);
return matchesCategory && isPurchased;
}).reduce((mult, upgrade) => {
return mult * upgrade.multiplier;
}, 1);
};
export const computeRunestoneMultipliers = (
purchasedUpgradeIds: string[],
/**
* Computes all four runestone multipliers from the purchased upgrade IDs.
* @param purchasedUpgradeIds - The array of purchased prestige upgrade IDs.
* @returns An object containing all four runestone multiplier values.
*/
const computeRunestoneMultipliers = (
purchasedUpgradeIds: Array<string>,
): {
runestonesIncomeMultiplier: number;
runestonesClickMultiplier: number;
runestonesIncomeMultiplier: number;
runestonesClickMultiplier: number;
runestonesEssenceMultiplier: number;
runestonesCrystalMultiplier: number;
} => ({
runestonesIncomeMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "income"),
runestonesClickMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "click"),
runestonesEssenceMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "essence"),
runestonesCrystalMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "crystals"),
});
} => {
return {
runestonesClickMultiplier: getCategoryMultiplier(
purchasedUpgradeIds,
"click",
),
runestonesCrystalMultiplier: getCategoryMultiplier(
purchasedUpgradeIds,
"crystals",
),
runestonesEssenceMultiplier: getCategoryMultiplier(
purchasedUpgradeIds,
"essence",
),
runestonesIncomeMultiplier: getCategoryMultiplier(
purchasedUpgradeIds,
"income",
),
};
};
interface RunestoneParameters {
totalGoldEarned: number;
prestigeCount: number;
purchasedUpgradeIds: Array<string>;
echoRunestoneMultiplier?: number;
}
/**
* Calculates how many runestones the player earns from a prestige.
* Formula: floor(sqrt(totalGoldEarned / threshold)) * RUNESTONES_PER_PRESTIGE_LEVEL * runestoneMultiplier
* Formula: floor(sqrt(totalGoldEarned / threshold)) * RUNESTONES_PER_PRESTIGE_LEVEL * runestoneMultiplier.
* @param parameters - The parameters for the runestone calculation.
* @param parameters.totalGoldEarned - The total gold earned in the current run.
* @param parameters.prestigeCount - The current prestige count.
* @param parameters.purchasedUpgradeIds - The purchased prestige upgrade IDs.
* @param parameters.echoRunestoneMultiplier - An optional echo-upgrade multiplier.
* @returns The number of runestones earned.
*/
export const calculateRunestones = (
totalGoldEarned: number,
prestigeCount: number,
purchasedUpgradeIds: string[],
echoRunestoneMultiplier = 1,
): number => {
const calculateRunestones = (parameters: RunestoneParameters): number => {
const {
totalGoldEarned,
prestigeCount,
purchasedUpgradeIds,
echoRunestoneMultiplier = 1,
} = parameters;
const threshold = calculatePrestigeThreshold(prestigeCount);
const base =
Math.floor(Math.sqrt(totalGoldEarned / threshold)) * RUNESTONES_PER_PRESTIGE_LEVEL;
const runestoneMult = getCategoryMultiplier(purchasedUpgradeIds, "runestones");
const base
= Math.floor(Math.sqrt(totalGoldEarned / threshold))
* runestonesPerPrestigeLevel;
const runestoneMult = getCategoryMultiplier(
purchasedUpgradeIds,
"runestones",
);
return Math.floor(base * runestoneMult * echoRunestoneMultiplier);
};
/**
* Calculates the new prestige production multiplier.
* Formula: 1.15^prestigeCount — exponential scaling per prestige.
* @param prestigeCount - The new prestige count.
* @returns The production multiplier for the new prestige level.
*/
export const calculateProductionMultiplier = (prestigeCount: number): number =>
Math.pow(1.15, prestigeCount);
const calculateProductionMultiplier = (
prestigeCount: number,
): number => {
return Math.pow(1.15, prestigeCount);
};
/**
* Returns the milestone runestone bonus for the given prestige count.
* Every MILESTONE_INTERVAL prestiges awards milestone_number * MILESTONE_RUNESTONES_PER_INTERVAL stones.
* @param prestigeCount - The prestige count after the current prestige.
* @returns The milestone runestone bonus, or 0 if not a milestone prestige.
*/
export const calculateMilestoneBonus = (newPrestigeCount: number): number => {
if (newPrestigeCount % MILESTONE_INTERVAL !== 0) return 0;
const milestoneNumber = newPrestigeCount / MILESTONE_INTERVAL;
return milestoneNumber * MILESTONE_RUNESTONES_PER_INTERVAL;
const calculateMilestoneBonus = (prestigeCount: number): number => {
if (prestigeCount % milestoneInterval !== 0) {
return 0;
}
const milestoneNumber = prestigeCount / milestoneInterval;
return milestoneNumber * milestoneRunestonesPerInterval;
};
/**
* Generates the reset game state after a prestige.
* Carries over prestige data and runestones; resets everything else.
* @param currentState - The game state at the time of the prestige.
* @param characterName - The player's character name to carry forward.
* @returns The new game state, prestige data, and runestone counts.
*/
export const buildPostPrestigeState = (
const buildPostPrestigeState = (
currentState: GameState,
characterName: string,
): { newState: GameState; newPrestigeData: PrestigeData; runestonesEarned: number; milestoneRunestones: number } => {
const echoRunestoneMultiplier = currentState.transcendence?.echoPrestigeRunestoneMultiplier ?? 1;
const runestonesEarned = calculateRunestones(
currentState.player.totalGoldEarned,
currentState.prestige.count,
currentState.prestige.purchasedUpgradeIds,
echoRunestoneMultiplier,
);
const newPrestigeCount = currentState.prestige.count + 1;
const { purchasedUpgradeIds } = currentState.prestige;
const milestoneRunestones = calculateMilestoneBonus(newPrestigeCount);
const newPrestigeData: PrestigeData = {
count: newPrestigeCount,
runestones: currentState.prestige.runestones + runestonesEarned + milestoneRunestones,
productionMultiplier: calculateProductionMultiplier(newPrestigeCount),
): {
prestigeState: GameState;
prestigeData: PrestigeData;
runestonesEarned: number;
milestoneRunestones: number;
} => {
const {
autoPrestigeEnabled,
count: currentPrestigeCount,
purchasedUpgradeIds,
lastPrestigedAt: Date.now(),
runestones: currentRunestones,
} = currentState.prestige;
const echoRunestoneMultiplier
= currentState.transcendence?.echoPrestigeRunestoneMultiplier ?? 1;
const runestonesEarned = calculateRunestones({
echoRunestoneMultiplier: echoRunestoneMultiplier,
prestigeCount: currentPrestigeCount,
purchasedUpgradeIds: purchasedUpgradeIds,
totalGoldEarned: currentState.player.totalGoldEarned,
});
const updatedPrestigeCount = currentPrestigeCount + 1;
const milestoneRunestones = calculateMilestoneBonus(updatedPrestigeCount);
const prestigeData: PrestigeData = {
count: updatedPrestigeCount,
lastPrestigedAt: Date.now(),
productionMultiplier: calculateProductionMultiplier(updatedPrestigeCount),
purchasedUpgradeIds: purchasedUpgradeIds,
runestones:
currentRunestones + runestonesEarned + milestoneRunestones,
...computeRunestoneMultipliers(purchasedUpgradeIds),
...(currentState.prestige.autoPrestigeEnabled !== undefined
? { autoPrestigeEnabled: currentState.prestige.autoPrestigeEnabled }
: {}),
...autoPrestigeEnabled === undefined
? {}
: { autoPrestigeEnabled },
};
const freshState = INITIAL_GAME_STATE(currentState.player, characterName);
const newState: GameState = {
const freshState = initialGameState(currentState.player, characterName);
const prestigeState: GameState = {
...freshState,
prestige: newPrestigeData,
lastTickAt: Date.now(),
prestige: prestigeData,
// Codex lore persists across prestiges — players keep their discovered entries
...(currentState.codex ? { codex: currentState.codex } : {}),
...currentState.codex === undefined
? {}
: { codex: currentState.codex },
// Transcendence data is permanent — never wiped by prestige
...(currentState.transcendence ? { transcendence: currentState.transcendence } : {}),
...currentState.transcendence === undefined
? {}
: { transcendence: currentState.transcendence },
// Apotheosis data is eternal — never wiped by prestige
...(currentState.apotheosis ? { apotheosis: currentState.apotheosis } : {}),
...currentState.apotheosis === undefined
? {}
: { apotheosis: currentState.apotheosis },
// Story chapter progress is permanent — survives all resets
...(currentState.story ? { story: currentState.story } : {}),
...currentState.story === undefined
? {}
: { story: currentState.story },
};
return { newState, newPrestigeData, runestonesEarned, milestoneRunestones };
return {
milestoneRunestones,
prestigeData,
prestigeState,
runestonesEarned,
};
};
export {
buildPostPrestigeState,
calculateMilestoneBonus,
calculatePrestigeThreshold,
calculateProductionMultiplier,
calculateRunestones,
computeRunestoneMultipliers,
isEligibleForPrestige,
};
+62 -22
View File
@@ -1,30 +1,60 @@
/**
* @file Title unlock logic for checking and awarding in-game titles.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { gameTitles } from "../data/titles.js";
import type { GameState } from "@elysium/types";
import { TITLES } from "../data/titles.js";
export const checkAndUnlockTitles = (
currentUnlocked: string[],
state: GameState,
guildName: string,
createdAt: number,
): string[] => {
interface TitleCheckParameters {
currentUnlocked: Array<string>;
state: GameState;
guildName: string;
createdAt: number;
}
/**
* Checks which titles the player has newly earned and returns their IDs.
* @param parameters - The parameters for the title check.
* @param parameters.currentUnlocked - The array of already-unlocked title IDs.
* @param parameters.state - The current game state.
* @param parameters.guildName - The player's current guild name.
* @param parameters.createdAt - The timestamp (ms) when the player account was created.
* @returns An array of newly unlocked title IDs.
*/
const checkAndUnlockTitles = (
parameters: TitleCheckParameters,
): Array<string> => {
const { currentUnlocked, state, guildName, createdAt } = parameters;
const metrics: Record<string, number | boolean> = {
totalClicks: state.player.totalClicks,
totalGoldEarned: state.player.totalGoldEarned,
bossesDefeated: state.bosses.filter((b) => b.status === "defeated").length,
questsCompleted: state.quests.filter((q) => q.status === "completed").length,
prestigeCount: state.prestige.count,
transcendenceCount: state.transcendence?.count ?? 0,
achievementsUnlocked: state.achievements.filter((achievement) => {
return achievement.unlockedAt !== null;
}).length,
adventurerTotal: state.adventurers.reduce((sum, adventurer) => {
return sum + adventurer.count;
}, 0),
apotheosisCount: state.apotheosis?.count ?? 0,
adventurerTotal: state.adventurers.reduce((sum, a) => sum + a.count, 0),
achievementsUnlocked: state.achievements.filter((a) => a.unlockedAt !== null).length,
guildFounded: guildName.trim().length > 0,
playedDays: Math.floor((Date.now() - createdAt) / 86_400_000),
bossesDefeated: state.bosses.filter((boss) => {
return boss.status === "defeated";
}).length,
guildFounded: guildName.trim().length > 0,
playedDays: Math.floor((Date.now() - createdAt) / 86_400_000),
prestigeCount: state.prestige.count,
questsCompleted: state.quests.filter((quest) => {
return quest.status === "completed";
}).length,
totalClicks: state.player.totalClicks,
totalGoldEarned: state.player.totalGoldEarned,
transcendenceCount: state.transcendence?.count ?? 0,
};
const newlyUnlocked: string[] = [];
const newlyUnlocked: Array<string> = [];
for (const title of TITLES) {
if (currentUnlocked.includes(title.id)) continue;
for (const title of gameTitles) {
if (currentUnlocked.includes(title.id)) {
continue;
}
const { type, amount } = title.condition;
let earned = false;
@@ -32,6 +62,7 @@ export const checkAndUnlockTitles = (
if (type === "guildFounded") {
earned = metrics.guildFounded === true;
} else if (amount !== undefined) {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- metrics[type] is number when type is not guildFounded */
earned = (metrics[type] as number) >= amount;
}
@@ -43,9 +74,18 @@ export const checkAndUnlockTitles = (
return newlyUnlocked;
};
export const parseUnlockedTitles = (raw: unknown): string[] => {
/**
* Parses the raw unlocked titles value from the database into a string array.
* @param raw - The raw value from the database (may be any type).
* @returns An array of title ID strings.
*/
const parseUnlockedTitles = (raw: unknown): Array<string> => {
if (Array.isArray(raw)) {
return raw.filter((item): item is string => typeof item === "string");
return raw.filter((item): item is string => {
return typeof item === "string";
});
}
return [];
};
export { checkAndUnlockTitles, parseUnlockedTitles };
+129 -47
View File
@@ -1,88 +1,170 @@
import type { GameState, TranscendenceData, TranscendenceUpgradeCategory } from "@elysium/types";
import { INITIAL_GAME_STATE } from "../data/initialState.js";
import { DEFAULT_TRANSCENDENCE_UPGRADES } from "../data/transcendenceUpgrades.js";
/**
* @file Transcendence eligibility checks, echo calculations, and post-transcendence state builder.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { initialGameState } from "../data/initialState.js";
import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js";
import type {
GameState,
TranscendenceData,
TranscendenceUpgradeCategory,
} from "@elysium/types";
/** ID of the boss that must be defeated to unlock transcendence */
const FINAL_BOSS_ID = "the_absolute_one";
/**
* ID of the boss that must be defeated to unlock transcendence.
*/
const finalBossId = "the_absolute_one";
/** Base constant used in the echo yield formula */
const ECHO_FORMULA_CONSTANT = 853;
/**
* Base constant used in the echo yield formula.
*/
const echoFormulaConstant = 853;
const getCategoryMultiplier = (
purchasedIds: string[],
purchasedIds: Array<string>,
category: TranscendenceUpgradeCategory,
): number =>
DEFAULT_TRANSCENDENCE_UPGRADES
.filter((u) => u.category === category && purchasedIds.includes(u.id))
.reduce((mult, u) => mult * u.multiplier, 1);
): number => {
return defaultTranscendenceUpgrades.filter((upgrade) => {
return upgrade.category === category && purchasedIds.includes(upgrade.id);
}).reduce((mult, upgrade) => {
return mult * upgrade.multiplier;
}, 1);
};
export const computeTranscendenceMultipliers = (
purchasedUpgradeIds: string[],
): Omit<TranscendenceData, "count" | "echoes" | "purchasedUpgradeIds"> => ({
echoIncomeMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "income"),
echoCombatMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "combat"),
echoPrestigeThresholdMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "prestige_threshold"),
echoPrestigeRunestoneMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "prestige_runestones"),
echoMetaMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "echo_meta"),
});
/**
* Computes all transcendence multipliers from the purchased upgrade IDs.
* @param purchasedUpgradeIds - The array of purchased transcendence upgrade IDs.
* @returns An object containing all transcendence multiplier values.
*/
const computeTranscendenceMultipliers = (
purchasedUpgradeIds: Array<string>,
): Omit<TranscendenceData, "count" | "echoes" | "purchasedUpgradeIds"> => {
return {
echoCombatMultiplier: getCategoryMultiplier(
purchasedUpgradeIds,
"combat",
),
echoIncomeMultiplier: getCategoryMultiplier(
purchasedUpgradeIds,
"income",
),
echoMetaMultiplier: getCategoryMultiplier(
purchasedUpgradeIds,
"echo_meta",
),
echoPrestigeRunestoneMultiplier: getCategoryMultiplier(
purchasedUpgradeIds,
"prestige_runestones",
),
echoPrestigeThresholdMultiplier: getCategoryMultiplier(
purchasedUpgradeIds,
"prestige_threshold",
),
};
};
/**
* Returns true when the player is eligible to transcend:
* they must have defeated the final boss at least once.
* @param state - The current game state.
* @returns Whether the player is eligible for transcendence.
*/
export const isEligibleForTranscendence = (state: GameState): boolean =>
state.bosses.some((b) => b.id === FINAL_BOSS_ID && b.status === "defeated");
const isEligibleForTranscendence = (state: GameState): boolean => {
return state.bosses.some((boss) => {
return boss.id === finalBossId && boss.status === "defeated";
});
};
/**
* Calculates echo yield for a transcendence.
* Formula: floor(CONSTANT / sqrt(prestigeCount)) × echoMetaMultiplier
* Formula: floor(CONSTANT / sqrt(prestigeCount)) × echoMetaMultiplier.
* Fewer prestiges = more echoes (rewards efficient play).
* Minimum prestige count of 1 is enforced to avoid division by zero.
* @param prestigeCount - The current prestige count.
* @param echoMetaMultiplier - The echo meta multiplier from transcendence upgrades.
* @returns The number of echoes earned.
*/
export const calculateEchoes = (
const calculateEchoes = (
prestigeCount: number,
echoMetaMultiplier: number,
): number => {
const safeCount = Math.max(prestigeCount, 1);
return Math.floor((ECHO_FORMULA_CONSTANT / Math.sqrt(safeCount)) * echoMetaMultiplier);
const baseEchoes = echoFormulaConstant / Math.sqrt(safeCount);
return Math.floor(baseEchoes * echoMetaMultiplier);
};
/**
* Builds the permanent-data spread objects that survive a transcendence reset.
* @param currentState - The game state at the time of transcendence.
* @param transcendenceData - The newly-computed transcendence data to carry forward.
* @returns A partial GameState object containing all data that persists through transcendence.
*/
const buildPermanentSpreads = (
currentState: GameState,
transcendenceData: TranscendenceData,
): Partial<GameState> => {
return {
transcendence: transcendenceData,
...currentState.codex === undefined
? {}
: { codex: currentState.codex },
...currentState.apotheosis === undefined
? {}
: { apotheosis: currentState.apotheosis },
...currentState.story === undefined
? {}
: { story: currentState.story },
};
};
/**
* Builds the new game state after a transcendence (nuclear reset).
* Wipes everything except codex, dailyChallenges, and transcendence data.
* @param currentState - The game state at the time of transcendence.
* @param characterName - The player's character name to carry forward.
* @returns The new game state, transcendence data, and echoes earned.
*/
export const buildPostTranscendenceState = (
const buildPostTranscendenceState = (
currentState: GameState,
characterName: string,
): { newState: GameState; newTranscendenceData: TranscendenceData; echoesEarned: number } => {
): {
transcendenceState: GameState;
transcendenceData: TranscendenceData;
echoesEarned: number;
} => {
const previousTranscendence = currentState.transcendence;
const echoMetaMultiplier = previousTranscendence?.echoMetaMultiplier ?? 1;
const echoesEarned = calculateEchoes(currentState.prestige.count, echoMetaMultiplier);
const echoesEarned = calculateEchoes(
currentState.prestige.count,
echoMetaMultiplier,
);
const previousEchoes = previousTranscendence?.echoes ?? 0;
const newCount = (previousTranscendence?.count ?? 0) + 1;
const newPurchasedIds = previousTranscendence?.purchasedUpgradeIds ?? [];
const updatedCount = (previousTranscendence?.count ?? 0) + 1;
const updatedPurchasedIds = previousTranscendence?.purchasedUpgradeIds ?? [];
const newTranscendenceData: TranscendenceData = {
count: newCount,
echoes: previousEchoes + echoesEarned,
purchasedUpgradeIds: newPurchasedIds,
...computeTranscendenceMultipliers(newPurchasedIds),
const transcendenceData: TranscendenceData = {
count: updatedCount,
echoes: previousEchoes + echoesEarned,
purchasedUpgradeIds: updatedPurchasedIds,
...computeTranscendenceMultipliers(updatedPurchasedIds),
};
const freshState = INITIAL_GAME_STATE(currentState.player, characterName);
const newState: GameState = {
const freshState = initialGameState(currentState.player, characterName);
const transcendenceState: GameState = {
...freshState,
lastTickAt: Date.now(),
// Codex lore persists through all resets
...(currentState.codex ? { codex: currentState.codex } : {}),
// Transcendence data is permanent
transcendence: newTranscendenceData,
// Apotheosis data is eternal — never wiped by transcendence
...(currentState.apotheosis ? { apotheosis: currentState.apotheosis } : {}),
// Story chapter progress is permanent — survives all resets
...(currentState.story ? { story: currentState.story } : {}),
...buildPermanentSpreads(currentState, transcendenceData),
};
return { newState, newTranscendenceData, echoesEarned };
return { echoesEarned, transcendenceData, transcendenceState };
};
export {
buildPostTranscendenceState,
calculateEchoes,
computeTranscendenceMultipliers,
isEligibleForTranscendence,
};
+52 -21
View File
@@ -1,19 +1,39 @@
const DISCORD_API = "https://discord.com/api/v10";
/**
* @file Discord webhook and role-grant utilities for milestone events.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case and Pascal-case header names */
const discordApi = "https://discord.com/api/v10";
export const grantApotheosisRole = async (discordId: string): Promise<void> => {
const botToken = process.env["DISCORD_BOT_TOKEN"];
const guildId = process.env["DISCORD_GUILD_ID"];
const roleId = process.env["DISCORD_APOTHEOSIS_ROLE_ID"];
/**
* Grants the apotheosis Discord role to the given player if configured.
* Fails silently so role grant errors do not affect the game action.
* @param discordId - The Discord user ID to grant the role to.
* @returns A promise that resolves when the role grant attempt completes.
*/
const grantApotheosisRole = async(discordId: string): Promise<void> => {
const botToken = process.env.DISCORD_BOT_TOKEN;
const guildId = process.env.DISCORD_GUILD_ID;
const roleId = process.env.DISCORD_APOTHEOSIS_ROLE_ID;
if (!botToken || !guildId || !roleId) {
if (
botToken === undefined || botToken === ""
|| guildId === undefined || guildId === ""
|| roleId === undefined || roleId === ""
) {
return;
}
try {
await fetch(`${DISCORD_API}/guilds/${guildId}/members/${discordId}/roles/${roleId}`, {
method: "PUT",
headers: { Authorization: `Bot ${botToken}` },
});
await fetch(
`${discordApi}/guilds/${guildId}/members/${discordId}/roles/${roleId}`,
{
headers: { Authorization: `Bot ${botToken}` },
method: "PUT",
},
);
} catch {
// Graceful degradation — role grant failure must not affect the apotheosis
}
@@ -22,37 +42,48 @@ export const grantApotheosisRole = async (discordId: string): Promise<void> => {
type MilestoneType = "prestige" | "transcendence" | "apotheosis";
interface MilestoneCounts {
prestige: number;
prestige: number;
transcendence: number;
apotheosis: number;
apotheosis: number;
}
const MILESTONE_VERBS: Record<MilestoneType, string> = {
prestige: "prestiged",
const milestoneVerbs: Record<MilestoneType, string> = {
apotheosis: "reached apotheosis",
prestige: "prestiged",
transcendence: "transcended",
apotheosis: "reached apotheosis",
};
export const postMilestoneWebhook = async (
/**
* Posts a milestone announcement to the configured Discord webhook.
* Fails silently so webhook errors do not affect the game action.
* @param discordId - The Discord user ID of the player.
* @param milestone - The type of milestone reached.
* @param counts - The current prestige, transcendence, and apotheosis counts.
* @returns A promise that resolves when the webhook post attempt completes.
*/
const postMilestoneWebhook = async(
discordId: string,
milestone: MilestoneType,
counts: MilestoneCounts,
): Promise<void> => {
const webhookUrl = process.env["DISCORD_MILESTONE_WEBHOOK"];
if (!webhookUrl) {
const webhookUrl = process.env.DISCORD_MILESTONE_WEBHOOK;
if (webhookUrl === undefined || webhookUrl === "") {
return;
}
const verb = MILESTONE_VERBS[milestone];
const verb = milestoneVerbs[milestone];
/* eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- counts fields are numbers, intentionally stringified */
const content = `<@${discordId}> has ${verb}~! They are now on Prestige ${counts.prestige}, Transcendence ${counts.transcendence}, Apotheosis ${counts.apotheosis}!`;
try {
await fetch(webhookUrl, {
method: "POST",
body: JSON.stringify({ content }),
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
method: "POST",
});
} catch {
// Graceful degradation — webhook failure must not affect the game action
}
};
export { grantApotheosisRole, postMilestoneWebhook };
+10 -1
View File
@@ -1 +1,10 @@
export type HonoEnv = { Variables: { discordId: string } };
/**
* @file Hono environment type definitions.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable @typescript-eslint/naming-convention -- Variables is required by Hono */
export interface HonoEnvironment {
Variables: { discordId: string };
}
+2 -2
View File
@@ -101,7 +101,7 @@ describe("apotheosis route", () => {
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
const res = await post();
expect(res.status).toBe(200);
const body = await res.json() as { newApotheosisCount: number };
expect(body.newApotheosisCount).toBe(1);
const body = await res.json() as { apotheosisCount: number };
expect(body.apotheosisCount).toBe(1);
});
});
+15 -15
View File
@@ -5,10 +5,10 @@ import {
buildPostApotheosisState,
isEligibleForApotheosis,
} from "../../src/services/apotheosis.js";
import { DEFAULT_TRANSCENDENCE_UPGRADES } from "../../src/data/transcendenceUpgrades.js";
import { defaultTranscendenceUpgrades } from "../../src/data/transcendenceUpgrades.js";
import type { GameState } from "@elysium/types";
const ALL_UPGRADE_IDS = DEFAULT_TRANSCENDENCE_UPGRADES.map((u) => u.id);
const ALL_UPGRADE_IDS = defaultTranscendenceUpgrades.map((u) => u.id);
const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
({
@@ -74,42 +74,42 @@ describe("isEligibleForApotheosis", () => {
describe("buildPostApotheosisState", () => {
it("increments apotheosis count from 0", () => {
const state = makeMinimalState();
const { newApotheosisData } = buildPostApotheosisState(state, "T");
expect(newApotheosisData.count).toBe(1);
const { updatedApotheosisData } = buildPostApotheosisState(state, "T");
expect(updatedApotheosisData.count).toBe(1);
});
it("increments apotheosis count from existing value", () => {
const state = makeMinimalState({ apotheosis: { count: 2 } });
const { newApotheosisData } = buildPostApotheosisState(state, "T");
expect(newApotheosisData.count).toBe(3);
const { updatedApotheosisData } = buildPostApotheosisState(state, "T");
expect(updatedApotheosisData.count).toBe(3);
});
it("persists codex", () => {
const codex = { entries: [{ id: "e1", unlockedAt: 1000, sourceType: "exploration" as const }] };
const state = makeMinimalState({ codex });
const { newState } = buildPostApotheosisState(state, "T");
expect(newState.codex).toEqual(codex);
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.codex).toEqual(codex);
});
it("persists story", () => {
const story = { unlockedChapterIds: ["ch1"], completedChapters: [] };
const state = makeMinimalState({ story });
const { newState } = buildPostApotheosisState(state, "T");
expect(newState.story).toEqual(story);
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.story).toEqual(story);
});
it("wipes prestige data", () => {
const state = makeMinimalState({
prestige: { count: 10, runestones: 1000, productionMultiplier: 3, purchasedUpgradeIds: [] },
});
const { newState } = buildPostApotheosisState(state, "T");
expect(newState.prestige.count).toBe(0);
expect(newState.prestige.runestones).toBe(0);
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.prestige.count).toBe(0);
expect(updatedState.prestige.runestones).toBe(0);
});
it("sets apotheosis count on new state", () => {
const state = makeMinimalState({ apotheosis: { count: 0 } });
const { newState } = buildPostApotheosisState(state, "T");
expect(newState.apotheosis?.count).toBe(1);
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.apotheosis?.count).toBe(1);
});
});
+19 -19
View File
@@ -90,19 +90,19 @@ describe("isEligibleForPrestige", () => {
describe("calculateRunestones", () => {
it("calculates basic runestones formula", () => {
// floor(sqrt(4_000_000 / 1_000_000)) × 10 = floor(2) × 10 = 20
const result = calculateRunestones(4_000_000, 0, []);
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
expect(result).toBe(20);
});
it("applies echo runestone multiplier", () => {
// floor(sqrt(4) × 10) = 20; × 2 = 40
const result = calculateRunestones(4_000_000, 0, [], 2);
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [], echoRunestoneMultiplier: 2 });
expect(result).toBe(40);
});
it("applies purchased runestone upgrade multiplier", () => {
// With "runestones_1" purchased (multiplier 1.25): floor(20 × 1.25) = 25
const result = calculateRunestones(4_000_000, 0, ["runestone_gain_1"]);
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: ["runestone_gain_1"] });
expect(result).toBeGreaterThan(20);
});
});
@@ -176,15 +176,15 @@ describe("computeRunestoneMultipliers", () => {
describe("buildPostPrestigeState", () => {
it("increments prestige count", () => {
const state = makeMinimalState({ player: makePlayer(4_000_000) });
const { newPrestigeData } = buildPostPrestigeState(state, "Tester");
expect(newPrestigeData.count).toBe(1);
const { prestigeData } = buildPostPrestigeState(state, "Tester");
expect(prestigeData.count).toBe(1);
});
it("sums runestones earned", () => {
const state = makeMinimalState({ player: makePlayer(4_000_000) });
const { newPrestigeData, runestonesEarned } = buildPostPrestigeState(state, "Tester");
const { prestigeData, runestonesEarned } = buildPostPrestigeState(state, "Tester");
expect(runestonesEarned).toBeGreaterThan(0);
expect(newPrestigeData.runestones).toBe(runestonesEarned);
expect(prestigeData.runestones).toBe(runestonesEarned);
});
it("adds milestone runestones at prestige 5", () => {
@@ -199,15 +199,15 @@ describe("buildPostPrestigeState", () => {
it("persists codex from current state", () => {
const codex = { entries: [{ id: "e1", unlockedAt: 1000, sourceType: "exploration" as const }] };
const state = makeMinimalState({ codex });
const { newState } = buildPostPrestigeState(state, "Tester");
expect(newState.codex).toEqual(codex);
const { prestigeState } = buildPostPrestigeState(state, "Tester");
expect(prestigeState.codex).toEqual(codex);
});
it("persists story from current state", () => {
const story = { unlockedChapterIds: ["ch1"], completedChapters: [] };
const state = makeMinimalState({ story });
const { newState } = buildPostPrestigeState(state, "Tester");
expect(newState.story).toEqual(story);
const { prestigeState } = buildPostPrestigeState(state, "Tester");
expect(prestigeState.story).toEqual(story);
});
it("persists transcendence from current state", () => {
@@ -218,28 +218,28 @@ describe("buildPostPrestigeState", () => {
echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1,
};
const state = makeMinimalState({ transcendence });
const { newState } = buildPostPrestigeState(state, "Tester");
expect(newState.transcendence).toEqual(transcendence);
const { prestigeState } = buildPostPrestigeState(state, "Tester");
expect(prestigeState.transcendence).toEqual(transcendence);
});
it("preserves autoPrestigeEnabled when set", () => {
const state = makeMinimalState({
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [], autoPrestigeEnabled: true },
});
const { newPrestigeData } = buildPostPrestigeState(state, "Tester");
expect(newPrestigeData.autoPrestigeEnabled).toBe(true);
const { prestigeData } = buildPostPrestigeState(state, "Tester");
expect(prestigeData.autoPrestigeEnabled).toBe(true);
});
it("omits autoPrestigeEnabled when not set", () => {
const state = makeMinimalState();
const { newPrestigeData } = buildPostPrestigeState(state, "Tester");
expect(newPrestigeData.autoPrestigeEnabled).toBeUndefined();
const { prestigeData } = buildPostPrestigeState(state, "Tester");
expect(prestigeData.autoPrestigeEnabled).toBeUndefined();
});
it("preserves apotheosis data across prestige", () => {
const apotheosis = { count: 2 };
const state = makeMinimalState({ apotheosis });
const { newState } = buildPostPrestigeState(state, "Tester");
expect(newState.apotheosis).toEqual(apotheosis);
const { prestigeState } = buildPostPrestigeState(state, "Tester");
expect(prestigeState.apotheosis).toEqual(apotheosis);
});
});
+11 -11
View File
@@ -62,7 +62,7 @@ describe("checkAndUnlockTitles", () => {
it("returns empty array when no new titles are earned", () => {
const state = makeMinimalState();
const result = checkAndUnlockTitles([], state, "", NOW);
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: "", createdAt: NOW });
expect(result).toEqual([]);
});
@@ -70,19 +70,19 @@ describe("checkAndUnlockTitles", () => {
const state = makeMinimalState({
player: { discordId: "t", username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 10_000, characterName: "T" },
});
const result = checkAndUnlockTitles(["click_maniac"], state, "", NOW);
const result = checkAndUnlockTitles({ currentUnlocked: ["click_maniac"], state, guildName: "", createdAt: NOW });
expect(result).not.toContain("click_maniac");
});
it("unlocks guild_founder when guild name is non-empty", () => {
const state = makeMinimalState();
const result = checkAndUnlockTitles([], state, "My Guild", NOW);
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: "My Guild", createdAt: NOW });
expect(result).toContain("guild_founder");
});
it("does not unlock guild_founder for whitespace-only guild name", () => {
const state = makeMinimalState();
const result = checkAndUnlockTitles([], state, " ", NOW);
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: " ", createdAt: NOW });
expect(result).not.toContain("guild_founder");
});
@@ -90,7 +90,7 @@ describe("checkAndUnlockTitles", () => {
const state = makeMinimalState({
quests: [{ status: "completed" }] as GameState["quests"],
});
const result = checkAndUnlockTitles([], state, "", NOW);
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: "", createdAt: NOW });
expect(result).toContain("the_adventurous");
});
@@ -98,7 +98,7 @@ describe("checkAndUnlockTitles", () => {
const state = makeMinimalState({
bosses: [{ status: "defeated" }] as GameState["bosses"],
});
const result = checkAndUnlockTitles([], state, "", NOW);
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: "", createdAt: NOW });
expect(result).toContain("boss_slayer");
});
@@ -106,21 +106,21 @@ describe("checkAndUnlockTitles", () => {
const state = makeMinimalState({
prestige: { count: 1, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
});
const result = checkAndUnlockTitles([], state, "", NOW);
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: "", createdAt: NOW });
expect(result).toContain("the_undying");
});
it("unlocks veteran after 30 days of play", () => {
const createdAt = NOW - THIRTY_DAYS_MS;
const state = makeMinimalState();
const result = checkAndUnlockTitles([], state, "", createdAt);
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: "", createdAt });
expect(result).toContain("veteran");
});
it("does not unlock veteran before 30 days", () => {
const createdAt = NOW - (29 * 86_400_000);
const state = makeMinimalState();
const result = checkAndUnlockTitles([], state, "", createdAt);
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: "", createdAt });
expect(result).not.toContain("veteran");
});
@@ -129,7 +129,7 @@ describe("checkAndUnlockTitles", () => {
bosses: [{ status: "defeated" }] as GameState["bosses"],
quests: [{ status: "completed" }] as GameState["quests"],
});
const result = checkAndUnlockTitles([], state, "Guild", NOW);
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: "Guild", createdAt: NOW });
expect(result).toContain("boss_slayer");
expect(result).toContain("the_adventurous");
expect(result).toContain("guild_founder");
@@ -145,7 +145,7 @@ describe("checkAndUnlockTitles", () => {
apotheosis: { count: 1 },
});
// Just verify this runs without error — the counts are read via ?. chains
const result = checkAndUnlockTitles([], state, "", NOW);
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: "", createdAt: NOW });
expect(Array.isArray(result)).toBe(true);
});
});
+13 -13
View File
@@ -123,8 +123,8 @@ describe("calculateEchoes", () => {
describe("buildPostTranscendenceState", () => {
it("increments transcendence count from 0", () => {
const state = makeMinimalState();
const { newTranscendenceData } = buildPostTranscendenceState(state, "T");
expect(newTranscendenceData.count).toBe(1);
const { transcendenceData } = buildPostTranscendenceState(state, "T");
expect(transcendenceData.count).toBe(1);
});
it("accumulates echoes", () => {
@@ -135,37 +135,37 @@ describe("buildPostTranscendenceState", () => {
echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1,
},
});
const { newTranscendenceData, echoesEarned } = buildPostTranscendenceState(state, "T");
expect(newTranscendenceData.echoes).toBe(100 + echoesEarned);
const { transcendenceData, echoesEarned } = buildPostTranscendenceState(state, "T");
expect(transcendenceData.echoes).toBe(100 + echoesEarned);
});
it("persists codex from current state", () => {
const codex = { entries: [{ id: "e1", unlockedAt: 1000, sourceType: "exploration" as const }] };
const state = makeMinimalState({ codex });
const { newState } = buildPostTranscendenceState(state, "T");
expect(newState.codex).toEqual(codex);
const { transcendenceState } = buildPostTranscendenceState(state, "T");
expect(transcendenceState.codex).toEqual(codex);
});
it("persists story from current state", () => {
const story = { unlockedChapterIds: ["ch1"], completedChapters: [] };
const state = makeMinimalState({ story });
const { newState } = buildPostTranscendenceState(state, "T");
expect(newState.story).toEqual(story);
const { transcendenceState } = buildPostTranscendenceState(state, "T");
expect(transcendenceState.story).toEqual(story);
});
it("persists apotheosis from current state", () => {
const apotheosis = { count: 2 };
const state = makeMinimalState({ apotheosis });
const { newState } = buildPostTranscendenceState(state, "T");
expect(newState.apotheosis).toEqual(apotheosis);
const { transcendenceState } = buildPostTranscendenceState(state, "T");
expect(transcendenceState.apotheosis).toEqual(apotheosis);
});
it("resets prestige to fresh state", () => {
const state = makeMinimalState({
prestige: { count: 5, runestones: 500, productionMultiplier: 2, purchasedUpgradeIds: [] },
});
const { newState } = buildPostTranscendenceState(state, "T");
expect(newState.prestige.count).toBe(0);
expect(newState.prestige.runestones).toBe(0);
const { transcendenceState } = buildPostTranscendenceState(state, "T");
expect(transcendenceState.prestige.count).toBe(0);
expect(transcendenceState.prestige.runestones).toBe(0);
});
});