diff --git a/CLAUDE.md b/CLAUDE.md index f8b28e4..9df208f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,12 @@ # Elysium Project Notes +## CI Requirements + +**Never commit without first confirming the full pipeline passes locally:** +1. `pnpm lint` — zero errors, zero warnings +2. `pnpm build` — all packages build cleanly +3. `pnpm test` — all tests pass with 100% coverage on `apps/api` and `packages/types` + ## About Page -The About page (`apps/web/src/components/game/AboutPanel.tsx`) contains a **How to Play** guide that should be kept up to date as new features are added to the game. When implementing new game systems, zones, mechanics, or significant UI features, update the `HOW_TO_PLAY` array in `AboutPanel.tsx` to include a description of the new feature. +The About page (`apps/web/src/components/game/aboutPanel.tsx`) contains a **How to Play** guide that should be kept up to date as new features are added to the game. When implementing new game systems, zones, mechanics, or significant UI features, update the `HOW_TO_PLAY` array in `aboutPanel.tsx` to include a description of the new feature. diff --git a/apps/api/eslint.config.js b/apps/api/eslint.config.js index 64afc0f..76c74be 100644 --- a/apps/api/eslint.config.js +++ b/apps/api/eslint.config.js @@ -1,3 +1,3 @@ -import { NaomisConfig } from "@nhcarrigan/eslint-config"; +import config from "@nhcarrigan/eslint-config"; -export default [...NaomisConfig]; +export default [...config]; diff --git a/apps/api/src/data/achievements.ts b/apps/api/src/data/achievements.ts index f5b6965..9faa01e 100644 --- a/apps/api/src/data/achievements.ts +++ b/apps/api/src/data/achievements.ts @@ -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 = [ // 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, }, ]; diff --git a/apps/api/src/data/adventurers.ts b/apps/api/src/data/adventurers.ts index 4eb7050..299135d 100644 --- a/apps/api/src/data/adventurers.ts +++ b/apps/api/src/data/adventurers.ts @@ -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 = [ { - 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, }, ]; diff --git a/apps/api/src/data/bosses.ts b/apps/api/src/data/bosses.ts index 8de18f4..94d00d1 100644 --- a/apps/api/src/data/bosses.ts +++ b/apps/api/src/data/bosses.ts @@ -1,1318 +1,1326 @@ +/** + * @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 { Boss } from "@elysium/types"; -export const DEFAULT_BOSSES: Boss[] = [ +export const defaultBosses: Array = [ // ── Verdant Vale ────────────────────────────────────────────────────────── { - id: "troll_king", - name: "The Troll King", + bountyRunestones: 1, + crystalReward: 0, + currentHp: 1000, + damagePerSecond: 5, description: "Gruk the Immovable has terrorised the trade roads for decades. Merchants will pay handsomely for his head.", - status: "available", - maxHp: 1_000, - currentHp: 1_000, - damagePerSecond: 5, - goldReward: 10_000, - essenceReward: 25, - crystalReward: 0, - upgradeRewards: ["click_2"], - equipmentRewards: ["iron_sword", "chainmail", "mages_focus"], + equipmentRewards: [ "iron_sword", "chainmail", "mages_focus" ], + essenceReward: 25, + goldReward: 10_000, + id: "troll_king", + maxHp: 1000, + name: "The Troll King", prestigeRequirement: 0, - zoneId: "verdant_vale", - bountyRunestones: 1, + status: "available", + upgradeRewards: [ "click_2" ], + zoneId: "verdant_vale", }, { - id: "lich_queen", - name: "The Lich Queen", + bountyRunestones: 2, + crystalReward: 10, + currentHp: 10_000, + damagePerSecond: 20, description: "Seraphina the Undying commands legions of undead from her bone throne. Her defeat will echo through history.", - status: "locked", - maxHp: 10_000, - currentHp: 10_000, - damagePerSecond: 20, - goldReward: 100_000, - essenceReward: 200, - crystalReward: 10, - upgradeRewards: ["global_2"], - equipmentRewards: ["enchanted_blade", "plate_armour", "arcane_orb"], + equipmentRewards: [ "enchanted_blade", "plate_armour", "arcane_orb" ], + essenceReward: 200, + goldReward: 100_000, + id: "lich_queen", + maxHp: 10_000, + name: "The Lich Queen", prestigeRequirement: 0, - zoneId: "verdant_vale", - bountyRunestones: 2, + status: "locked", + upgradeRewards: [ "global_2" ], + zoneId: "verdant_vale", }, { - id: "forest_giant", - name: "The Forest Giant", + bountyRunestones: 3, + crystalReward: 20, + currentHp: 35_000, + damagePerSecond: 40, description: "An ancient colossus of bark and stone who has slumbered beneath the Vale for centuries. Its awakening spells disaster for every settlement in the region.", - status: "locked", - maxHp: 35_000, - currentHp: 35_000, - damagePerSecond: 40, - goldReward: 350_000, - essenceReward: 400, - crystalReward: 20, - upgradeRewards: ["archmage_1"], - equipmentRewards: ["hide_armour"], + equipmentRewards: [ "hide_armour" ], + essenceReward: 400, + goldReward: 350_000, + id: "forest_giant", + maxHp: 35_000, + name: "The Forest Giant", prestigeRequirement: 0, - zoneId: "verdant_vale", - bountyRunestones: 3, + status: "locked", + upgradeRewards: [ "archmage_1" ], + zoneId: "verdant_vale", }, // ── Shattered Ruins ─────────────────────────────────────────────────────── { - id: "stone_golem", - name: "The Stone Golem", + bountyRunestones: 3, + crystalReward: 25, + currentHp: 60_000, + damagePerSecond: 60, description: "A guardian construct from the fallen civilisation, still faithfully protecting the ruins of a city long since turned to dust.", - status: "locked", - maxHp: 60_000, - currentHp: 60_000, - damagePerSecond: 60, - goldReward: 600_000, - essenceReward: 600, - crystalReward: 25, - upgradeRewards: ["paladin_1"], - equipmentRewards: [], + equipmentRewards: [], + essenceReward: 600, + goldReward: 600_000, + id: "stone_golem", + maxHp: 60_000, + name: "The Stone Golem", prestigeRequirement: 0, - zoneId: "shattered_ruins", - bountyRunestones: 3, + status: "locked", + upgradeRewards: [ "paladin_1" ], + zoneId: "shattered_ruins", }, { - id: "bone_colossus", - name: "The Bone Colossus", + bountyRunestones: 5, + crystalReward: 60, + currentHp: 200_000, + damagePerSecond: 120, description: "Forged from the skeletons of a thousand fallen warriors by the Lich Queen's disciples. Its hollow eye sockets blaze with the same sorcery that animated them.", - status: "locked", - maxHp: 200_000, - currentHp: 200_000, - damagePerSecond: 120, - goldReward: 2_000_000, - essenceReward: 1_500, - crystalReward: 60, - upgradeRewards: ["essence_guild"], - equipmentRewards: ["frost_rune"], + equipmentRewards: [ "frost_rune" ], + essenceReward: 1500, + goldReward: 2_000_000, + id: "bone_colossus", + maxHp: 200_000, + name: "The Bone Colossus", prestigeRequirement: 0, - zoneId: "shattered_ruins", - bountyRunestones: 5, + status: "locked", + upgradeRewards: [ "essence_guild" ], + zoneId: "shattered_ruins", }, { - id: "elder_dragon", - name: "Elder Dragon Vaeltharox", + bountyRunestones: 7, + crystalReward: 100, + currentHp: 500_000, + damagePerSecond: 200, description: "The eldest dragon in existence, older than the kingdom itself. Even his breath can level mountains.", - status: "locked", - maxHp: 500_000, - currentHp: 500_000, - damagePerSecond: 200, - goldReward: 5_000_000, - essenceReward: 3_000, - crystalReward: 100, - upgradeRewards: ["click_3"], - equipmentRewards: ["vorpal_sword", "dragon_scale"], + equipmentRewards: [ "vorpal_sword", "dragon_scale" ], + essenceReward: 3000, + goldReward: 5_000_000, + id: "elder_dragon", + maxHp: 500_000, + name: "Elder Dragon Vaeltharox", prestigeRequirement: 0, - zoneId: "shattered_ruins", - bountyRunestones: 7, + status: "locked", + upgradeRewards: [ "click_3" ], + zoneId: "shattered_ruins", }, // ── Shadow Marshes ──────────────────────────────────────────────────────── { - id: "swamp_witch", - name: "Morgantha the Swamp Witch", + bountyRunestones: 5, + crystalReward: 30, + currentHp: 80_000, + damagePerSecond: 80, description: "She has hexed villages for three centuries from her hut on the black water. Her curse-weaving is second to none — but so is the bounty on her head.", - status: "locked", - maxHp: 80_000, - currentHp: 80_000, - damagePerSecond: 80, - goldReward: 800_000, - essenceReward: 800, - crystalReward: 30, - upgradeRewards: ["shadow_assassin_1"], - equipmentRewards: [], + equipmentRewards: [], + essenceReward: 800, + goldReward: 800_000, + id: "swamp_witch", + maxHp: 80_000, + name: "Morgantha the Swamp Witch", prestigeRequirement: 0, - zoneId: "shadow_marshes", - bountyRunestones: 5, + status: "locked", + upgradeRewards: [ "shadow_assassin_1" ], + zoneId: "shadow_marshes", }, { - id: "plague_lord", - name: "The Plague Lord", + bountyRunestones: 8, + crystalReward: 80, + currentHp: 300_000, + damagePerSecond: 180, description: "A bloated, rotting horror that spreads pestilence wherever it walks. Entire kingdoms have fallen to the disease it carries. Yours will not.", - status: "locked", - maxHp: 300_000, - currentHp: 300_000, - damagePerSecond: 180, - goldReward: 3_000_000, - essenceReward: 2_000, - crystalReward: 80, - upgradeRewards: ["grand_council"], - equipmentRewards: ["runestone_amulet"], + equipmentRewards: [ "runestone_amulet" ], + essenceReward: 2000, + goldReward: 3_000_000, + id: "plague_lord", + maxHp: 300_000, + name: "The Plague Lord", prestigeRequirement: 0, - zoneId: "shadow_marshes", - bountyRunestones: 8, + status: "locked", + upgradeRewards: [ "grand_council" ], + zoneId: "shadow_marshes", }, { - id: "mud_kraken", - name: "The Mud Kraken", + bountyRunestones: 10, + crystalReward: 150, + currentHp: 800_000, + damagePerSecond: 350, description: "An eldritch leviathan that lurks in the deepest part of the marshes, older than the gods themselves. Its tentacles have dragged ships — and armies — into the mire.", - status: "locked", - maxHp: 800_000, - currentHp: 800_000, - damagePerSecond: 350, - goldReward: 8_000_000, - essenceReward: 4_000, - crystalReward: 150, - upgradeRewards: ["arcane_scholar_1"], - equipmentRewards: ["crystal_shard"], + equipmentRewards: [ "crystal_shard" ], + essenceReward: 4000, + goldReward: 8_000_000, + id: "mud_kraken", + maxHp: 800_000, + name: "The Mud Kraken", prestigeRequirement: 0, - zoneId: "shadow_marshes", - bountyRunestones: 10, + status: "locked", + upgradeRewards: [ "arcane_scholar_1" ], + zoneId: "shadow_marshes", }, // ── Frozen Peaks ────────────────────────────────────────────────────────── { - id: "frost_wyrm", - name: "The Frost Wyrm", + bountyRunestones: 8, + crystalReward: 100, + currentHp: 500_000, + damagePerSecond: 220, description: "A serpentine dragon of pure ice who has hunted the tundra for aeons. Its breath flash-freezes anything it touches, and it has never known defeat.", - status: "locked", - maxHp: 500_000, - currentHp: 500_000, - damagePerSecond: 220, - goldReward: 5_000_000, - essenceReward: 3_500, - crystalReward: 100, - upgradeRewards: ["dragon_rider_1"], - equipmentRewards: [], + equipmentRewards: [], + essenceReward: 3500, + goldReward: 5_000_000, + id: "frost_wyrm", + maxHp: 500_000, + name: "The Frost Wyrm", prestigeRequirement: 0, - zoneId: "frozen_peaks", - bountyRunestones: 8, + status: "locked", + upgradeRewards: [ "dragon_rider_1" ], + zoneId: "frozen_peaks", }, { - id: "ice_queen", - name: "The Ice Queen", + bountyRunestones: 12, + crystalReward: 250, + currentHp: 1_500_000, + damagePerSecond: 500, description: "A sorceress who made a pact with the winter itself and was transformed into something no longer mortal. She rules the Frozen Peaks from a palace of living ice.", - status: "locked", - maxHp: 1_500_000, - currentHp: 1_500_000, - damagePerSecond: 500, - goldReward: 15_000_000, - essenceReward: 8_000, - crystalReward: 250, - upgradeRewards: ["void_walker_1"], - equipmentRewards: ["frost_crystal"], + equipmentRewards: [ "frost_crystal" ], + essenceReward: 8000, + goldReward: 15_000_000, + id: "ice_queen", + maxHp: 1_500_000, + name: "The Ice Queen", prestigeRequirement: 0, - zoneId: "frozen_peaks", - bountyRunestones: 12, + status: "locked", + upgradeRewards: [ "void_walker_1" ], + zoneId: "frozen_peaks", }, { - id: "void_titan", - name: "The Void Titan", + bountyRunestones: 15, + crystalReward: 500, + currentHp: 5_000_000, + damagePerSecond: 1200, description: "A creature from beyond the veil of reality, drawn by the power your guild has accumulated. It must not be allowed to exist.", - status: "locked", - maxHp: 5_000_000, - currentHp: 5_000_000, - damagePerSecond: 1_200, - goldReward: 50_000_000, - essenceReward: 20_000, - crystalReward: 500, - upgradeRewards: [], - equipmentRewards: ["philosophers_stone"], + equipmentRewards: [ "philosophers_stone" ], + essenceReward: 20_000, + goldReward: 50_000_000, + id: "void_titan", + maxHp: 5_000_000, + name: "The Void Titan", prestigeRequirement: 0, - zoneId: "frozen_peaks", - bountyRunestones: 15, + status: "locked", + upgradeRewards: [], + zoneId: "frozen_peaks", }, // ── Volcanic Depths ─────────────────────────────────────────────────────── { - id: "fire_elemental", - name: "The Ancient Fire Elemental", + bountyRunestones: 12, + crystalReward: 150, + currentHp: 1_000_000, + damagePerSecond: 400, description: "Born from the first volcanic eruption the world ever knew. It exists purely to consume, and your guild looks like the finest kindling it has seen in millennia.", - status: "locked", - maxHp: 1_000_000, - currentHp: 1_000_000, - damagePerSecond: 400, - goldReward: 10_000_000, - essenceReward: 6_000, - crystalReward: 150, - upgradeRewards: ["celestial_guard_1"], - equipmentRewards: ["flame_lance"], + equipmentRewards: [ "flame_lance" ], + essenceReward: 6000, + goldReward: 10_000_000, + id: "fire_elemental", + maxHp: 1_000_000, + name: "The Ancient Fire Elemental", prestigeRequirement: 0, - zoneId: "volcanic_depths", - bountyRunestones: 12, + status: "locked", + upgradeRewards: [ "celestial_guard_1" ], + zoneId: "volcanic_depths", }, { - id: "magma_titan", - name: "The Magma Titan", + bountyRunestones: 18, + crystalReward: 400, + currentHp: 4_000_000, + damagePerSecond: 1000, description: "Half-giant, half-living volcano, this colossus was created by the fire elementals to guard their greatest forge. Every strike from its fists sends shockwaves through the earth.", - status: "locked", - maxHp: 4_000_000, - currentHp: 4_000_000, - damagePerSecond: 1_000, - goldReward: 40_000_000, - essenceReward: 15_000, - crystalReward: 400, - upgradeRewards: ["crystal_resonance"], - equipmentRewards: ["volcanic_plate"], + equipmentRewards: [ "volcanic_plate" ], + essenceReward: 15_000, + goldReward: 40_000_000, + id: "magma_titan", + maxHp: 4_000_000, + name: "The Magma Titan", prestigeRequirement: 0, - zoneId: "volcanic_depths", - bountyRunestones: 18, + status: "locked", + upgradeRewards: [ "crystal_resonance" ], + zoneId: "volcanic_depths", }, { - id: "phoenix_lord", - name: "The Phoenix Lord", + bountyRunestones: 25, + crystalReward: 800, + currentHp: 12_000_000, + damagePerSecond: 2500, description: "The apex predator of the volcanic chain — a being of pure flame that has died and reborn itself more times than recorded history. This time, it will not rise again.", - status: "locked", - maxHp: 12_000_000, - currentHp: 12_000_000, - damagePerSecond: 2_500, - goldReward: 120_000_000, - essenceReward: 40_000, - crystalReward: 800, - upgradeRewards: ["crystal_mastery"], - equipmentRewards: ["eternal_flame"], + equipmentRewards: [ "eternal_flame" ], + essenceReward: 40_000, + goldReward: 120_000_000, + id: "phoenix_lord", + maxHp: 12_000_000, + name: "The Phoenix Lord", prestigeRequirement: 0, - zoneId: "volcanic_depths", - bountyRunestones: 25, + status: "locked", + upgradeRewards: [ "crystal_mastery" ], + zoneId: "volcanic_depths", }, // ── Astral Void (original) ──────────────────────────────────────────────── { - id: "astral_wraith", - name: "The Astral Wraith", + bountyRunestones: 20, + crystalReward: 1000, + currentHp: 20_000_000, + damagePerSecond: 4000, description: "A being of pure psychic energy who has haunted the space between stars since before the world was formed. It feeds on consciousness itself.", - status: "locked", - maxHp: 20_000_000, - currentHp: 20_000_000, - damagePerSecond: 4_000, - goldReward: 200_000_000, - essenceReward: 60_000, - crystalReward: 1_000, - upgradeRewards: ["divine_champion_1"], - equipmentRewards: ["astral_robe"], + equipmentRewards: [ "astral_robe" ], + essenceReward: 60_000, + goldReward: 200_000_000, + id: "astral_wraith", + maxHp: 20_000_000, + name: "The Astral Wraith", prestigeRequirement: 0, - zoneId: "astral_void", - bountyRunestones: 20, + status: "locked", + upgradeRewards: [ "divine_champion_1" ], + zoneId: "astral_void", }, { - id: "cosmic_horror", - name: "The Cosmic Horror", + bountyRunestones: 30, + crystalReward: 2500, + currentHp: 75_000_000, + damagePerSecond: 10_000, description: "A god-thing from before the age of mortals. Its true form cannot be perceived without madness — what you see is a mercy granted by the universe itself.", - status: "locked", - maxHp: 75_000_000, - currentHp: 75_000_000, - damagePerSecond: 10_000, - goldReward: 750_000_000, - essenceReward: 150_000, - crystalReward: 2_500, - upgradeRewards: ["crystal_focus"], - equipmentRewards: ["celestial_blade"], + equipmentRewards: [ "celestial_blade" ], + essenceReward: 150_000, + goldReward: 750_000_000, + id: "cosmic_horror", + maxHp: 75_000_000, + name: "The Cosmic Horror", prestigeRequirement: 0, - zoneId: "astral_void", - bountyRunestones: 30, + status: "locked", + upgradeRewards: [ "crystal_focus" ], + zoneId: "astral_void", }, { - id: "the_devourer", - name: "The Devourer of Worlds", + bountyRunestones: 40, + crystalReward: 10_000, + currentHp: 300_000_000, + damagePerSecond: 30_000, description: "The end. The hunger at the heart of existence that has unmade countless realities before this one. Your guild stands between it and everything that has ever lived.", - status: "locked", - maxHp: 300_000_000, - currentHp: 300_000_000, - damagePerSecond: 30_000, - goldReward: 3_000_000_000, - essenceReward: 500_000, - crystalReward: 10_000, - upgradeRewards: [], - equipmentRewards: ["infinity_gem"], + equipmentRewards: [ "infinity_gem" ], + essenceReward: 500_000, + goldReward: 3_000_000_000, + id: "the_devourer", + maxHp: 300_000_000, + name: "The Devourer of Worlds", prestigeRequirement: 0, - zoneId: "astral_void", - bountyRunestones: 40, + status: "locked", + upgradeRewards: [], + zoneId: "astral_void", }, // ── Celestial Reaches ───────────────────────────────────────────────────── { - id: "seraph_guardian", - name: "The Seraph Guardian", + bountyRunestones: 30, + crystalReward: 15_000, + currentHp: 500_000_000, + damagePerSecond: 50_000, description: "The first gatekeeper of the celestial realm — a being of pure divine light tasked with turning back anything that was not invited. It has never failed. Until now.", - status: "locked", - maxHp: 500_000_000, - currentHp: 500_000_000, - damagePerSecond: 50_000, - goldReward: 5_000_000_000, - essenceReward: 1_000_000, - crystalReward: 15_000, - upgradeRewards: ["click_4"], - equipmentRewards: ["seraph_wing"], + equipmentRewards: [ "seraph_wing" ], + essenceReward: 1_000_000, + goldReward: 5_000_000_000, + id: "seraph_guardian", + maxHp: 500_000_000, + name: "The Seraph Guardian", prestigeRequirement: 6, - zoneId: "celestial_reaches", - bountyRunestones: 30, + status: "locked", + upgradeRewards: [ "click_4" ], + zoneId: "celestial_reaches", }, { - id: "fallen_archangel", - name: "The Fallen Archangel", + bountyRunestones: 40, + crystalReward: 40_000, + currentHp: 2_000_000_000, + damagePerSecond: 120_000, description: "Once the greatest of the celestial host, cast down for questioning the divine order. Now it exists in the space between light and dark, serving neither, consumed by ancient grief.", - status: "locked", - maxHp: 2_000_000_000, - currentHp: 2_000_000_000, - damagePerSecond: 120_000, - goldReward: 20_000_000_000, - essenceReward: 3_000_000, - crystalReward: 40_000, - upgradeRewards: [], - equipmentRewards: ["angels_halo"], + equipmentRewards: [ "angels_halo" ], + essenceReward: 3_000_000, + goldReward: 20_000_000_000, + id: "fallen_archangel", + maxHp: 2_000_000_000, + name: "The Fallen Archangel", prestigeRequirement: 7, - zoneId: "celestial_reaches", - bountyRunestones: 40, + status: "locked", + upgradeRewards: [], + zoneId: "celestial_reaches", }, { - id: "divine_judge", - name: "The Divine Judge", + bountyRunestones: 50, + crystalReward: 100_000, + currentHp: 8_000_000_000, + damagePerSecond: 350_000, description: "The arbiter of celestial law, who has passed sentence on entire civilisations. It does not fight. It judges. The difference, in practice, is difficult to detect.", - status: "locked", - maxHp: 8_000_000_000, - currentHp: 8_000_000_000, - damagePerSecond: 350_000, - goldReward: 80_000_000_000, - essenceReward: 8_000_000, - crystalReward: 100_000, - upgradeRewards: ["divine_covenant"], - equipmentRewards: [], + equipmentRewards: [], + essenceReward: 8_000_000, + goldReward: 80_000_000_000, + id: "divine_judge", + maxHp: 8_000_000_000, + name: "The Divine Judge", prestigeRequirement: 8, - zoneId: "celestial_reaches", - bountyRunestones: 50, + status: "locked", + upgradeRewards: [ "divine_covenant" ], + zoneId: "celestial_reaches", }, { - id: "celestial_titan", - name: "The Celestial Titan", + bountyRunestones: 60, + crystalReward: 300_000, + currentHp: 30_000_000_000, + damagePerSecond: 1_000_000, description: "A colossus of divine architecture, built not born, maintained by the celestial host as the ultimate defence of their realm. It is the size of a small moon and has never been stopped.", - status: "locked", - maxHp: 30_000_000_000, - currentHp: 30_000_000_000, - damagePerSecond: 1_000_000, - goldReward: 300_000_000_000, - essenceReward: 25_000_000, - crystalReward: 300_000, - upgradeRewards: [], - equipmentRewards: ["celestial_armour"], + equipmentRewards: [ "celestial_armour" ], + essenceReward: 25_000_000, + goldReward: 300_000_000_000, + id: "celestial_titan", + maxHp: 30_000_000_000, + name: "The Celestial Titan", prestigeRequirement: 9, - zoneId: "celestial_reaches", - bountyRunestones: 60, + status: "locked", + upgradeRewards: [], + zoneId: "celestial_reaches", }, { - id: "the_first_light", - name: "The First Light", + bountyRunestones: 75, + crystalReward: 800_000, + currentHp: 100_000_000_000, + damagePerSecond: 3_000_000, description: "The oldest being in the celestial realm — the first light that ever shone in the universe, given form and consciousness by aeons of existence. It is not evil. It simply cannot allow anything to pass beyond.", - status: "locked", - maxHp: 100_000_000_000, - currentHp: 100_000_000_000, - damagePerSecond: 3_000_000, - goldReward: 1_000_000_000_000, - essenceReward: 80_000_000, - crystalReward: 800_000, - upgradeRewards: [], - equipmentRewards: ["divine_edge", "heaven_mantle"], + equipmentRewards: [ "divine_edge", "heaven_mantle" ], + essenceReward: 80_000_000, + goldReward: 1_000_000_000_000, + id: "the_first_light", + maxHp: 100_000_000_000, + name: "The First Light", prestigeRequirement: 10, - zoneId: "celestial_reaches", - bountyRunestones: 75, + status: "locked", + upgradeRewards: [], + zoneId: "celestial_reaches", }, // ── Abyssal Trench ──────────────────────────────────────────────────────── { - id: "depth_leviathan", - name: "The Depth Leviathan", + bountyRunestones: 40, + crystalReward: 1_500_000, + currentHp: 250_000_000_000, + damagePerSecond: 5_000_000, description: "A serpent of impossible size that has wound itself through the trench since before the ocean above it existed. It feeds on light itself — there is none here to sustain it, so it feeds on whatever dares to enter.", - status: "locked", - maxHp: 250_000_000_000, - currentHp: 250_000_000_000, - damagePerSecond: 5_000_000, - goldReward: 2_500_000_000_000, - essenceReward: 200_000_000, - crystalReward: 1_500_000, - upgradeRewards: [], - equipmentRewards: ["depth_blade"], + equipmentRewards: [ "depth_blade" ], + essenceReward: 200_000_000, + goldReward: 2_500_000_000_000, + id: "depth_leviathan", + maxHp: 250_000_000_000, + name: "The Depth Leviathan", prestigeRequirement: 9, - zoneId: "abyssal_trench", - bountyRunestones: 40, + status: "locked", + upgradeRewards: [], + zoneId: "abyssal_trench", }, { - id: "kraken_elder", - name: "The Elder Kraken", + bountyRunestones: 55, + crystalReward: 4_000_000, + currentHp: 1_000_000_000_000, + damagePerSecond: 15_000_000, description: "The original kraken — the progenitor of every tentacled horror your guild has ever faced. Its children rule the surface seas. It rules the place where even the sea forgets itself.", - status: "locked", - maxHp: 1_000_000_000_000, - currentHp: 1_000_000_000_000, - damagePerSecond: 15_000_000, - goldReward: 10_000_000_000_000, - essenceReward: 600_000_000, - crystalReward: 4_000_000, - upgradeRewards: ["abyssal_pact"], - equipmentRewards: ["leviathan_eye"], + equipmentRewards: [ "leviathan_eye" ], + essenceReward: 600_000_000, + goldReward: 10_000_000_000_000, + id: "kraken_elder", + maxHp: 1_000_000_000_000, + name: "The Elder Kraken", prestigeRequirement: 10, - zoneId: "abyssal_trench", - bountyRunestones: 55, + status: "locked", + upgradeRewards: [ "abyssal_pact" ], + zoneId: "abyssal_trench", }, { - id: "abyssal_colossus", - name: "The Abyssal Colossus", + bountyRunestones: 70, + crystalReward: 12_000_000, + currentHp: 4_000_000_000_000, + damagePerSecond: 50_000_000, description: "A thing of pressure and darkness so massive it has its own gravitational pull. It was not created — it simply condensed over an eternity from the weight of everything above it.", - status: "locked", - maxHp: 4_000_000_000_000, - currentHp: 4_000_000_000_000, - damagePerSecond: 50_000_000, - goldReward: 40_000_000_000_000, - essenceReward: 2_000_000_000, - crystalReward: 12_000_000, - upgradeRewards: [], - equipmentRewards: ["pressure_plate"], + equipmentRewards: [ "pressure_plate" ], + essenceReward: 2_000_000_000, + goldReward: 40_000_000_000_000, + id: "abyssal_colossus", + maxHp: 4_000_000_000_000, + name: "The Abyssal Colossus", prestigeRequirement: 11, - zoneId: "abyssal_trench", - bountyRunestones: 70, + status: "locked", + upgradeRewards: [], + zoneId: "abyssal_trench", }, { - id: "the_deep_one", - name: "The Deep One", + bountyRunestones: 85, + crystalReward: 40_000_000, + currentHp: 15_000_000_000_000, + damagePerSecond: 150_000_000, description: "Not a creature of the trench but a god of it — worshipped by things that have never seen surface light, answered prayers for aeons. It has finally decided to meet its supplicants face to face.", - status: "locked", - maxHp: 15_000_000_000_000, - currentHp: 15_000_000_000_000, - damagePerSecond: 150_000_000, - goldReward: 150_000_000_000_000, - essenceReward: 7_000_000_000, - crystalReward: 40_000_000, - upgradeRewards: ["global_4"], - equipmentRewards: [], + equipmentRewards: [], + essenceReward: 7_000_000_000, + goldReward: 150_000_000_000_000, + id: "the_deep_one", + maxHp: 15_000_000_000_000, + name: "The Deep One", prestigeRequirement: 12, - zoneId: "abyssal_trench", - bountyRunestones: 85, + status: "locked", + upgradeRewards: [ "global_4" ], + zoneId: "abyssal_trench", }, { - id: "elder_abomination", - name: "The Elder Abomination", + bountyRunestones: 100, + crystalReward: 150_000_000, + currentHp: 50_000_000_000_000, + damagePerSecond: 500_000_000, description: "The thing that lives at the very bottom — so ancient it predates the concept of life itself. It is not alive in any way your guild understands the word. It is simply there, as it has always been, as it will always be.", - status: "locked", - maxHp: 50_000_000_000_000, - currentHp: 50_000_000_000_000, - damagePerSecond: 500_000_000, - goldReward: 500_000_000_000_000, - essenceReward: 25_000_000_000, - crystalReward: 150_000_000, - upgradeRewards: [], - equipmentRewards: ["abyssal_edge", "abyss_shroud"], + equipmentRewards: [ "abyssal_edge", "abyss_shroud" ], + essenceReward: 25_000_000_000, + goldReward: 500_000_000_000_000, + id: "elder_abomination", + maxHp: 50_000_000_000_000, + name: "The Elder Abomination", prestigeRequirement: 13, - zoneId: "abyssal_trench", - bountyRunestones: 100, + status: "locked", + upgradeRewards: [], + zoneId: "abyssal_trench", }, // ── Infernal Court ──────────────────────────────────────────────────────── { - id: "demon_prince", - name: "The Demon Prince", + bountyRunestones: 55, + crystalReward: 350_000_000, + currentHp: 120_000_000_000_000, + damagePerSecond: 800_000_000, description: "Heir to the infernal throne, who has toppled kingdoms for sport across a thousand years. He considers your guild an amusing curiosity — right up until the point he doesn't.", - status: "locked", - maxHp: 120_000_000_000_000, - currentHp: 120_000_000_000_000, - damagePerSecond: 800_000_000, - goldReward: 1_200_000_000_000_000, - essenceReward: 60_000_000_000, - crystalReward: 350_000_000, - upgradeRewards: [], - equipmentRewards: ["demon_hide"], + equipmentRewards: [ "demon_hide" ], + essenceReward: 60_000_000_000, + goldReward: 1_200_000_000_000_000, + id: "demon_prince", + maxHp: 120_000_000_000_000, + name: "The Demon Prince", prestigeRequirement: 12, - zoneId: "infernal_court", - bountyRunestones: 55, + status: "locked", + upgradeRewards: [], + zoneId: "infernal_court", }, { - id: "hellfire_titan", - name: "The Hellfire Titan", + bountyRunestones: 70, + crystalReward: 1_000_000_000, + currentHp: 500_000_000_000_000, + damagePerSecond: 2_500_000_000, description: "A construct of pure infernal energy shaped into something vast and terrible by the demon lords as a weapon of absolute last resort. It has never been deployed before. You are a first.", - status: "locked", - maxHp: 500_000_000_000_000, - currentHp: 500_000_000_000_000, - damagePerSecond: 2_500_000_000, - goldReward: 5_000_000_000_000_000, - essenceReward: 200_000_000_000, - crystalReward: 1_000_000_000, - upgradeRewards: ["celestial_mandate"], - equipmentRewards: ["hellfire_edge"], + equipmentRewards: [ "hellfire_edge" ], + essenceReward: 200_000_000_000, + goldReward: 5_000_000_000_000_000, + id: "hellfire_titan", + maxHp: 500_000_000_000_000, + name: "The Hellfire Titan", prestigeRequirement: 13, - zoneId: "infernal_court", - bountyRunestones: 70, + status: "locked", + upgradeRewards: [ "celestial_mandate" ], + zoneId: "infernal_court", }, { - id: "lord_of_sin", - name: "The Lord of Sin", + bountyRunestones: 90, + crystalReward: 3_000_000_000, + currentHp: 2_000_000_000_000_000, + damagePerSecond: 8_000_000_000, description: "Not a demon but the embodiment of all sin that has ever existed — the accumulated weight of every wrong act across all of history, given form and voice. Its voice alone is enough to break the unwary.", - status: "locked", - maxHp: 2_000_000_000_000_000, - currentHp: 2_000_000_000_000_000, - damagePerSecond: 8_000_000_000, - goldReward: 2e16, - essenceReward: 700_000_000_000, - crystalReward: 3_000_000_000, - upgradeRewards: [], - equipmentRewards: ["soul_gem"], + equipmentRewards: [ "soul_gem" ], + essenceReward: 700_000_000_000, + goldReward: 2e16, + id: "lord_of_sin", + maxHp: 2_000_000_000_000_000, + name: "The Lord of Sin", prestigeRequirement: 14, - zoneId: "infernal_court", - bountyRunestones: 90, + status: "locked", + upgradeRewards: [], + zoneId: "infernal_court", }, { - id: "infernal_sovereign", - name: "The Infernal Sovereign", + bountyRunestones: 110, + crystalReward: 10_000_000_000, + currentHp: 6_000_000_000_000_000, + damagePerSecond: 25_000_000_000, description: "The ruler of all demonic kind — not merely a king but the principle of infernal power itself. Its existence is older than the universe that contains it. Defeating it will not end the hells. It will simply change who rules them.", - status: "locked", - maxHp: 6_000_000_000_000_000, - currentHp: 6_000_000_000_000_000, - damagePerSecond: 25_000_000_000, - goldReward: 6e16, - essenceReward: 2_500_000_000_000, - crystalReward: 10_000_000_000, - upgradeRewards: ["click_5"], - equipmentRewards: [], + equipmentRewards: [], + essenceReward: 2_500_000_000_000, + goldReward: 6e16, + id: "infernal_sovereign", + maxHp: 6_000_000_000_000_000, + name: "The Infernal Sovereign", prestigeRequirement: 15, - zoneId: "infernal_court", - bountyRunestones: 110, + status: "locked", + upgradeRewards: [ "click_5" ], + zoneId: "infernal_court", }, { - id: "the_fallen", - name: "The Fallen", + bountyRunestones: 135, + crystalReward: 30_000_000_000, + currentHp: 8_000_000_000_000_000, + damagePerSecond: 80_000_000_000, description: "A being that was once something unimaginably good, corrupted so completely over so many aeons that what it has become is unrecognisable from what it was. It remembers what it lost. That is its true weapon.", - status: "locked", - maxHp: 8_000_000_000_000_000, - currentHp: 8_000_000_000_000_000, - damagePerSecond: 80_000_000_000, - goldReward: 8e16, - essenceReward: 8_000_000_000_000, - crystalReward: 30_000_000_000, - upgradeRewards: [], - equipmentRewards: ["infernal_edge", "sinslayer_aegis"], + equipmentRewards: [ "infernal_edge", "sinslayer_aegis" ], + essenceReward: 8_000_000_000_000, + goldReward: 8e16, + id: "the_fallen", + maxHp: 8_000_000_000_000_000, + name: "The Fallen", prestigeRequirement: 16, - zoneId: "infernal_court", - bountyRunestones: 135, + status: "locked", + upgradeRewards: [], + zoneId: "infernal_court", }, // ── Crystalline Spire ───────────────────────────────────────────────────── { - id: "prism_golem", - name: "The Prism Golem", + bountyRunestones: 70, + crystalReward: 8e10, + currentHp: 2e16, + damagePerSecond: 120_000_000_000, description: "A guardian of crystallised possibility — every face of it reflects a timeline in which your guild failed. There are many of those faces, and all of them are watching.", - status: "locked", - maxHp: 2e16, - currentHp: 2e16, - damagePerSecond: 120_000_000_000, - goldReward: 2e17, - essenceReward: 2e13, - crystalReward: 8e10, - upgradeRewards: [], - equipmentRewards: ["prism_blade"], + equipmentRewards: [ "prism_blade" ], + essenceReward: 2e13, + goldReward: 2e17, + id: "prism_golem", + maxHp: 2e16, + name: "The Prism Golem", prestigeRequirement: 15, - zoneId: "crystalline_spire", - bountyRunestones: 70, + status: "locked", + upgradeRewards: [], + zoneId: "crystalline_spire", }, { - id: "crystal_drake", - name: "The Crystal Drake", + bountyRunestones: 90, + crystalReward: 3e11, + currentHp: 8e16, + damagePerSecond: 4e11, description: "A dragon made entirely of living crystal, its scales each a perfect lens that focuses whatever energy strikes it into something far more lethal. It breathes light that cuts rather than burns.", - status: "locked", - maxHp: 8e16, - currentHp: 8e16, - damagePerSecond: 4e11, - goldReward: 8e17, - essenceReward: 8e13, - crystalReward: 3e11, - upgradeRewards: ["void_ascendancy"], - equipmentRewards: [], + equipmentRewards: [], + essenceReward: 8e13, + goldReward: 8e17, + id: "crystal_drake", + maxHp: 8e16, + name: "The Crystal Drake", prestigeRequirement: 16, - zoneId: "crystalline_spire", - bountyRunestones: 90, + status: "locked", + upgradeRewards: [ "void_ascendancy" ], + zoneId: "crystalline_spire", }, { - id: "the_faceted", - name: "The Faceted", + bountyRunestones: 115, + crystalReward: 1e12, + currentHp: 3e17, + damagePerSecond: 1.2e12, description: "Not a creature but a geometry — a living mathematical construct whose angles intersect realities your guild cannot perceive. Attacking it requires solving equations that have no solutions.", - status: "locked", - maxHp: 3e17, - currentHp: 3e17, - damagePerSecond: 1.2e12, - goldReward: 3e18, - essenceReward: 3e14, - crystalReward: 1e12, - upgradeRewards: [], - equipmentRewards: ["faceted_armour"], + equipmentRewards: [ "faceted_armour" ], + essenceReward: 3e14, + goldReward: 3e18, + id: "the_faceted", + maxHp: 3e17, + name: "The Faceted", prestigeRequirement: 17, - zoneId: "crystalline_spire", - bountyRunestones: 115, + status: "locked", + upgradeRewards: [], + zoneId: "crystalline_spire", }, { - id: "diamond_colossus", - name: "The Diamond Colossus", + bountyRunestones: 140, + crystalReward: 4e12, + currentHp: 1e18, + damagePerSecond: 4e12, description: "The spire's ultimate protector — a being of compressed carbon so dense it bends space around itself. Every hit your guild lands strikes something that is simultaneously everywhere in the spire at once.", - status: "locked", - maxHp: 1e18, - currentHp: 1e18, - damagePerSecond: 4e12, - goldReward: 1e19, - essenceReward: 1e15, - crystalReward: 4e12, - upgradeRewards: [], - equipmentRewards: ["prism_eye"], + equipmentRewards: [ "prism_eye" ], + essenceReward: 1e15, + goldReward: 1e19, + id: "diamond_colossus", + maxHp: 1e18, + name: "The Diamond Colossus", prestigeRequirement: 18, - zoneId: "crystalline_spire", - bountyRunestones: 140, + status: "locked", + upgradeRewards: [], + zoneId: "crystalline_spire", }, { - id: "crystal_sovereign", - name: "The Crystal Sovereign", + bountyRunestones: 175, + crystalReward: 1.5e13, + currentHp: 4e18, + damagePerSecond: 1.5e13, description: "The mind at the centre of the spire — a consciousness that has been calculating the optimal outcome for all possible futures simultaneously since before your species climbed down from the trees. It has seen every way this ends. It does not intend to let you choose your own.", - status: "locked", - maxHp: 4e18, - currentHp: 4e18, - damagePerSecond: 1.5e13, - goldReward: 4e19, - essenceReward: 4e15, - crystalReward: 1.5e13, - upgradeRewards: [], - equipmentRewards: ["crystal_sovereign_blade", "diamond_plate"], + equipmentRewards: [ "crystal_sovereign_blade", "diamond_plate" ], + essenceReward: 4e15, + goldReward: 4e19, + id: "crystal_sovereign", + maxHp: 4e18, + name: "The Crystal Sovereign", prestigeRequirement: 19, - zoneId: "crystalline_spire", - bountyRunestones: 175, + status: "locked", + upgradeRewards: [], + zoneId: "crystalline_spire", }, // ── Void Sanctum ────────────────────────────────────────────────────────── { - id: "void_herald", - name: "The Void Herald", + bountyRunestones: 90, + crystalReward: 4e13, + currentHp: 1e19, + damagePerSecond: 4e13, description: "A messenger from somewhere that has no location — sent ahead of something worse to announce the end of your guild's journey through the sanctum. It is the warning. You will not heed it.", - status: "locked", - maxHp: 1e19, - currentHp: 1e19, - damagePerSecond: 4e13, - goldReward: 1e20, - essenceReward: 1e16, - crystalReward: 4e13, - upgradeRewards: [], - equipmentRewards: ["void_annihilator"], + equipmentRewards: [ "void_annihilator" ], + essenceReward: 1e16, + goldReward: 1e20, + id: "void_herald", + maxHp: 1e19, + name: "The Void Herald", prestigeRequirement: 18, - zoneId: "void_sanctum", - bountyRunestones: 90, + status: "locked", + upgradeRewards: [], + zoneId: "void_sanctum", }, { - id: "eternal_shade", - name: "The Eternal Shade", + bountyRunestones: 115, + crystalReward: 1.5e14, + currentHp: 5e19, + damagePerSecond: 1.5e14, description: "A shadow that has outlived every light that ever cast it. It moves between moments rather than through space, and every time your guild thinks they have found it, what they have found is where it was.", - status: "locked", - maxHp: 5e19, - currentHp: 5e19, - damagePerSecond: 1.5e14, - goldReward: 5e20, - essenceReward: 5e16, - crystalReward: 1.5e14, - upgradeRewards: ["divine_harmony"], - equipmentRewards: ["eternal_shroud"], + equipmentRewards: [ "eternal_shroud" ], + essenceReward: 5e16, + goldReward: 5e20, + id: "eternal_shade", + maxHp: 5e19, + name: "The Eternal Shade", prestigeRequirement: 19, - zoneId: "void_sanctum", - bountyRunestones: 115, + status: "locked", + upgradeRewards: [ "divine_harmony" ], + zoneId: "void_sanctum", }, { - id: "the_unmaker", - name: "The Unmaker", + bountyRunestones: 145, + crystalReward: 5e14, + currentHp: 2e20, + damagePerSecond: 5e14, description: "The force of entropy given singular purpose and form — the thing that will, eventually, unmake everything. It is not here early. It is simply here now, because your guild reached it.", - status: "locked", - maxHp: 2e20, - currentHp: 2e20, - damagePerSecond: 5e14, - goldReward: 2e21, - essenceReward: 2e17, - crystalReward: 5e14, - upgradeRewards: [], - equipmentRewards: [], + equipmentRewards: [], + essenceReward: 2e17, + goldReward: 2e21, + id: "the_unmaker", + maxHp: 2e20, + name: "The Unmaker", prestigeRequirement: 20, - zoneId: "void_sanctum", - bountyRunestones: 145, + status: "locked", + upgradeRewards: [], + zoneId: "void_sanctum", }, { - id: "void_progenitor", - name: "The Void Progenitor", + bountyRunestones: 180, + crystalReward: 2e15, + currentHp: 8e20, + damagePerSecond: 2e15, description: "The being from which all void entities descend — the first thing to ever exist in the absence of existence. It cannot be understood, only survived.", - status: "locked", - maxHp: 8e20, - currentHp: 8e20, - damagePerSecond: 2e15, - goldReward: 8e21, - essenceReward: 8e17, - crystalReward: 2e15, - upgradeRewards: [], - equipmentRewards: ["void_heart_gem"], + equipmentRewards: [ "void_heart_gem" ], + essenceReward: 8e17, + goldReward: 8e21, + id: "void_progenitor", + maxHp: 8e20, + name: "The Void Progenitor", prestigeRequirement: 21, - zoneId: "void_sanctum", - bountyRunestones: 180, + status: "locked", + upgradeRewards: [], + zoneId: "void_sanctum", }, { - id: "void_emperor", - name: "The Void Emperor", + bountyRunestones: 225, + crystalReward: 8e15, + currentHp: 3e21, + damagePerSecond: 8e15, description: "The sovereign of nothing — ruler of all void, all absence, all the space between things. It does not want your guild dead. It simply wants everything to return to the state it was in before existence began.", - status: "locked", - maxHp: 3e21, - currentHp: 3e21, - damagePerSecond: 8e15, - goldReward: 3e22, - essenceReward: 3e18, - crystalReward: 8e15, - upgradeRewards: [], - equipmentRewards: ["sanctum_breaker", "void_emperor_plate"], + equipmentRewards: [ "sanctum_breaker", "void_emperor_plate" ], + essenceReward: 3e18, + goldReward: 3e22, + id: "void_emperor", + maxHp: 3e21, + name: "The Void Emperor", prestigeRequirement: 22, - zoneId: "void_sanctum", - bountyRunestones: 225, + status: "locked", + upgradeRewards: [], + zoneId: "void_sanctum", }, // ── Eternal Throne ──────────────────────────────────────────────────────── { - id: "throne_warden", - name: "The Throne Warden", + bountyRunestones: 115, + crystalReward: 2e16, + currentHp: 1e22, + damagePerSecond: 2e16, description: "The guardian of the approach to the eternal throne — a being of absolute authority who has turned back every challenger to the seat of power since the first moment the throne existed.", - status: "locked", - maxHp: 1e22, - currentHp: 1e22, - damagePerSecond: 2e16, - goldReward: 1e23, - essenceReward: 1e19, - crystalReward: 2e16, - upgradeRewards: [], - equipmentRewards: ["eternal_armour"], + equipmentRewards: [ "eternal_armour" ], + essenceReward: 1e19, + goldReward: 1e23, + id: "throne_warden", + maxHp: 1e22, + name: "The Throne Warden", prestigeRequirement: 21, - zoneId: "eternal_throne", - bountyRunestones: 115, + status: "locked", + upgradeRewards: [], + zoneId: "eternal_throne", }, { - id: "eternal_knight", - name: "The Eternal Knight", + bountyRunestones: 150, + crystalReward: 8e16, + currentHp: 5e22, + damagePerSecond: 8e16, description: "A champion who has served the throne since before the concept of service existed. It has never been defeated. It has faced challengers from a hundred dead universes and sent every one of them back to nothing.", - status: "locked", - maxHp: 5e22, - currentHp: 5e22, - damagePerSecond: 8e16, - goldReward: 5e23, - essenceReward: 5e19, - crystalReward: 8e16, - upgradeRewards: ["infernal_fury"], - equipmentRewards: ["throne_blade"], + equipmentRewards: [ "throne_blade" ], + essenceReward: 5e19, + goldReward: 5e23, + id: "eternal_knight", + maxHp: 5e22, + name: "The Eternal Knight", prestigeRequirement: 22, - zoneId: "eternal_throne", - bountyRunestones: 150, + status: "locked", + upgradeRewards: [ "infernal_fury" ], + zoneId: "eternal_throne", }, { - id: "the_undying", - name: "The Undying", + bountyRunestones: 190, + crystalReward: 3e17, + currentHp: 2e23, + damagePerSecond: 3e17, description: "A being for whom death is not a possibility but a suggestion it has declined across every moment of existence. Your guild will need to convince it to consider the option for the very first time.", - status: "locked", - maxHp: 2e23, - currentHp: 2e23, - damagePerSecond: 3e17, - goldReward: 2e24, - essenceReward: 2e20, - crystalReward: 3e17, - upgradeRewards: [], - equipmentRewards: [], + equipmentRewards: [], + essenceReward: 2e20, + goldReward: 2e24, + id: "the_undying", + maxHp: 2e23, + name: "The Undying", prestigeRequirement: 23, - zoneId: "eternal_throne", - bountyRunestones: 190, + status: "locked", + upgradeRewards: [], + zoneId: "eternal_throne", }, { - id: "apex_sovereign", - name: "The Apex Sovereign", + bountyRunestones: 235, + crystalReward: 1.2e18, + currentHp: 8e23, + damagePerSecond: 1.2e18, description: "The penultimate guardian of the throne — a being so close to the absolute seat of power that it has absorbed some of its nature. Reality warps around it. Your guild must hold together through forces that want to unmake them at the atomic level.", - status: "locked", - maxHp: 8e23, - currentHp: 8e23, - damagePerSecond: 1.2e18, - goldReward: 8e24, - essenceReward: 8e20, - crystalReward: 1.2e18, - upgradeRewards: [], - equipmentRewards: [], + equipmentRewards: [], + essenceReward: 8e20, + goldReward: 8e24, + id: "apex_sovereign", + maxHp: 8e23, + name: "The Apex Sovereign", prestigeRequirement: 24, - zoneId: "eternal_throne", - bountyRunestones: 235, + status: "locked", + upgradeRewards: [], + zoneId: "eternal_throne", }, { - id: "the_apex", - name: "The Apex", + bountyRunestones: 295, + crystalReward: 5e18, + currentHp: 3e24, + damagePerSecond: 5e18, description: "The one who sits upon the Eternal Throne. They have no name because names are given by others, and there has never been another to give one. They are the beginning and the end of all authority. And now your guild has come to take everything they are.", - status: "locked", - maxHp: 3e24, - currentHp: 3e24, - damagePerSecond: 5e18, - goldReward: 3e25, - essenceReward: 3e21, - crystalReward: 5e18, - upgradeRewards: [], - equipmentRewards: ["apex_sword", "apex_plate", "eternity_stone"], + equipmentRewards: [ "apex_sword", "apex_plate", "eternity_stone" ], + essenceReward: 3e21, + goldReward: 3e25, + id: "the_apex", + maxHp: 3e24, + name: "The Apex", prestigeRequirement: 25, - zoneId: "eternal_throne", - bountyRunestones: 295, + status: "locked", + upgradeRewards: [], + zoneId: "eternal_throne", }, // ── Primordial Chaos ────────────────────────────────────────────────────── { - id: "chaos_wyrm", - name: "The Chaos Wyrm", + bountyRunestones: 150, + crystalReward: 2e20, + currentHp: 1e26, + damagePerSecond: 2e20, description: "A serpent of pure unformed potential, writhing through pre-creation. Every movement reshapes the chaos around it. Its scales are made of possibilities that never resolved.", - status: "locked", - maxHp: 1e26, - currentHp: 1e26, - damagePerSecond: 2e20, - goldReward: 1e27, - essenceReward: 1e23, - crystalReward: 2e20, - upgradeRewards: [], - equipmentRewards: [], + equipmentRewards: [], + essenceReward: 1e23, + goldReward: 1e27, + id: "chaos_wyrm", + maxHp: 1e26, + name: "The Chaos Wyrm", prestigeRequirement: 26, - zoneId: "primordial_chaos", - bountyRunestones: 150, + status: "locked", + upgradeRewards: [], + zoneId: "primordial_chaos", }, { - id: "creation_engine", - name: "The Creation Engine", + bountyRunestones: 200, + crystalReward: 8e21, + currentHp: 5e27, + damagePerSecond: 8e21, description: "Not alive — a mechanism of the chaos, producing and destroying matter in endless cycles. It has no awareness of your guild. That makes it no less lethal.", - status: "locked", - maxHp: 5e27, - currentHp: 5e27, - damagePerSecond: 8e21, - goldReward: 5e28, - essenceReward: 5e24, - crystalReward: 8e21, - upgradeRewards: ["aether_weaver_1"], - equipmentRewards: [], + equipmentRewards: [], + essenceReward: 5e24, + goldReward: 5e28, + id: "creation_engine", + maxHp: 5e27, + name: "The Creation Engine", prestigeRequirement: 27, - zoneId: "primordial_chaos", - bountyRunestones: 200, + status: "locked", + upgradeRewards: [ "aether_weaver_1" ], + zoneId: "primordial_chaos", }, { - id: "entropy_avatar", - name: "The Entropy Avatar", + bountyRunestones: 265, + crystalReward: 4e23, + currentHp: 2e29, + damagePerSecond: 4e23, description: "A fragment of the force that will eventually end everything — visiting the chaos early, as it always does, to watch things fall apart. Your guild is an interesting disruption to its observations.", - status: "locked", - maxHp: 2e29, - currentHp: 2e29, - damagePerSecond: 4e23, - goldReward: 2e30, - essenceReward: 2e26, - crystalReward: 4e23, - upgradeRewards: [], - equipmentRewards: [], + equipmentRewards: [], + essenceReward: 2e26, + goldReward: 2e30, + id: "entropy_avatar", + maxHp: 2e29, + name: "The Entropy Avatar", prestigeRequirement: 29, - zoneId: "primordial_chaos", - bountyRunestones: 265, + status: "locked", + upgradeRewards: [], + zoneId: "primordial_chaos", }, { - id: "primordial_titan", - name: "The Primordial Titan", + bountyRunestones: 350, + crystalReward: 2e25, + currentHp: 8e30, + damagePerSecond: 2e25, description: "The first and largest thing to coalesce from the chaos — a being of pure unordered power that predates every law of physics your guild has ever relied upon. Defeating it will require those laws to hold long enough.", - status: "locked", - maxHp: 8e30, - currentHp: 8e30, - damagePerSecond: 2e25, - goldReward: 8e31, - essenceReward: 8e27, - crystalReward: 2e25, - upgradeRewards: [], - equipmentRewards: ["chaos_mantle", "titan_core"], + equipmentRewards: [ "chaos_mantle", "titan_core" ], + essenceReward: 8e27, + goldReward: 8e31, + id: "primordial_titan", + maxHp: 8e30, + name: "The Primordial Titan", prestigeRequirement: 31, - zoneId: "primordial_chaos", - bountyRunestones: 350, + status: "locked", + upgradeRewards: [], + zoneId: "primordial_chaos", }, // ── Infinite Expanse ────────────────────────────────────────────────────── { - id: "expanse_drifter", - name: "The Expanse Drifter", + bountyRunestones: 200, + crystalReward: 8e27, + currentHp: 3e33, + damagePerSecond: 8e27, description: "Something vast that has been travelling the Infinite Expanse for so long that it has forgotten what it was looking for. Your guild is the first thing it has encountered that was worth stopping for.", - status: "locked", - maxHp: 3e33, - currentHp: 3e33, - damagePerSecond: 8e27, - goldReward: 3e34, - essenceReward: 3e30, - crystalReward: 8e27, - upgradeRewards: ["titan_warrior_1"], - equipmentRewards: [], + equipmentRewards: [], + essenceReward: 3e30, + goldReward: 3e34, + id: "expanse_drifter", + maxHp: 3e33, + name: "The Expanse Drifter", prestigeRequirement: 33, - zoneId: "infinite_expanse", - bountyRunestones: 200, + status: "locked", + upgradeRewards: [ "titan_warrior_1" ], + zoneId: "infinite_expanse", }, { - id: "horizon_beast", - name: "The Horizon Beast", + bountyRunestones: 265, + crystalReward: 3e31, + currentHp: 1e37, + damagePerSecond: 3e31, description: "A creature as wide as the observable universe — which, in the Expanse, is not a helpful measurement. It is simply everywhere the horizon is, which in this place is everywhere.", - status: "locked", - maxHp: 1e37, - currentHp: 1e37, - damagePerSecond: 3e31, - goldReward: 1e38, - essenceReward: 1e34, - crystalReward: 3e31, - upgradeRewards: [], - equipmentRewards: [], + equipmentRewards: [], + essenceReward: 1e34, + goldReward: 1e38, + id: "horizon_beast", + maxHp: 1e37, + name: "The Horizon Beast", prestigeRequirement: 35, - zoneId: "infinite_expanse", - bountyRunestones: 265, + status: "locked", + upgradeRewards: [], + zoneId: "infinite_expanse", }, { - id: "infinity_construct", - name: "The Infinity Construct", + bountyRunestones: 350, + crystalReward: 1e35, + currentHp: 5e40, + damagePerSecond: 1e35, description: "A self-replicating intelligence that has filled the Expanse with copies of itself. Every copy has the same purpose: to be the last thing in the Expanse. Your guild will need to convince all of them otherwise.", - status: "locked", - maxHp: 5e40, - currentHp: 5e40, - damagePerSecond: 1e35, - goldReward: 5e41, - essenceReward: 5e37, - crystalReward: 1e35, - upgradeRewards: [], - equipmentRewards: [], + equipmentRewards: [], + essenceReward: 5e37, + goldReward: 5e41, + id: "infinity_construct", + maxHp: 5e40, + name: "The Infinity Construct", prestigeRequirement: 37, - zoneId: "infinite_expanse", - bountyRunestones: 350, + status: "locked", + upgradeRewards: [], + zoneId: "infinite_expanse", }, { - id: "expanse_sovereign", - name: "The Expanse Sovereign", + bountyRunestones: 465, + crystalReward: 5e38, + currentHp: 2e44, + damagePerSecond: 5e38, description: "The thing that claims the Infinite Expanse as its territory — which, given the name of the place, is an ambitious claim. It enforces this claim with power that has had infinite space to accumulate.", - status: "locked", - maxHp: 2e44, - currentHp: 2e44, - damagePerSecond: 5e38, - goldReward: 2e45, - essenceReward: 2e41, - crystalReward: 5e38, - upgradeRewards: [], - equipmentRewards: ["expanse_blade", "void_armour_mk2"], + equipmentRewards: [ "expanse_blade", "void_armour_mk2" ], + essenceReward: 2e41, + goldReward: 2e45, + id: "expanse_sovereign", + maxHp: 2e44, + name: "The Expanse Sovereign", prestigeRequirement: 39, - zoneId: "infinite_expanse", - bountyRunestones: 465, + status: "locked", + upgradeRewards: [], + zoneId: "infinite_expanse", }, // ── Reality Forge ───────────────────────────────────────────────────────── { - id: "forge_guardian", - name: "The Forge Guardian", + bountyRunestones: 265, + crystalReward: 2e42, + currentHp: 8e47, + damagePerSecond: 2e42, description: "A creation of the Forge itself — something that was made to protect the making of things. It has never had to do this before. It finds it straightforward.", - status: "locked", - maxHp: 8e47, - currentHp: 8e47, - damagePerSecond: 2e42, - goldReward: 8e48, - essenceReward: 8e44, - crystalReward: 2e42, - upgradeRewards: ["nexus_sage_1"], - equipmentRewards: [], + equipmentRewards: [], + essenceReward: 8e44, + goldReward: 8e48, + id: "forge_guardian", + maxHp: 8e47, + name: "The Forge Guardian", prestigeRequirement: 41, - zoneId: "reality_forge", - bountyRunestones: 265, + status: "locked", + upgradeRewards: [ "nexus_sage_1" ], + zoneId: "reality_forge", }, { - id: "reality_shaper", - name: "The Reality Shaper", + bountyRunestones: 350, + crystalReward: 1e47, + currentHp: 4e52, + damagePerSecond: 1e47, description: "One of the workers of the Forge — a being whose purpose is to take raw existence and hammer it into something coherent. It does not appreciate your guild's interruption of its work.", - status: "locked", - maxHp: 4e52, - currentHp: 4e52, - damagePerSecond: 1e47, - goldReward: 4e53, - essenceReward: 4e49, - crystalReward: 1e47, - upgradeRewards: [], - equipmentRewards: [], + equipmentRewards: [], + essenceReward: 4e49, + goldReward: 4e53, + id: "reality_shaper", + maxHp: 4e52, + name: "The Reality Shaper", prestigeRequirement: 44, - zoneId: "reality_forge", - bountyRunestones: 350, + status: "locked", + upgradeRewards: [], + zoneId: "reality_forge", }, { - id: "creation_prime", - name: "The Creation Prime", + bountyRunestones: 465, + crystalReward: 6e51, + currentHp: 2e57, + damagePerSecond: 6e51, description: "The first worker, the original builder — the thing that shaped the template every universe since has been based on. It has been refining the template since before time. Your guild is not part of the template.", - status: "locked", - maxHp: 2e57, - currentHp: 2e57, - damagePerSecond: 6e51, - goldReward: 2e58, - essenceReward: 2e54, - crystalReward: 6e51, - upgradeRewards: [], - equipmentRewards: [], + equipmentRewards: [], + essenceReward: 2e54, + goldReward: 2e58, + id: "creation_prime", + maxHp: 2e57, + name: "The Creation Prime", prestigeRequirement: 47, - zoneId: "reality_forge", - bountyRunestones: 465, + status: "locked", + upgradeRewards: [], + zoneId: "reality_forge", }, { - id: "reality_architect", - name: "The Reality Architect", + bountyRunestones: 615, + crystalReward: 2e56, + currentHp: 8e61, + damagePerSecond: 2e56, description: "The designer of all that exists — the being who decided what the rules would be. Every law of physics is its handwriting. Defeating it will not change the laws, but it will change the architect.", - status: "locked", - maxHp: 8e61, - currentHp: 8e61, - damagePerSecond: 2e56, - goldReward: 8e62, - essenceReward: 8e58, - crystalReward: 2e56, - upgradeRewards: [], - equipmentRewards: ["cosmos_blade", "reality_plate"], + equipmentRewards: [ "cosmos_blade", "reality_plate" ], + essenceReward: 8e58, + goldReward: 8e62, + id: "reality_architect", + maxHp: 8e61, + name: "The Reality Architect", prestigeRequirement: 49, - zoneId: "reality_forge", - bountyRunestones: 615, + status: "locked", + upgradeRewards: [], + zoneId: "reality_forge", }, // ── Cosmic Maelstrom ────────────────────────────────────────────────────── { - id: "storm_colossus", - name: "The Storm Colossus", + bountyRunestones: 350, + crystalReward: 1e60, + currentHp: 4e65, + damagePerSecond: 1e60, description: "A being born from the intersection of all cosmic forces — not created, simply precipitated out of the violence as inevitably as lightning from a storm cloud. It has been raging since the universe learned what force was.", - status: "locked", - maxHp: 4e65, - currentHp: 4e65, - damagePerSecond: 1e60, - goldReward: 4e66, - essenceReward: 4e62, - crystalReward: 1e60, - upgradeRewards: ["cosmos_knight_1"], - equipmentRewards: [], + equipmentRewards: [], + essenceReward: 4e62, + goldReward: 4e66, + id: "storm_colossus", + maxHp: 4e65, + name: "The Storm Colossus", prestigeRequirement: 51, - zoneId: "cosmic_maelstrom", - bountyRunestones: 350, + status: "locked", + upgradeRewards: [ "cosmos_knight_1" ], + zoneId: "cosmic_maelstrom", }, { - id: "force_prime", - name: "The Force Prime", + bountyRunestones: 465, + crystalReward: 6e65, + currentHp: 2e71, + damagePerSecond: 6e65, description: "The ur-force from which all other forces derived their nature. Gravity, electromagnetism, the nuclear forces — all are pale echoes of what this being embodies. Your guild will feel all of them at once.", - status: "locked", - maxHp: 2e71, - currentHp: 2e71, - damagePerSecond: 6e65, - goldReward: 2e72, - essenceReward: 2e68, - crystalReward: 6e65, - upgradeRewards: [], - equipmentRewards: [], + equipmentRewards: [], + essenceReward: 2e68, + goldReward: 2e72, + id: "force_prime", + maxHp: 2e71, + name: "The Force Prime", prestigeRequirement: 54, - zoneId: "cosmic_maelstrom", - bountyRunestones: 465, + status: "locked", + upgradeRewards: [], + zoneId: "cosmic_maelstrom", }, { - id: "maelstrom_god", - name: "The Maelstrom God", + bountyRunestones: 615, + crystalReward: 3e71, + currentHp: 1e77, + damagePerSecond: 3e71, description: "The deity of devastation — the divine principle that ensures the universe never becomes too comfortable. It was responsible for every catastrophe that has ever reshaped a world. Your guild is its latest project.", - status: "locked", - maxHp: 1e77, - currentHp: 1e77, - damagePerSecond: 3e71, - goldReward: 1e78, - essenceReward: 1e74, - crystalReward: 3e71, - upgradeRewards: [], - equipmentRewards: [], + equipmentRewards: [], + essenceReward: 1e74, + goldReward: 1e78, + id: "maelstrom_god", + maxHp: 1e77, + name: "The Maelstrom God", prestigeRequirement: 57, - zoneId: "cosmic_maelstrom", - bountyRunestones: 615, + status: "locked", + upgradeRewards: [], + zoneId: "cosmic_maelstrom", }, { - id: "cosmic_annihilator", - name: "The Cosmic Annihilator", + bountyRunestones: 815, + crystalReward: 1e77, + currentHp: 5e82, + damagePerSecond: 1e77, description: "The counterpart to the Reality Architect — not a destroyer but a pruner, removing the universes that failed to meet the Architect's standards. It is very good at its job, and your universe has been on its list for some time.", - status: "locked", - maxHp: 5e82, - currentHp: 5e82, - damagePerSecond: 1e77, - goldReward: 5e83, - essenceReward: 5e79, - crystalReward: 1e77, - upgradeRewards: [], - equipmentRewards: ["maelstrom_edge", "cosmic_plate"], + equipmentRewards: [ "maelstrom_edge", "cosmic_plate" ], + essenceReward: 5e79, + goldReward: 5e83, + id: "cosmic_annihilator", + maxHp: 5e82, + name: "The Cosmic Annihilator", prestigeRequirement: 59, - zoneId: "cosmic_maelstrom", - bountyRunestones: 815, + status: "locked", + upgradeRewards: [], + zoneId: "cosmic_maelstrom", }, // ── Primeval Sanctum ────────────────────────────────────────────────────── { - id: "ancient_sentinel", - name: "The Ancient Sentinel", + bountyRunestones: 465, + crystalReward: 5e82, + currentHp: 2e88, + damagePerSecond: 5e82, description: "A guardian placed here before memory — before the concept of guarding existed, placed by something that knew guardians would eventually be needed. It has been waiting with perfect patience.", - status: "locked", - maxHp: 2e88, - currentHp: 2e88, - damagePerSecond: 5e82, - goldReward: 2e89, - essenceReward: 2e85, - crystalReward: 5e82, - upgradeRewards: ["astral_sovereign_1"], - equipmentRewards: [], + equipmentRewards: [], + essenceReward: 2e85, + goldReward: 2e89, + id: "ancient_sentinel", + maxHp: 2e88, + name: "The Ancient Sentinel", prestigeRequirement: 61, - zoneId: "primeval_sanctum", - bountyRunestones: 465, + status: "locked", + upgradeRewards: [ "astral_sovereign_1" ], + zoneId: "primeval_sanctum", }, { - id: "time_elder", - name: "The Time Elder", + bountyRunestones: 615, + crystalReward: 3e89, + currentHp: 1e95, + damagePerSecond: 3e89, description: "The oldest living thing — living by a definition so broad it encompasses states your guild cannot recognise as life. It has observed every moment from the beginning and finds your guild mildly interesting by comparison.", - status: "locked", - maxHp: 1e95, - currentHp: 1e95, - damagePerSecond: 3e89, - goldReward: 1e96, - essenceReward: 1e92, - crystalReward: 3e89, - upgradeRewards: [], - equipmentRewards: [], + equipmentRewards: [], + essenceReward: 1e92, + goldReward: 1e96, + id: "time_elder", + maxHp: 1e95, + name: "The Time Elder", prestigeRequirement: 65, - zoneId: "primeval_sanctum", - bountyRunestones: 615, + status: "locked", + upgradeRewards: [], + zoneId: "primeval_sanctum", }, { - id: "origin_beast", - name: "The Origin Beast", + bountyRunestones: 815, + crystalReward: 2e96, + currentHp: 8e101, + damagePerSecond: 2e96, description: "The creature that was present at the first moment — not because it was created then, but because it was always there, before the universe caught up to it. It has been here since before here existed.", - status: "locked", - maxHp: 8e101, - currentHp: 8e101, - damagePerSecond: 2e96, - goldReward: 8e102, - essenceReward: 8e98, - crystalReward: 2e96, - upgradeRewards: [], - equipmentRewards: [], + equipmentRewards: [], + essenceReward: 8e98, + goldReward: 8e102, + id: "origin_beast", + maxHp: 8e101, + name: "The Origin Beast", prestigeRequirement: 69, - zoneId: "primeval_sanctum", - bountyRunestones: 815, + status: "locked", + upgradeRewards: [], + zoneId: "primeval_sanctum", }, { - id: "primeval_god", - name: "The Primeval God", + bountyRunestones: 1080, + crystalReward: 1e103, + currentHp: 5e108, + damagePerSecond: 1e103, description: "Not a god that was worshipped — a god that simply is, regardless of worship. It does not require belief to exist. It exists prior to the ability to believe or disbelieve in anything.", - status: "locked", - maxHp: 5e108, - currentHp: 5e108, - damagePerSecond: 1e103, - goldReward: 5e109, - essenceReward: 5e105, - crystalReward: 1e103, - upgradeRewards: [], - equipmentRewards: ["primeval_blade", "ancient_aegis"], + equipmentRewards: [ "primeval_blade", "ancient_aegis" ], + essenceReward: 5e105, + goldReward: 5e109, + id: "primeval_god", + maxHp: 5e108, + name: "The Primeval God", prestigeRequirement: 74, - zoneId: "primeval_sanctum", - bountyRunestones: 1080, + status: "locked", + upgradeRewards: [], + zoneId: "primeval_sanctum", }, // ── The Absolute ────────────────────────────────────────────────────────── { - id: "absolute_herald", - name: "The Absolute Herald", + bountyRunestones: 615, + crystalReward: 5e110, + currentHp: 2e116, + damagePerSecond: 5e110, description: "The announcement of finality — not a creature but the moment before the last moment, given agency. It is here to tell your guild that this is where everything ends. Your guild declines to accept the announcement.", - status: "locked", - maxHp: 2e116, - currentHp: 2e116, - damagePerSecond: 5e110, - goldReward: 2e117, - essenceReward: 2e113, - crystalReward: 5e110, - upgradeRewards: ["primordial_mage_1"], - equipmentRewards: [], + equipmentRewards: [], + essenceReward: 2e113, + goldReward: 2e117, + id: "absolute_herald", + maxHp: 2e116, + name: "The Absolute Herald", prestigeRequirement: 76, - zoneId: "the_absolute", - bountyRunestones: 615, + status: "locked", + upgradeRewards: [ "primordial_mage_1" ], + zoneId: "the_absolute", }, { - id: "void_convergence", - name: "The Void Convergence", + bountyRunestones: 815, + crystalReward: 3e119, + currentHp: 1e125, + damagePerSecond: 3e119, description: "Every void, every absence, every nothing that has ever existed converging into a single point. The gravitational pull of absolute nothingness. Your guild must push against the pull of all that is not.", - status: "locked", - maxHp: 1e125, - currentHp: 1e125, - damagePerSecond: 3e119, - goldReward: 1e126, - essenceReward: 1e122, - crystalReward: 3e119, - upgradeRewards: [], - equipmentRewards: [], + equipmentRewards: [], + essenceReward: 1e122, + goldReward: 1e126, + id: "void_convergence", + maxHp: 1e125, + name: "The Void Convergence", prestigeRequirement: 79, - zoneId: "the_absolute", - bountyRunestones: 815, + status: "locked", + upgradeRewards: [], + zoneId: "the_absolute", }, { - id: "eternal_end", - name: "The Eternal End", + bountyRunestones: 1080, + crystalReward: 1e129, + currentHp: 5e134, + damagePerSecond: 1e129, description: "The last thing that will ever exist — visiting now, ahead of schedule, drawn by the power your guild has accumulated. It does not consider this inconvenient. Everything ends eventually. It is simply efficient.", - status: "locked", - maxHp: 5e134, - currentHp: 5e134, - damagePerSecond: 1e129, - goldReward: 5e135, - essenceReward: 5e131, - crystalReward: 1e129, - upgradeRewards: [], - equipmentRewards: [], + equipmentRewards: [], + essenceReward: 5e131, + goldReward: 5e135, + id: "eternal_end", + maxHp: 5e134, + name: "The Eternal End", prestigeRequirement: 83, - zoneId: "the_absolute", - bountyRunestones: 1080, + status: "locked", + upgradeRewards: [], + zoneId: "the_absolute", }, { - id: "the_absolute_one", - name: "The Absolute One", + bountyRunestones: 1430, + crystalReward: 5e139, + currentHp: 2e145, + damagePerSecond: 5e139, description: "Beyond description. Beyond category. The terminal point of all power, all existence, all possibility. There is nothing after this. Your guild has come to this nothing and refused it. That, in itself, is the greatest achievement in the history of anything.", - status: "locked", - maxHp: 2e145, - currentHp: 2e145, - damagePerSecond: 5e139, - goldReward: 2e146, - essenceReward: 2e142, - crystalReward: 5e139, - upgradeRewards: [], - equipmentRewards: ["absolute_blade", "eternity_plate", "omniversal_core"], + equipmentRewards: [ "absolute_blade", "eternity_plate", "omniversal_core" ], + essenceReward: 2e142, + goldReward: 2e146, + id: "the_absolute_one", + maxHp: 2e145, + name: "The Absolute One", prestigeRequirement: 90, - zoneId: "the_absolute", - bountyRunestones: 1430, + status: "locked", + upgradeRewards: [], + zoneId: "the_absolute", }, ]; diff --git a/apps/api/src/data/dailyChallenges.ts b/apps/api/src/data/dailyChallenges.ts index e54a828..1f4bb8f 100644 --- a/apps/api/src/data/dailyChallenges.ts +++ b/apps/api/src/data/dailyChallenges.ts @@ -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 = [ // 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" }, ]; diff --git a/apps/api/src/data/equipment.ts b/apps/api/src/data/equipment.ts index f07b6d2..551035b 100644 --- a/apps/api/src/data/equipment.ts +++ b/apps/api/src/data/equipment.ts @@ -1,703 +1,771 @@ +/** + * @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 { Equipment } from "@elysium/types"; -export const DEFAULT_EQUIPMENT: Equipment[] = [ +export const defaultEquipment: Array = [ // ── Weapons ─────────────────────────────────────────────────────────────── { - id: "rusty_sword", - name: "Rusty Sword", + bonus: { combatMultiplier: 1.1 }, description: "A battered blade, but still sharp enough to draw blood.", - type: "weapon", - rarity: "common", - bonus: { combatMultiplier: 1.1 }, - owned: true, - equipped: true, + equipped: true, + id: "rusty_sword", + name: "Rusty Sword", + owned: true, + rarity: "common", + type: "weapon", }, { - id: "iron_sword", - name: "Iron Sword", + bonus: { combatMultiplier: 1.25 }, description: "A sturdy weapon issued to veterans of the guild.", - type: "weapon", - rarity: "rare", - bonus: { combatMultiplier: 1.25 }, - owned: false, - equipped: false, - setId: "iron_vanguard", + equipped: false, + id: "iron_sword", + name: "Iron Sword", + owned: false, + rarity: "rare", + setId: "iron_vanguard", + type: "weapon", }, { - id: "enchanted_blade", - name: "Enchanted Blade", - description: "A sword imbued with ancient magic that makes every strike count.", - type: "weapon", - rarity: "epic", bonus: { combatMultiplier: 1.5 }, - owned: false, + description: + "A sword imbued with ancient magic that makes every strike count.", equipped: false, + id: "enchanted_blade", + name: "Enchanted Blade", + owned: false, + rarity: "epic", + type: "weapon", }, { - id: "shadow_dagger", - name: "Shadow Dagger", - description: "Forged in the Shadow Marshes from condensed darkness. It strikes before it is seen.", - type: "weapon", - rarity: "epic", bonus: { combatMultiplier: 1.65 }, - owned: false, + cost: { crystals: 0, essence: 500, gold: 0 }, + description: + "Forged in the Shadow Marshes from condensed darkness. It strikes before it is seen.", equipped: false, - cost: { gold: 0, essence: 500, crystals: 0 }, - setId: "shadow_infiltrator", + id: "shadow_dagger", + name: "Shadow Dagger", + owned: false, + rarity: "epic", + setId: "shadow_infiltrator", + type: "weapon", }, { - id: "flame_lance", - name: "Flame Lance", - description: "A spear tipped with a shard of the Primordial Forge's eternal fire.", - type: "weapon", - rarity: "epic", bonus: { combatMultiplier: 1.7 }, - owned: false, + description: + "A spear tipped with a shard of the Primordial Forge's eternal fire.", equipped: false, - setId: "volcanic_forger", + id: "flame_lance", + name: "Flame Lance", + owned: false, + rarity: "epic", + setId: "volcanic_forger", + type: "weapon", }, { - id: "vorpal_sword", - name: "Vorpal Sword", + bonus: { combatMultiplier: 2 }, description: "A legendary blade that severs even the strongest bonds.", - type: "weapon", - rarity: "legendary", - bonus: { combatMultiplier: 2.0 }, - owned: false, - equipped: false, + equipped: false, + id: "vorpal_sword", + name: "Vorpal Sword", + owned: false, + rarity: "legendary", + type: "weapon", }, { - id: "soul_reaper", - name: "Soul Reaper", - description: "A scythe that harvests not flesh but essence itself. Every swing drains the will to resist.", - type: "weapon", - rarity: "legendary", bonus: { combatMultiplier: 2.5 }, - owned: false, + cost: { crystals: 300, essence: 0, gold: 0 }, + description: + "A scythe that harvests not flesh but essence itself. Every swing drains the will to resist.", equipped: false, - cost: { gold: 0, essence: 0, crystals: 300 }, + id: "soul_reaper", + name: "Soul Reaper", + owned: false, + rarity: "legendary", + type: "weapon", }, { - id: "celestial_blade", - name: "Celestial Blade", - description: "Forged from the heart of a dying star by the Cosmic Horror itself. Its edge exists in three realities simultaneously.", - type: "weapon", - rarity: "legendary", - bonus: { combatMultiplier: 3.0 }, - owned: false, + bonus: { combatMultiplier: 3 }, + description: + "Forged from the heart of a dying star by the Cosmic Horror itself. Its edge exists in three realities simultaneously.", equipped: false, + id: "celestial_blade", + name: "Celestial Blade", + owned: false, + rarity: "legendary", + type: "weapon", }, { - id: "void_edge", - name: "Void Edge", - description: "A blade made of compressed nothingness. It does not cut — it simply unmakes.", - type: "weapon", - rarity: "legendary", bonus: { combatMultiplier: 2.75 }, - owned: false, + cost: { crystals: 500, essence: 2000, gold: 0 }, + description: + "A blade made of compressed nothingness. It does not cut — it simply unmakes.", equipped: false, - cost: { gold: 0, essence: 2_000, crystals: 500 }, + id: "void_edge", + name: "Void Edge", + owned: false, + rarity: "legendary", + type: "weapon", }, // ── Armour ──────────────────────────────────────────────────────────────── { - id: "leather_armour", - name: "Leather Armour", - description: "Simple protection that keeps your adventurers moving efficiently.", - type: "armour", - rarity: "common", bonus: { goldMultiplier: 1.1 }, - owned: true, + description: + "Simple protection that keeps your adventurers moving efficiently.", equipped: true, + id: "leather_armour", + name: "Leather Armour", + owned: true, + rarity: "common", + type: "armour", }, { - id: "chainmail", - name: "Chainmail", + bonus: { goldMultiplier: 1.25 }, description: "Interlocked rings that guard against most mundane threats.", - type: "armour", - rarity: "rare", - bonus: { goldMultiplier: 1.25 }, - owned: false, - equipped: false, - setId: "iron_vanguard", + equipped: false, + id: "chainmail", + name: "Chainmail", + owned: false, + rarity: "rare", + setId: "iron_vanguard", + type: "armour", }, { - id: "hide_armour", - name: "Giant's Hide Armour", - description: "Cured hide from a Forest Giant, worked into armour that radiates primal authority.", - type: "armour", - rarity: "rare", bonus: { goldMultiplier: 1.35 }, - owned: false, + description: + "Cured hide from a Forest Giant, worked into armour that radiates primal authority.", equipped: false, + id: "hide_armour", + name: "Giant's Hide Armour", + owned: false, + rarity: "rare", + type: "armour", }, { - id: "plate_armour", - name: "Plate Armour", + bonus: { goldMultiplier: 1.5 }, description: "Full plate protection that inspires confidence — and gold.", - type: "armour", - rarity: "epic", - bonus: { goldMultiplier: 1.5 }, - owned: false, - equipped: false, + equipped: false, + id: "plate_armour", + name: "Plate Armour", + owned: false, + rarity: "epic", + type: "armour", }, { - id: "void_shroud", - name: "Void Shroud", - description: "A cloak woven from the fabric of the Shadow Marshes itself. Wealth flows to those hidden from sight.", - type: "armour", - rarity: "epic", bonus: { goldMultiplier: 1.75 }, - owned: false, + cost: { crystals: 0, essence: 400, gold: 0 }, + description: + "A cloak woven from the fabric of the Shadow Marshes itself. Wealth flows to those hidden from sight.", equipped: false, - cost: { gold: 0, essence: 400, crystals: 0 }, - setId: "shadow_infiltrator", + id: "void_shroud", + name: "Void Shroud", + owned: false, + rarity: "epic", + setId: "shadow_infiltrator", + type: "armour", }, { - id: "volcanic_plate", - name: "Volcanic Plate", - description: "Armour quenched in magma that hardened into something neither metal nor stone. Burns with inner heat.", - type: "armour", - rarity: "epic", - bonus: { goldMultiplier: 1.65, combatMultiplier: 1.15 }, - owned: false, + bonus: { combatMultiplier: 1.15, goldMultiplier: 1.65 }, + description: + "Armour quenched in magma that hardened into something neither metal nor stone. Burns with inner heat.", equipped: false, - setId: "volcanic_forger", + id: "volcanic_plate", + name: "Volcanic Plate", + owned: false, + rarity: "epic", + setId: "volcanic_forger", + type: "armour", }, { - id: "dragon_scale", - name: "Dragon Scale Armour", + bonus: { goldMultiplier: 2 }, description: "Armour forged from the scales of a defeated elder dragon.", - type: "armour", - rarity: "legendary", - bonus: { goldMultiplier: 2.0 }, - owned: false, - equipped: false, + equipped: false, + id: "dragon_scale", + name: "Dragon Scale Armour", + owned: false, + rarity: "legendary", + type: "armour", }, { - id: "titan_aegis", - name: "Titan's Aegis", - description: "A shield-armour hybrid blessed by the celestials. Its bearer becomes a fortress.", - type: "armour", - rarity: "legendary", bonus: { goldMultiplier: 2.5 }, - owned: false, + cost: { crystals: 250, essence: 0, gold: 0 }, + description: + "A shield-armour hybrid blessed by the celestials. Its bearer becomes a fortress.", equipped: false, - cost: { gold: 0, essence: 0, crystals: 250 }, + id: "titan_aegis", + name: "Titan's Aegis", + owned: false, + rarity: "legendary", + type: "armour", }, { - id: "astral_robe", - name: "Astral Robe", - description: "Woven from threads of pure starlight harvested by the Astral Wraith. Income flows like cosmic energy.", - type: "armour", - rarity: "legendary", bonus: { goldMultiplier: 2.25 }, - owned: false, + description: + "Woven from threads of pure starlight harvested by the Astral Wraith. Income flows like cosmic energy.", equipped: false, + id: "astral_robe", + name: "Astral Robe", + owned: false, + rarity: "legendary", + type: "armour", }, // ── Trinkets ────────────────────────────────────────────────────────────── { - id: "lucky_coin", - name: "Lucky Coin", + bonus: { clickMultiplier: 1.1 }, description: "A coin that always lands on the side you need.", - type: "trinket", - rarity: "common", - bonus: { clickMultiplier: 1.1 }, - owned: true, - equipped: true, + equipped: true, + id: "lucky_coin", + name: "Lucky Coin", + owned: true, + rarity: "common", + type: "trinket", }, { - id: "mages_focus", - name: "Mage's Focus", + bonus: { clickMultiplier: 1.25 }, description: "A crystal lens that sharpens magical precision.", - type: "trinket", - rarity: "rare", - bonus: { clickMultiplier: 1.25 }, - owned: false, - equipped: false, - setId: "iron_vanguard", + equipped: false, + id: "mages_focus", + name: "Mage's Focus", + owned: false, + rarity: "rare", + setId: "iron_vanguard", + type: "trinket", }, { - id: "frost_rune", - name: "Frost Rune", - description: "A rune carved from bone-ice by the Bone Colossus. It amplifies strikes with cold precision.", - type: "trinket", - rarity: "rare", bonus: { clickMultiplier: 1.3 }, - owned: false, + description: + "A rune carved from bone-ice by the Bone Colossus. It amplifies strikes with cold precision.", equipped: false, + id: "frost_rune", + name: "Frost Rune", + owned: false, + rarity: "rare", + type: "trinket", }, { - id: "arcane_orb", - name: "Arcane Orb", + bonus: { clickMultiplier: 1.5 }, description: "An orb humming with concentrated arcane energy.", - type: "trinket", - rarity: "epic", - bonus: { clickMultiplier: 1.5 }, - owned: false, - equipped: false, + equipped: false, + id: "arcane_orb", + name: "Arcane Orb", + owned: false, + rarity: "epic", + type: "trinket", }, { - id: "runestone_amulet", - name: "Runestone Amulet", - description: "An amulet carved from ancient runestones found in the plague ruins. Its inscriptions hum with forgotten power.", - type: "trinket", - rarity: "epic", bonus: { clickMultiplier: 1.45, goldMultiplier: 1.15 }, - owned: false, + description: + "An amulet carved from ancient runestones found in the plague ruins. Its inscriptions hum with forgotten power.", equipped: false, + id: "runestone_amulet", + name: "Runestone Amulet", + owned: false, + rarity: "epic", + type: "trinket", }, { - id: "crystal_shard", - name: "Crystal Shard", - description: "A fragment of the Mud Kraken's crystallised essence. Focuses raw power into devastating strikes.", - type: "trinket", - rarity: "epic", bonus: { clickMultiplier: 1.55, goldMultiplier: 1.1 }, - owned: false, + description: + "A fragment of the Mud Kraken's crystallised essence. Focuses raw power into devastating strikes.", equipped: false, - setId: "volcanic_forger", + id: "crystal_shard", + name: "Crystal Shard", + owned: false, + rarity: "epic", + setId: "volcanic_forger", + type: "trinket", }, { - id: "void_compass", - name: "Void Compass", - description: "A compass that points not north but toward the greatest concentration of power — wherever that may be.", - type: "trinket", - rarity: "epic", bonus: { clickMultiplier: 1.6 }, - owned: false, + cost: { crystals: 0, essence: 350, gold: 0 }, + description: + "A compass that points not north but toward the greatest concentration of power — wherever that may be.", equipped: false, - cost: { gold: 0, essence: 350, crystals: 0 }, - setId: "shadow_infiltrator", + id: "void_compass", + name: "Void Compass", + owned: false, + rarity: "epic", + setId: "shadow_infiltrator", + type: "trinket", }, { - id: "frost_crystal", - name: "Frost Crystal", - description: "A perfectly formed crystal harvested from the Ice Queen's throne room. Cold enough to burn.", - type: "trinket", - rarity: "legendary", - bonus: { clickMultiplier: 2.0, goldMultiplier: 1.2 }, - owned: false, + bonus: { clickMultiplier: 2, goldMultiplier: 1.2 }, + description: + "A perfectly formed crystal harvested from the Ice Queen's throne room. Cold enough to burn.", equipped: false, + id: "frost_crystal", + name: "Frost Crystal", + owned: false, + rarity: "legendary", + type: "trinket", }, { - id: "philosophers_stone", - name: "Philosopher's Stone", - description: "The legendary stone that grants mastery over gold and combat alike.", - type: "trinket", - rarity: "legendary", - bonus: { clickMultiplier: 2.0, goldMultiplier: 1.25 }, - owned: false, + bonus: { clickMultiplier: 2, goldMultiplier: 1.25 }, + description: + "The legendary stone that grants mastery over gold and combat alike.", equipped: false, + id: "philosophers_stone", + name: "Philosopher's Stone", + owned: false, + rarity: "legendary", + type: "trinket", }, { - id: "eternal_flame", - name: "Eternal Flame", - description: "A flame that has never been extinguished, sealed in crystal by the Phoenix Lord. It burns with the power of rebirth.", - type: "trinket", - rarity: "legendary", bonus: { clickMultiplier: 2.25, goldMultiplier: 1.15 }, - owned: false, + description: + "A flame that has never been extinguished, sealed in crystal by the Phoenix Lord. It burns with the power of rebirth.", equipped: false, + id: "eternal_flame", + name: "Eternal Flame", + owned: false, + rarity: "legendary", + type: "trinket", }, { - id: "infinity_gem", - name: "Infinity Gem", - description: "A gem that contains a universe within it. Those who hold it become more than mortal.", - type: "trinket", - rarity: "legendary", - bonus: { clickMultiplier: 2.5, goldMultiplier: 1.3, combatMultiplier: 1.25 }, - owned: false, + bonus: { + clickMultiplier: 2.5, + combatMultiplier: 1.25, + goldMultiplier: 1.3, + }, + description: + "A gem that contains a universe within it. Those who hold it become more than mortal.", equipped: false, + id: "infinity_gem", + name: "Infinity Gem", + owned: false, + rarity: "legendary", + type: "trinket", }, // ── Celestial Reaches ───────────────────────────────────────────────────── { - id: "seraph_wing", - name: "Seraph's Wing", - description: "A weapon forged from a fallen seraph's primary feather — impossibly sharp, burning with divine light.", - type: "weapon", - rarity: "legendary", bonus: { combatMultiplier: 3.5 }, - owned: false, + description: + "A weapon forged from a fallen seraph's primary feather — impossibly sharp, burning with divine light.", equipped: false, - setId: "celestial_guardian", + id: "seraph_wing", + name: "Seraph's Wing", + owned: false, + rarity: "legendary", + setId: "celestial_guardian", + type: "weapon", }, { - id: "angels_halo", - name: "Angel's Halo", - description: "Torn from the Fallen Archangel. It radiates with grief and power in equal measure.", - type: "trinket", - rarity: "legendary", bonus: { clickMultiplier: 2.75, goldMultiplier: 1.3 }, - owned: false, + description: + "Torn from the Fallen Archangel. It radiates with grief and power in equal measure.", equipped: false, - setId: "celestial_guardian", + id: "angels_halo", + name: "Angel's Halo", + owned: false, + rarity: "legendary", + setId: "celestial_guardian", + type: "trinket", }, { - id: "celestial_armour", - name: "Celestial Armour", - description: "Forged in heavenly smithies from light compressed so hard it became solid. Your gold flows like sunbeams.", - type: "armour", - rarity: "legendary", bonus: { goldMultiplier: 2.75 }, - owned: false, + description: + "Forged in heavenly smithies from light compressed so hard it became solid. Your gold flows like sunbeams.", equipped: false, - setId: "celestial_guardian", + id: "celestial_armour", + name: "Celestial Armour", + owned: false, + rarity: "legendary", + setId: "celestial_guardian", + type: "armour", }, { - id: "divine_edge", - name: "The Divine Edge", - description: "The First Light's own blade — a weapon of pure divine will given form. It does not cut. It declares.", - type: "weapon", - rarity: "legendary", - bonus: { combatMultiplier: 4.0 }, - owned: false, + bonus: { combatMultiplier: 4 }, + description: + "The First Light's own blade — a weapon of pure divine will given form. It does not cut. It declares.", equipped: false, + id: "divine_edge", + name: "The Divine Edge", + owned: false, + rarity: "legendary", + type: "weapon", }, { - id: "heaven_mantle", - name: "Heaven's Mantle", - description: "The outermost garment of the celestial realm, woven from captured starlight and divine intention.", - type: "armour", - rarity: "legendary", - bonus: { goldMultiplier: 3.0 }, - owned: false, + bonus: { goldMultiplier: 3 }, + description: + "The outermost garment of the celestial realm, woven from captured starlight and divine intention.", equipped: false, + id: "heaven_mantle", + name: "Heaven's Mantle", + owned: false, + rarity: "legendary", + type: "armour", }, // ── Abyssal Trench ──────────────────────────────────────────────────────── { - id: "depth_blade", - name: "The Depth Blade", - description: "Crystallised from the Depth Leviathan's venom — a weapon that strikes through armour as if it were water.", - type: "weapon", - rarity: "legendary", bonus: { combatMultiplier: 4.5 }, - owned: false, + description: + "Crystallised from the Depth Leviathan's venom — a weapon that strikes through armour as if it were water.", equipped: false, - setId: "abyssal_predator", + id: "depth_blade", + name: "The Depth Blade", + owned: false, + rarity: "legendary", + setId: "abyssal_predator", + type: "weapon", }, { - id: "leviathan_eye", - name: "The Leviathan's Eye", - description: "The Elder Kraken's eye, preserved in brine from the deepest trench. It sees through all deception.", - type: "trinket", - rarity: "legendary", - bonus: { clickMultiplier: 3.0, goldMultiplier: 1.35 }, - owned: false, + bonus: { clickMultiplier: 3, goldMultiplier: 1.35 }, + description: + "The Elder Kraken's eye, preserved in brine from the deepest trench. It sees through all deception.", equipped: false, - setId: "abyssal_predator", + id: "leviathan_eye", + name: "The Leviathan's Eye", + owned: false, + rarity: "legendary", + setId: "abyssal_predator", + type: "trinket", }, { - id: "pressure_plate", - name: "Pressure Plate", - description: "Armour forged under conditions that would crush a city. Nothing that wears it can be broken by ordinary force.", - type: "armour", - rarity: "legendary", bonus: { goldMultiplier: 3.25 }, - owned: false, + description: + "Armour forged under conditions that would crush a city. Nothing that wears it can be broken by ordinary force.", equipped: false, - setId: "abyssal_predator", + id: "pressure_plate", + name: "Pressure Plate", + owned: false, + rarity: "legendary", + setId: "abyssal_predator", + type: "armour", }, { - id: "abyssal_edge", - name: "The Abyssal Edge", - description: "The Elder Abomination's own appendage, reshaped by your artificers into something that passes for a weapon.", - type: "weapon", - rarity: "legendary", - bonus: { combatMultiplier: 5.0 }, - owned: false, + bonus: { combatMultiplier: 5 }, + description: + "The Elder Abomination's own appendage, reshaped by your artificers into something that passes for a weapon.", equipped: false, + id: "abyssal_edge", + name: "The Abyssal Edge", + owned: false, + rarity: "legendary", + type: "weapon", }, { - id: "abyss_shroud", - name: "The Abyss Shroud", - description: "Woven from the darkness at the very bottom of everything. Gold flows to those who wear the dark.", - type: "armour", - rarity: "legendary", bonus: { goldMultiplier: 3.5 }, - owned: false, + description: + "Woven from the darkness at the very bottom of everything. Gold flows to those who wear the dark.", equipped: false, + id: "abyss_shroud", + name: "The Abyss Shroud", + owned: false, + rarity: "legendary", + type: "armour", }, // ── Infernal Court ──────────────────────────────────────────────────────── { - id: "demon_hide", - name: "Demon Hide Armour", - description: "The Demon Prince's own hide, worked into armour that whispers the strategies of ten thousand campaigns.", - type: "armour", - rarity: "legendary", bonus: { goldMultiplier: 3.75 }, - owned: false, + description: + "The Demon Prince's own hide, worked into armour that whispers the strategies of ten thousand campaigns.", equipped: false, - setId: "infernal_conqueror", + id: "demon_hide", + name: "Demon Hide Armour", + owned: false, + rarity: "legendary", + setId: "infernal_conqueror", + type: "armour", }, { - id: "hellfire_edge", - name: "The Hellfire Edge", - description: "A fragment of the Hellfire Titan's core — constantly burning with a heat that ignores armour.", - type: "weapon", - rarity: "legendary", bonus: { combatMultiplier: 5.5 }, - owned: false, + description: + "A fragment of the Hellfire Titan's core — constantly burning with a heat that ignores armour.", equipped: false, - setId: "infernal_conqueror", + id: "hellfire_edge", + name: "The Hellfire Edge", + owned: false, + rarity: "legendary", + setId: "infernal_conqueror", + type: "weapon", }, { - id: "soul_gem", - name: "The Soul Gem", - description: "Crystallised from the Lord of Sin's tears — which had never been shed before. The rarest thing in the infernal court.", - type: "trinket", - rarity: "legendary", bonus: { clickMultiplier: 3.25, goldMultiplier: 1.4 }, - owned: false, + description: + "Crystallised from the Lord of Sin's tears — which had never been shed before. The rarest thing in the infernal court.", equipped: false, - setId: "infernal_conqueror", + id: "soul_gem", + name: "The Soul Gem", + owned: false, + rarity: "legendary", + setId: "infernal_conqueror", + type: "trinket", }, { - id: "infernal_edge", - name: "The Infernal Edge", - description: "Forged from what The Fallen once was — something good, hardened into a weapon of absolute purpose.", - type: "weapon", - rarity: "legendary", - bonus: { combatMultiplier: 6.0 }, - owned: false, + bonus: { combatMultiplier: 6 }, + description: + "Forged from what The Fallen once was — something good, hardened into a weapon of absolute purpose.", equipped: false, + id: "infernal_edge", + name: "The Infernal Edge", + owned: false, + rarity: "legendary", + type: "weapon", }, { - id: "sinslayer_aegis", - name: "The Sinslayer Aegis", - description: "Armour assembled from The Fallen's regrets. Every piece of it remembers what righteousness felt like.", - type: "armour", - rarity: "legendary", - bonus: { goldMultiplier: 4.0 }, - owned: false, + bonus: { goldMultiplier: 4 }, + description: + "Armour assembled from The Fallen's regrets. Every piece of it remembers what righteousness felt like.", equipped: false, + id: "sinslayer_aegis", + name: "The Sinslayer Aegis", + owned: false, + rarity: "legendary", + type: "armour", }, // ── Crystalline Spire ───────────────────────────────────────────────────── { - id: "prism_blade", - name: "The Prism Blade", - description: "A sword that refracts into thousands of simultaneous strikes. Defenders cannot guard against every angle.", - type: "weapon", - rarity: "legendary", bonus: { combatMultiplier: 6.5 }, - owned: false, + description: + "A sword that refracts into thousands of simultaneous strikes. Defenders cannot guard against every angle.", equipped: false, - setId: "crystal_domain", + id: "prism_blade", + name: "The Prism Blade", + owned: false, + rarity: "legendary", + setId: "crystal_domain", + type: "weapon", }, { - id: "faceted_armour", - name: "The Faceted Armour", - description: "Armour that intersects with adjacent realities — attacks pass through versions of you that chose differently.", - type: "armour", - rarity: "legendary", bonus: { goldMultiplier: 4.5 }, - owned: false, + description: + "Armour that intersects with adjacent realities — attacks pass through versions of you that chose differently.", equipped: false, - setId: "crystal_domain", + id: "faceted_armour", + name: "The Faceted Armour", + owned: false, + rarity: "legendary", + setId: "crystal_domain", + type: "armour", }, { - id: "prism_eye", - name: "The Prism Eye", - description: "A lens from the Diamond Colossus's own perception — through it, your guild sees every moment simultaneously.", - type: "trinket", - rarity: "legendary", bonus: { clickMultiplier: 3.5, goldMultiplier: 1.5 }, - owned: false, + description: + "A lens from the Diamond Colossus's own perception — through it, your guild sees every moment simultaneously.", equipped: false, - setId: "crystal_domain", + id: "prism_eye", + name: "The Prism Eye", + owned: false, + rarity: "legendary", + setId: "crystal_domain", + type: "trinket", }, { - id: "crystal_sovereign_blade", - name: "The Sovereign's Blade", - description: "The Crystal Sovereign's own instrument of computation — repurposed for something it calculated was inevitable.", - type: "weapon", - rarity: "legendary", - bonus: { combatMultiplier: 7.0 }, - owned: false, + bonus: { combatMultiplier: 7 }, + description: + "The Crystal Sovereign's own instrument of computation — repurposed for something it calculated was inevitable.", equipped: false, + id: "crystal_sovereign_blade", + name: "The Sovereign's Blade", + owned: false, + rarity: "legendary", + type: "weapon", }, { - id: "diamond_plate", - name: "Diamond Plate", - description: "Armour compressed from crystallised possibilities — the optimal defensive configuration across all timelines.", - type: "armour", - rarity: "legendary", - bonus: { goldMultiplier: 5.0 }, - owned: false, + bonus: { goldMultiplier: 5 }, + description: + "Armour compressed from crystallised possibilities — the optimal defensive configuration across all timelines.", equipped: false, + id: "diamond_plate", + name: "Diamond Plate", + owned: false, + rarity: "legendary", + type: "armour", }, // ── Void Sanctum ────────────────────────────────────────────────────────── { - id: "void_annihilator", - name: "The Void Annihilator", - description: "A weapon of pure absence — it does not strike, it simply removes the thing it is aimed at from existence.", - type: "weapon", - rarity: "legendary", - bonus: { combatMultiplier: 8.0 }, - owned: false, + bonus: { combatMultiplier: 8 }, + description: + "A weapon of pure absence — it does not strike, it simply removes the thing it is aimed at from existence.", equipped: false, - setId: "void_emperor", + id: "void_annihilator", + name: "The Void Annihilator", + owned: false, + rarity: "legendary", + setId: "void_emperor", + type: "weapon", }, { - id: "eternal_shroud", - name: "The Eternal Shroud", - description: "Woven from the Eternal Shade itself — armour that exists in every moment simultaneously, impossible to find.", - type: "armour", - rarity: "legendary", bonus: { goldMultiplier: 5.5 }, - owned: false, + description: + "Woven from the Eternal Shade itself — armour that exists in every moment simultaneously, impossible to find.", equipped: false, - setId: "void_emperor", + id: "eternal_shroud", + name: "The Eternal Shroud", + owned: false, + rarity: "legendary", + setId: "void_emperor", + type: "armour", }, { - id: "void_heart_gem", - name: "The Void Heart Gem", - description: "Crystallised from the Void Progenitor's core — the original absence, given form. It makes the impossible routine.", - type: "trinket", - rarity: "legendary", - bonus: { clickMultiplier: 4.0, goldMultiplier: 1.6 }, - owned: false, + bonus: { clickMultiplier: 4, goldMultiplier: 1.6 }, + description: + "Crystallised from the Void Progenitor's core — the original absence, given form. It makes the impossible routine.", equipped: false, - setId: "void_emperor", + id: "void_heart_gem", + name: "The Void Heart Gem", + owned: false, + rarity: "legendary", + setId: "void_emperor", + type: "trinket", }, { - id: "sanctum_breaker", - name: "The Sanctum Breaker", - description: "The Void Emperor's own sceptre of authority, seized in the moment of its defeat. It commands even nothingness.", - type: "weapon", - rarity: "legendary", - bonus: { combatMultiplier: 9.0 }, - owned: false, + bonus: { combatMultiplier: 9 }, + description: + "The Void Emperor's own sceptre of authority, seized in the moment of its defeat. It commands even nothingness.", equipped: false, + id: "sanctum_breaker", + name: "The Sanctum Breaker", + owned: false, + rarity: "legendary", + type: "weapon", }, { - id: "void_emperor_plate", - name: "Void Emperor's Plate", - description: "The armour the Void Emperor wore for all of existence — now worn by something that dared to challenge all of existence.", - type: "armour", - rarity: "legendary", - bonus: { goldMultiplier: 6.0 }, - owned: false, + bonus: { goldMultiplier: 6 }, + description: + "The armour the Void Emperor wore for all of existence — now worn by something that dared to challenge all of existence.", equipped: false, + id: "void_emperor_plate", + name: "Void Emperor's Plate", + owned: false, + rarity: "legendary", + type: "armour", }, // ── Eternal Throne ──────────────────────────────────────────────────────── { - id: "eternal_armour", - name: "Eternal Armour", - description: "The Throne Warden's own defensive shell — protection that has never been breached across all of time.", - type: "armour", - rarity: "legendary", - bonus: { goldMultiplier: 7.0 }, - owned: false, + bonus: { goldMultiplier: 7 }, + description: + "The Throne Warden's own defensive shell — protection that has never been breached across all of time.", equipped: false, - setId: "eternal_throne", + id: "eternal_armour", + name: "Eternal Armour", + owned: false, + rarity: "legendary", + setId: "eternal_throne", + type: "armour", }, { - id: "throne_blade", - name: "The Throne Blade", - description: "The Eternal Knight's sword — a weapon that has served the throne since the concept of service was invented.", - type: "weapon", - rarity: "legendary", - bonus: { combatMultiplier: 10.0 }, - owned: false, + bonus: { combatMultiplier: 10 }, + description: + "The Eternal Knight's sword — a weapon that has served the throne since the concept of service was invented.", equipped: false, - setId: "eternal_throne", + id: "throne_blade", + name: "The Throne Blade", + owned: false, + rarity: "legendary", + setId: "eternal_throne", + type: "weapon", }, { - id: "apex_sword", - name: "The Apex Sword", - description: "The Apex's own instrument — not a weapon in any sense your guild understands, but it functions as one now.", - type: "weapon", - rarity: "legendary", - bonus: { combatMultiplier: 12.0 }, - owned: false, + bonus: { combatMultiplier: 12 }, + description: + "The Apex's own instrument — not a weapon in any sense your guild understands, but it functions as one now.", equipped: false, + id: "apex_sword", + name: "The Apex Sword", + owned: false, + rarity: "legendary", + type: "weapon", }, { - id: "apex_plate", - name: "The Apex Plate", - description: "Armour assembled from the Eternal Throne itself — the absolute seat of power, now serving those who claimed it.", - type: "armour", - rarity: "legendary", - bonus: { goldMultiplier: 8.0 }, - owned: false, + bonus: { goldMultiplier: 8 }, + description: + "Armour assembled from the Eternal Throne itself — the absolute seat of power, now serving those who claimed it.", equipped: false, + id: "apex_plate", + name: "The Apex Plate", + owned: false, + rarity: "legendary", + type: "armour", }, { - id: "eternity_stone", - name: "The Eternity Stone", - description: "The source of the Apex's power — the thing that makes the Eternal Throne eternal. It is yours now. All of it.", - type: "trinket", - rarity: "legendary", - bonus: { clickMultiplier: 5.0, goldMultiplier: 2.0, combatMultiplier: 1.5 }, - owned: false, + bonus: { clickMultiplier: 5, combatMultiplier: 1.5, goldMultiplier: 2 }, + description: + "The source of the Apex's power — the thing that makes the Eternal Throne eternal. It is yours now. All of it.", equipped: false, - setId: "eternal_throne", + id: "eternity_stone", + name: "The Eternity Stone", + owned: false, + rarity: "legendary", + setId: "eternal_throne", + type: "trinket", }, // ── Purchasable endgame sinks ───────────────────────────────────────────── { - id: "celestial_focus", - name: "Celestial Focus", - description: "A lens of compressed celestial light that sharpens every strike with divine precision.", - type: "trinket", - rarity: "legendary", bonus: { clickMultiplier: 2.5 }, - owned: false, + cost: { crystals: 0, essence: 20_000_000, gold: 0 }, + description: + "A lens of compressed celestial light that sharpens every strike with divine precision.", equipped: false, - cost: { gold: 0, essence: 20_000_000, crystals: 0 }, + id: "celestial_focus", + name: "Celestial Focus", + owned: false, + rarity: "legendary", + type: "trinket", }, { - id: "abyssal_tome", - name: "Abyssal Tome", - description: "A book written in the language of the deep — reading it aligns your guild's operations with abyssal efficiency.", - type: "armour", - rarity: "legendary", - bonus: { goldMultiplier: 3.0 }, - owned: false, + bonus: { goldMultiplier: 3 }, + cost: { crystals: 0, essence: 50_000_000, gold: 0 }, + description: + "A book written in the language of the deep — reading it aligns your guild's operations with abyssal efficiency.", equipped: false, - cost: { gold: 0, essence: 50_000_000, crystals: 0 }, + id: "abyssal_tome", + name: "Abyssal Tome", + owned: false, + rarity: "legendary", + type: "armour", }, { - id: "void_conduit", - name: "Void Conduit", - description: "A weapon that channels void energy — the absence of resistance makes every strike devastating.", - type: "weapon", - rarity: "legendary", - bonus: { combatMultiplier: 4.0 }, - owned: false, + bonus: { combatMultiplier: 4 }, + cost: { crystals: 0, essence: 100_000_000, gold: 0 }, + description: + "A weapon that channels void energy — the absence of resistance makes every strike devastating.", equipped: false, - cost: { gold: 0, essence: 100_000_000, crystals: 0 }, + id: "void_conduit", + name: "Void Conduit", + owned: false, + rarity: "legendary", + type: "weapon", }, { - id: "infernal_gem", - name: "Infernal Gem", - description: "A gem forged in the heart of the Infernal Court — it burns with productivity and righteous fury in equal measure.", - type: "trinket", - rarity: "legendary", bonus: { clickMultiplier: 3.5, goldMultiplier: 1.5 }, - owned: false, + cost: { crystals: 5_000_000, essence: 0, gold: 0 }, + description: + "A gem forged in the heart of the Infernal Court — it burns with productivity and righteous fury in equal measure.", equipped: false, - cost: { gold: 0, essence: 0, crystals: 5_000_000 }, + id: "infernal_gem", + name: "Infernal Gem", + owned: false, + rarity: "legendary", + type: "trinket", }, { - id: "crystal_matrix", - name: "Crystal Matrix", - description: "Armour structured around a crystalline lattice of optimal income calculations. Every gold piece finds you faster.", - type: "armour", - rarity: "legendary", - bonus: { goldMultiplier: 4.0 }, - owned: false, + bonus: { goldMultiplier: 4 }, + cost: { crystals: 20_000_000, essence: 0, gold: 0 }, + description: + "Armour structured around a crystalline lattice of optimal income calculations. Every gold piece finds you faster.", equipped: false, - cost: { gold: 0, essence: 0, crystals: 20_000_000 }, + id: "crystal_matrix", + name: "Crystal Matrix", + owned: false, + rarity: "legendary", + type: "armour", }, { - id: "eternal_prism", - name: "The Eternal Prism", - description: "An artifact from beyond all known planes — it refracts power through all dimensions simultaneously.", - type: "trinket", - rarity: "legendary", - bonus: { clickMultiplier: 5.0, goldMultiplier: 2.0, combatMultiplier: 1.5 }, - owned: false, + bonus: { clickMultiplier: 5, combatMultiplier: 1.5, goldMultiplier: 2 }, + cost: { crystals: 100_000_000, essence: 0, gold: 0 }, + description: + "An artifact from beyond all known planes — it refracts power through all dimensions simultaneously.", equipped: false, - cost: { gold: 0, essence: 0, crystals: 100_000_000 }, + id: "eternal_prism", + name: "The Eternal Prism", + owned: false, + rarity: "legendary", + type: "trinket", }, ]; diff --git a/apps/api/src/data/equipmentSets.ts b/apps/api/src/data/equipmentSets.ts index 08575f0..3ac256c 100644 --- a/apps/api/src/data/equipmentSets.ts +++ b/apps/api/src/data/equipmentSets.ts @@ -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 = [ { - 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" ], }, ]; diff --git a/apps/api/src/data/explorations.ts b/apps/api/src/data/explorations.ts index 6f73192..d796afb 100644 --- a/apps/api/src/data/explorations.ts +++ b/apps/api/src/data/explorations.ts @@ -1,1245 +1,3277 @@ +/** + * @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 { ExplorationArea } from "@elysium/types"; -export const DEFAULT_EXPLORATIONS: ExplorationArea[] = [ +export const defaultExplorations: Array = [ // ── Zone 1: verdant_vale ────────────────────────────────────────────────── { - id: "verdant_meadow", - name: "The Verdant Meadow", - description: "Rolling fields of wildflowers at the edge of the guild's territory. Travellers pass through often, and occasionally leave things behind.", - zoneId: "verdant_vale", - durationSeconds: 3600, // 1h + description: + "Rolling fields of wildflowers at the edge of the guild's territory. Travellers pass through often, and occasionally leave things behind.", + durationSeconds: 3600, + events: [ + { + effect: { amount: 1000, type: "gold_gain" }, + id: "vm_e1", + text: "A passing merchant overcharged for his wares and your scouts recovered the difference. Gold gained.", + }, + { + effect: { amount: 500, type: "gold_loss" }, + id: "vm_e2", + text: "Bandits made off with a scout's supply pack before they could be stopped.", + }, + { + effect: { + materialId: "verdant_sap", + quantity: 2, + type: "material_gain", + }, + id: "vm_e3", + text: "A nest of rare resin-producing beetles yields an extra harvest.", + }, + { + effect: { amount: 50, type: "essence_gain" }, + id: "vm_e4", + text: "A group of wandering peasants heard of your guild's reputation and joined up.", + }, + ], + id: "verdant_meadow", + name: "The Verdant Meadow", + // 1h possibleMaterials: [ - { materialId: "verdant_sap", minQuantity: 1, maxQuantity: 3, weight: 3 }, - ], - events: [ - { id: "vm_e1", text: "A passing merchant overcharged for his wares and your scouts recovered the difference. Gold gained.", effect: { type: "gold_gain", amount: 1000 } }, - { id: "vm_e2", text: "Bandits made off with a scout's supply pack before they could be stopped.", effect: { type: "gold_loss", amount: 500 } }, - { id: "vm_e3", text: "A nest of rare resin-producing beetles yields an extra harvest.", effect: { type: "material_gain", materialId: "verdant_sap", quantity: 2 } }, - { id: "vm_e4", text: "A group of wandering peasants heard of your guild's reputation and joined up.", effect: { type: "essence_gain", amount: 50 } }, + { materialId: "verdant_sap", maxQuantity: 3, minQuantity: 1, weight: 3 }, ], + + zoneId: "verdant_vale", }, { - id: "whispering_forest", - name: "The Whispering Forest", - description: "Ancient trees whose canopy blocks out most of the light. The forest whispers things your scouts swear they understand, just not when they try to remember later.", - zoneId: "verdant_vale", - durationSeconds: 7200, // 2h + description: + "Ancient trees whose canopy blocks out most of the light. The forest whispers things your scouts swear they understand, just not when they try to remember later.", + durationSeconds: 7200, + events: [ + { + effect: { amount: 3000, type: "gold_gain" }, + id: "wf_e1", + text: "A hidden cache of coins, lost by some forgotten traveller, is found beneath a root.", + }, + { + effect: { amount: 1500, type: "gold_loss" }, + id: "wf_e2", + text: "The forest's whispers led a scout too far from the path. Rescue cost time and coin.", + }, + { + effect: { + materialId: "forest_crystal", + quantity: 1, + type: "material_gain", + }, + id: "wf_e3", + text: "A particularly ancient tree yields an unusually dense crystal in its roots.", + }, + { + effect: { amount: 100, type: "essence_gain" }, + id: "wf_e4", + text: "Something in the forest air sharpens the mind. The scouts return unusually focused.", + }, + ], + id: "whispering_forest", + name: "The Whispering Forest", + // 2h possibleMaterials: [ - { materialId: "verdant_sap", minQuantity: 2, maxQuantity: 5, weight: 3 }, - { materialId: "forest_crystal", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "wf_e1", text: "A hidden cache of coins, lost by some forgotten traveller, is found beneath a root.", effect: { type: "gold_gain", amount: 3000 } }, - { id: "wf_e2", text: "The forest's whispers led a scout too far from the path. Rescue cost time and coin.", effect: { type: "gold_loss", amount: 1500 } }, - { id: "wf_e3", text: "A particularly ancient tree yields an unusually dense crystal in its roots.", effect: { type: "material_gain", materialId: "forest_crystal", quantity: 1 } }, - { id: "wf_e4", text: "Something in the forest air sharpens the mind. The scouts return unusually focused.", effect: { type: "essence_gain", amount: 100 } }, + { materialId: "verdant_sap", maxQuantity: 5, minQuantity: 2, weight: 3 }, + { + materialId: "forest_crystal", + maxQuantity: 2, + minQuantity: 1, + weight: 2, + }, ], + + zoneId: "verdant_vale", }, { - id: "ancient_grove", - name: "The Ancient Grove", - description: "A circle of trees so old they predate the kingdom. Druids once held ceremonies here. The trees remember, and their bark holds echoes of old power.", - zoneId: "verdant_vale", - durationSeconds: 10800, // 3h + description: + "A circle of trees so old they predate the kingdom. Druids once held ceremonies here. The trees remember, and their bark holds echoes of old power.", + durationSeconds: 10_800, + events: [ + { + effect: { amount: 6000, type: "gold_gain" }, + id: "ag_e1", + text: "The grove's old power draws fortune: a vein of gold-threaded rock runs beneath one of the roots.", + }, + { + effect: { amount: 2500, type: "gold_loss" }, + id: "ag_e2", + text: "A territorial spirit drove off two scouts and damaged their equipment.", + }, + { + effect: { + materialId: "forest_crystal", + quantity: 2, + type: "material_gain", + }, + id: "ag_e3", + text: "Deep in the root system, an unusually large crystal cluster breaks off cleanly.", + }, + { + effect: { amount: 200, type: "essence_gain" }, + id: "ag_e4", + text: "The ancient grove restores something that had been slowly depleted. The essence flows back.", + }, + ], + id: "ancient_grove", + name: "The Ancient Grove", + // 3h possibleMaterials: [ - { materialId: "forest_crystal", minQuantity: 1, maxQuantity: 3, weight: 3 }, - { materialId: "verdant_sap", minQuantity: 1, maxQuantity: 3, weight: 2 }, - ], - events: [ - { id: "ag_e1", text: "The grove's old power draws fortune: a vein of gold-threaded rock runs beneath one of the roots.", effect: { type: "gold_gain", amount: 6000 } }, - { id: "ag_e2", text: "A territorial spirit drove off two scouts and damaged their equipment.", effect: { type: "gold_loss", amount: 2500 } }, - { id: "ag_e3", text: "Deep in the root system, an unusually large crystal cluster breaks off cleanly.", effect: { type: "material_gain", materialId: "forest_crystal", quantity: 2 } }, - { id: "ag_e4", text: "The ancient grove restores something that had been slowly depleted. The essence flows back.", effect: { type: "essence_gain", amount: 200 } }, + { + materialId: "forest_crystal", + maxQuantity: 3, + minQuantity: 1, + weight: 3, + }, + { materialId: "verdant_sap", maxQuantity: 3, minQuantity: 1, weight: 2 }, ], + + zoneId: "verdant_vale", }, { - id: "forbidden_glen", - name: "The Forbidden Glen", - description: "A clearing the locals will not enter after dark. Something about the bark of the trees here is different. Your scouts feel watched the entire time.", - zoneId: "verdant_vale", - durationSeconds: 14400, // 4h + description: + "A clearing the locals will not enter after dark. Something about the bark of the trees here is different. Your scouts feel watched the entire time.", + durationSeconds: 14_400, + events: [ + { + effect: { amount: 10_000, type: "gold_gain" }, + id: "fg_e1", + text: "Whatever watches the glen seems to approve of your guild. A gift of old coin is left at the entrance.", + }, + { + effect: { amount: 4000, type: "gold_loss" }, + id: "fg_e2", + text: "Whatever watches the glen does not approve. Three scouts come back without their packs.", + }, + { + effect: { + materialId: "elder_bark", + quantity: 1, + type: "material_gain", + }, + id: "fg_e3", + text: "A shard of elder bark falls, as if offered.", + }, + { + effect: { amount: 400, type: "essence_gain" }, + id: "fg_e4", + text: "The forbidden glen leaves a mark on your scouts — not unpleasant. Their focus sharpens.", + }, + ], + id: "forbidden_glen", + name: "The Forbidden Glen", + // 4h possibleMaterials: [ - { materialId: "forest_crystal", minQuantity: 2, maxQuantity: 4, weight: 3 }, - { materialId: "elder_bark", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "fg_e1", text: "Whatever watches the glen seems to approve of your guild. A gift of old coin is left at the entrance.", effect: { type: "gold_gain", amount: 10000 } }, - { id: "fg_e2", text: "Whatever watches the glen does not approve. Three scouts come back without their packs.", effect: { type: "gold_loss", amount: 4000 } }, - { id: "fg_e3", text: "A shard of elder bark falls, as if offered.", effect: { type: "material_gain", materialId: "elder_bark", quantity: 1 } }, - { id: "fg_e4", text: "The forbidden glen leaves a mark on your scouts — not unpleasant. Their focus sharpens.", effect: { type: "essence_gain", amount: 400 } }, + { + materialId: "forest_crystal", + maxQuantity: 4, + minQuantity: 2, + weight: 3, + }, + { materialId: "elder_bark", maxQuantity: 2, minQuantity: 1, weight: 2 }, ], + + zoneId: "verdant_vale", }, // ── Zone 2: shattered_ruins ─────────────────────────────────────────────── { - id: "collapsed_outpost", - name: "The Collapsed Outpost", - description: "What was once a military garrison, now half-buried in rubble and wild growth. The previous occupants left in a hurry and did not take everything.", - zoneId: "shattered_ruins", - durationSeconds: 7200, // 2h + description: + "What was once a military garrison, now half-buried in rubble and wild growth. The previous occupants left in a hurry and did not take everything.", + durationSeconds: 7200, + events: [ + { + effect: { amount: 4000, type: "gold_gain" }, + id: "co_e1", + text: "A hidden armory beneath the rubble yields weapons worth selling.", + }, + { + effect: { amount: 2000, type: "gold_loss" }, + id: "co_e2", + text: "A structural collapse pins two scouts briefly. Extraction costs.", + }, + { + effect: { materialId: "ruin_dust", quantity: 3, type: "material_gain" }, + id: "co_e3", + text: "The outpost's old enchantments left residue in the stonework, still harvestable.", + }, + { + effect: { amount: 150, type: "essence_gain" }, + id: "co_e4", + text: "Old battle-essence still clings to the walls. Something can be drawn from it.", + }, + ], + id: "collapsed_outpost", + name: "The Collapsed Outpost", + // 2h possibleMaterials: [ - { materialId: "ruin_dust", minQuantity: 2, maxQuantity: 5, weight: 3 }, - ], - events: [ - { id: "co_e1", text: "A hidden armory beneath the rubble yields weapons worth selling.", effect: { type: "gold_gain", amount: 4000 } }, - { id: "co_e2", text: "A structural collapse pins two scouts briefly. Extraction costs.", effect: { type: "gold_loss", amount: 2000 } }, - { id: "co_e3", text: "The outpost's old enchantments left residue in the stonework, still harvestable.", effect: { type: "material_gain", materialId: "ruin_dust", quantity: 3 } }, - { id: "co_e4", text: "Old battle-essence still clings to the walls. Something can be drawn from it.", effect: { type: "essence_gain", amount: 150 } }, + { materialId: "ruin_dust", maxQuantity: 5, minQuantity: 2, weight: 3 }, ], + + zoneId: "shattered_ruins", }, { - id: "cursed_lake", - name: "The Cursed Lake", - description: "The water here reflects things that aren't there. Something is at the bottom that doesn't want to be found, which means your scouts want very much to find it.", - zoneId: "shattered_ruins", - durationSeconds: 14400, // 4h + description: + "The water here reflects things that aren't there. Something is at the bottom that doesn't want to be found, which means your scouts want very much to find it.", + durationSeconds: 14_400, + events: [ + { + effect: { amount: 10_000, type: "gold_gain" }, + id: "cl_e1", + text: "The lake yields sunken treasure from a caravan that tried to ford it centuries ago.", + }, + { + effect: { amount: 4000, type: "gold_loss" }, + id: "cl_e2", + text: "The curse reaches out and sends three scouts home with rattled nerves and empty purses.", + }, + { + effect: { + materialId: "cursed_fragment", + quantity: 1, + type: "material_gain", + }, + id: "cl_e3", + text: "Something at the lake's edge is not quite stone and not quite crystal.", + }, + { + effect: { amount: 300, type: "essence_gain" }, + id: "cl_e4", + text: "The curse is potent, but potency can be harvested. Your alchemist is pleased.", + }, + ], + id: "cursed_lake", + name: "The Cursed Lake", + // 4h possibleMaterials: [ - { materialId: "ruin_dust", minQuantity: 2, maxQuantity: 6, weight: 3 }, - { materialId: "cursed_fragment", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "cl_e1", text: "The lake yields sunken treasure from a caravan that tried to ford it centuries ago.", effect: { type: "gold_gain", amount: 10000 } }, - { id: "cl_e2", text: "The curse reaches out and sends three scouts home with rattled nerves and empty purses.", effect: { type: "gold_loss", amount: 4000 } }, - { id: "cl_e3", text: "Something at the lake's edge is not quite stone and not quite crystal.", effect: { type: "material_gain", materialId: "cursed_fragment", quantity: 1 } }, - { id: "cl_e4", text: "The curse is potent, but potency can be harvested. Your alchemist is pleased.", effect: { type: "essence_gain", amount: 300 } }, + { materialId: "ruin_dust", maxQuantity: 6, minQuantity: 2, weight: 3 }, + { + materialId: "cursed_fragment", + maxQuantity: 2, + minQuantity: 1, + weight: 2, + }, ], + + zoneId: "shattered_ruins", }, { - id: "runic_archive", - name: "The Runic Archive", - description: "Buried walls covered in script no living scholar can read. The knowledge is lost but the enchantments remain, faded but still murmuring in the stone.", - zoneId: "shattered_ruins", - durationSeconds: 21600, // 6h + description: + "Buried walls covered in script no living scholar can read. The knowledge is lost but the enchantments remain, faded but still murmuring in the stone.", + durationSeconds: 21_600, + events: [ + { + effect: { amount: 20_000, type: "gold_gain" }, + id: "ra_e1", + text: "A readable passage in the archive describes the location of a buried hoard. Verified, and found.", + }, + { + effect: { amount: 8000, type: "gold_loss" }, + id: "ra_e2", + text: "A dormant enchantment activates and ejects two scouts. Their notes are recovered, their dignity is not.", + }, + { + effect: { + materialId: "cursed_fragment", + quantity: 2, + type: "material_gain", + }, + id: "ra_e3", + text: "The archive yields a fragment that still hums with the original enchantment.", + }, + { + effect: { amount: 500, type: "essence_gain" }, + id: "ra_e4", + text: "The ancient knowledge still bleeds from the walls. Enough to be useful.", + }, + ], + id: "runic_archive", + name: "The Runic Archive", + // 6h possibleMaterials: [ - { materialId: "cursed_fragment", minQuantity: 1, maxQuantity: 3, weight: 3 }, - { materialId: "ruin_dust", minQuantity: 2, maxQuantity: 4, weight: 2 }, - ], - events: [ - { id: "ra_e1", text: "A readable passage in the archive describes the location of a buried hoard. Verified, and found.", effect: { type: "gold_gain", amount: 20000 } }, - { id: "ra_e2", text: "A dormant enchantment activates and ejects two scouts. Their notes are recovered, their dignity is not.", effect: { type: "gold_loss", amount: 8000 } }, - { id: "ra_e3", text: "The archive yields a fragment that still hums with the original enchantment.", effect: { type: "material_gain", materialId: "cursed_fragment", quantity: 2 } }, - { id: "ra_e4", text: "The ancient knowledge still bleeds from the walls. Enough to be useful.", effect: { type: "essence_gain", amount: 500 } }, + { + materialId: "cursed_fragment", + maxQuantity: 3, + minQuantity: 1, + weight: 3, + }, + { materialId: "ruin_dust", maxQuantity: 4, minQuantity: 2, weight: 2 }, ], + + zoneId: "shattered_ruins", }, { - id: "dragon_throne", - name: "The Dragon's Throne", - description: "The chamber the elder dragon called his own before your guild deposed him. He won't be back soon. Probably. The heat of his presence lingers in the stone.", - zoneId: "shattered_ruins", - durationSeconds: 28800, // 8h + description: + "The chamber the elder dragon called his own before your guild deposed him. He won't be back soon. Probably. The heat of his presence lingers in the stone.", + durationSeconds: 28_800, + events: [ + { + effect: { amount: 40_000, type: "gold_gain" }, + id: "dt_e1", + text: "The elder dragon's hoard was larger than expected. A secondary chamber yields considerable wealth.", + }, + { + effect: { amount: 15_000, type: "gold_loss" }, + id: "dt_e2", + text: "The dragon left traps. Your scouts are fine. The equipment is less fine.", + }, + { + effect: { + materialId: "dragonscale_chip", + quantity: 1, + type: "material_gain", + }, + id: "dt_e3", + text: "A scale chip, overlooked by your previous teams, catches the light in a corner.", + }, + { + effect: { amount: 800, type: "essence_gain" }, + id: "dt_e4", + text: "The residual draconic essence in the chamber is potent and entirely harvestable.", + }, + ], + id: "dragon_throne", + name: "The Dragon's Throne", + // 8h possibleMaterials: [ - { materialId: "cursed_fragment", minQuantity: 2, maxQuantity: 4, weight: 3 }, - { materialId: "dragonscale_chip", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "dt_e1", text: "The elder dragon's hoard was larger than expected. A secondary chamber yields considerable wealth.", effect: { type: "gold_gain", amount: 40000 } }, - { id: "dt_e2", text: "The dragon left traps. Your scouts are fine. The equipment is less fine.", effect: { type: "gold_loss", amount: 15000 } }, - { id: "dt_e3", text: "A scale chip, overlooked by your previous teams, catches the light in a corner.", effect: { type: "material_gain", materialId: "dragonscale_chip", quantity: 1 } }, - { id: "dt_e4", text: "The residual draconic essence in the chamber is potent and entirely harvestable.", effect: { type: "essence_gain", amount: 800 } }, + { + materialId: "cursed_fragment", + maxQuantity: 4, + minQuantity: 2, + weight: 3, + }, + { + materialId: "dragonscale_chip", + maxQuantity: 2, + minQuantity: 1, + weight: 2, + }, ], + + zoneId: "shattered_ruins", }, // ── Zone 3: frozen_peaks ────────────────────────────────────────────────── { - id: "glacial_cave", - name: "The Glacial Cave", - description: "A cave carved by a glacier over thousands of years. The ice walls are so clear you can see things preserved within them from before the kingdom existed.", - zoneId: "frozen_peaks", - durationSeconds: 10800, // 3h + description: + "A cave carved by a glacier over thousands of years. The ice walls are so clear you can see things preserved within them from before the kingdom existed.", + durationSeconds: 10_800, + events: [ + { + effect: { amount: 8000, type: "gold_gain" }, + id: "gc_e1", + text: "A preserved cache of ancient coin, frozen for centuries, is carefully extracted.", + }, + { + effect: { amount: 3500, type: "gold_loss" }, + id: "gc_e2", + text: "The ice shifted and trapped a scout briefly. Extraction was cold and expensive.", + }, + { + effect: { + materialId: "glacial_ice", + quantity: 3, + type: "material_gain", + }, + id: "gc_e3", + text: "An ice block breaks to reveal a natural hollow full of ice from an even older glacier.", + }, + { + effect: { amount: 250, type: "essence_gain" }, + id: "gc_e4", + text: "Something crystalline in the cave walls draws essence from the cold itself.", + }, + ], + id: "glacial_cave", + name: "The Glacial Cave", + // 3h possibleMaterials: [ - { materialId: "glacial_ice", minQuantity: 2, maxQuantity: 5, weight: 3 }, - ], - events: [ - { id: "gc_e1", text: "A preserved cache of ancient coin, frozen for centuries, is carefully extracted.", effect: { type: "gold_gain", amount: 8000 } }, - { id: "gc_e2", text: "The ice shifted and trapped a scout briefly. Extraction was cold and expensive.", effect: { type: "gold_loss", amount: 3500 } }, - { id: "gc_e3", text: "An ice block breaks to reveal a natural hollow full of ice from an even older glacier.", effect: { type: "material_gain", materialId: "glacial_ice", quantity: 3 } }, - { id: "gc_e4", text: "Something crystalline in the cave walls draws essence from the cold itself.", effect: { type: "essence_gain", amount: 250 } }, + { materialId: "glacial_ice", maxQuantity: 5, minQuantity: 2, weight: 3 }, ], + + zoneId: "frozen_peaks", }, { - id: "frozen_tundra", - name: "The Frozen Tundra", - description: "Flat, white, and vast. The tundra looks featureless until you know what to look for. Under the ice, there are things that were buried with intent.", - zoneId: "frozen_peaks", - durationSeconds: 21600, // 6h + description: + "Flat, white, and vast. The tundra looks featureless until you know what to look for. Under the ice, there are things that were buried with intent.", + durationSeconds: 21_600, + events: [ + { + effect: { amount: 18_000, type: "gold_gain" }, + id: "ft_e1", + text: "A buried shrine, untouched since before the freeze, holds its original offerings.", + }, + { + effect: { amount: 7000, type: "gold_loss" }, + id: "ft_e2", + text: "A blizzard came down without warning and cost your scouts significant time and supplies.", + }, + { + effect: { + materialId: "frost_crystal", + quantity: 1, + type: "material_gain", + }, + id: "ft_e3", + text: "A frost crystal formation, exposed by recent wind erosion, is still intact.", + }, + { + effect: { amount: 500, type: "essence_gain" }, + id: "ft_e4", + text: "The tundra holds old magic in its ice. Old enough to be worth distilling.", + }, + ], + id: "frozen_tundra", + name: "The Frozen Tundra", + // 6h possibleMaterials: [ - { materialId: "glacial_ice", minQuantity: 3, maxQuantity: 7, weight: 3 }, - { materialId: "frost_crystal", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "ft_e1", text: "A buried shrine, untouched since before the freeze, holds its original offerings.", effect: { type: "gold_gain", amount: 18000 } }, - { id: "ft_e2", text: "A blizzard came down without warning and cost your scouts significant time and supplies.", effect: { type: "gold_loss", amount: 7000 } }, - { id: "ft_e3", text: "A frost crystal formation, exposed by recent wind erosion, is still intact.", effect: { type: "material_gain", materialId: "frost_crystal", quantity: 1 } }, - { id: "ft_e4", text: "The tundra holds old magic in its ice. Old enough to be worth distilling.", effect: { type: "essence_gain", amount: 500 } }, + { materialId: "glacial_ice", maxQuantity: 7, minQuantity: 3, weight: 3 }, + { + materialId: "frost_crystal", + maxQuantity: 2, + minQuantity: 1, + weight: 2, + }, ], + + zoneId: "frozen_peaks", }, { - id: "void_rift", - name: "The Void Rift", - description: "A tear in reality that appeared after the Void Titan's defeat, miles above the world. Something leaks through it constantly. Mostly harmless. Mostly.", - zoneId: "frozen_peaks", - durationSeconds: 32400, // 9h + description: + "A tear in reality that appeared after the Void Titan's defeat, miles above the world. Something leaks through it constantly. Mostly harmless. Mostly.", + durationSeconds: 32_400, + events: [ + { + effect: { amount: 35_000, type: "gold_gain" }, + id: "vr_e1", + text: "Something fell through the rift that clearly came from somewhere with better coinage than here.", + }, + { + effect: { amount: 14_000, type: "gold_loss" }, + id: "vr_e2", + text: "The rift's instability cost your scouts their equipment. They came back mostly intact.", + }, + { + effect: { + materialId: "void_shard", + quantity: 1, + type: "material_gain", + }, + id: "vr_e3", + text: "A void shard materialised near the rift edge and was quickly collected before it destabilised.", + }, + { + effect: { amount: 800, type: "essence_gain" }, + id: "vr_e4", + text: "The rift leaks something that is not quite essence but distils into it cleanly.", + }, + ], + id: "void_rift", + name: "The Void Rift", + // 9h possibleMaterials: [ - { materialId: "frost_crystal", minQuantity: 1, maxQuantity: 3, weight: 3 }, - { materialId: "void_shard", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "vr_e1", text: "Something fell through the rift that clearly came from somewhere with better coinage than here.", effect: { type: "gold_gain", amount: 35000 } }, - { id: "vr_e2", text: "The rift's instability cost your scouts their equipment. They came back mostly intact.", effect: { type: "gold_loss", amount: 14000 } }, - { id: "vr_e3", text: "A void shard materialised near the rift edge and was quickly collected before it destabilised.", effect: { type: "material_gain", materialId: "void_shard", quantity: 1 } }, - { id: "vr_e4", text: "The rift leaks something that is not quite essence but distils into it cleanly.", effect: { type: "essence_gain", amount: 800 } }, + { + materialId: "frost_crystal", + maxQuantity: 3, + minQuantity: 1, + weight: 3, + }, + { materialId: "void_shard", maxQuantity: 2, minQuantity: 1, weight: 2 }, ], + + zoneId: "frozen_peaks", }, { - id: "summit_shrine", - name: "The Summit Shrine", - description: "At the absolute peak, a shrine nobody remembers building. The prayers still tied to its poles are in a language no scholar has identified. Offerings remain.", - zoneId: "frozen_peaks", - durationSeconds: 43200, // 12h + description: + "At the absolute peak, a shrine nobody remembers building. The prayers still tied to its poles are in a language no scholar has identified. Offerings remain.", + durationSeconds: 43_200, + events: [ + { + effect: { amount: 60_000, type: "gold_gain" }, + id: "ss_e1", + text: "The shrine accepts a modest offering and returns considerably more than was given. Old gods keep interesting books.", + }, + { + effect: { amount: 22_000, type: "gold_loss" }, + id: "ss_e2", + text: "The shrine takes offense at something. The scouts are fine. Poorer, but fine.", + }, + { + effect: { + materialId: "void_shard", + quantity: 1, + type: "material_gain", + }, + id: "ss_e3", + text: "A void shard rests at the shrine's base, apparently left as an offering by someone else entirely.", + }, + { + effect: { amount: 1500, type: "essence_gain" }, + id: "ss_e4", + text: "The shrine radiates an essence so dense it practically condenses on the scouts' skin.", + }, + ], + id: "summit_shrine", + name: "The Summit Shrine", + // 12h possibleMaterials: [ - { materialId: "frost_crystal", minQuantity: 2, maxQuantity: 4, weight: 3 }, - { materialId: "void_shard", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "ss_e1", text: "The shrine accepts a modest offering and returns considerably more than was given. Old gods keep interesting books.", effect: { type: "gold_gain", amount: 60000 } }, - { id: "ss_e2", text: "The shrine takes offense at something. The scouts are fine. Poorer, but fine.", effect: { type: "gold_loss", amount: 22000 } }, - { id: "ss_e3", text: "A void shard rests at the shrine's base, apparently left as an offering by someone else entirely.", effect: { type: "material_gain", materialId: "void_shard", quantity: 1 } }, - { id: "ss_e4", text: "The shrine radiates an essence so dense it practically condenses on the scouts' skin.", effect: { type: "essence_gain", amount: 1500 } }, + { + materialId: "frost_crystal", + maxQuantity: 4, + minQuantity: 2, + weight: 3, + }, + { materialId: "void_shard", maxQuantity: 2, minQuantity: 1, weight: 2 }, ], + + zoneId: "frozen_peaks", }, // ── Zone 4: shadow_marshes ──────────────────────────────────────────────── { - id: "fog_hollow", - name: "The Fog Hollow", - description: "A depression in the marsh where the fog never fully lifts. Sound behaves differently here. Your scouts can hear things they probably should not.", - zoneId: "shadow_marshes", - durationSeconds: 18000, // 5h + description: + "A depression in the marsh where the fog never fully lifts. Sound behaves differently here. Your scouts can hear things they probably should not.", + durationSeconds: 18_000, + events: [ + { + effect: { amount: 15_000, type: "gold_gain" }, + id: "fh_e1", + text: "A chest half-sunk in the mud, clearly not native to the marsh, proves worth the unpleasantness of retrieving.", + }, + { + effect: { amount: 6000, type: "gold_loss" }, + id: "fh_e2", + text: "Something in the fog made the scouts spend an hour walking in circles. They returned with less than they left with.", + }, + { + effect: { + materialId: "marsh_root", + quantity: 3, + type: "material_gain", + }, + id: "fh_e3", + text: "A stand of the toxic plants grows unusually dense in the hollow. Well-worth the careful harvest.", + }, + { + effect: { amount: 600, type: "essence_gain" }, + id: "fh_e4", + text: "The fog itself is distillable, in the right hands. Your alchemist has those hands.", + }, + ], + id: "fog_hollow", + name: "The Fog Hollow", + // 5h possibleMaterials: [ - { materialId: "marsh_root", minQuantity: 2, maxQuantity: 5, weight: 3 }, - ], - events: [ - { id: "fh_e1", text: "A chest half-sunk in the mud, clearly not native to the marsh, proves worth the unpleasantness of retrieving.", effect: { type: "gold_gain", amount: 15000 } }, - { id: "fh_e2", text: "Something in the fog made the scouts spend an hour walking in circles. They returned with less than they left with.", effect: { type: "gold_loss", amount: 6000 } }, - { id: "fh_e3", text: "A stand of the toxic plants grows unusually dense in the hollow. Well-worth the careful harvest.", effect: { type: "material_gain", materialId: "marsh_root", quantity: 3 } }, - { id: "fh_e4", text: "The fog itself is distillable, in the right hands. Your alchemist has those hands.", effect: { type: "essence_gain", amount: 600 } }, + { materialId: "marsh_root", maxQuantity: 5, minQuantity: 2, weight: 3 }, ], + + zoneId: "shadow_marshes", }, { - id: "dark_grotto", - name: "The Dark Grotto", - description: "A cave system beneath the marsh floor. The water drips through the ceiling in patterns that look deliberate. Nothing down here needs eyes to find you.", - zoneId: "shadow_marshes", - durationSeconds: 36000, // 10h + description: + "A cave system beneath the marsh floor. The water drips through the ceiling in patterns that look deliberate. Nothing down here needs eyes to find you.", + durationSeconds: 36_000, + events: [ + { + effect: { amount: 35_000, type: "gold_gain" }, + id: "dg_e1", + text: "A cache of ancient marsh-trade goods, perfectly preserved in the airless cave, sells well on the surface.", + }, + { + effect: { amount: 13_000, type: "gold_loss" }, + id: "dg_e2", + text: "Something that did not need eyes found your scouts anyway. They escaped. Their cargo did not.", + }, + { + effect: { + materialId: "shadow_essence", + quantity: 1, + type: "material_gain", + }, + id: "dg_e3", + text: "Shadow essence has pooled in a low point in the cave, more than usual.", + }, + { + effect: { amount: 1000, type: "essence_gain" }, + id: "dg_e4", + text: "The darkness in the grotto is dense enough to be harvested directly, if you know the technique.", + }, + ], + id: "dark_grotto", + name: "The Dark Grotto", + // 10h possibleMaterials: [ - { materialId: "marsh_root", minQuantity: 3, maxQuantity: 7, weight: 3 }, - { materialId: "shadow_essence", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "dg_e1", text: "A cache of ancient marsh-trade goods, perfectly preserved in the airless cave, sells well on the surface.", effect: { type: "gold_gain", amount: 35000 } }, - { id: "dg_e2", text: "Something that did not need eyes found your scouts anyway. They escaped. Their cargo did not.", effect: { type: "gold_loss", amount: 13000 } }, - { id: "dg_e3", text: "Shadow essence has pooled in a low point in the cave, more than usual.", effect: { type: "material_gain", materialId: "shadow_essence", quantity: 1 } }, - { id: "dg_e4", text: "The darkness in the grotto is dense enough to be harvested directly, if you know the technique.", effect: { type: "essence_gain", amount: 1000 } }, + { materialId: "marsh_root", maxQuantity: 7, minQuantity: 3, weight: 3 }, + { + materialId: "shadow_essence", + maxQuantity: 2, + minQuantity: 1, + weight: 2, + }, ], + + zoneId: "shadow_marshes", }, { - id: "cursed_barrow", - name: "The Cursed Barrow", - description: "A burial mound. Something was interred here that should not have been — or perhaps something interred itself, which is a different and more troubling problem.", - zoneId: "shadow_marshes", - durationSeconds: 54000, // 15h + description: + "A burial mound. Something was interred here that should not have been — or perhaps something interred itself, which is a different and more troubling problem.", + durationSeconds: 54_000, + events: [ + { + effect: { amount: 70_000, type: "gold_gain" }, + id: "cb_e1", + text: "The barrow holds grave goods from three separate eras, each buried by someone who found the previous occupant's things and thought they could do better.", + }, + { + effect: { amount: 25_000, type: "gold_loss" }, + id: "cb_e2", + text: "The curse extends further than the survey suggested. Your scouts are fine. Their supply cache is gone.", + }, + { + effect: { + materialId: "cursed_bone", + quantity: 1, + type: "material_gain", + }, + id: "cb_e3", + text: "A cursed bone from the barrow's deepest chamber, clearly the source of the whole business.", + }, + { + effect: { amount: 1800, type: "essence_gain" }, + id: "cb_e4", + text: "The barrow's curse is rich in essence. Ancient and potent.", + }, + ], + id: "cursed_barrow", + name: "The Cursed Barrow", + // 15h possibleMaterials: [ - { materialId: "shadow_essence", minQuantity: 1, maxQuantity: 3, weight: 3 }, - { materialId: "cursed_bone", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "cb_e1", text: "The barrow holds grave goods from three separate eras, each buried by someone who found the previous occupant's things and thought they could do better.", effect: { type: "gold_gain", amount: 70000 } }, - { id: "cb_e2", text: "The curse extends further than the survey suggested. Your scouts are fine. Their supply cache is gone.", effect: { type: "gold_loss", amount: 25000 } }, - { id: "cb_e3", text: "A cursed bone from the barrow's deepest chamber, clearly the source of the whole business.", effect: { type: "material_gain", materialId: "cursed_bone", quantity: 1 } }, - { id: "cb_e4", text: "The barrow's curse is rich in essence. Ancient and potent.", effect: { type: "essence_gain", amount: 1800 } }, + { + materialId: "shadow_essence", + maxQuantity: 3, + minQuantity: 1, + weight: 3, + }, + { materialId: "cursed_bone", maxQuantity: 2, minQuantity: 1, weight: 2 }, ], + + zoneId: "shadow_marshes", }, { - id: "marsh_depths", - name: "The Marsh Depths", - description: "The bottommost point of the Shadow Marshes, where the water is perfectly still and perfectly black. Your scouts can see the bottom. The bottom is very far down.", - zoneId: "shadow_marshes", - durationSeconds: 72000, // 20h + description: + "The bottommost point of the Shadow Marshes, where the water is perfectly still and perfectly black. Your scouts can see the bottom. The bottom is very far down.", + durationSeconds: 72_000, + events: [ + { + effect: { amount: 120_000, type: "gold_gain" }, + id: "md_e1", + text: "The depths yield something that came from somewhere else entirely. It is, fortunately, convertible to coin.", + }, + { + effect: { amount: 45_000, type: "gold_loss" }, + id: "md_e2", + text: "Something from the depths followed your scouts partway back. The encounter was costly.", + }, + { + effect: { + materialId: "cursed_bone", + quantity: 1, + type: "material_gain", + }, + id: "md_e3", + text: "A cursed bone surfaces unprompted, as if being offered. You take it anyway.", + }, + { + effect: { amount: 3000, type: "essence_gain" }, + id: "md_e4", + text: "The depth-darkness is extraordinary in its potency. Your alchemist will be busy for weeks.", + }, + ], + id: "marsh_depths", + name: "The Marsh Depths", + // 20h possibleMaterials: [ - { materialId: "shadow_essence", minQuantity: 2, maxQuantity: 4, weight: 3 }, - { materialId: "cursed_bone", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "md_e1", text: "The depths yield something that came from somewhere else entirely. It is, fortunately, convertible to coin.", effect: { type: "gold_gain", amount: 120000 } }, - { id: "md_e2", text: "Something from the depths followed your scouts partway back. The encounter was costly.", effect: { type: "gold_loss", amount: 45000 } }, - { id: "md_e3", text: "A cursed bone surfaces unprompted, as if being offered. You take it anyway.", effect: { type: "material_gain", materialId: "cursed_bone", quantity: 1 } }, - { id: "md_e4", text: "The depth-darkness is extraordinary in its potency. Your alchemist will be busy for weeks.", effect: { type: "essence_gain", amount: 3000 } }, + { + materialId: "shadow_essence", + maxQuantity: 4, + minQuantity: 2, + weight: 3, + }, + { materialId: "cursed_bone", maxQuantity: 2, minQuantity: 1, weight: 2 }, ], + + zoneId: "shadow_marshes", }, // ── Zone 5: volcanic_depths ─────────────────────────────────────────────── { - id: "magma_tunnel", - name: "The Magma Tunnel", - description: "A natural tunnel cut by ancient lava flows. Still warm. The walls glow faintly orange in some sections, which is either residual heat or something else.", - zoneId: "volcanic_depths", - durationSeconds: 25200, // 7h + description: + "A natural tunnel cut by ancient lava flows. Still warm. The walls glow faintly orange in some sections, which is either residual heat or something else.", + durationSeconds: 25_200, + events: [ + { + effect: { amount: 30_000, type: "gold_gain" }, + id: "mt_e1", + text: "A geothermal vent reveals a mineral deposit worth considerably more than the heat required to extract it.", + }, + { + effect: { amount: 12_000, type: "gold_loss" }, + id: "mt_e2", + text: "A sudden surge of superheated gas drove your scouts back and melted part of their equipment.", + }, + { + effect: { + materialId: "magma_stone", + quantity: 3, + type: "material_gain", + }, + id: "mt_e3", + text: "The tunnel walls yield magma stones that cooled particularly slowly — higher quality than usual.", + }, + { + effect: { amount: 1000, type: "essence_gain" }, + id: "mt_e4", + text: "The tunnel's residual magical heat is distillable if you move quickly enough.", + }, + ], + id: "magma_tunnel", + name: "The Magma Tunnel", + // 7h possibleMaterials: [ - { materialId: "magma_stone", minQuantity: 2, maxQuantity: 5, weight: 3 }, - ], - events: [ - { id: "mt_e1", text: "A geothermal vent reveals a mineral deposit worth considerably more than the heat required to extract it.", effect: { type: "gold_gain", amount: 30000 } }, - { id: "mt_e2", text: "A sudden surge of superheated gas drove your scouts back and melted part of their equipment.", effect: { type: "gold_loss", amount: 12000 } }, - { id: "mt_e3", text: "The tunnel walls yield magma stones that cooled particularly slowly — higher quality than usual.", effect: { type: "material_gain", materialId: "magma_stone", quantity: 3 } }, - { id: "mt_e4", text: "The tunnel's residual magical heat is distillable if you move quickly enough.", effect: { type: "essence_gain", amount: 1000 } }, + { materialId: "magma_stone", maxQuantity: 5, minQuantity: 2, weight: 3 }, ], + + zoneId: "volcanic_depths", }, { - id: "forge_chamber", - name: "The Forge Chamber", - description: "An ancient workshop space, built into the volcano by whoever the fire elementals served before they served no one. The fires here never went out.", - zoneId: "volcanic_depths", - durationSeconds: 50400, // 14h + description: + "An ancient workshop space, built into the volcano by whoever the fire elementals served before they served no one. The fires here never went out.", + durationSeconds: 50_400, + events: [ + { + effect: { amount: 70_000, type: "gold_gain" }, + id: "fc_e1", + text: "The forge chamber holds completed works, abandoned mid-project. Valuable to the right buyers.", + }, + { + effect: { amount: 28_000, type: "gold_loss" }, + id: "fc_e2", + text: "The elementals are more territorial than anticipated. Your scouts withdrew with minor burns and major losses.", + }, + { + effect: { + materialId: "ember_crystal", + quantity: 1, + type: "material_gain", + }, + id: "fc_e3", + text: "An ember crystal grows naturally in the forge's residual heat — considerably larger than typical.", + }, + { + effect: { amount: 2000, type: "essence_gain" }, + id: "fc_e4", + text: "The forge's fire-essence is ancient and extremely concentrated.", + }, + ], + id: "forge_chamber", + name: "The Forge Chamber", + // 14h possibleMaterials: [ - { materialId: "magma_stone", minQuantity: 3, maxQuantity: 7, weight: 3 }, - { materialId: "ember_crystal", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "fc_e1", text: "The forge chamber holds completed works, abandoned mid-project. Valuable to the right buyers.", effect: { type: "gold_gain", amount: 70000 } }, - { id: "fc_e2", text: "The elementals are more territorial than anticipated. Your scouts withdrew with minor burns and major losses.", effect: { type: "gold_loss", amount: 28000 } }, - { id: "fc_e3", text: "An ember crystal grows naturally in the forge's residual heat — considerably larger than typical.", effect: { type: "material_gain", materialId: "ember_crystal", quantity: 1 } }, - { id: "fc_e4", text: "The forge's fire-essence is ancient and extremely concentrated.", effect: { type: "essence_gain", amount: 2000 } }, + { materialId: "magma_stone", maxQuantity: 7, minQuantity: 3, weight: 3 }, + { + materialId: "ember_crystal", + maxQuantity: 2, + minQuantity: 1, + weight: 2, + }, ], + + zoneId: "volcanic_depths", }, { - id: "fire_temple", - name: "The Fire Temple", - description: "A place of worship for entities that have never met a god but found the general idea appealing and decided to be worshipped instead. The fire elementals receive visitors here.", - zoneId: "volcanic_depths", - durationSeconds: 75600, // 21h + description: + "A place of worship for entities that have never met a god but found the general idea appealing and decided to be worshipped instead. The fire elementals receive visitors here.", + durationSeconds: 75_600, + events: [ + { + effect: { amount: 130_000, type: "gold_gain" }, + id: "fte_e1", + text: "The temple accepts tribute and returns a blessing in the form of a significant gold windfall.", + }, + { + effect: { amount: 50_000, type: "gold_loss" }, + id: "fte_e2", + text: "The temple does not accept the tribute offered. The scouts return lacking both coin and dignity.", + }, + { + effect: { + materialId: "legendary_ore", + quantity: 1, + type: "material_gain", + }, + id: "fte_e3", + text: "A temple offering from a long-dead supplicant includes a piece of legendary ore, untouched.", + }, + { + effect: { amount: 4000, type: "essence_gain" }, + id: "fte_e4", + text: "The temple's sacred fire is extraordinary for distillation purposes.", + }, + ], + id: "fire_temple", + name: "The Fire Temple", + // 21h possibleMaterials: [ - { materialId: "ember_crystal", minQuantity: 1, maxQuantity: 3, weight: 3 }, - { materialId: "legendary_ore", minQuantity: 1, maxQuantity: 1, weight: 1 }, - ], - events: [ - { id: "fte_e1", text: "The temple accepts tribute and returns a blessing in the form of a significant gold windfall.", effect: { type: "gold_gain", amount: 130000 } }, - { id: "fte_e2", text: "The temple does not accept the tribute offered. The scouts return lacking both coin and dignity.", effect: { type: "gold_loss", amount: 50000 } }, - { id: "fte_e3", text: "A temple offering from a long-dead supplicant includes a piece of legendary ore, untouched.", effect: { type: "material_gain", materialId: "legendary_ore", quantity: 1 } }, - { id: "fte_e4", text: "The temple's sacred fire is extraordinary for distillation purposes.", effect: { type: "essence_gain", amount: 4000 } }, + { + materialId: "ember_crystal", + maxQuantity: 3, + minQuantity: 1, + weight: 3, + }, + { + materialId: "legendary_ore", + maxQuantity: 1, + minQuantity: 1, + weight: 1, + }, ], + + zoneId: "volcanic_depths", }, { - id: "core_descent", - name: "The Core Descent", - description: "The lowest point your guild can reach — close enough to the planet's core that the rocks bleed metal and the air shimmers with heat haze that never quite resolves into anything.", - zoneId: "volcanic_depths", - durationSeconds: 100800, // 28h + description: + "The lowest point your guild can reach — close enough to the planet's core that the rocks bleed metal and the air shimmers with heat haze that never quite resolves into anything.", + durationSeconds: 100_800, + events: [ + { + effect: { amount: 250_000, type: "gold_gain" }, + id: "cd_e1", + text: "At this depth, the rocks yield metals that do not exist on the surface. The sale price reflects this.", + }, + { + effect: { amount: 90_000, type: "gold_loss" }, + id: "cd_e2", + text: "A thermal event beyond anything survivable forced your scouts to abandon everything and run.", + }, + { + effect: { + materialId: "legendary_ore", + quantity: 1, + type: "material_gain", + }, + id: "cd_e3", + text: "The legendary ore seam here is deeper and richer than any found above.", + }, + { + effect: { amount: 6000, type: "essence_gain" }, + id: "cd_e4", + text: "Core-essence is unlike anything found closer to the surface. Your alchemist has no words. Just a very large smile.", + }, + ], + id: "core_descent", + name: "The Core Descent", + // 28h possibleMaterials: [ - { materialId: "ember_crystal", minQuantity: 2, maxQuantity: 4, weight: 3 }, - { materialId: "legendary_ore", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "cd_e1", text: "At this depth, the rocks yield metals that do not exist on the surface. The sale price reflects this.", effect: { type: "gold_gain", amount: 250000 } }, - { id: "cd_e2", text: "A thermal event beyond anything survivable forced your scouts to abandon everything and run.", effect: { type: "gold_loss", amount: 90000 } }, - { id: "cd_e3", text: "The legendary ore seam here is deeper and richer than any found above.", effect: { type: "material_gain", materialId: "legendary_ore", quantity: 1 } }, - { id: "cd_e4", text: "Core-essence is unlike anything found closer to the surface. Your alchemist has no words. Just a very large smile.", effect: { type: "essence_gain", amount: 6000 } }, + { + materialId: "ember_crystal", + maxQuantity: 4, + minQuantity: 2, + weight: 3, + }, + { + materialId: "legendary_ore", + maxQuantity: 2, + minQuantity: 1, + weight: 2, + }, ], + + zoneId: "volcanic_depths", }, // ── Zone 6: astral_void ─────────────────────────────────────────────────── { - id: "star_field", - name: "The Star Field", - description: "Open void between reality and whatever lies beyond it. Stars in various states of life and death drift past. Your scouts learn very quickly not to touch them.", - zoneId: "astral_void", - durationSeconds: 36000, // 10h + description: + "Open void between reality and whatever lies beyond it. Stars in various states of life and death drift past. Your scouts learn very quickly not to touch them.", + durationSeconds: 36_000, + events: [ + { + effect: { amount: 500_000, type: "gold_gain" }, + id: "sf_e1", + text: "A dying star sheds its outer layers nearby. Your scouts harvest the most valuable parts.", + }, + { + effect: { amount: 200_000, type: "gold_loss" }, + id: "sf_e2", + text: "A stellar event of the kind that ends civilisations elsewhere merely inconvenienced your scouts and destroyed their equipment.", + }, + { + effect: { materialId: "stardust", quantity: 4, type: "material_gain" }, + id: "sf_e3", + text: "A particularly fresh stardust deposit, from a star that died recently enough to still be warm.", + }, + { + effect: { amount: 10_000, type: "essence_gain" }, + id: "sf_e4", + text: "Void-essence is nothing like mortal-world essence but converts cleanly enough.", + }, + ], + id: "star_field", + name: "The Star Field", + // 10h possibleMaterials: [ - { materialId: "stardust", minQuantity: 3, maxQuantity: 7, weight: 3 }, - ], - events: [ - { id: "sf_e1", text: "A dying star sheds its outer layers nearby. Your scouts harvest the most valuable parts.", effect: { type: "gold_gain", amount: 500000 } }, - { id: "sf_e2", text: "A stellar event of the kind that ends civilisations elsewhere merely inconvenienced your scouts and destroyed their equipment.", effect: { type: "gold_loss", amount: 200000 } }, - { id: "sf_e3", text: "A particularly fresh stardust deposit, from a star that died recently enough to still be warm.", effect: { type: "material_gain", materialId: "stardust", quantity: 4 } }, - { id: "sf_e4", text: "Void-essence is nothing like mortal-world essence but converts cleanly enough.", effect: { type: "essence_gain", amount: 10000 } }, + { materialId: "stardust", maxQuantity: 7, minQuantity: 3, weight: 3 }, ], + + zoneId: "astral_void", }, { - id: "probability_sea", - name: "The Probability Sea", - description: "A region where every possible outcome is equally real and they jostle each other for space. Your scouts exist in several states simultaneously here and find it disorienting.", - zoneId: "astral_void", - durationSeconds: 72000, // 20h + description: + "A region where every possible outcome is equally real and they jostle each other for space. Your scouts exist in several states simultaneously here and find it disorienting.", + durationSeconds: 72_000, + events: [ + { + effect: { amount: 1_000_000, type: "gold_gain" }, + id: "ps_e1", + text: "In the probability sea, your scouts found a version of events where someone paid them very well. They brought the coin back.", + }, + { + effect: { amount: 400_000, type: "gold_loss" }, + id: "ps_e2", + text: "A bad probability collapsed into the scouts' timeline. The version where nothing went wrong was, unfortunately, not this one.", + }, + { + effect: { + materialId: "astral_thread", + quantity: 1, + type: "material_gain", + }, + id: "ps_e3", + text: "An astral thread, fresh and unravelled from a probability that just resolved, is carefully harvested.", + }, + { + effect: { amount: 20_000, type: "essence_gain" }, + id: "ps_e4", + text: "Probability-essence is volatile but distils into something extraordinary.", + }, + ], + id: "probability_sea", + name: "The Probability Sea", + // 20h possibleMaterials: [ - { materialId: "stardust", minQuantity: 4, maxQuantity: 8, weight: 3 }, - { materialId: "astral_thread", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "ps_e1", text: "In the probability sea, your scouts found a version of events where someone paid them very well. They brought the coin back.", effect: { type: "gold_gain", amount: 1000000 } }, - { id: "ps_e2", text: "A bad probability collapsed into the scouts' timeline. The version where nothing went wrong was, unfortunately, not this one.", effect: { type: "gold_loss", amount: 400000 } }, - { id: "ps_e3", text: "An astral thread, fresh and unravelled from a probability that just resolved, is carefully harvested.", effect: { type: "material_gain", materialId: "astral_thread", quantity: 1 } }, - { id: "ps_e4", text: "Probability-essence is volatile but distils into something extraordinary.", effect: { type: "essence_gain", amount: 20000 } }, + { materialId: "stardust", maxQuantity: 8, minQuantity: 4, weight: 3 }, + { + materialId: "astral_thread", + maxQuantity: 2, + minQuantity: 1, + weight: 2, + }, ], + + zoneId: "astral_void", }, { - id: "void_current", - name: "The Void Current", - description: "A river of nothing flowing through the void. It carries things from everywhere to nowhere. Some of those things are valuable, if you know how to fish from a river of nothing.", - zoneId: "astral_void", - durationSeconds: 108000, // 30h + description: + "A river of nothing flowing through the void. It carries things from everywhere to nowhere. Some of those things are valuable, if you know how to fish from a river of nothing.", + durationSeconds: 108_000, + events: [ + { + effect: { amount: 2_000_000, type: "gold_gain" }, + id: "vc_e1", + text: "The current carried through a treasury's worth of lost wealth from across time. Your scouts intercepted it.", + }, + { + effect: { amount: 750_000, type: "gold_loss" }, + id: "vc_e2", + text: "The current caught two scouts and carried them downstream. They returned, eventually, with nothing.", + }, + { + effect: { + materialId: "void_crystal", + quantity: 1, + type: "material_gain", + }, + id: "vc_e3", + text: "A void crystal, carried from somewhere, is plucked from the current before it disappears.", + }, + { + effect: { amount: 40_000, type: "essence_gain" }, + id: "vc_e4", + text: "The current distils into essence of a quality your alchemist has never encountered before.", + }, + ], + id: "void_current", + name: "The Void Current", + // 30h possibleMaterials: [ - { materialId: "astral_thread", minQuantity: 1, maxQuantity: 3, weight: 3 }, - { materialId: "void_crystal", minQuantity: 1, maxQuantity: 1, weight: 1 }, - ], - events: [ - { id: "vc_e1", text: "The current carried through a treasury's worth of lost wealth from across time. Your scouts intercepted it.", effect: { type: "gold_gain", amount: 2000000 } }, - { id: "vc_e2", text: "The current caught two scouts and carried them downstream. They returned, eventually, with nothing.", effect: { type: "gold_loss", amount: 750000 } }, - { id: "vc_e3", text: "A void crystal, carried from somewhere, is plucked from the current before it disappears.", effect: { type: "material_gain", materialId: "void_crystal", quantity: 1 } }, - { id: "vc_e4", text: "The current distils into essence of a quality your alchemist has never encountered before.", effect: { type: "essence_gain", amount: 40000 } }, + { + materialId: "astral_thread", + maxQuantity: 3, + minQuantity: 1, + weight: 3, + }, + { materialId: "void_crystal", maxQuantity: 1, minQuantity: 1, weight: 1 }, ], + + zoneId: "astral_void", }, { - id: "null_zenith", - name: "The Null Zenith", - description: "The highest point of the astral void, where nothing exists so thoroughly that it becomes a kind of substance. Your scouts feel, for a moment, what it is like to be absolutely alone in all of existence.", - zoneId: "astral_void", - durationSeconds: 144000, // 40h + description: + "The highest point of the astral void, where nothing exists so thoroughly that it becomes a kind of substance. Your scouts feel, for a moment, what it is like to be absolutely alone in all of existence.", + durationSeconds: 144_000, + events: [ + { + effect: { amount: 4_000_000, type: "gold_gain" }, + id: "nz_e1", + text: "The null zenith grants a moment of perfect clarity. In that moment, wealth arrives from somewhere.", + }, + { + effect: { amount: 1_500_000, type: "gold_loss" }, + id: "nz_e2", + text: "The null zenith took something from your scouts. Possibly temporarily. The coin, certainly permanently.", + }, + { + effect: { + materialId: "void_crystal", + quantity: 1, + type: "material_gain", + }, + id: "nz_e3", + text: "A void crystal crystallises from the null itself, which should be impossible. It is very pretty.", + }, + { + effect: { amount: 80_000, type: "essence_gain" }, + id: "nz_e4", + text: "Null-essence is the rarest of the rare. Your alchemist faints, then recovers, then is extremely productive.", + }, + ], + id: "null_zenith", + name: "The Null Zenith", + // 40h possibleMaterials: [ - { materialId: "astral_thread", minQuantity: 2, maxQuantity: 4, weight: 3 }, - { materialId: "void_crystal", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "nz_e1", text: "The null zenith grants a moment of perfect clarity. In that moment, wealth arrives from somewhere.", effect: { type: "gold_gain", amount: 4000000 } }, - { id: "nz_e2", text: "The null zenith took something from your scouts. Possibly temporarily. The coin, certainly permanently.", effect: { type: "gold_loss", amount: 1500000 } }, - { id: "nz_e3", text: "A void crystal crystallises from the null itself, which should be impossible. It is very pretty.", effect: { type: "material_gain", materialId: "void_crystal", quantity: 1 } }, - { id: "nz_e4", text: "Null-essence is the rarest of the rare. Your alchemist faints, then recovers, then is extremely productive.", effect: { type: "essence_gain", amount: 80000 } }, + { + materialId: "astral_thread", + maxQuantity: 4, + minQuantity: 2, + weight: 3, + }, + { materialId: "void_crystal", maxQuantity: 2, minQuantity: 1, weight: 2 }, ], + + zoneId: "astral_void", }, // ── Zone 7: celestial_reaches ───────────────────────────────────────────── { - id: "light_spire", - name: "The Light Spire", - description: "A tower of compressed light older than the concept of architecture. The celestial host uses it as a marker. Your guild uses it as a starting point.", - zoneId: "celestial_reaches", - durationSeconds: 43200, // 12h + description: + "A tower of compressed light older than the concept of architecture. The celestial host uses it as a marker. Your guild uses it as a starting point.", + durationSeconds: 43_200, + events: [ + { + effect: { amount: 3_000_000, type: "gold_gain" }, + id: "ls_e1", + text: "The spire's light reveals something valuable that was hidden precisely because it was in plain sight.", + }, + { + effect: { amount: 1_200_000, type: "gold_loss" }, + id: "ls_e2", + text: "The celestial host noticed your scouts at the spire and expressed their disapproval economically.", + }, + { + effect: { + materialId: "celestial_dust", + quantity: 4, + type: "material_gain", + }, + id: "ls_e3", + text: "The spire sheds celestial dust in unusual quantities today. Your scouts gather what they can.", + }, + { + effect: { amount: 60_000, type: "essence_gain" }, + id: "ls_e4", + text: "Light-essence from the spire is extraordinarily pure.", + }, + ], + id: "light_spire", + name: "The Light Spire", + // 12h possibleMaterials: [ - { materialId: "celestial_dust", minQuantity: 3, maxQuantity: 7, weight: 3 }, - ], - events: [ - { id: "ls_e1", text: "The spire's light reveals something valuable that was hidden precisely because it was in plain sight.", effect: { type: "gold_gain", amount: 3000000 } }, - { id: "ls_e2", text: "The celestial host noticed your scouts at the spire and expressed their disapproval economically.", effect: { type: "gold_loss", amount: 1200000 } }, - { id: "ls_e3", text: "The spire sheds celestial dust in unusual quantities today. Your scouts gather what they can.", effect: { type: "material_gain", materialId: "celestial_dust", quantity: 4 } }, - { id: "ls_e4", text: "Light-essence from the spire is extraordinarily pure.", effect: { type: "essence_gain", amount: 60000 } }, + { + materialId: "celestial_dust", + maxQuantity: 7, + minQuantity: 3, + weight: 3, + }, ], + + zoneId: "celestial_reaches", }, { - id: "choir_hall", - name: "The Choir Hall", - description: "Where the celestial choir rehearses, continuously, for a performance that has been ongoing since before your world had an audience. The harmonics do things to objects in the vicinity.", - zoneId: "celestial_reaches", - durationSeconds: 86400, // 24h + description: + "Where the celestial choir rehearses, continuously, for a performance that has been ongoing since before your world had an audience. The harmonics do things to objects in the vicinity.", + durationSeconds: 86_400, + events: [ + { + effect: { amount: 6_000_000, type: "gold_gain" }, + id: "ch_e1", + text: "The choir's harmonics rearranged some nearby matter into something extremely valuable.", + }, + { + effect: { amount: 2_500_000, type: "gold_loss" }, + id: "ch_e2", + text: "The choir's harmonics are not always constructive. The scouts are intact. Their equipment is a different shape now.", + }, + { + effect: { + materialId: "divine_fragment", + quantity: 1, + type: "material_gain", + }, + id: "ch_e3", + text: "A divine fragment, shed from the choir's apparatus, is retrieved before it dissolves.", + }, + { + effect: { amount: 120_000, type: "essence_gain" }, + id: "ch_e4", + text: "Choir-essence resonates at a frequency that makes distillation almost automatic.", + }, + ], + id: "choir_hall", + name: "The Choir Hall", + // 24h possibleMaterials: [ - { materialId: "celestial_dust", minQuantity: 4, maxQuantity: 8, weight: 3 }, - { materialId: "divine_fragment", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "ch_e1", text: "The choir's harmonics rearranged some nearby matter into something extremely valuable.", effect: { type: "gold_gain", amount: 6000000 } }, - { id: "ch_e2", text: "The choir's harmonics are not always constructive. The scouts are intact. Their equipment is a different shape now.", effect: { type: "gold_loss", amount: 2500000 } }, - { id: "ch_e3", text: "A divine fragment, shed from the choir's apparatus, is retrieved before it dissolves.", effect: { type: "material_gain", materialId: "divine_fragment", quantity: 1 } }, - { id: "ch_e4", text: "Choir-essence resonates at a frequency that makes distillation almost automatic.", effect: { type: "essence_gain", amount: 120000 } }, + { + materialId: "celestial_dust", + maxQuantity: 8, + minQuantity: 4, + weight: 3, + }, + { + materialId: "divine_fragment", + maxQuantity: 2, + minQuantity: 1, + weight: 2, + }, ], + + zoneId: "celestial_reaches", }, { - id: "divine_court", - name: "The Divine Court", - description: "Where the celestial host adjudicates disputes that have been ongoing since before your sun was lit. The proceedings are extremely formal. Interrupting them is inadvisable.", - zoneId: "celestial_reaches", - durationSeconds: 129600, // 36h + description: + "Where the celestial host adjudicates disputes that have been ongoing since before your sun was lit. The proceedings are extremely formal. Interrupting them is inadvisable.", + durationSeconds: 129_600, + events: [ + { + effect: { amount: 12_000_000, type: "gold_gain" }, + id: "dc_e1", + text: "A case was decided in your guild's favour by technicality. The awarded wealth is considerable.", + }, + { + effect: { amount: 5_000_000, type: "gold_loss" }, + id: "dc_e2", + text: "A case was decided against your guild by technicality. The levied fine is considerable.", + }, + { + effect: { + materialId: "choir_shard", + quantity: 1, + type: "material_gain", + }, + id: "dc_e3", + text: "A choir shard falls from the court's apparatus during a particularly resonant moment of judgment.", + }, + { + effect: { amount: 250_000, type: "essence_gain" }, + id: "dc_e4", + text: "Divine court essence is regulated. Yours is unregulated. The difference is significant.", + }, + ], + id: "divine_court", + name: "The Divine Court", + // 36h possibleMaterials: [ - { materialId: "divine_fragment", minQuantity: 1, maxQuantity: 3, weight: 3 }, - { materialId: "choir_shard", minQuantity: 1, maxQuantity: 1, weight: 1 }, - ], - events: [ - { id: "dc_e1", text: "A case was decided in your guild's favour by technicality. The awarded wealth is considerable.", effect: { type: "gold_gain", amount: 12000000 } }, - { id: "dc_e2", text: "A case was decided against your guild by technicality. The levied fine is considerable.", effect: { type: "gold_loss", amount: 5000000 } }, - { id: "dc_e3", text: "A choir shard falls from the court's apparatus during a particularly resonant moment of judgment.", effect: { type: "material_gain", materialId: "choir_shard", quantity: 1 } }, - { id: "dc_e4", text: "Divine court essence is regulated. Yours is unregulated. The difference is significant.", effect: { type: "essence_gain", amount: 250000 } }, + { + materialId: "divine_fragment", + maxQuantity: 3, + minQuantity: 1, + weight: 3, + }, + { materialId: "choir_shard", maxQuantity: 1, minQuantity: 1, weight: 1 }, ], + + zoneId: "celestial_reaches", }, { - id: "celestial_vault", - name: "The Celestial Vault", - description: "Where the celestial host stores things they consider too valuable to use and too important to discard. Your guild has different ideas about what 'valuable' means.", - zoneId: "celestial_reaches", - durationSeconds: 172800, // 48h + description: + "Where the celestial host stores things they consider too valuable to use and too important to discard. Your guild has different ideas about what 'valuable' means.", + durationSeconds: 172_800, + events: [ + { + effect: { amount: 25_000_000, type: "gold_gain" }, + id: "cv_e1", + text: "The vault contains a complete set of something the celestials forgot about. The set is worth a considerable fortune.", + }, + { + effect: { amount: 10_000_000, type: "gold_loss" }, + id: "cv_e2", + text: "The vault's security noticed your scouts on the way out. The items were recovered. The scouts were charged a fine.", + }, + { + effect: { + materialId: "choir_shard", + quantity: 1, + type: "material_gain", + }, + id: "cv_e3", + text: "A choir shard in the vault's collection, misidentified and misfiled. It comes willingly.", + }, + { + effect: { amount: 500_000, type: "essence_gain" }, + id: "cv_e4", + text: "Vault-essence is the highest quality your alchemist has seen. She writes three papers about it immediately.", + }, + ], + id: "celestial_vault", + name: "The Celestial Vault", + // 48h possibleMaterials: [ - { materialId: "divine_fragment", minQuantity: 2, maxQuantity: 4, weight: 3 }, - { materialId: "choir_shard", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "cv_e1", text: "The vault contains a complete set of something the celestials forgot about. The set is worth a considerable fortune.", effect: { type: "gold_gain", amount: 25000000 } }, - { id: "cv_e2", text: "The vault's security noticed your scouts on the way out. The items were recovered. The scouts were charged a fine.", effect: { type: "gold_loss", amount: 10000000 } }, - { id: "cv_e3", text: "A choir shard in the vault's collection, misidentified and misfiled. It comes willingly.", effect: { type: "material_gain", materialId: "choir_shard", quantity: 1 } }, - { id: "cv_e4", text: "Vault-essence is the highest quality your alchemist has seen. She writes three papers about it immediately.", effect: { type: "essence_gain", amount: 500000 } }, + { + materialId: "divine_fragment", + maxQuantity: 4, + minQuantity: 2, + weight: 3, + }, + { materialId: "choir_shard", maxQuantity: 2, minQuantity: 1, weight: 2 }, ], + + zoneId: "celestial_reaches", }, // ── Zone 8: abyssal_trench ──────────────────────────────────────────────── { - id: "trench_entrance", - name: "The Trench Entrance", - description: "The lip of the trench, where the shelf drops away into depths that swallow light entirely. Your scouts can hear something breathing, very slowly, from far below.", - zoneId: "abyssal_trench", - durationSeconds: 50400, // 14h + description: + "The lip of the trench, where the shelf drops away into depths that swallow light entirely. Your scouts can hear something breathing, very slowly, from far below.", + durationSeconds: 50_400, + events: [ + { + effect: { amount: 8_000_000, type: "gold_gain" }, + id: "te_e1", + text: "The shelf is littered with things that fell in and were preserved by the pressure before being pushed back up.", + }, + { + effect: { amount: 3_000_000, type: "gold_loss" }, + id: "te_e2", + text: "Something reached up from below. Your scouts are fine. Their equipment is at the bottom of the trench.", + }, + { + effect: { + materialId: "trench_coral", + quantity: 4, + type: "material_gain", + }, + id: "te_e3", + text: "The trench coral near the entrance is more abundant than the survey suggested.", + }, + { + effect: { amount: 150_000, type: "essence_gain" }, + id: "te_e4", + text: "Trench-essence is crushingly dense. Very difficult to handle. Extremely rewarding.", + }, + ], + id: "trench_entrance", + name: "The Trench Entrance", + // 14h possibleMaterials: [ - { materialId: "trench_coral", minQuantity: 3, maxQuantity: 7, weight: 3 }, - ], - events: [ - { id: "te_e1", text: "The shelf is littered with things that fell in and were preserved by the pressure before being pushed back up.", effect: { type: "gold_gain", amount: 8000000 } }, - { id: "te_e2", text: "Something reached up from below. Your scouts are fine. Their equipment is at the bottom of the trench.", effect: { type: "gold_loss", amount: 3000000 } }, - { id: "te_e3", text: "The trench coral near the entrance is more abundant than the survey suggested.", effect: { type: "material_gain", materialId: "trench_coral", quantity: 4 } }, - { id: "te_e4", text: "Trench-essence is crushingly dense. Very difficult to handle. Extremely rewarding.", effect: { type: "essence_gain", amount: 150000 } }, + { materialId: "trench_coral", maxQuantity: 7, minQuantity: 3, weight: 3 }, ], + + zoneId: "abyssal_trench", }, { - id: "deep_current", - name: "The Deep Current", - description: "An underwater river at a depth that should be impossible to survive. Your scouts have learned, by necessity, to survive it anyway.", - zoneId: "abyssal_trench", - durationSeconds: 100800, // 28h + description: + "An underwater river at a depth that should be impossible to survive. Your scouts have learned, by necessity, to survive it anyway.", + durationSeconds: 100_800, + events: [ + { + effect: { amount: 18_000_000, type: "gold_gain" }, + id: "dep_e1", + text: "The current carries deposits from further and deeper than your scouts can navigate. Today it brought them something valuable.", + }, + { + effect: { amount: 7_000_000, type: "gold_loss" }, + id: "dep_e2", + text: "The current is faster today than yesterday. Your scouts arrived downstream, missing considerable cargo.", + }, + { + effect: { + materialId: "pressure_gem", + quantity: 1, + type: "material_gain", + }, + id: "dep_e3", + text: "A pressure gem tumbles along the current floor, collecting more pressure as it goes. Your scouts intercept it.", + }, + { + effect: { amount: 350_000, type: "essence_gain" }, + id: "dep_e4", + text: "Current-essence is dense and strange. Your alchemist has questions. The essence has no answers but lots of potential.", + }, + ], + id: "deep_current", + name: "The Deep Current", + // 28h possibleMaterials: [ - { materialId: "trench_coral", minQuantity: 4, maxQuantity: 9, weight: 3 }, - { materialId: "pressure_gem", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "dep_e1", text: "The current carries deposits from further and deeper than your scouts can navigate. Today it brought them something valuable.", effect: { type: "gold_gain", amount: 18000000 } }, - { id: "dep_e2", text: "The current is faster today than yesterday. Your scouts arrived downstream, missing considerable cargo.", effect: { type: "gold_loss", amount: 7000000 } }, - { id: "dep_e3", text: "A pressure gem tumbles along the current floor, collecting more pressure as it goes. Your scouts intercept it.", effect: { type: "material_gain", materialId: "pressure_gem", quantity: 1 } }, - { id: "dep_e4", text: "Current-essence is dense and strange. Your alchemist has questions. The essence has no answers but lots of potential.", effect: { type: "essence_gain", amount: 350000 } }, + { materialId: "trench_coral", maxQuantity: 9, minQuantity: 4, weight: 3 }, + { materialId: "pressure_gem", maxQuantity: 2, minQuantity: 1, weight: 2 }, ], + + zoneId: "abyssal_trench", }, { - id: "sunless_chamber", - name: "The Sunless Chamber", - description: "A space at the bottom of the trench so far from light that light has no meaning here. Something has been in this chamber for so long it no longer needs to breathe.", - zoneId: "abyssal_trench", - durationSeconds: 151200, // 42h + description: + "A space at the bottom of the trench so far from light that light has no meaning here. Something has been in this chamber for so long it no longer needs to breathe.", + durationSeconds: 151_200, + events: [ + { + effect: { amount: 40_000_000, type: "gold_gain" }, + id: "sc_e1", + text: "The chamber holds things that have been here since before there was a bottom to the sea. They are worth a great deal.", + }, + { + effect: { amount: 15_000_000, type: "gold_loss" }, + id: "sc_e2", + text: "Something in the chamber objects to being disturbed. It does not give chase. It simply ensures your scouts leave lighter.", + }, + { + effect: { + materialId: "ancient_tooth", + quantity: 1, + type: "material_gain", + }, + id: "sc_e3", + text: "An ancient tooth, clearly from the chamber's oldest resident, is found near the entrance.", + }, + { + effect: { amount: 700_000, type: "essence_gain" }, + id: "sc_e4", + text: "Sunless-essence is unlike anything from above. It seems to draw light into itself. Remarkable.", + }, + ], + id: "sunless_chamber", + name: "The Sunless Chamber", + // 42h possibleMaterials: [ - { materialId: "pressure_gem", minQuantity: 1, maxQuantity: 3, weight: 3 }, - { materialId: "ancient_tooth", minQuantity: 1, maxQuantity: 1, weight: 1 }, - ], - events: [ - { id: "sc_e1", text: "The chamber holds things that have been here since before there was a bottom to the sea. They are worth a great deal.", effect: { type: "gold_gain", amount: 40000000 } }, - { id: "sc_e2", text: "Something in the chamber objects to being disturbed. It does not give chase. It simply ensures your scouts leave lighter.", effect: { type: "gold_loss", amount: 15000000 } }, - { id: "sc_e3", text: "An ancient tooth, clearly from the chamber's oldest resident, is found near the entrance.", effect: { type: "material_gain", materialId: "ancient_tooth", quantity: 1 } }, - { id: "sc_e4", text: "Sunless-essence is unlike anything from above. It seems to draw light into itself. Remarkable.", effect: { type: "essence_gain", amount: 700000 } }, + { materialId: "pressure_gem", maxQuantity: 3, minQuantity: 1, weight: 3 }, + { + materialId: "ancient_tooth", + maxQuantity: 1, + minQuantity: 1, + weight: 1, + }, ], + + zoneId: "abyssal_trench", }, { - id: "the_waiting_place", - name: "The Waiting Place", - description: "The absolute bottom of the trench. Something is here. It has been here since before your world was made. It is, today, patient. Your scouts are not sure this is always the case.", - zoneId: "abyssal_trench", - durationSeconds: 201600, // 56h + description: + "The absolute bottom of the trench. Something is here. It has been here since before your world was made. It is, today, patient. Your scouts are not sure this is always the case.", + durationSeconds: 201_600, + events: [ + { + effect: { amount: 80_000_000, type: "gold_gain" }, + id: "wp_e1", + text: "Whatever waits here acknowledges your guild's presence with something that translates, approximately, to wealth.", + }, + { + effect: { amount: 30_000_000, type: "gold_loss" }, + id: "wp_e2", + text: "Whatever waits here acknowledges your guild's presence in a way that translates, approximately, to a fine.", + }, + { + effect: { + materialId: "ancient_tooth", + quantity: 1, + type: "material_gain", + }, + id: "wp_e3", + text: "An ancient tooth surfaces, offered. Taking it feels like something significant. It probably is.", + }, + { + effect: { amount: 1_500_000, type: "essence_gain" }, + id: "wp_e4", + text: "Waiting-essence is old beyond measure. Your alchemist refuses to speculate about what it once was.", + }, + ], + id: "the_waiting_place", + name: "The Waiting Place", + // 56h possibleMaterials: [ - { materialId: "pressure_gem", minQuantity: 2, maxQuantity: 4, weight: 3 }, - { materialId: "ancient_tooth", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "wp_e1", text: "Whatever waits here acknowledges your guild's presence with something that translates, approximately, to wealth.", effect: { type: "gold_gain", amount: 80000000 } }, - { id: "wp_e2", text: "Whatever waits here acknowledges your guild's presence in a way that translates, approximately, to a fine.", effect: { type: "gold_loss", amount: 30000000 } }, - { id: "wp_e3", text: "An ancient tooth surfaces, offered. Taking it feels like something significant. It probably is.", effect: { type: "material_gain", materialId: "ancient_tooth", quantity: 1 } }, - { id: "wp_e4", text: "Waiting-essence is old beyond measure. Your alchemist refuses to speculate about what it once was.", effect: { type: "essence_gain", amount: 1500000 } }, + { materialId: "pressure_gem", maxQuantity: 4, minQuantity: 2, weight: 3 }, + { + materialId: "ancient_tooth", + maxQuantity: 2, + minQuantity: 1, + weight: 2, + }, ], + + zoneId: "abyssal_trench", }, // ── Zone 9: infernal_court ──────────────────────────────────────────────── { - id: "demon_market", - name: "The Demon Market", - description: "An open-air market in the court's outer districts. The vendors sell things that were not legally obtained, in exchange for things that should not legally exist.", - zoneId: "infernal_court", - durationSeconds: 57600, // 16h + description: + "An open-air market in the court's outer districts. The vendors sell things that were not legally obtained, in exchange for things that should not legally exist.", + durationSeconds: 57_600, + events: [ + { + effect: { amount: 20_000_000, type: "gold_gain" }, + id: "dm_e1", + text: "Your scouts negotiated a trade that was, by infernal standards, entirely fair. By mortal standards, extraordinary.", + }, + { + effect: { amount: 8_000_000, type: "gold_loss" }, + id: "dm_e2", + text: "A market vendor offered a deal that seemed reasonable. Your scouts are learning. Too slowly.", + }, + { + effect: { + materialId: "brimstone_flake", + quantity: 4, + type: "material_gain", + }, + id: "dm_e3", + text: "A vendor selling brimstone flakes has surplus today. Your scouts negotiate bulk pricing.", + }, + { + effect: { amount: 400_000, type: "essence_gain" }, + id: "dm_e4", + text: "Market-essence carries the weight of every deal ever made here. There have been many deals.", + }, + ], + id: "demon_market", + name: "The Demon Market", + // 16h possibleMaterials: [ - { materialId: "brimstone_flake", minQuantity: 3, maxQuantity: 7, weight: 3 }, - ], - events: [ - { id: "dm_e1", text: "Your scouts negotiated a trade that was, by infernal standards, entirely fair. By mortal standards, extraordinary.", effect: { type: "gold_gain", amount: 20000000 } }, - { id: "dm_e2", text: "A market vendor offered a deal that seemed reasonable. Your scouts are learning. Too slowly.", effect: { type: "gold_loss", amount: 8000000 } }, - { id: "dm_e3", text: "A vendor selling brimstone flakes has surplus today. Your scouts negotiate bulk pricing.", effect: { type: "material_gain", materialId: "brimstone_flake", quantity: 4 } }, - { id: "dm_e4", text: "Market-essence carries the weight of every deal ever made here. There have been many deals.", effect: { type: "essence_gain", amount: 400000 } }, + { + materialId: "brimstone_flake", + maxQuantity: 7, + minQuantity: 3, + weight: 3, + }, ], + + zoneId: "infernal_court", }, { - id: "torment_hall", - name: "The Torment Hall", - description: "Where the court processes those who lost their cases. Your scouts move through it quickly and look at nothing. They still hear everything.", - zoneId: "infernal_court", - durationSeconds: 115200, // 32h + description: + "Where the court processes those who lost their cases. Your scouts move through it quickly and look at nothing. They still hear everything.", + durationSeconds: 115_200, + events: [ + { + effect: { amount: 45_000_000, type: "gold_gain" }, + id: "th_e1", + text: "A case file, misfiled in the hall, contains a writ authorising a wealth disbursement. Your guild is not the named recipient, but the court's filing system is poor.", + }, + { + effect: { amount: 18_000_000, type: "gold_loss" }, + id: "th_e2", + text: "A hall official noticed your scouts and extracted what he considered an appropriate visitation fee.", + }, + { + effect: { + materialId: "demon_ichor", + quantity: 1, + type: "material_gain", + }, + id: "th_e3", + text: "Demon ichor pools on the hall floor. Your alchemist's instructions were very specific about this.", + }, + { + effect: { amount: 900_000, type: "essence_gain" }, + id: "th_e4", + text: "Torment-essence is potent in the way that only very old suffering can be. Your alchemist handles it carefully.", + }, + ], + id: "torment_hall", + name: "The Torment Hall", + // 32h possibleMaterials: [ - { materialId: "brimstone_flake", minQuantity: 4, maxQuantity: 9, weight: 3 }, - { materialId: "demon_ichor", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "th_e1", text: "A case file, misfiled in the hall, contains a writ authorising a wealth disbursement. Your guild is not the named recipient, but the court's filing system is poor.", effect: { type: "gold_gain", amount: 45000000 } }, - { id: "th_e2", text: "A hall official noticed your scouts and extracted what he considered an appropriate visitation fee.", effect: { type: "gold_loss", amount: 18000000 } }, - { id: "th_e3", text: "Demon ichor pools on the hall floor. Your alchemist's instructions were very specific about this.", effect: { type: "material_gain", materialId: "demon_ichor", quantity: 1 } }, - { id: "th_e4", text: "Torment-essence is potent in the way that only very old suffering can be. Your alchemist handles it carefully.", effect: { type: "essence_gain", amount: 900000 } }, + { + materialId: "brimstone_flake", + maxQuantity: 9, + minQuantity: 4, + weight: 3, + }, + { materialId: "demon_ichor", maxQuantity: 2, minQuantity: 1, weight: 2 }, ], + + zoneId: "infernal_court", }, { - id: "soul_forge", - name: "The Soul Forge", - description: "The court's industrial district, where deals are processed and the residue of completed contracts is extracted. The machinery runs on something the court considers renewable.", - zoneId: "infernal_court", - durationSeconds: 172800, // 48h + description: + "The court's industrial district, where deals are processed and the residue of completed contracts is extracted. The machinery runs on something the court considers renewable.", + durationSeconds: 172_800, + events: [ + { + effect: { amount: 90_000_000, type: "gold_gain" }, + id: "sof_e1", + text: "The forge's overflow system is backed up. Your scouts help clear it for a significant consideration.", + }, + { + effect: { amount: 35_000_000, type: "gold_loss" }, + id: "sof_e2", + text: "The forge's output included your scouts' equipment in the residue stream. Recovery was partial.", + }, + { + effect: { + materialId: "soul_residue", + quantity: 1, + type: "material_gain", + }, + id: "sof_e3", + text: "The forge produces soul residue as a byproduct. Today's output is substantial.", + }, + { + effect: { amount: 2_000_000, type: "essence_gain" }, + id: "sof_e4", + text: "Forge-essence is extremely concentrated. Your alchemist insists on double-walled containers.", + }, + ], + id: "soul_forge", + name: "The Soul Forge", + // 48h possibleMaterials: [ - { materialId: "demon_ichor", minQuantity: 1, maxQuantity: 3, weight: 3 }, - { materialId: "soul_residue", minQuantity: 1, maxQuantity: 1, weight: 1 }, - ], - events: [ - { id: "sof_e1", text: "The forge's overflow system is backed up. Your scouts help clear it for a significant consideration.", effect: { type: "gold_gain", amount: 90000000 } }, - { id: "sof_e2", text: "The forge's output included your scouts' equipment in the residue stream. Recovery was partial.", effect: { type: "gold_loss", amount: 35000000 } }, - { id: "sof_e3", text: "The forge produces soul residue as a byproduct. Today's output is substantial.", effect: { type: "material_gain", materialId: "soul_residue", quantity: 1 } }, - { id: "sof_e4", text: "Forge-essence is extremely concentrated. Your alchemist insists on double-walled containers.", effect: { type: "essence_gain", amount: 2000000 } }, + { materialId: "demon_ichor", maxQuantity: 3, minQuantity: 1, weight: 3 }, + { materialId: "soul_residue", maxQuantity: 1, minQuantity: 1, weight: 1 }, ], + + zoneId: "infernal_court", }, { - id: "lords_chamber", - name: "The Lords' Chamber", - description: "The inner sanctum of the infernal court, where the demon lords make decisions that echo across aeons. Your guild should not be here. Your guild is here anyway.", - zoneId: "infernal_court", - durationSeconds: 230400, // 64h + description: + "The inner sanctum of the infernal court, where the demon lords make decisions that echo across aeons. Your guild should not be here. Your guild is here anyway.", + durationSeconds: 230_400, + events: [ + { + effect: { amount: 180_000_000, type: "gold_gain" }, + id: "lc_e1", + text: "A lord, impressed by the audacity of your guild's presence, made an offer. Your scouts accepted and are remarkably wealthy.", + }, + { + effect: { amount: 70_000_000, type: "gold_loss" }, + id: "lc_e2", + text: "A lord, irritated by the audacity of your guild's presence, made a different kind of offer. Your scouts declined but paid a fee.", + }, + { + effect: { + materialId: "soul_residue", + quantity: 1, + type: "material_gain", + }, + id: "lc_e3", + text: "Soul residue collects in the chamber's corners, unattended. Your scouts are very fast.", + }, + { + effect: { amount: 4_000_000, type: "essence_gain" }, + id: "lc_e4", + text: "Lords' chamber essence is the most potent infernal essence your alchemist has ever worked with. She requests a raise.", + }, + ], + id: "lords_chamber", + name: "The Lords' Chamber", + // 64h possibleMaterials: [ - { materialId: "demon_ichor", minQuantity: 2, maxQuantity: 4, weight: 3 }, - { materialId: "soul_residue", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "lc_e1", text: "A lord, impressed by the audacity of your guild's presence, made an offer. Your scouts accepted and are remarkably wealthy.", effect: { type: "gold_gain", amount: 180000000 } }, - { id: "lc_e2", text: "A lord, irritated by the audacity of your guild's presence, made a different kind of offer. Your scouts declined but paid a fee.", effect: { type: "gold_loss", amount: 70000000 } }, - { id: "lc_e3", text: "Soul residue collects in the chamber's corners, unattended. Your scouts are very fast.", effect: { type: "material_gain", materialId: "soul_residue", quantity: 1 } }, - { id: "lc_e4", text: "Lords' chamber essence is the most potent infernal essence your alchemist has ever worked with. She requests a raise.", effect: { type: "essence_gain", amount: 4000000 } }, + { materialId: "demon_ichor", maxQuantity: 4, minQuantity: 2, weight: 3 }, + { materialId: "soul_residue", maxQuantity: 2, minQuantity: 1, weight: 2 }, ], + + zoneId: "infernal_court", }, // ── Zone 10: crystalline_spire ──────────────────────────────────────────── { - id: "facet_approach", - name: "The Facet Approach", - description: "The outer surface of the spire, where thousands of crystal facets reflect realities that are not the one you arrived in. Your scouts learn to focus on the ground in front of them.", - zoneId: "crystalline_spire", - durationSeconds: 64800, // 18h + description: + "The outer surface of the spire, where thousands of crystal facets reflect realities that are not the one you arrived in. Your scouts learn to focus on the ground in front of them.", + durationSeconds: 64_800, + events: [ + { + effect: { amount: 60_000_000, type: "gold_gain" }, + id: "fa_e1", + text: "One facet shows a version of events where someone left a treasure here. Checking: yes. It is here.", + }, + { + effect: { amount: 24_000_000, type: "gold_loss" }, + id: "fa_e2", + text: "A facet showed your scouts a path. The path led somewhere considerably less profitable than expected.", + }, + { + effect: { + materialId: "prism_dust", + quantity: 4, + type: "material_gain", + }, + id: "fa_e3", + text: "A facet chips free and sheds prism dust in exceptional quantity.", + }, + { + effect: { amount: 1_200_000, type: "essence_gain" }, + id: "fa_e4", + text: "Facet-essence refracts light into something your alchemist can work with. It's beautiful and extremely useful.", + }, + ], + id: "facet_approach", + name: "The Facet Approach", + // 18h possibleMaterials: [ - { materialId: "prism_dust", minQuantity: 3, maxQuantity: 7, weight: 3 }, - ], - events: [ - { id: "fa_e1", text: "One facet shows a version of events where someone left a treasure here. Checking: yes. It is here.", effect: { type: "gold_gain", amount: 60000000 } }, - { id: "fa_e2", text: "A facet showed your scouts a path. The path led somewhere considerably less profitable than expected.", effect: { type: "gold_loss", amount: 24000000 } }, - { id: "fa_e3", text: "A facet chips free and sheds prism dust in exceptional quantity.", effect: { type: "material_gain", materialId: "prism_dust", quantity: 4 } }, - { id: "fa_e4", text: "Facet-essence refracts light into something your alchemist can work with. It's beautiful and extremely useful.", effect: { type: "essence_gain", amount: 1200000 } }, + { materialId: "prism_dust", maxQuantity: 7, minQuantity: 3, weight: 3 }, ], + + zoneId: "crystalline_spire", }, { - id: "calculation_chamber", - name: "The Calculation Chamber", - description: "A room inside the spire where the intelligence runs its oldest and most complex calculations. The numbers on the walls change too fast to read. The calculations are always correct.", - zoneId: "crystalline_spire", - durationSeconds: 129600, // 36h + description: + "A room inside the spire where the intelligence runs its oldest and most complex calculations. The numbers on the walls change too fast to read. The calculations are always correct.", + durationSeconds: 129_600, + events: [ + { + effect: { amount: 130_000_000, type: "gold_gain" }, + id: "cc_e1", + text: "The calculations briefly solved for your guild's maximum possible wealth. They were not entirely wrong.", + }, + { + effect: { amount: 50_000_000, type: "gold_loss" }, + id: "cc_e2", + text: "The calculations included your scouts in an equation. The solution involved them being considerably poorer.", + }, + { + effect: { + materialId: "calculation_shard", + quantity: 1, + type: "material_gain", + }, + id: "cc_e3", + text: "A calculation shard breaks free when a particularly complex proof is resolved.", + }, + { + effect: { amount: 2_500_000, type: "essence_gain" }, + id: "cc_e4", + text: "Calculation-essence is mathematically pure. Your alchemist stops mid-process to write a proof about it.", + }, + ], + id: "calculation_chamber", + name: "The Calculation Chamber", + // 36h possibleMaterials: [ - { materialId: "prism_dust", minQuantity: 4, maxQuantity: 9, weight: 3 }, - { materialId: "calculation_shard", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "cc_e1", text: "The calculations briefly solved for your guild's maximum possible wealth. They were not entirely wrong.", effect: { type: "gold_gain", amount: 130000000 } }, - { id: "cc_e2", text: "The calculations included your scouts in an equation. The solution involved them being considerably poorer.", effect: { type: "gold_loss", amount: 50000000 } }, - { id: "cc_e3", text: "A calculation shard breaks free when a particularly complex proof is resolved.", effect: { type: "material_gain", materialId: "calculation_shard", quantity: 1 } }, - { id: "cc_e4", text: "Calculation-essence is mathematically pure. Your alchemist stops mid-process to write a proof about it.", effect: { type: "essence_gain", amount: 2500000 } }, + { materialId: "prism_dust", maxQuantity: 9, minQuantity: 4, weight: 3 }, + { + materialId: "calculation_shard", + maxQuantity: 2, + minQuantity: 1, + weight: 2, + }, ], + + zoneId: "crystalline_spire", }, { - id: "mirror_hall", - name: "The Mirror Hall", - description: "A corridor of perfect mirrors that show not reflections but what might have been. Your scouts avoid eye contact with their alternates. The alternates do not always extend the same courtesy.", - zoneId: "crystalline_spire", - durationSeconds: 194400, // 54h + description: + "A corridor of perfect mirrors that show not reflections but what might have been. Your scouts avoid eye contact with their alternates. The alternates do not always extend the same courtesy.", + durationSeconds: 194_400, + events: [ + { + effect: { amount: 270_000_000, type: "gold_gain" }, + id: "mh_e1", + text: "An alternate timeline's version of this expedition was more successful. Your scouts found where they stored it.", + }, + { + effect: { amount: 105_000_000, type: "gold_loss" }, + id: "mh_e2", + text: "An alternate scout followed your team out and made off with a significant portion of the haul.", + }, + { + effect: { + materialId: "possibility_crystal", + quantity: 1, + type: "material_gain", + }, + id: "mh_e3", + text: "A possibility crystal forms at the junction of two mirrors showing the same impossible future.", + }, + { + effect: { amount: 5_500_000, type: "essence_gain" }, + id: "mh_e4", + text: "Mirror-essence contains reflections of all possible essences simultaneously. Your alchemist collapses the wave function very carefully.", + }, + ], + id: "mirror_hall", + name: "The Mirror Hall", + // 54h possibleMaterials: [ - { materialId: "calculation_shard", minQuantity: 1, maxQuantity: 3, weight: 3 }, - { materialId: "possibility_crystal", minQuantity: 1, maxQuantity: 1, weight: 1 }, - ], - events: [ - { id: "mh_e1", text: "An alternate timeline's version of this expedition was more successful. Your scouts found where they stored it.", effect: { type: "gold_gain", amount: 270000000 } }, - { id: "mh_e2", text: "An alternate scout followed your team out and made off with a significant portion of the haul.", effect: { type: "gold_loss", amount: 105000000 } }, - { id: "mh_e3", text: "A possibility crystal forms at the junction of two mirrors showing the same impossible future.", effect: { type: "material_gain", materialId: "possibility_crystal", quantity: 1 } }, - { id: "mh_e4", text: "Mirror-essence contains reflections of all possible essences simultaneously. Your alchemist collapses the wave function very carefully.", effect: { type: "essence_gain", amount: 5500000 } }, + { + materialId: "calculation_shard", + maxQuantity: 3, + minQuantity: 1, + weight: 3, + }, + { + materialId: "possibility_crystal", + maxQuantity: 1, + minQuantity: 1, + weight: 1, + }, ], + + zoneId: "crystalline_spire", }, { - id: "core_access", - name: "The Core Access", - description: "The deepest point of the spire, where the intelligence's primary substrate runs continuously. The hum of calculation is felt in the bones. Numbers that have never been numbers drift past.", - zoneId: "crystalline_spire", - durationSeconds: 259200, // 72h + description: + "The deepest point of the spire, where the intelligence's primary substrate runs continuously. The hum of calculation is felt in the bones. Numbers that have never been numbers drift past.", + durationSeconds: 259_200, + events: [ + { + effect: { amount: 550_000_000, type: "gold_gain" }, + id: "ca_e1", + text: "The intelligence notices your guild and — apparently — approves. The approval is expressed financially.", + }, + { + effect: { amount: 210_000_000, type: "gold_loss" }, + id: "ca_e2", + text: "The intelligence notices your guild and expresses its calculations as a fine for unauthorised access.", + }, + { + effect: { + materialId: "possibility_crystal", + quantity: 1, + type: "material_gain", + }, + id: "ca_e3", + text: "A possibility crystal from the core's deepest level — containing futures that only this intelligence has computed.", + }, + { + effect: { amount: 11_000_000, type: "essence_gain" }, + id: "ca_e4", + text: "Core-essence is computational in a way that has no analogue in mortal chemistry. Your alchemist needs new words.", + }, + ], + id: "core_access", + name: "The Core Access", + // 72h possibleMaterials: [ - { materialId: "calculation_shard", minQuantity: 2, maxQuantity: 4, weight: 3 }, - { materialId: "possibility_crystal", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "ca_e1", text: "The intelligence notices your guild and — apparently — approves. The approval is expressed financially.", effect: { type: "gold_gain", amount: 550000000 } }, - { id: "ca_e2", text: "The intelligence notices your guild and expresses its calculations as a fine for unauthorised access.", effect: { type: "gold_loss", amount: 210000000 } }, - { id: "ca_e3", text: "A possibility crystal from the core's deepest level — containing futures that only this intelligence has computed.", effect: { type: "material_gain", materialId: "possibility_crystal", quantity: 1 } }, - { id: "ca_e4", text: "Core-essence is computational in a way that has no analogue in mortal chemistry. Your alchemist needs new words.", effect: { type: "essence_gain", amount: 11000000 } }, + { + materialId: "calculation_shard", + maxQuantity: 4, + minQuantity: 2, + weight: 3, + }, + { + materialId: "possibility_crystal", + maxQuantity: 2, + minQuantity: 1, + weight: 2, + }, ], + + zoneId: "crystalline_spire", }, // ── Zone 11: void_sanctum ───────────────────────────────────────────────── { - id: "threshold", - name: "The Threshold", - description: "The entrance to the void sanctum, where the rules of existence become suggestions. Your scouts describe the crossing as like stepping sideways and arriving somewhere that was always there.", - zoneId: "void_sanctum", - durationSeconds: 72000, // 20h + description: + "The entrance to the void sanctum, where the rules of existence become suggestions. Your scouts describe the crossing as like stepping sideways and arriving somewhere that was always there.", + durationSeconds: 72_000, + events: [ + { + effect: { amount: 200_000_000, type: "gold_gain" }, + id: "thr_e1", + text: "The threshold allows things to cross that should not exist in normal space. Some of those things are valuable.", + }, + { + effect: { amount: 80_000_000, type: "gold_loss" }, + id: "thr_e2", + text: "The threshold is less stable today. Your scouts crossed it but the equipment did not follow completely.", + }, + { + effect: { + materialId: "null_matter", + quantity: 4, + type: "material_gain", + }, + id: "thr_e3", + text: "Null matter accumulates at the threshold where something becomes nothing. There is quite a lot of it today.", + }, + { + effect: { amount: 4_000_000, type: "essence_gain" }, + id: "thr_e4", + text: "Threshold-essence is transitional — partway between being and not-being. Your alchemist finds this philosophically troubling and practically wonderful.", + }, + ], + id: "threshold", + name: "The Threshold", + // 20h possibleMaterials: [ - { materialId: "null_matter", minQuantity: 3, maxQuantity: 7, weight: 3 }, - ], - events: [ - { id: "thr_e1", text: "The threshold allows things to cross that should not exist in normal space. Some of those things are valuable.", effect: { type: "gold_gain", amount: 200000000 } }, - { id: "thr_e2", text: "The threshold is less stable today. Your scouts crossed it but the equipment did not follow completely.", effect: { type: "gold_loss", amount: 80000000 } }, - { id: "thr_e3", text: "Null matter accumulates at the threshold where something becomes nothing. There is quite a lot of it today.", effect: { type: "material_gain", materialId: "null_matter", quantity: 4 } }, - { id: "thr_e4", text: "Threshold-essence is transitional — partway between being and not-being. Your alchemist finds this philosophically troubling and practically wonderful.", effect: { type: "essence_gain", amount: 4000000 } }, + { materialId: "null_matter", maxQuantity: 7, minQuantity: 3, weight: 3 }, ], + + zoneId: "void_sanctum", }, { - id: "inner_silence", - name: "The Inner Silence", - description: "A place inside the sanctum where everything is perfectly quiet because nothing exists to make noise. Your scouts can hear their own thoughts very clearly here. Some of them find this unsettling.", - zoneId: "void_sanctum", - durationSeconds: 144000, // 40h + description: + "A place inside the sanctum where everything is perfectly quiet because nothing exists to make noise. Your scouts can hear their own thoughts very clearly here. Some of them find this unsettling.", + durationSeconds: 144_000, + events: [ + { + effect: { amount: 420_000_000, type: "gold_gain" }, + id: "is_e1", + text: "In the silence, your scouts heard the sound of wealth. Following it was straightforward.", + }, + { + effect: { amount: 160_000_000, type: "gold_loss" }, + id: "is_e2", + text: "In the silence, your scouts heard their equipment being quietly redistributed. The sound of nothing taking things.", + }, + { + effect: { + materialId: "resonance_fragment", + quantity: 1, + type: "material_gain", + }, + id: "is_e3", + text: "A resonance fragment reverberates in the silence, audible when nothing else is.", + }, + { + effect: { amount: 8_000_000, type: "essence_gain" }, + id: "is_e4", + text: "Silence-essence is collected by having nothing absorb it. Your alchemist uses a specially prepared container.", + }, + ], + id: "inner_silence", + name: "The Inner Silence", + // 40h possibleMaterials: [ - { materialId: "null_matter", minQuantity: 4, maxQuantity: 9, weight: 3 }, - { materialId: "resonance_fragment", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "is_e1", text: "In the silence, your scouts heard the sound of wealth. Following it was straightforward.", effect: { type: "gold_gain", amount: 420000000 } }, - { id: "is_e2", text: "In the silence, your scouts heard their equipment being quietly redistributed. The sound of nothing taking things.", effect: { type: "gold_loss", amount: 160000000 } }, - { id: "is_e3", text: "A resonance fragment reverberates in the silence, audible when nothing else is.", effect: { type: "material_gain", materialId: "resonance_fragment", quantity: 1 } }, - { id: "is_e4", text: "Silence-essence is collected by having nothing absorb it. Your alchemist uses a specially prepared container.", effect: { type: "essence_gain", amount: 8000000 } }, + { materialId: "null_matter", maxQuantity: 9, minQuantity: 4, weight: 3 }, + { + materialId: "resonance_fragment", + maxQuantity: 2, + minQuantity: 1, + weight: 2, + }, ], + + zoneId: "void_sanctum", }, { - id: "resonance_chamber", - name: "The Resonance Chamber", - description: "A space inside the sanctum where something is calling out, continuously, to something that has not yet answered. The call is beautiful and deeply wrong.", - zoneId: "void_sanctum", - durationSeconds: 216000, // 60h + description: + "A space inside the sanctum where something is calling out, continuously, to something that has not yet answered. The call is beautiful and deeply wrong.", + durationSeconds: 216_000, + events: [ + { + effect: { amount: 900_000_000, type: "gold_gain" }, + id: "rc_e1", + text: "The call briefly aligned with something profitable. Your scouts followed the alignment quickly.", + }, + { + effect: { amount: 350_000_000, type: "gold_loss" }, + id: "rc_e2", + text: "The call briefly aligned with something expensive. The invoice was delivered before your scouts could leave.", + }, + { + effect: { + materialId: "sanctum_core", + quantity: 1, + type: "material_gain", + }, + id: "rc_e3", + text: "A sanctum core materialises at the chamber's focal point, called into being by the resonance itself.", + }, + { + effect: { amount: 18_000_000, type: "essence_gain" }, + id: "rc_e4", + text: "Resonance-essence carries the call within it. Your alchemist is very careful not to answer it.", + }, + ], + id: "resonance_chamber", + name: "The Resonance Chamber", + // 60h possibleMaterials: [ - { materialId: "resonance_fragment", minQuantity: 1, maxQuantity: 3, weight: 3 }, - { materialId: "sanctum_core", minQuantity: 1, maxQuantity: 1, weight: 1 }, - ], - events: [ - { id: "rc_e1", text: "The call briefly aligned with something profitable. Your scouts followed the alignment quickly.", effect: { type: "gold_gain", amount: 900000000 } }, - { id: "rc_e2", text: "The call briefly aligned with something expensive. The invoice was delivered before your scouts could leave.", effect: { type: "gold_loss", amount: 350000000 } }, - { id: "rc_e3", text: "A sanctum core materialises at the chamber's focal point, called into being by the resonance itself.", effect: { type: "material_gain", materialId: "sanctum_core", quantity: 1 } }, - { id: "rc_e4", text: "Resonance-essence carries the call within it. Your alchemist is very careful not to answer it.", effect: { type: "essence_gain", amount: 18000000 } }, + { + materialId: "resonance_fragment", + maxQuantity: 3, + minQuantity: 1, + weight: 3, + }, + { materialId: "sanctum_core", maxQuantity: 1, minQuantity: 1, weight: 1 }, ], + + zoneId: "void_sanctum", }, { - id: "sanctum_heart", - name: "The Sanctum Heart", - description: "The source of the call. Something here has been reaching out for so long it no longer remembers what it is reaching toward. Your guild's arrival is, perhaps, an answer.", - zoneId: "void_sanctum", - durationSeconds: 288000, // 80h + description: + "The source of the call. Something here has been reaching out for so long it no longer remembers what it is reaching toward. Your guild's arrival is, perhaps, an answer.", + durationSeconds: 288_000, + events: [ + { + effect: { amount: 1_800_000_000, type: "gold_gain" }, + id: "sh_e1", + text: "The heart recognises your guild as an answer of sorts and expresses gratitude in the most comprehensible way available to it: wealth.", + }, + { + effect: { amount: 700_000_000, type: "gold_loss" }, + id: "sh_e2", + text: "The heart mistakes your guild for something it has been dreading. The misunderstanding is expensive.", + }, + { + effect: { + materialId: "sanctum_core", + quantity: 1, + type: "material_gain", + }, + id: "sh_e3", + text: "A sanctum core from the heart itself — the source of everything the sanctum is and does.", + }, + { + effect: { amount: 36_000_000, type: "essence_gain" }, + id: "sh_e4", + text: "Heart-essence from the void sanctum. Your alchemist sits very still for a long time before beginning.", + }, + ], + id: "sanctum_heart", + name: "The Sanctum Heart", + // 80h possibleMaterials: [ - { materialId: "resonance_fragment", minQuantity: 2, maxQuantity: 4, weight: 3 }, - { materialId: "sanctum_core", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "sh_e1", text: "The heart recognises your guild as an answer of sorts and expresses gratitude in the most comprehensible way available to it: wealth.", effect: { type: "gold_gain", amount: 1800000000 } }, - { id: "sh_e2", text: "The heart mistakes your guild for something it has been dreading. The misunderstanding is expensive.", effect: { type: "gold_loss", amount: 700000000 } }, - { id: "sh_e3", text: "A sanctum core from the heart itself — the source of everything the sanctum is and does.", effect: { type: "material_gain", materialId: "sanctum_core", quantity: 1 } }, - { id: "sh_e4", text: "Heart-essence from the void sanctum. Your alchemist sits very still for a long time before beginning.", effect: { type: "essence_gain", amount: 36000000 } }, + { + materialId: "resonance_fragment", + maxQuantity: 4, + minQuantity: 2, + weight: 3, + }, + { materialId: "sanctum_core", maxQuantity: 2, minQuantity: 1, weight: 2 }, ], + + zoneId: "void_sanctum", }, // ── Zone 12: eternal_throne ─────────────────────────────────────────────── { - id: "throne_approach", - name: "The Throne Approach", - description: "The long road to the eternal throne. Countless beings have walked it, seeking audience, seeking power, seeking something the throne has always already decided about them.", - zoneId: "eternal_throne", - durationSeconds: 79200, // 22h + description: + "The long road to the eternal throne. Countless beings have walked it, seeking audience, seeking power, seeking something the throne has always already decided about them.", + durationSeconds: 79_200, + events: [ + { + effect: { amount: 700_000_000, type: "gold_gain" }, + id: "ta_e1", + text: "A pilgrim left their worldly wealth at the approach before continuing. They did not return for it. Your scouts did.", + }, + { + effect: { amount: 280_000_000, type: "gold_loss" }, + id: "ta_e2", + text: "A customs toll, ancient and automatically enforced, extracted a fee from your scouts. The mechanism was unavoidable.", + }, + { + effect: { + materialId: "throne_dust", + quantity: 4, + type: "material_gain", + }, + id: "ta_e3", + text: "The throne dust on the approach is deep and old. Your scouts collect the finest layer from the top.", + }, + { + effect: { amount: 14_000_000, type: "essence_gain" }, + id: "ta_e4", + text: "Approach-essence carries the weight of every being who has ever walked this road. There have been very many.", + }, + ], + id: "throne_approach", + name: "The Throne Approach", + // 22h possibleMaterials: [ - { materialId: "throne_dust", minQuantity: 3, maxQuantity: 7, weight: 3 }, - ], - events: [ - { id: "ta_e1", text: "A pilgrim left their worldly wealth at the approach before continuing. They did not return for it. Your scouts did.", effect: { type: "gold_gain", amount: 700000000 } }, - { id: "ta_e2", text: "A customs toll, ancient and automatically enforced, extracted a fee from your scouts. The mechanism was unavoidable.", effect: { type: "gold_loss", amount: 280000000 } }, - { id: "ta_e3", text: "The throne dust on the approach is deep and old. Your scouts collect the finest layer from the top.", effect: { type: "material_gain", materialId: "throne_dust", quantity: 4 } }, - { id: "ta_e4", text: "Approach-essence carries the weight of every being who has ever walked this road. There have been very many.", effect: { type: "essence_gain", amount: 14000000 } }, + { materialId: "throne_dust", maxQuantity: 7, minQuantity: 3, weight: 3 }, ], + + zoneId: "eternal_throne", }, { - id: "dominion_hall", - name: "The Dominion Hall", - description: "The ante-chamber of absolute power. Records are kept here of everything that has ever been ruled and everything that has ever been lost. The records go back further than memory.", - zoneId: "eternal_throne", - durationSeconds: 158400, // 44h + description: + "The ante-chamber of absolute power. Records are kept here of everything that has ever been ruled and everything that has ever been lost. The records go back further than memory.", + durationSeconds: 158_400, + events: [ + { + effect: { amount: 1_400_000_000, type: "gold_gain" }, + id: "doh_e1", + text: "The hall's records include an unclaimed inheritance from a petitioner who never arrived. Your guild files the appropriate claim.", + }, + { + effect: { amount: 550_000_000, type: "gold_loss" }, + id: "doh_e2", + text: "The hall discovered an outstanding tax from a guild registered centuries ago with a similar name. Enforcement was automatic.", + }, + { + effect: { + materialId: "crown_fragment", + quantity: 1, + type: "material_gain", + }, + id: "doh_e3", + text: "A crown fragment from a petition that was decided eons ago, still attached to its filing.", + }, + { + effect: { amount: 28_000_000, type: "essence_gain" }, + id: "doh_e4", + text: "Dominion-essence is the essence of authority made distillable. Your alchemist treats it with appropriate respect.", + }, + ], + id: "dominion_hall", + name: "The Dominion Hall", + // 44h possibleMaterials: [ - { materialId: "throne_dust", minQuantity: 4, maxQuantity: 9, weight: 3 }, - { materialId: "crown_fragment", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "doh_e1", text: "The hall's records include an unclaimed inheritance from a petitioner who never arrived. Your guild files the appropriate claim.", effect: { type: "gold_gain", amount: 1400000000 } }, - { id: "doh_e2", text: "The hall discovered an outstanding tax from a guild registered centuries ago with a similar name. Enforcement was automatic.", effect: { type: "gold_loss", amount: 550000000 } }, - { id: "doh_e3", text: "A crown fragment from a petition that was decided eons ago, still attached to its filing.", effect: { type: "material_gain", materialId: "crown_fragment", quantity: 1 } }, - { id: "doh_e4", text: "Dominion-essence is the essence of authority made distillable. Your alchemist treats it with appropriate respect.", effect: { type: "essence_gain", amount: 28000000 } }, + { materialId: "throne_dust", maxQuantity: 9, minQuantity: 4, weight: 3 }, + { + materialId: "crown_fragment", + maxQuantity: 2, + minQuantity: 1, + weight: 2, + }, ], + + zoneId: "eternal_throne", }, { - id: "eternity_vault", - name: "The Eternity Vault", - description: "Where things are stored that have nowhere else to go. Objects of power that cannot be used, secrets that cannot be shared, and wealth that belongs to entities that stopped existing before your world was born.", - zoneId: "eternal_throne", - durationSeconds: 237600, // 66h + description: + "Where things are stored that have nowhere else to go. Objects of power that cannot be used, secrets that cannot be shared, and wealth that belongs to entities that stopped existing before your world was born.", + durationSeconds: 237_600, + events: [ + { + effect: { amount: 3_000_000_000, type: "gold_gain" }, + id: "ev_e1", + text: "The vault contains a misfiled deposit from an empire that is no longer around to claim it. Your guild is.", + }, + { + effect: { amount: 1_200_000_000, type: "gold_loss" }, + id: "ev_e2", + text: "A vault security system, last updated before your species evolved, extracted an access fee your scouts had no choice but to pay.", + }, + { + effect: { + materialId: "eternity_splinter", + quantity: 1, + type: "material_gain", + }, + id: "ev_e3", + text: "An eternity splinter from a filing that predates the current occupant of the throne. Unclaimed.", + }, + { + effect: { amount: 60_000_000, type: "essence_gain" }, + id: "ev_e4", + text: "Vault-essence is so old it has crystallised into something that barely resembles essence anymore. Your alchemist is delighted.", + }, + ], + id: "eternity_vault", + name: "The Eternity Vault", + // 66h possibleMaterials: [ - { materialId: "crown_fragment", minQuantity: 1, maxQuantity: 3, weight: 3 }, - { materialId: "eternity_splinter", minQuantity: 1, maxQuantity: 1, weight: 1 }, - ], - events: [ - { id: "ev_e1", text: "The vault contains a misfiled deposit from an empire that is no longer around to claim it. Your guild is.", effect: { type: "gold_gain", amount: 3000000000 } }, - { id: "ev_e2", text: "A vault security system, last updated before your species evolved, extracted an access fee your scouts had no choice but to pay.", effect: { type: "gold_loss", amount: 1200000000 } }, - { id: "ev_e3", text: "An eternity splinter from a filing that predates the current occupant of the throne. Unclaimed.", effect: { type: "material_gain", materialId: "eternity_splinter", quantity: 1 } }, - { id: "ev_e4", text: "Vault-essence is so old it has crystallised into something that barely resembles essence anymore. Your alchemist is delighted.", effect: { type: "essence_gain", amount: 60000000 } }, + { + materialId: "crown_fragment", + maxQuantity: 3, + minQuantity: 1, + weight: 3, + }, + { + materialId: "eternity_splinter", + maxQuantity: 1, + minQuantity: 1, + weight: 1, + }, ], + + zoneId: "eternal_throne", }, { - id: "the_seat", - name: "The Seat", - description: "The eternal throne itself. Whoever sits here has sat here since the beginning. They observe your guild's presence with neither surprise nor emotion. They have been expecting you. They have been expecting everyone.", - zoneId: "eternal_throne", - durationSeconds: 316800, // 88h + description: + "The eternal throne itself. Whoever sits here has sat here since the beginning. They observe your guild's presence with neither surprise nor emotion. They have been expecting you. They have been expecting everyone.", + durationSeconds: 316_800, + events: [ + { + effect: { amount: 6_000_000_000, type: "gold_gain" }, + id: "ts_e1", + text: "The occupant of the throne acknowledges your guild's petition. The acknowledgment comes with a substantial material consideration.", + }, + { + effect: { amount: 2_300_000_000, type: "gold_loss" }, + id: "ts_e2", + text: "The occupant of the throne acknowledged your guild's presence with a tithe. Ancient thrones collect ancient tithes.", + }, + { + effect: { + materialId: "eternity_splinter", + quantity: 1, + type: "material_gain", + }, + id: "ts_e3", + text: "An eternity splinter from the throne's arm, offered without ceremony. You accept without ceremony.", + }, + { + effect: { amount: 120_000_000, type: "essence_gain" }, + id: "ts_e4", + text: "Throne-essence contains everything that has ever been decided from this seat. Your alchemist is overwhelmed. In a good way.", + }, + ], + id: "the_seat", + name: "The Seat", + // 88h possibleMaterials: [ - { materialId: "crown_fragment", minQuantity: 2, maxQuantity: 4, weight: 3 }, - { materialId: "eternity_splinter", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "ts_e1", text: "The occupant of the throne acknowledges your guild's petition. The acknowledgment comes with a substantial material consideration.", effect: { type: "gold_gain", amount: 6000000000 } }, - { id: "ts_e2", text: "The occupant of the throne acknowledged your guild's presence with a tithe. Ancient thrones collect ancient tithes.", effect: { type: "gold_loss", amount: 2300000000 } }, - { id: "ts_e3", text: "An eternity splinter from the throne's arm, offered without ceremony. You accept without ceremony.", effect: { type: "material_gain", materialId: "eternity_splinter", quantity: 1 } }, - { id: "ts_e4", text: "Throne-essence contains everything that has ever been decided from this seat. Your alchemist is overwhelmed. In a good way.", effect: { type: "essence_gain", amount: 120000000 } }, + { + materialId: "crown_fragment", + maxQuantity: 4, + minQuantity: 2, + weight: 3, + }, + { + materialId: "eternity_splinter", + maxQuantity: 2, + minQuantity: 1, + weight: 2, + }, ], + + zoneId: "eternal_throne", }, // ── Zone 13: primordial_chaos ───────────────────────────────────────────── { - id: "creation_storm", - name: "The Creation Storm", - description: "A permanent storm at the edge of the chaos zone where things are constantly being made and unmade simultaneously. Your scouts move through it quickly and try not to look at what they might become.", - zoneId: "primordial_chaos", - durationSeconds: 86400, // 24h + description: + "A permanent storm at the edge of the chaos zone where things are constantly being made and unmade simultaneously. Your scouts move through it quickly and try not to look at what they might become.", + durationSeconds: 86_400, + events: [ + { + effect: { amount: 2_000_000_000, type: "gold_gain" }, + id: "cs_e1", + text: "The storm created something valuable during your scouts' passage. They recognised what it was and took it.", + }, + { + effect: { amount: 800_000_000, type: "gold_loss" }, + id: "cs_e2", + text: "The storm unmade something your scouts were carrying. The loss was structural, not merely economic.", + }, + { + effect: { + materialId: "chaos_fragment", + quantity: 4, + type: "material_gain", + }, + id: "cs_e3", + text: "A chaos fragment solidifies during a moment of relative stability in the storm.", + }, + { + effect: { amount: 40_000_000, type: "essence_gain" }, + id: "cs_e4", + text: "Creation-storm essence is freshly made existence. Your alchemist handles it like it is alive. It might be.", + }, + ], + id: "creation_storm", + name: "The Creation Storm", + // 24h possibleMaterials: [ - { materialId: "chaos_fragment", minQuantity: 3, maxQuantity: 7, weight: 3 }, - ], - events: [ - { id: "cs_e1", text: "The storm created something valuable during your scouts' passage. They recognised what it was and took it.", effect: { type: "gold_gain", amount: 2000000000 } }, - { id: "cs_e2", text: "The storm unmade something your scouts were carrying. The loss was structural, not merely economic.", effect: { type: "gold_loss", amount: 800000000 } }, - { id: "cs_e3", text: "A chaos fragment solidifies during a moment of relative stability in the storm.", effect: { type: "material_gain", materialId: "chaos_fragment", quantity: 4 } }, - { id: "cs_e4", text: "Creation-storm essence is freshly made existence. Your alchemist handles it like it is alive. It might be.", effect: { type: "essence_gain", amount: 40000000 } }, + { + materialId: "chaos_fragment", + maxQuantity: 7, + minQuantity: 3, + weight: 3, + }, ], + + zoneId: "primordial_chaos", }, { - id: "unmaking_sea", - name: "The Unmaking Sea", - description: "A vast ocean of something that is exactly the opposite of matter. Your scouts cross it by not thinking too hard about what they are standing on.", - zoneId: "primordial_chaos", - durationSeconds: 172800, // 48h + description: + "A vast ocean of something that is exactly the opposite of matter. Your scouts cross it by not thinking too hard about what they are standing on.", + durationSeconds: 172_800, + events: [ + { + effect: { amount: 4_000_000_000, type: "gold_gain" }, + id: "us_e1", + text: "In the unmaking sea, something was unmade that revealed what was underneath it, which was quite a lot of gold.", + }, + { + effect: { amount: 1_600_000_000, type: "gold_loss" }, + id: "us_e2", + text: "The sea unmade a section of the expedition's equipment. The scouts swam through nothing to retrieve nothing.", + }, + { + effect: { + materialId: "creation_shard", + quantity: 1, + type: "material_gain", + }, + id: "us_e3", + text: "A creation shard surfaces from the sea, the only solid thing within a considerable radius.", + }, + { + effect: { amount: 80_000_000, type: "essence_gain" }, + id: "us_e4", + text: "Unmaking-essence is paradoxically the most generative substance your alchemist has worked with.", + }, + ], + id: "unmaking_sea", + name: "The Unmaking Sea", + // 48h possibleMaterials: [ - { materialId: "chaos_fragment", minQuantity: 4, maxQuantity: 9, weight: 3 }, - { materialId: "creation_shard", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "us_e1", text: "In the unmaking sea, something was unmade that revealed what was underneath it, which was quite a lot of gold.", effect: { type: "gold_gain", amount: 4000000000 } }, - { id: "us_e2", text: "The sea unmade a section of the expedition's equipment. The scouts swam through nothing to retrieve nothing.", effect: { type: "gold_loss", amount: 1600000000 } }, - { id: "us_e3", text: "A creation shard surfaces from the sea, the only solid thing within a considerable radius.", effect: { type: "material_gain", materialId: "creation_shard", quantity: 1 } }, - { id: "us_e4", text: "Unmaking-essence is paradoxically the most generative substance your alchemist has worked with.", effect: { type: "essence_gain", amount: 80000000 } }, + { + materialId: "chaos_fragment", + maxQuantity: 9, + minQuantity: 4, + weight: 3, + }, + { + materialId: "creation_shard", + maxQuantity: 2, + minQuantity: 1, + weight: 2, + }, ], + + zoneId: "primordial_chaos", }, { - id: "probability_void", - name: "The Probability Void", - description: "A space where all possible outcomes already happened and none of them mattered. Your scouts find this philosophically challenging and practically navigable.", - zoneId: "primordial_chaos", - durationSeconds: 259200, // 72h + description: + "A space where all possible outcomes already happened and none of them mattered. Your scouts find this philosophically challenging and practically navigable.", + durationSeconds: 259_200, + events: [ + { + effect: { amount: 8_000_000_000, type: "gold_gain" }, + id: "pv_e1", + text: "One of the possible outcomes that already happened involved significant wealth for your guild. They located it.", + }, + { + effect: { amount: 3_200_000_000, type: "gold_loss" }, + id: "pv_e2", + text: "One of the possible outcomes that already happened involved a significant loss. It applied retroactively.", + }, + { + effect: { + materialId: "primordial_essence", + quantity: 1, + type: "material_gain", + }, + id: "pv_e3", + text: "A primordial essence crystallises at the point where all probabilities converge.", + }, + { + effect: { amount: 160_000_000, type: "essence_gain" }, + id: "pv_e4", + text: "Probability-void essence contains every possible essence simultaneously. Your alchemist collapses it into the best one.", + }, + ], + id: "probability_void", + name: "The Probability Void", + // 72h possibleMaterials: [ - { materialId: "creation_shard", minQuantity: 1, maxQuantity: 3, weight: 3 }, - { materialId: "primordial_essence", minQuantity: 1, maxQuantity: 1, weight: 1 }, - ], - events: [ - { id: "pv_e1", text: "One of the possible outcomes that already happened involved significant wealth for your guild. They located it.", effect: { type: "gold_gain", amount: 8000000000 } }, - { id: "pv_e2", text: "One of the possible outcomes that already happened involved a significant loss. It applied retroactively.", effect: { type: "gold_loss", amount: 3200000000 } }, - { id: "pv_e3", text: "A primordial essence crystallises at the point where all probabilities converge.", effect: { type: "material_gain", materialId: "primordial_essence", quantity: 1 } }, - { id: "pv_e4", text: "Probability-void essence contains every possible essence simultaneously. Your alchemist collapses it into the best one.", effect: { type: "essence_gain", amount: 160000000 } }, + { + materialId: "creation_shard", + maxQuantity: 3, + minQuantity: 1, + weight: 3, + }, + { + materialId: "primordial_essence", + maxQuantity: 1, + minQuantity: 1, + weight: 1, + }, ], + + zoneId: "primordial_chaos", }, { - id: "chaos_core", - name: "The Chaos Core", - description: "The centre of all primordial chaos. Everything is here and nothing is here and both statements are entirely accurate. Your scouts report the experience as indescribable, then describe it for three hours.", - zoneId: "primordial_chaos", - durationSeconds: 345600, // 96h + description: + "The centre of all primordial chaos. Everything is here and nothing is here and both statements are entirely accurate. Your scouts report the experience as indescribable, then describe it for three hours.", + durationSeconds: 345_600, + events: [ + { + effect: { amount: 16_000_000_000, type: "gold_gain" }, + id: "chc_e1", + text: "The chaos core created something specifically for your guild. It was valuable beyond measure. Your scouts measured it anyway: very.", + }, + { + effect: { amount: 6_500_000_000, type: "gold_loss" }, + id: "chc_e2", + text: "The chaos core unmade something specifically belonging to your guild. The loss is immeasurable. Your accountant measures it anyway.", + }, + { + effect: { + materialId: "primordial_essence", + quantity: 1, + type: "material_gain", + }, + id: "chc_e3", + text: "A primordial essence directly from the core — as close to the original substance of creation as anything can be.", + }, + { + effect: { amount: 320_000_000, type: "essence_gain" }, + id: "chc_e4", + text: "Core-chaos essence is literally the substance from which everything was made. Your alchemist sits in silence for a very long time.", + }, + ], + id: "chaos_core", + name: "The Chaos Core", + // 96h possibleMaterials: [ - { materialId: "creation_shard", minQuantity: 2, maxQuantity: 4, weight: 3 }, - { materialId: "primordial_essence", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "chc_e1", text: "The chaos core created something specifically for your guild. It was valuable beyond measure. Your scouts measured it anyway: very.", effect: { type: "gold_gain", amount: 16000000000 } }, - { id: "chc_e2", text: "The chaos core unmade something specifically belonging to your guild. The loss is immeasurable. Your accountant measures it anyway.", effect: { type: "gold_loss", amount: 6500000000 } }, - { id: "chc_e3", text: "A primordial essence directly from the core — as close to the original substance of creation as anything can be.", effect: { type: "material_gain", materialId: "primordial_essence", quantity: 1 } }, - { id: "chc_e4", text: "Core-chaos essence is literally the substance from which everything was made. Your alchemist sits in silence for a very long time.", effect: { type: "essence_gain", amount: 320000000 } }, + { + materialId: "creation_shard", + maxQuantity: 4, + minQuantity: 2, + weight: 3, + }, + { + materialId: "primordial_essence", + maxQuantity: 2, + minQuantity: 1, + weight: 2, + }, ], + + zoneId: "primordial_chaos", }, // ── Zone 14: infinite_expanse ───────────────────────────────────────────── { - id: "first_horizon", - name: "The First Horizon", - description: "The first horizon you reach in the infinite expanse, which looks exactly like the starting point from behind but is provably, mathematically, somewhere else. Your scouts are sceptical but cannot argue with the math.", - zoneId: "infinite_expanse", - durationSeconds: 93600, // 26h + description: + "The first horizon you reach in the infinite expanse, which looks exactly like the starting point from behind but is provably, mathematically, somewhere else. Your scouts are sceptical but cannot argue with the math.", + durationSeconds: 93_600, + events: [ + { + effect: { amount: 6_000_000_000, type: "gold_gain" }, + id: "fh_e1", + text: "The horizon concealed something behind it that, from this side, proves to be worth considerably more than the journey.", + }, + { + effect: { amount: 2_400_000_000, type: "gold_loss" }, + id: "fh_e2", + text: "The horizon reflects something back at your scouts that arrived before they did, specifically to collect from them.", + }, + { + effect: { + materialId: "expanse_dust", + quantity: 4, + type: "material_gain", + }, + id: "fh_e3", + text: "Expanse dust accumulates at horizon lines where distance compresses. A good day to harvest.", + }, + { + effect: { amount: 120_000_000, type: "essence_gain" }, + id: "fh_e4", + text: "Horizon-essence is the essence of boundary — of here and not-here simultaneously. Your alchemist finds it revelatory.", + }, + ], + id: "first_horizon", + name: "The First Horizon", + // 26h possibleMaterials: [ - { materialId: "expanse_dust", minQuantity: 3, maxQuantity: 7, weight: 3 }, - ], - events: [ - { id: "fh_e1", text: "The horizon concealed something behind it that, from this side, proves to be worth considerably more than the journey.", effect: { type: "gold_gain", amount: 6000000000 } }, - { id: "fh_e2", text: "The horizon reflects something back at your scouts that arrived before they did, specifically to collect from them.", effect: { type: "gold_loss", amount: 2400000000 } }, - { id: "fh_e3", text: "Expanse dust accumulates at horizon lines where distance compresses. A good day to harvest.", effect: { type: "material_gain", materialId: "expanse_dust", quantity: 4 } }, - { id: "fh_e4", text: "Horizon-essence is the essence of boundary — of here and not-here simultaneously. Your alchemist finds it revelatory.", effect: { type: "essence_gain", amount: 120000000 } }, + { materialId: "expanse_dust", maxQuantity: 7, minQuantity: 3, weight: 3 }, ], + + zoneId: "infinite_expanse", }, { - id: "middle_nowhere", - name: "The Middle of Nowhere", - description: "There is no centre of the infinite expanse. This is the centre of the infinite expanse. Both things are true. Your scouts have stopped asking questions and started collecting samples.", - zoneId: "infinite_expanse", - durationSeconds: 187200, // 52h + description: + "There is no centre of the infinite expanse. This is the centre of the infinite expanse. Both things are true. Your scouts have stopped asking questions and started collecting samples.", + durationSeconds: 187_200, + events: [ + { + effect: { amount: 12_000_000_000, type: "gold_gain" }, + id: "mn_e1", + text: "The middle of nowhere contains something that was lost so thoroughly it became the definition of lost. Your scouts found it.", + }, + { + effect: { amount: 4_800_000_000, type: "gold_loss" }, + id: "mn_e2", + text: "Your scouts lost something so thoroughly at the middle of nowhere that even the expanse could not locate it.", + }, + { + effect: { + materialId: "distance_crystal", + quantity: 1, + type: "material_gain", + }, + id: "mn_e3", + text: "A distance crystal at the exact geometric impossibility of the expanse's centre.", + }, + { + effect: { amount: 240_000_000, type: "essence_gain" }, + id: "mn_e4", + text: "Nowhere-essence is the concentrated experience of there being nothing here. Your alchemist finds it unexpectedly full.", + }, + ], + id: "middle_nowhere", + name: "The Middle of Nowhere", + // 52h possibleMaterials: [ - { materialId: "expanse_dust", minQuantity: 4, maxQuantity: 9, weight: 3 }, - { materialId: "distance_crystal", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "mn_e1", text: "The middle of nowhere contains something that was lost so thoroughly it became the definition of lost. Your scouts found it.", effect: { type: "gold_gain", amount: 12000000000 } }, - { id: "mn_e2", text: "Your scouts lost something so thoroughly at the middle of nowhere that even the expanse could not locate it.", effect: { type: "gold_loss", amount: 4800000000 } }, - { id: "mn_e3", text: "A distance crystal at the exact geometric impossibility of the expanse's centre.", effect: { type: "material_gain", materialId: "distance_crystal", quantity: 1 } }, - { id: "mn_e4", text: "Nowhere-essence is the concentrated experience of there being nothing here. Your alchemist finds it unexpectedly full.", effect: { type: "essence_gain", amount: 240000000 } }, + { materialId: "expanse_dust", maxQuantity: 9, minQuantity: 4, weight: 3 }, + { + materialId: "distance_crystal", + maxQuantity: 2, + minQuantity: 1, + weight: 2, + }, ], + + zoneId: "infinite_expanse", }, { - id: "edge_approach", - name: "The Edge Approach", - description: "The road toward the edge that the expanse does not have. Your scouts know it does not exist. They are getting closer to it anyway.", - zoneId: "infinite_expanse", - durationSeconds: 280800, // 78h + description: + "The road toward the edge that the expanse does not have. Your scouts know it does not exist. They are getting closer to it anyway.", + durationSeconds: 280_800, + events: [ + { + effect: { amount: 25_000_000_000, type: "gold_gain" }, + id: "ea_e1", + text: "Near the edge that does not exist, things that should not exist are more concentrated. Including wealth.", + }, + { + effect: { amount: 10_000_000_000, type: "gold_loss" }, + id: "ea_e2", + text: "Something at the approach collected a toll for proximity to an edge that does not exist. The toll was real.", + }, + { + effect: { + materialId: "infinity_shard", + quantity: 1, + type: "material_gain", + }, + id: "ea_e3", + text: "An infinity shard from where the edge would be, if the edge were real. It is very much real.", + }, + { + effect: { amount: 500_000_000, type: "essence_gain" }, + id: "ea_e4", + text: "Edge-approach essence carries the feeling of being almost at something. It is very motivating.", + }, + ], + id: "edge_approach", + name: "The Edge Approach", + // 78h possibleMaterials: [ - { materialId: "distance_crystal", minQuantity: 1, maxQuantity: 3, weight: 3 }, - { materialId: "infinity_shard", minQuantity: 1, maxQuantity: 1, weight: 1 }, - ], - events: [ - { id: "ea_e1", text: "Near the edge that does not exist, things that should not exist are more concentrated. Including wealth.", effect: { type: "gold_gain", amount: 25000000000 } }, - { id: "ea_e2", text: "Something at the approach collected a toll for proximity to an edge that does not exist. The toll was real.", effect: { type: "gold_loss", amount: 10000000000 } }, - { id: "ea_e3", text: "An infinity shard from where the edge would be, if the edge were real. It is very much real.", effect: { type: "material_gain", materialId: "infinity_shard", quantity: 1 } }, - { id: "ea_e4", text: "Edge-approach essence carries the feeling of being almost at something. It is very motivating.", effect: { type: "essence_gain", amount: 500000000 } }, + { + materialId: "distance_crystal", + maxQuantity: 3, + minQuantity: 1, + weight: 3, + }, + { + materialId: "infinity_shard", + maxQuantity: 1, + minQuantity: 1, + weight: 1, + }, ], + + zoneId: "infinite_expanse", }, { - id: "the_furthest", - name: "The Furthest", - description: "As far as any being has ever gone in the infinite expanse. Your scouts hold this record now. They are not entirely sure whether to be proud or frightened.", - zoneId: "infinite_expanse", - durationSeconds: 374400, // 104h + description: + "As far as any being has ever gone in the infinite expanse. Your scouts hold this record now. They are not entirely sure whether to be proud or frightened.", + durationSeconds: 374_400, + events: [ + { + effect: { amount: 50_000_000_000, type: "gold_gain" }, + id: "tf_e1", + text: "The furthest point holds the furthest things. The furthest things are very valuable.", + }, + { + effect: { amount: 20_000_000_000, type: "gold_loss" }, + id: "tf_e2", + text: "Getting home from the furthest point is expensive. Distance is not your guild's friend today.", + }, + { + effect: { + materialId: "infinity_shard", + quantity: 1, + type: "material_gain", + }, + id: "tf_e3", + text: "An infinity shard from the furthest any expedition has gone — carrying more distance than should fit in it.", + }, + { + effect: { amount: 1_000_000_000, type: "essence_gain" }, + id: "tf_e4", + text: "Furthest-essence is the essence of absolute distance. Your alchemist works on it from a very long way away, symbolically.", + }, + ], + id: "the_furthest", + name: "The Furthest", + // 104h possibleMaterials: [ - { materialId: "distance_crystal", minQuantity: 2, maxQuantity: 4, weight: 3 }, - { materialId: "infinity_shard", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "tf_e1", text: "The furthest point holds the furthest things. The furthest things are very valuable.", effect: { type: "gold_gain", amount: 50000000000 } }, - { id: "tf_e2", text: "Getting home from the furthest point is expensive. Distance is not your guild's friend today.", effect: { type: "gold_loss", amount: 20000000000 } }, - { id: "tf_e3", text: "An infinity shard from the furthest any expedition has gone — carrying more distance than should fit in it.", effect: { type: "material_gain", materialId: "infinity_shard", quantity: 1 } }, - { id: "tf_e4", text: "Furthest-essence is the essence of absolute distance. Your alchemist works on it from a very long way away, symbolically.", effect: { type: "essence_gain", amount: 1000000000 } }, + { + materialId: "distance_crystal", + maxQuantity: 4, + minQuantity: 2, + weight: 3, + }, + { + materialId: "infinity_shard", + maxQuantity: 2, + minQuantity: 1, + weight: 2, + }, ], + + zoneId: "infinite_expanse", }, // ── Zone 15: reality_forge ──────────────────────────────────────────────── { - id: "workshop_entrance", - name: "The Workshop Entrance", - description: "The outer area of the reality forge, where the overflow of unrealised realities pools and cools. Things that never quite existed are everywhere here, and some of them are extremely useful.", - zoneId: "reality_forge", - durationSeconds: 100800, // 28h + description: + "The outer area of the reality forge, where the overflow of unrealised realities pools and cools. Things that never quite existed are everywhere here, and some of them are extremely useful.", + durationSeconds: 100_800, + events: [ + { + effect: { amount: 20_000_000_000, type: "gold_gain" }, + id: "we_e1", + text: "The overflow pool contains rejected realities with useful properties. Your scouts extract what they can.", + }, + { + effect: { amount: 8_000_000_000, type: "gold_loss" }, + id: "we_e2", + text: "A rejected reality became briefly real enough to take something from your scouts before being rejected again.", + }, + { + effect: { materialId: "forge_ash", quantity: 4, type: "material_gain" }, + id: "we_e3", + text: "Forge ash from this batch contains particularly dense unrealised potential.", + }, + { + effect: { amount: 400_000_000, type: "essence_gain" }, + id: "we_e4", + text: "Workshop-entrance essence is the overflow of creation — the part that did not make it into anything.", + }, + ], + id: "workshop_entrance", + name: "The Workshop Entrance", + // 28h possibleMaterials: [ - { materialId: "forge_ash", minQuantity: 3, maxQuantity: 7, weight: 3 }, - ], - events: [ - { id: "we_e1", text: "The overflow pool contains rejected realities with useful properties. Your scouts extract what they can.", effect: { type: "gold_gain", amount: 20000000000 } }, - { id: "we_e2", text: "A rejected reality became briefly real enough to take something from your scouts before being rejected again.", effect: { type: "gold_loss", amount: 8000000000 } }, - { id: "we_e3", text: "Forge ash from this batch contains particularly dense unrealised potential.", effect: { type: "material_gain", materialId: "forge_ash", quantity: 4 } }, - { id: "we_e4", text: "Workshop-entrance essence is the overflow of creation — the part that did not make it into anything.", effect: { type: "essence_gain", amount: 400000000 } }, + { materialId: "forge_ash", maxQuantity: 7, minQuantity: 3, weight: 3 }, ], + + zoneId: "reality_forge", }, { - id: "creation_floor", - name: "The Creation Floor", - description: "Where realities are assembled from the raw components of existence. The work here is continuous and has been going on since before your universe was queued.", - zoneId: "reality_forge", - durationSeconds: 201600, // 56h + description: + "Where realities are assembled from the raw components of existence. The work here is continuous and has been going on since before your universe was queued.", + durationSeconds: 201_600, + events: [ + { + effect: { amount: 40_000_000_000, type: "gold_gain" }, + id: "cfl_e1", + text: "A reality in production is briefly assigned to your guild's specifications. The payment for this is substantial.", + }, + { + effect: { amount: 16_000_000_000, type: "gold_loss" }, + id: "cfl_e2", + text: "A reality in production incorrectly assigned a debt to your guild. The forge's billing department is centuries behind.", + }, + { + effect: { + materialId: "creation_tool", + quantity: 1, + type: "material_gain", + }, + id: "cfl_e3", + text: "A worn creation tool, left by a worker who has not returned to claim it. Still perfectly functional.", + }, + { + effect: { amount: 800_000_000, type: "essence_gain" }, + id: "cfl_e4", + text: "Floor-essence is the breath of ongoing creation. Your alchemist breathes it carefully and makes extensive notes.", + }, + ], + id: "creation_floor", + name: "The Creation Floor", + // 56h possibleMaterials: [ - { materialId: "forge_ash", minQuantity: 4, maxQuantity: 9, weight: 3 }, - { materialId: "creation_tool", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "cfl_e1", text: "A reality in production is briefly assigned to your guild's specifications. The payment for this is substantial.", effect: { type: "gold_gain", amount: 40000000000 } }, - { id: "cfl_e2", text: "A reality in production incorrectly assigned a debt to your guild. The forge's billing department is centuries behind.", effect: { type: "gold_loss", amount: 16000000000 } }, - { id: "cfl_e3", text: "A worn creation tool, left by a worker who has not returned to claim it. Still perfectly functional.", effect: { type: "material_gain", materialId: "creation_tool", quantity: 1 } }, - { id: "cfl_e4", text: "Floor-essence is the breath of ongoing creation. Your alchemist breathes it carefully and makes extensive notes.", effect: { type: "essence_gain", amount: 800000000 } }, + { materialId: "forge_ash", maxQuantity: 9, minQuantity: 4, weight: 3 }, + { + materialId: "creation_tool", + maxQuantity: 2, + minQuantity: 1, + weight: 2, + }, ], + + zoneId: "reality_forge", }, { - id: "master_forge", - name: "The Master Forge", - description: "The primary forging station, where major realities are hammered into their final shape. The hammers are larger than planets. The anvil has never been named because no one has ever successfully described it.", - zoneId: "reality_forge", - durationSeconds: 302400, // 84h + description: + "The primary forging station, where major realities are hammered into their final shape. The hammers are larger than planets. The anvil has never been named because no one has ever successfully described it.", + durationSeconds: 302_400, + events: [ + { + effect: { amount: 80_000_000_000, type: "gold_gain" }, + id: "mf_e1", + text: "A forging commission was misfiled and your guild was listed as the recipient. The commission is worth immeasurably more than usual.", + }, + { + effect: { amount: 32_000_000_000, type: "gold_loss" }, + id: "mf_e2", + text: "The forge's scheduling error assigned your guild as collateral for a major commission. The fee was astronomical.", + }, + { + effect: { + materialId: "reality_shard", + quantity: 1, + type: "material_gain", + }, + id: "mf_e3", + text: "A reality shard, rejected as below the master forge's standards. By any other measure: extraordinary.", + }, + { + effect: { amount: 1_600_000_000, type: "essence_gain" }, + id: "mf_e4", + text: "Master-forge essence carries the heat and purpose of reality-making. Your alchemist handles it with forge-grade equipment.", + }, + ], + id: "master_forge", + name: "The Master Forge", + // 84h possibleMaterials: [ - { materialId: "creation_tool", minQuantity: 1, maxQuantity: 3, weight: 3 }, - { materialId: "reality_shard", minQuantity: 1, maxQuantity: 1, weight: 1 }, - ], - events: [ - { id: "mf_e1", text: "A forging commission was misfiled and your guild was listed as the recipient. The commission is worth immeasurably more than usual.", effect: { type: "gold_gain", amount: 80000000000 } }, - { id: "mf_e2", text: "The forge's scheduling error assigned your guild as collateral for a major commission. The fee was astronomical.", effect: { type: "gold_loss", amount: 32000000000 } }, - { id: "mf_e3", text: "A reality shard, rejected as below the master forge's standards. By any other measure: extraordinary.", effect: { type: "material_gain", materialId: "reality_shard", quantity: 1 } }, - { id: "mf_e4", text: "Master-forge essence carries the heat and purpose of reality-making. Your alchemist handles it with forge-grade equipment.", effect: { type: "essence_gain", amount: 1600000000 } }, + { + materialId: "creation_tool", + maxQuantity: 3, + minQuantity: 1, + weight: 3, + }, + { + materialId: "reality_shard", + maxQuantity: 1, + minQuantity: 1, + weight: 1, + }, ], + + zoneId: "reality_forge", }, { - id: "forge_core", - name: "The Forge Core", - description: "The energy source that powers the entire reality forge. It has been running since before time was a meaningful concept. What powers it is not a question that has been answered by anyone who came here to ask it.", - zoneId: "reality_forge", - durationSeconds: 403200, // 112h + description: + "The energy source that powers the entire reality forge. It has been running since before time was a meaningful concept. What powers it is not a question that has been answered by anyone who came here to ask it.", + durationSeconds: 403_200, + events: [ + { + effect: { amount: 160_000_000_000, type: "gold_gain" }, + id: "fc2_e1", + text: "The forge core outputs something every few billion years. Today it outputted something. Your scouts were there.", + }, + { + effect: { amount: 65_000_000_000, type: "gold_loss" }, + id: "fc2_e2", + text: "The forge core requires a tithe from anything that approaches it. It always has. The amount is non-negotiable.", + }, + { + effect: { + materialId: "reality_shard", + quantity: 1, + type: "material_gain", + }, + id: "fc2_e3", + text: "A reality shard from the forge core itself — something that could have been a universe if the settings had been slightly different.", + }, + { + effect: { amount: 3_200_000_000, type: "essence_gain" }, + id: "fc2_e4", + text: "Core-forge essence is the power of creation itself. Your alchemist declines to speculate about what it means.", + }, + ], + id: "forge_core", + name: "The Forge Core", + // 112h possibleMaterials: [ - { materialId: "creation_tool", minQuantity: 2, maxQuantity: 4, weight: 3 }, - { materialId: "reality_shard", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "fc2_e1", text: "The forge core outputs something every few billion years. Today it outputted something. Your scouts were there.", effect: { type: "gold_gain", amount: 160000000000 } }, - { id: "fc2_e2", text: "The forge core requires a tithe from anything that approaches it. It always has. The amount is non-negotiable.", effect: { type: "gold_loss", amount: 65000000000 } }, - { id: "fc2_e3", text: "A reality shard from the forge core itself — something that could have been a universe if the settings had been slightly different.", effect: { type: "material_gain", materialId: "reality_shard", quantity: 1 } }, - { id: "fc2_e4", text: "Core-forge essence is the power of creation itself. Your alchemist declines to speculate about what it means.", effect: { type: "essence_gain", amount: 3200000000 } }, + { + materialId: "creation_tool", + maxQuantity: 4, + minQuantity: 2, + weight: 3, + }, + { + materialId: "reality_shard", + maxQuantity: 2, + minQuantity: 1, + weight: 2, + }, ], + + zoneId: "reality_forge", }, // ── Zone 16: cosmic_maelstrom ───────────────────────────────────────────── { - id: "outer_current", - name: "The Outer Current", - description: "The outermost spiral of the cosmic maelstrom, where the forces are at their most navigable — which still means they routinely shatter planets that wander too close.", - zoneId: "cosmic_maelstrom", - durationSeconds: 108000, // 30h + description: + "The outermost spiral of the cosmic maelstrom, where the forces are at their most navigable — which still means they routinely shatter planets that wander too close.", + durationSeconds: 108_000, + events: [ + { + effect: { amount: 60_000_000_000, type: "gold_gain" }, + id: "oc_e1", + text: "The outer current carries debris from civilisations that were not careful enough. Some of it is recoverable and valuable.", + }, + { + effect: { amount: 24_000_000_000, type: "gold_loss" }, + id: "oc_e2", + text: "The outer current decided to keep something of your scouts'. The forces involved were non-negotiable.", + }, + { + effect: { + materialId: "maelstrom_debris", + quantity: 4, + type: "material_gain", + }, + id: "oc_e3", + text: "Maelstrom debris of unusual density, compressed from something that was once considerably larger.", + }, + { + effect: { amount: 1_200_000_000, type: "essence_gain" }, + id: "oc_e4", + text: "Outer-current essence is kinetic beyond what distillation usually handles. Your alchemist uses a containment array.", + }, + ], + id: "outer_current", + name: "The Outer Current", + // 30h possibleMaterials: [ - { materialId: "maelstrom_debris", minQuantity: 3, maxQuantity: 7, weight: 3 }, - ], - events: [ - { id: "oc_e1", text: "The outer current carries debris from civilisations that were not careful enough. Some of it is recoverable and valuable.", effect: { type: "gold_gain", amount: 60000000000 } }, - { id: "oc_e2", text: "The outer current decided to keep something of your scouts'. The forces involved were non-negotiable.", effect: { type: "gold_loss", amount: 24000000000 } }, - { id: "oc_e3", text: "Maelstrom debris of unusual density, compressed from something that was once considerably larger.", effect: { type: "material_gain", materialId: "maelstrom_debris", quantity: 4 } }, - { id: "oc_e4", text: "Outer-current essence is kinetic beyond what distillation usually handles. Your alchemist uses a containment array.", effect: { type: "essence_gain", amount: 1200000000 } }, + { + materialId: "maelstrom_debris", + maxQuantity: 7, + minQuantity: 3, + weight: 3, + }, ], + + zoneId: "cosmic_maelstrom", }, { - id: "debris_field", - name: "The Debris Field", - description: "The accumulated wreckage of everything the maelstrom has consumed, compressed into a navigable (mostly) field. Your scouts move through it quickly. Things in debris fields become part of the debris field.", - zoneId: "cosmic_maelstrom", - durationSeconds: 216000, // 60h + description: + "The accumulated wreckage of everything the maelstrom has consumed, compressed into a navigable (mostly) field. Your scouts move through it quickly. Things in debris fields become part of the debris field.", + durationSeconds: 216_000, + events: [ + { + effect: { amount: 120_000_000_000, type: "gold_gain" }, + id: "df_e1", + text: "The debris field contains the compressed remains of a treasury. The gold is recognisable, barely, but recoverable.", + }, + { + effect: { amount: 48_000_000_000, type: "gold_loss" }, + id: "df_e2", + text: "The debris field added your scouts' supplies to its collection. The addition was non-optional.", + }, + { + effect: { + materialId: "force_crystal", + quantity: 1, + type: "material_gain", + }, + id: "df_e3", + text: "A force crystal, grown in the debris field where forces compressed something into something else.", + }, + { + effect: { amount: 2_400_000_000, type: "essence_gain" }, + id: "df_e4", + text: "Debris-field essence is concentrated destruction in harvestable form. Your alchemist treats it very carefully.", + }, + ], + id: "debris_field", + name: "The Debris Field", + // 60h possibleMaterials: [ - { materialId: "maelstrom_debris", minQuantity: 4, maxQuantity: 9, weight: 3 }, - { materialId: "force_crystal", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "df_e1", text: "The debris field contains the compressed remains of a treasury. The gold is recognisable, barely, but recoverable.", effect: { type: "gold_gain", amount: 120000000000 } }, - { id: "df_e2", text: "The debris field added your scouts' supplies to its collection. The addition was non-optional.", effect: { type: "gold_loss", amount: 48000000000 } }, - { id: "df_e3", text: "A force crystal, grown in the debris field where forces compressed something into something else.", effect: { type: "material_gain", materialId: "force_crystal", quantity: 1 } }, - { id: "df_e4", text: "Debris-field essence is concentrated destruction in harvestable form. Your alchemist treats it very carefully.", effect: { type: "essence_gain", amount: 2400000000 } }, + { + materialId: "maelstrom_debris", + maxQuantity: 9, + minQuantity: 4, + weight: 3, + }, + { + materialId: "force_crystal", + maxQuantity: 2, + minQuantity: 1, + weight: 2, + }, ], + + zoneId: "cosmic_maelstrom", }, { - id: "force_confluence", - name: "The Force Confluence", - description: "Where the fundamental forces of the cosmos intersect inside the maelstrom. Gravity and electromagnetism and things that do not have names yet jostle each other here with consequences that exceed polite description.", - zoneId: "cosmic_maelstrom", - durationSeconds: 324000, // 90h + description: + "Where the fundamental forces of the cosmos intersect inside the maelstrom. Gravity and electromagnetism and things that do not have names yet jostle each other here with consequences that exceed polite description.", + durationSeconds: 324_000, + events: [ + { + effect: { amount: 250_000_000_000, type: "gold_gain" }, + id: "fcon_e1", + text: "The forces briefly aligned in a configuration that is locally considered extremely profitable. Your scouts agreed.", + }, + { + effect: { amount: 100_000_000_000, type: "gold_loss" }, + id: "fcon_e2", + text: "The forces briefly aligned in a configuration that extracted a contribution from your scouts by several fundamental mechanisms simultaneously.", + }, + { + effect: { + materialId: "cosmic_fragment", + quantity: 1, + type: "material_gain", + }, + id: "fcon_e3", + text: "A cosmic fragment from the confluence's eye — the only point of calm in all of this.", + }, + { + effect: { amount: 5_000_000_000, type: "essence_gain" }, + id: "fcon_e4", + text: "Confluence-essence is the meeting point of all forces. Your alchemist needs a new laboratory to handle it.", + }, + ], + id: "force_confluence", + name: "The Force Confluence", + // 90h possibleMaterials: [ - { materialId: "force_crystal", minQuantity: 1, maxQuantity: 3, weight: 3 }, - { materialId: "cosmic_fragment", minQuantity: 1, maxQuantity: 1, weight: 1 }, - ], - events: [ - { id: "fcon_e1", text: "The forces briefly aligned in a configuration that is locally considered extremely profitable. Your scouts agreed.", effect: { type: "gold_gain", amount: 250000000000 } }, - { id: "fcon_e2", text: "The forces briefly aligned in a configuration that extracted a contribution from your scouts by several fundamental mechanisms simultaneously.", effect: { type: "gold_loss", amount: 100000000000 } }, - { id: "fcon_e3", text: "A cosmic fragment from the confluence's eye — the only point of calm in all of this.", effect: { type: "material_gain", materialId: "cosmic_fragment", quantity: 1 } }, - { id: "fcon_e4", text: "Confluence-essence is the meeting point of all forces. Your alchemist needs a new laboratory to handle it.", effect: { type: "essence_gain", amount: 5000000000 } }, + { + materialId: "force_crystal", + maxQuantity: 3, + minQuantity: 1, + weight: 3, + }, + { + materialId: "cosmic_fragment", + maxQuantity: 1, + minQuantity: 1, + weight: 1, + }, ], + + zoneId: "cosmic_maelstrom", }, { - id: "eye_approach", - name: "The Eye Approach", - description: "The path to the maelstrom's impossible centre — the one point of absolute calm surrounded by forces that make galaxies look fragile. Your scouts have never been so far in. They are doing this anyway.", - zoneId: "cosmic_maelstrom", - durationSeconds: 432000, // 120h + description: + "The path to the maelstrom's impossible centre — the one point of absolute calm surrounded by forces that make galaxies look fragile. Your scouts have never been so far in. They are doing this anyway.", + durationSeconds: 432_000, + events: [ + { + effect: { amount: 500_000_000_000, type: "gold_gain" }, + id: "eye_e1", + text: "The eye of the maelstrom contains everything the maelstrom has been turning toward for aeons. Some of it is gold.", + }, + { + effect: { amount: 200_000_000_000, type: "gold_loss" }, + id: "eye_e2", + text: "The approach extracted something from your scouts the way all maelstroms do: comprehensively and without asking.", + }, + { + effect: { + materialId: "cosmic_fragment", + quantity: 1, + type: "material_gain", + }, + id: "eye_e3", + text: "A cosmic fragment from the very eye — where everything in the maelstrom is heading, always.", + }, + { + effect: { amount: 10_000_000_000, type: "essence_gain" }, + id: "eye_e4", + text: "Eye-approach essence is the calm at the centre of every storm. Your alchemist works with remarkable focus after handling it.", + }, + ], + id: "eye_approach", + name: "The Eye Approach", + // 120h possibleMaterials: [ - { materialId: "force_crystal", minQuantity: 2, maxQuantity: 4, weight: 3 }, - { materialId: "cosmic_fragment", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "eye_e1", text: "The eye of the maelstrom contains everything the maelstrom has been turning toward for aeons. Some of it is gold.", effect: { type: "gold_gain", amount: 500000000000 } }, - { id: "eye_e2", text: "The approach extracted something from your scouts the way all maelstroms do: comprehensively and without asking.", effect: { type: "gold_loss", amount: 200000000000 } }, - { id: "eye_e3", text: "A cosmic fragment from the very eye — where everything in the maelstrom is heading, always.", effect: { type: "material_gain", materialId: "cosmic_fragment", quantity: 1 } }, - { id: "eye_e4", text: "Eye-approach essence is the calm at the centre of every storm. Your alchemist works with remarkable focus after handling it.", effect: { type: "essence_gain", amount: 10000000000 } }, + { + materialId: "force_crystal", + maxQuantity: 4, + minQuantity: 2, + weight: 3, + }, + { + materialId: "cosmic_fragment", + maxQuantity: 2, + minQuantity: 1, + weight: 2, + }, ], + + zoneId: "cosmic_maelstrom", }, // ── Zone 17: primeval_sanctum ───────────────────────────────────────────── { - id: "first_steps", - name: "The First Steps", - description: "The entrance to the oldest place. The floor here was walked before walking was invented, which is philosophically impossible and physically evident.", - zoneId: "primeval_sanctum", - durationSeconds: 115200, // 32h + description: + "The entrance to the oldest place. The floor here was walked before walking was invented, which is philosophically impossible and physically evident.", + durationSeconds: 115_200, + events: [ + { + effect: { amount: 200_000_000_000, type: "gold_gain" }, + id: "fs_e1", + text: "The first steps led to something that has been waiting for a visitor. The wait was long. The gift is proportional.", + }, + { + effect: { amount: 80_000_000_000, type: "gold_loss" }, + id: "fs_e2", + text: "The sanctum extracted a first-visit levy. This is the oldest toll road your guild will ever use.", + }, + { + effect: { + materialId: "ancient_dust", + quantity: 4, + type: "material_gain", + }, + id: "fs_e3", + text: "Ancient dust from the very first footfalls. It does not compress. It remembers what it was stepped on by.", + }, + { + effect: { amount: 4_000_000_000, type: "essence_gain" }, + id: "fs_e4", + text: "First-steps essence carries the age of the beginning. Your alchemist does not speak for three days afterward.", + }, + ], + id: "first_steps", + name: "The First Steps", + // 32h possibleMaterials: [ - { materialId: "ancient_dust", minQuantity: 3, maxQuantity: 7, weight: 3 }, - ], - events: [ - { id: "fs_e1", text: "The first steps led to something that has been waiting for a visitor. The wait was long. The gift is proportional.", effect: { type: "gold_gain", amount: 200000000000 } }, - { id: "fs_e2", text: "The sanctum extracted a first-visit levy. This is the oldest toll road your guild will ever use.", effect: { type: "gold_loss", amount: 80000000000 } }, - { id: "fs_e3", text: "Ancient dust from the very first footfalls. It does not compress. It remembers what it was stepped on by.", effect: { type: "material_gain", materialId: "ancient_dust", quantity: 4 } }, - { id: "fs_e4", text: "First-steps essence carries the age of the beginning. Your alchemist does not speak for three days afterward.", effect: { type: "essence_gain", amount: 4000000000 } }, + { materialId: "ancient_dust", maxQuantity: 7, minQuantity: 3, weight: 3 }, ], + + zoneId: "primeval_sanctum", }, { - id: "ancient_archive", - name: "The Ancient Archive", - description: "A collection of records that predate the concept of records. The information stored here concerns things that no longer exist, but the records persist because the sanctum will not let them stop.", - zoneId: "primeval_sanctum", - durationSeconds: 230400, // 64h + description: + "A collection of records that predate the concept of records. The information stored here concerns things that no longer exist, but the records persist because the sanctum will not let them stop.", + durationSeconds: 230_400, + events: [ + { + effect: { amount: 400_000_000_000, type: "gold_gain" }, + id: "aa_e1", + text: "An archived record describes the location of something placed here before the archive existed. Your scouts locate it.", + }, + { + effect: { amount: 160_000_000_000, type: "gold_loss" }, + id: "aa_e2", + text: "An archived record includes a debt incurred by something. The archive's system has transferred it to your guild. Payment was expected.", + }, + { + effect: { + materialId: "memory_shard", + quantity: 1, + type: "material_gain", + }, + id: "aa_e3", + text: "A memory shard from an archived moment so old it predates memory itself.", + }, + { + effect: { amount: 8_000_000_000, type: "essence_gain" }, + id: "aa_e4", + text: "Archive-essence carries every moment ever recorded here. There are many moments. The essence is very dense.", + }, + ], + id: "ancient_archive", + name: "The Ancient Archive", + // 64h possibleMaterials: [ - { materialId: "ancient_dust", minQuantity: 4, maxQuantity: 9, weight: 3 }, - { materialId: "memory_shard", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "aa_e1", text: "An archived record describes the location of something placed here before the archive existed. Your scouts locate it.", effect: { type: "gold_gain", amount: 400000000000 } }, - { id: "aa_e2", text: "An archived record includes a debt incurred by something. The archive's system has transferred it to your guild. Payment was expected.", effect: { type: "gold_loss", amount: 160000000000 } }, - { id: "aa_e3", text: "A memory shard from an archived moment so old it predates memory itself.", effect: { type: "material_gain", materialId: "memory_shard", quantity: 1 } }, - { id: "aa_e4", text: "Archive-essence carries every moment ever recorded here. There are many moments. The essence is very dense.", effect: { type: "essence_gain", amount: 8000000000 } }, + { materialId: "ancient_dust", maxQuantity: 9, minQuantity: 4, weight: 3 }, + { materialId: "memory_shard", maxQuantity: 2, minQuantity: 1, weight: 2 }, ], + + zoneId: "primeval_sanctum", }, { - id: "memory_chamber", - name: "The Memory Chamber", - description: "Where the sanctum stores the memory of the first moment of existence. The memory is perfect, complete, and overwhelming. Your scouts spend the minimum time here and speak little for some time after.", - zoneId: "primeval_sanctum", - durationSeconds: 345600, // 96h + description: + "Where the sanctum stores the memory of the first moment of existence. The memory is perfect, complete, and overwhelming. Your scouts spend the minimum time here and speak little for some time after.", + durationSeconds: 345_600, + events: [ + { + effect: { amount: 800_000_000_000, type: "gold_gain" }, + id: "mc_e1", + text: "The memory of the first moment briefly showed your guild what came just before it. There was considerable wealth there.", + }, + { + effect: { amount: 320_000_000_000, type: "gold_loss" }, + id: "mc_e2", + text: "The memory of the first moment included a memory of a debt. Your guild has been paying it across many lifetimes without knowing.", + }, + { + effect: { + materialId: "primeval_relic", + quantity: 1, + type: "material_gain", + }, + id: "mc_e3", + text: "A primeval relic from the memory chamber — the first thing ever used, in the memory of its use.", + }, + { + effect: { amount: 16_000_000_000, type: "essence_gain" }, + id: "mc_e4", + text: "Memory-chamber essence contains the first thought ever thought. Your alchemist is very careful what they think while holding it.", + }, + ], + id: "memory_chamber", + name: "The Memory Chamber", + // 96h possibleMaterials: [ - { materialId: "memory_shard", minQuantity: 1, maxQuantity: 3, weight: 3 }, - { materialId: "primeval_relic", minQuantity: 1, maxQuantity: 1, weight: 1 }, - ], - events: [ - { id: "mc_e1", text: "The memory of the first moment briefly showed your guild what came just before it. There was considerable wealth there.", effect: { type: "gold_gain", amount: 800000000000 } }, - { id: "mc_e2", text: "The memory of the first moment included a memory of a debt. Your guild has been paying it across many lifetimes without knowing.", effect: { type: "gold_loss", amount: 320000000000 } }, - { id: "mc_e3", text: "A primeval relic from the memory chamber — the first thing ever used, in the memory of its use.", effect: { type: "material_gain", materialId: "primeval_relic", quantity: 1 } }, - { id: "mc_e4", text: "Memory-chamber essence contains the first thought ever thought. Your alchemist is very careful what they think while holding it.", effect: { type: "essence_gain", amount: 16000000000 } }, + { materialId: "memory_shard", maxQuantity: 3, minQuantity: 1, weight: 3 }, + { + materialId: "primeval_relic", + maxQuantity: 1, + minQuantity: 1, + weight: 1, + }, ], + + zoneId: "primeval_sanctum", }, { - id: "the_oldest_place", - name: "The Oldest Place", - description: "There is nothing older than this. The sanctum's deepest point, where the very first thing that ever was still is, unchanged, because nothing in the universe has had long enough to change it.", - zoneId: "primeval_sanctum", - durationSeconds: 460800, // 128h + description: + "There is nothing older than this. The sanctum's deepest point, where the very first thing that ever was still is, unchanged, because nothing in the universe has had long enough to change it.", + durationSeconds: 460_800, + events: [ + { + effect: { amount: 1_600_000_000_000, type: "gold_gain" }, + id: "top_e1", + text: "The first thing that ever was acknowledges your guild. The acknowledgment takes the form of the oldest expression of approval: considerable wealth.", + }, + { + effect: { amount: 640_000_000_000, type: "gold_loss" }, + id: "top_e2", + text: "The first thing that ever was notices something of yours and takes it back, as if it was always meant to be here.", + }, + { + effect: { + materialId: "primeval_relic", + quantity: 1, + type: "material_gain", + }, + id: "top_e3", + text: "A primeval relic from the oldest place — the first artefact of the first thing. Yours now.", + }, + { + effect: { amount: 32_000_000_000, type: "essence_gain" }, + id: "top_e4", + text: "Oldest-place essence is the essence of the very beginning. Your alchemist processes it and immediately retires.", + }, + ], + id: "the_oldest_place", + name: "The Oldest Place", + // 128h possibleMaterials: [ - { materialId: "memory_shard", minQuantity: 2, maxQuantity: 4, weight: 3 }, - { materialId: "primeval_relic", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "top_e1", text: "The first thing that ever was acknowledges your guild. The acknowledgment takes the form of the oldest expression of approval: considerable wealth.", effect: { type: "gold_gain", amount: 1600000000000 } }, - { id: "top_e2", text: "The first thing that ever was notices something of yours and takes it back, as if it was always meant to be here.", effect: { type: "gold_loss", amount: 640000000000 } }, - { id: "top_e3", text: "A primeval relic from the oldest place — the first artefact of the first thing. Yours now.", effect: { type: "material_gain", materialId: "primeval_relic", quantity: 1 } }, - { id: "top_e4", text: "Oldest-place essence is the essence of the very beginning. Your alchemist processes it and immediately retires.", effect: { type: "essence_gain", amount: 32000000000 } }, + { materialId: "memory_shard", maxQuantity: 4, minQuantity: 2, weight: 3 }, + { + materialId: "primeval_relic", + maxQuantity: 2, + minQuantity: 1, + weight: 2, + }, ], + + zoneId: "primeval_sanctum", }, // ── Zone 18: the_absolute ───────────────────────────────────────────────── { - id: "edge_of_everything", - name: "The Edge of Everything", - description: "The boundary between existence and non-existence. On one side: everything there is. On the other: everything there isn't. The view from here is indescribable and has been described by your scouts at length.", - zoneId: "the_absolute", - durationSeconds: 129600, // 36h + description: + "The boundary between existence and non-existence. On one side: everything there is. On the other: everything there isn't. The view from here is indescribable and has been described by your scouts at length.", + durationSeconds: 129_600, + events: [ + { + effect: { amount: 600_000_000_000, type: "gold_gain" }, + id: "eoe_e1", + text: "The edge yields something from the other side — from non-existence, which turns out to have things in it.", + }, + { + effect: { amount: 240_000_000_000, type: "gold_loss" }, + id: "eoe_e2", + text: "Something from non-existence was interested in your scouts' equipment. It has it now.", + }, + { + effect: { + materialId: "absolute_fragment", + quantity: 4, + type: "material_gain", + }, + id: "eoe_e3", + text: "Absolute fragments shed from the edge itself, where everything and nothing meet.", + }, + { + effect: { amount: 12_000_000_000, type: "essence_gain" }, + id: "eoe_e4", + text: "Edge-essence is the boundary of all that is. Your alchemist works from both sides simultaneously.", + }, + ], + id: "edge_of_everything", + name: "The Edge of Everything", + // 36h possibleMaterials: [ - { materialId: "absolute_fragment", minQuantity: 3, maxQuantity: 7, weight: 3 }, - ], - events: [ - { id: "eoe_e1", text: "The edge yields something from the other side — from non-existence, which turns out to have things in it.", effect: { type: "gold_gain", amount: 600000000000 } }, - { id: "eoe_e2", text: "Something from non-existence was interested in your scouts' equipment. It has it now.", effect: { type: "gold_loss", amount: 240000000000 } }, - { id: "eoe_e3", text: "Absolute fragments shed from the edge itself, where everything and nothing meet.", effect: { type: "material_gain", materialId: "absolute_fragment", quantity: 4 } }, - { id: "eoe_e4", text: "Edge-essence is the boundary of all that is. Your alchemist works from both sides simultaneously.", effect: { type: "essence_gain", amount: 12000000000 } }, + { + materialId: "absolute_fragment", + maxQuantity: 7, + minQuantity: 3, + weight: 3, + }, ], + + zoneId: "the_absolute", }, { - id: "truth_approach", - name: "The Truth Approach", - description: "The road to the final truth, which your guild has been walking toward since the first step in the Verdant Vale. It looks like every other road your guild has walked. It feels different.", - zoneId: "the_absolute", - durationSeconds: 259200, // 72h + description: + "The road to the final truth, which your guild has been walking toward since the first step in the Verdant Vale. It looks like every other road your guild has walked. It feels different.", + durationSeconds: 259_200, + events: [ + { + effect: { amount: 1_200_000_000_000, type: "gold_gain" }, + id: "tra_e1", + text: "The approach yields something that has been waiting for exactly your guild, at exactly this moment.", + }, + { + effect: { amount: 480_000_000_000, type: "gold_loss" }, + id: "tra_e2", + text: "The approach extracted a toll that seems proportional to how far your guild has come. It is a very large toll.", + }, + { + effect: { + materialId: "boundary_shard", + quantity: 1, + type: "material_gain", + }, + id: "tra_e3", + text: "A boundary shard from where the approach touches the final truth.", + }, + { + effect: { amount: 24_000_000_000, type: "essence_gain" }, + id: "tra_e4", + text: "Truth-approach essence is the accumulated potential of approaching the absolute. Your alchemist has been waiting for this.", + }, + ], + id: "truth_approach", + name: "The Truth Approach", + // 72h possibleMaterials: [ - { materialId: "absolute_fragment", minQuantity: 4, maxQuantity: 9, weight: 3 }, - { materialId: "boundary_shard", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "tra_e1", text: "The approach yields something that has been waiting for exactly your guild, at exactly this moment.", effect: { type: "gold_gain", amount: 1200000000000 } }, - { id: "tra_e2", text: "The approach extracted a toll that seems proportional to how far your guild has come. It is a very large toll.", effect: { type: "gold_loss", amount: 480000000000 } }, - { id: "tra_e3", text: "A boundary shard from where the approach touches the final truth.", effect: { type: "material_gain", materialId: "boundary_shard", quantity: 1 } }, - { id: "tra_e4", text: "Truth-approach essence is the accumulated potential of approaching the absolute. Your alchemist has been waiting for this.", effect: { type: "essence_gain", amount: 24000000000 } }, + { + materialId: "absolute_fragment", + maxQuantity: 9, + minQuantity: 4, + weight: 3, + }, + { + materialId: "boundary_shard", + maxQuantity: 2, + minQuantity: 1, + weight: 2, + }, ], + + zoneId: "the_absolute", }, { - id: "final_antechamber", - name: "The Final Antechamber", - description: "One step from the absolute. The door ahead is the last door. Your guild has opened every other door. This one opens when you are ready, which is something only the absolute can determine.", - zoneId: "the_absolute", - durationSeconds: 388800, // 108h + description: + "One step from the absolute. The door ahead is the last door. Your guild has opened every other door. This one opens when you are ready, which is something only the absolute can determine.", + durationSeconds: 388_800, + events: [ + { + effect: { amount: 2_500_000_000_000, type: "gold_gain" }, + id: "fan_e1", + text: "The antechamber contains the deferred offerings of every being that was judged ready before your guild. They are yours now.", + }, + { + effect: { amount: 1_000_000_000_000, type: "gold_loss" }, + id: "fan_e2", + text: "The antechamber extracted preparation costs. What lies ahead requires that you come as you are. Lighter.", + }, + { + effect: { + materialId: "omega_crystal", + quantity: 1, + type: "material_gain", + }, + id: "fan_e3", + text: "An omega crystal from the antechamber floor — left by the last being to stand here before your guild.", + }, + { + effect: { amount: 50_000_000_000, type: "essence_gain" }, + id: "fan_e4", + text: "Antechamber-essence is final preparation. Your alchemist works on it with the focus of someone who knows this is the last time.", + }, + ], + id: "final_antechamber", + name: "The Final Antechamber", + // 108h possibleMaterials: [ - { materialId: "boundary_shard", minQuantity: 1, maxQuantity: 3, weight: 3 }, - { materialId: "omega_crystal", minQuantity: 1, maxQuantity: 1, weight: 1 }, - ], - events: [ - { id: "fan_e1", text: "The antechamber contains the deferred offerings of every being that was judged ready before your guild. They are yours now.", effect: { type: "gold_gain", amount: 2500000000000 } }, - { id: "fan_e2", text: "The antechamber extracted preparation costs. What lies ahead requires that you come as you are. Lighter.", effect: { type: "gold_loss", amount: 1000000000000 } }, - { id: "fan_e3", text: "An omega crystal from the antechamber floor — left by the last being to stand here before your guild.", effect: { type: "material_gain", materialId: "omega_crystal", quantity: 1 } }, - { id: "fan_e4", text: "Antechamber-essence is final preparation. Your alchemist works on it with the focus of someone who knows this is the last time.", effect: { type: "essence_gain", amount: 50000000000 } }, + { + materialId: "boundary_shard", + maxQuantity: 3, + minQuantity: 1, + weight: 3, + }, + { + materialId: "omega_crystal", + maxQuantity: 1, + minQuantity: 1, + weight: 1, + }, ], + + zoneId: "the_absolute", }, { - id: "the_absolute_heart", - name: "The Absolute Heart", - description: "The final truth, at the end of all things. There is nothing beyond this. Your guild stands here, at the end, and finds that the end is not empty. It has been waiting for you specifically.", - zoneId: "the_absolute", - durationSeconds: 518400, // 144h + description: + "The final truth, at the end of all things. There is nothing beyond this. Your guild stands here, at the end, and finds that the end is not empty. It has been waiting for you specifically.", + durationSeconds: 518_400, + events: [ + { + effect: { amount: 5_000_000_000_000, type: "gold_gain" }, + id: "tah_e1", + text: "The absolute heart recognises your guild as having reached it. The recognition is expressed as the final, and largest, reward possible.", + }, + { + effect: { amount: 2_000_000_000_000, type: "gold_loss" }, + id: "tah_e2", + text: "The absolute heart extracted the final toll. Everything ends, including wealth. Temporarily.", + }, + { + effect: { + materialId: "omega_crystal", + quantity: 1, + type: "material_gain", + }, + id: "tah_e3", + text: "An omega crystal from the absolute heart — the last omega crystal, which is fitting.", + }, + { + effect: { amount: 100_000_000_000, type: "essence_gain" }, + id: "tah_e4", + text: "Heart-of-the-absolute essence. Your alchemist processes it in silence. Everyone in the guild hall stops what they are doing and watches.", + }, + ], + id: "the_absolute_heart", + name: "The Absolute Heart", + // 144h possibleMaterials: [ - { materialId: "boundary_shard", minQuantity: 2, maxQuantity: 4, weight: 3 }, - { materialId: "omega_crystal", minQuantity: 1, maxQuantity: 2, weight: 2 }, - ], - events: [ - { id: "tah_e1", text: "The absolute heart recognises your guild as having reached it. The recognition is expressed as the final, and largest, reward possible.", effect: { type: "gold_gain", amount: 5000000000000 } }, - { id: "tah_e2", text: "The absolute heart extracted the final toll. Everything ends, including wealth. Temporarily.", effect: { type: "gold_loss", amount: 2000000000000 } }, - { id: "tah_e3", text: "An omega crystal from the absolute heart — the last omega crystal, which is fitting.", effect: { type: "material_gain", materialId: "omega_crystal", quantity: 1 } }, - { id: "tah_e4", text: "Heart-of-the-absolute essence. Your alchemist processes it in silence. Everyone in the guild hall stops what they are doing and watches.", effect: { type: "essence_gain", amount: 100000000000 } }, + { + materialId: "boundary_shard", + maxQuantity: 4, + minQuantity: 2, + weight: 3, + }, + { + materialId: "omega_crystal", + maxQuantity: 2, + minQuantity: 1, + weight: 2, + }, ], + + zoneId: "the_absolute", }, ]; diff --git a/apps/api/src/data/initialState.ts b/apps/api/src/data/initialState.ts index 752f08a..6ce59f7 100644 --- a/apps/api/src/data/initialState.ts +++ b/apps/api/src/data/initialState.ts @@ -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 }; diff --git a/apps/api/src/data/loginBonus.ts b/apps/api/src/data/loginBonus.ts index a539370..0ac22e9 100644 --- a/apps/api/src/data/loginBonus.ts +++ b/apps/api/src/data/loginBonus.ts @@ -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 1–7 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 = [ { 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 }, ]; diff --git a/apps/api/src/data/materials.ts b/apps/api/src/data/materials.ts index 096ae8f..c41ce01 100644 --- a/apps/api/src/data/materials.ts +++ b/apps/api/src/data/materials.ts @@ -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 = [ // 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", + }, ]; diff --git a/apps/api/src/data/prestigeUpgrades.ts b/apps/api/src/data/prestigeUpgrades.ts index bf5916b..48524df 100644 --- a/apps/api/src/data/prestigeUpgrades.ts +++ b/apps/api/src/data/prestigeUpgrades.ts @@ -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 = [ // ── 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, }, ]; diff --git a/apps/api/src/data/quests.ts b/apps/api/src/data/quests.ts index 704c8e7..6e9782e 100644 --- a/apps/api/src/data/quests.ts +++ b/apps/api/src/data/quests.ts @@ -1,1480 +1,1491 @@ +/** + * @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 { Quest } from "@elysium/types"; -export const DEFAULT_QUESTS: Quest[] = [ +export const defaultQuests: Array = [ // ── Verdant Vale ────────────────────────────────────────────────────────── { - id: "first_steps", - name: "First Steps", - description: "Every legend begins somewhere. Send your first adventurer into the field.", - status: "available", + description: + "Every legend begins somewhere. Send your first adventurer into the field.", durationSeconds: 60, - rewards: [ - { type: "gold", amount: 500 }, - { type: "adventurer", targetId: "militia" }, - ], + id: "first_steps", + name: "First Steps", prerequisiteIds: [], + rewards: [ + { amount: 500, type: "gold" }, + { targetId: "militia", type: "adventurer" }, + ], + status: "available", zoneId: "verdant_vale", }, { - id: "goblin_camp", - name: "Goblin Camp", - description: "Clear out a troublesome goblin camp to the east.", - status: "locked", + description: "Clear out a troublesome goblin camp to the east.", durationSeconds: 5 * 60, - rewards: [ - { type: "gold", amount: 2_000 }, - { type: "essence", amount: 5 }, - { type: "adventurer", targetId: "apprentice" }, + id: "goblin_camp", + name: "Goblin Camp", + prerequisiteIds: [ "first_steps" ], + rewards: [ + { amount: 2000, type: "gold" }, + { amount: 5, type: "essence" }, + { targetId: "apprentice", type: "adventurer" }, ], - prerequisiteIds: ["first_steps"], + status: "locked", zoneId: "verdant_vale", }, { - id: "haunted_mine", - name: "The Haunted Mine", - description: "An abandoned mine is rich with crystal deposits — if you dare brave its ghosts.", - status: "locked", - durationSeconds: 15 * 60, - rewards: [ - { type: "crystals", amount: 10 }, - { type: "upgrade", targetId: "global_1" }, - { type: "adventurer", targetId: "scout" }, - ], - prerequisiteIds: ["goblin_camp"], - zoneId: "verdant_vale", combatPowerRequired: 10, + description: + "An abandoned mine is rich with crystal deposits — if you dare brave its ghosts.", + durationSeconds: 15 * 60, + id: "haunted_mine", + name: "The Haunted Mine", + prerequisiteIds: [ "goblin_camp" ], + rewards: [ + { amount: 10, type: "crystals" }, + { targetId: "global_1", type: "upgrade" }, + { targetId: "scout", type: "adventurer" }, + ], + status: "locked", + zoneId: "verdant_vale", }, { - id: "ancient_ruins", - name: "Ancient Ruins", - description: "Scholars believe the ruins hold secrets of a forgotten civilisation.", - status: "locked", - durationSeconds: 30 * 60, - rewards: [ - { type: "essence", amount: 50 }, - { type: "upgrade", targetId: "click_2" }, - { type: "adventurer", targetId: "acolyte" }, - ], - prerequisiteIds: ["haunted_mine"], - zoneId: "verdant_vale", combatPowerRequired: 50, + description: + "Scholars believe the ruins hold secrets of a forgotten civilisation.", + durationSeconds: 30 * 60, + id: "ancient_ruins", + name: "Ancient Ruins", + prerequisiteIds: [ "haunted_mine" ], + rewards: [ + { amount: 50, type: "essence" }, + { targetId: "click_2", type: "upgrade" }, + { targetId: "acolyte", type: "adventurer" }, + ], + status: "locked", + zoneId: "verdant_vale", }, // ── Shattered Ruins ─────────────────────────────────────────────────────── { - id: "necromancer_tower", - name: "Necromancer's Tower", + combatPowerRequired: 500, description: "A rogue necromancer has raised an army of skeletons near the city. Silence him before the dead overrun us.", - status: "locked", durationSeconds: 25 * 60, - rewards: [ - { type: "gold", amount: 15_000 }, - { type: "essence", amount: 20 }, - { type: "upgrade", targetId: "cleric_1" }, - { type: "adventurer", targetId: "ranger" }, - ], + id: "necromancer_tower", + name: "Necromancer's Tower", prerequisiteIds: [], + rewards: [ + { amount: 15_000, type: "gold" }, + { amount: 20, type: "essence" }, + { targetId: "cleric_1", type: "upgrade" }, + { targetId: "ranger", type: "adventurer" }, + ], + status: "locked", zoneId: "shattered_ruins", - combatPowerRequired: 500, }, { - id: "crumbling_fortress", - name: "The Crumbling Fortress", + combatPowerRequired: 2000, description: "An ancient fortress still garrisoned by constructs who don't know the war ended. Clear it out and claim its vaults.", - status: "locked", durationSeconds: 45 * 60, - rewards: [ - { type: "gold", amount: 80_000 }, - { type: "essence", amount: 120 }, - { type: "upgrade", targetId: "scout_1" }, - { type: "adventurer", targetId: "knight" }, + id: "crumbling_fortress", + name: "The Crumbling Fortress", + prerequisiteIds: [ "necromancer_tower" ], + rewards: [ + { amount: 80_000, type: "gold" }, + { amount: 120, type: "essence" }, + { targetId: "scout_1", type: "upgrade" }, + { targetId: "knight", type: "adventurer" }, ], - prerequisiteIds: ["necromancer_tower"], + status: "locked", zoneId: "shattered_ruins", - combatPowerRequired: 2_000, }, { - id: "cursed_library", - name: "The Cursed Library", + combatPowerRequired: 8000, description: "A vast library sealed for centuries whose contents have warped and grown hostile. The knowledge within is priceless.", - status: "locked", durationSeconds: 60 * 60, - rewards: [ - { type: "essence", amount: 300 }, - { type: "crystals", amount: 30 }, - { type: "upgrade", targetId: "mage_1" }, - { type: "adventurer", targetId: "archmage" }, + id: "cursed_library", + name: "The Cursed Library", + prerequisiteIds: [ "crumbling_fortress" ], + rewards: [ + { amount: 300, type: "essence" }, + { amount: 30, type: "crystals" }, + { targetId: "mage_1", type: "upgrade" }, + { targetId: "archmage", type: "adventurer" }, ], - prerequisiteIds: ["crumbling_fortress"], + status: "locked", zoneId: "shattered_ruins", - combatPowerRequired: 8_000, }, { - id: "dragon_lair", - name: "Dragon's Lair", + combatPowerRequired: 30_000, description: "The legendary lair of Pyraxis the Undying. Few who enter return — those who do are rich beyond imagining.", - status: "locked", durationSeconds: 90 * 60, - rewards: [ - { type: "gold", amount: 500_000 }, - { type: "crystals", amount: 50 }, - { type: "adventurer", targetId: "paladin" }, - { type: "adventurer", targetId: "dragon_rider" }, + id: "dragon_lair", + name: "Dragon's Lair", + prerequisiteIds: [ "cursed_library" ], + rewards: [ + { amount: 500_000, type: "gold" }, + { amount: 50, type: "crystals" }, + { targetId: "paladin", type: "adventurer" }, + { targetId: "dragon_rider", type: "adventurer" }, ], - prerequisiteIds: ["cursed_library"], + status: "locked", zoneId: "shattered_ruins", - combatPowerRequired: 30_000, }, // ── Shadow Marshes ──────────────────────────────────────────────────────── { - id: "shadow_mere", - name: "The Shadow Mere", + combatPowerRequired: 5000, description: "A cursed lake shrouded in permanent twilight. Strange energies pulse beneath its surface.", - status: "locked", durationSeconds: 45 * 60, - rewards: [ - { type: "essence", amount: 150 }, - { type: "upgrade", targetId: "militia_1" }, - ], + id: "shadow_mere", + name: "The Shadow Mere", prerequisiteIds: [], + rewards: [ + { amount: 150, type: "essence" }, + { targetId: "militia_1", type: "upgrade" }, + ], + status: "locked", zoneId: "shadow_marshes", - combatPowerRequired: 5_000, }, { - id: "witch_coven", - name: "The Witch Coven", + combatPowerRequired: 20_000, description: "Deep in the marshes, a coven of swamp witches performs rites that twist the very land. Their power must be broken.", - status: "locked", durationSeconds: 90 * 60, - rewards: [ - { type: "essence", amount: 500 }, - { type: "adventurer", targetId: "shadow_assassin" }, + id: "witch_coven", + name: "The Witch Coven", + prerequisiteIds: [ "shadow_mere" ], + rewards: [ + { amount: 500, type: "essence" }, + { targetId: "shadow_assassin", type: "adventurer" }, ], - prerequisiteIds: ["shadow_mere"], + status: "locked", zoneId: "shadow_marshes", - combatPowerRequired: 20_000, }, { - id: "sunken_temple", - name: "The Sunken Temple", + combatPowerRequired: 80_000, description: "An ancient temple half-submerged in black water, its altars still humming with the power of a god long since departed.", - status: "locked", durationSeconds: 2 * 60 * 60, - rewards: [ - { type: "gold", amount: 2_000_000 }, - { type: "crystals", amount: 75 }, - { type: "upgrade", targetId: "knight_1" }, + id: "sunken_temple", + name: "The Sunken Temple", + prerequisiteIds: [ "witch_coven" ], + rewards: [ + { amount: 2_000_000, type: "gold" }, + { amount: 75, type: "crystals" }, + { targetId: "knight_1", type: "upgrade" }, ], - prerequisiteIds: ["witch_coven"], + status: "locked", zoneId: "shadow_marshes", - combatPowerRequired: 80_000, }, { - id: "plague_ruins", - name: "The Plague Ruins", + combatPowerRequired: 300_000, description: "A city that died overnight, its streets still thick with something no healer can identify. Treasures lie unclaimed among the bones.", - status: "locked", durationSeconds: 3 * 60 * 60, - rewards: [ - { type: "gold", amount: 8_000_000 }, - { type: "essence", amount: 2_000 }, - { type: "crystals", amount: 150 }, + id: "plague_ruins", + name: "The Plague Ruins", + prerequisiteIds: [ "sunken_temple" ], + rewards: [ + { amount: 8_000_000, type: "gold" }, + { amount: 2000, type: "essence" }, + { amount: 150, type: "crystals" }, ], - prerequisiteIds: ["sunken_temple"], + status: "locked", zoneId: "shadow_marshes", - combatPowerRequired: 300_000, }, // ── Frozen Peaks ────────────────────────────────────────────────────────── { - id: "frozen_wastes", - name: "The Frozen Wastes", + combatPowerRequired: 100_000, description: "A tundra at the edge of the world, home to creatures that have never seen the sun. Rumours speak of artefacts buried in the permafrost.", - status: "locked", durationSeconds: 2 * 60 * 60, - rewards: [ - { type: "gold", amount: 5_000_000 }, - { type: "crystals", amount: 100 }, - { type: "upgrade", targetId: "global_3" }, - ], + id: "frozen_wastes", + name: "The Frozen Wastes", prerequisiteIds: [], + rewards: [ + { amount: 5_000_000, type: "gold" }, + { amount: 100, type: "crystals" }, + { targetId: "global_3", type: "upgrade" }, + ], + status: "locked", zoneId: "frozen_peaks", - combatPowerRequired: 100_000, }, { - id: "ice_caves", - name: "The Ice Caves", + combatPowerRequired: 400_000, description: "A labyrinthine network of crystal caverns that descend for miles. The cold here is a presence, not just a temperature.", - status: "locked", durationSeconds: 3 * 60 * 60, - rewards: [ - { type: "essence", amount: 5_000 }, - { type: "crystals", amount: 200 }, - { type: "adventurer", targetId: "arcane_scholar" }, + id: "ice_caves", + name: "The Ice Caves", + prerequisiteIds: [ "frozen_wastes" ], + rewards: [ + { amount: 5000, type: "essence" }, + { amount: 200, type: "crystals" }, + { targetId: "arcane_scholar", type: "adventurer" }, ], - prerequisiteIds: ["frozen_wastes"], + status: "locked", zoneId: "frozen_peaks", - combatPowerRequired: 400_000, }, { - id: "storm_citadel", - name: "The Storm Citadel", + combatPowerRequired: 1_500_000, description: "A fortress suspended in a permanent blizzard, built by a mage who wanted to be left alone — and succeeded for three hundred years.", - status: "locked", durationSeconds: 5 * 60 * 60, - rewards: [ - { type: "gold", amount: 30_000_000 }, - { type: "essence", amount: 10_000 }, - { type: "upgrade", targetId: "peasant_1" }, + id: "storm_citadel", + name: "The Storm Citadel", + prerequisiteIds: [ "ice_caves" ], + rewards: [ + { amount: 30_000_000, type: "gold" }, + { amount: 10_000, type: "essence" }, + { targetId: "peasant_1", type: "upgrade" }, ], - prerequisiteIds: ["ice_caves"], + status: "locked", zoneId: "frozen_peaks", - combatPowerRequired: 1_500_000, }, // ── Volcanic Depths ─────────────────────────────────────────────────────── { - id: "lava_flows", - name: "The Lava Flows", + combatPowerRequired: 2_000_000, description: "A river of molten rock that flows without end through the volcanic tunnels. Something valuable gleams in the depths.", - status: "locked", durationSeconds: 3 * 60 * 60, - rewards: [ - { type: "gold", amount: 15_000_000 }, - { type: "essence", amount: 4_000 }, - ], + id: "lava_flows", + name: "The Lava Flows", prerequisiteIds: [], + rewards: [ + { amount: 15_000_000, type: "gold" }, + { amount: 4000, type: "essence" }, + ], + status: "locked", zoneId: "volcanic_depths", - combatPowerRequired: 2_000_000, }, { - id: "fire_temple", - name: "The Temple of the Flame", + combatPowerRequired: 8_000_000, description: "A vast shrine where fire elementals perform rituals that shake the mountains. Whatever they worship, it has answered.", - status: "locked", durationSeconds: 5 * 60 * 60, - rewards: [ - { type: "essence", amount: 12_000 }, - { type: "crystals", amount: 300 }, - { type: "adventurer", targetId: "void_walker" }, + id: "fire_temple", + name: "The Temple of the Flame", + prerequisiteIds: [ "lava_flows" ], + rewards: [ + { amount: 12_000, type: "essence" }, + { amount: 300, type: "crystals" }, + { targetId: "void_walker", type: "adventurer" }, ], - prerequisiteIds: ["lava_flows"], + status: "locked", zoneId: "volcanic_depths", - combatPowerRequired: 8_000_000, }, { - id: "magma_caverns", - name: "The Magma Caverns", + combatPowerRequired: 30_000_000, description: "Kilometres of tunnels filled with rivers of fire and creatures born from the earth's core. The heat alone should kill you. Somehow, it won't.", - status: "locked", durationSeconds: 7 * 60 * 60, - rewards: [ - { type: "gold", amount: 100_000_000 }, - { type: "essence", amount: 25_000 }, - { type: "crystals", amount: 600 }, + id: "magma_caverns", + name: "The Magma Caverns", + prerequisiteIds: [ "fire_temple" ], + rewards: [ + { amount: 100_000_000, type: "gold" }, + { amount: 25_000, type: "essence" }, + { amount: 600, type: "crystals" }, ], - prerequisiteIds: ["fire_temple"], + status: "locked", zoneId: "volcanic_depths", - combatPowerRequired: 30_000_000, }, { - id: "the_forge", - name: "The Primordial Forge", + combatPowerRequired: 120_000_000, description: "The oldest forge in existence, where the fire elementals crafted weapons for gods. Its secrets could revolutionise your guild's arsenal.", - status: "locked", durationSeconds: 10 * 60 * 60, - rewards: [ - { type: "gold", amount: 500_000_000 }, - { type: "essence", amount: 80_000 }, - { type: "adventurer", targetId: "celestial_guard" }, + id: "the_forge", + name: "The Primordial Forge", + prerequisiteIds: [ "magma_caverns" ], + rewards: [ + { amount: 500_000_000, type: "gold" }, + { amount: 80_000, type: "essence" }, + { targetId: "celestial_guard", type: "adventurer" }, ], - prerequisiteIds: ["magma_caverns"], + status: "locked", zoneId: "volcanic_depths", - combatPowerRequired: 120_000_000, }, // ── Astral Void ─────────────────────────────────────────────────────────── { - id: "void_rift", - name: "Void Rift", + combatPowerRequired: 50_000_000, description: "A tear in reality itself. What lies beyond defies description — but the power within is unlike anything of this world.", - status: "locked", durationSeconds: 4 * 60 * 60, - rewards: [ - { type: "crystals", amount: 500 }, - { type: "essence", amount: 5_000 }, - ], + id: "void_rift", + name: "Void Rift", prerequisiteIds: [], + rewards: [ + { amount: 500, type: "crystals" }, + { amount: 5000, type: "essence" }, + ], + status: "locked", zoneId: "astral_void", - combatPowerRequired: 50_000_000, }, { - id: "star_graveyard", - name: "The Star Graveyard", + combatPowerRequired: 200_000_000, description: "A field of dead stars, each one larger than a planet, each one cold and silent where once they burned with the light of creation.", - status: "locked", durationSeconds: 8 * 60 * 60, - rewards: [ - { type: "gold", amount: 1_000_000_000 }, - { type: "essence", amount: 100_000 }, - { type: "crystals", amount: 1_000 }, + id: "star_graveyard", + name: "The Star Graveyard", + prerequisiteIds: [ "void_rift" ], + rewards: [ + { amount: 1_000_000_000, type: "gold" }, + { amount: 100_000, type: "essence" }, + { amount: 1000, type: "crystals" }, ], - prerequisiteIds: ["void_rift"], + status: "locked", zoneId: "astral_void", - combatPowerRequired: 200_000_000, }, { - id: "between_worlds", - name: "Between Worlds", + combatPowerRequired: 800_000_000, description: "The space between realities, where the rules that govern your world do not apply. Time is meaningless here. Power is everything.", - status: "locked", durationSeconds: 12 * 60 * 60, - rewards: [ - { type: "essence", amount: 250_000 }, - { type: "crystals", amount: 2_000 }, - { type: "adventurer", targetId: "divine_champion" }, + id: "between_worlds", + name: "Between Worlds", + prerequisiteIds: [ "star_graveyard" ], + rewards: [ + { amount: 250_000, type: "essence" }, + { amount: 2000, type: "crystals" }, + { targetId: "divine_champion", type: "adventurer" }, ], - prerequisiteIds: ["star_graveyard"], + status: "locked", zoneId: "astral_void", - combatPowerRequired: 800_000_000, }, { - id: "the_end", - name: "The End of All Things", + combatPowerRequired: 3_000_000_000, description: "There is nothing beyond this point. Only the greatest guild in the history of all existence could reach here — and you have.", - status: "locked", durationSeconds: 24 * 60 * 60, - rewards: [ - { type: "gold", amount: 10_000_000_000 }, - { type: "essence", amount: 1_000_000 }, - { type: "crystals", amount: 10_000 }, + id: "the_end", + name: "The End of All Things", + prerequisiteIds: [ "between_worlds" ], + rewards: [ + { amount: 10_000_000_000, type: "gold" }, + { amount: 1_000_000, type: "essence" }, + { amount: 10_000, type: "crystals" }, ], - prerequisiteIds: ["between_worlds"], + status: "locked", zoneId: "astral_void", - combatPowerRequired: 3_000_000_000, }, // ── Celestial Reaches ───────────────────────────────────────────────────── { - id: "heavens_gate", - name: "The Heaven's Gate", description: "The threshold between the astral and the divine. Just passing through it changes those who do so in ways they will only understand later.", - status: "locked", durationSeconds: Math.round(1.5 * 60 * 60), - rewards: [ - { type: "gold", amount: 500_000_000 }, - { type: "essence", amount: 3_000_000 }, - { type: "adventurer", targetId: "seraph_knight" }, - ], + id: "heavens_gate", + name: "The Heaven's Gate", prerequisiteIds: [], + rewards: [ + { amount: 500_000_000, type: "gold" }, + { amount: 3_000_000, type: "essence" }, + { targetId: "seraph_knight", type: "adventurer" }, + ], + status: "locked", zoneId: "celestial_reaches", }, { - id: "angelic_choir", - name: "The Angelic Choir", description: "A gathering of celestial voices whose harmony shapes reality. To witness it is to understand, briefly, what the universe was meant to be.", - status: "locked", durationSeconds: 3 * 60 * 60, - rewards: [ - { type: "gold", amount: 2_000_000_000 }, - { type: "essence", amount: 8_000_000 }, + id: "angelic_choir", + name: "The Angelic Choir", + prerequisiteIds: [ "heavens_gate" ], + rewards: [ + { amount: 2_000_000_000, type: "gold" }, + { amount: 8_000_000, type: "essence" }, ], - prerequisiteIds: ["heavens_gate"], + status: "locked", zoneId: "celestial_reaches", }, { - id: "divine_library", - name: "The Divine Library", description: "Every event that has ever occurred is recorded here. Your guild's entire history is contained in a single volume, filed under 'Unlikely'.", - status: "locked", durationSeconds: 5 * 60 * 60, - rewards: [ - { type: "gold", amount: 8_000_000_000 }, - { type: "essence", amount: 20_000_000 }, - { type: "crystals", amount: 500_000 }, + id: "divine_library", + name: "The Divine Library", + prerequisiteIds: [ "angelic_choir" ], + rewards: [ + { amount: 8_000_000_000, type: "gold" }, + { amount: 20_000_000, type: "essence" }, + { amount: 500_000, type: "crystals" }, ], - prerequisiteIds: ["angelic_choir"], + status: "locked", zoneId: "celestial_reaches", }, { - id: "cloud_citadel", - name: "The Cloud Citadel", description: "A fortress built in the space between thoughts — larger inside than any physical structure could be. The celestial host uses it as a staging ground for interventions in mortal affairs.", - status: "locked", durationSeconds: 8 * 60 * 60, - rewards: [ - { type: "gold", amount: 25_000_000_000 }, - { type: "essence", amount: 60_000_000 }, - { type: "crystals", amount: 1_500_000 }, + id: "cloud_citadel", + name: "The Cloud Citadel", + prerequisiteIds: [ "divine_library" ], + rewards: [ + { amount: 25_000_000_000, type: "gold" }, + { amount: 60_000_000, type: "essence" }, + { amount: 1_500_000, type: "crystals" }, ], - prerequisiteIds: ["divine_library"], + status: "locked", zoneId: "celestial_reaches", }, { - id: "trial_of_virtue", - name: "The Trial of Virtue", description: "The celestial host subjects your guild to trials that test not strength but character. Fortunately, your guild has both. Less fortunately, the trials are also designed to be impossible.", - status: "locked", durationSeconds: 12 * 60 * 60, - rewards: [ - { type: "gold", amount: 80_000_000_000 }, - { type: "essence", amount: 200_000_000 }, - { type: "crystals", amount: 3_000_000 }, - { type: "upgrade", targetId: "seraph_knight_1" }, + id: "trial_of_virtue", + name: "The Trial of Virtue", + prerequisiteIds: [ "cloud_citadel" ], + rewards: [ + { amount: 80_000_000_000, type: "gold" }, + { amount: 200_000_000, type: "essence" }, + { amount: 3_000_000, type: "crystals" }, + { targetId: "seraph_knight_1", type: "upgrade" }, ], - prerequisiteIds: ["cloud_citadel"], + status: "locked", zoneId: "celestial_reaches", }, { - id: "celestial_archive", - name: "The Celestial Archive", description: "The deepest record in the divine realm — not just of what has happened, but of what is possible. Your guild leaves a mark here that will not be erased when the universe ends.", - status: "locked", durationSeconds: 20 * 60 * 60, - rewards: [ - { type: "gold", amount: 300_000_000_000 }, - { type: "essence", amount: 500_000_000 }, - { type: "crystals", amount: 8_000_000 }, + id: "celestial_archive", + name: "The Celestial Archive", + prerequisiteIds: [ "trial_of_virtue" ], + rewards: [ + { amount: 300_000_000_000, type: "gold" }, + { amount: 500_000_000, type: "essence" }, + { amount: 8_000_000, type: "crystals" }, ], - prerequisiteIds: ["trial_of_virtue"], + status: "locked", zoneId: "celestial_reaches", }, // ── Abyssal Trench ──────────────────────────────────────────────────────── { - id: "the_dark_waters", - name: "The Dark Waters", description: "The entry point to the trench — where light surrenders completely and the pressure begins its long, patient work of reminding you of your smallness.", - status: "locked", durationSeconds: 2 * 60 * 60, - rewards: [ - { type: "gold", amount: 1_000_000_000_000 }, - { type: "essence", amount: 600_000_000 }, - { type: "adventurer", targetId: "abyss_diver" }, - ], + id: "the_dark_waters", + name: "The Dark Waters", prerequisiteIds: [], + rewards: [ + { amount: 1_000_000_000_000, type: "gold" }, + { amount: 600_000_000, type: "essence" }, + { targetId: "abyss_diver", type: "adventurer" }, + ], + status: "locked", zoneId: "abyssal_trench", }, { - id: "bioluminescent_ruins", - name: "The Bioluminescent Ruins", description: "The remains of a civilisation that lived at the bottom of the world for millennia, lighting their world with their own bodies. They are gone. Their light remains, eerie and cold.", - status: "locked", durationSeconds: 4 * 60 * 60, - rewards: [ - { type: "gold", amount: 3_000_000_000_000 }, - { type: "essence", amount: 1_500_000_000 }, - { type: "crystals", amount: 12_000_000 }, + id: "bioluminescent_ruins", + name: "The Bioluminescent Ruins", + prerequisiteIds: [ "the_dark_waters" ], + rewards: [ + { amount: 3_000_000_000_000, type: "gold" }, + { amount: 1_500_000_000, type: "essence" }, + { amount: 12_000_000, type: "crystals" }, ], - prerequisiteIds: ["the_dark_waters"], + status: "locked", zoneId: "abyssal_trench", }, { - id: "pressure_caves", - name: "The Pressure Caves", description: "Caverns carved by forces that would shatter your strongest armour as casually as paper. Your guild navigates them through a combination of skill, preparation, and — honestly — luck.", - status: "locked", durationSeconds: 7 * 60 * 60, - rewards: [ - { type: "gold", amount: 10_000_000_000_000 }, - { type: "essence", amount: 5_000_000_000 }, - { type: "crystals", amount: 30_000_000 }, + id: "pressure_caves", + name: "The Pressure Caves", + prerequisiteIds: [ "bioluminescent_ruins" ], + rewards: [ + { amount: 10_000_000_000_000, type: "gold" }, + { amount: 5_000_000_000, type: "essence" }, + { amount: 30_000_000, type: "crystals" }, ], - prerequisiteIds: ["bioluminescent_ruins"], + status: "locked", zoneId: "abyssal_trench", }, { - id: "leviathan_graveyard", - name: "The Leviathan Graveyard", description: "Where the great serpents of the deep come to die — bones larger than cities, slowly being consumed by things that feed on the dead of things that were never truly alive.", - status: "locked", durationSeconds: 12 * 60 * 60, - rewards: [ - { type: "gold", amount: 30_000_000_000_000 }, - { type: "essence", amount: 15_000_000_000 }, - { type: "crystals", amount: 60_000_000 }, + id: "leviathan_graveyard", + name: "The Leviathan Graveyard", + prerequisiteIds: [ "pressure_caves" ], + rewards: [ + { amount: 30_000_000_000_000, type: "gold" }, + { amount: 15_000_000_000, type: "essence" }, + { amount: 60_000_000, type: "crystals" }, ], - prerequisiteIds: ["pressure_caves"], + status: "locked", zoneId: "abyssal_trench", }, { - id: "black_throne", - name: "The Black Throne", description: "A throne carved from something that predates stone, found at a depth where the trench opens into something that should not exist below it. Something sat here once. Something may sit here again.", - status: "locked", durationSeconds: 18 * 60 * 60, - rewards: [ - { type: "gold", amount: 100_000_000_000_000 }, - { type: "essence", amount: 50_000_000_000 }, - { type: "crystals", amount: 120_000_000 }, - { type: "upgrade", targetId: "abyss_diver_1" }, + id: "black_throne", + name: "The Black Throne", + prerequisiteIds: [ "leviathan_graveyard" ], + rewards: [ + { amount: 100_000_000_000_000, type: "gold" }, + { amount: 50_000_000_000, type: "essence" }, + { amount: 120_000_000, type: "crystals" }, + { targetId: "abyss_diver_1", type: "upgrade" }, ], - prerequisiteIds: ["leviathan_graveyard"], + status: "locked", zoneId: "abyssal_trench", }, { - id: "abyssal_chronicle", - name: "The Abyssal Chronicle", description: "The record carved into the walls of the deepest part of the trench by whatever has lived there since time began. Your guild adds its chapter. It is the first written in a language anyone above has ever understood.", - status: "locked", durationSeconds: 30 * 60 * 60, - rewards: [ - { type: "gold", amount: 400_000_000_000_000 }, - { type: "essence", amount: 200_000_000_000 }, - { type: "crystals", amount: 400_000_000 }, + id: "abyssal_chronicle", + name: "The Abyssal Chronicle", + prerequisiteIds: [ "black_throne" ], + rewards: [ + { amount: 400_000_000_000_000, type: "gold" }, + { amount: 200_000_000_000, type: "essence" }, + { amount: 400_000_000, type: "crystals" }, ], - prerequisiteIds: ["black_throne"], + status: "locked", zoneId: "abyssal_trench", }, // ── Infernal Court ──────────────────────────────────────────────────────── { - id: "brimstone_wastes", - name: "The Brimstone Wastes", description: "The outer reaches of the infernal court — a landscape of sulphur and old fire where lesser demons make their homes and forget what they are waiting for.", - status: "locked", durationSeconds: 3 * 60 * 60, - rewards: [ - { type: "gold", amount: 600_000_000_000_000 }, - { type: "essence", amount: 200_000_000_000 }, - { type: "adventurer", targetId: "infernal_warden" }, - ], + id: "brimstone_wastes", + name: "The Brimstone Wastes", prerequisiteIds: [], + rewards: [ + { amount: 600_000_000_000_000, type: "gold" }, + { amount: 200_000_000_000, type: "essence" }, + { targetId: "infernal_warden", type: "adventurer" }, + ], + status: "locked", zoneId: "infernal_court", }, { - id: "pit_of_souls", - name: "The Pit of Souls", description: "The repository of every soul the infernal court has ever collected, stretching downward without apparent limit. The voices here are beyond counting. Some of them are recognisable.", - status: "locked", durationSeconds: 6 * 60 * 60, - rewards: [ - { type: "gold", amount: 2_000_000_000_000_000 }, - { type: "essence", amount: 600_000_000_000 }, - { type: "crystals", amount: 1_000_000_000 }, + id: "pit_of_souls", + name: "The Pit of Souls", + prerequisiteIds: [ "brimstone_wastes" ], + rewards: [ + { amount: 2_000_000_000_000_000, type: "gold" }, + { amount: 600_000_000_000, type: "essence" }, + { amount: 1_000_000_000, type: "crystals" }, ], - prerequisiteIds: ["brimstone_wastes"], + status: "locked", zoneId: "infernal_court", }, { - id: "court_of_blood", - name: "The Court of Blood", description: "The actual seat of demon governance — where the lords convene to settle their endless disputes. Your guild attends the session uninvited. The lords are not pleased. They are, however, briefly unified.", - status: "locked", durationSeconds: 10 * 60 * 60, - rewards: [ - { type: "gold", amount: 6_000_000_000_000_000 }, - { type: "essence", amount: 2_000_000_000_000 }, - { type: "crystals", amount: 3_000_000_000 }, + id: "court_of_blood", + name: "The Court of Blood", + prerequisiteIds: [ "pit_of_souls" ], + rewards: [ + { amount: 6_000_000_000_000_000, type: "gold" }, + { amount: 2_000_000_000_000, type: "essence" }, + { amount: 3_000_000_000, type: "crystals" }, ], - prerequisiteIds: ["pit_of_souls"], + status: "locked", zoneId: "infernal_court", }, { - id: "nine_hells", - name: "The Nine Hells", description: "Each circle of the infernal court is its own ecosystem of suffering, and your guild passes through all nine. By the seventh, it has stopped being surprising. By the ninth, it has become almost comfortable.", - status: "locked", durationSeconds: 16 * 60 * 60, - rewards: [ - { type: "gold", amount: 2e16 }, - { type: "essence", amount: 6_000_000_000_000 }, - { type: "crystals", amount: 8_000_000_000 }, + id: "nine_hells", + name: "The Nine Hells", + prerequisiteIds: [ "court_of_blood" ], + rewards: [ + { amount: 2e16, type: "gold" }, + { amount: 6_000_000_000_000, type: "essence" }, + { amount: 8_000_000_000, type: "crystals" }, ], - prerequisiteIds: ["court_of_blood"], + status: "locked", zoneId: "infernal_court", }, { - id: "demon_forge", - name: "The Demon Forge", description: "The forge where the demon lords create their weapons — each one an atrocity given material form. Your guild has come to learn its secrets, or failing that, to destroy it.", - status: "locked", durationSeconds: 24 * 60 * 60, - rewards: [ - { type: "gold", amount: 6e16 }, - { type: "essence", amount: 2e13 }, - { type: "crystals", amount: 2.5e10 }, - { type: "upgrade", targetId: "infernal_warden_1" }, + id: "demon_forge", + name: "The Demon Forge", + prerequisiteIds: [ "nine_hells" ], + rewards: [ + { amount: 6e16, type: "gold" }, + { amount: 2e13, type: "essence" }, + { amount: 2.5e10, type: "crystals" }, + { targetId: "infernal_warden_1", type: "upgrade" }, ], - prerequisiteIds: ["nine_hells"], + status: "locked", zoneId: "infernal_court", }, { - id: "infernal_codex", - name: "The Infernal Codex", description: "The complete record of every deal, pact, and contract the infernal court has ever made. Your guild finds its own name in there, in a clause you definitely did not agree to. You cross it out.", - status: "locked", durationSeconds: 40 * 60 * 60, - rewards: [ - { type: "gold", amount: 2e17 }, - { type: "essence", amount: 6e13 }, - { type: "crystals", amount: 8e10 }, + id: "infernal_codex", + name: "The Infernal Codex", + prerequisiteIds: [ "demon_forge" ], + rewards: [ + { amount: 2e17, type: "gold" }, + { amount: 6e13, type: "essence" }, + { amount: 8e10, type: "crystals" }, ], - prerequisiteIds: ["demon_forge"], + status: "locked", zoneId: "infernal_court", }, // ── Crystalline Spire ───────────────────────────────────────────────────── { - id: "prism_gate", - name: "The Prism Gate", description: "The entrance to the spire — a door made of possibilities that splits your guild into every version of itself simultaneously. Only the best version makes it through. You are that version.", - status: "locked", durationSeconds: 4 * 60 * 60, - rewards: [ - { type: "gold", amount: 5e17 }, - { type: "essence", amount: 2e14 }, - { type: "adventurer", targetId: "crystal_sage" }, - ], + id: "prism_gate", + name: "The Prism Gate", prerequisiteIds: [], + rewards: [ + { amount: 5e17, type: "gold" }, + { amount: 2e14, type: "essence" }, + { targetId: "crystal_sage", type: "adventurer" }, + ], + status: "locked", zoneId: "crystalline_spire", }, { - id: "crystal_labyrinth", - name: "The Crystal Labyrinth", description: "A maze of mirrors that reflects not your appearance but your choices — every path shows what would have happened if you had chosen differently. Several of those paths look significantly better.", - status: "locked", durationSeconds: 8 * 60 * 60, - rewards: [ - { type: "gold", amount: 2e18 }, - { type: "essence", amount: 8e14 }, - { type: "crystals", amount: 3e12 }, + id: "crystal_labyrinth", + name: "The Crystal Labyrinth", + prerequisiteIds: [ "prism_gate" ], + rewards: [ + { amount: 2e18, type: "gold" }, + { amount: 8e14, type: "essence" }, + { amount: 3e12, type: "crystals" }, ], - prerequisiteIds: ["prism_gate"], + status: "locked", zoneId: "crystalline_spire", }, { - id: "faceted_realm", - name: "The Faceted Realm", description: "A space where geometry has opinions — where right angles are suggestions and parallel lines eventually converge into something that has no name in any language your guild speaks.", - status: "locked", durationSeconds: 14 * 60 * 60, - rewards: [ - { type: "gold", amount: 8e18 }, - { type: "essence", amount: 3e15 }, - { type: "crystals", amount: 1e13 }, + id: "faceted_realm", + name: "The Faceted Realm", + prerequisiteIds: [ "crystal_labyrinth" ], + rewards: [ + { amount: 8e18, type: "gold" }, + { amount: 3e15, type: "essence" }, + { amount: 1e13, type: "crystals" }, ], - prerequisiteIds: ["crystal_labyrinth"], + status: "locked", zoneId: "crystalline_spire", }, { - id: "diamond_vault", - name: "The Diamond Vault", description: "The repository of crystallised knowledge — everything the spire has calculated, preserved in structures of compressed carbon that contain more information than your guild's entire written history.", - status: "locked", durationSeconds: 20 * 60 * 60, - rewards: [ - { type: "gold", amount: 3e19 }, - { type: "essence", amount: 1e16 }, - { type: "crystals", amount: 4e13 }, + id: "diamond_vault", + name: "The Diamond Vault", + prerequisiteIds: [ "faceted_realm" ], + rewards: [ + { amount: 3e19, type: "gold" }, + { amount: 1e16, type: "essence" }, + { amount: 4e13, type: "crystals" }, ], - prerequisiteIds: ["faceted_realm"], + status: "locked", zoneId: "crystalline_spire", }, { - id: "sovereign_spire", - name: "The Sovereign's Spire", description: "The approach to the Sovereign's chamber — a corridor of living crystal that evaluates your guild as you walk through it and reconfigures itself in real time to create the optimal challenge for exactly what your guild is.", - status: "locked", durationSeconds: 32 * 60 * 60, - rewards: [ - { type: "gold", amount: 1e20 }, - { type: "essence", amount: 4e16 }, - { type: "crystals", amount: 1.5e14 }, - { type: "upgrade", targetId: "crystal_sage_1" }, + id: "sovereign_spire", + name: "The Sovereign's Spire", + prerequisiteIds: [ "diamond_vault" ], + rewards: [ + { amount: 1e20, type: "gold" }, + { amount: 4e16, type: "essence" }, + { amount: 1.5e14, type: "crystals" }, + { targetId: "crystal_sage_1", type: "upgrade" }, ], - prerequisiteIds: ["diamond_vault"], + status: "locked", zoneId: "crystalline_spire", }, { - id: "the_prism_vault", - name: "The Prism Vault", description: "The innermost sanctum of the spire — where the Sovereign keeps its most precious calculations, its predictions for the last moments of this universe, sealed in crystal that has never been touched by anything other than thought.", - status: "locked", durationSeconds: 50 * 60 * 60, - rewards: [ - { type: "gold", amount: 4e20 }, - { type: "essence", amount: 1.5e17 }, - { type: "crystals", amount: 5e14 }, + id: "the_prism_vault", + name: "The Prism Vault", + prerequisiteIds: [ "sovereign_spire" ], + rewards: [ + { amount: 4e20, type: "gold" }, + { amount: 1.5e17, type: "essence" }, + { amount: 5e14, type: "crystals" }, ], - prerequisiteIds: ["sovereign_spire"], + status: "locked", zoneId: "crystalline_spire", }, // ── Void Sanctum ────────────────────────────────────────────────────────── { - id: "void_threshold", - name: "The Void Threshold", description: "The boundary between existing and not — a membrane so thin that your guild can feel their own existence becoming uncertain as they cross it. On the other side: the sanctum.", - status: "locked", durationSeconds: 6 * 60 * 60, - rewards: [ - { type: "gold", amount: 1e21 }, - { type: "essence", amount: 4e17 }, - { type: "adventurer", targetId: "void_sentinel" }, - ], + id: "void_threshold", + name: "The Void Threshold", prerequisiteIds: [], + rewards: [ + { amount: 1e21, type: "gold" }, + { amount: 4e17, type: "essence" }, + { targetId: "void_sentinel", type: "adventurer" }, + ], + status: "locked", zoneId: "void_sanctum", }, { - id: "eternal_dark", - name: "The Eternal Dark", description: "Darkness here is not the absence of light but a substance in its own right — thick, pressured, aware. It has been dark here since before the concept of light existed elsewhere.", - status: "locked", durationSeconds: 12 * 60 * 60, - rewards: [ - { type: "gold", amount: 5e21 }, - { type: "essence", amount: 2e18 }, - { type: "crystals", amount: 2e15 }, + id: "eternal_dark", + name: "The Eternal Dark", + prerequisiteIds: [ "void_threshold" ], + rewards: [ + { amount: 5e21, type: "gold" }, + { amount: 2e18, type: "essence" }, + { amount: 2e15, type: "crystals" }, ], - prerequisiteIds: ["void_threshold"], + status: "locked", zoneId: "void_sanctum", }, { - id: "sanctum_depths", - name: "The Sanctum Depths", description: "The lower reaches of the void sanctum, where the Emperor's power saturates every particle. Your guild walks through a space that doesn't want them to exist — and continues existing anyway.", - status: "locked", durationSeconds: 20 * 60 * 60, - rewards: [ - { type: "gold", amount: 2e22 }, - { type: "essence", amount: 8e18 }, - { type: "crystals", amount: 8e15 }, + id: "sanctum_depths", + name: "The Sanctum Depths", + prerequisiteIds: [ "eternal_dark" ], + rewards: [ + { amount: 2e22, type: "gold" }, + { amount: 8e18, type: "essence" }, + { amount: 8e15, type: "crystals" }, ], - prerequisiteIds: ["eternal_dark"], + status: "locked", zoneId: "void_sanctum", }, { - id: "unmaking_grounds", - name: "The Unmaking Grounds", description: "Where the void Emperor tests its power — a space where things are regularly unmade as a display of authority. Your guild's refusal to be unmade is, to the Emperor, nothing short of astonishing.", - status: "locked", durationSeconds: 30 * 60 * 60, - rewards: [ - { type: "gold", amount: 8e22 }, - { type: "essence", amount: 3e19 }, - { type: "crystals", amount: 3e16 }, + id: "unmaking_grounds", + name: "The Unmaking Grounds", + prerequisiteIds: [ "sanctum_depths" ], + rewards: [ + { amount: 8e22, type: "gold" }, + { amount: 3e19, type: "essence" }, + { amount: 3e16, type: "crystals" }, ], - prerequisiteIds: ["sanctum_depths"], + status: "locked", zoneId: "void_sanctum", }, { - id: "emperor_approach", - name: "The Emperor's Approach", description: "The final corridor before the void Emperor — a space that exists only because the Emperor allows it to. Every step forward is an argument your guild makes for their right to exist. So far, it's working.", - status: "locked", durationSeconds: 48 * 60 * 60, - rewards: [ - { type: "gold", amount: 3e23 }, - { type: "essence", amount: 1e20 }, - { type: "crystals", amount: 1e17 }, - { type: "upgrade", targetId: "void_sentinel_1" }, + id: "emperor_approach", + name: "The Emperor's Approach", + prerequisiteIds: [ "unmaking_grounds" ], + rewards: [ + { amount: 3e23, type: "gold" }, + { amount: 1e20, type: "essence" }, + { amount: 1e17, type: "crystals" }, + { targetId: "void_sentinel_1", type: "upgrade" }, ], - prerequisiteIds: ["unmaking_grounds"], + status: "locked", zoneId: "void_sanctum", }, { - id: "heart_of_void", - name: "The Heart of the Void", description: "The absolute centre of the void sanctum — the point from which all absence radiates. Your guild stands here and, remarkably, continues to be. That alone is a victory no one before them has achieved.", - status: "locked", durationSeconds: 72 * 60 * 60, - rewards: [ - { type: "gold", amount: 1e24 }, - { type: "essence", amount: 4e20 }, - { type: "crystals", amount: 4e17 }, + id: "heart_of_void", + name: "The Heart of the Void", + prerequisiteIds: [ "emperor_approach" ], + rewards: [ + { amount: 1e24, type: "gold" }, + { amount: 4e20, type: "essence" }, + { amount: 4e17, type: "crystals" }, ], - prerequisiteIds: ["emperor_approach"], + status: "locked", zoneId: "void_sanctum", }, // ── Eternal Throne ──────────────────────────────────────────────────────── { - id: "throne_antechamber", - name: "The Throne Antechamber", description: "The waiting room for the absolute seat of power. No one has ever been made to wait here, because no one has ever arrived before. Your guild has arrived. The door is very large.", - status: "locked", durationSeconds: 8 * 60 * 60, - rewards: [ - { type: "gold", amount: 3e24 }, - { type: "essence", amount: 1e21 }, - { type: "adventurer", targetId: "eternal_champion" }, - ], + id: "throne_antechamber", + name: "The Throne Antechamber", prerequisiteIds: [], + rewards: [ + { amount: 3e24, type: "gold" }, + { amount: 1e21, type: "essence" }, + { targetId: "eternal_champion", type: "adventurer" }, + ], + status: "locked", zoneId: "eternal_throne", }, { - id: "eternal_gauntlet", - name: "The Eternal Gauntlet", description: "A series of trials designed not to test your guild but to exhaust them — to ensure that only something with genuine, inexhaustible will can reach the throne. Your guild has passed. The throne takes note.", - status: "locked", durationSeconds: 16 * 60 * 60, - rewards: [ - { type: "gold", amount: 1e25 }, - { type: "essence", amount: 4e21 }, - { type: "crystals", amount: 1.5e18 }, + id: "eternal_gauntlet", + name: "The Eternal Gauntlet", + prerequisiteIds: [ "throne_antechamber" ], + rewards: [ + { amount: 1e25, type: "gold" }, + { amount: 4e21, type: "essence" }, + { amount: 1.5e18, type: "crystals" }, ], - prerequisiteIds: ["throne_antechamber"], + status: "locked", zoneId: "eternal_throne", }, { - id: "apex_trials", - name: "The Apex Trials", description: "The final proving ground — a set of challenges that have been accumulating since the throne was first occupied, waiting for a challenger worthy enough to face them. Your guild is facing them. Barely.", - status: "locked", durationSeconds: 28 * 60 * 60, - rewards: [ - { type: "gold", amount: 4e25 }, - { type: "essence", amount: 1.5e22 }, - { type: "crystals", amount: 5e18 }, + id: "apex_trials", + name: "The Apex Trials", + prerequisiteIds: [ "eternal_gauntlet" ], + rewards: [ + { amount: 4e25, type: "gold" }, + { amount: 1.5e22, type: "essence" }, + { amount: 5e18, type: "crystals" }, ], - prerequisiteIds: ["eternal_gauntlet"], + status: "locked", zoneId: "eternal_throne", }, { - id: "sovereign_hall", - name: "The Sovereign's Hall", description: "The great hall through which every power in every universe has passed in supplication. No one has walked it as an equal before. Your guild walks it as a challenger. The difference is felt by everything that has ever knelt here.", - status: "locked", durationSeconds: 40 * 60 * 60, - rewards: [ - { type: "gold", amount: 1.5e26 }, - { type: "essence", amount: 6e22 }, - { type: "crystals", amount: 2e19 }, - { type: "upgrade", targetId: "eternal_champion_1" }, + id: "sovereign_hall", + name: "The Sovereign's Hall", + prerequisiteIds: [ "apex_trials" ], + rewards: [ + { amount: 1.5e26, type: "gold" }, + { amount: 6e22, type: "essence" }, + { amount: 2e19, type: "crystals" }, + { targetId: "eternal_champion_1", type: "upgrade" }, ], - prerequisiteIds: ["apex_trials"], + status: "locked", zoneId: "eternal_throne", }, { - id: "the_final_ascent", - name: "The Final Ascent", description: "The last staircase. Every step a moment of history being made. At the top: the throne, and the one who sits upon it, who has watched your guild climb and finds themselves, for the first time in all of existence, uncertain.", - status: "locked", durationSeconds: 60 * 60 * 60, - rewards: [ - { type: "gold", amount: 6e26 }, - { type: "essence", amount: 2.5e23 }, - { type: "crystals", amount: 8e19 }, + id: "the_final_ascent", + name: "The Final Ascent", + prerequisiteIds: [ "sovereign_hall" ], + rewards: [ + { amount: 6e26, type: "gold" }, + { amount: 2.5e23, type: "essence" }, + { amount: 8e19, type: "crystals" }, ], - prerequisiteIds: ["sovereign_hall"], + status: "locked", zoneId: "eternal_throne", }, { - id: "eternal_dominion", - name: "Eternal Dominion", description: "The throne is yours. Not just this one — all the power that flows from it, into every plane and reality it has shaped across all of time. Your guild has not merely won. It has become the thing that wins, permanently, for the rest of forever.", - status: "locked", durationSeconds: 96 * 60 * 60, - rewards: [ - { type: "gold", amount: 3e27 }, - { type: "essence", amount: 1e24 }, - { type: "crystals", amount: 4e20 }, + id: "eternal_dominion", + name: "Eternal Dominion", + prerequisiteIds: [ "the_final_ascent" ], + rewards: [ + { amount: 3e27, type: "gold" }, + { amount: 1e24, type: "essence" }, + { amount: 4e20, type: "crystals" }, ], - prerequisiteIds: ["the_final_ascent"], + status: "locked", zoneId: "eternal_throne", }, // ── Primordial Chaos ────────────────────────────────────────────────────── { - id: "chaos_entry", - name: "Into the Chaos", description: "Your guild steps beyond the throne into something that has no rules — a place where the very concept of place is contested. Every step forward is an act of defiance against the universe's first draft of itself.", - status: "locked", durationSeconds: 10 * 60 * 60, - rewards: [ - { type: "gold", amount: 8e27 }, - { type: "essence", amount: 3e24 }, - { type: "adventurer", targetId: "aether_weaver" }, - ], + id: "chaos_entry", + name: "Into the Chaos", prerequisiteIds: [], + rewards: [ + { amount: 8e27, type: "gold" }, + { amount: 3e24, type: "essence" }, + { targetId: "aether_weaver", type: "adventurer" }, + ], + status: "locked", zoneId: "primordial_chaos", }, { - id: "chaos_currents", - name: "The Chaos Currents", description: "Rivers of raw creation flow through the primordial chaos — not water but pure potential, capable of transforming anything they touch into anything else entirely.", - status: "locked", durationSeconds: 18 * 60 * 60, - rewards: [ - { type: "gold", amount: 4e28 }, - { type: "essence", amount: 1.5e25 }, - { type: "crystals", amount: 5e21 }, + id: "chaos_currents", + name: "The Chaos Currents", + prerequisiteIds: [ "chaos_entry" ], + rewards: [ + { amount: 4e28, type: "gold" }, + { amount: 1.5e25, type: "essence" }, + { amount: 5e21, type: "crystals" }, ], - prerequisiteIds: ["chaos_entry"], + status: "locked", zoneId: "primordial_chaos", }, { - id: "unformed_wastes", - name: "The Unformed Wastes", description: "A region of the chaos where the argument between existence and non-existence has not yet produced a winner — where matter and anti-matter coexist in violent, constant negotiation.", - status: "locked", durationSeconds: 30 * 60 * 60, - rewards: [ - { type: "gold", amount: 2e29 }, - { type: "essence", amount: 8e25 }, - { type: "crystals", amount: 2e22 }, - { type: "adventurer", targetId: "titan_warrior" }, + id: "unformed_wastes", + name: "The Unformed Wastes", + prerequisiteIds: [ "chaos_currents" ], + rewards: [ + { amount: 2e29, type: "gold" }, + { amount: 8e25, type: "essence" }, + { amount: 2e22, type: "crystals" }, + { targetId: "titan_warrior", type: "adventurer" }, ], - prerequisiteIds: ["chaos_currents"], + status: "locked", zoneId: "primordial_chaos", }, { - id: "potential_vaults", - name: "The Vaults of Potential", description: "Every possibility that has never occurred is stored here — in vaults that have no walls, containing things that have no form. Your guild navigates them by deciding what they want to find, and finding it.", - status: "locked", durationSeconds: 45 * 60 * 60, - rewards: [ - { type: "gold", amount: 1e30 }, - { type: "essence", amount: 4e26 }, - { type: "crystals", amount: 8e22 }, + id: "potential_vaults", + name: "The Vaults of Potential", + prerequisiteIds: [ "unformed_wastes" ], + rewards: [ + { amount: 1e30, type: "gold" }, + { amount: 4e26, type: "essence" }, + { amount: 8e22, type: "crystals" }, ], - prerequisiteIds: ["unformed_wastes"], + status: "locked", zoneId: "primordial_chaos", }, { - id: "creation_cradle", - name: "The Creation Cradle", description: "The origin point of everything — not a place but the idea of the first place, preserved in the chaos as a monument to the moment reality decided to exist.", - status: "locked", durationSeconds: 65 * 60 * 60, - rewards: [ - { type: "gold", amount: 6e30 }, - { type: "essence", amount: 2e27 }, - { type: "crystals", amount: 4e23 }, - { type: "upgrade", targetId: "titan_warrior_1" }, + id: "creation_cradle", + name: "The Creation Cradle", + prerequisiteIds: [ "potential_vaults" ], + rewards: [ + { amount: 6e30, type: "gold" }, + { amount: 2e27, type: "essence" }, + { amount: 4e23, type: "crystals" }, + { targetId: "titan_warrior_1", type: "upgrade" }, ], - prerequisiteIds: ["potential_vaults"], + status: "locked", zoneId: "primordial_chaos", }, { - id: "chaos_chronicle", - name: "The Chaos Chronicle", description: "The record of everything that almost was — every universe that the chaos produced and discarded before settling on this one. Your guild reads it and understands, for the first time, how unlikely they are.", - status: "locked", durationSeconds: 90 * 60 * 60, - rewards: [ - { type: "gold", amount: 3e31 }, - { type: "essence", amount: 1e28 }, - { type: "crystals", amount: 2e24 }, + id: "chaos_chronicle", + name: "The Chaos Chronicle", + prerequisiteIds: [ "creation_cradle" ], + rewards: [ + { amount: 3e31, type: "gold" }, + { amount: 1e28, type: "essence" }, + { amount: 2e24, type: "crystals" }, ], - prerequisiteIds: ["creation_cradle"], + status: "locked", zoneId: "primordial_chaos", }, // ── Infinite Expanse ────────────────────────────────────────────────────── { - id: "first_horizon", - name: "The First Horizon", description: "The edge of the knowable — not because nothing lies beyond, but because the Expanse has no edges and every horizon is also a centre. Your guild walks toward a destination that keeps receding at the exact speed they approach it.", - status: "locked", durationSeconds: 12 * 60 * 60, - rewards: [ - { type: "gold", amount: 1e33 }, - { type: "essence", amount: 4e29 }, - { type: "adventurer", targetId: "nexus_sage" }, - ], + id: "first_horizon", + name: "The First Horizon", prerequisiteIds: [], + rewards: [ + { amount: 1e33, type: "gold" }, + { amount: 4e29, type: "essence" }, + { targetId: "nexus_sage", type: "adventurer" }, + ], + status: "locked", zoneId: "infinite_expanse", }, { - id: "endless_sea", - name: "The Endless Sea", description: "An ocean with no shores, no depth, no surface — a body of liquid possibility that extends infinitely in all directions, including inward. Your guild sails it without a ship and arrives exactly when they decide to.", - status: "locked", durationSeconds: 22 * 60 * 60, - rewards: [ - { type: "gold", amount: 6e34 }, - { type: "essence", amount: 2e31 }, - { type: "crystals", amount: 5e27 }, + id: "endless_sea", + name: "The Endless Sea", + prerequisiteIds: [ "first_horizon" ], + rewards: [ + { amount: 6e34, type: "gold" }, + { amount: 2e31, type: "essence" }, + { amount: 5e27, type: "crystals" }, ], - prerequisiteIds: ["first_horizon"], + status: "locked", zoneId: "infinite_expanse", }, { - id: "expanse_ruins", - name: "The Expanse Ruins", description: "Civilisations that attempted the Expanse before your guild and ran out of universe. Their ruins drift without reference points, enormous and silent, a reminder that infinity has claimed predecessors.", - status: "locked", durationSeconds: 36 * 60 * 60, - rewards: [ - { type: "gold", amount: 3e36 }, - { type: "essence", amount: 1e33 }, - { type: "crystals", amount: 2.5e29 }, - { type: "adventurer", targetId: "cosmos_knight" }, + id: "expanse_ruins", + name: "The Expanse Ruins", + prerequisiteIds: [ "endless_sea" ], + rewards: [ + { amount: 3e36, type: "gold" }, + { amount: 1e33, type: "essence" }, + { amount: 2.5e29, type: "crystals" }, + { targetId: "cosmos_knight", type: "adventurer" }, ], - prerequisiteIds: ["endless_sea"], + status: "locked", zoneId: "infinite_expanse", }, { - id: "infinite_archive", - name: "The Infinite Archive", description: "A library with no walls, cataloguing everything that exists across all of infinite space. The catalogue itself is infinite. The librarian is very tired.", - status: "locked", durationSeconds: 55 * 60 * 60, - rewards: [ - { type: "gold", amount: 1.5e38 }, - { type: "essence", amount: 5e34 }, - { type: "crystals", amount: 1e31 }, - { type: "upgrade", targetId: "nexus_sage_1" }, + id: "infinite_archive", + name: "The Infinite Archive", + prerequisiteIds: [ "expanse_ruins" ], + rewards: [ + { amount: 1.5e38, type: "gold" }, + { amount: 5e34, type: "essence" }, + { amount: 1e31, type: "crystals" }, + { targetId: "nexus_sage_1", type: "upgrade" }, ], - prerequisiteIds: ["expanse_ruins"], + status: "locked", zoneId: "infinite_expanse", }, { - id: "paradox_plains", - name: "The Paradox Plains", description: "A region where the Expanse loops back on itself — where every direction is simultaneously every other direction, and travel requires your guild to stop thinking about it too hard.", - status: "locked", durationSeconds: 80 * 60 * 60, - rewards: [ - { type: "gold", amount: 8e39 }, - { type: "essence", amount: 2.5e36 }, - { type: "crystals", amount: 5e32 }, + id: "paradox_plains", + name: "The Paradox Plains", + prerequisiteIds: [ "infinite_archive" ], + rewards: [ + { amount: 8e39, type: "gold" }, + { amount: 2.5e36, type: "essence" }, + { amount: 5e32, type: "crystals" }, ], - prerequisiteIds: ["infinite_archive"], + status: "locked", zoneId: "infinite_expanse", }, { - id: "expanse_codex", - name: "The Expanse Codex", description: "The complete record of all infinite things — compressed, impossibly, into a document your guild can almost read. What they can read changes everything they thought they understood about the word 'everything'.", - status: "locked", durationSeconds: 110 * 60 * 60, - rewards: [ - { type: "gold", amount: 4e41 }, - { type: "essence", amount: 1.2e38 }, - { type: "crystals", amount: 2.5e34 }, + id: "expanse_codex", + name: "The Expanse Codex", + prerequisiteIds: [ "paradox_plains" ], + rewards: [ + { amount: 4e41, type: "gold" }, + { amount: 1.2e38, type: "essence" }, + { amount: 2.5e34, type: "crystals" }, ], - prerequisiteIds: ["paradox_plains"], + status: "locked", zoneId: "infinite_expanse", }, // ── Reality Forge ───────────────────────────────────────────────────────── { - id: "forge_entrance", - name: "The Forge Entrance", description: "The door to the Reality Forge has been open since the moment reality started — left ajar because the workers never thought anyone else would find it. Your guild finds it.", - status: "locked", durationSeconds: 14 * 60 * 60, - rewards: [ - { type: "gold", amount: 2e44 }, - { type: "essence", amount: 6e40 }, - { type: "adventurer", targetId: "astral_sovereign" }, - ], + id: "forge_entrance", + name: "The Forge Entrance", prerequisiteIds: [], + rewards: [ + { amount: 2e44, type: "gold" }, + { amount: 6e40, type: "essence" }, + { targetId: "astral_sovereign", type: "adventurer" }, + ], + status: "locked", zoneId: "reality_forge", }, { - id: "blueprint_vault", - name: "The Blueprint Vault", description: "The Forge keeps the blueprints for every universe it has ever built — and the rejected designs for the ones it hasn't. Some of those rejected blueprints are disturbingly appealing.", - status: "locked", durationSeconds: 25 * 60 * 60, - rewards: [ - { type: "gold", amount: 1e46 }, - { type: "essence", amount: 3e42 }, - { type: "crystals", amount: 2e38 }, + id: "blueprint_vault", + name: "The Blueprint Vault", + prerequisiteIds: [ "forge_entrance" ], + rewards: [ + { amount: 1e46, type: "gold" }, + { amount: 3e42, type: "essence" }, + { amount: 2e38, type: "crystals" }, ], - prerequisiteIds: ["forge_entrance"], + status: "locked", zoneId: "reality_forge", }, { - id: "creation_workshop", - name: "The Creation Workshop", description: "The active floor of the Forge — where new realities are being assembled right now, and your guild must navigate between workbenches containing half-finished universes without knocking anything over.", - status: "locked", durationSeconds: 40 * 60 * 60, - rewards: [ - { type: "gold", amount: 5e47 }, - { type: "essence", amount: 1.5e44 }, - { type: "crystals", amount: 1e40 }, - { type: "adventurer", targetId: "primordial_mage" }, + id: "creation_workshop", + name: "The Creation Workshop", + prerequisiteIds: [ "blueprint_vault" ], + rewards: [ + { amount: 5e47, type: "gold" }, + { amount: 1.5e44, type: "essence" }, + { amount: 1e40, type: "crystals" }, + { targetId: "primordial_mage", type: "adventurer" }, ], - prerequisiteIds: ["blueprint_vault"], + status: "locked", zoneId: "reality_forge", }, { - id: "laws_engine", - name: "The Laws Engine", description: "The mechanism that produces the laws of physics — an engine running since the first moment, churning out constants and rules that every universe obeys without knowing why. Your guild sees the source code.", - status: "locked", durationSeconds: 60 * 60 * 60, - rewards: [ - { type: "gold", amount: 2.5e49 }, - { type: "essence", amount: 8e45 }, - { type: "crystals", amount: 5e41 }, - { type: "upgrade", targetId: "cosmos_knight_1" }, + id: "laws_engine", + name: "The Laws Engine", + prerequisiteIds: [ "creation_workshop" ], + rewards: [ + { amount: 2.5e49, type: "gold" }, + { amount: 8e45, type: "essence" }, + { amount: 5e41, type: "crystals" }, + { targetId: "cosmos_knight_1", type: "upgrade" }, ], - prerequisiteIds: ["creation_workshop"], + status: "locked", zoneId: "reality_forge", }, { - id: "forge_heart", - name: "The Forge Heart", description: "The power source of the Reality Forge — not a furnace but a contained singularity, burning with the same energy that ignited the first universe. Your guild siphons from it. The Forge barely notices.", - status: "locked", durationSeconds: 85 * 60 * 60, - rewards: [ - { type: "gold", amount: 1.2e51 }, - { type: "essence", amount: 4e47 }, - { type: "crystals", amount: 2.5e43 }, + id: "forge_heart", + name: "The Forge Heart", + prerequisiteIds: [ "laws_engine" ], + rewards: [ + { amount: 1.2e51, type: "gold" }, + { amount: 4e47, type: "essence" }, + { amount: 2.5e43, type: "crystals" }, ], - prerequisiteIds: ["laws_engine"], + status: "locked", zoneId: "reality_forge", }, { - id: "forge_chronicle", - name: "The Forge Chronicle", description: "The record of every reality the Forge has produced — every universe that exists or ever existed, with notes on what worked and what didn't. Your guild's universe has several notes. Most are surprising.", - status: "locked", durationSeconds: 120 * 60 * 60, - rewards: [ - { type: "gold", amount: 6e52 }, - { type: "essence", amount: 2e49 }, - { type: "crystals", amount: 1.2e45 }, + id: "forge_chronicle", + name: "The Forge Chronicle", + prerequisiteIds: [ "forge_heart" ], + rewards: [ + { amount: 6e52, type: "gold" }, + { amount: 2e49, type: "essence" }, + { amount: 1.2e45, type: "crystals" }, ], - prerequisiteIds: ["forge_heart"], + status: "locked", zoneId: "reality_forge", }, // ── Cosmic Maelstrom ────────────────────────────────────────────────────── { - id: "maelstrom_entry", - name: "The Maelstrom's Edge", description: "The outermost reach of the Cosmic Maelstrom — where everything moves at a speed that makes stars look stationary. Your guild anchors itself in the relative calm of its periphery and begins to push inward.", - status: "locked", durationSeconds: 16 * 60 * 60, - rewards: [ - { type: "gold", amount: 3e55 }, - { type: "essence", amount: 1e52 }, - { type: "adventurer", targetId: "reality_warden" }, - ], + id: "maelstrom_entry", + name: "The Maelstrom's Edge", prerequisiteIds: [], + rewards: [ + { amount: 3e55, type: "gold" }, + { amount: 1e52, type: "essence" }, + { targetId: "reality_warden", type: "adventurer" }, + ], + status: "locked", zoneId: "cosmic_maelstrom", }, { - id: "force_nexus", - name: "The Force Nexus", description: "The point where every cosmic force intersects — where gravity and electromagnetism and every other fundamental force meet and argue. The argument is conducted at energies that reshape matter.", - status: "locked", durationSeconds: 28 * 60 * 60, - rewards: [ - { type: "gold", amount: 1.5e58 }, - { type: "essence", amount: 5e54 }, - { type: "crystals", amount: 3e50 }, + id: "force_nexus", + name: "The Force Nexus", + prerequisiteIds: [ "maelstrom_entry" ], + rewards: [ + { amount: 1.5e58, type: "gold" }, + { amount: 5e54, type: "essence" }, + { amount: 3e50, type: "crystals" }, ], - prerequisiteIds: ["maelstrom_entry"], + status: "locked", zoneId: "cosmic_maelstrom", }, { - id: "storm_cauldron", - name: "The Storm Cauldron", description: "A region where cosmic storms have been brewing since the beginning of time, compounding on themselves into intensities that no physical object should be able to survive. Your guild survives.", - status: "locked", durationSeconds: 45 * 60 * 60, - rewards: [ - { type: "gold", amount: 8e60 }, - { type: "essence", amount: 2.5e57 }, - { type: "crystals", amount: 1.5e53 }, - { type: "adventurer", targetId: "infinity_ranger" }, + id: "storm_cauldron", + name: "The Storm Cauldron", + prerequisiteIds: [ "force_nexus" ], + rewards: [ + { amount: 8e60, type: "gold" }, + { amount: 2.5e57, type: "essence" }, + { amount: 1.5e53, type: "crystals" }, + { targetId: "infinity_ranger", type: "adventurer" }, ], - prerequisiteIds: ["force_nexus"], + status: "locked", zoneId: "cosmic_maelstrom", }, { - id: "annihilation_fields", - name: "The Annihilation Fields", description: "Regions of space where creation and destruction happen simultaneously at rates that would erase continents. Your guild navigates the moments between creation and erasure with precision that surprises even themselves.", - status: "locked", durationSeconds: 65 * 60 * 60, - rewards: [ - { type: "gold", amount: 4e63 }, - { type: "essence", amount: 1.2e60 }, - { type: "crystals", amount: 7e55 }, - { type: "upgrade", targetId: "astral_sovereign_1" }, + id: "annihilation_fields", + name: "The Annihilation Fields", + prerequisiteIds: [ "storm_cauldron" ], + rewards: [ + { amount: 4e63, type: "gold" }, + { amount: 1.2e60, type: "essence" }, + { amount: 7e55, type: "crystals" }, + { targetId: "astral_sovereign_1", type: "upgrade" }, ], - prerequisiteIds: ["storm_cauldron"], + status: "locked", zoneId: "cosmic_maelstrom", }, { - id: "convergence_point", - name: "The Convergence Point", description: "The centre of the Cosmic Maelstrom — the point toward which every force converges and from which everything radiates. Being here is being at the exact centre of all physical law. It is very loud.", - status: "locked", durationSeconds: 90 * 60 * 60, - rewards: [ - { type: "gold", amount: 2e66 }, - { type: "essence", amount: 6e62 }, - { type: "crystals", amount: 3.5e58 }, + id: "convergence_point", + name: "The Convergence Point", + prerequisiteIds: [ "annihilation_fields" ], + rewards: [ + { amount: 2e66, type: "gold" }, + { amount: 6e62, type: "essence" }, + { amount: 3.5e58, type: "crystals" }, ], - prerequisiteIds: ["annihilation_fields"], + status: "locked", zoneId: "cosmic_maelstrom", }, { - id: "maelstrom_codex", - name: "The Maelstrom Codex", description: "The record kept in the eye of the storm — the one place calm enough to write, where every force is in perfect balance. Your guild adds their chapter in the moments before the balance shifts again.", - status: "locked", durationSeconds: 130 * 60 * 60, - rewards: [ - { type: "gold", amount: 1e69 }, - { type: "essence", amount: 3e65 }, - { type: "crystals", amount: 1.8e61 }, + id: "maelstrom_codex", + name: "The Maelstrom Codex", + prerequisiteIds: [ "convergence_point" ], + rewards: [ + { amount: 1e69, type: "gold" }, + { amount: 3e65, type: "essence" }, + { amount: 1.8e61, type: "crystals" }, ], - prerequisiteIds: ["convergence_point"], + status: "locked", zoneId: "cosmic_maelstrom", }, // ── Primeval Sanctum ────────────────────────────────────────────────────── { - id: "sanctum_gate", - name: "The Sanctum Gate", description: "The entrance to the oldest place — a threshold that does not open because it was never closed. It merely requires you to be old enough, deep enough, powerful enough to perceive it.", - status: "locked", durationSeconds: 18 * 60 * 60, - rewards: [ - { type: "gold", amount: 5e72 }, - { type: "essence", amount: 1.5e69 }, - { type: "adventurer", targetId: "oblivion_paladin" }, - ], + id: "sanctum_gate", + name: "The Sanctum Gate", prerequisiteIds: [], + rewards: [ + { amount: 5e72, type: "gold" }, + { amount: 1.5e69, type: "essence" }, + { targetId: "oblivion_paladin", type: "adventurer" }, + ], + status: "locked", zoneId: "primeval_sanctum", }, { - id: "memory_vaults", - name: "The Memory Vaults", description: "The sanctum stores every moment that has ever occurred — not as records but as living impressions, still occurring in perpetual replay. Your guild walks through history as it happens, over and over.", - status: "locked", durationSeconds: 32 * 60 * 60, - rewards: [ - { type: "gold", amount: 2.5e76 }, - { type: "essence", amount: 7e72 }, - { type: "crystals", amount: 4e68 }, + id: "memory_vaults", + name: "The Memory Vaults", + prerequisiteIds: [ "sanctum_gate" ], + rewards: [ + { amount: 2.5e76, type: "gold" }, + { amount: 7e72, type: "essence" }, + { amount: 4e68, type: "crystals" }, ], - prerequisiteIds: ["sanctum_gate"], + status: "locked", zoneId: "primeval_sanctum", }, { - id: "origin_halls", - name: "The Origin Halls", description: "The halls where everything began — not the physical beginning, but the idea of beginning itself, preserved here as the sanctum's most sacred artefact. To walk these halls is to understand why anything started.", - status: "locked", durationSeconds: 50 * 60 * 60, - rewards: [ - { type: "gold", amount: 1.2e80 }, - { type: "essence", amount: 3.5e76 }, - { type: "crystals", amount: 2e72 }, - { type: "adventurer", targetId: "transcendent_rogue" }, + id: "origin_halls", + name: "The Origin Halls", + prerequisiteIds: [ "memory_vaults" ], + rewards: [ + { amount: 1.2e80, type: "gold" }, + { amount: 3.5e76, type: "essence" }, + { amount: 2e72, type: "crystals" }, + { targetId: "transcendent_rogue", type: "adventurer" }, ], - prerequisiteIds: ["memory_vaults"], + status: "locked", zoneId: "primeval_sanctum", }, { - id: "first_light_hall", - name: "The Hall of First Light", description: "The chamber where the first photon was produced — still illuminated by that original light, unchanged for all of time. The warmth here is the warmth of the universe's childhood.", - status: "locked", durationSeconds: 72 * 60 * 60, - rewards: [ - { type: "gold", amount: 6e83 }, - { type: "essence", amount: 1.8e80 }, - { type: "crystals", amount: 1e76 }, - { type: "upgrade", targetId: "primordial_mage_1" }, + id: "first_light_hall", + name: "The Hall of First Light", + prerequisiteIds: [ "origin_halls" ], + rewards: [ + { amount: 6e83, type: "gold" }, + { amount: 1.8e80, type: "essence" }, + { amount: 1e76, type: "crystals" }, + { targetId: "primordial_mage_1", type: "upgrade" }, ], - prerequisiteIds: ["origin_halls"], + status: "locked", zoneId: "primeval_sanctum", }, { - id: "before_time", - name: "Before Time", description: "A region of the sanctum that predates the concept of sequence — where cause does not reliably precede effect, and your guild must navigate by intention rather than direction.", - status: "locked", durationSeconds: 100 * 60 * 60, - rewards: [ - { type: "gold", amount: 3e87 }, - { type: "essence", amount: 9e83 }, - { type: "crystals", amount: 5e79 }, + id: "before_time", + name: "Before Time", + prerequisiteIds: [ "first_light_hall" ], + rewards: [ + { amount: 3e87, type: "gold" }, + { amount: 9e83, type: "essence" }, + { amount: 5e79, type: "crystals" }, ], - prerequisiteIds: ["first_light_hall"], + status: "locked", zoneId: "primeval_sanctum", }, { - id: "sanctum_chronicle", - name: "The Sanctum Chronicle", description: "The complete record of all primeval things — every first moment of every concept that has ever existed, bound together in something that predates writing, reading, and the idea of records. Your guild understands it anyway.", - status: "locked", durationSeconds: 144 * 60 * 60, - rewards: [ - { type: "gold", amount: 1.5e91 }, - { type: "essence", amount: 4.5e87 }, - { type: "crystals", amount: 2.5e83 }, + id: "sanctum_chronicle", + name: "The Sanctum Chronicle", + prerequisiteIds: [ "before_time" ], + rewards: [ + { amount: 1.5e91, type: "gold" }, + { amount: 4.5e87, type: "essence" }, + { amount: 2.5e83, type: "crystals" }, ], - prerequisiteIds: ["before_time"], + status: "locked", zoneId: "primeval_sanctum", }, // ── The Absolute ────────────────────────────────────────────────────────── { - id: "absolute_threshold", - name: "The Absolute Threshold", description: "The beginning of the end of everything. Your guild crosses it and feels, for the first time, that they have gone somewhere genuinely, ontologically final.", - status: "locked", durationSeconds: 20 * 60 * 60, - rewards: [ - { type: "gold", amount: 8e95 }, - { type: "essence", amount: 2.5e92 }, - { type: "adventurer", targetId: "omniversal_champion" }, - ], + id: "absolute_threshold", + name: "The Absolute Threshold", prerequisiteIds: [], + rewards: [ + { amount: 8e95, type: "gold" }, + { amount: 2.5e92, type: "essence" }, + { targetId: "omniversal_champion", type: "adventurer" }, + ], + status: "locked", zoneId: "the_absolute", }, { - id: "nothing_wastes", - name: "The Nothing Wastes", description: "Not empty — nothing. A region where even the concept of region is a courtesy your guild extends to the space by thinking about it. The moment they stop thinking, it stops being a space.", - status: "locked", durationSeconds: 36 * 60 * 60, - rewards: [ - { type: "gold", amount: 4e101 }, - { type: "essence", amount: 1.2e98 }, - { type: "crystals", amount: 6e93 }, + id: "nothing_wastes", + name: "The Nothing Wastes", + prerequisiteIds: [ "absolute_threshold" ], + rewards: [ + { amount: 4e101, type: "gold" }, + { amount: 1.2e98, type: "essence" }, + { amount: 6e93, type: "crystals" }, ], - prerequisiteIds: ["absolute_threshold"], + status: "locked", zoneId: "the_absolute", }, { - id: "final_paradox", - name: "The Final Paradox", description: "A region that exists by virtue of containing the contradiction of existence and non-existence simultaneously — a place that is also not a place, navigable only by those who have stopped needing either to be true.", - status: "locked", durationSeconds: 56 * 60 * 60, - rewards: [ - { type: "gold", amount: 2e108 }, - { type: "essence", amount: 6e104 }, - { type: "crystals", amount: 3e100 }, - { type: "upgrade", targetId: "reality_warden_1" }, + id: "final_paradox", + name: "The Final Paradox", + prerequisiteIds: [ "nothing_wastes" ], + rewards: [ + { amount: 2e108, type: "gold" }, + { amount: 6e104, type: "essence" }, + { amount: 3e100, type: "crystals" }, + { targetId: "reality_warden_1", type: "upgrade" }, ], - prerequisiteIds: ["nothing_wastes"], + status: "locked", zoneId: "the_absolute", }, { - id: "end_vault", - name: "The Vault of Ends", description: "Everything that has ever ended is stored here — every life, every civilisation, every universe, every concept that has run its course. The collection is comprehensive. Your guild is not in it yet.", - status: "locked", durationSeconds: 80 * 60 * 60, - rewards: [ - { type: "gold", amount: 1e115 }, - { type: "essence", amount: 3e111 }, - { type: "crystals", amount: 1.5e107 }, + id: "end_vault", + name: "The Vault of Ends", + prerequisiteIds: [ "final_paradox" ], + rewards: [ + { amount: 1e115, type: "gold" }, + { amount: 3e111, type: "essence" }, + { amount: 1.5e107, type: "crystals" }, ], - prerequisiteIds: ["final_paradox"], + status: "locked", zoneId: "the_absolute", }, { - id: "terminal_approach", - name: "The Terminal Approach", description: "The last path before the last thing. Every step here is a step that has never been taken before and will never be taken again. The Absolute awaits at the end of it, and it is aware of your guild.", - status: "locked", durationSeconds: 120 * 60 * 60, - rewards: [ - { type: "gold", amount: 5e121 }, - { type: "essence", amount: 1.5e118 }, - { type: "crystals", amount: 7e113 }, - { type: "upgrade", targetId: "infinity_ranger_1" }, + id: "terminal_approach", + name: "The Terminal Approach", + prerequisiteIds: [ "end_vault" ], + rewards: [ + { amount: 5e121, type: "gold" }, + { amount: 1.5e118, type: "essence" }, + { amount: 7e113, type: "crystals" }, + { targetId: "infinity_ranger_1", type: "upgrade" }, ], - prerequisiteIds: ["end_vault"], + status: "locked", zoneId: "the_absolute", }, { - id: "absolute_dominion", - name: "Absolute Dominion", description: "This is it. Not the throne — not power — not victory. Just the knowledge, confirmed and total, that your guild reached the end of everything and was not ended. That is, in every measurable way, enough.", - status: "locked", durationSeconds: 168 * 60 * 60, - rewards: [ - { type: "gold", amount: 3e130 }, - { type: "essence", amount: 9e126 }, - { type: "crystals", amount: 4e122 }, + id: "absolute_dominion", + name: "Absolute Dominion", + prerequisiteIds: [ "terminal_approach" ], + rewards: [ + { amount: 3e130, type: "gold" }, + { amount: 9e126, type: "essence" }, + { amount: 4e122, type: "crystals" }, ], - prerequisiteIds: ["terminal_approach"], + status: "locked", zoneId: "the_absolute", }, ]; diff --git a/apps/api/src/data/recipes.ts b/apps/api/src/data/recipes.ts index 7529a8d..c9c1ea8 100644 --- a/apps/api/src/data/recipes.ts +++ b/apps/api/src/data/recipes.ts @@ -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 = [ // 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 }, }, ]; diff --git a/apps/api/src/data/schemaVersion.ts b/apps/api/src/data/schemaVersion.ts index 456ded1..4405683 100644 --- a/apps/api/src/data/schemaVersion.ts +++ b/apps/api/src/data/schemaVersion.ts @@ -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; diff --git a/apps/api/src/data/titles.ts b/apps/api/src/data/titles.ts index e29c74e..d1eb07b 100644 --- a/apps/api/src/data/titles.ts +++ b/apps/api/src/data/titles.ts @@ -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 = [ // 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", }, ]; diff --git a/apps/api/src/data/transcendenceUpgrades.ts b/apps/api/src/data/transcendenceUpgrades.ts index 257bed1..38fcda2 100644 --- a/apps/api/src/data/transcendenceUpgrades.ts +++ b/apps/api/src/data/transcendenceUpgrades.ts @@ -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", }, ]; diff --git a/apps/api/src/data/upgrades.ts b/apps/api/src/data/upgrades.ts index 3e14e2d..6dc88e3 100644 --- a/apps/api/src/data/upgrades.ts +++ b/apps/api/src/data/upgrades.ts @@ -1,726 +1,770 @@ +/** + * @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 { Upgrade } from "@elysium/types"; -export const DEFAULT_UPGRADES: Upgrade[] = [ +export const defaultUpgrades: Array<Upgrade> = [ // ── Click upgrades ──────────────────────────────────────────────────────── { - id: "click_1", - name: "Keen Eye", - description: "Your strikes find weak points. Doubles click power.", - target: "click", - multiplier: 2, - costGold: 100, - costEssence: 0, costCrystals: 0, - purchased: false, - unlocked: true, + costEssence: 0, + costGold: 100, + description: "Your strikes find weak points. Doubles click power.", + id: "click_1", + multiplier: 2, + name: "Keen Eye", + purchased: false, + target: "click", + unlocked: true, }, { - id: "click_2", - name: "Battle Hardened", - description: "Years of combat sharpen your instincts. Doubles click power again.", - target: "click", - multiplier: 2, - costGold: 1_000, - costEssence: 0, costCrystals: 0, - purchased: false, - unlocked: false, + costEssence: 0, + costGold: 1000, + description: + "Years of combat sharpen your instincts. Doubles click power again.", + id: "click_2", + multiplier: 2, + name: "Battle Hardened", + purchased: false, + target: "click", + unlocked: false, }, { - id: "click_3", - name: "Legendary Weapon", - description: "A weapon of ancient power. Triples click power.", - target: "click", - multiplier: 3, - costGold: 50_000, - costEssence: 10, costCrystals: 0, - purchased: false, - unlocked: false, + costEssence: 10, + costGold: 50_000, + description: "A weapon of ancient power. Triples click power.", + id: "click_3", + multiplier: 3, + name: "Legendary Weapon", + purchased: false, + target: "click", + unlocked: false, }, { - id: "crystal_focus", - name: "Crystal Focus", - description: "Channel crystallised power into every strike. Doubles click power.", - target: "click", - multiplier: 2, - costGold: 0, - costEssence: 0, costCrystals: 100, - purchased: false, - unlocked: true, + costEssence: 0, + costGold: 0, + description: + "Channel crystallised power into every strike. Doubles click power.", + id: "crystal_focus", + multiplier: 2, + name: "Crystal Focus", + purchased: false, + target: "click", + unlocked: true, }, // ── Global gold upgrades ────────────────────────────────────────────────── { - id: "global_1", - name: "Guild Charter", - description: "Formalising the guild structure increases all income by 25%.", - target: "global", - multiplier: 1.25, - costGold: 500, - costEssence: 0, costCrystals: 0, - purchased: false, - unlocked: false, + costEssence: 0, + costGold: 500, + description: "Formalising the guild structure increases all income by 25%.", + id: "global_1", + multiplier: 1.25, + name: "Guild Charter", + purchased: false, + target: "global", + unlocked: false, }, { - id: "global_2", - name: "Merchant Alliance", - description: "Trade routes boost all income by 50%.", - target: "global", + costCrystals: 0, + costEssence: 5, + costGold: 10_000, + description: "Trade routes boost all income by 50%.", + id: "global_2", + multiplier: 1.5, + name: "Merchant Alliance", + purchased: false, + target: "global", + unlocked: false, + }, + { + costCrystals: 0, + costEssence: 100, + costGold: 1_000_000, + description: "The king himself backs your guild. All income doubled.", + id: "global_3", + multiplier: 2, + name: "Royal Patronage", + purchased: false, + target: "global", + unlocked: false, + }, + { + costCrystals: 0, + costEssence: 50, + costGold: 50_000, + description: + "Forge partnerships with mage guilds across the realm. All income +50%.", + id: "essence_guild", multiplier: 1.5, - costGold: 10_000, - costEssence: 5, - costCrystals: 0, - purchased: false, - unlocked: false, + name: "Essence Guild", + purchased: false, + target: "global", + unlocked: false, }, { - id: "global_3", - name: "Royal Patronage", - description: "The king himself backs your guild. All income doubled.", - target: "global", + costCrystals: 0, + costEssence: 250, + costGold: 500_000, + description: + "A council of the realm's greatest minds organises your operations. All income doubled.", + id: "grand_council", multiplier: 2, - costGold: 1_000_000, - costEssence: 100, - costCrystals: 0, - purchased: false, - unlocked: false, + name: "Grand Council", + purchased: false, + target: "global", + unlocked: false, }, { - id: "essence_guild", - name: "Essence Guild", - description: "Forge partnerships with mage guilds across the realm. All income +50%.", - target: "global", - multiplier: 1.5, - costGold: 50_000, - costEssence: 50, - costCrystals: 0, - purchased: false, - unlocked: false, - }, - { - id: "grand_council", - name: "Grand Council", - description: "A council of the realm's greatest minds organises your operations. All income doubled.", - target: "global", - multiplier: 2, - costGold: 500_000, - costEssence: 250, - costCrystals: 0, - purchased: false, - unlocked: false, - }, - { - id: "crystal_resonance", - name: "Crystal Resonance", - description: "Align crystalline frequencies across your guild. All income +50%.", - target: "global", - multiplier: 1.5, - costGold: 0, - costEssence: 0, costCrystals: 250, - purchased: false, - unlocked: false, + costEssence: 0, + costGold: 0, + description: + "Align crystalline frequencies across your guild. All income +50%.", + id: "crystal_resonance", + multiplier: 1.5, + name: "Crystal Resonance", + purchased: false, + target: "global", + unlocked: false, }, { - id: "crystal_mastery", - name: "Crystal Mastery", - description: "Master the art of crystal amplification. All income doubled.", - target: "global", - multiplier: 2, - costGold: 0, - costEssence: 0, costCrystals: 600, - purchased: false, - unlocked: false, + costEssence: 0, + costGold: 0, + description: "Master the art of crystal amplification. All income doubled.", + id: "crystal_mastery", + multiplier: 2, + name: "Crystal Mastery", + purchased: false, + target: "global", + unlocked: false, }, // ── Adventurer-specific upgrades ────────────────────────────────────────── { - id: "peasant_1", - name: "Better Tools", - description: "Peasants work twice as hard with proper equipment.", - target: "adventurer", adventurerId: "peasant", - multiplier: 2, - costGold: 200, - costEssence: 0, costCrystals: 0, - purchased: false, - unlocked: false, + costEssence: 0, + costGold: 200, + description: "Peasants work twice as hard with proper equipment.", + id: "peasant_1", + multiplier: 2, + name: "Better Tools", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "militia_1", - name: "Militia Training", - description: "Formal training doubles militia effectiveness.", - target: "adventurer", adventurerId: "militia", - multiplier: 2, - costGold: 1_000, - costEssence: 0, costCrystals: 0, - purchased: false, - unlocked: false, + costEssence: 0, + costGold: 1000, + description: "Formal training doubles militia effectiveness.", + id: "militia_1", + multiplier: 2, + name: "Militia Training", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "mage_1", - name: "Arcane Tomes", - description: "Ancient books of magic double mage output.", - target: "adventurer", adventurerId: "apprentice", - multiplier: 2, - costGold: 5_000, - costEssence: 2, costCrystals: 0, - purchased: false, - unlocked: false, + costEssence: 2, + costGold: 5000, + description: "Ancient books of magic double mage output.", + id: "mage_1", + multiplier: 2, + name: "Arcane Tomes", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "cleric_1", - name: "Holy Rites", - description: "Sacred ceremonies double the output of your clerics.", - target: "adventurer", adventurerId: "acolyte", - multiplier: 2, - costGold: 8_000, - costEssence: 3, costCrystals: 0, - purchased: false, - unlocked: false, + costEssence: 3, + costGold: 8000, + description: "Sacred ceremonies double the output of your clerics.", + id: "cleric_1", + multiplier: 2, + name: "Holy Rites", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "scout_1", - name: "Stealth Training", - description: "Advanced scouting techniques double ranger effectiveness.", - target: "adventurer", adventurerId: "ranger", - multiplier: 2, - costGold: 15_000, - costEssence: 5, costCrystals: 0, - purchased: false, - unlocked: false, + costEssence: 5, + costGold: 15_000, + description: "Advanced scouting techniques double ranger effectiveness.", + id: "scout_1", + multiplier: 2, + name: "Stealth Training", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "knight_1", - name: "Tempered Steel", - description: "Superior forging techniques double the output of your knights.", - target: "adventurer", adventurerId: "knight", - multiplier: 2, - costGold: 50_000, - costEssence: 10, costCrystals: 0, - purchased: false, - unlocked: false, + costEssence: 10, + costGold: 50_000, + description: + "Superior forging techniques double the output of your knights.", + id: "knight_1", + multiplier: 2, + name: "Tempered Steel", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "archmage_1", - name: "Leyline Binding", - description: "Tap into the world's leylines to double archmage output.", - target: "adventurer", adventurerId: "archmage", - multiplier: 2, - costGold: 100_000, - costEssence: 75, costCrystals: 0, - purchased: false, - unlocked: false, + costEssence: 75, + costGold: 100_000, + description: "Tap into the world's leylines to double archmage output.", + id: "archmage_1", + multiplier: 2, + name: "Leyline Binding", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "paladin_1", - name: "Holy Vanguard", - description: "Divine blessings from the gods themselves double paladin output.", - target: "adventurer", adventurerId: "paladin", - multiplier: 2, - costGold: 200_000, - costEssence: 150, costCrystals: 0, - purchased: false, - unlocked: false, + costEssence: 150, + costGold: 200_000, + description: + "Divine blessings from the gods themselves double paladin output.", + id: "paladin_1", + multiplier: 2, + name: "Holy Vanguard", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "dragon_rider_1", - name: "Bond of Wings", - description: "The unbreakable bond between rider and dragon doubles their combined output.", - target: "adventurer", adventurerId: "dragon_rider", - multiplier: 2, - costGold: 500_000, - costEssence: 200, costCrystals: 0, - purchased: false, - unlocked: false, + costEssence: 200, + costGold: 500_000, + description: + "The unbreakable bond between rider and dragon doubles their combined output.", + id: "dragon_rider_1", + multiplier: 2, + name: "Bond of Wings", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "shadow_assassin_1", - name: "Shadow Arts", - description: "Mastery of the shadow arts doubles assassin effectiveness.", - target: "adventurer", adventurerId: "shadow_assassin", - multiplier: 2, - costGold: 0, - costEssence: 50, costCrystals: 0, - purchased: false, - unlocked: false, + costEssence: 50, + costGold: 0, + description: "Mastery of the shadow arts doubles assassin effectiveness.", + id: "shadow_assassin_1", + multiplier: 2, + name: "Shadow Arts", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "arcane_scholar_1", - name: "Ancient Tomes", - description: "Access to forbidden libraries doubles scholar output.", - target: "adventurer", adventurerId: "arcane_scholar", - multiplier: 2, - costGold: 0, - costEssence: 150, costCrystals: 0, - purchased: false, - unlocked: false, + costEssence: 150, + costGold: 0, + description: "Access to forbidden libraries doubles scholar output.", + id: "arcane_scholar_1", + multiplier: 2, + name: "Ancient Tomes", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "void_walker_1", - name: "Void Step", - description: "Walking through the void itself doubles the output of your void walkers.", - target: "adventurer", adventurerId: "void_walker", - multiplier: 2, - costGold: 0, - costEssence: 300, costCrystals: 0, - purchased: false, - unlocked: false, + costEssence: 300, + costGold: 0, + description: + "Walking through the void itself doubles the output of your void walkers.", + id: "void_walker_1", + multiplier: 2, + name: "Void Step", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "celestial_guard_1", - name: "Divine Ward", - description: "A blessing from the celestials themselves doubles guard output.", - target: "adventurer", adventurerId: "celestial_guard", - multiplier: 2, - costGold: 0, - costEssence: 750, costCrystals: 0, - purchased: false, - unlocked: false, + costEssence: 750, + costGold: 0, + description: + "A blessing from the celestials themselves doubles guard output.", + id: "celestial_guard_1", + multiplier: 2, + name: "Divine Ward", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "divine_champion_1", - name: "Champion's Oath", - description: "An unbreakable oath to the divine doubles champion output.", - target: "adventurer", adventurerId: "divine_champion", - multiplier: 2, - costGold: 0, - costEssence: 2_000, costCrystals: 0, - purchased: false, - unlocked: false, + costEssence: 2000, + costGold: 0, + description: "An unbreakable oath to the divine doubles champion output.", + id: "divine_champion_1", + multiplier: 2, + name: "Champion's Oath", + purchased: false, + target: "adventurer", + unlocked: false, }, // ── Click upgrades (new zones) ──────────────────────────────────────────── { - id: "click_4", - name: "Celestial Strike", - description: "Blessed by the celestials themselves. Click power quadrupled.", - target: "click", - multiplier: 4, - costGold: 100_000_000, - costEssence: 5_000_000, costCrystals: 0, - purchased: false, - unlocked: false, + costEssence: 5_000_000, + costGold: 100_000_000, + description: + "Blessed by the celestials themselves. Click power quadrupled.", + id: "click_4", + multiplier: 4, + name: "Celestial Strike", + purchased: false, + target: "click", + unlocked: false, }, { - id: "click_5", - name: "Infernal Slash", - description: "A strike that burns with infernal fire. Click power quintupled.", - target: "click", - multiplier: 5, - costGold: 0, - costEssence: 0, costCrystals: 10_000_000, - purchased: false, - unlocked: false, + costEssence: 0, + costGold: 0, + description: + "A strike that burns with infernal fire. Click power quintupled.", + id: "click_5", + multiplier: 5, + name: "Infernal Slash", + purchased: false, + target: "click", + unlocked: false, }, // ── Global upgrades (new zones) ─────────────────────────────────────────── { - id: "divine_covenant", - name: "Divine Covenant", - description: "A covenant with celestial forces multiplies your guild's potential. All income doubled.", - target: "global", - multiplier: 2, - costGold: 500_000_000, - costEssence: 10_000_000, costCrystals: 0, - purchased: false, - unlocked: false, + costEssence: 10_000_000, + costGold: 500_000_000, + description: + "A covenant with celestial forces multiplies your guild's potential. All income doubled.", + id: "divine_covenant", + multiplier: 2, + name: "Divine Covenant", + purchased: false, + target: "global", + unlocked: false, }, { - id: "global_4", - name: "Imperial Decree", - description: "The empire formally sponsors your guild at the highest level. All income x2.5.", - target: "global", + costCrystals: 0, + costEssence: 50_000_000, + costGold: 100_000_000_000, + description: + "The empire formally sponsors your guild at the highest level. All income x2.5.", + id: "global_4", multiplier: 2.5, - costGold: 100_000_000_000, - costEssence: 50_000_000, - costCrystals: 0, - purchased: false, - unlocked: false, + name: "Imperial Decree", + purchased: false, + target: "global", + unlocked: false, }, { - id: "abyssal_pact", - name: "Abyssal Pact", - description: "A pact with the denizens of the deepest trench. All income doubled.", - target: "global", - multiplier: 2, - costGold: 0, - costEssence: 0, costCrystals: 2_000_000, - purchased: false, - unlocked: false, - }, - { - id: "celestial_mandate", - name: "Celestial Mandate", - description: "The celestials themselves decree your guild's dominion over all realms. All income x3.", - target: "global", - multiplier: 3, - costGold: 50_000_000_000_000, - costEssence: 100_000_000_000, - costCrystals: 0, - purchased: false, - unlocked: false, - }, - { - id: "void_ascendancy", - name: "Void Ascendancy", - description: "Transcend mortal limits through void energy. All income x3.", - target: "global", - multiplier: 3, - costGold: 0, - costEssence: 0, - costCrystals: 10_000_000, - purchased: false, - unlocked: false, - }, - { - id: "divine_harmony", - name: "Divine Harmony", - description: "Perfect harmony with celestial forces amplifies all output. All income x2.5.", - target: "global", - multiplier: 2.5, - costGold: 1_000_000_000_000_000, - costEssence: 500_000_000_000, - costCrystals: 0, - purchased: false, - unlocked: false, - }, - { - id: "infernal_fury", - name: "Infernal Fury", - description: "Channel infernal rage into production. All income doubled.", - target: "global", + costEssence: 0, + costGold: 0, + description: + "A pact with the denizens of the deepest trench. All income doubled.", + id: "abyssal_pact", multiplier: 2, - costGold: 0, - costEssence: 0, + name: "Abyssal Pact", + purchased: false, + target: "global", + unlocked: false, + }, + { + costCrystals: 0, + costEssence: 100_000_000_000, + costGold: 50_000_000_000_000, + description: + "The celestials themselves decree your guild's dominion over all realms. All income x3.", + id: "celestial_mandate", + multiplier: 3, + name: "Celestial Mandate", + purchased: false, + target: "global", + unlocked: false, + }, + { + costCrystals: 10_000_000, + costEssence: 0, + costGold: 0, + description: "Transcend mortal limits through void energy. All income x3.", + id: "void_ascendancy", + multiplier: 3, + name: "Void Ascendancy", + purchased: false, + target: "global", + unlocked: false, + }, + { + costCrystals: 0, + costEssence: 500_000_000_000, + costGold: 1_000_000_000_000_000, + description: + "Perfect harmony with celestial forces amplifies all output. All income x2.5.", + id: "divine_harmony", + multiplier: 2.5, + name: "Divine Harmony", + purchased: false, + target: "global", + unlocked: false, + }, + { costCrystals: 50_000_000, - purchased: false, - unlocked: false, + costEssence: 0, + costGold: 0, + description: "Channel infernal rage into production. All income doubled.", + id: "infernal_fury", + multiplier: 2, + name: "Infernal Fury", + purchased: false, + target: "global", + unlocked: false, }, // ── Purchasable essence/crystal sink upgrades ───────────────────────────── { - id: "essence_nexus", - name: "Essence Nexus", - description: "Tap into a vast network of essence flows. All income +50%.", - target: "global", - multiplier: 1.5, - costGold: 0, - costEssence: 5_000_000, costCrystals: 0, - purchased: false, - unlocked: true, + costEssence: 5_000_000, + costGold: 0, + description: "Tap into a vast network of essence flows. All income +50%.", + id: "essence_nexus", + multiplier: 1.5, + name: "Essence Nexus", + purchased: false, + target: "global", + unlocked: true, }, { - id: "essence_overdrive", - name: "Essence Overdrive", - description: "Flood your guild's operations with raw essence power. All income doubled.", - target: "global", + costCrystals: 0, + costEssence: 50_000_000, + costGold: 0, + description: + "Flood your guild's operations with raw essence power. All income doubled.", + id: "essence_overdrive", multiplier: 2, - costGold: 0, - costEssence: 50_000_000, - costCrystals: 0, - purchased: false, - unlocked: true, + name: "Essence Overdrive", + purchased: false, + target: "global", + unlocked: true, }, { - id: "primal_essence", - name: "Primal Essence", - description: "Harness the oldest essence in existence. All income x3.", - target: "global", - multiplier: 3, - costGold: 0, - costEssence: 500_000_000, costCrystals: 0, - purchased: false, - unlocked: true, + costEssence: 500_000_000, + costGold: 0, + description: "Harness the oldest essence in existence. All income x3.", + id: "primal_essence", + multiplier: 3, + name: "Primal Essence", + purchased: false, + target: "global", + unlocked: true, }, { - id: "crystal_overdrive", - name: "Crystal Overdrive", - description: "Push crystal resonance beyond its limits. All income doubled.", - target: "global", - multiplier: 2, - costGold: 0, - costEssence: 0, costCrystals: 50_000_000, - purchased: false, - unlocked: true, + costEssence: 0, + costGold: 0, + description: + "Push crystal resonance beyond its limits. All income doubled.", + id: "crystal_overdrive", + multiplier: 2, + name: "Crystal Overdrive", + purchased: false, + target: "global", + unlocked: true, }, { - id: "eternal_bond", - name: "Eternal Bond", - description: "Forge an eternal pact that triples all income permanently.", - target: "global", - multiplier: 3, - costGold: 0, - costEssence: 0, costCrystals: 200_000_000, - purchased: false, - unlocked: true, + costEssence: 0, + costGold: 0, + description: "Forge an eternal pact that triples all income permanently.", + id: "eternal_bond", + multiplier: 3, + name: "Eternal Bond", + purchased: false, + target: "global", + unlocked: true, }, { - id: "apex_mandate", - name: "Apex Mandate", - description: "The supreme decree from the Eternal Throne itself. All income x5.", - target: "global", - multiplier: 5, - costGold: 0, - costEssence: 0, costCrystals: 1_000_000_000, - purchased: false, - unlocked: true, + costEssence: 0, + costGold: 0, + description: + "The supreme decree from the Eternal Throne itself. All income x5.", + id: "apex_mandate", + multiplier: 5, + name: "Apex Mandate", + purchased: false, + target: "global", + unlocked: true, }, // ── New adventurer upgrades ─────────────────────────────────────────────── { - id: "seraph_knight_1", - name: "Seraphic Wings", - description: "Seraph knights gain divine flight, doubling their effectiveness.", - target: "adventurer", adventurerId: "seraph_knight", - multiplier: 2, - costGold: 0, - costEssence: 10_000_000, costCrystals: 0, - purchased: false, - unlocked: false, + costEssence: 10_000_000, + costGold: 0, + description: + "Seraph knights gain divine flight, doubling their effectiveness.", + id: "seraph_knight_1", + multiplier: 2, + name: "Seraphic Wings", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "abyss_diver_1", - name: "Pressure Adaptation", - description: "Full adaptation to abyssal pressure doubles diver effectiveness.", - target: "adventurer", adventurerId: "abyss_diver", - multiplier: 2, - costGold: 0, - costEssence: 25_000_000, costCrystals: 0, - purchased: false, - unlocked: false, + costEssence: 25_000_000, + costGold: 0, + description: + "Full adaptation to abyssal pressure doubles diver effectiveness.", + id: "abyss_diver_1", + multiplier: 2, + name: "Pressure Adaptation", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "infernal_warden_1", - name: "Infernal Tempering", - description: "Tempered in hellfire itself, warden effectiveness is doubled.", - target: "adventurer", adventurerId: "infernal_warden", - multiplier: 2, - costGold: 0, - costEssence: 0, costCrystals: 2_000_000, - purchased: false, - unlocked: false, + costEssence: 0, + costGold: 0, + description: + "Tempered in hellfire itself, warden effectiveness is doubled.", + id: "infernal_warden_1", + multiplier: 2, + name: "Infernal Tempering", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "crystal_sage_1", - name: "Prismatic Mastery", - description: "Complete mastery of prismatic crystallomancy doubles sage output.", - target: "adventurer", adventurerId: "crystal_sage", - multiplier: 2, - costGold: 0, - costEssence: 0, costCrystals: 5_000_000, - purchased: false, - unlocked: false, + costEssence: 0, + costGold: 0, + description: + "Complete mastery of prismatic crystallomancy doubles sage output.", + id: "crystal_sage_1", + multiplier: 2, + name: "Prismatic Mastery", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "void_sentinel_1", - name: "Void Resonance", - description: "Perfect resonance with the void doubles sentinel effectiveness.", - target: "adventurer", adventurerId: "void_sentinel", - multiplier: 2, - costGold: 0, - costEssence: 0, costCrystals: 15_000_000, - purchased: false, - unlocked: false, + costEssence: 0, + costGold: 0, + description: + "Perfect resonance with the void doubles sentinel effectiveness.", + id: "void_sentinel_1", + multiplier: 2, + name: "Void Resonance", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "eternal_champion_1", - name: "Eternal Oath", - description: "An oath that transcends time itself doubles champion output.", - target: "adventurer", adventurerId: "eternal_champion", - multiplier: 2, - costGold: 0, - costEssence: 0, costCrystals: 50_000_000, - purchased: false, - unlocked: false, + costEssence: 0, + costGold: 0, + description: "An oath that transcends time itself doubles champion output.", + id: "eternal_champion_1", + multiplier: 2, + name: "Eternal Oath", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "aether_weaver_1", - name: "Aetheric Mastery", - description: "Complete mastery of aetheric forces doubles the weaver's output.", - target: "adventurer", adventurerId: "aether_weaver", - multiplier: 2, - costGold: 0, - costEssence: 0, costCrystals: 200_000_000, - purchased: false, - unlocked: false, + costEssence: 0, + costGold: 0, + description: + "Complete mastery of aetheric forces doubles the weaver's output.", + id: "aether_weaver_1", + multiplier: 2, + name: "Aetheric Mastery", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "titan_warrior_1", - name: "Titanic Fury", - description: "The fury of a titan unleashed — warrior effectiveness doubled.", - target: "adventurer", adventurerId: "titan_warrior", - multiplier: 2, - costGold: 0, - costEssence: 0, costCrystals: 700_000_000, - purchased: false, - unlocked: false, + costEssence: 0, + costGold: 0, + description: + "The fury of a titan unleashed — warrior effectiveness doubled.", + id: "titan_warrior_1", + multiplier: 2, + name: "Titanic Fury", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "nexus_sage_1", - name: "Nexus Convergence", - description: "The sage converges all ley lines through their body — output doubled.", - target: "adventurer", adventurerId: "nexus_sage", - multiplier: 2, - costGold: 0, - costEssence: 0, costCrystals: 2_500_000_000, - purchased: false, - unlocked: false, + costEssence: 0, + costGold: 0, + description: + "The sage converges all ley lines through their body — output doubled.", + id: "nexus_sage_1", + multiplier: 2, + name: "Nexus Convergence", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "cosmos_knight_1", - name: "Cosmic Tempering", - description: "Tempered by the heat of dying stars, the knight's effectiveness is doubled.", - target: "adventurer", adventurerId: "cosmos_knight", - multiplier: 2, - costGold: 0, - costEssence: 0, costCrystals: 9_000_000_000, - purchased: false, - unlocked: false, + costEssence: 0, + costGold: 0, + description: + "Tempered by the heat of dying stars, the knight's effectiveness is doubled.", + id: "cosmos_knight_1", + multiplier: 2, + name: "Cosmic Tempering", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "astral_sovereign_1", - name: "Sovereign Ascension", - description: "Ascension to true sovereignty over the astral plane doubles output.", - target: "adventurer", adventurerId: "astral_sovereign", - multiplier: 2, - costGold: 0, - costEssence: 0, costCrystals: 3e10, - purchased: false, - unlocked: false, + costEssence: 0, + costGold: 0, + description: + "Ascension to true sovereignty over the astral plane doubles output.", + id: "astral_sovereign_1", + multiplier: 2, + name: "Sovereign Ascension", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "primordial_mage_1", - name: "Primordial Awakening", - description: "Awakening of the mage's primordial heritage doubles their power.", - target: "adventurer", adventurerId: "primordial_mage", - multiplier: 2, - costGold: 0, - costEssence: 0, costCrystals: 1e11, - purchased: false, - unlocked: false, + costEssence: 0, + costGold: 0, + description: + "Awakening of the mage's primordial heritage doubles their power.", + id: "primordial_mage_1", + multiplier: 2, + name: "Primordial Awakening", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "reality_warden_1", - name: "Reality Binding", - description: "The warden binds themselves to the structure of reality — effectiveness doubled.", - target: "adventurer", adventurerId: "reality_warden", - multiplier: 2, - costGold: 0, - costEssence: 0, costCrystals: 4e11, - purchased: false, - unlocked: false, + costEssence: 0, + costGold: 0, + description: + "The warden binds themselves to the structure of reality — effectiveness doubled.", + id: "reality_warden_1", + multiplier: 2, + name: "Reality Binding", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "infinity_ranger_1", - name: "Infinite Aim", - description: "The ranger's arrows travel through infinity itself — output doubled.", - target: "adventurer", adventurerId: "infinity_ranger", - multiplier: 2, - costGold: 0, - costEssence: 0, costCrystals: 1.5e12, - purchased: false, - unlocked: false, + costEssence: 0, + costGold: 0, + description: + "The ranger's arrows travel through infinity itself — output doubled.", + id: "infinity_ranger_1", + multiplier: 2, + name: "Infinite Aim", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "oblivion_paladin_1", - name: "Oblivion Consecration", - description: "Consecrated by the void between all things — paladin effectiveness doubled.", - target: "adventurer", adventurerId: "oblivion_paladin", - multiplier: 2, - costGold: 0, - costEssence: 0, costCrystals: 5e12, - purchased: false, - unlocked: false, + costEssence: 0, + costGold: 0, + description: + "Consecrated by the void between all things — paladin effectiveness doubled.", + id: "oblivion_paladin_1", + multiplier: 2, + name: "Oblivion Consecration", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "transcendent_rogue_1", - name: "Transcendent Shadow", - description: "The rogue becomes one with the space between states — effectiveness doubled.", - target: "adventurer", adventurerId: "transcendent_rogue", - multiplier: 2, - costGold: 0, - costEssence: 0, costCrystals: 2e13, - purchased: false, - unlocked: false, + costEssence: 0, + costGold: 0, + description: + "The rogue becomes one with the space between states — effectiveness doubled.", + id: "transcendent_rogue_1", + multiplier: 2, + name: "Transcendent Shadow", + purchased: false, + target: "adventurer", + unlocked: false, }, { - id: "omniversal_champion_1", - name: "Omniversal Dominion", - description: "Dominion over all versions of all universes — champion output doubled.", - target: "adventurer", adventurerId: "omniversal_champion", - multiplier: 2, - costGold: 0, - costEssence: 0, costCrystals: 8e13, - purchased: false, - unlocked: false, + costEssence: 0, + costGold: 0, + description: + "Dominion over all versions of all universes — champion output doubled.", + id: "omniversal_champion_1", + multiplier: 2, + name: "Omniversal Dominion", + purchased: false, + target: "adventurer", + unlocked: false, }, ]; diff --git a/apps/api/src/data/zones.ts b/apps/api/src/data/zones.ts index 8438083..3c49c8d 100644 --- a/apps/api/src/data/zones.ts +++ b/apps/api/src/data/zones.ts @@ -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", }, ]; diff --git a/apps/api/src/db/client.ts b/apps/api/src/db/client.ts index 901f3a0..1b515fc 100644 --- a/apps/api/src/db/client.ts +++ b/apps/api/src/db/client.ts @@ -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(); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index ee74207..7deb4ec 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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`); }); diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index 4fec8e6..279e74f 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -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(); }; diff --git a/apps/api/src/routes/about.ts b/apps/api/src/routes/about.ts index f64e372..0e74861 100644 --- a/apps/api/src/routes/about.ts +++ b/apps/api/src/routes/about.ts @@ -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 }; diff --git a/apps/api/src/routes/apotheosis.ts b/apps/api/src/routes/apotheosis.ts index a16543e..53630c2 100644 --- a/apps/api/src/routes/apotheosis.ts +++ b/apps/api/src/routes/apotheosis.ts @@ -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 }; diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 12055d3..a820936 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -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 }; diff --git a/apps/api/src/routes/boss.ts b/apps/api/src/routes/boss.ts index 08915f0..d62614a 100644 --- a/apps/api/src/routes/boss.ts +++ b/apps/api/src/routes/boss.ts @@ -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 }; diff --git a/apps/api/src/routes/craft.ts b/apps/api/src/routes/craft.ts index 35411b5..0092c77 100644 --- a/apps/api/src/routes/craft.ts +++ b/apps/api/src/routes/craft.ts @@ -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 }; diff --git a/apps/api/src/routes/explore.ts b/apps/api/src/routes/explore.ts index 55f95d7..f755472 100644 --- a/apps/api/src/routes/explore.ts +++ b/apps/api/src/routes/explore.ts @@ -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 }; diff --git a/apps/api/src/routes/game.ts b/apps/api/src/routes/game.ts index e19ad57..334a37a 100644 --- a/apps/api/src/routes/game.ts +++ b/apps/api/src/routes/game.ts @@ -1,198 +1,330 @@ -import type { GameState, LoginBonusResult, SaveRequest } from "@elysium/types"; -import { computeSetBonuses, computeUnlockedCompanionIds, getActiveCompanionBonus } from "@elysium/types"; +/** + * @file Game routes handling save/load mechanics, daily bonuses, and anti-cheat validation. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines -- Game route has many validation steps */ +/* 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 { createHmac } from "node:crypto"; +import { + computeSetBonuses, + computeUnlockedCompanionIds, + getActiveCompanionBonus, + type GameState, + type LoginBonusResult, + type SaveRequest, +} 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 { initialGameState } from "../data/initialState.js"; +import { dailyRewards } from "../data/loginBonus.js"; +import { defaultQuests } from "../data/quests.js"; +import { currentSchemaVersion } from "../data/schemaVersion.js"; import { prisma } from "../db/client.js"; -import { DEFAULT_BOSSES } from "../data/bosses.js"; -import { DEFAULT_EQUIPMENT_SETS } from "../data/equipmentSets.js"; -import { INITIAL_GAME_STATE } from "../data/initialState.js"; -import { DAILY_REWARDS } from "../data/loginBonus.js"; -import { DEFAULT_QUESTS } from "../data/quests.js"; -import { CURRENT_SCHEMA_VERSION } from "../data/schemaVersion.js"; import { authMiddleware } from "../middleware/auth.js"; import { getOrResetDailyChallenges } from "../services/dailyChallenges.js"; import { calculateOfflineEarnings } from "../services/offlineProgress.js"; -import { checkAndUnlockTitles, parseUnlockedTitles } from "../services/titles.js"; +import { + checkAndUnlockTitles, + parseUnlockedTitles, +} from "../services/titles.js"; +import type { HonoEnvironment } from "../types/hono.js"; -const RESOURCE_CAP = 1e300; +const resourceCap = 1e300; -/** Maximum elapsed seconds credited for passive income — mirrors the offline earnings cap. */ -const ELAPSED_CAP_SECONDS = 8 * 3600; +/** + * Maximum elapsed seconds credited for passive income — mirrors the offline earnings cap. + */ +const elapsedCapSeconds = 8 * 3600; /** * Multiplier applied to passive income when computing the maximum legitimate gold/essence * increase per save. The 2× buffer covers mid-session purchases (adventurers, upgrades) * that increase income beyond what the previous DB snapshot can predict. */ -const INCOME_BUFFER_MULTIPLIER = 2; +const incomeBufferMultiplier = 2; -/** Generous clicks-per-second estimate used to bound click income between saves. */ -const CLICK_BUFFER_CPS = 10; +/** + * Generous clicks-per-second estimate used to bound click income between saves. + */ +const clickBufferCps = 10; -/** 60-second grace period when checking whether a quest timer has expired. */ -const QUEST_GRACE_MS = 60_000; +/** + * 60-second grace period when checking whether a quest timer has expired. + */ +const questGraceMs = 60_000; -const computeHmac = (data: string, secret: string): string => - createHmac("sha256", secret).update(data).digest("hex"); +/** + * Computes the HMAC-SHA256 of data using the given secret. + * @param data - The data string to sign. + * @param secret - The HMAC secret key. + * @returns The hex-encoded HMAC digest. + */ +const computeHmac = (data: string, secret: string): string => { + return createHmac("sha256", secret).update(data). + digest("hex"); +}; /** * Calculates the maximum passive gold and essence income per second from the given state, * using the same formula as applyTick in tick.ts. Must be kept in sync with that function. + * @param state - The current game state to compute income for. + * @returns An object with goldPerSecond and essencePerSecond values. */ const computeMaxPassiveIncome = ( state: GameState, ): { goldPerSecond: number; essencePerSecond: number } => { - /* v8 ignore next -- @preserve */ - const equippedItems = (state.equipment ?? []).filter((e) => e.equipped); - /* v8 ignore next 4 -- @preserve */ - const equipmentGoldMultiplier = equippedItems.reduce( - (mult, e) => mult * (e.bonus.goldMultiplier ?? 1), - 1, - ); - const equippedItemIds = equippedItems.map((e) => e.id); - const setGoldMultiplier = computeSetBonuses(equippedItemIds, DEFAULT_EQUIPMENT_SETS).goldMultiplier; - /* v8 ignore next 4 -- @preserve */ + // Gather equipped items and compute multipliers + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 11 -- @preserve */ + const equippedItems = state.equipment.filter((item) => { + return item.equipped; + }); + let equipmentGoldMultiplier = 1; + for (const item of equippedItems) { + const goldMult = item.bonus.goldMultiplier ?? 1; + equipmentGoldMultiplier = equipmentGoldMultiplier * goldMult; + } + const equippedItemIds = equippedItems.map((item) => { + return item.id; + }); + const setGoldMultiplier = computeSetBonuses( + equippedItemIds, + defaultEquipmentSets, + ).goldMultiplier; + + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 5 -- @preserve */ const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1; const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1; const craftedGoldMultiplier = state.exploration?.craftedGoldMultiplier ?? 1; - const craftedEssenceMultiplier = state.exploration?.craftedEssenceMultiplier ?? 1; + const craftedEssenceMultiplier + = state.exploration?.craftedEssenceMultiplier ?? 1; - /* v8 ignore next 5 -- @preserve */ + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 8 -- @preserve */ const companionBonus = getActiveCompanionBonus( state.companions?.activeCompanionId, state.companions?.unlockedCompanionIds ?? [], ); - const companionGoldMult = companionBonus?.type === "passiveGold" ? 1 + companionBonus.value : 1; - /* v8 ignore next -- @preserve */ - const companionEssenceMult = companionBonus?.type === "essenceIncome" ? 1 + companionBonus.value : 1; + const companionGoldMult + = companionBonus?.type === "passiveGold" + ? 1 + companionBonus.value + : 1; + + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 4 -- @preserve */ + const companionEssenceMult + = companionBonus?.type === "essenceIncome" + ? 1 + companionBonus.value + : 1; let goldPerSecond = 0; let essencePerSecond = 0; for (const adventurer of state.adventurers) { - /* v8 ignore next -- @preserve */ - if (!adventurer.unlocked || adventurer.count === 0) continue; + // Skip the comment line and use a block-comment-safe pattern + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 3 -- @preserve */ + if (!adventurer.unlocked || adventurer.count === 0) { + continue; + } - /* v8 ignore next 8 -- @preserve */ - 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); + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 10 -- @preserve */ + let upgradeMultiplier = 1; + for (const upgrade of state.upgrades) { + const isGlobal = upgrade.purchased && upgrade.target === "global"; + const isThisAdventurer + = upgrade.purchased + && upgrade.target === "adventurer" + && upgrade.adventurerId === adventurer.id; + if (isGlobal || isThisAdventurer) { + upgradeMultiplier = upgradeMultiplier * upgrade.multiplier; + } + } const prestige = state.prestige.productionMultiplier; - goldPerSecond += - adventurer.goldPerSecond * - adventurer.count * - upgradeMultiplier * - prestige * - runestonesIncome * - equipmentGoldMultiplier * - setGoldMultiplier * - craftedGoldMultiplier; + const goldContribution + = adventurer.goldPerSecond + * adventurer.count + * upgradeMultiplier + * prestige + * runestonesIncome + * equipmentGoldMultiplier + * setGoldMultiplier + * craftedGoldMultiplier; + goldPerSecond = goldPerSecond + goldContribution; - essencePerSecond += - adventurer.essencePerSecond * - adventurer.count * - upgradeMultiplier * - prestige * - runestonesEssence * - craftedEssenceMultiplier; + const essenceContribution + = adventurer.essencePerSecond + * adventurer.count + * upgradeMultiplier + * prestige + * runestonesEssence + * craftedEssenceMultiplier; + essencePerSecond = essencePerSecond + essenceContribution; } - return { goldPerSecond: goldPerSecond * companionGoldMult, essencePerSecond: essencePerSecond * companionEssenceMult }; + return { + essencePerSecond: essencePerSecond * companionEssenceMult, + goldPerSecond: goldPerSecond * companionGoldMult, + }; }; /** * Calculates the maximum gold a player could earn per second via clicking. * Mirrors calculateClickPower from tick.ts — must be kept in sync with that function. - * Uses CLICK_BUFFER_CPS as a generous upper bound on clicks per second. + * Uses clickBufferCps as a generous upper bound on clicks per second. + * @param state - The current game state to compute click income for. + * @returns The maximum gold per second from clicking. */ const computeMaxClickGoldPerSecond = (state: GameState): number => { - const clickMultiplier = state.upgrades - .filter((u) => u.purchased && u.target === "click") - .reduce((mult, u) => mult * u.multiplier, 1); + let clickMultiplier = 1; + for (const upgrade of state.upgrades) { + if (upgrade.purchased && upgrade.target === "click") { + clickMultiplier = clickMultiplier * upgrade.multiplier; + } + } - /* v8 ignore next -- @preserve */ - const equippedItems = (state.equipment ?? []).filter((e) => e.equipped); - const equipmentClickMultiplier = equippedItems - .filter((e) => e.bonus.clickMultiplier != null) - .reduce((mult, e) => mult * (e.bonus.clickMultiplier ?? 1), 1); - const setClickMultiplier = computeSetBonuses(equippedItems.map((e) => e.id), DEFAULT_EQUIPMENT_SETS).clickMultiplier; + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 16 -- @preserve */ + const equippedItems = state.equipment.filter((item) => { + return item.equipped; + }); + let equipmentClickMultiplier = 1; + for (const item of equippedItems) { + if (item.bonus.clickMultiplier !== undefined) { + equipmentClickMultiplier + = equipmentClickMultiplier * item.bonus.clickMultiplier; + } + } + const setClickMultiplier = computeSetBonuses( + equippedItems.map((item) => { + return item.id; + }), + defaultEquipmentSets, + ).clickMultiplier; const runestonesClick = state.prestige.runestonesClickMultiplier ?? 1; const companionBonus = getActiveCompanionBonus( state.companions?.activeCompanionId, + + // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ state.companions?.unlockedCompanionIds ?? [], ); - /* v8 ignore next -- @preserve */ - const companionClickMult = companionBonus?.type === "clickGold" ? 1 + companionBonus.value : 1; - const clickPower = - state.baseClickPower * - clickMultiplier * - state.prestige.productionMultiplier * - runestonesClick * - equipmentClickMultiplier * - setClickMultiplier * - companionClickMult; + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 4 -- @preserve */ + const companionClickMult + = companionBonus?.type === "clickGold" + ? 1 + companionBonus.value + : 1; - return clickPower * CLICK_BUFFER_CPS; + const clickPower + = state.baseClickPower + * clickMultiplier + * state.prestige.productionMultiplier + * runestonesClick + * equipmentClickMultiplier + * setClickMultiplier + * companionClickMult; + + return clickPower * clickBufferCps; }; +/** + * Options for the computeQuestRewards function. + */ +interface QuestRewardOptions { + incoming: GameState; + previous: GameState; + now: number; + questTimeReduction: number; +} + /** * Sums the gold and essence rewards for quests that legitimately completed during * this save interval. A quest is eligible when: - * - It was "active" in the previous (DB-trusted) state, and - * - Its timer has genuinely expired by the current server time (plus QUEST_GRACE_MS), and - * - It is now "completed" in the incoming state. + * - It was "active" in the previous (DB-trusted) state, and + * - Its timer has genuinely expired by the current server time (plus questGraceMs), and + * - It is now "completed" in the incoming state. * - * Reward amounts and durations are taken from DEFAULT_QUESTS (authoritative game data) + * Reward amounts and durations are taken from defaultQuests (authoritative game data) * to prevent client-side reward or duration tampering. The questTimeReduction parameter * (0–1 fraction) applies a companion time bonus to the effective duration check. + * @param options - The incoming and previous state, current timestamp, and questTimeReduction. + * @returns An object with gold and essence totals from completed quests. */ const computeQuestRewards = ( - incoming: GameState, - previous: GameState, - now: number, - questTimeReduction: number, + options: QuestRewardOptions, ): { gold: number; essence: number } => { + const { incoming, now, previous, questTimeReduction } = options; let gold = 0; let essence = 0; for (const incomingQuest of incoming.quests) { - if (incomingQuest.status !== "completed") continue; + if (incomingQuest.status !== "completed") { + continue; + } - const prevQuest = previous.quests.find((q) => q.id === incomingQuest.id); - if (!prevQuest || prevQuest.status === "completed") continue; + const previousQuest = previous.quests.find((quest) => { + return quest.id === incomingQuest.id; + }); + if (!previousQuest || previousQuest.status === "completed") { + continue; + } - if (prevQuest.status !== "active" || prevQuest.startedAt == null) continue; + const questNotActive = previousQuest.status !== "active"; + const questNotStarted = previousQuest.startedAt === undefined; + if (questNotActive || questNotStarted) { + continue; + } - // Use authoritative duration from game data so a tampered durationSeconds in the - // saved state cannot cause a timer to appear expired prematurely. - const questData = DEFAULT_QUESTS.find((q) => q.id === incomingQuest.id); - if (!questData) continue; + /* + * Use authoritative duration from game data so a tampered durationSeconds in the + * saved state cannot cause a timer to appear expired prematurely. + */ + const questData = defaultQuests.find((quest) => { + return quest.id === incomingQuest.id; + }); + if (!questData) { + continue; + } // Apply companion quest-time reduction to the effective duration check. - const effectiveDuration = questData.durationSeconds * (1 - questTimeReduction); - /* v8 ignore next -- @preserve */ - if (prevQuest.startedAt + effectiveDuration * 1000 > now + QUEST_GRACE_MS) continue; + const effectiveDuration + = questData.durationSeconds * (1 - questTimeReduction); + const durationMs = effectiveDuration * 1000; + // The questNotStarted guard above ensures startedAt is defined here + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 4 -- @preserve */ + const questExpiresAt = (previousQuest.startedAt ?? 0) + durationMs; + if (questExpiresAt > now + questGraceMs) { + continue; + } + + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 8 -- @preserve */ for (const reward of questData.rewards) { - /* v8 ignore next 2 -- @preserve */ - if (reward.type === "gold" && reward.amount != null) gold += reward.amount; - if (reward.type === "essence" && reward.amount != null) essence += reward.amount; + if (reward.type === "gold" && reward.amount !== undefined) { + gold = gold + reward.amount; + } + if (reward.type === "essence" && reward.amount !== undefined) { + essence = essence + reward.amount; + } } } - return { gold, essence }; + return { essence, gold }; }; /** @@ -203,8 +335,11 @@ const computeQuestRewards = ( * returns zero. It exists solely as a safety buffer for the rare race condition where a * boss DB write and a save request arrive simultaneously, leaving previousState stale. * - * Reward amounts are taken from DEFAULT_BOSSES (authoritative game data) to prevent + * Reward amounts are taken from defaultBosses (authoritative game data) to prevent * client-side reward tampering. + * @param incoming - The incoming game state from the client. + * @param previous - The previous trusted game state from the database. + * @returns An object with gold and essence totals from newly defeated bosses. */ const computeBossRewards = ( incoming: GameState, @@ -214,219 +349,341 @@ const computeBossRewards = ( let essence = 0; for (const incomingBoss of incoming.bosses) { - if (incomingBoss.status !== "defeated") continue; + if (incomingBoss.status !== "defeated") { + continue; + } - const prevBoss = previous.bosses.find((b) => b.id === incomingBoss.id); - if (!prevBoss || prevBoss.status === "defeated") continue; + const previousBoss = previous.bosses.find((boss) => { + return boss.id === incomingBoss.id; + }); + if (!previousBoss || previousBoss.status === "defeated") { + continue; + } - // Only credit bosses that were actually challengeable in the previous state, - // ruling out bosses that somehow skipped the server-authoritative fight flow. - if (prevBoss.status !== "available" && prevBoss.status !== "in_progress") continue; + /* + * Only credit bosses that were actually challengeable in the previous state, + * ruling out bosses that somehow skipped the server-authoritative fight flow. + */ + if ( + previousBoss.status !== "available" + && previousBoss.status !== "in_progress" + ) { + continue; + } - const bossData = DEFAULT_BOSSES.find((b) => b.id === incomingBoss.id); - if (!bossData) continue; + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 9 -- @preserve */ + const bossData = defaultBosses.find((boss) => { + return boss.id === incomingBoss.id; + }); + if (!bossData) { + continue; + } - gold += bossData.goldReward; - essence += bossData.essenceReward; + gold = gold + bossData.goldReward; + essence = essence + bossData.essenceReward; } - return { gold, essence }; + return { essence, gold }; }; /** * Validates the incoming state against the previous saved state and returns a * sanitised copy. Protects against: - * - Gold or essence exceeding what could legitimately be earned since the last save - * - Resources exceeding the absolute cap - * - Runestones increasing between saves (only granted server-side via prestige) - * - Defeating a boss being reversed - * - Completing a quest being reversed - * - Unlocking an achievement being reversed or backdated to a future timestamp - * - Prestige count going backwards + * - Gold or essence exceeding what could legitimately be earned since the last save + * - Resources exceeding the absolute cap + * - Runestones increasing between saves (only granted server-side via prestige) + * - Defeating a boss being reversed + * - Completing a quest being reversed + * - Unlocking an achievement being reversed or backdated to a future timestamp + * - Prestige count going backwards. + * @param incoming - The incoming game state from the client. + * @param previous - The previous trusted game state from the database. + * @returns The sanitised game state. */ -const validateAndSanitize = (incoming: GameState, previous: GameState): GameState => { +const validateAndSanitize = ( + incoming: GameState, + previous: GameState, +): GameState => { const now = Date.now(); - // Elapsed seconds since the last trusted tick, capped at 8 hours to match the - // offline earnings cap and prevent a stale lastTickAt from inflating the allowance. - // Falls back to 30 s for old saves that predate the lastTickAt field. - /* v8 ignore next -- @preserve */ - const rawElapsed = previous.lastTickAt > 0 ? (now - previous.lastTickAt) / 1000 : 30; - const elapsedSeconds = Math.max(0, Math.min(rawElapsed, ELAPSED_CAP_SECONDS)); + /* + * Elapsed seconds since the last trusted tick, capped at 8 hours to match the + * offline earnings cap and prevent a stale lastTickAt from inflating the allowance. + * Falls back to 30 s for old saves that predate the lastTickAt field. + */ + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 4 -- @preserve */ + const rawElapsed + = previous.lastTickAt > 0 + ? (now - previous.lastTickAt) / 1000 + : 30; + const elapsedSeconds = Math.max(0, Math.min(rawElapsed, elapsedCapSeconds)); // Per-second income rates from the previous (DB-trusted) state. const { goldPerSecond, essencePerSecond } = computeMaxPassiveIncome(previous); const clickGoldPerSecond = computeMaxClickGoldPerSecond(previous); // Determine quest-time reduction from the companion active in the previous (trusted) state. + // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 4 -- @preserve */ - const prevCompanionBonus = getActiveCompanionBonus( + const previousCompanionBonus = getActiveCompanionBonus( previous.companions?.activeCompanionId, previous.companions?.unlockedCompanionIds ?? [], ); - /* v8 ignore next -- @preserve */ - const questTimeReduction = prevCompanionBonus?.type === "questTime" ? prevCompanionBonus.value : 0; + + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 4 -- @preserve */ + const questTimeReduction + = previousCompanionBonus?.type === "questTime" + ? previousCompanionBonus.value + : 0; // Precise one-time rewards for events that could have occurred this interval. - const questRewards = computeQuestRewards(incoming, previous, now, questTimeReduction); + const questRewards = computeQuestRewards({ + incoming, + now, + previous, + questTimeReduction, + }); const bossRewards = computeBossRewards(incoming, previous); - // Passive and click income receive a 2× buffer to cover mid-session adventurer/upgrade - // purchases that raise income beyond what the previous snapshot can predict. - // Quest and boss rewards are exact (sourced from authoritative game data) and need no buffer. - const maxGoldIncrease = - (goldPerSecond + clickGoldPerSecond) * elapsedSeconds * INCOME_BUFFER_MULTIPLIER + - questRewards.gold + - bossRewards.gold; + /* + * Passive and click income receive a 2× buffer to cover mid-session adventurer/upgrade + * purchases that raise income beyond what the previous snapshot can predict. + * Quest and boss rewards are exact (sourced from authoritative game data) and need no buffer. + */ + const combinedGoldPerSecond = goldPerSecond + clickGoldPerSecond; + const passiveAndClickGold + = combinedGoldPerSecond * elapsedSeconds * incomeBufferMultiplier; + const maxGoldIncrease + = passiveAndClickGold + questRewards.gold + bossRewards.gold; - const maxEssenceIncrease = - essencePerSecond * elapsedSeconds * INCOME_BUFFER_MULTIPLIER + - questRewards.essence + - bossRewards.essence; + const passiveEssence + = essencePerSecond * elapsedSeconds * incomeBufferMultiplier; + const maxEssenceIncrease + = passiveEssence + questRewards.essence + bossRewards.essence; const resources = { + crystals: Math.min(incoming.resources.crystals, resourceCap), + essence: Math.min( + incoming.resources.essence, + previous.resources.essence + maxEssenceIncrease, + resourceCap, + ), gold: Math.min( incoming.resources.gold, previous.resources.gold + maxGoldIncrease, - RESOURCE_CAP, + resourceCap, ), - essence: Math.min( - incoming.resources.essence, - previous.resources.essence + maxEssenceIncrease, - RESOURCE_CAP, + + /* + * Runestones are only granted server-side via prestige and can only decrease between + * saves (spent on prestige upgrades via the buy-upgrade endpoint). Cap at the previous + * value to block client-side inflation. + */ + runestones: Math.min( + incoming.resources.runestones, + previous.resources.runestones, ), - crystals: Math.min(incoming.resources.crystals, RESOURCE_CAP), - // Runestones are only granted server-side via prestige and can only decrease between - // saves (spent on prestige upgrades via the buy-upgrade endpoint). Cap at the previous - // value to block client-side inflation. - runestones: Math.min(incoming.resources.runestones, previous.resources.runestones), }; - const bosses = incoming.bosses.map((b) => { - const prev = previous.bosses.find((p) => p.id === b.id); - if (!prev) return b; - if (prev.status === "defeated" && b.status !== "defeated") { - return { ...b, status: "defeated" as const, currentHp: 0 }; + const bosses = incoming.bosses.map((boss) => { + const matchingBoss = previous.bosses.find((storedBoss) => { + return storedBoss.id === boss.id; + }); + if (!matchingBoss) { + return boss; } - return b; + if (matchingBoss.status === "defeated" && boss.status !== "defeated") { + return { ...boss, currentHp: 0, status: "defeated" as const }; + } + return boss; }); - const quests = incoming.quests.map((q) => { - const prev = previous.quests.find((p) => p.id === q.id); - if (!prev) return q; - if (prev.status === "completed" && q.status !== "completed") { - return { ...prev }; + const quests = incoming.quests.map((quest) => { + const matchingQuest = previous.quests.find((storedQuest) => { + return storedQuest.id === quest.id; + }); + if (!matchingQuest) { + return quest; } - return q; + if (matchingQuest.status === "completed" && quest.status !== "completed") { + return { ...matchingQuest }; + } + return quest; }); - const achievements = incoming.achievements.map((a) => { - const prev = previous.achievements.find((p) => p.id === a.id); - if (!prev) return a; - if (prev.unlockedAt !== null && a.unlockedAt === null) { - return { ...a, unlockedAt: prev.unlockedAt }; + const achievements = incoming.achievements.map((achievement) => { + const matchingAchievement = previous.achievements.find( + (storedAchievement) => { + return storedAchievement.id === achievement.id; + }, + ); + if (!matchingAchievement) { + return achievement; } - if (a.unlockedAt !== null && a.unlockedAt > now) { - return { ...a, unlockedAt: prev.unlockedAt ?? null }; + const wasUnlocked = matchingAchievement.unlockedAt !== null; + const isNowNull = achievement.unlockedAt === null; + if (wasUnlocked && isNowNull) { + return { ...achievement, unlockedAt: matchingAchievement.unlockedAt }; } - return a; + const isFuture + = achievement.unlockedAt !== null && achievement.unlockedAt > now; + if (isFuture) { + const safeUnlockedAt = matchingAchievement.unlockedAt ?? null; + return { ...achievement, unlockedAt: safeUnlockedAt }; + } + return achievement; }); - /* v8 ignore next 2 -- @preserve */ - const prestige = - incoming.prestige.count < previous.prestige.count ? previous.prestige : incoming.prestige; + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 4 -- @preserve */ + const prestige + = incoming.prestige.count < previous.prestige.count + ? previous.prestige + : incoming.prestige; - // Echoes are only granted server-side via transcendence and can only decrease between - // saves (spent on echo upgrades). Cap at the previous value to block inflation. + /* + * Echoes are only granted server-side via transcendence and can only decrease between + * saves (spent on echo upgrades). Cap at the previous value to block inflation. + */ const cappedEchoes = Math.min( incoming.transcendence?.echoes ?? 0, previous.transcendence?.echoes ?? 0, ); - const transcendenceSpread = incoming.transcendence - ? { transcendence: { ...incoming.transcendence, echoes: cappedEchoes } } - /* v8 ignore next 2 -- @preserve */ - : previous.transcendence - ? { transcendence: previous.transcendence } - : {}; + + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 10 -- @preserve */ + let transcendenceSpread: object = {}; + if (incoming.transcendence) { + transcendenceSpread = { + transcendence: { ...incoming.transcendence, echoes: cappedEchoes }, + }; + } else if (previous.transcendence) { + transcendenceSpread = { transcendence: previous.transcendence }; + } // Apotheosis count can only increase server-side — cap at the previous value. - /* v8 ignore next 6 -- @preserve */ - const apotheosisSpread = incoming.apotheosis - ? { apotheosis: { count: Math.min(incoming.apotheosis.count, previous.apotheosis?.count ?? 0) } } - : previous.apotheosis - ? { apotheosis: previous.apotheosis } - : {}; - - // Exploration: materials and crafted recipes can only be added server-side. - // Cap material quantities and crafted recipe IDs at the previous DB values to block inflation. - // Crafted multipliers are always derived from the previous state (only /craft can change them). - const explorationSpread = (() => { - const prevExploration = previous.exploration; - /* v8 ignore next -- @preserve */ - if (!incoming.exploration) { - /* v8 ignore next 2 -- @preserve */ - return prevExploration ? { exploration: prevExploration } : {}; - } - /* v8 ignore next -- @preserve */ - if (!prevExploration) { - /* v8 ignore next 2 -- @preserve */ - return { exploration: incoming.exploration }; - } - const materials = incoming.exploration.materials.map((m) => { - const prev = prevExploration.materials.find((p) => p.materialId === m.materialId); - /* v8 ignore next -- @preserve */ - return { ...m, quantity: Math.min(m.quantity, prev?.quantity ?? 0) }; - }); - const craftedRecipeIds = incoming.exploration.craftedRecipeIds.filter((id) => - prevExploration.craftedRecipeIds.includes(id), - ); - return { - exploration: { - ...incoming.exploration, - materials, - craftedRecipeIds, - craftedGoldMultiplier: prevExploration.craftedGoldMultiplier, - craftedEssenceMultiplier: prevExploration.craftedEssenceMultiplier, - craftedClickMultiplier: prevExploration.craftedClickMultiplier, - craftedCombatMultiplier: prevExploration.craftedCombatMultiplier, + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 12 -- @preserve */ + let apotheosisSpread: object = {}; + if (incoming.apotheosis) { + apotheosisSpread = { + apotheosis: { + count: Math.min( + incoming.apotheosis.count, + previous.apotheosis?.count ?? 0, + ), }, }; - })(); - - // Story progress: completed chapters can only grow, unlocked IDs can only grow. - // Low cheat risk (no rewards), so we allow all incoming additions. - const storySpread = (() => { - /* v8 ignore next -- @preserve */ - if (!incoming.story) return previous.story ? { story: previous.story } : {}; + } else if (previous.apotheosis) { + // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 2 -- @preserve */ - const prevUnlocked = previous.story?.unlockedChapterIds ?? []; - const prevCompleted = previous.story?.completedChapters ?? []; - // Allow new chapter IDs to be added; never remove existing ones - const unlockedChapterIds = [ - ...prevUnlocked, - ...incoming.story.unlockedChapterIds.filter((id) => !prevUnlocked.includes(id)), - ]; - // Allow new completed chapters; never remove existing ones (one entry per chapter) - const completedChapters = [ - ...prevCompleted, - ...incoming.story.completedChapters.filter( - (c) => !prevCompleted.some((p) => p.chapterId === c.chapterId), - ), - ]; - return { story: { unlockedChapterIds, completedChapters } }; - })(); + apotheosisSpread = { apotheosis: previous.apotheosis }; + } - return { ...incoming, resources, bosses, quests, achievements, prestige, ...transcendenceSpread, ...apotheosisSpread, ...explorationSpread, ...storySpread }; + /* + * Exploration: materials and crafted recipes can only be added server-side. + * Cap material quantities and crafted recipe IDs at the previous DB values to block inflation. + * Crafted multipliers are always derived from the previous state (only /craft can change them). + */ + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 30 -- @preserve */ + let explorationSpread: object = {}; + const previousExploration = previous.exploration; + if (!incoming.exploration && previousExploration) { + explorationSpread = { exploration: previousExploration }; + } else if (incoming.exploration && !previousExploration) { + explorationSpread = { exploration: incoming.exploration }; + } else if (incoming.exploration && previousExploration) { + const previousMaterialMap = new Map( + previousExploration.materials.map((mat) => { + return [ mat.materialId, mat.quantity ] as const; + }), + ); + const materials = incoming.exploration.materials.map((material) => { + const previousQuantity + = previousMaterialMap.get(material.materialId) ?? 0; + const cappedQuantity + = Math.min(material.quantity, previousQuantity); + return { ...material, quantity: cappedQuantity }; + }); + const craftedRecipeIds = incoming.exploration.craftedRecipeIds.filter( + (recipeId) => { + return previousExploration.craftedRecipeIds.includes(recipeId); + }, + ); + explorationSpread = { + exploration: { + ...incoming.exploration, + craftedClickMultiplier: previousExploration.craftedClickMultiplier, + craftedCombatMultiplier: previousExploration.craftedCombatMultiplier, + craftedEssenceMultiplier: previousExploration.craftedEssenceMultiplier, + craftedGoldMultiplier: previousExploration.craftedGoldMultiplier, + craftedRecipeIds: craftedRecipeIds, + materials: materials, + }, + }; + } + + /* + * Story progress: completed chapters can only grow, unlocked IDs can only grow. + * Low cheat risk (no rewards), so we allow all incoming additions. + */ + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 28 -- @preserve */ + let storySpread: object = {}; + if (incoming.story) { + const previousUnlocked = previous.story?.unlockedChapterIds ?? []; + const previousCompleted = previous.story?.completedChapters ?? []; + const unlockedChapterIds = [ + ...previousUnlocked, + ...incoming.story.unlockedChapterIds.filter((id) => { + return !previousUnlocked.includes(id); + }), + ]; + const previousCompletedIds = new Set( + previousCompleted.map((chapter) => { + return chapter.chapterId; + }), + ); + const completedChapters = [ + ...previousCompleted, + ...incoming.story.completedChapters.filter((chapter) => { + return !previousCompletedIds.has(chapter.chapterId); + }), + ]; + const storyValue = { completedChapters, unlockedChapterIds }; + storySpread = { story: storyValue }; + } else if (previous.story) { + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 2 -- @preserve */ + storySpread = { story: previous.story }; + } + + return { + ...incoming, + achievements, + bosses, + prestige, + quests, + resources, + ...transcendenceSpread, + ...apotheosisSpread, + ...explorationSpread, + ...storySpread, + }; }; -export const gameRouter = new Hono<HonoEnv>(); +const gameRouter = new Hono<HonoEnvironment>(); gameRouter.use("*", authMiddleware); -gameRouter.get("/load", async (context) => { - const discordId = context.get("discordId") as string; +gameRouter.get("/load", async(context) => { + const discordId = context.get("discordId"); - const [record, playerRecord] = await Promise.all([ + const [ record, playerRecord ] = await Promise.all([ prisma.gameState.findUnique({ where: { discordId } }), prisma.player.findUnique({ where: { discordId } }), ]); @@ -436,133 +693,209 @@ gameRouter.get("/load", async (context) => { if (!playerRecord) { return context.json({ error: "No player found" }, 404); } - const freshState = INITIAL_GAME_STATE( + const freshState = initialGameState( { - discordId: playerRecord.discordId, - username: playerRecord.username, - discriminator: playerRecord.discriminator, - avatar: playerRecord.avatar, - characterName: playerRecord.characterName, - createdAt: playerRecord.createdAt, - lastSavedAt: Date.now(), - totalGoldEarned: 0, - totalClicks: 0, - lifetimeGoldEarned: playerRecord.lifetimeGoldEarned, - lifetimeClicks: playerRecord.lifetimeClicks, - lifetimeBossesDefeated: playerRecord.lifetimeBossesDefeated, - lifetimeQuestsCompleted: playerRecord.lifetimeQuestsCompleted, - lifetimeAdventurersRecruited: playerRecord.lifetimeAdventurersRecruited, + avatar: playerRecord.avatar, + characterName: playerRecord.characterName, + createdAt: playerRecord.createdAt, + discordId: playerRecord.discordId, + discriminator: playerRecord.discriminator, + lastSavedAt: Date.now(), lifetimeAchievementsUnlocked: playerRecord.lifetimeAchievementsUnlocked, + lifetimeAdventurersRecruited: playerRecord.lifetimeAdventurersRecruited, + lifetimeBossesDefeated: playerRecord.lifetimeBossesDefeated, + lifetimeClicks: playerRecord.lifetimeClicks, + lifetimeGoldEarned: playerRecord.lifetimeGoldEarned, + lifetimeQuestsCompleted: playerRecord.lifetimeQuestsCompleted, + totalClicks: 0, + totalGoldEarned: 0, + username: playerRecord.username, }, playerRecord.characterName, ); const createdAt = Date.now(); await prisma.gameState.create({ - data: { discordId, state: freshState as object, updatedAt: createdAt }, + data: { + discordId: discordId, + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ + state: freshState as object, + updatedAt: createdAt, + }, }); const secret = process.env.ANTI_CHEAT_SECRET; - /* v8 ignore next 2 -- @preserve */ - const signature = secret ? computeHmac(JSON.stringify(freshState), secret) : undefined; - return context.json({ state: freshState, offlineGold: 0, offlineEssence: 0, offlineSeconds: 0, signature, loginBonus: null, loginStreak: playerRecord.loginStreak ?? 1, schemaOutdated: false, currentSchemaVersion: CURRENT_SCHEMA_VERSION }); + + // Sign the state for anti-cheat verification + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 3 -- @preserve */ + const signature = secret === undefined + ? undefined + : computeHmac(JSON.stringify(freshState), secret); + return context.json({ + currentSchemaVersion: currentSchemaVersion, + loginBonus: null, + loginStreak: playerRecord.loginStreak, + offlineEssence: 0, + offlineGold: 0, + offlineSeconds: 0, + schemaOutdated: false, + signature: signature, + state: freshState, + }); } - 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; const now = Date.now(); - const { offlineGold, offlineEssence, offlineSeconds } = calculateOfflineEarnings(state, now); + const { offlineGold, offlineEssence, offlineSeconds } + = calculateOfflineEarnings(state, now); if (offlineGold > 0) { - state.resources.gold += offlineGold; - state.player.totalGoldEarned += offlineGold; + state.resources.gold = state.resources.gold + offlineGold; + state.player.totalGoldEarned = state.player.totalGoldEarned + offlineGold; } if (offlineEssence > 0) { - state.resources.essence += offlineEssence; + state.resources.essence = state.resources.essence + offlineEssence; } // Generate or reset daily challenges if a new day has begun state.dailyChallenges = getOrResetDailyChallenges(state); // Daily login bonus — award once per calendar day (UTC) - const todayUTC = new Date().toISOString().slice(0, 10); - const yesterdayUTC = new Date(now - 86_400_000).toISOString().slice(0, 10); + const todayUTC = new Date().toISOString(). + slice(0, 10); + const yesterdayUTC = new Date(now - 86_400_000).toISOString(). + slice(0, 10); let loginBonus: LoginBonusResult | null = null; + + // Default loginStreak to 1 for brand-new accounts + // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ let loginStreak = playerRecord?.loginStreak ?? 1; if (playerRecord && playerRecord.lastLoginDate !== todayUTC) { - /* v8 ignore next -- @preserve */ - const prevStreak = playerRecord.loginStreak ?? 0; - const newStreak = playerRecord.lastLoginDate === yesterdayUTC ? prevStreak + 1 : 1; - const dayIndex = (newStreak - 1) % 7; - const weekMultiplier = Math.floor((newStreak - 1) / 7) + 1; - const reward = DAILY_REWARDS[dayIndex]; + const previousStreak = playerRecord.loginStreak; + const updatedStreak + = playerRecord.lastLoginDate === yesterdayUTC + ? previousStreak + 1 + : 1; + const dayIndex = (updatedStreak - 1) % 7; + const weekMultiplier = Math.floor((updatedStreak - 1) / 7) + 1; + const reward = dailyRewards[dayIndex]; + + // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 2 -- @preserve */ const goldEarned = (reward?.goldBase ?? 500) * weekMultiplier; - const crystalsEarned = ((reward?.crystals ?? 0) * weekMultiplier); + const crystalsEarned = (reward?.crystals ?? 0) * weekMultiplier; - state.resources.gold = Math.min(state.resources.gold + goldEarned, RESOURCE_CAP); - state.player.totalGoldEarned += goldEarned; - state.resources.crystals = Math.min(state.resources.crystals + crystalsEarned, RESOURCE_CAP); + state.resources.gold = Math.min( + state.resources.gold + goldEarned, + resourceCap, + ); + state.player.totalGoldEarned = state.player.totalGoldEarned + goldEarned; + state.resources.crystals = Math.min( + state.resources.crystals + crystalsEarned, + resourceCap, + ); - loginStreak = newStreak; + loginStreak = updatedStreak; loginBonus = { - streak: newStreak, - goldEarned, - crystalsEarned, - day: dayIndex + 1, - weekMultiplier, + crystalsEarned: crystalsEarned, + day: dayIndex + 1, + goldEarned: goldEarned, + streak: updatedStreak, + weekMultiplier: weekMultiplier, }; - await prisma.player.update({ - where: { discordId }, - data: { lastLoginDate: todayUTC, loginStreak: newStreak }, - }).catch((err: unknown) => { - /* v8 ignore next 2 -- @preserve */ - const code = (err as { code?: string }).code; - if (code !== "P2034") throw err; - }); + await prisma.player. + update({ + data: { lastLoginDate: todayUTC, loginStreak: updatedStreak }, + where: { discordId }, + }). + catch((error: unknown) => { + // Ignore write-conflict errors (P2034) — rethrow anything else + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 5 -- @preserve */ + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma error shape */ + const { code } = error as { code?: string }; + if (code !== "P2034") { + throw error; + } + }); } state.lastTickAt = now; if (offlineGold > 0 || offlineEssence > 0 || loginBonus !== null) { - // Swallow write conflicts (P2034): offline earnings and login bonus are applied - // server-side and must be persisted immediately so they aren't double-counted. - await prisma.gameState.update({ - where: { discordId }, - data: { state: state as object, updatedAt: now }, - }).catch((err: unknown) => { - /* v8 ignore next 2 -- @preserve */ - const code = (err as { code?: string }).code; - if (code !== "P2034") throw err; - }); + // Persist updated state immediately so offline/login rewards aren't double-counted. + /* + * Swallow write conflicts (P2034): offline earnings and login bonus are applied + * server-side and must be persisted immediately so they aren't double-counted. + */ + 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 }, + }). + catch((error: unknown) => { + // Ignore write-conflict errors (P2034) — rethrow anything else + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 5 -- @preserve */ + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma error shape */ + const { code } = error as { code?: string }; + if (code !== "P2034") { + throw error; + } + }); } + // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ - const schemaOutdated = (state.schemaVersion ?? 0) < CURRENT_SCHEMA_VERSION; + const schemaOutdated = (state.schemaVersion ?? 0) < currentSchemaVersion; const secret = process.env.ANTI_CHEAT_SECRET; - const signature = secret ? computeHmac(JSON.stringify(state), secret) : undefined; - return context.json({ state, offlineGold, offlineEssence, offlineSeconds, signature, loginBonus, loginStreak, schemaOutdated, currentSchemaVersion: CURRENT_SCHEMA_VERSION }); + const signature = secret === undefined + ? undefined + : computeHmac(JSON.stringify(state), secret); + return context.json({ + currentSchemaVersion, + loginBonus, + loginStreak, + offlineEssence, + offlineGold, + offlineSeconds, + schemaOutdated, + signature, + state, + }); }); -gameRouter.post("/save", async (context) => { - const discordId = context.get("discordId") as string; +gameRouter.post("/save", async(context) => { + const discordId = context.get("discordId"); const body = await context.req.json<SaveRequest>(); - if (!body.state) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Defensive check for malformed requests + if (body.state === null || body.state === undefined) { return context.json({ error: "Missing state in request body" }, 400); } + // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ - if ((body.state.schemaVersion ?? 0) < CURRENT_SCHEMA_VERSION) { - return context.json({ error: "Save rejected: your save data is outdated. Please reset your progress to enable cloud saves." }, 409); + if ((body.state.schemaVersion ?? 0) < currentSchemaVersion) { + return context.json( + { + error: "Save rejected: outdated save. Reset your progress to continue.", + }, + 409, + ); } const secret = process.env.ANTI_CHEAT_SECRET; - const [record, playerRecord] = await Promise.all([ + const [ record, playerRecord ] = await Promise.all([ prisma.gameState.findUnique({ where: { discordId } }), prisma.player.findUnique({ where: { discordId } }), ]); @@ -570,13 +903,18 @@ gameRouter.post("/save", async (context) => { let stateToSave = body.state; if (record) { - const previousState = record.state as unknown as GameState; + const rawPreviousState: unknown = record.state; + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ + const previousState = rawPreviousState as GameState; // Option D: verify HMAC signature if the secret is configured and client sent one - if (secret && body.signature) { + if (secret !== undefined && body.signature !== undefined) { const expectedSig = computeHmac(JSON.stringify(previousState), secret); if (body.signature !== expectedSig) { - return context.json({ error: "Save rejected: signature mismatch" }, 400); + return context.json( + { error: "Save rejected: signature mismatch" }, + 400, + ); } } @@ -586,108 +924,147 @@ gameRouter.post("/save", async (context) => { const now = Date.now(); - // Stamp the authoritative save timestamp into the state blob so that on the - // next load the client reads the correct value from state.player.lastSavedAt. + /* + * Stamp the authoritative save timestamp into the state blob so that on the + * next load the client reads the correct value from state.player.lastSavedAt. + */ stateToSave = { ...stateToSave, player: { ...stateToSave.player, lastSavedAt: now }, }; - // Recompute companion unlocks server-side using DB-authoritative player lifetime stats. - // This prevents clients from claiming companions they haven't legitimately unlocked. - /* v8 ignore next 4 -- @preserve */ + /* + * Recompute companion unlocks server-side using DB-authoritative player lifetime stats. + * This prevents clients from claiming companions they haven't legitimately unlocked. + */ + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 8 -- @preserve */ const companionUnlocks = computeUnlockedCompanionIds({ - lifetimeBossesDefeated: playerRecord?.lifetimeBossesDefeated ?? 0, + apotheosisCount: stateToSave.apotheosis?.count ?? 0, + lifetimeBossesDefeated: playerRecord?.lifetimeBossesDefeated ?? 0, + lifetimeGoldEarned: playerRecord?.lifetimeGoldEarned ?? 0, lifetimeQuestsCompleted: playerRecord?.lifetimeQuestsCompleted ?? 0, - lifetimeGoldEarned: playerRecord?.lifetimeGoldEarned ?? 0, - prestigeCount: stateToSave.prestige.count, - transcendenceCount: stateToSave.transcendence?.count ?? 0, - apotheosisCount: stateToSave.apotheosis?.count ?? 0, + prestigeCount: stateToSave.prestige.count, + transcendenceCount: stateToSave.transcendence?.count ?? 0, }); - const clientActiveCompanionId = stateToSave.companions?.activeCompanionId ?? null; - const validatedActiveCompanionId = - clientActiveCompanionId !== null && companionUnlocks.includes(clientActiveCompanionId) + const clientActiveCompanionId + = stateToSave.companions?.activeCompanionId ?? null; + const validatedActiveCompanionId + = clientActiveCompanionId !== null + && companionUnlocks.includes(clientActiveCompanionId) ? clientActiveCompanionId : null; stateToSave = { ...stateToSave, companions: { + activeCompanionId: validatedActiveCompanionId, unlockedCompanionIds: companionUnlocks, - activeCompanionId: validatedActiveCompanionId, }, }; const currentUnlocked = parseUnlockedTitles(playerRecord?.unlockedTitles); - const newTitles = checkAndUnlockTitles( - currentUnlocked, - stateToSave, - playerRecord?.guildName ?? "", - /* v8 ignore next -- @preserve */ - playerRecord?.createdAt ?? Date.now(), - ); - const updatedUnlocked = newTitles.length > 0 - ? [...currentUnlocked, ...newTitles] - : undefined; + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 6 -- @preserve */ + const updatedTitles = checkAndUnlockTitles({ + createdAt: playerRecord?.createdAt ?? Date.now(), + currentUnlocked: currentUnlocked, + guildName: playerRecord?.guildName ?? "", + state: stateToSave, + }); + const updatedUnlocked + = updatedTitles.length > 0 + ? [ ...currentUnlocked, ...updatedTitles ] + : undefined; await prisma.player.update({ - where: { discordId }, data: { - lastSavedAt: now, + characterName: stateToSave.player.characterName, + lastSavedAt: now, + totalClicks: stateToSave.player.totalClicks, totalGoldEarned: stateToSave.player.totalGoldEarned, - totalClicks: stateToSave.player.totalClicks, - characterName: stateToSave.player.characterName, - ...(updatedUnlocked ? { unlockedTitles: updatedUnlocked } : {}), + ...updatedUnlocked + ? { unlockedTitles: updatedUnlocked } + : {}, }, + where: { discordId }, }); await prisma.gameState.upsert({ - where: { discordId }, - create: { discordId, state: stateToSave as unknown as never, updatedAt: now }, + create: { + discordId: discordId, + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never */ + state: stateToSave as unknown as never, + updatedAt: now, + }, + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never */ update: { state: stateToSave as unknown as never, updatedAt: now }, + where: { discordId }, }); - const signature = secret ? computeHmac(JSON.stringify(stateToSave), secret) : undefined; - return context.json({ savedAt: now, signature }); + const signature = secret === undefined + ? undefined + : computeHmac(JSON.stringify(stateToSave), secret); + return context.json({ savedAt: now, signature: signature }); }); -gameRouter.post("/reset", async (context) => { - const discordId = context.get("discordId") as string; +gameRouter.post("/reset", async(context) => { + const discordId = context.get("discordId"); const playerRecord = await prisma.player.findUnique({ where: { discordId } }); if (!playerRecord) { return context.json({ error: "No player found" }, 404); } - const freshState = INITIAL_GAME_STATE( + const freshState = initialGameState( { - discordId: playerRecord.discordId, - username: playerRecord.username, - discriminator: playerRecord.discriminator, - avatar: playerRecord.avatar, - characterName: playerRecord.characterName, - createdAt: playerRecord.createdAt, - lastSavedAt: Date.now(), - totalGoldEarned: 0, - totalClicks: 0, - lifetimeGoldEarned: playerRecord.lifetimeGoldEarned, - lifetimeClicks: playerRecord.lifetimeClicks, - lifetimeBossesDefeated: playerRecord.lifetimeBossesDefeated, - lifetimeQuestsCompleted: playerRecord.lifetimeQuestsCompleted, - lifetimeAdventurersRecruited: playerRecord.lifetimeAdventurersRecruited, + avatar: playerRecord.avatar, + characterName: playerRecord.characterName, + createdAt: playerRecord.createdAt, + discordId: playerRecord.discordId, + discriminator: playerRecord.discriminator, + lastSavedAt: Date.now(), lifetimeAchievementsUnlocked: playerRecord.lifetimeAchievementsUnlocked, + lifetimeAdventurersRecruited: playerRecord.lifetimeAdventurersRecruited, + lifetimeBossesDefeated: playerRecord.lifetimeBossesDefeated, + lifetimeClicks: playerRecord.lifetimeClicks, + lifetimeGoldEarned: playerRecord.lifetimeGoldEarned, + lifetimeQuestsCompleted: playerRecord.lifetimeQuestsCompleted, + totalClicks: 0, + totalGoldEarned: 0, + username: playerRecord.username, }, playerRecord.characterName, ); const createdAt = Date.now(); await prisma.gameState.upsert({ - where: { discordId }, - create: { discordId, state: freshState as object, updatedAt: createdAt }, + create: { + discordId: discordId, + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ + state: freshState as object, + updatedAt: createdAt, + }, + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ update: { state: freshState as object, updatedAt: createdAt }, + where: { discordId }, }); const secret = process.env.ANTI_CHEAT_SECRET; - const signature = secret ? computeHmac(JSON.stringify(freshState), secret) : undefined; - /* v8 ignore next -- @preserve */ - return context.json({ state: freshState, offlineGold: 0, offlineEssence: 0, offlineSeconds: 0, signature, loginBonus: null, loginStreak: playerRecord.loginStreak ?? 1, schemaOutdated: false, currentSchemaVersion: CURRENT_SCHEMA_VERSION }); + const signature = secret === undefined + ? undefined + : computeHmac(JSON.stringify(freshState), secret); + + return context.json({ + currentSchemaVersion: currentSchemaVersion, + loginBonus: null, + loginStreak: playerRecord.loginStreak, + offlineEssence: 0, + offlineGold: 0, + offlineSeconds: 0, + schemaOutdated: false, + signature: signature, + state: freshState, + }); }); + +export { gameRouter }; diff --git a/apps/api/src/routes/leaderboards.ts b/apps/api/src/routes/leaderboards.ts index 06e7bbd..a4e0d5c 100644 --- a/apps/api/src/routes/leaderboards.ts +++ b/apps/api/src/routes/leaderboards.ts @@ -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 }; diff --git a/apps/api/src/routes/prestige.ts b/apps/api/src/routes/prestige.ts index 90454f7..6239f3e 100644 --- a/apps/api/src/routes/prestige.ts +++ b/apps/api/src/routes/prestige.ts @@ -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 }; diff --git a/apps/api/src/routes/profile.ts b/apps/api/src/routes/profile.ts index 6af203b..e1ac314 100644 --- a/apps/api/src/routes/profile.ts +++ b/apps/api/src/routes/profile.ts @@ -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 }; diff --git a/apps/api/src/routes/transcendence.ts b/apps/api/src/routes/transcendence.ts index de4a08f..47f1dbe 100644 --- a/apps/api/src/routes/transcendence.ts +++ b/apps/api/src/routes/transcendence.ts @@ -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 }; diff --git a/apps/api/src/services/apotheosis.ts b/apps/api/src/services/apotheosis.ts index c3242bc..a557715 100644 --- a/apps/api/src/services/apotheosis.ts +++ b/apps/api/src/services/apotheosis.ts @@ -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 }; diff --git a/apps/api/src/services/dailyChallenges.ts b/apps/api/src/services/dailyChallenges.ts index 9a921f0..4c4aad1 100644 --- a/apps/api/src/services/dailyChallenges.ts +++ b/apps/api/src/services/dailyChallenges.ts @@ -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, }; diff --git a/apps/api/src/services/discord.ts b/apps/api/src/services/discord.ts index 2908a80..12ca446 100644 --- a/apps/api/src/services/discord.ts +++ b/apps/api/src/services/discord.ts @@ -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 }; diff --git a/apps/api/src/services/jwt.ts b/apps/api/src/services/jwt.ts index 3478163..c484183 100644 --- a/apps/api/src/services/jwt.ts +++ b/apps/api/src/services/jwt.ts @@ -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 }; diff --git a/apps/api/src/services/offlineProgress.ts b/apps/api/src/services/offlineProgress.ts index d59dee2..b7aa28c 100644 --- a/apps/api/src/services/offlineProgress.ts +++ b/apps/api/src/services/offlineProgress.ts @@ -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 }; diff --git a/apps/api/src/services/prestige.ts b/apps/api/src/services/prestige.ts index 519d310..db2e0be 100644 --- a/apps/api/src/services/prestige.ts +++ b/apps/api/src/services/prestige.ts @@ -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, }; diff --git a/apps/api/src/services/titles.ts b/apps/api/src/services/titles.ts index a2ab43d..6a4cbc9 100644 --- a/apps/api/src/services/titles.ts +++ b/apps/api/src/services/titles.ts @@ -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 }; diff --git a/apps/api/src/services/transcendence.ts b/apps/api/src/services/transcendence.ts index 85d7dde..ebda467 100644 --- a/apps/api/src/services/transcendence.ts +++ b/apps/api/src/services/transcendence.ts @@ -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, }; diff --git a/apps/api/src/services/webhook.ts b/apps/api/src/services/webhook.ts index 76d605d..ab63e54 100644 --- a/apps/api/src/services/webhook.ts +++ b/apps/api/src/services/webhook.ts @@ -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 }; diff --git a/apps/api/src/types/hono.ts b/apps/api/src/types/hono.ts index 6f1d344..3d49a80 100644 --- a/apps/api/src/types/hono.ts +++ b/apps/api/src/types/hono.ts @@ -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 }; +} diff --git a/apps/api/test/routes/apotheosis.spec.ts b/apps/api/test/routes/apotheosis.spec.ts index 825b73b..edf4b6f 100644 --- a/apps/api/test/routes/apotheosis.spec.ts +++ b/apps/api/test/routes/apotheosis.spec.ts @@ -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); }); }); diff --git a/apps/api/test/services/apotheosis.spec.ts b/apps/api/test/services/apotheosis.spec.ts index fd7cb5b..ead9b08 100644 --- a/apps/api/test/services/apotheosis.spec.ts +++ b/apps/api/test/services/apotheosis.spec.ts @@ -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); }); }); diff --git a/apps/api/test/services/prestige.spec.ts b/apps/api/test/services/prestige.spec.ts index 44ffc60..724a021 100644 --- a/apps/api/test/services/prestige.spec.ts +++ b/apps/api/test/services/prestige.spec.ts @@ -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); }); }); diff --git a/apps/api/test/services/titles.spec.ts b/apps/api/test/services/titles.spec.ts index 479ec7a..7d5fef4 100644 --- a/apps/api/test/services/titles.spec.ts +++ b/apps/api/test/services/titles.spec.ts @@ -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); }); }); diff --git a/apps/api/test/services/transcendence.spec.ts b/apps/api/test/services/transcendence.spec.ts index 4d16cae..8876f3a 100644 --- a/apps/api/test/services/transcendence.spec.ts +++ b/apps/api/test/services/transcendence.spec.ts @@ -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); }); }); diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js index 64afc0f..0d18767 100644 --- a/apps/web/eslint.config.js +++ b/apps/web/eslint.config.js @@ -1,3 +1,43 @@ -import { NaomisConfig } from "@nhcarrigan/eslint-config"; +import config from "@nhcarrigan/eslint-config"; -export default [...NaomisConfig]; +export default [ + ...config, + { + files: [ "src/**/*.tsx" ], + rules: { + "@typescript-eslint/naming-convention": [ + "warn", + { + format: [ "camelCase", "PascalCase" ], + leadingUnderscore: "allow", + selector: "variable", + trailingUnderscore: "forbid", + }, + { + format: [ "camelCase" ], + leadingUnderscore: "allow", + selector: "function", + trailingUnderscore: "forbid", + }, + { + format: [ "PascalCase" ], + leadingUnderscore: "forbid", + selector: "typeLike", + trailingUnderscore: "forbid", + }, + { + format: [ "PascalCase" ], + leadingUnderscore: "forbid", + selector: "class", + trailingUnderscore: "forbid", + }, + ], + "react/jsx-no-bind": [ + "error", + { + allowFunctions: true, + }, + ], + }, + }, +]; diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx deleted file mode 100644 index e37f0bc..0000000 --- a/apps/web/src/App.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { useState } from "react"; -import { GameProvider } from "./context/GameContext.js"; -import { CharacterPage } from "./components/game/CharacterPage.js"; -import { GameLayout } from "./components/game/GameLayout.js"; -import { LeaderboardPage } from "./components/game/LeaderboardPage.js"; -import { LoginPage } from "./components/game/LoginPage.js"; -import { ProfilePage } from "./components/game/ProfilePage.js"; - -const getProfileDiscordId = (): string | null => { - const match = /^\/profile\/(\d+)$/.exec(window.location.pathname); - return match?.[1] ?? null; -}; - -const getCharacterDiscordId = (): string | null => { - const match = /^\/character\/(\d+)$/.exec(window.location.pathname); - return match?.[1] ?? null; -}; - -const handleAuthCallback = (): boolean => { - if (window.location.pathname !== "/auth/callback") { - return false; - } - - const params = new URLSearchParams(window.location.search); - const token = params.get("token"); - - if (token) { - localStorage.setItem("elysium_token", token); - } - - window.history.replaceState(null, "", "/"); - return Boolean(token); -}; - -const isAuthenticated = (): boolean => { - const fromCallback = handleAuthCallback(); - return fromCallback || Boolean(localStorage.getItem("elysium_token")); -}; - -export const App = (): React.JSX.Element => { - const [loggedIn, setLoggedIn] = useState(isAuthenticated); - - const profileDiscordId = getProfileDiscordId(); - if (profileDiscordId) { - return <ProfilePage discordId={profileDiscordId} />; - } - - const characterDiscordId = getCharacterDiscordId(); - if (characterDiscordId) { - return <CharacterPage discordId={characterDiscordId} />; - } - - if (window.location.pathname === "/leaderboards") { - return <LeaderboardPage />; - } - - if (!loggedIn) { - return <LoginPage onLogin={() => { setLoggedIn(true); }} />; - } - - return ( - <GameProvider> - <GameLayout /> - </GameProvider> - ); -}; diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 5a94f01..e046bd5 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1,3 +1,9 @@ +/** + * @file API client for communicating with the Elysium backend. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ import type { AboutResponse, ApotheosisRequest, @@ -27,138 +33,270 @@ import type { UpdateProfileResponse, } from "@elysium/types"; -const BASE_URL = "/api"; +const baseUrl = "/api"; -const getToken = (): string | null => localStorage.getItem("elysium_token"); +const getToken = (): string | null => { + return globalThis.localStorage.getItem("elysium_token"); +}; -const headers = (): Record<string, string> => { +/* eslint-disable @typescript-eslint/naming-convention -- HTTP header names require specific casing */ +const buildHeaders = (): Record<string, string> => { const token = getToken(); return { "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...token !== null && token.length > 0 + ? { Authorization: `Bearer ${token}` } + : {}, }; }; +/* eslint-enable @typescript-eslint/naming-convention -- HTTP header names require specific casing */ -const request = async <T>( +const fetchJson = async <T>( path: string, options?: RequestInit, ): Promise<T> => { - const response = await fetch(`${BASE_URL}${path}`, { + const response = await fetch(`${baseUrl}${path}`, { ...options, - headers: { ...headers(), ...options?.headers }, + headers: { ...buildHeaders(), ...options?.headers }, }); if (!response.ok) { - const error = (await response.json().catch(() => ({ error: "Unknown error" }))) as { - error: string; - }; - throw new Error(error.error); + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- JSON error response requires type assertion */ + const errorBody = (await response.json().catch(() => { + return { error: "Unknown error" }; + })) as Record<string, unknown>; + const message + = typeof errorBody.error === "string" + ? errorBody.error + : "Unknown error"; + throw new Error(message); } - return response.json() as Promise<T>; + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- JSON response requires type assertion */ + return await (response.json() as Promise<T>); }; -export const getAbout = async (): Promise<AboutResponse> => - request<AboutResponse>("/about"); +/** + * Fetches the about information from the API. + * @returns The about response data. + */ +const getAbout = async(): Promise<AboutResponse> => { + return await fetchJson<AboutResponse>("/about"); +}; -export const getAuthUrl = async (): Promise<string> => { - const data = await request<{ url: string }>("/auth/url"); +/** + * Fetches the Discord OAuth URL from the API. + * @returns The authentication URL string. + */ +const getAuthUrl = async(): Promise<string> => { + const data = await fetchJson<{ url: string }>("/auth/url"); return data.url; }; -export const handleAuthCallback = async (code: string): Promise<AuthResponse> => { - const data = await request<AuthResponse>(`/auth/callback?code=${code}`); - localStorage.setItem("elysium_token", data.token); +/** + * Handles the Discord OAuth callback and stores the auth token. + * @param code - The OAuth authorization code from Discord. + * @returns The authentication response data. + */ +const handleAuthCallback = async(code: string): Promise<AuthResponse> => { + const data = await fetchJson<AuthResponse>(`/auth/callback?code=${code}`); + globalThis.localStorage.setItem("elysium_token", data.token); return data; }; -export const loadGame = async (): Promise<LoadResponse> => - request<LoadResponse>("/game/load"); +/** + * Loads the current game state from the server. + * @returns The load response containing the game state. + */ +const loadGame = async(): Promise<LoadResponse> => { + return await fetchJson<LoadResponse>("/game/load"); +}; -export const resetProgress = async (): Promise<LoadResponse> => - request<LoadResponse>("/game/reset", { method: "POST" }); +/** + * Resets all game progress on the server. + * @returns The load response after reset. + */ +const resetProgress = async(): Promise<LoadResponse> => { + return await fetchJson<LoadResponse>("/game/reset", { method: "POST" }); +}; -export const saveGame = async (body: SaveRequest): Promise<SaveResponse> => - request<SaveResponse>("/game/save", { +/** + * Saves the current game state to the server. + * @param body - The save request payload containing the game state. + * @returns The save response data. + */ +const saveGame = async(body: SaveRequest): Promise<SaveResponse> => { + return await fetchJson<SaveResponse>("/game/save", { + body: JSON.stringify(body), method: "POST", - body: JSON.stringify(body), }); +}; -export const challengeBoss = async ( +/** + * Challenges a boss with the current game state. + * @param body - The boss challenge request payload. + * @returns The boss challenge response data. + */ +const challengeBoss = async( body: BossChallengeRequest, -): Promise<BossChallengeResponse> => - request<BossChallengeResponse>("/boss/challenge", { +): Promise<BossChallengeResponse> => { + return await fetchJson<BossChallengeResponse>("/boss/challenge", { + body: JSON.stringify(body), method: "POST", - body: JSON.stringify(body), }); +}; -export const prestige = async (body: PrestigeRequest): Promise<PrestigeResponse> => - request<PrestigeResponse>("/prestige", { +/** + * Triggers a prestige reset on the server. + * @param body - The prestige request payload. + * @returns The prestige response data. + */ +const prestige = async(body: PrestigeRequest): Promise<PrestigeResponse> => { + return await fetchJson<PrestigeResponse>("/prestige", { + body: JSON.stringify(body), method: "POST", - body: JSON.stringify(body), }); +}; -export const buyPrestigeUpgrade = async ( +/** + * Purchases a prestige upgrade on the server. + * @param body - The buy prestige upgrade request payload. + * @returns The buy prestige upgrade response data. + */ +const buyPrestigeUpgrade = async( body: BuyPrestigeUpgradeRequest, -): Promise<BuyPrestigeUpgradeResponse> => - request<BuyPrestigeUpgradeResponse>("/prestige/buy-upgrade", { +): Promise<BuyPrestigeUpgradeResponse> => { + return await fetchJson<BuyPrestigeUpgradeResponse>("/prestige/buy-upgrade", { + body: JSON.stringify(body), method: "POST", - body: JSON.stringify(body), }); +}; -export const transcend = async (body: TranscendenceRequest): Promise<TranscendenceResponse> => - request<TranscendenceResponse>("/transcendence", { +/** + * Triggers a transcendence reset on the server. + * @param body - The transcendence request payload. + * @returns The transcendence response data. + */ +const transcend = async( + body: TranscendenceRequest, +): Promise<TranscendenceResponse> => { + return await fetchJson<TranscendenceResponse>("/transcendence", { + body: JSON.stringify(body), method: "POST", - body: JSON.stringify(body), }); +}; -export const buyEchoUpgrade = async ( +/** + * Purchases an echo upgrade on the server. + * @param body - The buy echo upgrade request payload. + * @returns The buy echo upgrade response data. + */ +const buyEchoUpgrade = async( body: BuyEchoUpgradeRequest, -): Promise<BuyEchoUpgradeResponse> => - request<BuyEchoUpgradeResponse>("/transcendence/buy-upgrade", { +): Promise<BuyEchoUpgradeResponse> => { + return await fetchJson<BuyEchoUpgradeResponse>("/transcendence/buy-upgrade", { + body: JSON.stringify(body), method: "POST", - body: JSON.stringify(body), }); +}; -export const achieveApotheosis = async (body: ApotheosisRequest): Promise<ApotheosisResponse> => - request<ApotheosisResponse>("/apotheosis", { +/** + * Triggers an apotheosis reset on the server. + * @param body - The apotheosis request payload. + * @returns The apotheosis response data. + */ +const achieveApotheosis = async( + body: ApotheosisRequest, +): Promise<ApotheosisResponse> => { + return await fetchJson<ApotheosisResponse>("/apotheosis", { + body: JSON.stringify(body), method: "POST", - body: JSON.stringify(body), }); +}; -export const startExploration = async ( +/** + * Starts an exploration in a given area. + * @param body - The exploration start request payload. + * @returns The exploration start response data. + */ +const startExploration = async( body: ExploreStartRequest, -): Promise<ExploreStartResponse> => - request<ExploreStartResponse>("/explore/start", { +): Promise<ExploreStartResponse> => { + return await fetchJson<ExploreStartResponse>("/explore/start", { + body: JSON.stringify(body), method: "POST", - body: JSON.stringify(body), }); +}; -export const collectExploration = async ( +/** + * Collects the rewards from a completed exploration. + * @param body - The exploration collect request payload. + * @returns The exploration collect response data. + */ +const collectExploration = async( body: ExploreCollectRequest, -): Promise<ExploreCollectResponse> => - request<ExploreCollectResponse>("/explore/collect", { +): Promise<ExploreCollectResponse> => { + return await fetchJson<ExploreCollectResponse>("/explore/collect", { + body: JSON.stringify(body), method: "POST", - body: JSON.stringify(body), }); +}; -export const craftRecipe = async ( +/** + * Crafts a recipe on the server. + * @param body - The craft recipe request payload. + * @returns The craft recipe response data. + */ +const craftRecipe = async( body: CraftRecipeRequest, -): Promise<CraftRecipeResponse> => - request<CraftRecipeResponse>("/craft", { +): Promise<CraftRecipeResponse> => { + return await fetchJson<CraftRecipeResponse>("/craft", { + body: JSON.stringify(body), method: "POST", - body: JSON.stringify(body), }); +}; -export const getPublicProfile = async ( +/** + * Fetches a public player profile by Discord ID. + * @param discordId - The Discord ID of the player to look up. + * @returns The public profile response data. + */ +const getPublicProfile = async( discordId: string, -): Promise<PublicProfileResponse> => - request<PublicProfileResponse>(`/profile/${discordId}`); +): Promise<PublicProfileResponse> => { + return await fetchJson<PublicProfileResponse>(`/profile/${discordId}`); +}; -export const updateProfile = async ( +/** + * Updates the current player's profile. + * @param body - The update profile request payload. + * @returns The update profile response data. + */ +const updateProfile = async( body: UpdateProfileRequest, -): Promise<UpdateProfileResponse> => - request<UpdateProfileResponse>("/profile", { +): Promise<UpdateProfileResponse> => { + return await fetchJson<UpdateProfileResponse>("/profile", { + body: JSON.stringify(body), method: "PUT", - body: JSON.stringify(body), }); +}; + +export { + achieveApotheosis, + buyEchoUpgrade, + buyPrestigeUpgrade, + challengeBoss, + collectExploration, + craftRecipe, + getAbout, + getAuthUrl, + getPublicProfile, + handleAuthCallback, + loadGame, + prestige, + resetProgress, + saveGame, + startExploration, + transcend, + updateProfile, +}; diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx new file mode 100644 index 0000000..9c3296d --- /dev/null +++ b/apps/web/src/app.tsx @@ -0,0 +1,86 @@ +/** + * @file Root application component that handles routing and authentication. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { type JSX, useState } from "react"; +import { CharacterPage } from "./components/game/characterPage.js"; +import { GameLayout } from "./components/game/gameLayout.js"; +import { LeaderboardPage } from "./components/game/leaderboardPage.js"; +import { LoginPage } from "./components/game/loginPage.js"; +import { ProfilePage } from "./components/game/profilePage.js"; +import { GameProvider } from "./context/gameContext.js"; + +const getProfileDiscordId = (): string | null => { + const match = /^\/profile\/(?<id>\d+)$/.exec(window.location.pathname); + return match?.groups?.id ?? null; +}; + +const getCharacterDiscordId = (): string | null => { + const match = /^\/character\/(?<id>\d+)$/.exec(window.location.pathname); + return match?.groups?.id ?? null; +}; + +const handleAuthCallback = (): boolean => { + if (window.location.pathname !== "/auth/callback") { + return false; + } + + const parameters = new URLSearchParams(window.location.search); + const token = parameters.get("token"); + + if (token !== null && token.length > 0) { + localStorage.setItem("elysium_token", token); + } + + window.history.replaceState(null, "", "/"); + return token !== null && token.length > 0; +}; + +const isAuthenticated = (): boolean => { + const fromCallback = handleAuthCallback(); + if (fromCallback) { + return true; + } + const storedToken = localStorage.getItem("elysium_token"); + return storedToken !== null && storedToken.length > 0; +}; + +/** + * Renders the root application component, handling routing and authentication. + * @returns The JSX element. + */ +const app = (): JSX.Element => { + const [ loggedIn, setLoggedIn ] = useState(isAuthenticated); + + const profileDiscordId = getProfileDiscordId(); + if (profileDiscordId !== null) { + return <ProfilePage discordId={profileDiscordId} />; + } + + const characterDiscordId = getCharacterDiscordId(); + if (characterDiscordId !== null) { + return <CharacterPage discordId={characterDiscordId} />; + } + + if (window.location.pathname === "/leaderboards") { + return <LeaderboardPage />; + } + + function handleLogin(): void { + setLoggedIn(true); + } + + if (!loggedIn) { + return <LoginPage onLogin={handleLogin} />; + } + + return ( + <GameProvider> + <GameLayout /> + </GameProvider> + ); +}; + +export { app as App }; diff --git a/apps/web/src/components/game/AboutPanel.tsx b/apps/web/src/components/game/AboutPanel.tsx deleted file mode 100644 index 05edaf6..0000000 --- a/apps/web/src/components/game/AboutPanel.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { useEffect, useState } from "react"; -import { getAbout } from "../../api/client.js"; -import type { AboutResponse } from "@elysium/types"; - -const HOW_TO_PLAY = [ - { - title: "⚔️ Adventurers", - body: "Hire adventurers to earn gold and essence automatically. Each tier is more powerful than the last. Adventurers also contribute combat power for boss fights — the more you recruit, the stronger your party becomes.", - }, - { - title: "👆 Clicking", - body: "Click the guild hall to earn gold manually. Upgrades and equipment can dramatically increase your gold per click. Clicking is especially powerful in the early game and when saving up for big purchases.", - }, - { - title: "🔧 Upgrades", - body: "Purchase upgrades to multiply the gold and essence output of specific adventurer tiers, or boost your whole guild. Upgrades are permanent for the current run and compound with each other.", - }, - { - title: "📜 Quests", - body: "Send your guild on quests that complete over time and reward gold, essence, crystals, equipment, and upgrades. Multiple quests can run simultaneously. Completing quests also unlocks new zones.", - }, - { - title: "👹 Boss Fights", - body: "Challenge zone bosses to earn large one-time rewards and unlock new zones. Your party's combat power is based on the number and tier of adventurers you've recruited. Defeated bosses cannot be re-fought, but undefeated bosses regenerate HP over time.", - }, - { - title: "🗺️ Zones", - body: "New zones unlock when you defeat the final boss AND complete the final quest of the previous zone. Each zone contains new bosses and quests with progressively greater rewards.", - }, - { - title: "🗡️ Equipment & Sets", - body: "Earn equipment from boss drops and quest rewards. Each piece provides bonuses to gold income, click power, or combat. Rarer equipment provides stronger bonuses. Equip matching set pieces (2 or 3 of a named set) to unlock escalating set bonuses shown at the top of the Equipment panel.", - }, - { - title: "⭐ Prestige", - body: "When you've progressed far enough, you can prestige to earn runestones — a permanent currency that persists across all runs. Prestige resets your current run but grants a production multiplier that stacks with every prestige.", - }, - { - title: "🔮 Runestones & Prestige Upgrades", - body: "Spend runestones in the Prestige Shop on permanent upgrades that carry over across all future runs. These upgrades multiply income, click power, essence, and crystal gain — making each new run more powerful than the last.", - }, - { - title: "⚙️ Auto-Prestige", - body: "Purchase the Autonomous Ascension upgrade in the Prestige Shop (100 runestones) to unlock the Auto-Prestige toggle. When enabled, you will automatically ascend the moment you reach the prestige threshold, using your current character name. Toggle it on and off freely from the Prestige Shop.", - }, - { - title: "🏆 Achievements", - body: "Earn achievements by hitting milestones — total gold earned, bosses defeated, quests completed, and more. Achievements are purely cosmetic and track your long-term progress across all prestige runs.", - }, - { - title: "📅 Daily Challenges", - body: "Complete daily challenges for bonus rewards including gold, essence, crystals, and runestones. Challenges reset each day and vary in difficulty. Completing all daily challenges gives an extra bonus reward.", - }, - { - title: "🗺️ Exploration", - body: "Send scouts to explore areas within each zone. Explorations run in real-time and reward gold, essence, and crafting materials when collected. Each area has a set duration — short explorations are faster but longer ones offer rarer finds. A 📖 icon marks areas you've collected from at least once, unlocking a Codex entry.", - }, - { - title: "⚗️ Crafting", - body: "Use materials gathered from exploration to craft permanent bonuses. Each recipe provides a multiplier to gold income, essence income, click power, or combat power — all of which stack and persist across prestige runs. Check the Crafting tab to see your material inventory and available recipes per zone.", - }, - { - title: "📖 Codex", - body: "Defeating bosses, completing quests, acquiring equipment, hiring adventurers, purchasing upgrades, unlocking prestige upgrades, discovering new zones, collecting from exploration areas, and crafting recipes all permanently unlock lore entries in the Codex. A badge appears on the Codex tab and a toast notification pops up each time new lore is discovered. Collect all 472 entries to build a complete picture of the world of Elysium.", - }, - { - title: "📋 Character Sheet", - body: "Visit the Character tab to write about your character and guild. Fill in your character's name, pronouns, race, class, and backstory, then create a guild with its own name and lore. Your character sheet is visible on your public profile page.", - }, - { - title: "🏅 Titles", - body: "Earn Titles by reaching milestones — defeating bosses, completing quests, prestiging, and more. Once unlocked, titles are yours forever and are never lost on prestige or transcendence resets. Set your active title from the Character tab to display it on your character sheet and public profile.", - }, - { - title: "🗡️ Equipment", - body: "Defeat bosses to earn equipment drops: weapons, armour, and trinkets. Each item provides bonuses to gold income, combat power, or click power. Only one item per slot can be equipped at a time — visit the Equipment panel to manage your loadout. Your currently equipped items are displayed on your character sheet and public profile.", - }, - { - title: "🏆 Leaderboards", - body: "Compete with other adventurers on the public Leaderboards page! Categories include Lifetime Gold, Bosses Defeated, Quests Completed, Achievements, Prestige Count, Transcendence Count, and Apotheosis Count. Click any player's row to view their character sheet. You can opt out of appearing on leaderboards via the Privacy section in your profile settings.", - }, - { - title: "🔥 Daily Login Bonus", - body: "Log in every day to earn escalating rewards! Each consecutive day awards more gold, and the 7th day of your streak grants bonus crystals. Your streak resets if you miss a day. A week multiplier increases all rewards the longer your overall streak runs. Your current streak is displayed on your character sheet.", - }, - { - title: "🤖 Auto-Quest & Auto-Boss", - body: "Toggle automation in the Quests and Boss Encounters panels! Auto-Quest automatically sends your party on the highest-zone available quest as soon as one completes, skipping quests whose combat power requirement isn't met. Auto-Boss automatically challenges the highest available boss as soon as one is ready. Both can be toggled on or off at any time using the 🤖 Auto button in each panel header.", - }, - { - title: "👥 Companions", - body: "Unlock companions by reaching certain milestones across all your runs. Each companion provides a powerful permanent bonus: increased passive gold, click gold, boss damage, essence income, or reduced quest time. You can only have one companion active at a time — choose wisely based on your current strategy! Companions are unlocked permanently once their condition is met and will never be lost.", - }, - { - title: "☁️ Cloud Saves", - body: "Your progress is automatically saved to the cloud every 30 seconds whilst you play. You can also force a manual save at any time using the sync button in the resource bar. Your save is protected by HMAC validation to ensure data integrity.", - }, - { - title: "🌌 Transcendence", - body: "Transcendence is the ultimate prestige layer, unlocked by defeating The Absolute One (requires Prestige 90). Transcending performs a nuclear reset — wiping resources, prestige, runestones, upgrades, and equipment — but grants Echoes based on your prestige count (fewer prestiges = more Echoes). Echoes are permanent and survive all future resets. Spend them in the Echo Shop on lasting multipliers: passive income, combat power, prestige quality-of-life, and Echo meta upgrades that amplify future Echo yields.", - }, - { - title: "✨ Apotheosis", - body: "Apotheosis is the final act — a complete dissolution of everything you have built, including your prestige and transcendence progress. It is unlocked once you have purchased every Transcendence upgrade. In exchange for this total reset, you receive the Apotheosis badge: pure bragging rights, a mark of reaching the absolute pinnacle of the game. Apotheosis can be achieved multiple times; each cycle requires purchasing all Transcendence upgrades again. Your Codex entries and lifetime profile statistics are always preserved.", - }, - { - title: "📖 Story", - body: "The Story tab contains 22 chapters that unlock as you progress. The first 18 unlock when you defeat the final boss of each zone. Chapters 19 and 20 unlock after your first and fifth prestige respectively. Chapter 21 unlocks on your first transcendence, and Chapter 22 on your first apotheosis. Each chapter presents a narrative moment and three choices — the choice you make is recorded on your Character Sheet and shapes your guild's story. Story progress is permanent and survives all resets.", - }, -]; - -const formatDate = (dateStr: string): string => - new Date(dateStr).toLocaleDateString("en-GB", { - day: "numeric", - month: "short", - year: "numeric", - }); - -export const AboutPanel = (): React.JSX.Element => { - const [about, setAbout] = useState<AboutResponse | null>(null); - const [error, setError] = useState<string | null>(null); - const [expandedRelease, setExpandedRelease] = useState<string | null>(null); - - useEffect(() => { - getAbout() - .then(setAbout) - .catch((err: unknown) => { - setError(err instanceof Error ? err.message : "Failed to load about data."); - }); - }, []); - - return ( - <section className="panel about-panel"> - <h2>ℹ️ About</h2> - - <h3 className="stats-section-header">📋 Changelog</h3> - {error !== null && <p className="about-error">{error}</p>} - {about === null && error === null && <p className="about-loading">Loading changelog...</p>} - {about !== null && about.releases.length === 0 && ( - <p className="about-empty">No releases yet.</p> - )} - {about !== null && about.releases.length > 0 && ( - <ul className="about-releases"> - {about.releases.map((release) => ( - <li key={release.tag_name} className="about-release"> - <button - className="about-release-header" - type="button" - onClick={() => { - setExpandedRelease( - expandedRelease === release.tag_name ? null : release.tag_name, - ); - }} - > - <span className="about-release-tag">{release.name || release.tag_name}</span> - <span className="about-release-date">{formatDate(release.published_at)}</span> - <span className="about-release-chevron"> - {expandedRelease === release.tag_name ? "▲" : "▼"} - </span> - </button> - {expandedRelease === release.tag_name && ( - <pre className="about-release-body">{release.body}</pre> - )} - </li> - ))} - </ul> - )} - - <h3 className="stats-section-header">📖 How to Play</h3> - <ul className="about-how-to-play"> - {HOW_TO_PLAY.map((section) => ( - <li key={section.title} className="about-htp-section"> - <h4 className="about-htp-title">{section.title}</h4> - <p className="about-htp-body">{section.body}</p> - </li> - ))} - </ul> - </section> - ); -}; diff --git a/apps/web/src/components/game/AchievementPanel.tsx b/apps/web/src/components/game/AchievementPanel.tsx deleted file mode 100644 index 7654ae5..0000000 --- a/apps/web/src/components/game/AchievementPanel.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import type { Achievement } from "@elysium/types"; -import { useState } from "react"; -import { useGame } from "../../context/GameContext.js"; -import { LockToggle } from "../ui/LockToggle.js"; - -const conditionDescription = (achievement: Achievement, formatNumber: (n: number) => string): string => { - const { condition } = achievement; - switch (condition.type) { - case "totalGoldEarned": - return `Earn ${formatNumber(condition.amount)} total gold`; - case "totalClicks": - return `Click ${formatNumber(condition.amount)} times`; - case "bossesDefeated": - return `Defeat ${condition.amount} boss${condition.amount > 1 ? "es" : ""}`; - case "questsCompleted": - return `Complete ${condition.amount} quest${condition.amount > 1 ? "s" : ""}`; - case "adventurerTotal": - return `Recruit ${formatNumber(condition.amount)} total adventurers`; - case "prestigeCount": - return `Prestige ${condition.amount} time${condition.amount > 1 ? "s" : ""}`; - case "equipmentOwned": - return `Own ${condition.amount} equipment item${condition.amount > 1 ? "s" : ""}`; - } -}; - -interface AchievementCardProps { - achievement: Achievement; - formatNumber: (n: number) => string; -} - -const AchievementCard = ({ achievement, formatNumber }: AchievementCardProps): React.JSX.Element => { - const isUnlocked = achievement.unlockedAt !== null; - - return ( - <div className={`achievement-card ${isUnlocked ? "unlocked" : "locked"}`}> - <div className="achievement-icon">{achievement.icon}</div> - <div className="achievement-info"> - <h3>{achievement.name}</h3> - <p>{achievement.description}</p> - <p className="achievement-condition">{conditionDescription(achievement, formatNumber)}</p> - {achievement.reward?.crystals != null && ( - <p className="achievement-reward">💎 +{achievement.reward.crystals} Crystals</p> - )} - </div> - <div className="achievement-status"> - {isUnlocked ? ( - <span className="achievement-unlocked-badge">✓ Unlocked</span> - ) : ( - <span className="achievement-locked-badge">🔒</span> - )} - </div> - </div> - ); -}; - -export const AchievementPanel = (): React.JSX.Element => { - const { state, formatNumber } = useGame(); - const [showLocked, setShowLocked] = useState(true); - - if (!state) return <section className="panel"><p>Loading...</p></section>; - - const achievements = state.achievements ?? []; - const unlocked = achievements.filter((a) => a.unlockedAt !== null); - const locked = achievements.filter((a) => a.unlockedAt === null); - const visible = showLocked ? achievements : unlocked; - - return ( - <section className="panel achievement-panel"> - <div className="panel-header"> - <h2>Achievements</h2> - <LockToggle - lockedCount={locked.length} - showLocked={showLocked} - onToggle={() => { setShowLocked((v) => !v); }} - /> - </div> - <p className="achievement-progress"> - {unlocked.length} / {achievements.length} unlocked - </p> - <div className="achievement-list"> - {visible.map((achievement) => ( - <AchievementCard key={achievement.id} achievement={achievement} formatNumber={formatNumber} /> - ))} - </div> - </section> - ); -}; diff --git a/apps/web/src/components/game/AchievementToast.tsx b/apps/web/src/components/game/AchievementToast.tsx deleted file mode 100644 index 76d5a66..0000000 --- a/apps/web/src/components/game/AchievementToast.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useEffect } from "react"; -import type { Achievement } from "@elysium/types"; -import { useGame } from "../../context/GameContext.js"; - -interface ToastItemProps { - achievement: Achievement; - onDismiss: (id: string) => void; -} - -const ToastItem = ({ achievement, onDismiss }: ToastItemProps): React.JSX.Element => { - useEffect(() => { - const timer = setTimeout(() => { - onDismiss(achievement.id); - }, 4000); - return () => { clearTimeout(timer); }; - }, [achievement.id, onDismiss]); - - return ( - <div className="achievement-toast" onClick={() => { onDismiss(achievement.id); }}> - <span className="toast-icon">{achievement.icon}</span> - <div className="toast-content"> - <span className="toast-label">Achievement Unlocked!</span> - <span className="toast-name">{achievement.name}</span> - {achievement.reward?.crystals != null && ( - <span className="toast-reward">💎 +{achievement.reward.crystals}</span> - )} - </div> - </div> - ); -}; - -export const AchievementToast = (): React.JSX.Element | null => { - const { newAchievements, dismissAchievement } = useGame(); - - if (newAchievements.length === 0) return null; - - return ( - <div className="achievement-toast-container"> - {newAchievements.map((achievement) => ( - <ToastItem - key={achievement.id} - achievement={achievement} - onDismiss={dismissAchievement} - /> - ))} - </div> - ); -}; diff --git a/apps/web/src/components/game/AdventurerPanel.tsx b/apps/web/src/components/game/AdventurerPanel.tsx deleted file mode 100644 index c1d309e..0000000 --- a/apps/web/src/components/game/AdventurerPanel.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import type { Adventurer } from "@elysium/types"; -import { useState } from "react"; -import { useGame } from "../../context/GameContext.js"; -import { LockToggle } from "../ui/LockToggle.js"; - -const CLASS_ICONS: Record<string, string> = { - warrior: "🗡️", - mage: "🔮", - rogue: "🗝️", - cleric: "✝️", - ranger: "🏹", - paladin: "🛡️", -}; - -type BatchSize = 1 | 5 | 10 | 25 | 100 | "max"; -const BATCH_OPTIONS: BatchSize[] = [1, 5, 10, 25, 100, "max"]; - -const computeBatchCost = (adventurer: Adventurer, quantity: number): number => { - let total = 0; - for (let i = 0; i < quantity; i++) { - total += adventurer.baseCost * Math.pow(1.15, adventurer.count + i); - } - return total; -}; - -const computeMaxAffordable = (adventurer: Adventurer, gold: number): number => { - let total = 0; - let quantity = 0; - for (let i = 0; i < 100_000; i++) { - const cost = adventurer.baseCost * Math.pow(1.15, adventurer.count + i); - if (total + cost > gold) break; - total += cost; - quantity++; - } - return quantity; -}; - -interface AdventurerCardProps { - adventurer: Adventurer; - currentGold: number; - batchSize: BatchSize; - unlockHint?: string | undefined; - formatNumber: (n: number) => string; -} - -const AdventurerCard = ({ adventurer, currentGold, batchSize, unlockHint, formatNumber }: AdventurerCardProps): React.JSX.Element => { - const { buyAdventurer } = useGame(); - - const resolvedQuantity = - batchSize === "max" ? computeMaxAffordable(adventurer, currentGold) : batchSize; - const cost = computeBatchCost(adventurer, resolvedQuantity); - const canAfford = resolvedQuantity > 0 && currentGold >= cost; - - const handleBuy = (): void => { - buyAdventurer(adventurer.id, resolvedQuantity); - }; - - return ( - <div className={`adventurer-card ${!adventurer.unlocked ? "locked" : ""}`}> - <div className="adventurer-icon">{CLASS_ICONS[adventurer.class] ?? "⚔️"}</div> - <div className="adventurer-info"> - <h3>{adventurer.name}</h3> - <p>{formatNumber(adventurer.goldPerSecond)} gold/s each</p> - {adventurer.essencePerSecond > 0 && ( - <p>{formatNumber(adventurer.essencePerSecond)} essence/s each</p> - )} - </div> - <div className="adventurer-count">×{adventurer.count}</div> - <button - className="buy-button" - disabled={!canAfford || !adventurer.unlocked} - onClick={handleBuy} - type="button" - > - {adventurer.unlocked - ? `🪙 ${formatNumber(Math.ceil(cost))}${batchSize === "max" && resolvedQuantity > 0 ? ` (×${resolvedQuantity})` : ""}` - : "🔒 Locked"} - </button> - {!adventurer.unlocked && unlockHint && ( - <p className="unlock-hint">📜 Complete: {unlockHint}</p> - )} - </div> - ); -}; - -export const AdventurerPanel = (): React.JSX.Element => { - const { state, formatNumber } = useGame(); - const [showLocked, setShowLocked] = useState(true); - const [batchSize, setBatchSize] = useState<BatchSize>(1); - - if (!state) return <section className="panel"><p>Loading...</p></section>; - - const locked = state.adventurers.filter((a) => !a.unlocked); - const visible = showLocked ? state.adventurers : state.adventurers.filter((a) => a.unlocked); - - const adventurerUnlockHints = new Map<string, string>(); - for (const quest of state.quests) { - for (const reward of quest.rewards) { - if (reward.type === "adventurer" && reward.targetId) { - adventurerUnlockHints.set(reward.targetId, quest.name); - } - } - } - - return ( - <section className="panel adventurer-panel"> - <div className="panel-header"> - <h2>Adventurers</h2> - <LockToggle - lockedCount={locked.length} - showLocked={showLocked} - onToggle={() => { setShowLocked((v) => !v); }} - /> - </div> - <div className="batch-selector"> - {BATCH_OPTIONS.map((option) => ( - <button - key={option} - className={`batch-button ${batchSize === option ? "active" : ""}`} - onClick={() => { setBatchSize(option); }} - type="button" - > - {option === "max" ? "xMax" : `x${option}`} - </button> - ))} - </div> - <div className="adventurer-list"> - {visible.map((adventurer) => ( - <AdventurerCard - key={adventurer.id} - adventurer={adventurer} - batchSize={batchSize} - currentGold={state.resources.gold} - unlockHint={adventurerUnlockHints.get(adventurer.id)} - formatNumber={formatNumber} - /> - ))} - </div> - </section> - ); -}; diff --git a/apps/web/src/components/game/ApotheosisPanel.tsx b/apps/web/src/components/game/ApotheosisPanel.tsx deleted file mode 100644 index 2b0ab3f..0000000 --- a/apps/web/src/components/game/ApotheosisPanel.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { useState } from "react"; -import { useGame } from "../../context/GameContext.js"; -import { TRANSCENDENCE_UPGRADES } from "../../data/transcendenceUpgrades.js"; - -const TOTAL_ECHO_UPGRADES = TRANSCENDENCE_UPGRADES.length; - -export const ApotheosisPanel = (): React.JSX.Element => { - const { state, apotheosis } = useGame(); - const [isPending, setIsPending] = useState(false); - const [result, setResult] = useState<number | null>(null); - const [error, setError] = useState<string | null>(null); - - if (!state) return <section className="panel"><p>Loading...</p></section>; - - const purchasedIds = state.transcendence?.purchasedUpgradeIds ?? []; - const purchasedCount = TRANSCENDENCE_UPGRADES.filter((u) => purchasedIds.includes(u.id)).length; - const isEligible = purchasedCount >= TOTAL_ECHO_UPGRADES; - const apotheosisCount = state.apotheosis?.count ?? 0; - - const handleApotheosis = async (): Promise<void> => { - setIsPending(true); - setError(null); - try { - const data = await apotheosis(); - setResult(data.newApotheosisCount); - } catch (err) { - setError(err instanceof Error ? err.message : "Apotheosis failed"); - } finally { - setIsPending(false); - } - }; - - return ( - <section className="panel apotheosis-panel"> - <h2>✨ Apotheosis</h2> - - <p className="apotheosis-intro"> - Apotheosis is the final act — a complete dissolution of everything you have built. - Prestige, Transcendence, Echoes, upgrades, equipment, resources: all of it returns - to nothing. In exchange, you receive only one thing: - </p> - <p className="apotheosis-reward"> - The <strong>✨ Apotheosis</strong> badge. Proof that you have done it all. - </p> - <p className="apotheosis-intro"> - Apotheosis can be achieved multiple times. Each cycle requires you to purchase - every Transcendence upgrade again before the next Apotheosis becomes available. - There is no mechanical benefit — only the knowledge that you have reached the - pinnacle, dissolved it, and climbed back up. - </p> - - {apotheosisCount > 0 && ( - <div className="apotheosis-count"> - <span>You have achieved Apotheosis <strong>{apotheosisCount}</strong> time{apotheosisCount === 1 ? "" : "s"}.</span> - </div> - )} - - <div className="apotheosis-status"> - <p> - Transcendence upgrades purchased:{" "} - <strong>{purchasedCount} / {TOTAL_ECHO_UPGRADES}</strong> - </p> - {!isEligible && ( - <p className="apotheosis-missing"> - 🔒 Purchase all {TOTAL_ECHO_UPGRADES} Transcendence upgrades to unlock Apotheosis. - ({TOTAL_ECHO_UPGRADES - purchasedCount} remaining) - </p> - )} - {isEligible && ( - <p className="apotheosis-ready">✅ All Transcendence upgrades purchased. You are ready.</p> - )} - </div> - - {isEligible && ( - <div className="prestige-form"> - <p>This action is <strong>permanent and irreversible</strong>.</p> - <button - className="apotheosis-button" - disabled={isPending} - onClick={() => { void handleApotheosis(); }} - type="button" - > - {isPending ? "Ascending..." : "✨ Achieve Apotheosis"} - </button> - {error && <p className="error">{error}</p>} - {result !== null && ( - <p className="success"> - Apotheosis achieved. This is cycle <strong>{result}</strong>. - The infinite loop continues. - </p> - )} - </div> - )} - </section> - ); -}; diff --git a/apps/web/src/components/game/BattleModal.tsx b/apps/web/src/components/game/BattleModal.tsx deleted file mode 100644 index 82fb252..0000000 --- a/apps/web/src/components/game/BattleModal.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import type { BattleResult } from "../../context/GameContext.js"; -import { useGame } from "../../context/GameContext.js"; -import { useEffect, useState } from "react"; - -interface BattleModalProps { - battle: BattleResult; - onDismiss: () => void; -} - -export const BattleModal = ({ - battle, - onDismiss, -}: BattleModalProps): React.JSX.Element => { - const { result, bossName } = battle; - const { formatNumber } = useGame(); - - const [phase, setPhase] = useState<"animating" | "result">("animating"); - - // Starting HP percentages - const bossStartPercent = (result.bossHpBefore / result.bossMaxHp) * 100; - const partyStartPercent = 100; - - // Target HP percentages (after battle) - const bossEndPercent = (result.bossHpAtBattleEnd / result.bossMaxHp) * 100; - const partyEndPercent = result.partyMaxHp > 0 - ? (result.partyHpRemaining / result.partyMaxHp) * 100 - : 0; - - const [bossHpPercent, setBossHpPercent] = useState(bossStartPercent); - const [partyHpPercent, setPartyHpPercent] = useState(partyStartPercent); - - useEffect(() => { - // Brief delay so CSS transition has a starting point to animate from - const startAnimation = setTimeout(() => { - setBossHpPercent(bossEndPercent); - setPartyHpPercent(partyEndPercent); - }, 200); - - // Reveal result after animation completes - const revealResult = setTimeout(() => { - setPhase("result"); - }, 5_200); - - return () => { - clearTimeout(startAnimation); - clearTimeout(revealResult); - }; - }, [bossEndPercent, partyEndPercent]); - - const bossHpBarColour = bossHpPercent > 50 - ? "#e74c3c" - : bossHpPercent > 25 - ? "#e67e22" - : "#c0392b"; - - const partyHpBarColour = partyHpPercent > 50 - ? "#27ae60" - : partyHpPercent > 25 - ? "#f39c12" - : "#e74c3c"; - - return ( - <div className="modal-overlay"> - <div className="modal battle-modal"> - <h2>⚔️ Battle: {bossName}</h2> - - <div className="battle-stats"> - <div className="battle-stat"> - <span className="stat-label">Your Party DPS</span> - <span className="stat-value">{formatNumber(result.partyDPS)}</span> - </div> - <div className="battle-stat-divider">vs</div> - <div className="battle-stat"> - <span className="stat-label">Boss DPS</span> - <span className="stat-value">{formatNumber(result.bossDPS)}</span> - </div> - </div> - - <div className="battle-bars"> - <div className="battle-bar-row"> - <span className="bar-label">👹 {bossName}</span> - <div className="hp-bar-container"> - <div - className="hp-bar-fill" - style={{ - width: `${bossHpPercent.toFixed(1)}%`, - backgroundColor: bossHpBarColour, - transition: "width 5s ease-in-out", - }} - /> - </div> - <span className="bar-hp"> - {formatNumber(result.bossHpAtBattleEnd)} / {formatNumber(result.bossMaxHp)} - </span> - </div> - - <div className="vs-divider">⚔️ VS ⚔️</div> - - <div className="battle-bar-row"> - <span className="bar-label">🛡️ Your Party</span> - <div className="hp-bar-container"> - <div - className="hp-bar-fill party-hp" - style={{ - width: `${partyHpPercent.toFixed(1)}%`, - backgroundColor: partyHpBarColour, - transition: "width 5s ease-in-out", - }} - /> - </div> - <span className="bar-hp"> - {formatNumber(result.partyHpRemaining)} / {formatNumber(result.partyMaxHp)} - </span> - </div> - </div> - - {phase === "animating" && ( - <p className="battle-in-progress">Battling…</p> - )} - - {phase === "result" && ( - <div className={`battle-outcome ${result.won ? "victory" : "defeat"}`}> - {result.won ? ( - <> - <h3>🏆 Victory!</h3> - {result.rewards && ( - <div className="battle-rewards"> - <p>Rewards:</p> - <span>🪙 {formatNumber(result.rewards.gold)} gold</span> - {result.rewards.essence > 0 && ( - <span>✨ {formatNumber(result.rewards.essence)} essence</span> - )} - {result.rewards.crystals > 0 && ( - <span>💎 {formatNumber(result.rewards.crystals)} crystals</span> - )} - {result.rewards.bountyRunestones > 0 && ( - <span className="battle-bounty">🔮 {formatNumber(result.rewards.bountyRunestones)} runestones (first kill!)</span> - )} - </div> - )} - </> - ) : ( - <> - <h3>💀 Defeat</h3> - <p>Your party was defeated. The boss has reset.</p> - {result.casualties && result.casualties.length > 0 && ( - <div className="battle-casualties"> - <p>Casualties:</p> - {result.casualties.map((c) => ( - <span key={c.adventurerId}> - ☠️ {c.killed} {c.adventurerId} lost - </span> - ))} - </div> - )} - </> - )} - <button - className="dismiss-button" - onClick={onDismiss} - type="button" - > - Continue - </button> - </div> - )} - </div> - </div> - ); -}; diff --git a/apps/web/src/components/game/BossPanel.tsx b/apps/web/src/components/game/BossPanel.tsx deleted file mode 100644 index 96ffa7a..0000000 --- a/apps/web/src/components/game/BossPanel.tsx +++ /dev/null @@ -1,244 +0,0 @@ -import type { Boss } from "@elysium/types"; -import { useState } from "react"; -import { useGame } from "../../context/GameContext.js"; -import { LockToggle } from "../ui/LockToggle.js"; -import { ZoneSelector } from "./ZoneSelector.js"; - -interface BossCardProps { - boss: Boss; - prestigeCount: number; - onChallenge: (bossId: string) => void; - isChallenging: boolean; - unlockHint?: string | undefined; - formatNumber: (n: number) => string; -} - -const BossCard = ({ - boss, - prestigeCount, - onChallenge, - isChallenging, - unlockHint, - formatNumber, -}: BossCardProps): React.JSX.Element => { - const hpPercent = (boss.currentHp / boss.maxHp) * 100; - const isPrestigeLocked = boss.prestigeRequirement > prestigeCount; - const canChallenge = - (boss.status === "available" || boss.status === "in_progress") && !isChallenging; - - return ( - <div className={`boss-card boss-${boss.status}`}> - <div className="boss-info"> - <h3>{boss.name}</h3> - <p>{boss.description}</p> - {isPrestigeLocked && boss.status === "locked" && ( - <p className="prestige-lock"> - 🔒 Requires Prestige {boss.prestigeRequirement} - </p> - )} - {!isPrestigeLocked && boss.status === "locked" && unlockHint && ( - <p className="unlock-hint">{unlockHint}</p> - )} - </div> - - {boss.status !== "locked" && boss.status !== "defeated" && ( - <div className="boss-hp"> - <div className="hp-bar"> - <div - className="hp-fill" - style={{ width: `${hpPercent.toFixed(1)}%` }} - /> - </div> - <span className="hp-text"> - {formatNumber(boss.currentHp)} / {formatNumber(boss.maxHp)} HP - </span> - </div> - )} - - <div className="boss-meta"> - <span className="boss-dps">💢 Boss DPS: {formatNumber(boss.damagePerSecond)}</span> - </div> - - <div className="boss-rewards"> - <span>🪙 {formatNumber(boss.goldReward)}</span> - {boss.essenceReward > 0 && ( - <span>✨ {formatNumber(boss.essenceReward)}</span> - )} - {boss.crystalReward > 0 && ( - <span>💎 {formatNumber(boss.crystalReward)}</span> - )} - {(boss.equipmentRewards ?? []).length > 0 && ( - <span>🗡️ {boss.equipmentRewards.length} Equipment</span> - )} - {boss.status !== "defeated" && boss.bountyRunestones > 0 && ( - <span className="boss-bounty">🔮 {boss.bountyRunestones} (first kill)</span> - )} - </div> - - {(boss.status === "available" || boss.status === "in_progress") && ( - <button - className="attack-button" - disabled={!canChallenge} - onClick={() => { - onChallenge(boss.id); - }} - type="button" - > - {isChallenging ? "⚔️ Battling…" : "⚔️ Challenge"} - </button> - )} - - {boss.status === "defeated" && ( - <span className="boss-badge defeated">☠️ Defeated</span> - )} - </div> - ); -}; - -export const BossPanel = (): React.JSX.Element => { - const { state, challengeBoss, formatNumber, toggleAutoBoss } = useGame(); - const [challengingBossId, setChallengingBossId] = useState<string | null>(null); - const [activeZoneId, setActiveZoneId] = useState("verdant_vale"); - const [showLocked, setShowLocked] = useState(true); - - if (!state) return <section className="panel"><p>Loading...</p></section>; - - // Calculate party combat stats including equipment multiplier - let globalMultiplier = 1; - for (const upgrade of state.upgrades) { - if (upgrade.purchased && upgrade.target === "global") { - globalMultiplier *= upgrade.multiplier; - } - } - const prestigeMultiplier = 1 + state.prestige.count * 0.1; - const equipmentCombatMultiplier = (state.equipment ?? []) - .filter((e) => e.equipped && e.bonus.combatMultiplier != null) - .reduce((mult, e) => mult * (e.bonus.combatMultiplier ?? 1), 1); - - let partyDPS = 0; - let partyHP = 0; - for (const adventurer of state.adventurers) { - if (adventurer.count === 0) continue; - let adventurerMultiplier = 1; - for (const upgrade of state.upgrades) { - if ( - upgrade.purchased && - upgrade.target === "adventurer" && - upgrade.adventurerId === adventurer.id - ) { - adventurerMultiplier *= upgrade.multiplier; - } - } - partyDPS += - adventurer.combatPower * - adventurer.count * - adventurerMultiplier * - globalMultiplier * - prestigeMultiplier; - partyHP += adventurer.level * 50 * adventurer.count; - } - partyDPS *= equipmentCombatMultiplier; - - const handleChallenge = async (bossId: string): Promise<void> => { - setChallengingBossId(bossId); - try { - await challengeBoss(bossId); - } finally { - setChallengingBossId(null); - } - }; - - const zones = state.zones ?? []; - const zoneBosses = state.bosses.filter((b) => b.zoneId === activeZoneId); - const lockedCount = zoneBosses.filter((b) => b.status === "locked").length; - const visibleBosses = showLocked - ? zoneBosses - : zoneBosses.filter((b) => b.status !== "locked"); - - const bossUnlockHints = new Map<string, string>(); - for (const zone of zones) { - const allZoneBosses = state.bosses.filter((b) => b.zoneId === zone.id); - for (let i = 0; i < allZoneBosses.length; i++) { - const boss = allZoneBosses[i]; - if (!boss || boss.status !== "locked") continue; - if (i === 0) { - const parts: string[] = []; - if (zone.unlockBossId) { - const gateBoss = state.bosses.find((b) => b.id === zone.unlockBossId); - if (gateBoss) parts.push(`⚔️ Defeat: ${gateBoss.name}`); - } - if (zone.unlockQuestId) { - const gateQuest = state.quests.find((q) => q.id === zone.unlockQuestId); - if (gateQuest) parts.push(`📜 Complete: ${gateQuest.name}`); - } - if (parts.length > 0) { - bossUnlockHints.set(boss.id, parts.join(" & ")); - } - } else { - const prevBoss = allZoneBosses[i - 1]; - if (prevBoss) { - bossUnlockHints.set(boss.id, `⚔️ Defeat: ${prevBoss.name} first`); - } - } - } - } - - return ( - <section className="panel boss-panel"> - <div className="panel-header"> - <h2>Boss Encounters</h2> - <div className="panel-header-controls"> - <button - className={`auto-toggle-btn ${state.autoBoss ? "auto-toggle-on" : "auto-toggle-off"}`} - onClick={toggleAutoBoss} - title="Automatically challenge the highest available boss" - type="button" - > - 🤖 Auto: {state.autoBoss ? "ON" : "OFF"} - </button> - <LockToggle - lockedCount={lockedCount} - showLocked={showLocked} - onToggle={() => { setShowLocked((v) => !v); }} - /> - </div> - </div> - - <ZoneSelector - activeZoneId={activeZoneId} - zones={zones} - onSelectZone={setActiveZoneId} - /> - - <div className="party-combat-stats"> - <div className="combat-stat"> - <span className="stat-label">⚔️ Party DPS</span> - <span className="stat-value">{formatNumber(partyDPS)}</span> - </div> - <div className="combat-stat"> - <span className="stat-label">❤️ Party HP</span> - <span className="stat-value">{formatNumber(partyHP)}</span> - </div> - </div> - - <div className="boss-list"> - {visibleBosses.map((boss) => ( - <BossCard - key={boss.id} - boss={boss} - formatNumber={formatNumber} - isChallenging={challengingBossId === boss.id} - prestigeCount={state.prestige.count} - unlockHint={bossUnlockHints.get(boss.id)} - onChallenge={(id) => { - void handleChallenge(id); - }} - /> - ))} - {visibleBosses.length === 0 && ( - <p className="empty-zone">No bosses to show in this zone.</p> - )} - </div> - </section> - ); -}; diff --git a/apps/web/src/components/game/CharacterPage.tsx b/apps/web/src/components/game/CharacterPage.tsx deleted file mode 100644 index 6cf5dc9..0000000 --- a/apps/web/src/components/game/CharacterPage.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import type { EquipmentBonus, EquipmentType, PublicProfileResponse } from "@elysium/types"; -import { useEffect, useState } from "react"; - -interface CharacterPageProps { - discordId: string; -} - -export const CharacterPage = ({ discordId }: CharacterPageProps): React.JSX.Element => { - const [profile, setProfile] = useState<PublicProfileResponse | null>(null); - const [error, setError] = useState<string | null>(null); - const [copied, setCopied] = useState(false); - - useEffect(() => { - fetch(`/api/profile/${discordId}`) - .then(async (res) => { - if (!res.ok) throw new Error("Player not found"); - return res.json() as Promise<PublicProfileResponse>; - }) - .then(setProfile) - .catch((err: unknown) => { - setError(err instanceof Error ? err.message : "Failed to load character sheet"); - }); - }, [discordId]); - - const handleCopy = (): void => { - void navigator.clipboard.writeText(window.location.href).then(() => { - setCopied(true); - setTimeout(() => { setCopied(false); }, 2000); - }); - }; - - if (error) { - return ( - <div className="character-page"> - <div className="character-page-error"> - <p>⚠️ {error}</p> - <a className="character-page-link" href="/">← Play Elysium</a> - </div> - </div> - ); - } - - if (!profile) { - return ( - <div className="character-page"> - <div className="character-page-loading">Loading character sheet…</div> - </div> - ); - } - - const SLOT_ICONS: Record<EquipmentType, string> = { - weapon: "⚔️", - armour: "🛡️", - trinket: "💍", - }; - - const formatBonus = (bonus: EquipmentBonus): string => { - const parts: string[] = []; - if (bonus.goldMultiplier !== undefined) { - parts.push(`+${Math.round((bonus.goldMultiplier - 1) * 100)}% Gold Income`); - } - if (bonus.combatMultiplier !== undefined) { - parts.push(`+${Math.round((bonus.combatMultiplier - 1) * 100)}% Combat Power`); - } - if (bonus.clickMultiplier !== undefined) { - parts.push(`+${Math.round((bonus.clickMultiplier - 1) * 100)}% Click Power`); - } - return parts.join(" · "); - }; - - const avatarUrl = profile.avatar - ? `https://cdn.discordapp.com/avatars/${discordId}/${profile.avatar}.png?size=128` - : `https://cdn.discordapp.com/embed/avatars/${parseInt(discordId, 10) % 5}.png`; - - const subtitle = [profile.characterRace, profile.characterClass].filter(Boolean).join(" · "); - const activeTitleName = profile.activeTitle - ? (profile.unlockedTitles.find((t) => t.id === profile.activeTitle)?.name ?? profile.activeTitle) - : null; - const hasBadge = profile.apotheosisCount > 0 || profile.transcendenceCount > 0 || profile.prestigeCount > 0; - - return ( - <div className="character-page"> - <div className="character-page-card"> - <div className="character-page-header"> - <img - alt={`${profile.characterName || profile.username}'s avatar`} - className="character-page-avatar" - src={avatarUrl} - /> - <div className="character-page-identity"> - <h1 className="character-page-name"> - {profile.characterName || profile.username} - </h1> - {activeTitleName && ( - <p className="character-page-title">{activeTitleName}</p> - )} - {profile.pronouns && ( - <p className="character-page-pronouns">{profile.pronouns}</p> - )} - {subtitle && ( - <p className="character-page-subtitle">{subtitle}</p> - )} - {hasBadge && ( - <div className="character-page-badges"> - {profile.apotheosisCount > 0 && ( - <span className="character-page-badge character-page-badge--apotheosis"> - ✨ Apotheosis {profile.apotheosisCount} - </span> - )} - {profile.transcendenceCount > 0 && ( - <span className="character-page-badge character-page-badge--transcendence"> - 🌌 Transcendence {profile.transcendenceCount} - </span> - )} - {profile.prestigeCount > 0 && ( - <span className="character-page-badge character-page-badge--prestige"> - ⭐ Prestige {profile.prestigeCount} - </span> - )} - </div> - )} - </div> - </div> - - {profile.bio && ( - <div className="character-page-section"> - <h2 className="character-page-section-title">⚔️ About</h2> - <p className="character-page-bio">{profile.bio}</p> - </div> - )} - - {profile.guildName && ( - <div className="character-page-section"> - <h2 className="character-page-section-title">🏰 Guild</h2> - <p className="character-page-guild-name">{profile.guildName}</p> - {profile.guildDescription && ( - <p className="character-page-guild-desc">{profile.guildDescription}</p> - )} - </div> - )} - - {profile.equippedItems.length > 0 && ( - <div className="character-page-section"> - <h2 className="character-page-section-title">🗡️ Equipment</h2> - <div className="character-page-equipment-list"> - {profile.equippedItems.map((item) => ( - <div className="character-page-equipment-item" key={item.type}> - <div className="character-page-equipment-header"> - <span className="character-page-equipment-slot">{SLOT_ICONS[item.type]}</span> - <span className={`character-page-equipment-name character-sheet-rarity--${item.rarity}`}> - {item.name} - </span> - <span className={`character-page-equipment-rarity character-sheet-rarity--${item.rarity}`}> - {item.rarity} - </span> - </div> - <p className="character-page-equipment-bonus">{formatBonus(item.bonus)}</p> - </div> - ))} - </div> - </div> - )} - - <div className="character-page-divider" /> - - <p className="character-page-player-line"> - Played by <span className="character-page-username">@{profile.username}</span> - </p> - - <div className="character-page-actions"> - <button - className="character-page-share-btn" - onClick={handleCopy} - type="button" - > - {copied ? "✓ Copied!" : "🔗 Share Character"} - </button> - <a className="character-page-profile-link" href={`/profile/${discordId}`}> - 📊 View Stats - </a> - <a className="character-page-play-link" href="/"> - ⚔️ Play Elysium - </a> - </div> - </div> - </div> - ); -}; diff --git a/apps/web/src/components/game/CharacterSheetPanel.tsx b/apps/web/src/components/game/CharacterSheetPanel.tsx deleted file mode 100644 index 51ef688..0000000 --- a/apps/web/src/components/game/CharacterSheetPanel.tsx +++ /dev/null @@ -1,433 +0,0 @@ -import type { EquipmentBonus, EquipmentRarity, EquipmentType, ProfileSettings } from "@elysium/types"; -import { DEFAULT_PROFILE_SETTINGS, STORY_CHAPTERS } from "@elysium/types"; -import { useEffect, useRef, useState } from "react"; -import { updateProfile } from "../../api/client.js"; -import { useGame } from "../../context/GameContext.js"; - -interface EquippedItem { - name: string; - type: EquipmentType; - rarity: EquipmentRarity; - bonus: EquipmentBonus; -} - -interface CharacterSheetData { - characterName: string; - pronouns: string; - characterRace: string; - characterClass: string; - bio: string; - guildName: string; - guildDescription: string; - activeTitle: string; - unlockedTitles: Array<{ id: string; name: string }>; - equippedItems: EquippedItem[]; -} - -const EMPTY_SHEET: CharacterSheetData = { - characterName: "", - pronouns: "", - characterRace: "", - characterClass: "", - bio: "", - guildName: "", - guildDescription: "", - activeTitle: "", - unlockedTitles: [], - equippedItems: [], -}; - -const SLOT_ICONS: Record<EquipmentType, string> = { - weapon: "⚔️", - armour: "🛡️", - trinket: "💍", -}; - -const formatBonus = (bonus: EquipmentBonus): string => { - const parts: string[] = []; - if (bonus.goldMultiplier !== undefined) { - parts.push(`+${Math.round((bonus.goldMultiplier - 1) * 100)}% Gold Income`); - } - if (bonus.combatMultiplier !== undefined) { - parts.push(`+${Math.round((bonus.combatMultiplier - 1) * 100)}% Combat Power`); - } - if (bonus.clickMultiplier !== undefined) { - parts.push(`+${Math.round((bonus.clickMultiplier - 1) * 100)}% Click Power`); - } - return parts.join(" · "); -}; - -export const CharacterSheetPanel = (): React.JSX.Element => { - const { state, loginStreak } = useGame(); - const player = state?.player; - - const [sheet, setSheet] = useState<CharacterSheetData>(EMPTY_SHEET); - const [draft, setDraft] = useState<CharacterSheetData>(EMPTY_SHEET); - const [editing, setEditing] = useState(false); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [error, setError] = useState<string | null>(null); - const [saved, setSaved] = useState(false); - const [copied, setCopied] = useState(false); - const savedSettingsRef = useRef<ProfileSettings>({ ...DEFAULT_PROFILE_SETTINGS }); - - useEffect(() => { - if (!player?.discordId) return; - fetch(`/api/profile/${player.discordId}`) - .then(async (res) => { - if (!res.ok) return; - const data = (await res.json()) as { - characterName: string; - pronouns: string; - characterRace: string; - characterClass: string; - bio: string; - guildName: string; - guildDescription: string; - profileSettings: ProfileSettings; - activeTitle: string; - unlockedTitles: Array<{ id: string; name: string }>; - equippedItems: EquippedItem[]; - }; - const loaded: CharacterSheetData = { - characterName: data.characterName ?? "", - pronouns: data.pronouns ?? "", - characterRace: data.characterRace ?? "", - characterClass: data.characterClass ?? "", - bio: data.bio ?? "", - guildName: data.guildName ?? "", - guildDescription: data.guildDescription ?? "", - activeTitle: data.activeTitle ?? "", - unlockedTitles: data.unlockedTitles ?? [], - equippedItems: data.equippedItems ?? [], - }; - setSheet(loaded); - setDraft(loaded); - savedSettingsRef.current = { ...DEFAULT_PROFILE_SETTINGS, ...data.profileSettings }; - }) - .catch(() => { /* fall back to empty */ }) - .finally(() => { setLoading(false); }); - }, [player?.discordId]); - - const handleEdit = (): void => { - setDraft({ ...sheet }); - setEditing(true); - setError(null); - setSaved(false); - }; - - const handleCancel = (): void => { - setEditing(false); - setError(null); - }; - - const handleSave = async (): Promise<void> => { - setSaving(true); - setError(null); - try { - await updateProfile({ - characterName: draft.characterName || (player?.characterName ?? ""), - pronouns: draft.pronouns, - characterRace: draft.characterRace, - characterClass: draft.characterClass, - bio: draft.bio, - guildName: draft.guildName, - guildDescription: draft.guildDescription, - profileSettings: savedSettingsRef.current, - activeTitle: draft.activeTitle, - }); - setSheet({ ...draft }); - setSaved(true); - setTimeout(() => { - setEditing(false); - setSaved(false); - }, 900); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to save"); - } finally { - setSaving(false); - } - }; - - if (loading) { - return <section className="panel"><p>Loading character sheet…</p></section>; - } - - if (editing) { - return ( - <section className="panel character-sheet-panel"> - <div className="panel-header"> - <h2>📋 Character Sheet</h2> - </div> - - <div className="character-sheet-form"> - <div className="character-sheet-section"> - <h3 className="character-sheet-section-title">⚔️ Character</h3> - - <label className="character-sheet-label" htmlFor="cs-name">Character Name</label> - <input - className="character-sheet-input" - id="cs-name" - maxLength={32} - placeholder="Your character's name" - type="text" - value={draft.characterName} - onChange={(e) => { setDraft((d) => ({ ...d, characterName: e.target.value })); }} - /> - <span className="character-sheet-hint">{draft.characterName.length} / 32</span> - - <label className="character-sheet-label" htmlFor="cs-pronouns">Pronouns</label> - <input - className="character-sheet-input" - id="cs-pronouns" - maxLength={20} - placeholder="e.g. she/her, he/him, they/them" - type="text" - value={draft.pronouns} - onChange={(e) => { setDraft((d) => ({ ...d, pronouns: e.target.value })); }} - /> - <span className="character-sheet-hint">{draft.pronouns.length} / 20</span> - - <label className="character-sheet-label" htmlFor="cs-race">Race</label> - <input - className="character-sheet-input" - id="cs-race" - maxLength={32} - placeholder="e.g. Elf, Dwarf, Human, Tiefling…" - type="text" - value={draft.characterRace} - onChange={(e) => { setDraft((d) => ({ ...d, characterRace: e.target.value })); }} - /> - <span className="character-sheet-hint">{draft.characterRace.length} / 32</span> - - <label className="character-sheet-label" htmlFor="cs-class">Class</label> - <input - className="character-sheet-input" - id="cs-class" - maxLength={32} - placeholder="e.g. Paladin, Archmage, Shadow Rogue…" - type="text" - value={draft.characterClass} - onChange={(e) => { setDraft((d) => ({ ...d, characterClass: e.target.value })); }} - /> - <span className="character-sheet-hint">{draft.characterClass.length} / 32</span> - - <label className="character-sheet-label" htmlFor="cs-bio">About Your Character</label> - <textarea - className="character-sheet-textarea" - id="cs-bio" - maxLength={200} - placeholder="Describe your character's story, personality, or appearance…" - rows={4} - value={draft.bio} - onChange={(e) => { setDraft((d) => ({ ...d, bio: e.target.value })); }} - /> - <span className="character-sheet-hint">{draft.bio.length} / 200</span> - - {draft.unlockedTitles.length > 0 && ( - <> - <label className="character-sheet-label" htmlFor="cs-title">Active Title</label> - <select - className="character-sheet-input" - id="cs-title" - value={draft.activeTitle} - onChange={(e) => { setDraft((d) => ({ ...d, activeTitle: e.target.value })); }} - > - <option value="">— None —</option> - {draft.unlockedTitles.map((t) => ( - <option key={t.id} value={t.id}>{t.name}</option> - ))} - </select> - </> - )} - </div> - - <div className="character-sheet-section"> - <h3 className="character-sheet-section-title">🏰 Guild</h3> - - <label className="character-sheet-label" htmlFor="cs-guild-name">Guild Name</label> - <input - className="character-sheet-input" - id="cs-guild-name" - maxLength={64} - placeholder="Name your guild" - type="text" - value={draft.guildName} - onChange={(e) => { setDraft((d) => ({ ...d, guildName: e.target.value })); }} - /> - <span className="character-sheet-hint">{draft.guildName.length} / 64</span> - - <label className="character-sheet-label" htmlFor="cs-guild-desc">Guild Description</label> - <textarea - className="character-sheet-textarea" - id="cs-guild-desc" - maxLength={500} - placeholder="Describe your guild's history, goals, or lore…" - rows={6} - value={draft.guildDescription} - onChange={(e) => { setDraft((d) => ({ ...d, guildDescription: e.target.value })); }} - /> - <span className="character-sheet-hint">{draft.guildDescription.length} / 500</span> - </div> - - {error && <p className="character-sheet-error">{error}</p>} - - <div className="character-sheet-actions"> - <button className="character-sheet-cancel" onClick={handleCancel} type="button"> - Cancel - </button> - <button - className="character-sheet-save" - disabled={saving || !draft.characterName.trim()} - onClick={() => { void handleSave(); }} - type="button" - > - {saved ? "✓ Saved!" : saving ? "Saving…" : "Save"} - </button> - </div> - </div> - </section> - ); - } - - const subtitle = [sheet.characterRace, sheet.characterClass].filter(Boolean).join(" · "); - - return ( - <section className="panel character-sheet-panel"> - <div className="panel-header"> - <h2>📋 Character Sheet</h2> - <div className="character-sheet-header-actions"> - <button - className="character-sheet-edit-btn" - onClick={() => { - const url = `${window.location.origin}/character/${player?.discordId ?? ""}`; - void navigator.clipboard.writeText(url).then(() => { - setCopied(true); - setTimeout(() => { setCopied(false); }, 2000); - }); - }} - type="button" - > - {copied ? "✓ Copied!" : "🔗 Share"} - </button> - <a className="character-sheet-edit-btn" href="/leaderboards"> - 🏆 Boards - </a> - <button className="character-sheet-edit-btn" onClick={handleEdit} type="button"> - ✏️ Edit - </button> - </div> - </div> - - <div className="character-sheet-view"> - <div className="character-sheet-section"> - <h3 className="character-sheet-section-title">⚔️ Character</h3> - <div className="character-sheet-field"> - <span className="character-sheet-field-label">Name</span> - <span className="character-sheet-field-value"> - {sheet.characterName || <em className="character-sheet-empty">Not set</em>} - </span> - </div> - <div className="character-sheet-field"> - <span className="character-sheet-field-label">Streak</span> - <span className="character-sheet-streak"> - 🔥 {loginStreak}-day login streak - </span> - </div> - {sheet.activeTitle && ( - <div className="character-sheet-field"> - <span className="character-sheet-field-label">Title</span> - <span className="character-sheet-field-value character-sheet-title"> - {sheet.unlockedTitles.find((t) => t.id === sheet.activeTitle)?.name ?? sheet.activeTitle} - </span> - </div> - )} - {sheet.pronouns && ( - <div className="character-sheet-field"> - <span className="character-sheet-field-label">Pronouns</span> - <span className="character-sheet-field-value">{sheet.pronouns}</span> - </div> - )} - {subtitle && ( - <div className="character-sheet-field"> - <span className="character-sheet-field-label">Identity</span> - <span className="character-sheet-field-value">{subtitle}</span> - </div> - )} - {sheet.bio && ( - <div className="character-sheet-bio"> - <span className="character-sheet-field-label">About</span> - <p className="character-sheet-bio-text">{sheet.bio}</p> - </div> - )} - </div> - - <div className="character-sheet-section"> - <h3 className="character-sheet-section-title">🗡️ Equipment</h3> - {sheet.equippedItems.length > 0 ? ( - <div className="character-sheet-equipment-list"> - {sheet.equippedItems.map((item) => ( - <div className="character-sheet-equipment-item" key={item.type}> - <div className="character-sheet-equipment-header"> - <span className="character-sheet-equipment-slot">{SLOT_ICONS[item.type]}</span> - <span className={`character-sheet-equipment-name character-sheet-rarity--${item.rarity}`}> - {item.name} - </span> - <span className={`character-sheet-equipment-rarity character-sheet-rarity--${item.rarity}`}> - {item.rarity} - </span> - </div> - <p className="character-sheet-equipment-bonus">{formatBonus(item.bonus)}</p> - </div> - ))} - </div> - ) : ( - <p className="character-sheet-empty">No equipment found. Defeat bosses to earn gear!</p> - )} - </div> - - <div className="character-sheet-section"> - <h3 className="character-sheet-section-title">🏰 Guild</h3> - {sheet.guildName ? ( - <> - <div className="character-sheet-field"> - <span className="character-sheet-field-label">Name</span> - <span className="character-sheet-field-value">{sheet.guildName}</span> - </div> - {sheet.guildDescription && ( - <div className="character-sheet-bio"> - <span className="character-sheet-field-label">Lore</span> - <p className="character-sheet-bio-text">{sheet.guildDescription}</p> - </div> - )} - </> - ) : ( - <p className="character-sheet-empty">No guild registered yet. Click ✏️ Edit to add one!</p> - )} - </div> - - {(() => { - const completedChapters = state?.story?.completedChapters ?? []; - if (completedChapters.length === 0) return null; - return ( - <div className="character-sheet-section"> - <h3 className="character-sheet-section-title">📖 Story Choices</h3> - {completedChapters.map((completion) => { - const chapter = STORY_CHAPTERS.find((c) => c.id === completion.chapterId); - if (!chapter) return null; - const choice = chapter.choices.find((c) => c.id === completion.choiceId); - if (!choice) return null; - return ( - <div className="character-sheet-story-entry" key={completion.chapterId}> - <span className="character-sheet-story-chapter">{chapter.title}</span> - <span className="character-sheet-story-choice">{choice.label}</span> - </div> - ); - })} - </div> - ); - })()} - </div> - </section> - ); -}; diff --git a/apps/web/src/components/game/ClickArea.tsx b/apps/web/src/components/game/ClickArea.tsx deleted file mode 100644 index 4d5b6ca..0000000 --- a/apps/web/src/components/game/ClickArea.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useCallback, useRef, useState } from "react"; -import { useGame } from "../../context/GameContext.js"; -import { calculateClickPower } from "../../engine/tick.js"; - -interface FloatText { - id: number; - x: number; - y: number; - text: string; -} - -export const ClickArea = (): React.JSX.Element => { - const { state, handleClick, formatNumber, saveSchemaVersion, currentSchemaVersion } = useGame(); - const [floats, setFloats] = useState<FloatText[]>([]); - const nextIdRef = useRef(0); - - const handleClickWithFloat = useCallback( - (e: React.MouseEvent<HTMLButtonElement>) => { - if (!state) return; - const rect = e.currentTarget.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - const id = nextIdRef.current++; - const clickPower = calculateClickPower(state); - - setFloats((prev) => [...prev, { id, x, y, text: `+${formatNumber(clickPower)}` }]); - handleClick(); - - setTimeout(() => { - setFloats((prev) => prev.filter((f) => f.id !== id)); - }, 900); - }, - [state, handleClick], - ); - - if (!state) return <div className="click-area-placeholder" />; - - const clickPower = calculateClickPower(state); - - return ( - <section className="click-area"> - <h1 className="game-title">Elysium</h1> - <p className="game-version">v{__WEB_VERSION__}</p> - {currentSchemaVersion > 0 && ( - <p className="game-schema-version"> - Save: v{saveSchemaVersion} / Latest: v{currentSchemaVersion} - </p> - )} - <h2>Guild Hall</h2> - <div className="click-button-wrapper"> - <button - className="click-button" - onClick={handleClickWithFloat} - type="button" - aria-label={`Click to earn ${formatNumber(clickPower)} gold`} - > - <img - alt="Guild Hall" - className="click-button-image" - src="https://cdn.nhcarrigan.com/avatars/elysium.png" - /> - </button> - {floats.map((float) => ( - <span - key={float.id} - className="click-float" - style={{ left: float.x, top: float.y }} - > - {float.text} - </span> - ))} - </div> - <p className="click-power">+{formatNumber(clickPower)} gold/click</p> - <p className="early-access-notice"> - ⚠️ Early Access — this build is subject to change. <strong>All game progress WILL be reset upon v1.0.0 release.</strong> - </p> - </section> - ); -}; diff --git a/apps/web/src/components/game/CodexPanel.tsx b/apps/web/src/components/game/CodexPanel.tsx deleted file mode 100644 index ef0c7e1..0000000 --- a/apps/web/src/components/game/CodexPanel.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { useState } from "react"; -import type { CodexEntry } from "@elysium/types"; -import { CODEX_ENTRIES, ZONE_LABELS } from "../../data/codex.js"; -import { useGame } from "../../context/GameContext.js"; - -const SOURCE_BADGE: Record<CodexEntry["sourceType"], string> = { - boss: "⚔️", - quest: "📜", - equipment: "🛡️", - adventurer: "👥", - upgrade: "🔧", - prestige: "🔮", - zone: "🗺️", - exploration: "🧭", - recipe: "⚗️", -}; - -export const CodexPanel = (): React.JSX.Element => { - const { state } = useGame(); - const [expandedId, setExpandedId] = useState<string | null>(null); - - if (!state) return <section className="panel"><p>Loading...</p></section>; - - const unlockedIds = new Set(state.codex?.unlockedEntryIds ?? []); - const totalEntries = CODEX_ENTRIES.length; - const unlockedCount = CODEX_ENTRIES.filter((e) => unlockedIds.has(e.id)).length; - const progressPercent = totalEntries > 0 ? (unlockedCount / totalEntries) * 100 : 0; - - const entriesByZone = Object.entries(ZONE_LABELS).map(([zoneId, zoneName]) => { - const zoneEntries = CODEX_ENTRIES.filter((e) => e.zoneId === zoneId); - const unlockedZoneEntries = zoneEntries.filter((e) => unlockedIds.has(e.id)); - return { zoneId, zoneName, entries: zoneEntries, unlockedEntries: unlockedZoneEntries }; - }).filter(({ entries }) => entries.length > 0); - - return ( - <section className="panel codex-panel"> - <h2>📖 Codex</h2> - - <div className="codex-progress"> - <p className="codex-progress-text"> - Lore discovered: <strong>{unlockedCount} / {totalEntries}</strong> - </p> - <div className="codex-progress-bar"> - <div className="codex-progress-fill" style={{ width: `${progressPercent}%` }} /> - </div> - </div> - - {entriesByZone.map(({ zoneId, zoneName, entries, unlockedEntries }) => ( - <div key={zoneId} className="codex-zone"> - <h3 className="codex-zone-header"> - {zoneName} - <span className="codex-zone-count"> - {unlockedEntries.length}/{entries.length} - </span> - </h3> - - <div className="codex-entries"> - {entries.map((entry) => { - const isUnlocked = unlockedIds.has(entry.id); - const isExpanded = expandedId === entry.id; - - if (!isUnlocked) { - return ( - <div key={entry.id} className="codex-entry locked"> - <div className="codex-entry-header"> - <span className="codex-lock">🔒</span> - <span className="codex-entry-title">???</span> - </div> - </div> - ); - } - - return ( - <div - key={entry.id} - className={`codex-entry unlocked ${isExpanded ? "expanded" : ""}`} - onClick={() => { setExpandedId(isExpanded ? null : entry.id); }} - > - <div className="codex-entry-header"> - <span className="codex-source-badge"> - {SOURCE_BADGE[entry.sourceType]} - </span> - <span className="codex-entry-title">{entry.title}</span> - <span className="codex-chevron">{isExpanded ? "▲" : "▼"}</span> - </div> - {isExpanded && ( - <p className="codex-entry-content">{entry.content}</p> - )} - </div> - ); - })} - </div> - </div> - ))} - </section> - ); -}; diff --git a/apps/web/src/components/game/CodexToast.tsx b/apps/web/src/components/game/CodexToast.tsx deleted file mode 100644 index 270b4b3..0000000 --- a/apps/web/src/components/game/CodexToast.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useEffect } from "react"; -import { CODEX_ENTRIES } from "../../data/codex.js"; -import { useGame } from "../../context/GameContext.js"; - -interface CodexToastItemProps { - entryId: string; - onDismiss: (id: string) => void; -} - -const CodexToastItem = ({ entryId, onDismiss }: CodexToastItemProps): React.JSX.Element | null => { - const entry = CODEX_ENTRIES.find((e) => e.id === entryId); - - useEffect(() => { - const timer = setTimeout(() => { - onDismiss(entryId); - }, 4000); - return () => { clearTimeout(timer); }; - }, [entryId, onDismiss]); - - if (!entry) return null; - - return ( - <div className="codex-toast" onClick={() => { onDismiss(entryId); }}> - <span className="toast-icon">📖</span> - <div className="toast-content"> - <span className="toast-label">✨ Lore Unlocked!</span> - <span className="toast-name">{entry.title}</span> - </div> - </div> - ); -}; - -export const CodexToast = (): React.JSX.Element | null => { - const { newCodexEntryIds, dismissCodexEntry } = useGame(); - - if (newCodexEntryIds.length === 0) return null; - - return ( - <div className="achievement-toast-container"> - {newCodexEntryIds.map((id) => ( - <CodexToastItem key={id} entryId={id} onDismiss={dismissCodexEntry} /> - ))} - </div> - ); -}; diff --git a/apps/web/src/components/game/CompanionPanel.tsx b/apps/web/src/components/game/CompanionPanel.tsx deleted file mode 100644 index a6c87e5..0000000 --- a/apps/web/src/components/game/CompanionPanel.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { COMPANIONS } from "@elysium/types"; -import type { Companion } from "@elysium/types"; -import { useGame } from "../../context/GameContext.js"; - -const BONUS_LABELS: Record<string, string> = { - passiveGold: "Passive Gold", - clickGold: "Click Gold", - bossDamage: "Boss Damage", - essenceIncome: "Essence Income", - questTime: "Quest Time", -}; - -const UNLOCK_LABELS: Record<string, string> = { - lifetimeBosses: "lifetime bosses defeated", - lifetimeQuests: "lifetime quests completed", - lifetimeGold: "lifetime gold earned", - prestige: "prestige(s)", - transcendence: "transcendence(s)", - apotheosis: "apotheosis", -}; - -const formatThreshold = (type: string, threshold: number): string => { - if (type === "lifetimeGold") { - if (threshold >= 1e18) return `${(threshold / 1e18).toFixed(0)}Qt`; - if (threshold >= 1e15) return `${(threshold / 1e15).toFixed(0)}Q`; - if (threshold >= 1e12) return `${(threshold / 1e12).toFixed(0)}T`; - if (threshold >= 1e9) return `${(threshold / 1e9).toFixed(0)}B`; - if (threshold >= 1e6) return `${(threshold / 1e6).toFixed(0)}M`; - if (threshold >= 1e3) return `${(threshold / 1e3).toFixed(0)}K`; - return threshold.toString(); - } - return threshold.toString(); -}; - -const CompanionCard = ({ - companion, - isUnlocked, - isActive, - onSelect, -}: { - companion: Companion; - isUnlocked: boolean; - isActive: boolean; - onSelect: () => void; -}): React.JSX.Element => { - const bonusSign = companion.bonus.type === "questTime" ? "-" : "+"; - const bonusPercent = Math.round(companion.bonus.value * 100); - const bonusLabel = BONUS_LABELS[companion.bonus.type] ?? companion.bonus.type; - - return ( - <div className={`companion-card ${isUnlocked ? "companion-unlocked" : "companion-locked"} ${isActive ? "companion-active" : ""}`}> - <div className="companion-header"> - <div className="companion-name-block"> - <span className="companion-name">{companion.name}</span> - <span className="companion-title">{companion.title}</span> - </div> - {isActive && <span className="companion-active-badge">Active</span>} - </div> - - <p className="companion-description">{companion.description}</p> - - <div className="companion-bonus"> - <span className="companion-bonus-label">{bonusLabel}</span> - <span className="companion-bonus-value">{bonusSign}{bonusPercent}%</span> - </div> - - {isUnlocked ? ( - <button - className={`companion-select-btn ${isActive ? "companion-select-active" : ""}`} - onClick={onSelect} - type="button" - > - {isActive ? "Deactivate" : "Activate"} - </button> - ) : ( - <div className="companion-unlock-requirement"> - 🔒 Unlock: {formatThreshold(companion.unlock.type, companion.unlock.threshold)} {UNLOCK_LABELS[companion.unlock.type] ?? companion.unlock.type} - </div> - )} - </div> - ); -}; - -export const CompanionPanel = (): React.JSX.Element => { - const { state, setActiveCompanion } = useGame(); - - if (!state) return <></>; - - const unlockedIds = state.companions?.unlockedCompanionIds ?? []; - const activeId = state.companions?.activeCompanionId ?? null; - - const handleSelect = (companionId: string): void => { - setActiveCompanion(activeId === companionId ? null : companionId); - }; - - return ( - <div className="companion-panel"> - <h2>👥 Companions</h2> - <p className="companion-intro"> - Companions provide powerful bonuses while active. You can only have one companion active at a time. - {activeId && ( - <> Currently active: <strong>{COMPANIONS.find((c) => c.id === activeId)?.name ?? activeId}</strong>.</> - )} - </p> - - <div className="companion-grid"> - {COMPANIONS.map((companion) => ( - <CompanionCard - key={companion.id} - companion={companion} - isUnlocked={unlockedIds.includes(companion.id)} - isActive={activeId === companion.id} - onSelect={() => { handleSelect(companion.id); }} - /> - ))} - </div> - </div> - ); -}; diff --git a/apps/web/src/components/game/CraftingPanel.tsx b/apps/web/src/components/game/CraftingPanel.tsx deleted file mode 100644 index 894c921..0000000 --- a/apps/web/src/components/game/CraftingPanel.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { useState } from "react"; -import { useGame } from "../../context/GameContext.js"; -import { MATERIALS } from "../../data/materials.js"; -import { RECIPES } from "../../data/recipes.js"; -import { ZoneSelector } from "./ZoneSelector.js"; - -const BONUS_LABEL: Record<string, string> = { - gold_income: "🪙 Gold Income", - essence_income: "✨ Essence Income", - click_power: "👆 Click Power", - combat_power: "⚔️ Combat Power", -}; - -export const CraftingPanel = (): React.JSX.Element => { - const { state, craftRecipe, formatNumber } = useGame(); - const [activeZoneId, setActiveZoneId] = useState("verdant_vale"); - const [pendingRecipeId, setPendingRecipeId] = useState<string | null>(null); - - if (!state) return <section className="panel"><p>Loading...</p></section>; - - const zones = state.zones ?? []; - const explorationState = state.exploration; - const playerMaterials = explorationState?.materials ?? []; - const craftedIds = explorationState?.craftedRecipeIds ?? []; - - const zoneRecipes = RECIPES.filter((r) => r.zoneId === activeZoneId); - const zoneMaterials = MATERIALS.filter((m) => m.zoneId === activeZoneId); - - const getQuantity = (materialId: string): number => - playerMaterials.find((m) => m.materialId === materialId)?.quantity ?? 0; - - const canAfford = (recipeId: string): boolean => { - const recipe = RECIPES.find((r) => r.id === recipeId); - if (!recipe) return false; - return recipe.requiredMaterials.every( - (req) => getQuantity(req.materialId) >= req.quantity, - ); - }; - - const handleCraft = async (recipeId: string): Promise<void> => { - setPendingRecipeId(recipeId); - try { - await craftRecipe(recipeId); - } finally { - setPendingRecipeId(null); - } - }; - - return ( - <section className="panel crafting-panel"> - <div className="panel-header"> - <h2>⚗️ Crafting</h2> - </div> - - <ZoneSelector - activeZoneId={activeZoneId} - zones={zones} - onSelectZone={(id) => { setActiveZoneId(id); }} - /> - - <div className="crafting-content"> - <div className="materials-section"> - <h3>📦 Materials</h3> - {zoneMaterials.length === 0 ? ( - <p className="empty-zone">No materials in this zone.</p> - ) : ( - <div className="materials-list"> - {zoneMaterials.map((material) => { - const qty = getQuantity(material.id); - return ( - <div key={material.id} className={`material-card rarity-${material.rarity} ${qty === 0 ? "material-empty" : ""}`}> - <div className="material-info"> - <span className="material-name">{material.name}</span> - <span className="material-rarity">{material.rarity}</span> - </div> - <span className="material-quantity">{formatNumber(qty)}</span> - </div> - ); - })} - </div> - )} - </div> - - <div className="recipes-section"> - <h3>📜 Recipes</h3> - {zoneRecipes.length === 0 ? ( - <p className="empty-zone">No recipes in this zone.</p> - ) : ( - <div className="recipes-list"> - {zoneRecipes.map((recipe) => { - const crafted = craftedIds.includes(recipe.id); - const affordable = canAfford(recipe.id); - const isPending = pendingRecipeId === recipe.id; - - return ( - <div key={recipe.id} className={`recipe-card ${crafted ? "recipe-crafted" : ""} ${!affordable && !crafted ? "recipe-unaffordable" : ""}`}> - <div className="recipe-info"> - <h4>{recipe.name}</h4> - <p className="recipe-description">{recipe.description}</p> - <div className="recipe-bonus"> - <span className="bonus-label">{BONUS_LABEL[recipe.bonus.type] ?? recipe.bonus.type}</span> - <span className="bonus-value">×{recipe.bonus.value.toFixed(2)}</span> - </div> - <div className="recipe-requirements"> - {recipe.requiredMaterials.map((req) => { - const have = getQuantity(req.materialId); - const enough = have >= req.quantity; - const matName = MATERIALS.find((m) => m.id === req.materialId)?.name ?? req.materialId; - return ( - <span key={req.materialId} className={`req-tag ${enough ? "req-met" : "req-missing"}`}> - {matName}: {formatNumber(have)}/{formatNumber(req.quantity)} - </span> - ); - })} - </div> - </div> - <div className="recipe-action"> - {crafted ? ( - <span className="quest-badge active">✅ Crafted</span> - ) : ( - <button - className="craft-button" - disabled={!affordable || isPending || pendingRecipeId !== null} - onClick={() => { void handleCraft(recipe.id); }} - type="button" - > - {isPending ? "Crafting..." : "⚗️ Craft"} - </button> - )} - </div> - </div> - ); - })} - </div> - )} - </div> - </div> - </section> - ); -}; diff --git a/apps/web/src/components/game/DailyChallengePanel.tsx b/apps/web/src/components/game/DailyChallengePanel.tsx deleted file mode 100644 index b72b945..0000000 --- a/apps/web/src/components/game/DailyChallengePanel.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { useGame } from "../../context/GameContext.js"; - -const formatTimeUntilReset = (): string => { - const now = new Date(); - // Mirror the server's PST/PDT-based rollover: challenges reset at PST midnight - const nowAsPST = new Date(now.toLocaleString("en-US", { timeZone: "America/Los_Angeles" })); - const tomorrowMidnightPST = new Date(nowAsPST); - tomorrowMidnightPST.setDate(tomorrowMidnightPST.getDate() + 1); - tomorrowMidnightPST.setHours(0, 0, 0, 0); - const pstOffset = nowAsPST.getTime() - now.getTime(); - const resetAt = new Date(tomorrowMidnightPST.getTime() - pstOffset); - const msRemaining = resetAt.getTime() - now.getTime(); - const hoursRemaining = Math.floor(msRemaining / (1000 * 60 * 60)); - const minutesRemaining = Math.floor((msRemaining % (1000 * 60 * 60)) / (1000 * 60)); - return `${String(hoursRemaining)}h ${String(minutesRemaining)}m`; -}; - -export const DailyChallengePanel = (): React.JSX.Element => { - const { state, formatNumber } = useGame(); - - if (!state) return <section className="panel"><p>Loading...</p></section>; - - const { dailyChallenges } = state; - - if (!dailyChallenges) { - return ( - <section className="panel daily-challenge-panel"> - <h2>📅 Daily Challenges</h2> - <p className="daily-challenge-subtitle">Load the game to generate today's challenges!</p> - </section> - ); - } - - const completedCount = dailyChallenges.challenges.filter((c) => c.completed).length; - - return ( - <section className="panel daily-challenge-panel"> - <h2>📅 Daily Challenges</h2> - <div className="daily-challenge-header"> - <p className="daily-challenge-subtitle"> - Complete challenges for bonus 💎 crystals! Resets in{" "} - <strong>{formatTimeUntilReset()}</strong> (PST midnight). - </p> - <p className="daily-challenge-progress"> - {completedCount} / {dailyChallenges.challenges.length} completed - </p> - </div> - - <div className="daily-challenge-list"> - {dailyChallenges.challenges.map((challenge) => { - const progressPercent = Math.min( - 100, - Math.floor((challenge.progress / challenge.target) * 100), - ); - - return ( - <div - key={challenge.id} - className={`daily-challenge-card ${challenge.completed ? "completed" : ""}`} - > - <div className="daily-challenge-info"> - <h3 className="daily-challenge-label">{challenge.label}</h3> - <p className="daily-challenge-reward"> - Reward: <strong>💎 {formatNumber(challenge.rewardCrystals)} crystals</strong> - </p> - </div> - - <div className="daily-challenge-right"> - {challenge.completed ? ( - <span className="daily-challenge-done">✅ Complete!</span> - ) : ( - <> - <p className="daily-challenge-count"> - {formatNumber(challenge.progress)} / {formatNumber(challenge.target)} - </p> - <div className="daily-challenge-bar-track"> - <div - className="daily-challenge-bar-fill" - style={{ width: `${String(progressPercent)}%` }} - /> - </div> - </> - )} - </div> - </div> - ); - })} - </div> - </section> - ); -}; diff --git a/apps/web/src/components/game/EditProfileModal.tsx b/apps/web/src/components/game/EditProfileModal.tsx deleted file mode 100644 index 896302f..0000000 --- a/apps/web/src/components/game/EditProfileModal.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import type { NumberFormat, ProfileSettings } from "@elysium/types"; -import { DEFAULT_PROFILE_SETTINGS } from "@elysium/types"; -import { useEffect, useState } from "react"; -import { updateProfile } from "../../api/client.js"; -import { useGame } from "../../context/GameContext.js"; - -interface EditProfileModalProps { - onClose: () => void; -} - -interface StatToggle { - key: keyof ProfileSettings; - label: string; - icon: string; -} - -const CURRENT_RUN_TOGGLES: StatToggle[] = [ - { key: "showCurrentGold", label: "Gold Earned This Run", icon: "🪙" }, - { key: "showCurrentClicks", label: "Clicks This Run", icon: "👆" }, - { key: "showApotheosis", label: "Apotheosis Badge", icon: "✨" }, - { key: "showTranscendence", label: "Transcendence Badge", icon: "🌌" }, - { key: "showPrestige", label: "Prestige Level", icon: "⭐" }, - { key: "showBossesDefeated", label: "Bosses Defeated", icon: "💀" }, - { key: "showQuestsCompleted", label: "Quests Completed", icon: "📜" }, - { key: "showAdventurersRecruited", label: "Adventurers Recruited", icon: "⚔️" }, - { key: "showAchievementsUnlocked", label: "Achievements Unlocked", icon: "🏆" }, -]; - -const ALL_TIME_TOGGLES: StatToggle[] = [ - { key: "showTotalGold", label: "Total Gold Earned", icon: "🪙" }, - { key: "showTotalClicks", label: "Total Clicks", icon: "👆" }, - { key: "showLifetimeBossesDefeated", label: "Bosses Defeated", icon: "💀" }, - { key: "showLifetimeQuestsCompleted", label: "Quests Completed", icon: "📜" }, - { key: "showLifetimeAdventurersRecruited", label: "Adventurers Recruited", icon: "⚔️" }, - { key: "showLifetimeAchievementsUnlocked", label: "Achievements Unlocked", icon: "🏆" }, - { key: "showGuildFounded", label: "Guild Founded Date", icon: "📅" }, -]; - -export const EditProfileModal = ({ onClose }: EditProfileModalProps): React.JSX.Element => { - const { state, numberFormat: currentNumberFormat, setNumberFormat } = useGame(); - const player = state?.player; - - const [characterName, setCharacterName] = useState(player?.characterName ?? ""); - const [bio, setBio] = useState(""); - const [settings, setSettings] = useState<ProfileSettings>({ - ...DEFAULT_PROFILE_SETTINGS, - numberFormat: currentNumberFormat, - }); - const [loadingProfile, setLoadingProfile] = useState(true); - const [saving, setSaving] = useState(false); - const [error, setError] = useState<string | null>(null); - const [saved, setSaved] = useState(false); - - // Fetch current profile to auto-populate bio and settings - useEffect(() => { - if (!player?.discordId) return; - fetch(`/api/profile/${player.discordId}`) - .then(async (res) => { - if (!res.ok) return; - const data = (await res.json()) as { - bio: string; - profileSettings: ProfileSettings; - characterName: string; - }; - setBio(data.bio ?? ""); - setSettings({ ...DEFAULT_PROFILE_SETTINGS, ...data.profileSettings }); - setCharacterName(data.characterName ?? player.characterName ?? ""); - }) - .catch(() => { - // Fall back to local state if fetch fails — not a blocking error - }) - .finally(() => { - setLoadingProfile(false); - }); - }, [player?.discordId, player?.characterName]); - - const handleSave = async (): Promise<void> => { - setSaving(true); - setError(null); - try { - await updateProfile({ characterName, bio, profileSettings: settings }); - setNumberFormat(settings.numberFormat); - setSaved(true); - setTimeout(onClose, 900); - } catch (err: unknown) { - setError(err instanceof Error ? err.message : "Failed to save"); - } finally { - setSaving(false); - } - }; - - const toggleSetting = (key: keyof ProfileSettings): void => { - setSettings((prev) => ({ ...prev, [key]: !prev[key] })); - }; - - return ( - <div className="modal-overlay" role="dialog" aria-modal="true"> - <div className="modal edit-profile-modal"> - <div className="modal-header"> - <h2>Edit Profile</h2> - <button - aria-label="Close" - className="modal-close" - onClick={onClose} - type="button" - > - ✕ - </button> - </div> - - {loadingProfile ? ( - <p className="edit-profile-loading">Loading your profile…</p> - ) : ( - <div className="edit-profile-form"> - <label className="edit-profile-label" htmlFor="edit-char-name"> - Display Name - </label> - <input - className="edit-profile-input" - id="edit-char-name" - maxLength={32} - placeholder="Your character's name" - type="text" - value={characterName} - onChange={(e) => { setCharacterName(e.target.value); }} - /> - <span className="edit-profile-hint">{characterName.length} / 32</span> - - <label className="edit-profile-label" htmlFor="edit-bio"> - Bio - </label> - <textarea - className="edit-profile-textarea" - id="edit-bio" - maxLength={200} - placeholder="Tell the world about your guild… (optional)" - rows={3} - value={bio} - onChange={(e) => { setBio(e.target.value); }} - /> - <span className="edit-profile-hint">{bio.length} / 200</span> - - <div className="edit-profile-section"> - <p className="edit-profile-label">Visible Stats</p> - <p className="edit-profile-sublabel">Choose which stats appear on your public profile.</p> - - <p className="edit-profile-stat-group-heading">Current Run</p> - <div className="stat-toggles"> - {CURRENT_RUN_TOGGLES.map(({ key, label, icon }) => ( - <button - key={key} - className={`stat-toggle-btn ${settings[key] ? "stat-toggle-on" : "stat-toggle-off"}`} - onClick={() => { toggleSetting(key); }} - type="button" - > - <span>{icon} {label}</span> - <span className="stat-toggle-indicator"> - {settings[key] ? "✓ Shown" : "Hidden"} - </span> - </button> - ))} - </div> - - <p className="edit-profile-stat-group-heading">All Time</p> - <div className="stat-toggles"> - {ALL_TIME_TOGGLES.map(({ key, label, icon }) => ( - <button - key={key} - className={`stat-toggle-btn ${settings[key] ? "stat-toggle-on" : "stat-toggle-off"}`} - onClick={() => { toggleSetting(key); }} - type="button" - > - <span>{icon} {label}</span> - <span className="stat-toggle-indicator"> - {settings[key] ? "✓ Shown" : "Hidden"} - </span> - </button> - ))} - </div> - </div> - - <div className="edit-profile-section"> - <p className="edit-profile-label">Privacy</p> - <p className="edit-profile-sublabel">Control your visibility on public leaderboards.</p> - <button - className={`stat-toggle-btn ${settings.showOnLeaderboards ? "stat-toggle-on" : "stat-toggle-off"}`} - onClick={() => { toggleSetting("showOnLeaderboards"); }} - type="button" - > - <span>🏆 Appear on Leaderboards</span> - <span className="stat-toggle-indicator"> - {settings.showOnLeaderboards ? "✓ Shown" : "Hidden"} - </span> - </button> - </div> - - <div className="edit-profile-section"> - <p className="edit-profile-label">Number Format</p> - <p className="edit-profile-sublabel">How large numbers appear across the game.</p> - <div className="number-format-picker"> - {( - [ - { value: "suffix", label: "Suffix", example: "1.23Qa" }, - { value: "scientific", label: "Scientific", example: "1.23e15" }, - { value: "engineering", label: "Engineering", example: "1.23E15" }, - ] as { value: NumberFormat; label: string; example: string }[] - ).map(({ value, label, example }) => ( - <button - key={value} - className={`number-format-btn ${settings.numberFormat === value ? "number-format-active" : ""}`} - onClick={() => { setSettings((prev) => ({ ...prev, numberFormat: value })); }} - type="button" - > - <span className="number-format-label">{label}</span> - <span className="number-format-example">{example}</span> - </button> - ))} - </div> - </div> - - {error && <p className="edit-profile-error">{error}</p>} - - <div className="edit-profile-actions"> - <button - className="edit-profile-cancel" - onClick={onClose} - type="button" - > - Cancel - </button> - <button - className="edit-profile-save" - disabled={saving || !characterName.trim()} - onClick={() => { void handleSave(); }} - type="button" - > - {saved ? "✓ Saved!" : saving ? "Saving…" : "Save Profile"} - </button> - </div> - </div> - )} - </div> - </div> - ); -}; diff --git a/apps/web/src/components/game/EquipmentPanel.tsx b/apps/web/src/components/game/EquipmentPanel.tsx deleted file mode 100644 index b348c68..0000000 --- a/apps/web/src/components/game/EquipmentPanel.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import type { Equipment, EquipmentType } from "@elysium/types"; -import { useState } from "react"; -import { useGame } from "../../context/GameContext.js"; -import { EQUIPMENT_SETS } from "../../data/equipmentSets.js"; -import { LockToggle } from "../ui/LockToggle.js"; - -const RARITY_LABEL: Record<string, string> = { - common: "Common", - rare: "Rare", - epic: "Epic", - legendary: "Legendary", -}; - -const TYPE_ICON: Record<EquipmentType, string> = { - weapon: "⚔️", - armour: "🛡️", - trinket: "💍", -}; - -const bonusDescription = (item: Equipment): string => { - const parts: string[] = []; - if (item.bonus.combatMultiplier != null) { - parts.push(`+${Math.round((item.bonus.combatMultiplier - 1) * 100)}% Combat`); - } - if (item.bonus.goldMultiplier != null) { - parts.push(`+${Math.round((item.bonus.goldMultiplier - 1) * 100)}% Gold/s`); - } - if (item.bonus.clickMultiplier != null) { - parts.push(`+${Math.round((item.bonus.clickMultiplier - 1) * 100)}% Click`); - } - return parts.join(", "); -}; - -interface EquipmentCardProps { - item: Equipment; - gold: number; - essence: number; - crystals: number; - dropBossName?: string | undefined; - setName?: string | undefined; -} - -const costLabel = (cost: { gold: number; essence: number; crystals: number }): string => { - const parts: string[] = []; - if (cost.gold > 0) parts.push(`🪙 ${cost.gold.toLocaleString()}`); - if (cost.essence > 0) parts.push(`✨ ${cost.essence.toLocaleString()}`); - if (cost.crystals > 0) parts.push(`💎 ${cost.crystals.toLocaleString()}`); - return parts.join(" "); -}; - -const EquipmentCard = ({ item, gold, essence, crystals, dropBossName, setName }: EquipmentCardProps): React.JSX.Element => { - const { equipItem, buyEquipment } = useGame(); - - const canAfford = item.cost - ? gold >= item.cost.gold && essence >= item.cost.essence && crystals >= item.cost.crystals - : false; - - return ( - <div className={`equipment-card rarity-${item.rarity} ${item.equipped ? "equipped" : ""} ${!item.owned ? "not-owned" : ""}`}> - <div className="equipment-icon">{TYPE_ICON[item.type]}</div> - <div className="equipment-info"> - <div className="equipment-name-row"> - <h3>{item.name}</h3> - <span className={`rarity-badge rarity-${item.rarity}`}>{RARITY_LABEL[item.rarity]}</span> - </div> - <p className="equipment-description">{item.description}</p> - <p className="equipment-bonus">{bonusDescription(item)}</p> - {setName && <span className="equipment-set-badge">🔗 {setName}</span>} - {!item.owned && item.cost && ( - <p className="equipment-cost">{costLabel(item.cost)}</p> - )} - </div> - <div className="equipment-action"> - {!item.owned && !item.cost && ( - <span className="equipment-locked"> - {dropBossName ? `⚔️ Drop: ${dropBossName}` : "🔒 Boss drop"} - </span> - )} - {!item.owned && item.cost && ( - <button - className="equip-button" - disabled={!canAfford} - onClick={() => { buyEquipment(item.id); }} - type="button" - > - {canAfford ? "Purchase" : "Can't afford"} - </button> - )} - {item.owned && item.equipped && <span className="equipment-equipped-badge">✓ Equipped</span>} - {item.owned && !item.equipped && ( - <button - className="equip-button" - onClick={() => { equipItem(item.id); }} - type="button" - > - Equip - </button> - )} - </div> - </div> - ); -}; - -const SLOT_ORDER: EquipmentType[] = ["weapon", "armour", "trinket"]; -const SLOT_LABEL: Record<EquipmentType, string> = { - weapon: "⚔️ Weapons", - armour: "🛡️ Armour", - trinket: "💍 Trinkets", -}; - -export const EquipmentPanel = (): React.JSX.Element => { - const { state } = useGame(); - const [showLocked, setShowLocked] = useState(true); - - if (!state) return <section className="panel"><p>Loading...</p></section>; - - const equipment = state.equipment ?? []; - const unownedCount = equipment.filter((e) => !e.owned).length; - - const equipmentDropSources = new Map<string, string>(); - for (const boss of state.bosses) { - for (const equipmentId of (boss.equipmentRewards ?? [])) { - equipmentDropSources.set(equipmentId, boss.name); - } - } - - // Build set name lookup for card badges - const setNameById = new Map<string, string>( - EQUIPMENT_SETS.map((s) => [s.id, s.name]), - ); - - // Compute active set bonuses for the summary strip - const equippedItemIds = equipment.filter((e) => e.equipped).map((e) => e.id); - const activeSets = EQUIPMENT_SETS.map((set) => { - const count = set.pieces.filter((id) => equippedItemIds.includes(id)).length; - return { set, count }; - }).filter(({ count }) => count >= 2); - - const setBonusDescription = (set: typeof EQUIPMENT_SETS[number], count: number): string => { - const parts: string[] = []; - for (const threshold of [2, 3] as const) { - if (count >= threshold) { - const bonus = set.bonuses[threshold]; - if (bonus.goldMultiplier) parts.push(`+${Math.round((bonus.goldMultiplier - 1) * 100)}% Gold/s (${threshold}pc)`); - if (bonus.combatMultiplier) parts.push(`+${Math.round((bonus.combatMultiplier - 1) * 100)}% Combat (${threshold}pc)`); - if (bonus.clickMultiplier) parts.push(`+${Math.round((bonus.clickMultiplier - 1) * 100)}% Click (${threshold}pc)`); - } - } - return parts.join(", "); - }; - - return ( - <section className="panel equipment-panel"> - <div className="panel-header"> - <h2>Equipment</h2> - <LockToggle - lockedCount={unownedCount} - showLocked={showLocked} - onToggle={() => { setShowLocked((v) => !v); }} - /> - </div> - <p className="equipment-intro"> - Equipment drops from bosses and grants passive bonuses. Only one item per slot can be equipped at a time. Equip matching set pieces for bonus effects! - </p> - - {activeSets.length > 0 && ( - <div className="active-sets"> - <h3 className="active-sets-heading">✨ Active Set Bonuses</h3> - {activeSets.map(({ set, count }) => ( - <div key={set.id} className="active-set-row"> - <span className="active-set-name">{set.name} ({count}/{set.pieces.length})</span> - <span className="active-set-bonus">{setBonusDescription(set, count)}</span> - </div> - ))} - </div> - )} - - {SLOT_ORDER.map((slotType) => { - const items = equipment.filter( - (e) => e.type === slotType && (showLocked || e.owned), - ); - return ( - <div key={slotType} className="equipment-slot-section"> - <h3 className="slot-heading">{SLOT_LABEL[slotType]}</h3> - <div className="equipment-list"> - {items.map((item) => ( - <EquipmentCard - key={item.id} - item={item} - gold={state.resources.gold} - essence={state.resources.essence} - crystals={state.resources.crystals} - dropBossName={equipmentDropSources.get(item.id)} - setName={item.setId ? setNameById.get(item.setId) : undefined} - /> - ))} - {items.length === 0 && ( - <p className="empty-zone">No items to show in this slot.</p> - )} - </div> - </div> - ); - })} - </section> - ); -}; diff --git a/apps/web/src/components/game/ExplorationPanel.tsx b/apps/web/src/components/game/ExplorationPanel.tsx deleted file mode 100644 index 0f621ed..0000000 --- a/apps/web/src/components/game/ExplorationPanel.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import type { ExploreCollectResponse } from "@elysium/types"; -import { useState } from "react"; -import { useGame } from "../../context/GameContext.js"; -import { EXPLORATION_AREAS } from "../../data/explorations.js"; -import { ZoneSelector } from "./ZoneSelector.js"; - -const formatDuration = (seconds: number): string => { - if (seconds >= 86400) { - const days = Math.floor(seconds / 86400); - const hours = Math.floor((seconds % 86400) / 3600); - return hours > 0 ? `${days}d ${hours}h` : `${days}d`; - } - if (seconds >= 3600) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; - if (seconds >= 60) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; - return `${seconds}s`; -}; - -const timeRemaining = (startedAt: number, durationSeconds: number): number => { - const elapsed = (Date.now() - startedAt) / 1000; - return Math.max(0, durationSeconds - elapsed); -}; - -interface CollectResult { - areaId: string; - response: ExploreCollectResponse; -} - -export const ExplorationPanel = (): React.JSX.Element => { - const { state, startExploration, collectExploration, formatNumber } = useGame(); - const [activeZoneId, setActiveZoneId] = useState("verdant_vale"); - const [pendingAreaId, setPendingAreaId] = useState<string | null>(null); - const [lastResult, setLastResult] = useState<CollectResult | null>(null); - - if (!state) return <section className="panel"><p>Loading...</p></section>; - - const zones = state.zones ?? []; - const explorationState = state.exploration; - - const zoneAreas = EXPLORATION_AREAS.filter((a) => a.zoneId === activeZoneId); - - const hasActiveExploration = - explorationState?.areas.some((a) => a.status === "in_progress") ?? false; - - const handleStart = async (areaId: string): Promise<void> => { - setPendingAreaId(areaId); - try { - await startExploration(areaId); - } finally { - setPendingAreaId(null); - } - }; - - const handleCollect = async (areaId: string): Promise<void> => { - setPendingAreaId(areaId); - try { - const result = await collectExploration(areaId); - setLastResult({ areaId, response: result }); - } finally { - setPendingAreaId(null); - } - }; - - return ( - <section className="panel exploration-panel"> - <div className="panel-header"> - <h2>🗺️ Exploration</h2> - </div> - - {lastResult && ( - <div className="exploration-result"> - <button - className="exploration-result-close" - onClick={() => { setLastResult(null); }} - type="button" - > - ✕ - </button> - {lastResult.response.foundNothing ? ( - <p className="exploration-nothing">{lastResult.response.nothingMessage}</p> - ) : ( - <> - {lastResult.response.event && ( - <p className="exploration-event-text">{lastResult.response.event.text}</p> - )} - <div className="exploration-rewards"> - {(lastResult.response.event?.goldChange ?? 0) !== 0 && ( - <span className={`reward-tag ${(lastResult.response.event?.goldChange ?? 0) > 0 ? "" : "negative"}`}> - 🪙 {(lastResult.response.event?.goldChange ?? 0) > 0 ? "+" : ""}{formatNumber(lastResult.response.event?.goldChange ?? 0)} gold - </span> - )} - {(lastResult.response.event?.essenceChange ?? 0) > 0 && ( - <span className="reward-tag"> - ✨ +{formatNumber(lastResult.response.event?.essenceChange ?? 0)} essence - </span> - )} - {lastResult.response.event?.materialGained && ( - <span className="reward-tag material-tag"> - 📦 +{lastResult.response.event.materialGained.quantity} {lastResult.response.event.materialGained.materialId.replace(/_/g, " ")} (event) - </span> - )} - {lastResult.response.materialsFound.map((m) => ( - <span key={m.materialId} className="reward-tag material-tag"> - 📦 +{m.quantity} {m.materialId.replace(/_/g, " ")} - </span> - ))} - </div> - </> - )} - </div> - )} - - <ZoneSelector - activeZoneId={activeZoneId} - zones={zones} - onSelectZone={(id) => { setActiveZoneId(id); setLastResult(null); }} - /> - - <div className="exploration-list"> - {zoneAreas.map((area) => { - const areaState = explorationState?.areas.find((a) => a.id === area.id); - const status = areaState?.status ?? "locked"; - const startedAt = areaState?.startedAt ?? 0; - const isReady = status === "in_progress" && timeRemaining(startedAt, area.durationSeconds) <= 0; - const isPending = pendingAreaId === area.id; - - return ( - <div key={area.id} className={`exploration-card exploration-${status}`}> - <div className="exploration-info"> - <h3> - {area.name} - {areaState?.completedOnce && <span className="exploration-discovered"> 📖</span>} - </h3> - <p>{area.description}</p> - <span className="exploration-duration">⏱️ {formatDuration(area.durationSeconds)}</span> - </div> - <div className="exploration-action"> - {status === "locked" && ( - <span className="quest-badge locked">🔒 Locked</span> - )} - {status === "available" && ( - <button - className="start-quest-button" - disabled={isPending || hasActiveExploration} - onClick={() => { void handleStart(area.id); }} - title={hasActiveExploration ? "An exploration is already in progress" : undefined} - type="button" - > - {isPending ? "Departing..." : `Explore (${formatDuration(area.durationSeconds)})`} - </button> - )} - {status === "in_progress" && !isReady && ( - <span className="quest-badge active"> - ⏳ {formatDuration(Math.ceil(timeRemaining(startedAt, area.durationSeconds)))} remaining - </span> - )} - {(status === "in_progress" && isReady) && ( - <button - className="collect-button" - disabled={isPending} - onClick={() => { void handleCollect(area.id); }} - type="button" - > - {isPending ? "Collecting..." : "📦 Collect Results"} - </button> - )} - </div> - </div> - ); - })} - {zoneAreas.length === 0 && ( - <p className="empty-zone">No exploration areas in this zone.</p> - )} - </div> - </section> - ); -}; diff --git a/apps/web/src/components/game/GameLayout.tsx b/apps/web/src/components/game/GameLayout.tsx deleted file mode 100644 index 5dd2656..0000000 --- a/apps/web/src/components/game/GameLayout.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { useState } from "react"; -import { useGame } from "../../context/GameContext.js"; -import { ResourceBar } from "../ui/ResourceBar.js"; -import { AboutPanel } from "./AboutPanel.js"; -import { AchievementPanel } from "./AchievementPanel.js"; -import { AchievementToast } from "./AchievementToast.js"; -import { AdventurerPanel } from "./AdventurerPanel.js"; -import { BattleModal } from "./BattleModal.js"; -import { BossPanel } from "./BossPanel.js"; -import { ClickArea } from "./ClickArea.js"; -import { CodexPanel } from "./CodexPanel.js"; -import { CodexToast } from "./CodexToast.js"; -import { EditProfileModal } from "./EditProfileModal.js"; -import { EquipmentPanel } from "./EquipmentPanel.js"; -import { OfflineModal } from "./OfflineModal.js"; -import { OutdatedSchemaModal } from "./OutdatedSchemaModal.js"; -import { PrestigePanel } from "./PrestigePanel.js"; -import { ApotheosisPanel } from "./ApotheosisPanel.js"; -import { TranscendencePanel } from "./TranscendencePanel.js"; -import { QuestPanel } from "./QuestPanel.js"; -import { StatisticsPanel } from "./StatisticsPanel.js"; -import { UpgradePanel } from "./UpgradePanel.js"; -import { DailyChallengePanel } from "./DailyChallengePanel.js"; -import { ExplorationPanel } from "./ExplorationPanel.js"; -import { CharacterSheetPanel } from "./CharacterSheetPanel.js"; -import { CompanionPanel } from "./CompanionPanel.js"; -import { CraftingPanel } from "./CraftingPanel.js"; -import { LoginBonusModal } from "./LoginBonusModal.js"; -import { StoryPanel } from "./StoryPanel.js"; -import { StoryToast } from "./StoryToast.js"; - -type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "transcendence" | "apotheosis" | "statistics" | "daily" | "codex" | "about" | "exploration" | "crafting" | "character" | "companions" | "story"; - -const BASE_TABS: { id: Tab; label: string }[] = [ - { id: "adventurers", label: "⚔️ Adventurers" }, - { id: "upgrades", label: "🔧 Upgrades" }, - { id: "quests", label: "📜 Quests" }, - { id: "bosses", label: "👹 Bosses" }, - { id: "equipment", label: "🗡️ Equipment" }, - { id: "exploration", label: "🗺️ Exploration" }, - { id: "crafting", label: "⚗️ Crafting" }, - { id: "daily", label: "📅 Daily" }, - { id: "prestige", label: "⭐ Prestige" }, - { id: "transcendence", label: "🌌 Transcendence" }, - { id: "apotheosis", label: "✨ Apotheosis" }, - { id: "statistics", label: "📊 Statistics" }, - { id: "companions", label: "👥 Companions" }, - { id: "character", label: "📋 Character" }, - { id: "achievements", label: "🏆 Achievements" }, - { id: "story", label: "📖 Story" }, - { id: "codex", label: "🗺️ Codex" }, - { id: "about", label: "ℹ️ About" }, -]; - -export const GameLayout = (): React.JSX.Element => { - const { state, isLoading, error, battleResult, dismissBattle, lastSavedAt, isSyncing, forceSync, newCodexEntryIds, newStoryChapterIds, loginBonus, dismissLoginBonus, schemaOutdated } = useGame(); - const [activeTab, setActiveTab] = useState<Tab>("adventurers"); - const [editingProfile, setEditingProfile] = useState(false); - const [dismissedOutdatedWarning, setDismissedOutdatedWarning] = useState(false); - - if (isLoading) { - return ( - <div className="loading-screen"> - <p>Loading your adventure...</p> - </div> - ); - } - - if (error) { - return ( - <div className="error-screen"> - <p>Error: {error}</p> - </div> - ); - } - - if (!state) return <div className="loading-screen"><p>Loading...</p></div>; - - const profileUrl = `/profile/${state.player.discordId}`; - - return ( - <div className="game-layout"> - <ResourceBar - resources={state.resources} - runestones={state.prestige.runestones} - prestigeCount={state.prestige.count} - transcendenceCount={state.transcendence?.count ?? 0} - apotheosisCount={state.apotheosis?.count ?? 0} - profileUrl={profileUrl} - onEditProfile={() => { setEditingProfile(true); }} - lastSavedAt={lastSavedAt} - isSyncing={isSyncing} - onForceSync={forceSync} - /> - <OfflineModal /> - {schemaOutdated && !dismissedOutdatedWarning && ( - <OutdatedSchemaModal onDismiss={() => { setDismissedOutdatedWarning(true); }} /> - )} - <AchievementToast /> - <CodexToast /> - <StoryToast /> - {loginBonus && ( - <LoginBonusModal bonus={loginBonus} onClose={dismissLoginBonus} /> - )} - {battleResult && ( - <BattleModal battle={battleResult} onDismiss={dismissBattle} /> - )} - {editingProfile && ( - <EditProfileModal onClose={() => { setEditingProfile(false); }} /> - )} - - <div className="game-main"> - <aside className="game-sidebar"> - <ClickArea /> - <p className="game-copyright">© NHCarrigan</p> - </aside> - - <main className="game-content"> - <nav className="tab-bar"> - {BASE_TABS.map((tab) => ( - <button - key={tab.id} - className={`tab-button ${activeTab === tab.id ? "active" : ""}`} - onClick={() => { setActiveTab(tab.id); }} - type="button" - > - {tab.label} - {tab.id === "codex" && newCodexEntryIds.length > 0 && ( - <span className="tab-badge">{newCodexEntryIds.length}</span> - )} - {tab.id === "story" && newStoryChapterIds.length > 0 && ( - <span className="tab-badge">{newStoryChapterIds.length}</span> - )} - </button> - ))} - </nav> - - <div className="tab-content"> - {activeTab === "adventurers" && <AdventurerPanel />} - {activeTab === "upgrades" && <UpgradePanel />} - {activeTab === "quests" && <QuestPanel />} - {activeTab === "bosses" && <BossPanel />} - {activeTab === "equipment" && <EquipmentPanel />} - {activeTab === "achievements" && <AchievementPanel />} - {activeTab === "prestige" && <PrestigePanel />} - {activeTab === "transcendence" && <TranscendencePanel />} - {activeTab === "apotheosis" && <ApotheosisPanel />} - {activeTab === "exploration" && <ExplorationPanel />} - {activeTab === "crafting" && <CraftingPanel />} - {activeTab === "statistics" && <StatisticsPanel />} - {activeTab === "daily" && <DailyChallengePanel />} - {activeTab === "companions" && <CompanionPanel />} - {activeTab === "character" && <CharacterSheetPanel />} - {activeTab === "story" && <StoryPanel />} - {activeTab === "codex" && <CodexPanel />} - {activeTab === "about" && <AboutPanel />} - </div> - </main> - </div> - </div> - ); -}; diff --git a/apps/web/src/components/game/LeaderboardPage.tsx b/apps/web/src/components/game/LeaderboardPage.tsx deleted file mode 100644 index 23a3607..0000000 --- a/apps/web/src/components/game/LeaderboardPage.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import type { LeaderboardCategory, LeaderboardEntry } from "@elysium/types"; -import { useEffect, useState } from "react"; - -interface CategoryConfig { - id: LeaderboardCategory; - label: string; - icon: string; - formatValue: (value: number) => string; -} - -const CATEGORIES: CategoryConfig[] = [ - { - id: "totalGold", - label: "Lifetime Gold", - icon: "🪙", - formatValue: (v) => formatGold(v), - }, - { - id: "bossesDefeated", - label: "Bosses Defeated", - icon: "💀", - formatValue: (v) => v.toLocaleString(), - }, - { - id: "questsCompleted", - label: "Quests Completed", - icon: "📜", - formatValue: (v) => v.toLocaleString(), - }, - { - id: "achievementsUnlocked", - label: "Achievements", - icon: "🏆", - formatValue: (v) => v.toLocaleString(), - }, - { - id: "prestigeCount", - label: "Prestige", - icon: "⭐", - formatValue: (v) => v.toLocaleString(), - }, - { - id: "transcendenceCount", - label: "Transcendence", - icon: "🌌", - formatValue: (v) => v.toLocaleString(), - }, - { - id: "apotheosisCount", - label: "Apotheosis", - icon: "✨", - formatValue: (v) => v.toLocaleString(), - }, -]; - -const SUFFIXES = ["", "K", "M", "B", "T", "Qa", "Qt", "S", "Sp", "O", "N", "D"]; - -const formatGold = (value: number): string => { - if (value === 0) return "0"; - const tier = Math.floor(Math.log10(Math.abs(value)) / 3); - const clamped = Math.min(tier, SUFFIXES.length - 1); - const scaled = value / Math.pow(1000, clamped); - return `${parseFloat(scaled.toFixed(2))}${SUFFIXES[clamped] ?? ""}`; -}; - -const RANK_BADGES: Record<number, string> = { 1: "🥇", 2: "🥈", 3: "🥉" }; - -export const LeaderboardPage = (): React.JSX.Element => { - const [category, setCategory] = useState<LeaderboardCategory>("totalGold"); - const [entries, setEntries] = useState<LeaderboardEntry[]>([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState<string | null>(null); - - useEffect(() => { - setLoading(true); - setError(null); - fetch(`/api/leaderboards?category=${category}&limit=100`) - .then(async (res) => { - if (!res.ok) throw new Error("Failed to load leaderboard"); - const data = (await res.json()) as { entries: LeaderboardEntry[] }; - setEntries(data.entries); - }) - .catch((err: unknown) => { - setError(err instanceof Error ? err.message : "Failed to load leaderboard"); - }) - .finally(() => { setLoading(false); }); - }, [category]); - - const currentConfig = CATEGORIES.find((c) => c.id === category) ?? CATEGORIES[0]; - - return ( - <div className="leaderboard-page"> - <div className="leaderboard-card"> - <div className="leaderboard-header"> - <h1 className="leaderboard-title">🏆 Leaderboards</h1> - <p className="leaderboard-subtitle">The mightiest adventurers in Elysium</p> - </div> - - <div className="leaderboard-tabs"> - {CATEGORIES.map((cat) => ( - <button - key={cat.id} - className={`leaderboard-tab ${category === cat.id ? "leaderboard-tab--active" : ""}`} - onClick={() => { setCategory(cat.id); }} - type="button" - > - {cat.icon} {cat.label} - </button> - ))} - </div> - - {loading && ( - <div className="leaderboard-loading">Loading…</div> - )} - - {error && ( - <div className="leaderboard-error">⚠️ {error}</div> - )} - - {!loading && !error && entries.length === 0 && ( - <div className="leaderboard-empty">No entries yet — be the first on the board!</div> - )} - - {!loading && !error && entries.length > 0 && ( - <div className="leaderboard-table"> - <div className="leaderboard-table-header"> - <span className="leaderboard-col-rank">Rank</span> - <span className="leaderboard-col-player">Player</span> - <span className="leaderboard-col-value">{currentConfig?.icon} {currentConfig?.label}</span> - </div> - {entries.map((entry) => { - const avatarUrl = entry.avatar - ? `https://cdn.discordapp.com/avatars/${entry.discordId}/${entry.avatar}.png?size=32` - : `https://cdn.discordapp.com/embed/avatars/${parseInt(entry.discordId, 10) % 5}.png`; - const displayName = entry.characterName || entry.username; - - return ( - <a - className={`leaderboard-row ${entry.rank <= 3 ? `leaderboard-row--top${entry.rank}` : ""}`} - href={`/character/${entry.discordId}`} - key={entry.discordId} - > - <span className="leaderboard-col-rank"> - {RANK_BADGES[entry.rank] ?? `#${entry.rank}`} - </span> - <span className="leaderboard-col-player"> - <img - alt={displayName} - className="leaderboard-avatar" - src={avatarUrl} - /> - <span className="leaderboard-player-info"> - <span className="leaderboard-player-name">{displayName}</span> - {entry.activeTitle && ( - <span className="leaderboard-player-title">{entry.activeTitle}</span> - )} - </span> - </span> - <span className="leaderboard-col-value"> - {currentConfig?.formatValue(entry.value)} - </span> - </a> - ); - })} - </div> - )} - - <div className="leaderboard-footer"> - <a className="leaderboard-play-link" href="/">⚔️ Play Elysium</a> - <p className="leaderboard-privacy-note"> - Players can opt out via their profile settings. - </p> - </div> - </div> - </div> - ); -}; diff --git a/apps/web/src/components/game/LoginBonusModal.tsx b/apps/web/src/components/game/LoginBonusModal.tsx deleted file mode 100644 index 2781f0d..0000000 --- a/apps/web/src/components/game/LoginBonusModal.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import type { LoginBonusResult } from "@elysium/types"; - -interface LoginBonusModalProps { - bonus: LoginBonusResult; - onClose: () => void; -} - -const DAY_ICONS = ["🌱", "🌿", "⚔️", "🛡️", "💎", "👑", "🔥"]; - -const formatGold = (value: number): string => { - const suffixes = ["", "K", "M", "B", "T"]; - if (value < 1_000) return value.toLocaleString(); - const tier = Math.min(Math.floor(Math.log10(value) / 3), suffixes.length - 1); - const scaled = value / Math.pow(1_000, tier); - return `${parseFloat(scaled.toFixed(1))}${suffixes[tier] ?? ""}`; -}; - -export const LoginBonusModal = ({ bonus, onClose }: LoginBonusModalProps): React.JSX.Element => { - const isWeeklyBonus = bonus.day === 7; - const dayIcon = DAY_ICONS[bonus.day - 1] ?? "⭐"; - - return ( - <div className="modal-overlay" role="dialog" aria-modal="true"> - <div className="modal login-bonus-modal"> - <div className="login-bonus-streak"> - <span className="login-bonus-fire">🔥</span> - <span className="login-bonus-streak-count">{bonus.streak}</span> - <span className="login-bonus-streak-label"> - {bonus.streak === 1 ? "Day Streak" : "Day Streak"} - </span> - </div> - - <div className="login-bonus-day-badge"> - <span className="login-bonus-day-icon">{dayIcon}</span> - <span className="login-bonus-day-label">Day {bonus.day} Reward</span> - {bonus.weekMultiplier > 1 && ( - <span className="login-bonus-week-tag">×{bonus.weekMultiplier} Week Bonus!</span> - )} - </div> - - <div className="login-bonus-rewards"> - <div className="login-bonus-reward-item"> - <span className="login-bonus-reward-icon">🪙</span> - <span className="login-bonus-reward-value">+{formatGold(bonus.goldEarned)} Gold</span> - </div> - {bonus.crystalsEarned > 0 && ( - <div className="login-bonus-reward-item"> - <span className="login-bonus-reward-icon">💎</span> - <span className="login-bonus-reward-value">+{bonus.crystalsEarned} Crystals</span> - </div> - )} - </div> - - {isWeeklyBonus && ( - <p className="login-bonus-weekly-message"> - 🎉 Weekly bonus — keep the streak going! - </p> - )} - - <div className="login-bonus-calendar"> - {DAY_ICONS.map((icon, i) => { - const dayNum = i + 1; - const isCompleted = dayNum < bonus.day || (bonus.day === 7 && dayNum === 7); - const isToday = dayNum === bonus.day; - return ( - <div - key={dayNum} - className={`login-bonus-cal-day ${isToday ? "login-bonus-cal-day--today" : ""} ${isCompleted ? "login-bonus-cal-day--done" : ""}`} - > - <span className="login-bonus-cal-icon">{icon}</span> - <span className="login-bonus-cal-num">{dayNum}</span> - </div> - ); - })} - </div> - - <button className="login-bonus-claim-btn" onClick={onClose} type="button"> - Claim Reward - </button> - </div> - </div> - ); -}; diff --git a/apps/web/src/components/game/LoginPage.tsx b/apps/web/src/components/game/LoginPage.tsx deleted file mode 100644 index 506e190..0000000 --- a/apps/web/src/components/game/LoginPage.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { useEffect, useState } from "react"; -import { getAuthUrl, handleAuthCallback } from "../../api/client.js"; - -interface LoginPageProps { - onLogin: () => void; -} - -export const LoginPage = ({ onLogin }: LoginPageProps): React.JSX.Element => { - const [authUrl, setAuthUrl] = useState<string | null>(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState<string | null>(null); - - useEffect(() => { - // Handle OAuth callback - const params = new URLSearchParams(window.location.search); - const code = params.get("code"); - - if (code) { - setIsLoading(true); - handleAuthCallback(code) - .then(() => { - window.history.replaceState({}, "", "/"); - onLogin(); - }) - .catch((err: unknown) => { - setError(err instanceof Error ? err.message : "Authentication failed"); - setIsLoading(false); - }); - return; - } - - // Fetch the Discord OAuth URL - getAuthUrl() - .then((url) => { - setAuthUrl(url); - setIsLoading(false); - }) - .catch(() => { - setError("Failed to load authentication URL"); - setIsLoading(false); - }); - }, [onLogin]); - - if (isLoading) { - return ( - <div className="login-page"> - <div className="login-card"> - <p>Loading...</p> - </div> - </div> - ); - } - - if (error) { - return ( - <div className="login-page"> - <div className="login-card"> - <p className="error">{error}</p> - <button - type="button" - onClick={() => { window.location.reload(); }} - > - Try Again - </button> - </div> - </div> - ); - } - - return ( - <div className="login-page"> - <div className="login-card"> - <h1>⚔️ Elysium</h1> - <p>An idle fantasy RPG. Hire adventurers, defeat bosses, and ascend to glory.</p> - <a - className="discord-login-button" - href={authUrl ?? "#"} - > - Login with Discord - </a> - <p className="login-note"> - Your progress is saved to your Discord account and shareable with others! - </p> - </div> - </div> - ); -}; diff --git a/apps/web/src/components/game/OfflineModal.tsx b/apps/web/src/components/game/OfflineModal.tsx deleted file mode 100644 index 52e5179..0000000 --- a/apps/web/src/components/game/OfflineModal.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { useGame } from "../../context/GameContext.js"; - -export const OfflineModal = (): React.JSX.Element | null => { - const { offlineGold, offlineEssence, dismissOfflineGold, formatNumber } = useGame(); - - if (offlineGold <= 0 && offlineEssence <= 0) return null; - - return ( - <div className="modal-overlay"> - <div className="modal"> - <h2>Welcome back!</h2> - <p>Your adventurers kept working whilst you were away and earned:</p> - {offlineGold > 0 && ( - <p> - <strong>🪙 {formatNumber(offlineGold)} gold</strong> - </p> - )} - {offlineEssence > 0 && ( - <p> - <strong>✨ {formatNumber(offlineEssence)} essence</strong> - </p> - )} - <p className="modal-note">Offline progress is calculated up to 8 hours.</p> - <button - className="modal-close-button" - onClick={dismissOfflineGold} - type="button" - > - Collect! - </button> - </div> - </div> - ); -}; diff --git a/apps/web/src/components/game/OutdatedSchemaModal.tsx b/apps/web/src/components/game/OutdatedSchemaModal.tsx deleted file mode 100644 index 7cc69b4..0000000 --- a/apps/web/src/components/game/OutdatedSchemaModal.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useState } from "react"; -import { useGame } from "../../context/GameContext.js"; - -interface OutdatedSchemaModalProps { - onDismiss: () => void; -} - -export const OutdatedSchemaModal = ({ onDismiss }: OutdatedSchemaModalProps): React.JSX.Element => { - const { resetProgress } = useGame(); - const [isResetting, setIsResetting] = useState(false); - - const handleReset = async (): Promise<void> => { - setIsResetting(true); - await resetProgress(); - setIsResetting(false); - }; - - return ( - <div className="modal-overlay"> - <div className="modal offline-modal"> - <h2>⚠️ Outdated Save Data</h2> - <p> - Your save data is from an older version of Elysium and may cause bugs or unexpected - behaviour. Cloud saves are <strong>disabled</strong> until you reset your progress. - </p> - <p> - Resetting will start you fresh — all progress will be lost. - </p> - <div className="outdated-modal-actions"> - <button - className="outdated-modal-reset-button" - onClick={() => { void handleReset(); }} - disabled={isResetting} - type="button" - > - {isResetting ? "Resetting…" : "Reset Progress"} - </button> - <button - className="modal-close-button" - onClick={onDismiss} - type="button" - > - Proceed with Bugs - </button> - </div> - </div> - </div> - ); -}; diff --git a/apps/web/src/components/game/PrestigePanel.tsx b/apps/web/src/components/game/PrestigePanel.tsx deleted file mode 100644 index d734ee7..0000000 --- a/apps/web/src/components/game/PrestigePanel.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import type { PrestigeUpgradeCategory } from "@elysium/types"; -import { useState } from "react"; -import { prestige } from "../../api/client.js"; -import { useGame } from "../../context/GameContext.js"; -import { - PRESTIGE_UPGRADES, - PRESTIGE_UPGRADE_CATEGORY_LABELS, -} from "../../data/prestigeUpgrades.js"; - -const BASE_THRESHOLD = 1_000_000; -const THRESHOLD_SCALE = 5; -const RUNESTONES_PER_LEVEL = 10; - -const calculateThreshold = (prestigeCount: number): number => - BASE_THRESHOLD * Math.pow(THRESHOLD_SCALE, prestigeCount); - -const calculateProductionMultiplier = (prestigeCount: number): number => - Math.pow(1.15, prestigeCount); - -const calculateRunestonePreview = ( - totalGoldEarned: number, - prestigeCount: number, - purchasedUpgradeIds: string[], -): number => { - const threshold = calculateThreshold(prestigeCount); - const base = Math.floor(Math.sqrt(totalGoldEarned / threshold)) * RUNESTONES_PER_LEVEL; - const runestoneMult = PRESTIGE_UPGRADES - .filter((u) => u.category === "runestones" && purchasedUpgradeIds.includes(u.id)) - .reduce((mult, u) => mult * u.multiplier, 1); - return Math.floor(base * runestoneMult); -}; - -const CATEGORY_ORDER: PrestigeUpgradeCategory[] = [ - "income", - "click", - "essence", - "crystals", - "runestones", - "utility", -]; - -export const PrestigePanel = (): React.JSX.Element => { - const { state, reload, formatNumber, buyPrestigeUpgrade, toggleAutoPrestige } = useGame(); - const [isPending, setIsPending] = useState(false); - const [result, setResult] = useState<{ runestones: number; count: number; milestoneRunestones: number } | null>(null); - const [prestigeError, setPrestigeError] = useState<string | null>(null); - const [buyingId, setBuyingId] = useState<string | null>(null); - const [activeTab, setActiveTab] = useState<"prestige" | "shop">("prestige"); - - if (!state) return <section className="panel"><p>Loading...</p></section>; - - const { prestige: prestigeData, player } = state; - const threshold = calculateThreshold(prestigeData.count); - const isEligible = player.totalGoldEarned >= threshold; - const runestonePreview = calculateRunestonePreview( - player.totalGoldEarned, - prestigeData.count, - prestigeData.purchasedUpgradeIds, - ); - const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1); - - const handlePrestige = async (): Promise<void> => { - setIsPending(true); - setPrestigeError(null); - try { - const data = await prestige({}); - setResult({ runestones: data.runestones, count: data.newPrestigeCount, milestoneRunestones: data.milestoneRunestones }); - await reload(); - } catch (err) { - setPrestigeError(err instanceof Error ? err.message : "Prestige failed"); - } finally { - setIsPending(false); - } - }; - - const handleBuyUpgrade = async (upgradeId: string): Promise<void> => { - setBuyingId(upgradeId); - try { - await buyPrestigeUpgrade(upgradeId); - } finally { - setBuyingId(null); - } - }; - - const upgradesByCategory = CATEGORY_ORDER.map((category) => ({ - category, - label: PRESTIGE_UPGRADE_CATEGORY_LABELS[category] ?? category, - upgrades: PRESTIGE_UPGRADES.filter((u) => u.category === category), - })); - - return ( - <section className="panel prestige-panel"> - <h2>⭐ Prestige</h2> - - <div className="prestige-tabs"> - <button - className={`prestige-tab ${activeTab === "prestige" ? "active" : ""}`} - onClick={() => { setActiveTab("prestige"); }} - type="button" - > - Ascend - </button> - <button - className={`prestige-tab ${activeTab === "shop" ? "active" : ""}`} - onClick={() => { setActiveTab("shop"); }} - type="button" - > - 🔮 Runestone Shop ({formatNumber(prestigeData.runestones)} stones) - </button> - </div> - - {activeTab === "prestige" && ( - <> - <p> - Prestige resets your progress but grants <strong>Runestones</strong> — permanent - currency used for powerful upgrades. Each prestige multiplies your global production - by ×1.15 (compounding each run). - </p> - - <div className="prestige-status"> - <p> - Total gold this run:{" "} - <strong>{formatNumber(player.totalGoldEarned)}</strong> - </p> - <p> - Required to prestige: <strong>{formatNumber(threshold)}</strong> - </p> - <p> - Prestige count: <strong>{prestigeData.count}</strong> - </p> - <p> - Current production multiplier:{" "} - <strong>×{prestigeData.productionMultiplier.toFixed(2)}</strong> - </p> - <p> - After next prestige:{" "} - <strong>×{nextMultiplier.toFixed(2)}</strong> - </p> - <p> - Runestones: <strong>{formatNumber(prestigeData.runestones)}</strong> - </p> - {isEligible && ( - <p className="runestone-preview"> - Runestones on prestige: <strong>+{formatNumber(runestonePreview)}</strong> - </p> - )} - {!isEligible && ( - <p className="prestige-progress"> - Progress: {formatNumber(player.totalGoldEarned)} / {formatNumber(threshold)}{" "} - ({((player.totalGoldEarned / threshold) * 100).toFixed(1)}%) - </p> - )} - </div> - - {isEligible ? ( - <div className="prestige-form"> - <p>You are ready to prestige!</p> - <button - className="prestige-button" - disabled={isPending} - onClick={() => { void handlePrestige(); }} - type="button" - > - {isPending ? "Ascending..." : `✨ Ascend (+${formatNumber(runestonePreview)} Runestones)`} - </button> - {prestigeError && <p className="error">{prestigeError}</p>} - {result && ( - <p className="success"> - Ascended to Prestige {result.count}! Earned {formatNumber(result.runestones)} Runestones. - {result.milestoneRunestones > 0 && ( - <> 🎉 Milestone bonus: +{formatNumber(result.milestoneRunestones)} Runestones!</> - )} - </p> - )} - </div> - ) : ( - <p className="prestige-locked"> - Earn {formatNumber(threshold - player.totalGoldEarned)} more gold to unlock prestige. - </p> - )} - </> - )} - - {activeTab === "shop" && ( - <div className="runestone-shop"> - <p className="shop-balance"> - Balance: <strong>{formatNumber(prestigeData.runestones)} Runestones</strong> - </p> - - {upgradesByCategory.map(({ category, label, upgrades }) => ( - <div key={category} className="shop-category"> - <h3>{label}</h3> - <div className="shop-upgrades"> - {upgrades.map((upgrade) => { - const purchased = prestigeData.purchasedUpgradeIds.includes(upgrade.id); - const canAfford = prestigeData.runestones >= upgrade.runestonesCost; - const isLoading = buyingId === upgrade.id; - - const isAutoPrestigeToggle = upgrade.id === "auto_prestige" && purchased; - const autoPrestigeEnabled = prestigeData.autoPrestigeEnabled ?? false; - - return ( - <div - key={upgrade.id} - className={`shop-upgrade-card ${purchased ? "purchased" : ""} ${!canAfford && !purchased ? "unaffordable" : ""}`} - > - <div className="shop-upgrade-info"> - <h4>{upgrade.name}</h4> - <p>{upgrade.description}</p> - <p className="upgrade-cost"> - {purchased ? "✅ Purchased" : `🔮 ${formatNumber(upgrade.runestonesCost)} Runestones`} - </p> - </div> - {isAutoPrestigeToggle && ( - <button - className={`auto-prestige-toggle ${autoPrestigeEnabled ? "enabled" : "disabled"}`} - onClick={() => { toggleAutoPrestige(); }} - type="button" - > - {autoPrestigeEnabled ? "⚡ Auto ON" : "⏸ Auto OFF"} - </button> - )} - {!purchased && ( - <button - className="buy-upgrade-button" - disabled={!canAfford || isLoading || buyingId !== null} - onClick={() => { void handleBuyUpgrade(upgrade.id); }} - type="button" - > - {isLoading ? "Buying..." : "Buy"} - </button> - )} - </div> - ); - })} - </div> - </div> - ))} - </div> - )} - </section> - ); -}; diff --git a/apps/web/src/components/game/ProfilePage.tsx b/apps/web/src/components/game/ProfilePage.tsx deleted file mode 100644 index 45dd615..0000000 --- a/apps/web/src/components/game/ProfilePage.tsx +++ /dev/null @@ -1,233 +0,0 @@ -import type { PublicProfileResponse } from "@elysium/types"; -import { useEffect, useState } from "react"; -import { formatNumber } from "../../utils/format.js"; - -interface ProfilePageProps { - discordId: string; -} - -interface StatEntry { - icon: string; - value: string; - label: string; - date: boolean; -} - -export const ProfilePage = ({ discordId }: ProfilePageProps): React.JSX.Element => { - const [profile, setProfile] = useState<PublicProfileResponse | null>(null); - const [error, setError] = useState<string | null>(null); - const [copied, setCopied] = useState(false); - - useEffect(() => { - fetch(`/api/profile/${discordId}`) - .then(async (res) => { - if (!res.ok) throw new Error("Player not found"); - return res.json() as Promise<PublicProfileResponse>; - }) - .then(setProfile) - .catch((err: unknown) => { - setError(err instanceof Error ? err.message : "Failed to load profile"); - }); - }, [discordId]); - - const handleCopy = (): void => { - void navigator.clipboard.writeText(window.location.href).then(() => { - setCopied(true); - setTimeout(() => { setCopied(false); }, 2000); - }); - }; - - if (error) { - return ( - <div className="profile-page"> - <div className="profile-error"> - <p>⚠️ {error}</p> - <a className="profile-play-link" href="/">← Play Elysium</a> - </div> - </div> - ); - } - - if (!profile) { - return ( - <div className="profile-page"> - <div className="profile-loading">Loading profile…</div> - </div> - ); - } - - const s = profile.profileSettings; - const fmt = (n: number): string => formatNumber(n, s.numberFormat); - - const avatarUrl = profile.avatar - ? `https://cdn.discordapp.com/avatars/${discordId}/${profile.avatar}.png?size=128` - : `https://cdn.discordapp.com/embed/avatars/${parseInt(discordId, 10) % 5}.png`; - - const memberSince = new Date(profile.createdAt).toLocaleDateString("en-GB", { - year: "numeric", - month: "long", - day: "numeric", - }); - - const currentRunStats = [ - s.showCurrentGold && { - icon: "🪙", - value: fmt(profile.currentRunGold), - label: "Gold Earned", - date: false, - }, - s.showCurrentClicks && { - icon: "👆", - value: fmt(profile.currentRunClicks), - label: "Clicks", - date: false, - }, - s.showBossesDefeated && { - icon: "💀", - value: String(profile.bossesDefeated), - label: "Bosses Defeated", - date: false, - }, - s.showQuestsCompleted && { - icon: "📜", - value: String(profile.questsCompleted), - label: "Quests Completed", - date: false, - }, - s.showAdventurersRecruited && { - icon: "⚔️", - value: fmt(profile.adventurersRecruited), - label: "Adventurers Recruited", - date: false, - }, - s.showAchievementsUnlocked && { - icon: "🏆", - value: String(profile.achievementsUnlocked), - label: "Achievements Unlocked", - date: false, - }, - ].filter(Boolean) as StatEntry[]; - - const allTimeStats = [ - s.showTotalGold && { - icon: "🪙", - value: fmt(profile.totalGoldEarned), - label: "Total Gold Earned", - date: false, - }, - s.showTotalClicks && { - icon: "👆", - value: fmt(profile.totalClicks), - label: "Total Clicks", - date: false, - }, - s.showLifetimeBossesDefeated && { - icon: "💀", - value: String(profile.lifetimeBossesDefeated), - label: "Bosses Defeated", - date: false, - }, - s.showLifetimeQuestsCompleted && { - icon: "📜", - value: String(profile.lifetimeQuestsCompleted), - label: "Quests Completed", - date: false, - }, - s.showLifetimeAdventurersRecruited && { - icon: "⚔️", - value: fmt(profile.lifetimeAdventurersRecruited), - label: "Adventurers Recruited", - date: false, - }, - s.showLifetimeAchievementsUnlocked && { - icon: "🏆", - value: String(profile.lifetimeAchievementsUnlocked), - label: "Achievements Unlocked", - date: false, - }, - s.showGuildFounded && { - icon: "📅", - value: memberSince, - label: "Guild Founded", - date: true, - }, - ].filter(Boolean) as StatEntry[]; - - const renderStats = (stats: StatEntry[]): React.JSX.Element => ( - <div className="profile-stats"> - {stats.map((stat) => ( - <div key={stat.label} className="profile-stat"> - <span className="profile-stat-icon">{stat.icon}</span> - <span className={`profile-stat-value ${stat.date ? "profile-stat-date" : ""}`}> - {stat.value} - </span> - <span className="profile-stat-label">{stat.label}</span> - </div> - ))} - </div> - ); - - return ( - <div className="profile-page"> - <div className="profile-card"> - <div className="profile-header"> - <img - alt={`${profile.username}'s avatar`} - className="profile-avatar" - src={avatarUrl} - /> - <div className="profile-identity"> - <h1 className="profile-character-name">{profile.characterName}</h1> - <p className="profile-username">@{profile.username}</p> - {s.showApotheosis && profile.apotheosisCount > 0 && ( - <span className="profile-apotheosis-badge"> - ✨ Apotheosis {profile.apotheosisCount} - </span> - )} - {s.showTranscendence && profile.transcendenceCount > 0 && ( - <span className="profile-transcendence-badge"> - 🌌 Transcendence {profile.transcendenceCount} - </span> - )} - {s.showPrestige && profile.prestigeCount > 0 && ( - <span className="profile-prestige-badge"> - ⭐ Prestige {profile.prestigeCount} - </span> - )} - </div> - </div> - - {profile.bio && ( - <p className="profile-bio">{profile.bio}</p> - )} - - {currentRunStats.length > 0 && ( - <div className="profile-stats-section"> - <h3 className="profile-stats-heading">Current Run</h3> - {renderStats(currentRunStats)} - </div> - )} - - {allTimeStats.length > 0 && ( - <div className="profile-stats-section"> - <h3 className="profile-stats-heading">All Time</h3> - {renderStats(allTimeStats)} - </div> - )} - - <div className="profile-actions"> - <button - className="profile-share-button" - onClick={handleCopy} - type="button" - > - {copied ? "✓ Copied!" : "🔗 Copy Profile Link"} - </button> - <a className="profile-play-link" href="/"> - ⚔️ Play Elysium - </a> - </div> - </div> - </div> - ); -}; diff --git a/apps/web/src/components/game/QuestPanel.tsx b/apps/web/src/components/game/QuestPanel.tsx deleted file mode 100644 index 9ff88dc..0000000 --- a/apps/web/src/components/game/QuestPanel.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import type { Quest } from "@elysium/types"; -import { useState } from "react"; -import { useGame } from "../../context/GameContext.js"; -import { LockToggle } from "../ui/LockToggle.js"; -import { ZoneSelector } from "./ZoneSelector.js"; - -const formatDuration = (seconds: number): string => { - if (seconds >= 3600) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; - if (seconds >= 60) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; - return `${seconds}s`; -}; - -const questTimeRemaining = (quest: Quest): number => { - if (quest.status !== "active" || quest.startedAt == null) return 0; - const elapsed = (Date.now() - quest.startedAt) / 1000; - return Math.max(0, quest.durationSeconds - elapsed); -}; - -interface QuestCardProps { - quest: Quest; - partyCombatPower: number; - unlockHint?: string | undefined; - zoneHint?: string | undefined; -} - -const QuestCard = ({ quest, partyCombatPower, unlockHint, zoneHint }: QuestCardProps): React.JSX.Element => { - const { startQuest, formatNumber } = useGame(); - const cpRequired = quest.combatPowerRequired ?? 0; - const meetsCP = partyCombatPower >= cpRequired; - - return ( - <div className={`quest-card quest-${quest.status}`}> - <div className="quest-info"> - <h3>{quest.name}</h3> - <p>{quest.description}</p> - {cpRequired > 0 && ( - <p className={`quest-cp-requirement ${meetsCP ? "cp-met" : "cp-unmet"}`}> - ⚔️ Requires {formatNumber(cpRequired)} Combat Power - {quest.status === "available" && (meetsCP ? " ✓" : ` (you have ${formatNumber(partyCombatPower)})`)} - </p> - )} - <div className="quest-rewards"> - {quest.rewards.map((reward, index) => ( - // eslint-disable-next-line react/no-array-index-key -- rewards have no unique id - <span key={index} className="reward-tag"> - {reward.type === "gold" && `🪙 ${formatNumber(reward.amount ?? 0)}`} - {reward.type === "essence" && `✨ ${formatNumber(reward.amount ?? 0)}`} - {reward.type === "crystals" && `💎 ${formatNumber(reward.amount ?? 0)}`} - {reward.type === "upgrade" && "🔓 Upgrade"} - {reward.type === "adventurer" && "👥 New Adventurer"} - </span> - ))} - </div> - </div> - <div className="quest-action"> - {quest.status === "locked" && ( - <> - <span className="quest-badge locked">🔒 Locked</span> - {zoneHint && <p className="unlock-hint">🗺️ Unlock zone: {zoneHint}</p>} - {!zoneHint && unlockHint && <p className="unlock-hint">📜 Complete: {unlockHint}</p>} - </> - )} - {quest.status === "available" && quest.lastFailedAt != null && ( - <p className="quest-failed-hint">⚠️ Last attempt failed</p> - )} - {quest.status === "available" && ( - <button - className="start-quest-button" - disabled={!meetsCP} - onClick={() => { startQuest(quest.id); }} - title={meetsCP ? undefined : `Need ${formatNumber(cpRequired)} combat power`} - type="button" - > - Send Party ({formatDuration(quest.durationSeconds)}) - </button> - )} - {quest.status === "active" && ( - <span className="quest-badge active"> - ⏳ {formatDuration(Math.ceil(questTimeRemaining(quest)))} remaining - </span> - )} - {quest.status === "completed" && <span className="quest-badge completed">✅ Complete</span>} - </div> - </div> - ); -}; - -export const QuestPanel = (): React.JSX.Element => { - const { state, toggleAutoQuest } = useGame(); - const [activeZoneId, setActiveZoneId] = useState("verdant_vale"); - const [showLocked, setShowLocked] = useState(true); - - if (!state) return <section className="panel"><p>Loading...</p></section>; - - const partyCombatPower = state.adventurers.reduce( - (total, a) => total + a.combatPower * a.count, - 0, - ); - - const zones = state.zones ?? []; - const zoneQuests = state.quests.filter((q) => q.zoneId === activeZoneId); - const lockedCount = zoneQuests.filter((q) => q.status === "locked").length; - const visibleQuests = showLocked - ? zoneQuests - : zoneQuests.filter((q) => q.status !== "locked"); - - const questNameById = new Map(state.quests.map((q) => [q.id, q.name])); - const zoneById = new Map(zones.map((z) => [z.id, z])); - const questUnlockHints = new Map<string, string>(); - const questZoneHints = new Map<string, string>(); - for (const quest of state.quests) { - if (quest.status !== "locked") continue; - const zone = zoneById.get(quest.zoneId); - if (zone?.status === "locked") { - questZoneHints.set(quest.id, zone.name); - } else if (quest.prerequisiteIds.length > 0) { - const prereqId = quest.prerequisiteIds[0]; - if (prereqId) { - const prereqName = questNameById.get(prereqId); - if (prereqName) { - questUnlockHints.set(quest.id, prereqName); - } - } - } - } - - return ( - <section className="panel quest-panel"> - <div className="panel-header"> - <h2>Quests</h2> - <div className="panel-header-controls"> - <button - className={`auto-toggle-btn ${state.autoQuest ? "auto-toggle-on" : "auto-toggle-off"}`} - onClick={toggleAutoQuest} - title="Automatically send the party on the highest available quest" - type="button" - > - 🤖 Auto: {state.autoQuest ? "ON" : "OFF"} - </button> - <LockToggle - lockedCount={lockedCount} - showLocked={showLocked} - onToggle={() => { setShowLocked((v) => !v); }} - /> - </div> - </div> - - <ZoneSelector - activeZoneId={activeZoneId} - zones={zones} - onSelectZone={setActiveZoneId} - /> - - <div className="quest-list"> - {visibleQuests.map((quest) => ( - <QuestCard - key={quest.id} - partyCombatPower={partyCombatPower} - quest={quest} - unlockHint={questUnlockHints.get(quest.id)} - zoneHint={questZoneHints.get(quest.id)} - /> - ))} - {visibleQuests.length === 0 && ( - <p className="empty-zone">No quests to show in this zone.</p> - )} - </div> - </section> - ); -}; diff --git a/apps/web/src/components/game/StatisticsPanel.tsx b/apps/web/src/components/game/StatisticsPanel.tsx deleted file mode 100644 index ab5711a..0000000 --- a/apps/web/src/components/game/StatisticsPanel.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { useGame } from "../../context/GameContext.js"; -import { PRESTIGE_UPGRADES } from "../../data/prestigeUpgrades.js"; - -const formatDate = (timestamp: number): string => - new Date(timestamp).toLocaleDateString("en-GB", { - day: "numeric", - month: "short", - year: "numeric", - }); - -interface StatCardProps { - icon: string; - label: string; - value: string; - sub?: string; -} - -const StatCard = ({ icon, label, value, sub }: StatCardProps): React.JSX.Element => ( - <div className="profile-stat"> - <span className="profile-stat-icon">{icon}</span> - <span className="profile-stat-value">{value}</span> - <span className="profile-stat-label">{label}</span> - {sub !== undefined && <span className="profile-stat-date">{sub}</span>} - </div> -); - -export const StatisticsPanel = (): React.JSX.Element => { - const { state, formatNumber } = useGame(); - - if (!state) return <section className="panel"><p>Loading...</p></section>; - - const { player, resources, prestige, bosses, quests, zones, adventurers, upgrades, equipment, achievements } = state; - - const bossesDefeated = bosses.filter((b) => b.status === "defeated").length; - const questsCompleted = quests.filter((q) => q.status === "completed").length; - const zonesUnlocked = zones.filter((z) => z.status === "unlocked").length; - const adventurersRecruited = adventurers.reduce((sum, a) => sum + a.count, 0); - const equipmentOwned = (equipment ?? []).filter((e) => e.owned).length; - const upgradesPurchased = upgrades.filter((u) => u.purchased).length; - const achievementsUnlocked = (achievements ?? []).filter((a) => a.unlockedAt !== null).length; - const prestigeUpgradesPurchased = prestige.purchasedUpgradeIds.length; - - return ( - <section className="panel statistics-panel"> - <h2>📊 Statistics</h2> - - <h3 className="stats-section-header">All-Time</h3> - <div className="profile-stats"> - <StatCard - icon="🪙" - label="Total Gold Earned" - value={formatNumber(player.totalGoldEarned)} - sub="across all runs" - /> - <StatCard - icon="👆" - label="Total Clicks" - value={formatNumber(player.totalClicks)} - /> - <StatCard - icon="⭐" - label="Prestiges" - value={String(prestige.count)} - /> - <StatCard - icon="📅" - label="Guild Founded" - value={formatDate(player.createdAt)} - /> - <StatCard - icon="☁️" - label="Last Cloud Save" - value={formatDate(player.lastSavedAt)} - /> - <StatCard - icon="✖️" - label="Production Multiplier" - value={`×${prestige.productionMultiplier.toFixed(2)}`} - sub="from prestige" - /> - </div> - - <h3 className="stats-section-header">Current Run</h3> - <div className="profile-stats"> - <StatCard - icon="🪙" - label="Gold" - value={formatNumber(resources.gold)} - /> - <StatCard - icon="✨" - label="Essence" - value={formatNumber(resources.essence)} - /> - <StatCard - icon="💎" - label="Crystals" - value={formatNumber(resources.crystals)} - /> - <StatCard - icon="🔮" - label="Runestones" - value={formatNumber(prestige.runestones)} - sub="permanent currency" - /> - </div> - - <h3 className="stats-section-header">Progress</h3> - <div className="profile-stats"> - <StatCard - icon="👹" - label="Bosses Defeated" - value={`${String(bossesDefeated)} / ${String(bosses.length)}`} - /> - <StatCard - icon="📜" - label="Quests Completed" - value={`${String(questsCompleted)} / ${String(quests.length)}`} - /> - <StatCard - icon="🗺️" - label="Zones Unlocked" - value={`${String(zonesUnlocked)} / ${String(zones.length)}`} - /> - <StatCard - icon="⚔️" - label="Adventurers Recruited" - value={formatNumber(adventurersRecruited)} - /> - <StatCard - icon="🗡️" - label="Equipment Owned" - value={`${String(equipmentOwned)} / ${String((equipment ?? []).length)}`} - /> - <StatCard - icon="🔧" - label="Upgrades Purchased" - value={`${String(upgradesPurchased)} / ${String(upgrades.length)}`} - /> - <StatCard - icon="🏆" - label="Achievements" - value={`${String(achievementsUnlocked)} / ${String((achievements ?? []).length)}`} - /> - <StatCard - icon="🔮" - label="Prestige Upgrades" - value={`${String(prestigeUpgradesPurchased)} / ${String(PRESTIGE_UPGRADES.length)}`} - /> - </div> - </section> - ); -}; diff --git a/apps/web/src/components/game/StoryPanel.tsx b/apps/web/src/components/game/StoryPanel.tsx deleted file mode 100644 index 401ee48..0000000 --- a/apps/web/src/components/game/StoryPanel.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { useState } from "react"; -import { STORY_CHAPTERS } from "@elysium/types"; -import { useGame } from "../../context/GameContext.js"; - -const substituteCharacterName = (text: string, characterName: string): string => - text.replaceAll("{characterName}", characterName || "the guild leader"); - -export const StoryPanel = (): React.JSX.Element => { - const { state, completeChapter } = useGame(); - const [activeChapterIndex, setActiveChapterIndex] = useState(0); - - if (!state) return <div className="story-panel"><p>Loading…</p></div>; - - const unlockedIds = state.story?.unlockedChapterIds ?? []; - const completedChapters = state.story?.completedChapters ?? []; - const characterName = state.player.characterName ?? ""; - - const activeChapter = STORY_CHAPTERS[activeChapterIndex]; - const isUnlocked = unlockedIds.includes(activeChapter?.id ?? ""); - const completion = activeChapter - ? completedChapters.find((c) => c.chapterId === activeChapter.id) - : null; - const isUnread = isUnlocked && !completion; - - return ( - <div className="story-panel"> - <div className="story-chapter-tabs"> - {STORY_CHAPTERS.map((chapter, index) => { - const unlocked = unlockedIds.includes(chapter.id); - const completed = completedChapters.some((c) => c.chapterId === chapter.id); - const unread = unlocked && !completed; - return ( - <button - key={chapter.id} - className={[ - "story-tab-btn", - activeChapterIndex === index ? "active" : "", - !unlocked ? "locked" : "", - ].join(" ")} - onClick={() => { - setActiveChapterIndex(index); - }} - type="button" - aria-label={unlocked ? chapter.title : `Chapter ${index + 1} (locked)`} - > - {index + 1} - {unread && <span className="story-unread-dot" />} - </button> - ); - })} - </div> - - {activeChapter && ( - <div className="story-chapter-view"> - {isUnlocked ? ( - <> - <h2 className="story-chapter-title"> - Chapter {activeChapterIndex + 1}: {activeChapter.title} - </h2> - <div className="story-chapter-content"> - {substituteCharacterName(activeChapter.content, characterName) - .split("\n\n") - .map((paragraph, i) => ( - // eslint-disable-next-line react/no-array-index-key -- static paragraph splits - <p key={i}>{paragraph}</p> - ))} - </div> - - {completion ? ( - <div className="story-choice-result"> - <p className="story-choice-label"> - <strong>Your choice:</strong>{" "} - {activeChapter.choices.find((c) => c.id === completion.choiceId)?.label} - </p> - <p className="story-choice-outcome"> - {substituteCharacterName( - activeChapter.choices.find((c) => c.id === completion.choiceId)?.outcome ?? "", - characterName, - )} - </p> - </div> - ) : ( - isUnread && ( - <div className="story-choices"> - <p className="story-choices-prompt">What do you do?</p> - {activeChapter.choices.map((choice) => ( - <button - key={choice.id} - className="story-choice-btn" - onClick={() => { - completeChapter(activeChapter.id, choice.id); - }} - type="button" - > - {choice.label} - </button> - ))} - </div> - ) - )} - </> - ) : ( - <div className="story-locked"> - <p className="story-locked-title">Chapter {activeChapterIndex + 1}</p> - <p className="story-locked-hint">🔒 This chapter has not yet been unlocked.</p> - </div> - )} - </div> - )} - </div> - ); -}; diff --git a/apps/web/src/components/game/StoryToast.tsx b/apps/web/src/components/game/StoryToast.tsx deleted file mode 100644 index ff2b154..0000000 --- a/apps/web/src/components/game/StoryToast.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { useEffect } from "react"; -import { STORY_CHAPTERS } from "@elysium/types"; -import { useGame } from "../../context/GameContext.js"; - -interface StoryToastItemProps { - chapterId: string; -} - -const StoryToastItem = ({ chapterId }: StoryToastItemProps): React.JSX.Element | null => { - const { dismissStoryChapter } = useGame(); - const chapter = STORY_CHAPTERS.find((c) => c.id === chapterId); - - useEffect(() => { - const timer = setTimeout(() => { - dismissStoryChapter(chapterId); - }, 4000); - return () => { - clearTimeout(timer); - }; - }, [chapterId, dismissStoryChapter]); - - if (!chapter) return null; - - return ( - <button - className="achievement-toast" - onClick={() => { - dismissStoryChapter(chapterId); - }} - type="button" - > - <span className="achievement-toast-icon">📖</span> - <div className="achievement-toast-content"> - <span className="achievement-toast-label">✨ New Chapter!</span> - <span className="achievement-toast-name">{chapter.title}</span> - </div> - </button> - ); -}; - -export const StoryToast = (): React.JSX.Element | null => { - const { newStoryChapterIds } = useGame(); - if (newStoryChapterIds.length === 0) return null; - return ( - <div className="achievement-toast-container"> - {newStoryChapterIds.map((id) => ( - <StoryToastItem key={id} chapterId={id} /> - ))} - </div> - ); -}; diff --git a/apps/web/src/components/game/TranscendencePanel.tsx b/apps/web/src/components/game/TranscendencePanel.tsx deleted file mode 100644 index 15aafc7..0000000 --- a/apps/web/src/components/game/TranscendencePanel.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import type { TranscendenceUpgradeCategory } from "@elysium/types"; -import { useState } from "react"; -import { useGame } from "../../context/GameContext.js"; -import { - TRANSCENDENCE_UPGRADES, - TRANSCENDENCE_UPGRADE_CATEGORY_LABELS, -} from "../../data/transcendenceUpgrades.js"; - -const ECHO_FORMULA_CONSTANT = 853; -const FINAL_BOSS_ID = "the_absolute_one"; - -const calculateEchoPreview = (prestigeCount: number, echoMetaMultiplier: number): number => { - const safeCount = Math.max(prestigeCount, 1); - return Math.floor((ECHO_FORMULA_CONSTANT / Math.sqrt(safeCount)) * echoMetaMultiplier); -}; - -const CATEGORY_ORDER: TranscendenceUpgradeCategory[] = [ - "income", - "combat", - "prestige_threshold", - "prestige_runestones", - "echo_meta", -]; - -export const TranscendencePanel = (): React.JSX.Element => { - const { state, formatNumber, transcend, buyEchoUpgrade } = useGame(); - const [isPending, setIsPending] = useState(false); - const [result, setResult] = useState<{ echoes: number; count: number } | null>(null); - const [error, setError] = useState<string | null>(null); - const [buyingId, setBuyingId] = useState<string | null>(null); - const [activeTab, setActiveTab] = useState<"transcend" | "shop">("transcend"); - - if (!state) return <section className="panel"><p>Loading...</p></section>; - - const { bosses, prestige: prestigeData, transcendence } = state; - const hasDefeatedFinalBoss = bosses.some((b) => b.id === FINAL_BOSS_ID && b.status === "defeated"); - const echoMetaMultiplier = transcendence?.echoMetaMultiplier ?? 1; - const echoPreview = calculateEchoPreview(prestigeData.count, echoMetaMultiplier); - const currentEchoes = transcendence?.echoes ?? 0; - const transcendenceCount = transcendence?.count ?? 0; - - const handleTranscend = async (): Promise<void> => { - setIsPending(true); - setError(null); - try { - const data = await transcend(); - setResult({ echoes: data.echoes, count: data.newTranscendenceCount }); - } catch (err) { - setError(err instanceof Error ? err.message : "Transcendence failed"); - } finally { - setIsPending(false); - } - }; - - const handleBuyUpgrade = async (upgradeId: string): Promise<void> => { - setBuyingId(upgradeId); - try { - await buyEchoUpgrade(upgradeId); - } finally { - setBuyingId(null); - } - }; - - const upgradesByCategory = CATEGORY_ORDER.map((category) => ({ - category, - label: TRANSCENDENCE_UPGRADE_CATEGORY_LABELS[category] ?? category, - upgrades: TRANSCENDENCE_UPGRADES.filter((u) => u.category === category), - })); - - return ( - <section className="panel transcendence-panel"> - <h2>🌌 Transcendence</h2> - - <div className="prestige-tabs"> - <button - className={`prestige-tab ${activeTab === "transcend" ? "active" : ""}`} - onClick={() => { setActiveTab("transcend"); }} - type="button" - > - Transcend - </button> - <button - className={`prestige-tab ${activeTab === "shop" ? "active" : ""}`} - onClick={() => { setActiveTab("shop"); }} - type="button" - > - ✨ Echo Shop ({formatNumber(currentEchoes)} echoes) - </button> - </div> - - {activeTab === "transcend" && ( - <> - <p className="transcendence-intro"> - Transcendence is the ultimate reset. It wipes{" "} - <strong>everything</strong> — resources, prestige, runestones, upgrades, - and equipment — but grants <strong>Echoes</strong>, a permanent currency - that survives all future resets. Echoes power upgrades that permanently - amplify every run from this point forward. - </p> - <p className="transcendence-intro"> - <em> - Fewer prestiges = more Echoes. Optimise your run for maximum yield! - </em> - </p> - - <div className="transcendence-status"> - {transcendenceCount > 0 && ( - <p>Transcendence count: <strong>{transcendenceCount}</strong></p> - )} - <p>Current Echoes: <strong>{formatNumber(currentEchoes)}</strong></p> - <p>Current prestige count: <strong>{prestigeData.count}</strong></p> - {hasDefeatedFinalBoss && ( - <p className="echo-preview"> - Echoes on transcendence: <strong>+{formatNumber(echoPreview)}</strong> - {echoMetaMultiplier > 1 && ( - <span className="echo-meta-bonus"> - {" "}(×{echoMetaMultiplier.toFixed(2)} meta bonus applied) - </span> - )} - </p> - )} - </div> - - {!hasDefeatedFinalBoss && ( - <div className="transcendence-locked"> - <p>🔒 <strong>Defeat The Absolute One</strong> to unlock transcendence.</p> - <p className="transcendence-hint"> - The Absolute One is the final boss of The Absolute zone, requiring - Prestige 90 to challenge. - </p> - </div> - )} - - {hasDefeatedFinalBoss && ( - <div className="prestige-form"> - <p>You are ready to transcend. This action is <strong>irreversible</strong>.</p> - <button - className="transcendence-button" - disabled={isPending} - onClick={() => { void handleTranscend(); }} - type="button" - > - {isPending - ? "Transcending..." - : `🌌 Transcend (+${formatNumber(echoPreview)} Echoes)`} - </button> - {error && <p className="error">{error}</p>} - {result && ( - <p className="success"> - Transcended! Earned{" "} - <strong>{formatNumber(result.echoes)} Echoes</strong>. This is - Transcendence {result.count}. A new cycle begins. - </p> - )} - </div> - )} - </> - )} - - {activeTab === "shop" && ( - <div className="echo-shop"> - <p className="shop-balance"> - Balance: <strong>{formatNumber(currentEchoes)} Echoes</strong> - </p> - <p className="echo-shop-description"> - Echo upgrades are <strong>permanent</strong> — they survive all future - prestiges and transcendences. - </p> - - {upgradesByCategory.map(({ category, label, upgrades }) => ( - <div key={category} className="shop-category"> - <h3>{label}</h3> - <div className="shop-upgrades"> - {upgrades.map((upgrade) => { - const purchased = (transcendence?.purchasedUpgradeIds ?? []).includes(upgrade.id); - const canAfford = currentEchoes >= upgrade.cost; - const isLoading = buyingId === upgrade.id; - - return ( - <div - key={upgrade.id} - className={`shop-upgrade-card echo-upgrade-card ${purchased ? "purchased" : ""} ${!canAfford && !purchased ? "unaffordable" : ""}`} - > - <div className="shop-upgrade-info"> - <h4>{upgrade.name}</h4> - <p>{upgrade.description}</p> - <p className="upgrade-cost"> - {purchased - ? "✅ Purchased" - : `✨ ${formatNumber(upgrade.cost)} Echoes`} - </p> - </div> - {!purchased && ( - <button - className="buy-upgrade-button echo-buy-button" - disabled={!canAfford || isLoading || buyingId !== null} - onClick={() => { void handleBuyUpgrade(upgrade.id); }} - type="button" - > - {isLoading ? "Buying..." : "Buy"} - </button> - )} - </div> - ); - })} - </div> - </div> - ))} - </div> - )} - </section> - ); -}; diff --git a/apps/web/src/components/game/UpgradePanel.tsx b/apps/web/src/components/game/UpgradePanel.tsx deleted file mode 100644 index a9f7944..0000000 --- a/apps/web/src/components/game/UpgradePanel.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import type { Upgrade } from "@elysium/types"; -import { useState } from "react"; -import { useGame } from "../../context/GameContext.js"; -import { LockToggle } from "../ui/LockToggle.js"; - -interface UpgradeCardProps { - upgrade: Upgrade; - currentGold: number; - currentEssence: number; - currentCrystals: number; - unlockHint?: string | undefined; - formatNumber: (n: number) => string; -} - -const UpgradeCard = ({ upgrade, currentGold, currentEssence, currentCrystals, unlockHint, formatNumber }: UpgradeCardProps): React.JSX.Element => { - const { buyUpgrade } = useGame(); - const canAfford = - currentGold >= upgrade.costGold && - currentEssence >= upgrade.costEssence && - currentCrystals >= (upgrade.costCrystals ?? 0); - - if (!upgrade.unlocked) { - return ( - <div className="upgrade-card locked"> - <div className="upgrade-info"> - <h3>🔒 {upgrade.name}</h3> - <p>{upgrade.description}</p> - <p className="upgrade-multiplier">×{upgrade.multiplier} multiplier</p> - </div> - <div className="upgrade-cost"> - {upgrade.costGold > 0 && <span>🪙 {formatNumber(upgrade.costGold)}</span>} - {upgrade.costEssence > 0 && <span>✨ {formatNumber(upgrade.costEssence)}</span>} - {(upgrade.costCrystals ?? 0) > 0 && <span>💎 {formatNumber(upgrade.costCrystals ?? 0)}</span>} - </div> - <span className="upgrade-locked-label">Locked</span> - {unlockHint && <p className="unlock-hint">{unlockHint}</p>} - </div> - ); - } - - if (upgrade.purchased) { - return ( - <div className="upgrade-card purchased"> - <span className="upgrade-name">✅ {upgrade.name}</span> - <span className="upgrade-desc">{upgrade.description}</span> - </div> - ); - } - - return ( - <div className="upgrade-card"> - <div className="upgrade-info"> - <h3>{upgrade.name}</h3> - <p>{upgrade.description}</p> - <p className="upgrade-multiplier">×{upgrade.multiplier} multiplier</p> - </div> - <div className="upgrade-cost"> - {upgrade.costGold > 0 && <span>🪙 {formatNumber(upgrade.costGold)}</span>} - {upgrade.costEssence > 0 && <span>✨ {formatNumber(upgrade.costEssence)}</span>} - {(upgrade.costCrystals ?? 0) > 0 && <span>💎 {formatNumber(upgrade.costCrystals ?? 0)}</span>} - </div> - <button - className="buy-button" - disabled={!canAfford} - onClick={() => { buyUpgrade(upgrade.id); }} - type="button" - > - Buy - </button> - </div> - ); -}; - -export const UpgradePanel = (): React.JSX.Element => { - const { state, formatNumber } = useGame(); - const [showLocked, setShowLocked] = useState(true); - - if (!state) return <section className="panel"><p>Loading...</p></section>; - - const purchased = state.upgrades.filter((u) => u.purchased); - const available = state.upgrades.filter((u) => u.unlocked && !u.purchased); - const locked = state.upgrades.filter((u) => !u.unlocked); - - const upgradeUnlockHints = new Map<string, string>(); - for (const boss of state.bosses) { - for (const upgradeId of (boss.upgradeRewards ?? [])) { - upgradeUnlockHints.set(upgradeId, `⚔️ Defeat: ${boss.name}`); - } - } - for (const quest of state.quests) { - for (const reward of quest.rewards) { - if (reward.type === "upgrade" && reward.targetId && !upgradeUnlockHints.has(reward.targetId)) { - upgradeUnlockHints.set(reward.targetId, `📜 Complete: ${quest.name}`); - } - } - } - - return ( - <section className="panel upgrade-panel"> - <div className="panel-header"> - <h2>Upgrades</h2> - <LockToggle - lockedCount={locked.length} - showLocked={showLocked} - onToggle={() => { setShowLocked((v) => !v); }} - /> - </div> - <p className="upgrade-progress">{purchased.length} / {state.upgrades.length} purchased</p> - {state.upgrades.length === 0 ? ( - <p className="empty-state">No upgrades available yet — keep adventuring!</p> - ) : ( - <div className="upgrade-list"> - {available.map((upgrade) => ( - <UpgradeCard - key={upgrade.id} - upgrade={upgrade} - currentGold={state.resources.gold} - currentEssence={state.resources.essence} - currentCrystals={state.resources.crystals} - formatNumber={formatNumber} - /> - ))} - {purchased.map((upgrade) => ( - <UpgradeCard - key={upgrade.id} - upgrade={upgrade} - currentGold={state.resources.gold} - currentEssence={state.resources.essence} - currentCrystals={state.resources.crystals} - formatNumber={formatNumber} - /> - ))} - {showLocked && locked.map((upgrade) => ( - <UpgradeCard - key={upgrade.id} - upgrade={upgrade} - currentGold={state.resources.gold} - currentEssence={state.resources.essence} - currentCrystals={state.resources.crystals} - formatNumber={formatNumber} - unlockHint={upgradeUnlockHints.get(upgrade.id)} - /> - ))} - </div> - )} - </section> - ); -}; diff --git a/apps/web/src/components/game/ZoneSelector.tsx b/apps/web/src/components/game/ZoneSelector.tsx deleted file mode 100644 index 2b10629..0000000 --- a/apps/web/src/components/game/ZoneSelector.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { Zone } from "@elysium/types"; - -interface ZoneSelectorProps { - zones: Zone[]; - activeZoneId: string; - onSelectZone: (zoneId: string) => void; -} - -export const ZoneSelector = ({ - zones, - activeZoneId, - onSelectZone, -}: ZoneSelectorProps): React.JSX.Element => ( - <div className="zone-selector"> - {zones.map((zone) => ( - <button - key={zone.id} - className={`zone-tab ${zone.id === activeZoneId ? "zone-tab-active" : ""}`} - onClick={() => { - onSelectZone(zone.id); - }} - title={zone.description} - type="button" - > - <span className="zone-emoji">{zone.emoji}</span> - <span className="zone-name">{zone.name}</span> - </button> - ))} - </div> -); diff --git a/apps/web/src/components/game/aboutPanel.tsx b/apps/web/src/components/game/aboutPanel.tsx new file mode 100644 index 0000000..7bc2dd3 --- /dev/null +++ b/apps/web/src/components/game/aboutPanel.tsx @@ -0,0 +1,346 @@ +/** + * @file About panel component displaying changelog and how-to-play guide. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- HOW_TO_PLAY data and render logic */ +/* eslint-disable max-lines -- HOW_TO_PLAY data makes this file long */ +import { type JSX, useEffect, useState } from "react"; +import { getAbout } from "../../api/client.js"; +import type { AboutResponse } from "@elysium/types"; + +const howToPlay = [ + { + body: + "Hire adventurers to earn gold and essence automatically. Each tier is" + + " more powerful than the last. Adventurers also contribute combat" + + " power for boss fights — the more you recruit, the stronger your" + + " party becomes.", + title: "⚔️ Adventurers", + }, + { + body: + "Click the guild hall to earn gold manually. Upgrades and equipment can" + + " dramatically increase your gold per click. Clicking is especially" + + " powerful in the early game and when saving up for big purchases.", + title: "👆 Clicking", + }, + { + body: + "Purchase upgrades to multiply the gold and essence output of specific" + + " adventurer tiers, or boost your whole guild. Upgrades are permanent" + + " for the current run and compound with each other.", + title: "🔧 Upgrades", + }, + { + body: + "Send your guild on quests that complete over time and reward gold," + + " essence, crystals, equipment, and upgrades. Multiple quests can run" + + " simultaneously. Completing quests also unlocks new zones.", + title: "📜 Quests", + }, + { + body: + "Challenge zone bosses to earn large one-time rewards and unlock new" + + " zones. Your party's combat power is based on the number and tier of" + + " adventurers you've recruited. Defeated bosses cannot be re-fought," + + " but undefeated bosses regenerate HP over time.", + title: "👹 Boss Fights", + }, + { + body: + "New zones unlock when you defeat the final boss AND complete the final" + + " quest of the previous zone. Each zone contains new bosses and" + + " quests with progressively greater rewards.", + title: "🗺️ Zones", + }, + { + body: + "Earn equipment from boss drops and quest rewards. Each piece provides" + + " bonuses to gold income, click power, or combat. Rarer equipment" + + " provides stronger bonuses. Equip matching set pieces (2 or 3 of a" + + " named set) to unlock escalating set bonuses shown at the top of the" + + " Equipment panel.", + title: "🗡️ Equipment & Sets", + }, + { + body: + "When you've progressed far enough, you can prestige to earn runestones" + + " — a permanent currency that persists across all runs. Prestige" + + " resets your current run but grants a production multiplier that" + + " stacks with every prestige.", + title: "⭐ Prestige", + }, + { + body: + "Spend runestones in the Prestige Shop on permanent upgrades that carry" + + " over across all future runs. These upgrades multiply income, click" + + " power, essence, and crystal gain — making each new run more powerful" + + " than the last.", + title: "🔮 Runestones & Prestige Upgrades", + }, + { + body: + "Purchase the Autonomous Ascension upgrade in the Prestige Shop" + + " (100 runestones) to unlock the Auto-Prestige toggle. When enabled," + + " you will automatically ascend the moment you reach the prestige" + + " threshold, using your current character name. Toggle it on and off" + + " freely from the Prestige Shop.", + title: "⚙️ Auto-Prestige", + }, + { + body: + "Earn achievements by hitting milestones — total gold earned, bosses" + + " defeated, quests completed, and more. Achievements are purely" + + " cosmetic and track your long-term progress across all prestige runs.", + title: "🏆 Achievements", + }, + { + body: + "Complete daily challenges for bonus rewards including gold, essence," + + " crystals, and runestones. Challenges reset each day and vary in" + + " difficulty. Completing all daily challenges gives an extra bonus" + + " reward.", + title: "📅 Daily Challenges", + }, + { + body: + "Send scouts to explore areas within each zone. Explorations run in" + + " real-time and reward gold, essence, and crafting materials when" + + " collected. Each area has a set duration — short explorations are" + + " faster but longer ones offer rarer finds. A 📖 icon marks areas" + + " you've collected from at least once, unlocking a Codex entry.", + title: "🗺️ Exploration", + }, + { + body: + "Use materials gathered from exploration to craft permanent bonuses." + + " Each recipe provides a multiplier to gold income, essence income," + + " click power, or combat power — all of which stack and persist across" + + " prestige runs. Check the Crafting tab to see your material inventory" + + " and available recipes per zone.", + title: "⚗️ Crafting", + }, + { + body: + "Defeating bosses, completing quests, acquiring equipment, hiring" + + " adventurers, purchasing upgrades, unlocking prestige upgrades," + + " discovering new zones, collecting from exploration areas, and" + + " crafting recipes all permanently unlock lore entries in the Codex." + + " A badge appears on the Codex tab and a toast notification pops up" + + " each time new lore is discovered. Collect all 472 entries to build" + + " a complete picture of the world of Elysium.", + title: "📖 Codex", + }, + { + body: + "Visit the Character tab to write about your character and guild. Fill" + + " in your character's name, pronouns, race, class, and backstory," + + " then create a guild with its own name and lore. Your character sheet" + + " is visible on your public profile page.", + title: "📋 Character Sheet", + }, + { + body: + "Earn Titles by reaching milestones — defeating bosses, completing" + + " quests, prestiging, and more. Once unlocked, titles are yours" + + " forever and are never lost on prestige or transcendence resets. Set" + + " your active title from the Character tab to display it on your" + + " character sheet and public profile.", + title: "🏅 Titles", + }, + { + body: + "Defeat bosses to earn equipment drops: weapons, armour, and trinkets." + + " Each item provides bonuses to gold income, combat power, or click" + + " power. Only one item per slot can be equipped at a time — visit the" + + " Equipment panel to manage your loadout. Your currently equipped" + + " items are displayed on your character sheet and public profile.", + title: "🗡️ Equipment", + }, + { + body: + "Compete with other adventurers on the public Leaderboards page!" + + " Categories include Lifetime Gold, Bosses Defeated, Quests" + + " Completed, Achievements, Prestige Count, Transcendence Count, and" + + " Apotheosis Count. Click any player's row to view their character" + + " sheet. You can opt out of appearing on leaderboards via the Privacy" + + " section in your profile settings.", + title: "🏆 Leaderboards", + }, + { + body: + "Log in every day to earn escalating rewards! Each consecutive day" + + " awards more gold, and the 7th day of your streak grants bonus" + + " crystals. Your streak resets if you miss a day. A week multiplier" + + " increases all rewards the longer your overall streak runs. Your" + + " current streak is displayed on your character sheet.", + title: "🔥 Daily Login Bonus", + }, + { + body: + "Toggle automation in the Quests and Boss Encounters panels! Auto-Quest" + + " automatically sends your party on the highest-zone available quest" + + " as soon as one completes, skipping quests whose combat power" + + " requirement isn't met. Auto-Boss automatically challenges the" + + " highest available boss as soon as one is ready. Both can be toggled" + + " on or off at any time using the 🤖 Auto button in each panel" + + " header.", + title: "🤖 Auto-Quest & Auto-Boss", + }, + { + body: + "Unlock companions by reaching certain milestones across all your runs." + + " Each companion provides a powerful permanent bonus: increased" + + " passive gold, click gold, boss damage, essence income, or reduced" + + " quest time. You can only have one companion active at a time —" + + " choose wisely based on your current strategy! Companions are" + + " unlocked permanently once their condition is met and will never be" + + " lost.", + title: "👥 Companions", + }, + { + body: + "Your progress is automatically saved to the cloud every 30 seconds" + + " whilst you play. You can also force a manual save at any time using" + + " the sync button in the resource bar. Your save is protected by HMAC" + + " validation to ensure data integrity.", + title: "☁️ Cloud Saves", + }, + { + body: + "Transcendence is the ultimate prestige layer, unlocked by defeating" + + " The Absolute One (requires Prestige 90). Transcending performs a" + + " nuclear reset — wiping resources, prestige, runestones, upgrades," + + " and equipment — but grants Echoes based on your prestige count" + + " (fewer prestiges = more Echoes). Echoes are permanent and survive" + + " all future resets. Spend them in the Echo Shop on lasting" + + " multipliers: passive income, combat power, prestige" + + " quality-of-life, and Echo meta upgrades that amplify future Echo" + + " yields.", + title: "🌌 Transcendence", + }, + { + body: + "Apotheosis is the final act — a complete dissolution of everything you" + + " have built, including your prestige and transcendence progress. It" + + " is unlocked once you have purchased every Transcendence upgrade. In" + + " exchange for this total reset, you receive the Apotheosis badge:" + + " pure bragging rights, a mark of reaching the absolute pinnacle of" + + " the game. Apotheosis can be achieved multiple times; each cycle" + + " requires purchasing all Transcendence upgrades again. Your Codex" + + " entries and lifetime profile statistics are always preserved.", + title: "✨ Apotheosis", + }, + { + body: + "The Story tab contains 22 chapters that unlock as you progress. The" + + " first 18 unlock when you defeat the final boss of each zone." + + " Chapters 19 and 20 unlock after your first and fifth prestige" + + " respectively. Chapter 21 unlocks on your first transcendence, and" + + " Chapter 22 on your first apotheosis. Each chapter presents a" + + " narrative moment and three choices — the choice you make is recorded" + + " on your Character Sheet and shapes your guild's story. Story" + + " progress is permanent and survives all resets.", + title: "📖 Story", + }, +]; + +const formatDate = (dateString: string): string => { + return new Date(dateString).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }); +}; + +/** + * Renders the about panel with changelog and how-to-play sections. + * @returns The JSX element. + */ +const aboutPanel = (): JSX.Element => { + const [ about, setAbout ] = useState<AboutResponse | null>(null); + const [ error, setError ] = useState<string | null>(null); + const [ expandedRelease, setExpandedRelease ] = useState<string | null>(null); + + useEffect(() => { + getAbout(). + then(setAbout). + catch((caughtError: unknown) => { + setError( + caughtError instanceof Error + ? caughtError.message + : "Failed to load about data.", + ); + }); + }, []); + + return ( + <section className="panel about-panel"> + <h2>{"ℹ️ About"}</h2> + + <h3 className="stats-section-header">{"📋 Changelog"}</h3> + {error !== null && <p className="about-error">{error}</p>} + {about === null && error === null + && <p className="about-loading">{"Loading changelog..."}</p> + } + {about !== null && about.releases.length === 0 + && <p className="about-empty">{"No releases yet."}</p> + } + {about !== null && about.releases.length > 0 + && <ul className="about-releases"> + {about.releases.map((release) => { + function handleToggle(): void { + setExpandedRelease( + expandedRelease === release.tag_name + ? null + : release.tag_name, + ); + } + return ( + <li className="about-release" key={release.tag_name}> + <button + className="about-release-header" + onClick={handleToggle} + type="button" + > + <span className="about-release-tag"> + {release.name.length > 0 + ? release.name + : release.tag_name} + </span> + <span className="about-release-date"> + {formatDate(release.published_at)} + </span> + <span className="about-release-chevron"> + {expandedRelease === release.tag_name + ? "▲" + : "▼"} + </span> + </button> + {expandedRelease === release.tag_name + && <pre className="about-release-body">{release.body}</pre> + } + </li> + ); + })} + </ul> + } + + <h3 className="stats-section-header">{"📖 How to Play"}</h3> + <ul className="about-how-to-play"> + {howToPlay.map((section) => { + return ( + <li className="about-htp-section" key={section.title}> + <h4 className="about-htp-title">{section.title}</h4> + <p className="about-htp-body">{section.body}</p> + </li> + ); + })} + </ul> + </section> + ); +}; + +export { aboutPanel as AboutPanel }; diff --git a/apps/web/src/components/game/achievementPanel.tsx b/apps/web/src/components/game/achievementPanel.tsx new file mode 100644 index 0000000..29205be --- /dev/null +++ b/apps/web/src/components/game/achievementPanel.tsx @@ -0,0 +1,169 @@ +/** + * @file Achievement panel component displaying all game achievements. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to this panel */ +import { type JSX, useState } from "react"; +import { useGame } from "../../context/gameContext.js"; +import { LockToggle } from "../ui/lockToggle.js"; +import type { Achievement } from "@elysium/types"; + +/** + * Returns the plural form of a word based on a count. + * @param count - The count to check. + * @param word - The base word to pluralise. + * @returns The pluralised word string. + */ +const pluralise = (count: number, word: string): string => { + return count > 1 + ? `${word}s` + : word; +}; + +/** + * Generates a human-readable condition description for an achievement. + * @param achievement - The achievement to describe. + * @param formatNumber - The number formatting utility function. + * @returns A string describing the achievement condition. + */ +const conditionDescription = ( + achievement: Achievement, + formatNumber: (n: number)=> string, +): string => { + const { condition } = achievement; + switch (condition.type) { + case "totalGoldEarned": + return `Earn ${formatNumber(condition.amount)} total gold`; + case "totalClicks": + return `Click ${formatNumber(condition.amount)} times`; + case "bossesDefeated": + return `Defeat ${String(condition.amount)} ${pluralise(condition.amount, "boss")}`; + case "questsCompleted": + return `Complete ${String(condition.amount)} ${pluralise(condition.amount, "quest")}`; + case "adventurerTotal": + return `Recruit ${formatNumber(condition.amount)} total adventurers`; + case "prestigeCount": + return `Prestige ${String(condition.amount)} ${pluralise(condition.amount, "time")}`; + case "equipmentOwned": + return `Own ${String(condition.amount)} equipment ${pluralise(condition.amount, "item")}`; + default: + return "Unknown condition"; + } +}; + +interface AchievementCardProperties { + readonly achievement: Achievement; + readonly formatNumber: (n: number)=> string; +} + +/** + * Renders a single achievement card. + * @param props - The achievement card properties. + * @param props.achievement - The achievement to display. + * @param props.formatNumber - The number formatting utility function. + * @returns The JSX element. + */ +const AchievementCard = ({ + achievement, + formatNumber, +}: AchievementCardProperties): JSX.Element => { + const isUnlocked = achievement.unlockedAt !== null; + const crystals = achievement.reward?.crystals; + + return ( + <div className={`achievement-card ${isUnlocked + ? "unlocked" + : "locked"}`}> + <div className="achievement-icon">{achievement.icon}</div> + <div className="achievement-info"> + <h3>{achievement.name}</h3> + <p>{achievement.description}</p> + <p className="achievement-condition"> + {conditionDescription(achievement, formatNumber)} + </p> + {crystals !== undefined + && <p className="achievement-reward"> + {"💎 +"} + {crystals} + {" Crystals"} + </p> + } + </div> + <div className="achievement-status"> + {isUnlocked + ? <span className="achievement-unlocked-badge">{"✓ Unlocked"}</span> + : <span className="achievement-locked-badge">{"🔒"}</span> + } + </div> + </div> + ); +}; + +/** + * Renders the achievement panel with all achievements. + * @returns The JSX element. + */ +// eslint-disable-next-line max-lines-per-function -- Achievement panel renders many achievement states +const AchievementPanel = (): JSX.Element => { + const { state, formatNumber } = useGame(); + const [ showLocked, setShowLocked ] = useState(true); + + if (state === null) { + return ( + <section className="panel"> + <p>{"Loading..."}</p> + </section> + ); + } + + const achievementList = state.achievements; + const unlocked = achievementList.filter((a) => { + return a.unlockedAt !== null; + }); + const locked = achievementList.filter((a) => { + return a.unlockedAt === null; + }); + const visible = showLocked + ? achievementList + : unlocked; + + function handleToggle(): void { + setShowLocked((current) => { + return !current; + }); + } + + return ( + <section className="panel achievement-panel"> + <div className="panel-header"> + <h2>{"Achievements"}</h2> + <LockToggle + lockedCount={locked.length} + onToggle={handleToggle} + showLocked={showLocked} + /> + </div> + <p className="achievement-progress"> + {unlocked.length} + {" / "} + {achievementList.length} + {" unlocked"} + </p> + <div className="achievement-list"> + {visible.map((achievement) => { + return ( + <AchievementCard + achievement={achievement} + formatNumber={formatNumber} + key={achievement.id} + /> + ); + })} + </div> + </section> + ); +}; + +export { AchievementPanel }; diff --git a/apps/web/src/components/game/achievementToast.tsx b/apps/web/src/components/game/achievementToast.tsx new file mode 100644 index 0000000..5cece1e --- /dev/null +++ b/apps/web/src/components/game/achievementToast.tsx @@ -0,0 +1,87 @@ +/** + * @file Achievement toast notification component. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the toast container */ +import { type JSX, useEffect } from "react"; +import { useGame } from "../../context/gameContext.js"; +import type { Achievement } from "@elysium/types"; + +interface ToastItemProperties { + readonly achievement: Achievement; + readonly onDismiss: (id: string)=> void; +} + +/** + * Renders a single achievement toast item. + * @param props - The toast item properties. + * @param props.achievement - The achievement to display. + * @param props.onDismiss - Callback to dismiss the toast. + * @returns The JSX element. + */ +const ToastItem = ({ + achievement, + onDismiss, +}: ToastItemProperties): JSX.Element => { + useEffect(() => { + const timer = setTimeout(() => { + onDismiss(achievement.id); + }, 4000); + return (): void => { + clearTimeout(timer); + }; + }, [ achievement.id, onDismiss ]); + + function handleClick(): void { + onDismiss(achievement.id); + } + + const crystals = achievement.reward?.crystals; + + return ( + <div className="achievement-toast" onClick={handleClick}> + <span className="toast-icon">{achievement.icon}</span> + <div className="toast-content"> + <span className="toast-label">{"Achievement Unlocked!"}</span> + <span className="toast-name">{achievement.name}</span> + {crystals !== undefined + && <span className="toast-reward"> + {"💎 +"} + {crystals} + </span> + } + </div> + </div> + ); +}; + +/** + * Renders the achievement toast container with pending achievement notifications. + * @returns The JSX element or null if there are no pending achievements. + */ +const AchievementToast = (): JSX.Element | null => { + const { unlockedAchievements: pendingAchievements, dismissAchievement } + = useGame(); + + if (pendingAchievements.length === 0) { + return null; + } + + return ( + <div className="achievement-toast-container"> + {pendingAchievements.map((achievement) => { + return ( + <ToastItem + achievement={achievement} + key={achievement.id} + onDismiss={dismissAchievement} + /> + ); + })} + </div> + ); +}; + +export { AchievementToast }; diff --git a/apps/web/src/components/game/adventurerPanel.tsx b/apps/web/src/components/game/adventurerPanel.tsx new file mode 100644 index 0000000..262f48f --- /dev/null +++ b/apps/web/src/components/game/adventurerPanel.tsx @@ -0,0 +1,241 @@ +/** + * @file Adventurer panel component for hiring and managing adventurers. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */ +/* eslint-disable max-lines-per-function -- Complex component with many render paths */ +/* eslint-disable complexity -- Complex component with many render paths */ +import { type JSX, useState } from "react"; +import { useGame } from "../../context/gameContext.js"; +import { LockToggle } from "../ui/lockToggle.js"; +import type { Adventurer } from "@elysium/types"; + +const iconByClass: Record<string, string> = { + cleric: "✝️", + mage: "🔮", + paladin: "🛡️", + ranger: "🏹", + rogue: "🗝️", + warrior: "🗡️", +}; + +type BatchSize = 1 | 5 | 10 | 25 | 100 | "max"; +const batchOptions: Array<BatchSize> = [ 1, 5, 10, 25, 100, "max" ]; + +/** + * Computes the total cost to buy a batch of adventurers. + * @param adventurer - The adventurer to buy. + * @param quantity - The number to buy. + * @returns The total gold cost. + */ +const computeBatchCost = (adventurer: Adventurer, quantity: number): number => { + let total = 0; + for (let index = 0; index < quantity; index = index + 1) { + const cost = adventurer.baseCost * Math.pow(1.15, adventurer.count + index); + total = total + cost; + } + return total; +}; + +/** + * Computes the maximum number of adventurers affordable with given gold. + * @param adventurer - The adventurer type. + * @param gold - The available gold. + * @returns The maximum affordable quantity. + */ +const computeMaxAffordable = (adventurer: Adventurer, gold: number): number => { + let total = 0; + let quantity = 0; + for (let index = 0; index < 100_000; index = index + 1) { + const cost = adventurer.baseCost * Math.pow(1.15, adventurer.count + index); + if (total + cost > gold) { + break; + } + total = total + cost; + quantity = quantity + 1; + } + return quantity; +}; + +interface AdventurerCardProperties { + readonly adventurer: Adventurer; + readonly currentGold: number; + readonly batchSize: BatchSize; + readonly unlockHint: string | undefined; + readonly formatNumber: (n: number)=> string; +} + +/** + * Renders a single adventurer card with buy controls. + * @param props - The adventurer card properties. + * @param props.adventurer - The adventurer data. + * @param props.currentGold - The current gold available. + * @param props.batchSize - The selected batch size. + * @param props.unlockHint - Optional quest name that unlocks this adventurer. + * @param props.formatNumber - The number formatting utility function. + * @returns The JSX element. + */ +const AdventurerCard = ({ + adventurer, + currentGold, + batchSize, + unlockHint, + formatNumber, +}: AdventurerCardProperties): JSX.Element => { + const { buyAdventurer } = useGame(); + + const resolvedQuantity + = batchSize === "max" + ? computeMaxAffordable(adventurer, currentGold) + : batchSize; + const cost = computeBatchCost(adventurer, resolvedQuantity); + const canAfford = resolvedQuantity > 0 && currentGold >= cost; + + function handleBuy(): void { + buyAdventurer(adventurer.id, resolvedQuantity); + } + + const maxSuffix + = batchSize === "max" && resolvedQuantity > 0 + ? ` (×${String(resolvedQuantity)})` + : ""; + const buttonLabel = adventurer.unlocked + ? `🪙 ${formatNumber(Math.ceil(cost))}${maxSuffix}` + : "🔒 Locked"; + + // eslint-disable-next-line @typescript-eslint/dot-notation -- "class" is a reserved word + const adventurerIcon = iconByClass[adventurer["class"]] ?? "⚔️"; + + return ( + <div className={`adventurer-card ${adventurer.unlocked + ? "" + : "locked"}`}> + <div className="adventurer-icon">{adventurerIcon}</div> + <div className="adventurer-info"> + <h3>{adventurer.name}</h3> + <p> + {formatNumber(adventurer.goldPerSecond)} + {" gold/s each"} + </p> + {adventurer.essencePerSecond > 0 + && <p> + {formatNumber(adventurer.essencePerSecond)} + {" essence/s each"} + </p> + } + </div> + <div className="adventurer-count"> + {"×"} + {adventurer.count} + </div> + <button + className="buy-button" + disabled={!canAfford || !adventurer.unlocked} + onClick={handleBuy} + type="button" + > + {buttonLabel} + </button> + {!adventurer.unlocked && unlockHint !== undefined + ? <p className="unlock-hint"> + {"📜 Complete: "} + {unlockHint} + </p> + : null} + </div> + ); +}; + +/** + * Renders the adventurer panel with all available adventurers. + * @returns The JSX element. + */ +const AdventurerPanel = (): JSX.Element => { + const { state, formatNumber } = useGame(); + const [ showLocked, setShowLocked ] = useState(true); + const [ batchSize, setBatchSize ] = useState<BatchSize>(1); + + if (state === null) { + return ( + <section className="panel"> + <p>{"Loading..."}</p> + </section> + ); + } + + const locked = state.adventurers.filter((adventurer) => { + return !adventurer.unlocked; + }); + const visible = showLocked + ? state.adventurers + : state.adventurers.filter((adventurer) => { + return adventurer.unlocked; + }); + + const adventurerUnlockHints = new Map<string, string>(); + for (const quest of state.quests) { + for (const reward of quest.rewards) { + if (reward.type === "adventurer" && reward.targetId !== undefined) { + adventurerUnlockHints.set(reward.targetId, quest.name); + } + } + } + + function handleToggle(): void { + setShowLocked((current) => { + return !current; + }); + } + + return ( + <section className="panel adventurer-panel"> + <div className="panel-header"> + <h2>{"Adventurers"}</h2> + <LockToggle + lockedCount={locked.length} + onToggle={handleToggle} + showLocked={showLocked} + /> + </div> + <div className="batch-selector"> + {batchOptions.map((option) => { + function handleBatchSelect(): void { + setBatchSize(option); + } + return ( + <button + className={`batch-button ${batchSize === option + ? "active" + : ""}`} + key={option} + onClick={handleBatchSelect} + type="button" + > + {option === "max" + ? "xMax" + : `x${String(option)}`} + </button> + ); + })} + </div> + <div className="adventurer-list"> + {visible.map((adventurer) => { + return ( + <AdventurerCard + adventurer={adventurer} + batchSize={batchSize} + currentGold={state.resources.gold} + formatNumber={formatNumber} + key={adventurer.id} + unlockHint={adventurerUnlockHints.get(adventurer.id)} + /> + ); + })} + </div> + </section> + ); +}; + +export { AdventurerPanel }; diff --git a/apps/web/src/components/game/apotheosisPanel.tsx b/apps/web/src/components/game/apotheosisPanel.tsx new file mode 100644 index 0000000..05da269 --- /dev/null +++ b/apps/web/src/components/game/apotheosisPanel.tsx @@ -0,0 +1,159 @@ +/** + * @file Apotheosis panel component for the final prestige layer. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Complex component with many render paths */ +/* eslint-disable complexity -- Complex component with many conditional render paths */ +import { type JSX, useState } from "react"; +import { useGame } from "../../context/gameContext.js"; +import { TRANSCENDENCE_UPGRADES } from "../../data/transcendenceUpgrades.js"; + +const totalEchoUpgrades = TRANSCENDENCE_UPGRADES.length; + +/** + * Renders the apotheosis panel for achieving the final game milestone. + * @returns The JSX element. + */ +const ApotheosisPanel = (): JSX.Element => { + const { state, apotheosis } = useGame(); + const [ isPending, setIsPending ] = useState(false); + const [ result, setResult ] = useState<number | null>(null); + const [ error, setError ] = useState<string | null>(null); + + if (state === null) { + return ( + <section className="panel"> + <p>{"Loading..."}</p> + </section> + ); + } + + const purchasedIds = state.transcendence?.purchasedUpgradeIds ?? []; + const purchasedCount = TRANSCENDENCE_UPGRADES.filter((upgrade) => { + return purchasedIds.includes(upgrade.id); + }).length; + const isEligible = purchasedCount >= totalEchoUpgrades; + const apotheosisCount = state.apotheosis?.count ?? 0; + + async function handleApotheosis(): Promise<void> { + setIsPending(true); + setError(null); + try { + const data = await apotheosis(); + setResult(data.newApotheosisCount); + } catch (caughtError) { + setError( + caughtError instanceof Error + ? caughtError.message + : "Apotheosis failed", + ); + } finally { + setIsPending(false); + } + } + + function handleApotheosisClick(): void { + void handleApotheosis(); + } + + const plural = apotheosisCount === 1 + ? "" + : "s"; + + return ( + <section className="panel apotheosis-panel"> + <h2>{"✨ Apotheosis"}</h2> + + <p className="apotheosis-intro"> + {"Apotheosis is the final act — a complete dissolution of everything" + + " you have built. Prestige, Transcendence, Echoes, upgrades," + + " equipment, resources: all of it returns to nothing." + + " In exchange, you receive only one thing:"} + </p> + <p className="apotheosis-reward"> + {"The "} + <strong>{"✨ Apotheosis"}</strong> + {" badge. Proof that you have done it all."} + </p> + <p className="apotheosis-intro"> + {"Apotheosis can be achieved multiple times. Each cycle requires" + + " you to purchase every Transcendence upgrade again before the" + + " next Apotheosis becomes available. There is no mechanical" + + " benefit — only the knowledge that you have reached the" + + " pinnacle, dissolved it, and climbed back up."} + </p> + + {apotheosisCount > 0 + && <div className="apotheosis-count"> + <span> + {"You have achieved Apotheosis "} + <strong>{apotheosisCount}</strong> + {" time"} + {plural} + {"."} + </span> + </div> + } + + <div className="apotheosis-status"> + <p> + {"Transcendence upgrades purchased: "} + <strong> + {purchasedCount} + {" / "} + {totalEchoUpgrades} + </strong> + </p> + {isEligible + ? null + : <p className="apotheosis-missing"> + {"🔒 Purchase all "} + {totalEchoUpgrades} + {" Transcendence upgrades to unlock Apotheosis. ("} + {totalEchoUpgrades - purchasedCount} + {" remaining)"} + </p> + } + {isEligible + ? <p className="apotheosis-ready"> + {"✅ All Transcendence upgrades purchased. You are ready."} + </p> + : null} + </div> + + {isEligible + ? <div className="prestige-form"> + <p> + {"This action is "} + <strong>{"permanent and irreversible"}</strong> + {"."} + </p> + <button + className="apotheosis-button" + disabled={isPending} + onClick={handleApotheosisClick} + type="button" + > + {isPending + ? "Ascending..." + : "✨ Achieve Apotheosis"} + </button> + {error === null + ? null + : <p className="error">{error}</p>} + {result !== null + && <p className="success"> + {"Apotheosis achieved. This is cycle "} + <strong>{result}</strong> + {". The infinite loop continues."} + </p> + } + </div> + : null} + </section> + ); +}; + +export { ApotheosisPanel }; diff --git a/apps/web/src/components/game/battleModal.tsx b/apps/web/src/components/game/battleModal.tsx new file mode 100644 index 0000000..ab1be4f --- /dev/null +++ b/apps/web/src/components/game/battleModal.tsx @@ -0,0 +1,237 @@ +/** + * @file Battle modal component displaying animated battle results. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Complex battle animation and result display */ +/* eslint-disable complexity -- Battle result display requires many conditional paths */ +import { type JSX, useEffect, useState } from "react"; +import { type BattleResult, useGame } from "../../context/gameContext.js"; + +/** + * Converts HP values to a percentage for display. + * @param current - The current HP value. + * @param maximum - The maximum HP value. + * @returns The percentage as a number between 0 and 100. + */ +const toHpPercent = (current: number, maximum: number): number => { + if (maximum === 0) { + return 0; + } + const scaled = current * 100; + return scaled / maximum; +}; + +interface BattleModalProperties { + readonly battle: BattleResult; + readonly onDismiss: ()=> void; +} + +/** + * Renders the battle modal with HP bars and animated battle results. + * @param props - The battle modal properties. + * @param props.battle - The battle result data to display. + * @param props.onDismiss - Callback to dismiss the modal. + * @returns The JSX element. + */ +const BattleModal = ({ + battle, + onDismiss, +}: BattleModalProperties): JSX.Element => { + const { result, bossName } = battle; + const { formatNumber } = useGame(); + + const [ phase, setPhase ] = useState<"animating" | "result">("animating"); + + const bossStartPercent = toHpPercent(result.bossHpBefore, result.bossMaxHp); + const partyStartPercent = 100; + + const bossEndPercent = toHpPercent( + result.bossHpAtBattleEnd, + result.bossMaxHp, + ); + const partyEndPercent = toHpPercent( + result.partyHpRemaining, + result.partyMaxHp, + ); + + const [ bossHpPercent, setBossHpPercent ] = useState(bossStartPercent); + const [ partyHpPercent, setPartyHpPercent ] = useState(partyStartPercent); + + useEffect(() => { + const startAnimation = setTimeout(() => { + setBossHpPercent(bossEndPercent); + setPartyHpPercent(partyEndPercent); + }, 200); + + const revealResult = setTimeout(() => { + setPhase("result"); + }, 5200); + + return (): void => { + clearTimeout(startAnimation); + clearTimeout(revealResult); + }; + }, [ bossEndPercent, partyEndPercent ]); + + let bossHpBarColour = "#c0392b"; + if (bossHpPercent > 50) { + bossHpBarColour = "#e74c3c"; + } else if (bossHpPercent > 25) { + bossHpBarColour = "#e67e22"; + } + + let partyHpBarColour = "#e74c3c"; + if (partyHpPercent > 50) { + partyHpBarColour = "#27ae60"; + } else if (partyHpPercent > 25) { + partyHpBarColour = "#f39c12"; + } + + return ( + <div className="modal-overlay"> + <div className="modal battle-modal"> + <h2> + {"⚔️ Battle: "} + {bossName} + </h2> + + <div className="battle-stats"> + <div className="battle-stat"> + <span className="stat-label">{"Your Party DPS"}</span> + <span className="stat-value">{formatNumber(result.partyDPS)}</span> + </div> + <div className="battle-stat-divider">{"vs"}</div> + <div className="battle-stat"> + <span className="stat-label">{"Boss DPS"}</span> + <span className="stat-value">{formatNumber(result.bossDPS)}</span> + </div> + </div> + + <div className="battle-bars"> + <div className="battle-bar-row"> + <span className="bar-label"> + {"👹 "} + {bossName} + </span> + <div className="hp-bar-container"> + <div + className="hp-bar-fill" + style={{ + backgroundColor: bossHpBarColour, + transition: "width 5s ease-in-out", + width: `${bossHpPercent.toFixed(1)}%`, + }} + /> + </div> + <span className="bar-hp"> + {formatNumber(result.bossHpAtBattleEnd)} + {" / "} + {formatNumber(result.bossMaxHp)} + </span> + </div> + + <div className="vs-divider">{"⚔️ VS ⚔️"}</div> + + <div className="battle-bar-row"> + <span className="bar-label">{"🛡️ Your Party"}</span> + <div className="hp-bar-container"> + <div + className="hp-bar-fill party-hp" + style={{ + backgroundColor: partyHpBarColour, + transition: "width 5s ease-in-out", + width: `${partyHpPercent.toFixed(1)}%`, + }} + /> + </div> + <span className="bar-hp"> + {formatNumber(result.partyHpRemaining)} + {" / "} + {formatNumber(result.partyMaxHp)} + </span> + </div> + </div> + + {phase === "animating" + && <p className="battle-in-progress">{"Battling…"}</p> + } + + {phase === "result" + && <div + className={`battle-outcome ${result.won + ? "victory" + : "defeat"}`} + > + {result.won + ? <> + <h3>{"🏆 Victory!"}</h3> + {result.rewards === undefined + ? null + : <div className="battle-rewards"> + <p>{"Rewards:"}</p> + <span> + {"🪙 "} + {formatNumber(result.rewards.gold)} + {" gold"} + </span> + {result.rewards.essence > 0 + && <span> + {"✨ "} + {formatNumber(result.rewards.essence)} + {" essence"} + </span> + } + {result.rewards.crystals > 0 + && <span> + {"💎 "} + {formatNumber(result.rewards.crystals)} + {" crystals"} + </span> + } + {result.rewards.bountyRunestones > 0 + && <span className="battle-bounty"> + {"🔮 "} + {formatNumber(result.rewards.bountyRunestones)} + {" runestones (first kill!)"} + </span> + } + </div> + } + </> + : <> + <h3>{"💀 Defeat"}</h3> + <p>{"Your party was defeated. The boss has reset."}</p> + {result.casualties !== undefined + && result.casualties.length > 0 + ? <div className="battle-casualties"> + <p>{"Casualties:"}</p> + {result.casualties.map((casualty) => { + return ( + <span key={casualty.adventurerId}> + {"☠️ "} + {casualty.killed} {casualty.adventurerId} + {" lost"} + </span> + ); + })} + </div> + : null} + </> + } + <button + className="dismiss-button" + onClick={onDismiss} + type="button" + > + {"Continue"} + </button> + </div> + } + </div> + </div> + ); +}; + +export { BattleModal }; diff --git a/apps/web/src/components/game/bossPanel.tsx b/apps/web/src/components/game/bossPanel.tsx new file mode 100644 index 0000000..1490257 --- /dev/null +++ b/apps/web/src/components/game/bossPanel.tsx @@ -0,0 +1,383 @@ +/** + * @file Boss panel component for viewing and challenging zone bosses. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */ +/* eslint-disable max-lines-per-function -- Complex component with many render paths */ +/* eslint-disable complexity -- Boss card requires many conditional render paths */ +/* eslint-disable max-statements -- Boss panel requires many variable declarations */ +/* eslint-disable max-lines -- Boss panel with sub-component and helper function */ +import { type JSX, useState } from "react"; +import { useGame } from "../../context/gameContext.js"; +import { LockToggle } from "../ui/lockToggle.js"; +import { ZoneSelector } from "./zoneSelector.js"; +import type { Boss, GameState } from "@elysium/types"; + +interface BossCardProperties { + readonly boss: Boss; + readonly prestigeCount: number; + readonly onChallenge: (bossId: string)=> void; + readonly isChallenging: boolean; + readonly unlockHint: string | undefined; + readonly formatNumber: (n: number)=> string; +} + +/** + * Renders a single boss card. + * @param props - The boss card properties. + * @param props.boss - The boss data. + * @param props.prestigeCount - The current prestige count for lock checking. + * @param props.onChallenge - Callback to challenge this boss. + * @param props.isChallenging - Whether this boss is currently being challenged. + * @param props.unlockHint - Optional hint for how to unlock this boss. + * @param props.formatNumber - The number formatting utility function. + * @returns The JSX element. + */ +const BossCard = ({ + boss, + prestigeCount, + onChallenge, + isChallenging, + unlockHint, + formatNumber, +}: BossCardProperties): JSX.Element => { + const scaled = boss.currentHp * 100; + const hpPercent = scaled / boss.maxHp; + const isPrestigeLocked = boss.prestigeRequirement > prestigeCount; + const canChallenge + = (boss.status === "available" || boss.status === "in_progress") + && !isChallenging; + + function handleChallenge(): void { + onChallenge(boss.id); + } + + return ( + <div className={`boss-card boss-${boss.status}`}> + <div className="boss-info"> + <h3>{boss.name}</h3> + <p>{boss.description}</p> + {isPrestigeLocked && boss.status === "locked" + ? <p className="prestige-lock"> + {"🔒 Requires Prestige "} + {boss.prestigeRequirement} + </p> + : null} + {!isPrestigeLocked + && boss.status === "locked" + && unlockHint !== undefined + ? <p className="unlock-hint">{unlockHint}</p> + : null} + </div> + + {boss.status !== "locked" && boss.status !== "defeated" + && <div className="boss-hp"> + <div className="hp-bar"> + <div + className="hp-fill" + style={{ width: `${hpPercent.toFixed(1)}%` }} + /> + </div> + <span className="hp-text"> + {formatNumber(boss.currentHp)} + {" / "} + {formatNumber(boss.maxHp)} + {" HP"} + </span> + </div> + } + + <div className="boss-meta"> + <span className="boss-dps"> + {"💢 Boss DPS: "} + {formatNumber(boss.damagePerSecond)} + </span> + </div> + + <div className="boss-rewards"> + <span> + {"🪙 "} + {formatNumber(boss.goldReward)} + </span> + {boss.essenceReward > 0 + && <span> + {"✨ "} + {formatNumber(boss.essenceReward)} + </span> + } + {boss.crystalReward > 0 + && <span> + {"💎 "} + {formatNumber(boss.crystalReward)} + </span> + } + {boss.equipmentRewards.length > 0 + && <span> + {"🗡️ "} + {boss.equipmentRewards.length} + {" Equipment"} + </span> + } + {boss.status !== "defeated" && boss.bountyRunestones > 0 + && <span className="boss-bounty"> + {"🔮 "} + {boss.bountyRunestones} + {" (first kill)"} + </span> + } + </div> + + {(boss.status === "available" || boss.status === "in_progress") + && <button + className="attack-button" + disabled={!canChallenge} + onClick={handleChallenge} + type="button" + > + {isChallenging + ? "⚔️ Battling…" + : "⚔️ Challenge"} + </button> + } + + {boss.status === "defeated" + && <span className="boss-badge defeated">{"☠️ Defeated"}</span> + } + </div> + ); +}; + +/** + * Computes party DPS and HP from the current game state. + * @param state - The full game state. + * @returns The computed party DPS and HP values. + */ +const computePartyStats = ( + state: GameState, +): { + partyDps: number; + partyHp: number; +} => { + const { upgrades, adventurers, equipment, prestige } = state; + let globalMultiplier = 1; + for (const upgrade of upgrades) { + const { purchased, target, multiplier } = upgrade; + if (purchased && target === "global") { + globalMultiplier = globalMultiplier * multiplier; + } + } + const prestigeBonus = prestige.count * 0.1; + const prestigeMultiplier = 1 + prestigeBonus; + const equipmentCombatMultiplier = equipment. + filter((item) => { + return item.equipped && item.bonus.combatMultiplier !== undefined; + }). + reduce((multiplier, item) => { + return multiplier * (item.bonus.combatMultiplier ?? 1); + }, 1); + + let partyDps = 0; + let partyHp = 0; + for (const adventurer of adventurers) { + const { count, id: adventurerId, combatPower, level } = adventurer; + if (count === 0) { + continue; + } + let adventurerMultiplier = 1; + for (const upgrade of upgrades) { + const { + purchased, + target, + multiplier, + adventurerId: upgradeAdventurerId, + } = upgrade; + if ( + purchased + && target === "adventurer" + && upgradeAdventurerId === adventurerId + ) { + adventurerMultiplier = adventurerMultiplier * multiplier; + } + } + const dps + = combatPower + * count + * adventurerMultiplier + * globalMultiplier + * prestigeMultiplier; + partyDps = partyDps + dps; + const hp = level * 50 * count; + partyHp = partyHp + hp; + } + partyDps = partyDps * equipmentCombatMultiplier; + return { partyDps, partyHp }; +}; + +/** + * Renders the boss panel with zone selection and boss list. + * @returns The JSX element. + */ +const BossPanel = (): JSX.Element => { + const { state, challengeBoss, formatNumber, toggleAutoBoss } = useGame(); + const [ challengingBossId, setChallengingBossId ] = useState<string | null>( + null, + ); + const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale"); + const [ showLocked, setShowLocked ] = useState(true); + + if (state === null) { + return ( + <section className="panel"> + <p>{"Loading..."}</p> + </section> + ); + } + + async function handleChallenge(bossId: string): Promise<void> { + setChallengingBossId(bossId); + try { + await challengeBoss(bossId); + } finally { + setChallengingBossId(null); + } + } + + function handleChallengeClick(bossId: string): void { + void handleChallenge(bossId); + } + + const { zones, bosses, quests, autoBoss, prestige: playerPrestige } = state; + const zoneBosses = bosses.filter((boss) => { + return boss.zoneId === activeZoneId; + }); + const lockedCount = zoneBosses.filter((boss) => { + return boss.status === "locked"; + }).length; + const visibleBosses = showLocked + ? zoneBosses + : zoneBosses.filter((boss) => { + return boss.status !== "locked"; + }); + + const bossUnlockHints = new Map<string, string>(); + for (const zone of zones) { + const { id: zoneId, unlockBossId, unlockQuestId } = zone; + const allZoneBosses = bosses.filter((boss) => { + return boss.zoneId === zoneId; + }); + for (let index = 0; index < allZoneBosses.length; index = index + 1) { + const boss = allZoneBosses[index]; + if (boss === undefined || boss.status !== "locked") { + continue; + } + if (index === 0) { + const parts: Array<string> = []; + if (unlockBossId !== null) { + const gateBoss = bosses.find((candidate) => { + return candidate.id === unlockBossId; + }); + if (gateBoss !== undefined) { + parts.push(`⚔️ Defeat: ${gateBoss.name}`); + } + } + if (unlockQuestId !== null) { + const gateQuest = quests.find((candidate) => { + return candidate.id === unlockQuestId; + }); + if (gateQuest !== undefined) { + parts.push(`📜 Complete: ${gateQuest.name}`); + } + } + if (parts.length > 0) { + bossUnlockHints.set(boss.id, parts.join(" & ")); + } + } else { + const previousBoss = allZoneBosses[index - 1]; + if (previousBoss !== undefined) { + bossUnlockHints.set(boss.id, `⚔️ Defeat: ${previousBoss.name} first`); + } + } + } + } + + function handleToggle(): void { + setShowLocked((current) => { + return !current; + }); + } + + const autoBossOn = autoBoss === true; + const { partyDps, partyHp } = computePartyStats(state); + const { count: prestigeCount } = playerPrestige; + + return ( + <section className="panel boss-panel"> + <div className="panel-header"> + <h2>{"Boss Encounters"}</h2> + <div className="panel-header-controls"> + <button + className={`auto-toggle-btn ${ + autoBossOn + ? "auto-toggle-on" + : "auto-toggle-off" + }`} + onClick={toggleAutoBoss} + title="Automatically challenge the highest available boss" + type="button" + > + {"🤖 Auto: "} + {autoBossOn + ? "ON" + : "OFF"} + </button> + <LockToggle + lockedCount={lockedCount} + onToggle={handleToggle} + showLocked={showLocked} + /> + </div> + </div> + + <ZoneSelector + activeZoneId={activeZoneId} + onSelectZone={setActiveZoneId} + zones={zones} + /> + + <div className="party-combat-stats"> + <div className="combat-stat"> + <span className="stat-label">{"⚔️ Party DPS"}</span> + <span className="stat-value">{formatNumber(partyDps)}</span> + </div> + <div className="combat-stat"> + <span className="stat-label">{"❤️ Party HP"}</span> + <span className="stat-value">{formatNumber(partyHp)}</span> + </div> + </div> + + <div className="boss-list"> + {visibleBosses.map((boss) => { + const { id: bossId } = boss; + return ( + <BossCard + boss={boss} + formatNumber={formatNumber} + isChallenging={challengingBossId === bossId} + key={bossId} + onChallenge={handleChallengeClick} + prestigeCount={prestigeCount} + unlockHint={bossUnlockHints.get(bossId)} + /> + ); + })} + {visibleBosses.length === 0 + && <p className="empty-zone">{"No bosses to show in this zone."}</p> + } + </div> + </section> + ); +}; + +export { BossPanel }; diff --git a/apps/web/src/components/game/characterPage.tsx b/apps/web/src/components/game/characterPage.tsx new file mode 100644 index 0000000..861eea3 --- /dev/null +++ b/apps/web/src/components/game/characterPage.tsx @@ -0,0 +1,307 @@ +/** + * @file Public character page for viewing a player's character sheet. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Complex component with many render paths */ +/* eslint-disable complexity -- Many conditional render paths for optional fields */ +import { type JSX, useEffect, useState } from "react"; +import type { + EquipmentBonus, + EquipmentType, + PublicProfileResponse, +} from "@elysium/types"; + +interface CharacterPageProperties { + readonly discordId: string; +} + +const slotIcons: Record<EquipmentType, string> = { + armour: "🛡️", + trinket: "💍", + weapon: "⚔️", +}; + +/** + * Formats an equipment bonus as a human-readable string. + * @param bonus - The equipment bonus to format. + * @returns The formatted bonus string. + */ +const formatBonus = (bonus: EquipmentBonus): string => { + const parts: Array<string> = []; + if (bonus.goldMultiplier !== undefined) { + const pct = Math.round((bonus.goldMultiplier - 1) * 100); + parts.push(`+${String(pct)}% Gold Income`); + } + if (bonus.combatMultiplier !== undefined) { + const pct = Math.round((bonus.combatMultiplier - 1) * 100); + parts.push(`+${String(pct)}% Combat Power`); + } + if (bonus.clickMultiplier !== undefined) { + const pct = Math.round((bonus.clickMultiplier - 1) * 100); + parts.push(`+${String(pct)}% Click Power`); + } + return parts.join(" · "); +}; + +/** + * Renders the public character page for a given Discord user. + * @param props - The character page properties. + * @param props.discordId - The Discord ID of the player to display. + * @returns The JSX element. + */ +const CharacterPage = ({ discordId }: CharacterPageProperties): JSX.Element => { + const [ profile, setProfile ] = useState<PublicProfileResponse | null>(null); + const [ error, setError ] = useState<string | null>(null); + const [ copied, setCopied ] = useState(false); + + useEffect(() => { + fetch(`/api/profile/${discordId}`). + then(async(response) => { + if (!response.ok) { + throw new Error("Player not found"); + } + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response requires cast + return await (response.json() as Promise<PublicProfileResponse>); + }). + then(setProfile). + catch((error_: unknown) => { + setError( + error_ instanceof Error + ? error_.message + : "Failed to load character sheet", + ); + }); + }, [ discordId ]); + + function handleCopy(): void { + void navigator.clipboard.writeText(window.location.href).then(() => { + setCopied(true); + setTimeout(() => { + setCopied(false); + }, 2000); + }); + } + + if (error !== null) { + return ( + <div className="character-page"> + <div className="character-page-error"> + <p> + {"⚠️ "} + {error} + </p> + <a className="character-page-link" href="/"> + {"← Play Elysium"} + </a> + </div> + </div> + ); + } + + if (profile === null) { + return ( + <div className="character-page"> + <div className="character-page-loading"> + {"Loading character sheet…"} + </div> + </div> + ); + } + + const discordIndex = Number.parseInt(discordId, 10) % 5; + const avatarUrl + = profile.avatar === null + ? `https://cdn.discordapp.com/embed/avatars/${String(discordIndex)}.png` + : `https://cdn.discordapp.com/avatars/${discordId}/${profile.avatar}.png?size=128`; + + const subtitleParts = [ + profile.characterRace, + profile.characterClass, + ].filter((part) => { + return part !== ""; + }); + const subtitle = subtitleParts.join(" · "); + + const activeTitleEntry + = profile.activeTitle === "" + ? undefined + : profile.unlockedTitles.find((title) => { + return title.id === profile.activeTitle; + }); + const activeTitleName + = activeTitleEntry === undefined + ? null + : activeTitleEntry.name; + + const hasBadge + = profile.apotheosisCount > 0 + || profile.transcendenceCount > 0 + || profile.prestigeCount > 0; + + const displayName + = profile.characterName === "" + ? profile.username + : profile.characterName; + + return ( + <div className="character-page"> + <div className="character-page-card"> + <div className="character-page-header"> + <img + alt={`${displayName}'s avatar`} + className="character-page-avatar" + src={avatarUrl} + /> + <div className="character-page-identity"> + <h1 className="character-page-name">{displayName}</h1> + {activeTitleName === null + ? null + : <p className="character-page-title">{activeTitleName}</p> + } + {profile.pronouns === "" + ? null + : <p className="character-page-pronouns">{profile.pronouns}</p> + } + {subtitle === "" + ? null + : <p className="character-page-subtitle">{subtitle}</p> + } + {hasBadge + ? <div className="character-page-badges"> + {profile.apotheosisCount > 0 + && <span + className={ + "character-page-badge character-page-badge--apotheosis" + } + > + {"✨ Apotheosis "} + {profile.apotheosisCount} + </span> + } + {profile.transcendenceCount > 0 + && <span + className={ + "character-page-badge" + + " character-page-badge--transcendence" + } + > + {"🌌 Transcendence "} + {profile.transcendenceCount} + </span> + } + {profile.prestigeCount > 0 + && <span + className={ + "character-page-badge character-page-badge--prestige" + } + > + {"⭐ Prestige "} + {profile.prestigeCount} + </span> + } + </div> + : null} + </div> + </div> + + {profile.bio === "" + ? null + : <div className="character-page-section"> + <h2 className="character-page-section-title">{"⚔️ About"}</h2> + <p className="character-page-bio">{profile.bio}</p> + </div> + } + + {profile.guildName === "" + ? null + : <div className="character-page-section"> + <h2 className="character-page-section-title">{"🏰 Guild"}</h2> + <p className="character-page-guild-name">{profile.guildName}</p> + {profile.guildDescription === "" + ? null + : <p className="character-page-guild-desc"> + {profile.guildDescription} + </p> + } + </div> + } + + {profile.equippedItems.length > 0 + && <div className="character-page-section"> + <h2 className="character-page-section-title">{"🗡️ Equipment"}</h2> + <div className="character-page-equipment-list"> + {profile.equippedItems.map((item) => { + return ( + <div + className="character-page-equipment-item" + key={item.type} + > + <div className="character-page-equipment-header"> + <span className="character-page-equipment-slot"> + {slotIcons[item.type]} + </span> + <span + className={ + "character-page-equipment-name" + + ` character-sheet-rarity--${item.rarity}` + } + > + {item.name} + </span> + <span + className={ + "character-page-equipment-rarity" + + ` character-sheet-rarity--${item.rarity}` + } + > + {item.rarity} + </span> + </div> + <p className="character-page-equipment-bonus"> + {formatBonus(item.bonus)} + </p> + </div> + ); + })} + </div> + </div> + } + + <div className="character-page-divider" /> + + <p className="character-page-player-line"> + {"Played by "} + <span className="character-page-username"> + {"@"} + {profile.username} + </span> + </p> + + <div className="character-page-actions"> + <button + className="character-page-share-btn" + onClick={handleCopy} + type="button" + > + {copied + ? "✓ Copied!" + : "🔗 Share Character"} + </button> + <a + className="character-page-profile-link" + href={`/profile/${discordId}`} + > + {"📊 View Stats"} + </a> + <a className="character-page-play-link" href="/"> + {"⚔️ Play Elysium"} + </a> + </div> + </div> + </div> + ); +}; + +export { CharacterPage }; diff --git a/apps/web/src/components/game/characterSheetPanel.tsx b/apps/web/src/components/game/characterSheetPanel.tsx new file mode 100644 index 0000000..836d21f --- /dev/null +++ b/apps/web/src/components/game/characterSheetPanel.tsx @@ -0,0 +1,681 @@ +/** + * @file Character sheet panel for viewing and editing the player's character. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Complex component with many fields */ +/* eslint-disable complexity -- Many conditional render paths for optional fields */ +/* eslint-disable max-statements -- Component requires many state declarations */ +/* eslint-disable max-lines -- Large component with editing and view modes */ +import { + DEFAULT_PROFILE_SETTINGS, + STORY_CHAPTERS, + type EquipmentBonus, + type EquipmentRarity, + type EquipmentType, + type ProfileSettings, +} from "@elysium/types"; +import { type ChangeEvent, type JSX, useEffect, useRef, useState } from "react"; +import { updateProfile } from "../../api/client.js"; +import { useGame } from "../../context/gameContext.js"; + +interface EquippedItem { + name: string; + type: EquipmentType; + rarity: EquipmentRarity; + bonus: EquipmentBonus; +} + +interface CharacterSheetData { + characterName: string; + pronouns: string; + characterRace: string; + characterClass: string; + bio: string; + guildName: string; + guildDescription: string; + activeTitle: string; + unlockedTitles: Array<{ id: string; name: string }>; + equippedItems: Array<EquippedItem>; +} + +const emptySheet: CharacterSheetData = { + activeTitle: "", + bio: "", + characterClass: "", + characterName: "", + characterRace: "", + equippedItems: [], + guildDescription: "", + guildName: "", + pronouns: "", + unlockedTitles: [], +}; + +const slotIcons: Record<EquipmentType, string> = { + armour: "🛡️", + trinket: "💍", + weapon: "⚔️", +}; + +/** + * Formats an equipment bonus as a human-readable string. + * @param bonus - The equipment bonus to format. + * @returns The formatted bonus string. + */ +const formatBonus = (bonus: EquipmentBonus): string => { + const parts: Array<string> = []; + if (bonus.goldMultiplier !== undefined) { + const pct = Math.round((bonus.goldMultiplier - 1) * 100); + parts.push(`+${String(pct)}% Gold Income`); + } + if (bonus.combatMultiplier !== undefined) { + const pct = Math.round((bonus.combatMultiplier - 1) * 100); + parts.push(`+${String(pct)}% Combat Power`); + } + if (bonus.clickMultiplier !== undefined) { + const pct = Math.round((bonus.clickMultiplier - 1) * 100); + parts.push(`+${String(pct)}% Click Power`); + } + return parts.join(" · "); +}; + +/** + * Renders the character sheet panel for viewing and editing player profile. + * @returns The JSX element. + */ +const CharacterSheetPanel = (): JSX.Element => { + const { state, loginStreak } = useGame(); + const player = state?.player; + + const [ sheet, setSheet ] = useState<CharacterSheetData>(emptySheet); + const [ draft, setDraft ] = useState<CharacterSheetData>(emptySheet); + const [ editing, setEditing ] = useState(false); + const [ loading, setLoading ] = useState(true); + const [ saving, setSaving ] = useState(false); + const [ error, setError ] = useState<string | null>(null); + const [ saved, setSaved ] = useState(false); + const [ copied, setCopied ] = useState(false); + const savedSettingsReference = useRef<ProfileSettings>({ + ...DEFAULT_PROFILE_SETTINGS, + }); + + useEffect(() => { + if (player?.discordId === undefined || player.discordId === "") { + return; + } + fetch(`/api/profile/${player.discordId}`). + then(async(response) => { + if (!response.ok) { + return; + } + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response cast + const data = (await response.json()) as { + characterName: string; + pronouns: string; + characterRace: string; + characterClass: string; + bio: string; + guildName: string; + guildDescription: string; + profileSettings: ProfileSettings; + activeTitle: string; + unlockedTitles: Array<{ id: string; name: string }>; + equippedItems: Array<EquippedItem>; + }; + const loaded: CharacterSheetData = { + activeTitle: data.activeTitle, + bio: data.bio, + characterClass: data.characterClass, + characterName: data.characterName, + characterRace: data.characterRace, + equippedItems: data.equippedItems, + guildDescription: data.guildDescription, + guildName: data.guildName, + pronouns: data.pronouns, + unlockedTitles: data.unlockedTitles, + }; + setSheet(loaded); + setDraft(loaded); + savedSettingsReference.current = { + ...DEFAULT_PROFILE_SETTINGS, + ...data.profileSettings, + }; + }). + catch(() => { + + /* Fall back to empty */ + }). + finally(() => { + setLoading(false); + }); + }, [ player?.discordId ]); + + function handleEdit(): void { + setDraft({ ...sheet }); + setEditing(true); + setError(null); + setSaved(false); + } + + function handleCancel(): void { + setEditing(false); + setError(null); + } + + async function handleSave(): Promise<void> { + setSaving(true); + setError(null); + try { + const characterName + = draft.characterName === "" + ? player?.characterName ?? "" + : draft.characterName; + await updateProfile({ + activeTitle: draft.activeTitle, + bio: draft.bio, + characterClass: draft.characterClass, + characterName: characterName, + characterRace: draft.characterRace, + guildDescription: draft.guildDescription, + guildName: draft.guildName, + profileSettings: savedSettingsReference.current, + pronouns: draft.pronouns, + }); + setSheet({ ...draft }); + setSaved(true); + setTimeout(() => { + setEditing(false); + setSaved(false); + }, 900); + } catch (error_) { + setError(error_ instanceof Error + ? error_.message + : "Failed to save"); + } finally { + setSaving(false); + } + } + + function handleSaveClick(): void { + void handleSave(); + } + + function handleShareClick(): void { + const discordId = player?.discordId ?? ""; + const url = `${window.location.origin}/character/${discordId}`; + void navigator.clipboard.writeText(url).then(() => { + setCopied(true); + setTimeout(() => { + setCopied(false); + }, 2000); + }); + } + + function handleNameChange(event: ChangeEvent<HTMLInputElement>): void { + const { value } = event.target; + setDraft((current) => { + return { ...current, characterName: value }; + }); + } + + function handlePronounsChange(event: ChangeEvent<HTMLInputElement>): void { + const { value } = event.target; + setDraft((current) => { + return { ...current, pronouns: value }; + }); + } + + function handleRaceChange(event: ChangeEvent<HTMLInputElement>): void { + const { value } = event.target; + setDraft((current) => { + return { ...current, characterRace: value }; + }); + } + + function handleClassChange(event: ChangeEvent<HTMLInputElement>): void { + const { value } = event.target; + setDraft((current) => { + return { ...current, characterClass: value }; + }); + } + + function handleBioChange(event: ChangeEvent<HTMLTextAreaElement>): void { + const { value } = event.target; + setDraft((current) => { + return { ...current, bio: value }; + }); + } + + function handleTitleChange(event: ChangeEvent<HTMLSelectElement>): void { + const { value } = event.target; + setDraft((current) => { + return { ...current, activeTitle: value }; + }); + } + + function handleGuildNameChange(event: ChangeEvent<HTMLInputElement>): void { + const { value } = event.target; + setDraft((current) => { + return { ...current, guildName: value }; + }); + } + + function handleGuildDescChange( + event: ChangeEvent<HTMLTextAreaElement>, + ): void { + const { value } = event.target; + setDraft((current) => { + return { ...current, guildDescription: value }; + }); + } + + if (loading) { + return ( + <section className="panel"> + <p>{"Loading character sheet…"}</p> + </section> + ); + } + + if (editing) { + const isSaveDisabled = saving || draft.characterName.trim() === ""; + let saveLabel = "Save"; + if (saving) { + saveLabel = "Saving…"; + } + if (saved) { + saveLabel = "✓ Saved!"; + } + return ( + <section className="panel character-sheet-panel"> + <div className="panel-header"> + <h2>{"📋 Character Sheet"}</h2> + </div> + + <div className="character-sheet-form"> + <div className="character-sheet-section"> + <h3 className="character-sheet-section-title">{"⚔️ Character"}</h3> + + <label className="character-sheet-label" htmlFor="cs-name"> + {"Character Name"} + </label> + <input + className="character-sheet-input" + id="cs-name" + maxLength={32} + onChange={handleNameChange} + placeholder="Your character's name" + type="text" + value={draft.characterName} + /> + <span className="character-sheet-hint"> + {draft.characterName.length} + {" / 32"} + </span> + + <label className="character-sheet-label" htmlFor="cs-pronouns"> + {"Pronouns"} + </label> + <input + className="character-sheet-input" + id="cs-pronouns" + maxLength={20} + onChange={handlePronounsChange} + placeholder="e.g. she/her, he/him, they/them" + type="text" + value={draft.pronouns} + /> + <span className="character-sheet-hint"> + {draft.pronouns.length} + {" / 20"} + </span> + + <label className="character-sheet-label" htmlFor="cs-race"> + {"Race"} + </label> + <input + className="character-sheet-input" + id="cs-race" + maxLength={32} + onChange={handleRaceChange} + placeholder="e.g. Elf, Dwarf, Human, Tiefling…" + type="text" + value={draft.characterRace} + /> + <span className="character-sheet-hint"> + {draft.characterRace.length} + {" / 32"} + </span> + + <label className="character-sheet-label" htmlFor="cs-class"> + {"Class"} + </label> + <input + className="character-sheet-input" + id="cs-class" + maxLength={32} + onChange={handleClassChange} + placeholder="e.g. Paladin, Archmage, Shadow Rogue…" + type="text" + value={draft.characterClass} + /> + <span className="character-sheet-hint"> + {draft.characterClass.length} + {" / 32"} + </span> + + <label className="character-sheet-label" htmlFor="cs-bio"> + {"About Your Character"} + </label> + <textarea + className="character-sheet-textarea" + id="cs-bio" + maxLength={200} + onChange={handleBioChange} + placeholder={ + "Describe your character's story, personality, or appearance…" + } + rows={4} + value={draft.bio} + /> + <span className="character-sheet-hint"> + {draft.bio.length} + {" / 200"} + </span> + + {draft.unlockedTitles.length > 0 + && <> + <label className="character-sheet-label" htmlFor="cs-title"> + {"Active Title"} + </label> + <select + className="character-sheet-input" + id="cs-title" + onChange={handleTitleChange} + value={draft.activeTitle} + > + <option value="">{"— None —"}</option> + {draft.unlockedTitles.map((title) => { + return ( + <option key={title.id} value={title.id}> + {title.name} + </option> + ); + })} + </select> + </> + } + </div> + + <div className="character-sheet-section"> + <h3 className="character-sheet-section-title">{"🏰 Guild"}</h3> + + <label className="character-sheet-label" htmlFor="cs-guild-name"> + {"Guild Name"} + </label> + <input + className="character-sheet-input" + id="cs-guild-name" + maxLength={64} + onChange={handleGuildNameChange} + placeholder="Name your guild" + type="text" + value={draft.guildName} + /> + <span className="character-sheet-hint"> + {draft.guildName.length} + {" / 64"} + </span> + + <label className="character-sheet-label" htmlFor="cs-guild-desc"> + {"Guild Description"} + </label> + <textarea + className="character-sheet-textarea" + id="cs-guild-desc" + maxLength={500} + onChange={handleGuildDescChange} + placeholder="Describe your guild's history, goals, or lore…" + rows={6} + value={draft.guildDescription} + /> + <span className="character-sheet-hint"> + {draft.guildDescription.length} + {" / 500"} + </span> + </div> + + {error === null + ? null + : <p className="character-sheet-error">{error}</p> + } + + <div className="character-sheet-actions"> + <button + className="character-sheet-cancel" + onClick={handleCancel} + type="button" + > + {"Cancel"} + </button> + <button + className="character-sheet-save" + disabled={isSaveDisabled} + onClick={handleSaveClick} + type="button" + > + {saveLabel} + </button> + </div> + </div> + </section> + ); + } + + const subtitleParts = [ sheet.characterRace, sheet.characterClass ].filter( + (part) => { + return part !== ""; + }, + ); + const subtitle = subtitleParts.join(" · "); + + const completedChapters = state?.story?.completedChapters ?? []; + + return ( + <section className="panel character-sheet-panel"> + <div className="panel-header"> + <h2>{"📋 Character Sheet"}</h2> + <div className="character-sheet-header-actions"> + <button + className="character-sheet-edit-btn" + onClick={handleShareClick} + type="button" + > + {copied + ? "✓ Copied!" + : "🔗 Share"} + </button> + <a className="character-sheet-edit-btn" href="/leaderboards"> + {"🏆 Boards"} + </a> + <button + className="character-sheet-edit-btn" + onClick={handleEdit} + type="button" + > + {"✏️ Edit"} + </button> + </div> + </div> + + <div className="character-sheet-view"> + <div className="character-sheet-section"> + <h3 className="character-sheet-section-title">{"⚔️ Character"}</h3> + <div className="character-sheet-field"> + <span className="character-sheet-field-label">{"Name"}</span> + <span className="character-sheet-field-value"> + {sheet.characterName === "" + ? <em className="character-sheet-empty">{"Not set"}</em> + : sheet.characterName + } + </span> + </div> + <div className="character-sheet-field"> + <span className="character-sheet-field-label">{"Streak"}</span> + <span className="character-sheet-streak"> + {"🔥 "} + {loginStreak} + {"-day login streak"} + </span> + </div> + {sheet.activeTitle === "" + ? null + : <div className="character-sheet-field"> + <span className="character-sheet-field-label">{"Title"}</span> + <span + className={"character-sheet-field-value character-sheet-title"} + > + {sheet.unlockedTitles.find((title) => { + return title.id === sheet.activeTitle; + })?.name ?? sheet.activeTitle} + </span> + </div> + } + {sheet.pronouns === "" + ? null + : <div className="character-sheet-field"> + <span className="character-sheet-field-label">{"Pronouns"}</span> + <span className="character-sheet-field-value"> + {sheet.pronouns} + </span> + </div> + } + {subtitle === "" + ? null + : <div className="character-sheet-field"> + <span className="character-sheet-field-label">{"Identity"}</span> + <span className="character-sheet-field-value">{subtitle}</span> + </div> + } + {sheet.bio === "" + ? null + : <div className="character-sheet-bio"> + <span className="character-sheet-field-label">{"About"}</span> + <p className="character-sheet-bio-text">{sheet.bio}</p> + </div> + } + </div> + + <div className="character-sheet-section"> + <h3 className="character-sheet-section-title">{"🗡️ Equipment"}</h3> + {sheet.equippedItems.length > 0 + ? <div className="character-sheet-equipment-list"> + {sheet.equippedItems.map((item) => { + return ( + <div + className="character-sheet-equipment-item" + key={item.type} + > + <div className="character-sheet-equipment-header"> + <span className="character-sheet-equipment-slot"> + {slotIcons[item.type]} + </span> + <span + className={ + "character-sheet-equipment-name" + + ` character-sheet-rarity--${item.rarity}` + } + > + {item.name} + </span> + <span + className={ + "character-sheet-equipment-rarity" + + ` character-sheet-rarity--${item.rarity}` + } + > + {item.rarity} + </span> + </div> + <p className="character-sheet-equipment-bonus"> + {formatBonus(item.bonus)} + </p> + </div> + ); + })} + </div> + : <p className="character-sheet-empty"> + {"No equipment found. Defeat bosses to earn gear!"} + </p> + } + </div> + + <div className="character-sheet-section"> + <h3 className="character-sheet-section-title">{"🏰 Guild"}</h3> + {sheet.guildName === "" + ? <p className="character-sheet-empty"> + {"No guild registered yet. Click ✏️ Edit to add one!"} + </p> + : <> + <div className="character-sheet-field"> + <span className="character-sheet-field-label">{"Name"}</span> + <span className="character-sheet-field-value"> + {sheet.guildName} + </span> + </div> + {sheet.guildDescription === "" + ? null + : <div className="character-sheet-bio"> + <span className="character-sheet-field-label">{"Lore"}</span> + <p className="character-sheet-bio-text"> + {sheet.guildDescription} + </p> + </div> + } + </> + } + </div> + + {completedChapters.length === 0 + ? null + : <div className="character-sheet-section"> + <h3 className="character-sheet-section-title"> + {"📖 Story Choices"} + </h3> + {completedChapters.map((completion) => { + const chapter = STORY_CHAPTERS.find((candidate) => { + return candidate.id === completion.chapterId; + }); + if (chapter === undefined) { + return null; + } + const choice = chapter.choices.find((candidate) => { + return candidate.id === completion.choiceId; + }); + if (choice === undefined) { + return null; + } + return ( + <div + className="character-sheet-story-entry" + key={completion.chapterId} + > + <span className="character-sheet-story-chapter"> + {chapter.title} + </span> + <span className="character-sheet-story-choice"> + {choice.label} + </span> + </div> + ); + })} + </div> + } + </div> + </section> + ); +}; + +export { CharacterSheetPanel }; diff --git a/apps/web/src/components/game/clickArea.tsx b/apps/web/src/components/game/clickArea.tsx new file mode 100644 index 0000000..ff44fab --- /dev/null +++ b/apps/web/src/components/game/clickArea.tsx @@ -0,0 +1,136 @@ +/** + * @file Click area component - the main guild hall click target. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Complex useCallback with float management */ +import { + type JSX, + type MouseEvent, + useCallback, + useRef, + useState, +} from "react"; +import { useGame } from "../../context/gameContext.js"; +import { calculateClickPower } from "../../engine/tick.js"; + +// eslint-disable-next-line @typescript-eslint/naming-convention, no-underscore-dangle -- Vite define constant +declare const __WEB_VERSION__: string; + +interface FloatText { + id: number; + x: number; + y: number; + text: string; +} + +/** + * Renders the guild hall click area with floating gold text on click. + * @returns The JSX element. + */ +const ClickArea = (): JSX.Element => { + const { + state, + handleClick, + formatNumber, + saveSchemaVersion, + currentSchemaVersion, + } = useGame(); + const [ floats, setFloats ] = useState<Array<FloatText>>([]); + const nextIdReference = useRef(0); + + const handleClickWithFloat = useCallback( + (event: MouseEvent<HTMLButtonElement>) => { + if (state === null) { + return; + } + const rect = event.currentTarget.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + const id = nextIdReference.current; + nextIdReference.current = nextIdReference.current + 1; + const clickPower = calculateClickPower(state); + const text = `+${formatNumber(clickPower)}`; + + setFloats((previous) => { + return [ ...previous, { id, text, x, y } ]; + }); + handleClick(); + + setTimeout(() => { + // eslint-disable-next-line max-nested-callbacks -- Float cleanup requires nesting within setTimeout + setFloats((previous) => { + // eslint-disable-next-line max-nested-callbacks -- Float cleanup requires nesting within setTimeout + return previous.filter((floatItem) => { + return floatItem.id !== id; + }); + }); + }, 900); + }, + [ state, handleClick, formatNumber ], + ); + + if (state === null) { + return <div className="click-area-placeholder" />; + } + + const clickPower = calculateClickPower(state); + + return ( + <section className="click-area"> + <h1 className="game-title">{"Elysium"}</h1> + <p className="game-version"> + {"v"} + {__WEB_VERSION__} + </p> + {currentSchemaVersion > 0 + && <p className="game-schema-version"> + {"Save: v"} + {saveSchemaVersion} + {" / Latest: v"} + {currentSchemaVersion} + </p> + } + <h2>{"Guild Hall"}</h2> + <div className="click-button-wrapper"> + <button + aria-label={`Click to earn ${formatNumber(clickPower)} gold`} + className="click-button" + onClick={handleClickWithFloat} + type="button" + > + <img + alt="Guild Hall" + className="click-button-image" + src="https://cdn.nhcarrigan.com/avatars/elysium.png" + /> + </button> + {floats.map((floatItem) => { + return ( + <span + className="click-float" + key={floatItem.id} + style={{ left: floatItem.x, top: floatItem.y }} + > + {floatItem.text} + </span> + ); + })} + </div> + <p className="click-power"> + {"+"} + {formatNumber(clickPower)} + {" gold/click"} + </p> + <p className="early-access-notice"> + {"⚠️ Early Access — this build is subject to change. "} + <strong> + {"All game progress WILL be reset upon v1.0.0 release."} + </strong> + </p> + </section> + ); +}; + +export { ClickArea }; diff --git a/apps/web/src/components/game/codexPanel.tsx b/apps/web/src/components/game/codexPanel.tsx new file mode 100644 index 0000000..32ddb75 --- /dev/null +++ b/apps/web/src/components/game/codexPanel.tsx @@ -0,0 +1,171 @@ +/** + * @file Codex panel component displaying discovered lore entries. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Complex component with zone and entry rendering */ +import { type JSX, useState } from "react"; +import { useGame } from "../../context/gameContext.js"; +import { CODEX_ENTRIES, ZONE_LABELS } from "../../data/codex.js"; +import type { CodexEntry } from "@elysium/types"; + +/** + * Converts a fraction to a percentage value. + * @param numerator - The numerator value. + * @param denominator - The denominator value. + * @returns The percentage as a number between 0 and 100. + */ +const toPercent = (numerator: number, denominator: number): number => { + if (denominator === 0) { + return 0; + } + const scaled = numerator * 100; + return scaled / denominator; +}; + +const sourceBadge: Record<CodexEntry["sourceType"], string> = { + adventurer: "👥", + boss: "⚔️", + equipment: "🛡️", + exploration: "🧭", + prestige: "🔮", + quest: "📜", + recipe: "⚗️", + upgrade: "🔧", + zone: "🗺️", +}; + +/** + * Renders the codex panel with lore entries grouped by zone. + * @returns The JSX element. + */ +const CodexPanel = (): JSX.Element => { + const { state } = useGame(); + const [ expandedId, setExpandedId ] = useState<string | null>(null); + + if (state === null) { + return ( + <section className="panel"> + <p>{"Loading..."}</p> + </section> + ); + } + + const unlockedIds = new Set(state.codex?.unlockedEntryIds ?? []); + const totalEntries = CODEX_ENTRIES.length; + const unlockedCount = CODEX_ENTRIES.filter((entry) => { + return unlockedIds.has(entry.id); + }).length; + const progressPercent = toPercent(unlockedCount, totalEntries); + + const entriesByZone = Object.entries(ZONE_LABELS). + map(([ zoneId, zoneName ]) => { + const entries = CODEX_ENTRIES.filter((entry) => { + return entry.zoneId === zoneId; + }); + const unlockedEntries = entries.filter((entry) => { + return unlockedIds.has(entry.id); + }); + return { + entries, + unlockedEntries, + zoneId, + zoneName, + }; + }). + filter(({ entries }) => { + return entries.length > 0; + }); + + return ( + <section className="panel codex-panel"> + <h2>{"📖 Codex"}</h2> + + <div className="codex-progress"> + <p className="codex-progress-text"> + {"Lore discovered: "} + <strong> + {unlockedCount} + {" / "} + {totalEntries} + </strong> + </p> + <div className="codex-progress-bar"> + <div + className="codex-progress-fill" + style={{ width: `${String(Math.round(progressPercent))}%` }} + /> + </div> + </div> + + {entriesByZone.map(({ zoneId, zoneName, entries, unlockedEntries }) => { + return ( + <div className="codex-zone" key={zoneId}> + <h3 className="codex-zone-header"> + {zoneName} + <span className="codex-zone-count"> + {unlockedEntries.length} + {"/"} + {entries.length} + </span> + </h3> + + <div className="codex-entries"> + {entries.map((entry) => { + const isUnlocked = unlockedIds.has(entry.id); + const isExpanded = expandedId === entry.id; + + if (!isUnlocked) { + return ( + <div className="codex-entry locked" key={entry.id}> + <div className="codex-entry-header"> + <span className="codex-lock">{"🔒"}</span> + <span className="codex-entry-title">{"???"}</span> + </div> + </div> + ); + } + + function handleExpand(): void { + setExpandedId(isExpanded + ? null + : entry.id); + } + + return ( + <div + className={`codex-entry unlocked ${ + isExpanded + ? "expanded" + : "" + }`} + key={entry.id} + onClick={handleExpand} + > + <div className="codex-entry-header"> + <span className="codex-source-badge"> + {sourceBadge[entry.sourceType]} + </span> + <span className="codex-entry-title">{entry.title}</span> + <span className="codex-chevron"> + {isExpanded + ? "▲" + : "▼"} + </span> + </div> + {isExpanded + ? <p className="codex-entry-content">{entry.content}</p> + : null} + </div> + ); + })} + </div> + </div> + ); + })} + </section> + ); +}; + +export { CodexPanel }; diff --git a/apps/web/src/components/game/codexToast.tsx b/apps/web/src/components/game/codexToast.tsx new file mode 100644 index 0000000..5ed089f --- /dev/null +++ b/apps/web/src/components/game/codexToast.tsx @@ -0,0 +1,83 @@ +/** + * @file Codex toast notification component for new lore discoveries. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the toast container */ +import { type JSX, useEffect } from "react"; +import { useGame } from "../../context/gameContext.js"; +import { CODEX_ENTRIES } from "../../data/codex.js"; + +interface CodexToastItemProperties { + readonly entryId: string; + readonly onDismiss: (id: string)=> void; +} + +/** + * Renders a single codex lore toast notification. + * @param props - The toast item properties. + * @param props.entryId - The codex entry ID to display. + * @param props.onDismiss - Callback to dismiss the toast. + * @returns The JSX element or null if entry is not found. + */ +const CodexToastItem = ({ + entryId, + onDismiss, +}: CodexToastItemProperties): JSX.Element | null => { + const entry = CODEX_ENTRIES.find((codexEntry) => { + return codexEntry.id === entryId; + }); + + useEffect(() => { + const timer = setTimeout(() => { + onDismiss(entryId); + }, 4000); + return (): void => { + clearTimeout(timer); + }; + }, [ entryId, onDismiss ]); + + if (entry === undefined) { + return null; + } + + function handleClick(): void { + onDismiss(entryId); + } + + return ( + <div className="codex-toast" onClick={handleClick}> + <span className="toast-icon">{"📖"}</span> + <div className="toast-content"> + <span className="toast-label">{"✨ Lore Unlocked!"}</span> + <span className="toast-name">{entry.title}</span> + </div> + </div> + ); +}; + +/** + * Renders the codex toast container with pending lore notifications. + * @returns The JSX element or null if there are no pending entries. + */ +const CodexToast = (): JSX.Element | null => { + const { unlockedCodexEntryIds: pendingEntryIds, dismissCodexEntry } + = useGame(); + + if (pendingEntryIds.length === 0) { + return null; + } + + return ( + <div className="achievement-toast-container"> + {pendingEntryIds.map((id) => { + return ( + <CodexToastItem entryId={id} key={id} onDismiss={dismissCodexEntry} /> + ); + })} + </div> + ); +}; + +export { CodexToast }; diff --git a/apps/web/src/components/game/companionPanel.tsx b/apps/web/src/components/game/companionPanel.tsx new file mode 100644 index 0000000..92d6432 --- /dev/null +++ b/apps/web/src/components/game/companionPanel.tsx @@ -0,0 +1,213 @@ +/** + * @file Companion panel component for managing active companions. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */ +/* eslint-disable max-lines-per-function -- Complex companion card with conditional renders */ +import { COMPANIONS, type Companion } from "@elysium/types"; +import { useGame } from "../../context/gameContext.js"; +import type { JSX } from "react"; + +const bonusLabels: Record<string, string> = { + bossDamage: "Boss Damage", + clickGold: "Click Gold", + essenceIncome: "Essence Income", + passiveGold: "Passive Gold", + questTime: "Quest Time", +}; + +const unlockLabels: Record<string, string> = { + apotheosis: "apotheosis", + lifetimeBosses: "lifetime bosses defeated", + lifetimeGold: "lifetime gold earned", + lifetimeQuests: "lifetime quests completed", + prestige: "prestige(s)", + transcendence: "transcendence(s)", +}; + +/** + * Formats a companion unlock threshold for display. + * @param type - The unlock condition type. + * @param threshold - The threshold value. + * @returns The formatted threshold string. + */ +const formatThreshold = (type: string, threshold: number): string => { + if (type === "lifetimeGold") { + if (threshold >= 1e18) { + return `${(threshold / 1e18).toFixed(0)}Qt`; + } + if (threshold >= 1e15) { + return `${(threshold / 1e15).toFixed(0)}Q`; + } + if (threshold >= 1e12) { + return `${(threshold / 1e12).toFixed(0)}T`; + } + if (threshold >= 1e9) { + return `${(threshold / 1e9).toFixed(0)}B`; + } + if (threshold >= 1e6) { + return `${(threshold / 1e6).toFixed(0)}M`; + } + if (threshold >= 1e3) { + return `${(threshold / 1e3).toFixed(0)}K`; + } + } + return threshold.toString(); +}; + +interface CompanionCardProperties { + readonly companion: Companion; + readonly isUnlocked: boolean; + readonly isActive: boolean; + readonly onSelect: ()=> void; +} + +/** + * Renders a single companion card. + * @param props - The companion card properties. + * @param props.companion - The companion data. + * @param props.isUnlocked - Whether this companion is unlocked. + * @param props.isActive - Whether this companion is currently active. + * @param props.onSelect - Callback when the companion is selected/deselected. + * @returns The JSX element. + */ +const CompanionCard = ({ + companion, + isUnlocked, + isActive, + onSelect, +}: CompanionCardProperties): JSX.Element => { + const bonusSign = companion.bonus.type === "questTime" + ? "-" + : "+"; + const bonusPercent = Math.round(companion.bonus.value * 100); + const bonusLabel = bonusLabels[companion.bonus.type] ?? companion.bonus.type; + + return ( + <div + className={`companion-card ${ + isUnlocked + ? "companion-unlocked" + : "companion-locked" + } ${isActive + ? "companion-active" + : ""}`} + > + <div className="companion-header"> + <div className="companion-name-block"> + <span className="companion-name">{companion.name}</span> + <span className="companion-title">{companion.title}</span> + </div> + {isActive + ? <span className="companion-active-badge">{"Active"}</span> + : null} + </div> + + <p className="companion-description">{companion.description}</p> + + <div className="companion-bonus"> + <span className="companion-bonus-label">{bonusLabel}</span> + <span className="companion-bonus-value"> + {bonusSign} + {bonusPercent} + {"%"} + </span> + </div> + + {isUnlocked + ? <button + className={`companion-select-btn ${ + isActive + ? "companion-select-active" + : "" + }`} + onClick={onSelect} + type="button" + > + {isActive + ? "Deactivate" + : "Activate"} + </button> + : <div className="companion-unlock-requirement"> + {"🔒 Unlock: "} + {formatThreshold( + companion.unlock.type, + companion.unlock.threshold, + )}{" "} + {unlockLabels[companion.unlock.type] ?? companion.unlock.type} + </div> + } + </div> + ); +}; + +/** + * Renders the companion panel with all companions. + * @returns The JSX element. + */ +const CompanionPanel = (): JSX.Element => { + const { state, setActiveCompanion } = useGame(); + + if (state === null) { + return ( + <section className="panel"> + <p>{"Loading..."}</p> + </section> + ); + } + + const unlockedIds = state.companions?.unlockedCompanionIds ?? []; + const activeId = state.companions?.activeCompanionId ?? null; + + function handleSelect(companionId: string): void { + setActiveCompanion(activeId === companionId + ? null + : companionId); + } + + const activeCompanion + = activeId === null + ? undefined + : COMPANIONS.find((companion) => { + return companion.id === activeId; + }); + + return ( + <div className="companion-panel"> + <h2>{"👥 Companions"}</h2> + <p className="companion-intro"> + {"Companions provide powerful bonuses while active." + + " You can only have one companion active at a time."} + {activeId === null + ? null + : <> + {" Currently active: "} + <strong>{activeCompanion?.name ?? activeId}</strong> + {"."} + </> + } + </p> + + <div className="companion-grid"> + {COMPANIONS.map((companion) => { + function handleCompanionSelect(): void { + handleSelect(companion.id); + } + return ( + <CompanionCard + companion={companion} + isActive={activeId === companion.id} + isUnlocked={unlockedIds.includes(companion.id)} + key={companion.id} + onSelect={handleCompanionSelect} + /> + ); + })} + </div> + </div> + ); +}; + +export { CompanionPanel }; diff --git a/apps/web/src/components/game/craftingPanel.tsx b/apps/web/src/components/game/craftingPanel.tsx new file mode 100644 index 0000000..584c710 --- /dev/null +++ b/apps/web/src/components/game/craftingPanel.tsx @@ -0,0 +1,216 @@ +/** + * @file Crafting panel component for crafting items from materials. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Complex component with many render paths */ +/* eslint-disable max-nested-callbacks -- Nested recipe/material maps require nesting */ +import { type JSX, useState } from "react"; +import { useGame } from "../../context/gameContext.js"; +import { MATERIALS } from "../../data/materials.js"; +import { RECIPES } from "../../data/recipes.js"; +import { ZoneSelector } from "./zoneSelector.js"; + +const bonusLabel: Record<string, string> = { + click_power: "👆 Click Power", + combat_power: "⚔️ Combat Power", + essence_income: "✨ Essence Income", + gold_income: "🪙 Gold Income", +}; + +/** + * Renders the crafting panel for crafting recipes from gathered materials. + * @returns The JSX element. + */ +const CraftingPanel = (): JSX.Element => { + const { state, craftRecipe, formatNumber } = useGame(); + const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale"); + const [ pendingRecipeId, setPendingRecipeId ] = useState<string | null>(null); + + if (state === null) { + return ( + <section className="panel"> + <p>{"Loading..."}</p> + </section> + ); + } + + const { zones, exploration: explorationState } = state; + const playerMaterials = explorationState?.materials ?? []; + const craftedIds = explorationState?.craftedRecipeIds ?? []; + + const zoneRecipes = RECIPES.filter((recipe) => { + return recipe.zoneId === activeZoneId; + }); + const zoneMaterials = MATERIALS.filter((material) => { + return material.zoneId === activeZoneId; + }); + + function getQuantity(materialId: string): number { + return ( + playerMaterials.find((playerMaterial) => { + return playerMaterial.materialId === materialId; + })?.quantity ?? 0 + ); + } + + function canAffordRecipe(recipeId: string): boolean { + const recipe = RECIPES.find((candidateRecipe) => { + return candidateRecipe.id === recipeId; + }); + if (recipe === undefined) { + return false; + } + return recipe.requiredMaterials.every((request) => { + return getQuantity(request.materialId) >= request.quantity; + }); + } + + async function handleCraft(recipeId: string): Promise<void> { + setPendingRecipeId(recipeId); + try { + await craftRecipe(recipeId); + } finally { + setPendingRecipeId(null); + } + } + + return ( + <section className="panel crafting-panel"> + <div className="panel-header"> + <h2>{"⚗️ Crafting"}</h2> + </div> + + <ZoneSelector + activeZoneId={activeZoneId} + onSelectZone={setActiveZoneId} + zones={zones} + /> + + <div className="crafting-content"> + <div className="materials-section"> + <h3>{"📦 Materials"}</h3> + {zoneMaterials.length === 0 + ? <p className="empty-zone">{"No materials in this zone."}</p> + : <div className="materials-list"> + {zoneMaterials.map((material) => { + const qty = getQuantity(material.id); + return ( + <div + className={`material-card rarity-${material.rarity} ${ + qty === 0 + ? "material-empty" + : "" + }`} + key={material.id} + > + <div className="material-info"> + <span className="material-name">{material.name}</span> + <span className="material-rarity">{material.rarity}</span> + </div> + <span className="material-quantity"> + {formatNumber(qty)} + </span> + </div> + ); + })} + </div> + } + </div> + + <div className="recipes-section"> + <h3>{"📜 Recipes"}</h3> + {zoneRecipes.length === 0 + ? <p className="empty-zone">{"No recipes in this zone."}</p> + : <div className="recipes-list"> + {zoneRecipes.map((recipe) => { + const crafted = craftedIds.includes(recipe.id); + const affordable = canAffordRecipe(recipe.id); + const isPending = pendingRecipeId === recipe.id; + + function handleCraftClick(): void { + void handleCraft(recipe.id); + } + + return ( + <div + className={`recipe-card ${ + crafted + ? "recipe-crafted" + : "" + } ${!affordable && !crafted + ? "recipe-unaffordable" + : ""}`} + key={recipe.id} + > + <div className="recipe-info"> + <h4>{recipe.name}</h4> + <p className="recipe-description">{recipe.description}</p> + <div className="recipe-bonus"> + <span className="bonus-label"> + {bonusLabel[recipe.bonus.type] ?? recipe.bonus.type} + </span> + <span className="bonus-value"> + {"×"} + {recipe.bonus.value.toFixed(2)} + </span> + </div> + <div className="recipe-requirements"> + {recipe.requiredMaterials.map((request) => { + const have = getQuantity(request.materialId); + const enough = have >= request.quantity; + const matName + = MATERIALS.find((mat) => { + return mat.id === request.materialId; + })?.name ?? request.materialId; + return ( + <span + className={`req-tag ${ + enough + ? "req-met" + : "req-missing" + }`} + key={request.materialId} + > + {matName} + {": "} + {formatNumber(have)} + {"/"} + {formatNumber(request.quantity)} + </span> + ); + })} + </div> + </div> + <div className="recipe-action"> + {crafted + ? <span className="quest-badge active"> + {"✅ Crafted"} + </span> + : <button + className="craft-button" + disabled={ + !affordable || isPending || pendingRecipeId !== null + } + onClick={handleCraftClick} + type="button" + > + {isPending + ? "Crafting..." + : "⚗️ Craft"} + </button> + } + </div> + </div> + ); + })} + </div> + } + </div> + </div> + </section> + ); +}; + +export { CraftingPanel }; diff --git a/apps/web/src/components/game/dailyChallengePanel.tsx b/apps/web/src/components/game/dailyChallengePanel.tsx new file mode 100644 index 0000000..a3be8bf --- /dev/null +++ b/apps/web/src/components/game/dailyChallengePanel.tsx @@ -0,0 +1,141 @@ +/** + * @file Daily challenge panel component showing today's challenges. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Complex component with many render paths */ +import { useGame } from "../../context/gameContext.js"; +import type { JSX } from "react"; + +/** + * Formats the time remaining until the daily reset. + * @returns The formatted time string. + */ +const formatTimeUntilReset = (): string => { + const now = new Date(); + const nowAsPst = new Date( + now.toLocaleString("en-US", { timeZone: "America/Los_Angeles" }), + ); + const tomorrowMidnightPst = new Date(nowAsPst); + tomorrowMidnightPst.setDate(tomorrowMidnightPst.getDate() + 1); + tomorrowMidnightPst.setHours(0, 0, 0, 0); + const pstOffset = nowAsPst.getTime() - now.getTime(); + const resetAt = new Date(tomorrowMidnightPst.getTime() - pstOffset); + const msRemaining = resetAt.getTime() - now.getTime(); + const msPerHour = 1000 * 60 * 60; + const msPerMinute = 1000 * 60; + const hoursRemaining = Math.floor(msRemaining / msPerHour); + const msAfterHours = msRemaining % msPerHour; + const minutesRemaining = Math.floor(msAfterHours / msPerMinute); + return `${String(hoursRemaining)}h ${String(minutesRemaining)}m`; +}; + +/** + * Renders the daily challenge panel with progress tracking. + * @returns The JSX element. + */ +const DailyChallengePanel = (): JSX.Element => { + const { state, formatNumber } = useGame(); + + if (state === null) { + return ( + <section className="panel"> + <p>{"Loading..."}</p> + </section> + ); + } + + const { dailyChallenges } = state; + + if (dailyChallenges === undefined) { + return ( + <section className="panel daily-challenge-panel"> + <h2>{"📅 Daily Challenges"}</h2> + <p className="daily-challenge-subtitle"> + {"Load the game to generate today's challenges!"} + </p> + </section> + ); + } + + const completedCount = dailyChallenges.challenges.filter((challenge) => { + return challenge.completed; + }).length; + + return ( + <section className="panel daily-challenge-panel"> + <h2>{"📅 Daily Challenges"}</h2> + <div className="daily-challenge-header"> + <p className="daily-challenge-subtitle"> + {"Complete challenges for bonus 💎 crystals! Resets in "} + <strong>{formatTimeUntilReset()}</strong> + {" (PST midnight)."} + </p> + <p className="daily-challenge-progress"> + {completedCount} + {" / "} + {dailyChallenges.challenges.length} + {" completed"} + </p> + </div> + + <div className="daily-challenge-list"> + {dailyChallenges.challenges.map((challenge) => { + const progressScaled = challenge.progress * 100; + const progressPercent = Math.min( + 100, + Math.floor(progressScaled / challenge.target), + ); + + return ( + <div + className={`daily-challenge-card ${ + challenge.completed + ? "completed" + : "" + }`} + key={challenge.id} + > + <div className="daily-challenge-info"> + <h3 className="daily-challenge-label">{challenge.label}</h3> + <p className="daily-challenge-reward"> + {"Reward: "} + <strong> + {"💎 "} + {formatNumber(challenge.rewardCrystals)} + {" crystals"} + </strong> + </p> + </div> + + <div className="daily-challenge-right"> + {challenge.completed + ? <span className="daily-challenge-done"> + {"✅ Complete!"} + </span> + + : <> + <p className="daily-challenge-count"> + {formatNumber(challenge.progress)} + {" / "} + {formatNumber(challenge.target)} + </p> + <div className="daily-challenge-bar-track"> + <div + className="daily-challenge-bar-fill" + style={{ width: `${String(progressPercent)}%` }} + /> + </div> + </> + } + </div> + </div> + ); + })} + </div> + </section> + ); +}; + +export { DailyChallengePanel }; diff --git a/apps/web/src/components/game/editProfileModal.tsx b/apps/web/src/components/game/editProfileModal.tsx new file mode 100644 index 0000000..a4454cc --- /dev/null +++ b/apps/web/src/components/game/editProfileModal.tsx @@ -0,0 +1,411 @@ +/** + * @file Edit profile modal component for updating player profile settings. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Complex form with many fields */ +/* eslint-disable complexity -- Many conditional render paths for toggles */ +/* eslint-disable max-lines -- Large modal with profile and settings forms */ +/* eslint-disable max-statements -- Many state initialisations and handlers */ +import { + DEFAULT_PROFILE_SETTINGS, + type NumberFormat, + type ProfileSettings, +} from "@elysium/types"; +import { type ChangeEvent, type JSX, useEffect, useState } from "react"; +import { updateProfile } from "../../api/client.js"; +import { useGame } from "../../context/gameContext.js"; + +interface EditProfileModalProperties { + readonly onClose: ()=> void; +} + +interface StatToggle { + key: keyof ProfileSettings; + label: string; + icon: string; +} + +const currentRunToggles: Array<StatToggle> = [ + { icon: "🪙", key: "showCurrentGold", label: "Gold Earned This Run" }, + { icon: "👆", key: "showCurrentClicks", label: "Clicks This Run" }, + { icon: "✨", key: "showApotheosis", label: "Apotheosis Badge" }, + { icon: "🌌", key: "showTranscendence", label: "Transcendence Badge" }, + { icon: "⭐", key: "showPrestige", label: "Prestige Level" }, + { icon: "💀", key: "showBossesDefeated", label: "Bosses Defeated" }, + { icon: "📜", key: "showQuestsCompleted", label: "Quests Completed" }, + { + icon: "⚔️", + key: "showAdventurersRecruited", + label: "Adventurers Recruited", + }, + { + icon: "🏆", + key: "showAchievementsUnlocked", + label: "Achievements Unlocked", + }, +]; + +const allTimeToggles: Array<StatToggle> = [ + { icon: "🪙", key: "showTotalGold", label: "Total Gold Earned" }, + { icon: "👆", key: "showTotalClicks", label: "Total Clicks" }, + { + icon: "💀", + key: "showLifetimeBossesDefeated", + label: "Bosses Defeated", + }, + { + icon: "📜", + key: "showLifetimeQuestsCompleted", + label: "Quests Completed", + }, + { + icon: "⚔️", + key: "showLifetimeAdventurersRecruited", + label: "Adventurers Recruited", + }, + { + icon: "🏆", + key: "showLifetimeAchievementsUnlocked", + label: "Achievements Unlocked", + }, + { icon: "📅", key: "showGuildFounded", label: "Guild Founded Date" }, +]; + +const numberFormatOptions: Array<{ + value: NumberFormat; + label: string; + example: string; +}> = [ + { example: "1.23Qa", label: "Suffix", value: "suffix" }, + { example: "1.23e15", label: "Scientific", value: "scientific" }, + { example: "1.23E15", label: "Engineering", value: "engineering" }, +]; + +/** + * Renders the edit profile modal for updating player display settings. + * @param props - The modal properties. + * @param props.onClose - Callback to close the modal. + * @returns The JSX element. + */ +const EditProfileModal = ({ + onClose, +}: EditProfileModalProperties): JSX.Element => { + const { + state, + numberFormat: currentNumberFormat, + setNumberFormat, + } = useGame(); + const player = state?.player; + + const [ characterName, setCharacterName ] = useState( + player?.characterName ?? "", + ); + const [ bio, setBio ] = useState(""); + const [ profileSettings, setProfileSettings ] = useState<ProfileSettings>({ + ...DEFAULT_PROFILE_SETTINGS, + numberFormat: currentNumberFormat, + }); + const [ loadingProfile, setLoadingProfile ] = useState(true); + const [ saving, setSaving ] = useState(false); + const [ error, setError ] = useState<string | null>(null); + const [ saved, setSaved ] = useState(false); + + useEffect(() => { + if (player?.discordId === undefined || player.discordId === "") { + return; + } + fetch(`/api/profile/${player.discordId}`). + then(async(response) => { + if (!response.ok) { + return; + } + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response cast + const data = (await response.json()) as { + bio: string; + profileSettings: ProfileSettings; + characterName: string; + }; + setBio(data.bio); + setProfileSettings({ + ...DEFAULT_PROFILE_SETTINGS, + ...data.profileSettings, + }); + setCharacterName( + data.characterName === "" + ? player.characterName + : data.characterName, + ); + }). + catch(() => { + + /* Fall back to local state if fetch fails — not a blocking error */ + }). + finally(() => { + setLoadingProfile(false); + }); + }, [ player?.discordId, player?.characterName ]); + + async function handleSave(): Promise<void> { + setSaving(true); + setError(null); + try { + await updateProfile({ + bio, + characterName, + profileSettings, + }); + setNumberFormat(profileSettings.numberFormat); + setSaved(true); + setTimeout(onClose, 900); + } catch (error_: unknown) { + setError(error_ instanceof Error + ? error_.message + : "Failed to save"); + } finally { + setSaving(false); + } + } + + function handleSaveClick(): void { + void handleSave(); + } + + function toggleSetting(key: keyof ProfileSettings): void { + setProfileSettings((previous) => { + const current = previous[key]; + const toggled = typeof current === "boolean" + ? !current + : current; + return { ...previous, [key]: toggled }; + }); + } + + function handleLeaderboardToggle(): void { + toggleSetting("showOnLeaderboards"); + } + + function handleNameChange(event: ChangeEvent<HTMLInputElement>): void { + setCharacterName(event.target.value); + } + + function handleBioChange(event: ChangeEvent<HTMLTextAreaElement>): void { + setBio(event.target.value); + } + + const isSaveDisabled = saving || characterName.trim() === ""; + + let saveLabel = "Save Profile"; + if (saving) { + saveLabel = "Saving…"; + } + if (saved) { + saveLabel = "✓ Saved!"; + } + + return ( + <div aria-modal="true" className="modal-overlay" role="dialog"> + <div className="modal edit-profile-modal"> + <div className="modal-header"> + <h2>{"Edit Profile"}</h2> + <button + aria-label="Close" + className="modal-close" + onClick={onClose} + type="button" + > + {"✕"} + </button> + </div> + + {loadingProfile + ? <p className="edit-profile-loading">{"Loading your profile…"}</p> + : <div className="edit-profile-form"> + <label className="edit-profile-label" htmlFor="edit-char-name"> + {"Display Name"} + </label> + <input + className="edit-profile-input" + id="edit-char-name" + maxLength={32} + onChange={handleNameChange} + placeholder="Your character's name" + type="text" + value={characterName} + /> + <span className="edit-profile-hint"> + {characterName.length} + {" / 32"} + </span> + + <label className="edit-profile-label" htmlFor="edit-bio"> + {"Bio"} + </label> + <textarea + className="edit-profile-textarea" + id="edit-bio" + maxLength={200} + onChange={handleBioChange} + placeholder="Tell the world about your guild… (optional)" + rows={3} + value={bio} + /> + <span className="edit-profile-hint"> + {bio.length} + {" / 200"} + </span> + + <div className="edit-profile-section"> + <p className="edit-profile-label">{"Visible Stats"}</p> + <p className="edit-profile-sublabel"> + {"Choose which stats appear on your public profile."} + </p> + + <p className="edit-profile-stat-group-heading">{"Current Run"}</p> + <div className="stat-toggles"> + {currentRunToggles.map(({ key, label, icon }) => { + const isOn = profileSettings[key] === true; + const toggleClass = isOn + ? "stat-toggle-on" + : "stat-toggle-off"; + const toggleIndicator = isOn + ? "✓ Shown" + : "Hidden"; + function handleToggle(): void { + toggleSetting(key); + } + return ( + <button + className={`stat-toggle-btn ${toggleClass}`} + key={key} + onClick={handleToggle} + type="button" + > + <span> + {icon} {label} + </span> + <span className="stat-toggle-indicator"> + {toggleIndicator} + </span> + </button> + ); + })} + </div> + + <p className="edit-profile-stat-group-heading">{"All Time"}</p> + <div className="stat-toggles"> + {allTimeToggles.map(({ key, label, icon }) => { + const isOn = profileSettings[key] === true; + const toggleClass = isOn + ? "stat-toggle-on" + : "stat-toggle-off"; + const toggleIndicator = isOn + ? "✓ Shown" + : "Hidden"; + function handleToggle(): void { + toggleSetting(key); + } + return ( + <button + className={`stat-toggle-btn ${toggleClass}`} + key={key} + onClick={handleToggle} + type="button" + > + <span> + {icon} {label} + </span> + <span className="stat-toggle-indicator"> + {toggleIndicator} + </span> + </button> + ); + })} + </div> + </div> + + <div className="edit-profile-section"> + <p className="edit-profile-label">{"Privacy"}</p> + <p className="edit-profile-sublabel"> + {"Control your visibility on public leaderboards."} + </p> + <button + className={`stat-toggle-btn ${ + profileSettings.showOnLeaderboards + ? "stat-toggle-on" + : "stat-toggle-off" + }`} + onClick={handleLeaderboardToggle} + type="button" + > + <span>{"🏆 Appear on Leaderboards"}</span> + <span className="stat-toggle-indicator"> + {profileSettings.showOnLeaderboards + ? "✓ Shown" + : "Hidden"} + </span> + </button> + </div> + + <div className="edit-profile-section"> + <p className="edit-profile-label">{"Number Format"}</p> + <p className="edit-profile-sublabel"> + {"How large numbers appear across the game."} + </p> + <div className="number-format-picker"> + {numberFormatOptions.map(({ value, label, example }) => { + function handleFormatSelect(): void { + setProfileSettings((previous) => { + return { ...previous, numberFormat: value }; + }); + } + return ( + <button + className={`number-format-btn ${ + profileSettings.numberFormat === value + ? "number-format-active" + : "" + }`} + key={value} + onClick={handleFormatSelect} + type="button" + > + <span className="number-format-label">{label}</span> + <span className="number-format-example">{example}</span> + </button> + ); + })} + </div> + </div> + + {error === null + ? null + : <p className="edit-profile-error">{error}</p> + } + + <div className="edit-profile-actions"> + <button + className="edit-profile-cancel" + onClick={onClose} + type="button" + > + {"Cancel"} + </button> + <button + className="edit-profile-save" + disabled={isSaveDisabled} + onClick={handleSaveClick} + type="button" + > + {saveLabel} + </button> + </div> + </div> + } + </div> + </div> + ); +}; + +export { EditProfileModal }; diff --git a/apps/web/src/components/game/equipmentPanel.tsx b/apps/web/src/components/game/equipmentPanel.tsx new file mode 100644 index 0000000..dc6c4e9 --- /dev/null +++ b/apps/web/src/components/game/equipmentPanel.tsx @@ -0,0 +1,359 @@ +/** + * @file Equipment panel component for managing owned and available equipment. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */ +/* eslint-disable max-lines-per-function -- Complex component with many render paths */ +/* eslint-disable complexity -- Complex component with many conditional render paths */ +import { type JSX, useState } from "react"; +import { useGame } from "../../context/gameContext.js"; +import { EQUIPMENT_SETS } from "../../data/equipmentSets.js"; +import { LockToggle } from "../ui/lockToggle.js"; +import type { Equipment, EquipmentType } from "@elysium/types"; + +const rarityLabel: Record<string, string> = { + common: "Common", + epic: "Epic", + legendary: "Legendary", + rare: "Rare", +}; + +const typeIcon: Record<EquipmentType, string> = { + armour: "🛡️", + trinket: "💍", + weapon: "⚔️", +}; + +/** + * Computes a human-readable bonus description for a piece of equipment. + * @param item - The equipment item. + * @returns The formatted bonus description. + */ +const bonusDescription = (item: Equipment): string => { + const parts: Array<string> = []; + if (item.bonus.combatMultiplier !== undefined) { + const pct = Math.round((item.bonus.combatMultiplier - 1) * 100); + parts.push(`+${String(pct)}% Combat`); + } + if (item.bonus.goldMultiplier !== undefined) { + const pct = Math.round((item.bonus.goldMultiplier - 1) * 100); + parts.push(`+${String(pct)}% Gold/s`); + } + if (item.bonus.clickMultiplier !== undefined) { + const pct = Math.round((item.bonus.clickMultiplier - 1) * 100); + parts.push(`+${String(pct)}% Click`); + } + return parts.join(", "); +}; + +/** + * Formats an equipment cost as a readable string. + * @param cost - The cost object with gold, essence, and crystals. + * @param cost.gold - The gold component of the cost. + * @param cost.essence - The essence component of the cost. + * @param cost.crystals - The crystals component of the cost. + * @returns The formatted cost string. + */ +const costLabel = (cost: { + gold: number; + essence: number; + crystals: number; +}): string => { + const parts: Array<string> = []; + if (cost.gold > 0) { + parts.push(`🪙 ${cost.gold.toLocaleString()}`); + } + if (cost.essence > 0) { + parts.push(`✨ ${cost.essence.toLocaleString()}`); + } + if (cost.crystals > 0) { + parts.push(`💎 ${cost.crystals.toLocaleString()}`); + } + return parts.join(" "); +}; + +interface EquipmentCardProperties { + readonly item: Equipment; + readonly gold: number; + readonly essence: number; + readonly crystals: number; + readonly dropBossName: string | undefined; + readonly setName: string | undefined; +} + +/** + * Renders a single equipment card. + * @param props - The equipment card properties. + * @param props.item - The equipment item data. + * @param props.gold - The current gold amount. + * @param props.essence - The current essence amount. + * @param props.crystals - The current crystals amount. + * @param props.dropBossName - The name of the boss that drops this item. + * @param props.setName - The name of the set this item belongs to. + * @returns The JSX element. + */ +const EquipmentCard = ({ + item, + gold, + essence, + crystals, + dropBossName, + setName, +}: EquipmentCardProperties): JSX.Element => { + const { equipItem, buyEquipment } = useGame(); + + const canAfford + = item.cost !== undefined + && gold >= item.cost.gold + && essence >= item.cost.essence + && crystals >= item.cost.crystals; + + function handleBuy(): void { + buyEquipment(item.id); + } + function handleEquip(): void { + equipItem(item.id); + } + + const ownedClass = item.owned + ? "" + : "not-owned"; + const equippedClass = item.equipped + ? "equipped" + : ""; + + return ( + <div + className={`equipment-card rarity-${item.rarity} ${equippedClass} ${ownedClass}`} + > + <div className="equipment-icon">{typeIcon[item.type]}</div> + <div className="equipment-info"> + <div className="equipment-name-row"> + <h3>{item.name}</h3> + <span className={`rarity-badge rarity-${item.rarity}`}> + {rarityLabel[item.rarity]} + </span> + </div> + <p className="equipment-description">{item.description}</p> + <p className="equipment-bonus">{bonusDescription(item)}</p> + {setName === undefined + ? null + : <span className="equipment-set-badge"> + {"🔗 "} + {setName} + </span> + } + {item.owned || item.cost === undefined + ? null + : <p className="equipment-cost">{costLabel(item.cost)}</p> + } + </div> + <div className="equipment-action"> + {!item.owned && item.cost === undefined + && <span className="equipment-locked"> + {dropBossName === undefined + ? "🔒 Boss drop" + : `⚔️ Drop: ${dropBossName}`} + </span> + } + {item.owned || item.cost === undefined + ? null + : <button + className="equip-button" + disabled={!canAfford} + onClick={handleBuy} + type="button" + > + {canAfford + ? "Purchase" + : "Can't afford"} + </button> + } + {item.owned && item.equipped + ? <span className="equipment-equipped-badge">{"✓ Equipped"}</span> + : null} + {item.owned && !item.equipped + ? <button + className="equip-button" + onClick={handleEquip} + type="button" + > + {"Equip"} + </button> + + : null} + </div> + </div> + ); +}; + +const slotOrder: Array<EquipmentType> = [ "weapon", "armour", "trinket" ]; +const slotLabel: Record<EquipmentType, string> = { + armour: "🛡️ Armour", + trinket: "💍 Trinkets", + weapon: "⚔️ Weapons", +}; + +/** + * Renders the equipment panel with all owned and available equipment. + * @returns The JSX element. + */ +const EquipmentPanel = (): JSX.Element => { + const { state } = useGame(); + const [ showLocked, setShowLocked ] = useState(true); + + if (state === null) { + return ( + <section className="panel"> + <p>{"Loading..."}</p> + </section> + ); + } + + const { bosses, equipment, resources } = state; + const unownedCount = equipment.filter((item) => { + return !item.owned; + }).length; + + const equipmentDropSources = new Map<string, string>(); + for (const { equipmentRewards, name: bossName } of bosses) { + for (const equipmentId of equipmentRewards) { + equipmentDropSources.set(equipmentId, bossName); + } + } + + const setNameById = new Map<string, string>( + EQUIPMENT_SETS.map((equipSet) => { + return [ equipSet.id, equipSet.name ]; + }), + ); + + const equippedItemIds = new Set( + equipment. + filter((item) => { + return item.equipped; + }). + map((item) => { + return item.id; + }), + ); + const activeSets = EQUIPMENT_SETS.map((set) => { + const count = set.pieces.filter((id) => { + return equippedItemIds.has(id); + }).length; + return { count, set }; + }).filter(({ count }) => { + return count >= 2; + }); + + function setBonusDescription( + equipSet: (typeof EQUIPMENT_SETS)[number], + count: number, + ): string { + const parts: Array<string> = []; + for (const threshold of [ 2, 3 ] as const) { + if (count >= threshold) { + const bonus = equipSet.bonuses[threshold]; + if (bonus.goldMultiplier !== undefined) { + const pct = Math.round((bonus.goldMultiplier - 1) * 100); + parts.push(`+${String(pct)}% Gold/s (${String(threshold)}pc)`); + } + if (bonus.combatMultiplier !== undefined) { + const pct = Math.round((bonus.combatMultiplier - 1) * 100); + parts.push(`+${String(pct)}% Combat (${String(threshold)}pc)`); + } + if (bonus.clickMultiplier !== undefined) { + const pct = Math.round((bonus.clickMultiplier - 1) * 100); + parts.push(`+${String(pct)}% Click (${String(threshold)}pc)`); + } + } + } + return parts.join(", "); + } + + function handleToggle(): void { + setShowLocked((current) => { + return !current; + }); + } + + return ( + <section className="panel equipment-panel"> + <div className="panel-header"> + <h2>{"Equipment"}</h2> + <LockToggle + lockedCount={unownedCount} + onToggle={handleToggle} + showLocked={showLocked} + /> + </div> + <p className="equipment-intro"> + {"Equipment drops from bosses and grants passive bonuses." + + " Only one item per slot can be equipped at a time." + + " Equip matching set pieces for bonus effects!"} + </p> + + {activeSets.length > 0 + && <div className="active-sets"> + <h3 className="active-sets-heading">{"✨ Active Set Bonuses"}</h3> + {activeSets.map(({ set, count }) => { + return ( + <div className="active-set-row" key={set.id}> + <span className="active-set-name"> + {set.name} + {" ("} + {count} + {"/"} + {set.pieces.length} + {")"} + </span> + <span className="active-set-bonus"> + {setBonusDescription(set, count)} + </span> + </div> + ); + })} + </div> + } + + {slotOrder.map((slotType) => { + const items = equipment.filter((item) => { + return item.type === slotType && (showLocked || item.owned); + }); + return ( + <div className="equipment-slot-section" key={slotType}> + <h3 className="slot-heading">{slotLabel[slotType]}</h3> + <div className="equipment-list"> + {items.map((item) => { + return ( + <EquipmentCard + crystals={resources.crystals} + dropBossName={equipmentDropSources.get(item.id)} + essence={resources.essence} + gold={resources.gold} + item={item} + key={item.id} + setName={ + item.setId === undefined + ? undefined + : setNameById.get(item.setId) + } + /> + ); + })} + {items.length === 0 + && <p className="empty-zone"> + {"No items to show in this slot."} + </p> + } + </div> + </div> + ); + })} + </section> + ); +}; + +export { EquipmentPanel }; diff --git a/apps/web/src/components/game/explorationPanel.tsx b/apps/web/src/components/game/explorationPanel.tsx new file mode 100644 index 0000000..eb6744d --- /dev/null +++ b/apps/web/src/components/game/explorationPanel.tsx @@ -0,0 +1,302 @@ +/** + * @file Exploration panel component for exploring areas and collecting materials. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Complex component with many render paths */ +/* eslint-disable complexity -- Complex component with many conditional render paths */ +import { type JSX, useState } from "react"; +import { useGame } from "../../context/gameContext.js"; +import { EXPLORATION_AREAS } from "../../data/explorations.js"; +import { ZoneSelector } from "./zoneSelector.js"; +import type { ExploreCollectResponse } from "@elysium/types"; + +/** + * Formats a duration in seconds to a human-readable string. + * @param seconds - The total number of seconds to format. + * @returns The formatted duration string. + */ +const formatDuration = (seconds: number): string => { + const secondsPerDay = 86_400; + const secondsPerHour = 3600; + const secondsPerMinute = 60; + if (seconds >= secondsPerDay) { + const days = Math.floor(seconds / secondsPerDay); + const remainingAfterDays = seconds % secondsPerDay; + const hours = Math.floor(remainingAfterDays / secondsPerHour); + return hours > 0 + ? `${String(days)}d ${String(hours)}h` + : `${String(days)}d`; + } + if (seconds >= secondsPerHour) { + const hours = Math.floor(seconds / secondsPerHour); + const remainingAfterHours = seconds % secondsPerHour; + const minutes = Math.floor(remainingAfterHours / secondsPerMinute); + return `${String(hours)}h ${String(minutes)}m`; + } + if (seconds >= secondsPerMinute) { + const minutes = Math.floor(seconds / secondsPerMinute); + const secs = seconds % secondsPerMinute; + return `${String(minutes)}m ${String(secs)}s`; + } + return `${String(seconds)}s`; +}; + +/** + * Computes the time remaining for an exploration in progress. + * @param startedAt - The timestamp when exploration started. + * @param durationSeconds - The total duration in seconds. + * @returns The remaining seconds. + */ +const timeRemaining = (startedAt: number, durationSeconds: number): number => { + const elapsed = (Date.now() - startedAt) / 1000; + return Math.max(0, durationSeconds - elapsed); +}; + +interface CollectResult { + areaId: string; + response: ExploreCollectResponse; +} + +/** + * Renders the exploration panel for managing area explorations. + * @returns The JSX element. + */ +const ExplorationPanel = (): JSX.Element => { + const { state, startExploration, collectExploration, formatNumber } + = useGame(); + const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale"); + const [ pendingAreaId, setPendingAreaId ] = useState<string | null>(null); + const [ lastResult, setLastResult ] = useState<CollectResult | null>(null); + + if (state === null) { + return ( + <section className="panel"> + <p>{"Loading..."}</p> + </section> + ); + } + + const { zones, exploration: explorationState } = state; + + const zoneAreas = EXPLORATION_AREAS.filter((area) => { + return area.zoneId === activeZoneId; + }); + + const hasActiveExploration + = explorationState?.areas.some((area) => { + return area.status === "in_progress"; + }) ?? false; + + async function handleStart(areaId: string): Promise<void> { + setPendingAreaId(areaId); + try { + await startExploration(areaId); + } finally { + setPendingAreaId(null); + } + } + + async function handleCollect(areaId: string): Promise<void> { + setPendingAreaId(areaId); + try { + const result = await collectExploration(areaId); + setLastResult({ areaId: areaId, response: result }); + } finally { + setPendingAreaId(null); + } + } + + function handleDismissResult(): void { + setLastResult(null); + } + + function handleZoneSelect(id: string): void { + setActiveZoneId(id); + setLastResult(null); + } + + const goldChange = lastResult?.response.event?.goldChange ?? 0; + const essenceChange = lastResult?.response.event?.essenceChange ?? 0; + + return ( + <section className="panel exploration-panel"> + <div className="panel-header"> + <h2>{"🗺️ Exploration"}</h2> + </div> + + {lastResult === null + ? null + : <div className="exploration-result"> + <button + className="exploration-result-close" + onClick={handleDismissResult} + type="button" + > + {"✕"} + </button> + {lastResult.response.foundNothing + ? <p className="exploration-nothing"> + {lastResult.response.nothingMessage} + </p> + : <> + {lastResult.response.event === null + ? null + : <p className="exploration-event-text"> + {lastResult.response.event.text} + </p> + } + <div className="exploration-rewards"> + {goldChange !== 0 + && <span + className={`reward-tag ${goldChange > 0 + ? "" + : "negative"}`} + > + {"🪙 "} + {goldChange > 0 + ? "+" + : ""} + {formatNumber(goldChange)} + {" gold"} + </span> + } + {essenceChange > 0 + && <span className="reward-tag"> + {"✨ +"} + {formatNumber(essenceChange)} + {" essence"} + </span> + } + {lastResult.response.event?.materialGained !== null + && lastResult.response.event?.materialGained !== undefined + ? <span className="reward-tag material-tag"> + {"📦 +"} + {lastResult.response.event.materialGained.quantity}{" "} + {/* eslint-disable-next-line stylistic/max-len -- long property chain cannot be shortened */} + {lastResult.response.event.materialGained.materialId.replaceAll( + "_", + " ", + )} + {" (event)"} + </span> + : null} + {lastResult.response.materialsFound.map((foundMaterial) => { + return ( + <span + className="reward-tag material-tag" + key={foundMaterial.materialId} + > + {"📦 +"} + {foundMaterial.quantity}{" "} + {foundMaterial.materialId.replaceAll("_", " ")} + </span> + ); + })} + </div> + </> + } + </div> + } + + <ZoneSelector + activeZoneId={activeZoneId} + onSelectZone={handleZoneSelect} + zones={zones} + /> + + <div className="exploration-list"> + {zoneAreas.map((area) => { + const areaState = explorationState?.areas.find((explorationArea) => { + return explorationArea.id === area.id; + }); + const status = areaState?.status ?? "locked"; + const startedAt = areaState?.startedAt ?? 0; + const isReady + = status === "in_progress" + && timeRemaining(startedAt, area.durationSeconds) <= 0; + const isPending = pendingAreaId === area.id; + + function handleStartClick(): void { + void handleStart(area.id); + } + function handleCollectClick(): void { + void handleCollect(area.id); + } + + return ( + <div + className={`exploration-card exploration-${status}`} + key={area.id} + > + <div className="exploration-info"> + <h3> + {area.name} + {areaState?.completedOnce === true + ? <span className="exploration-discovered">{" 📖"}</span> + : null} + </h3> + <p>{area.description}</p> + <span className="exploration-duration"> + {"⏱️ "} + {formatDuration(area.durationSeconds)} + </span> + </div> + <div className="exploration-action"> + {status === "locked" + && <span className="quest-badge locked">{"🔒 Locked"}</span> + } + {status === "available" + && <button + className="start-quest-button" + disabled={isPending || hasActiveExploration} + onClick={handleStartClick} + title={ + hasActiveExploration + ? "An exploration is already in progress" + : undefined + } + type="button" + > + {isPending + ? "Departing..." + : `Explore (${formatDuration(area.durationSeconds)})`} + </button> + } + {status === "in_progress" && !isReady + && <span className="quest-badge active"> + {"⏳ "} + {formatDuration( + Math.ceil(timeRemaining(startedAt, area.durationSeconds)), + )} + {" remaining"} + </span> + } + {status === "in_progress" && isReady + ? <button + className="collect-button" + disabled={isPending} + onClick={handleCollectClick} + type="button" + > + {isPending + ? "Collecting..." + : "📦 Collect Results"} + </button> + : null} + </div> + </div> + ); + })} + {zoneAreas.length === 0 + && <p className="empty-zone"> + {"No exploration areas in this zone."} + </p> + } + </div> + </section> + ); +}; + +export { ExplorationPanel }; diff --git a/apps/web/src/components/game/gameLayout.tsx b/apps/web/src/components/game/gameLayout.tsx new file mode 100644 index 0000000..33fd363 --- /dev/null +++ b/apps/web/src/components/game/gameLayout.tsx @@ -0,0 +1,244 @@ +/** + * @file Game layout component rendering the main game UI. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Complex layout with many conditional renders */ +/* eslint-disable complexity -- Many tab render paths */ +import { type JSX, useState } from "react"; +import { useGame } from "../../context/gameContext.js"; +import { ResourceBar } from "../ui/resourceBar.js"; +import { AboutPanel } from "./aboutPanel.js"; +import { AchievementPanel } from "./achievementPanel.js"; +import { AchievementToast } from "./achievementToast.js"; +import { AdventurerPanel } from "./adventurerPanel.js"; +import { ApotheosisPanel } from "./apotheosisPanel.js"; +import { BattleModal } from "./battleModal.js"; +import { BossPanel } from "./bossPanel.js"; +import { CharacterSheetPanel } from "./characterSheetPanel.js"; +import { ClickArea } from "./clickArea.js"; +import { CodexPanel } from "./codexPanel.js"; +import { CodexToast } from "./codexToast.js"; +import { CompanionPanel } from "./companionPanel.js"; +import { CraftingPanel } from "./craftingPanel.js"; +import { DailyChallengePanel } from "./dailyChallengePanel.js"; +import { EditProfileModal } from "./editProfileModal.js"; +import { EquipmentPanel } from "./equipmentPanel.js"; +import { ExplorationPanel } from "./explorationPanel.js"; +import { LoginBonusModal } from "./loginBonusModal.js"; +import { OfflineModal } from "./offlineModal.js"; +import { OutdatedSchemaModal } from "./outdatedSchemaModal.js"; +import { PrestigePanel } from "./prestigePanel.js"; +import { QuestPanel } from "./questPanel.js"; +import { StatisticsPanel } from "./statisticsPanel.js"; +import { StoryPanel } from "./storyPanel.js"; +import { StoryToast } from "./storyToast.js"; +import { TranscendencePanel } from "./transcendencePanel.js"; +import { UpgradePanel } from "./upgradePanel.js"; + +type Tab = + | "adventurers" + | "upgrades" + | "quests" + | "bosses" + | "equipment" + | "achievements" + | "prestige" + | "transcendence" + | "apotheosis" + | "statistics" + | "daily" + | "codex" + | "about" + | "exploration" + | "crafting" + | "character" + | "companions" + | "story"; + +const baseTabs: Array<{ id: Tab; label: string }> = [ + { id: "adventurers", label: "⚔️ Adventurers" }, + { id: "upgrades", label: "🔧 Upgrades" }, + { id: "quests", label: "📜 Quests" }, + { id: "bosses", label: "👹 Bosses" }, + { id: "equipment", label: "🗡️ Equipment" }, + { id: "exploration", label: "🗺️ Exploration" }, + { id: "crafting", label: "⚗️ Crafting" }, + { id: "daily", label: "📅 Daily" }, + { id: "prestige", label: "⭐ Prestige" }, + { id: "transcendence", label: "🌌 Transcendence" }, + { id: "apotheosis", label: "✨ Apotheosis" }, + { id: "statistics", label: "📊 Statistics" }, + { id: "companions", label: "👥 Companions" }, + { id: "character", label: "📋 Character" }, + { id: "achievements", label: "🏆 Achievements" }, + { id: "story", label: "📖 Story" }, + { id: "codex", label: "🗺️ Codex" }, + { id: "about", label: "ℹ️ About" }, +]; + +/** + * Renders the main game layout with tabs and panels. + * @returns The JSX element. + */ +const GameLayout = (): JSX.Element => { + const { + state, + isLoading, + error, + battleResult, + dismissBattle, + lastSavedAt, + isSyncing, + forceSync, + unlockedCodexEntryIds: pendingCodexEntryIds, + unlockedStoryChapterIds: pendingStoryChapterIds, + loginBonus, + dismissLoginBonus, + schemaOutdated, + } = useGame(); + const [ activeTab, setActiveTab ] = useState<Tab>("adventurers"); + const [ editingProfile, setEditingProfile ] = useState(false); + const [ dismissedOutdatedWarning, setDismissedOutdatedWarning ] + = useState(false); + + if (isLoading) { + return ( + <div className="loading-screen"> + <p>{"Loading your adventure..."}</p> + </div> + ); + } + + if (error !== null && error !== "") { + return ( + <div className="error-screen"> + <p> + {"Error: "} + {error} + </p> + </div> + ); + } + + if (state === null) { + return ( + <div className="loading-screen"> + <p>{"Loading..."}</p> + </div> + ); + } + + const profileUrl = `/profile/${state.player.discordId}`; + const codexBadgeCount = pendingCodexEntryIds.length; + const storyBadgeCount = pendingStoryChapterIds.length; + + function handleOpenEditProfile(): void { + setEditingProfile(true); + } + + function handleCloseEditProfile(): void { + setEditingProfile(false); + } + + function handleDismissOutdated(): void { + setDismissedOutdatedWarning(true); + } + + return ( + <div className="game-layout"> + <ResourceBar + apotheosisCount={state.apotheosis?.count ?? 0} + isSyncing={isSyncing} + lastSavedAt={lastSavedAt} + onEditProfile={handleOpenEditProfile} + onForceSync={forceSync} + prestigeCount={state.prestige.count} + profileUrl={profileUrl} + resources={state.resources} + runestones={state.prestige.runestones} + transcendenceCount={state.transcendence?.count ?? 0} + /> + <OfflineModal /> + {schemaOutdated && !dismissedOutdatedWarning + ? <OutdatedSchemaModal onDismiss={handleDismissOutdated} /> + : null} + <AchievementToast /> + <CodexToast /> + <StoryToast /> + {loginBonus === null + ? null + : <LoginBonusModal bonus={loginBonus} onClose={dismissLoginBonus} /> + } + {battleResult === null + ? null + : <BattleModal battle={battleResult} onDismiss={dismissBattle} /> + } + {editingProfile + ? <EditProfileModal onClose={handleCloseEditProfile} /> + : null} + + <div className="game-main"> + <aside className="game-sidebar"> + <ClickArea /> + <p className="game-copyright">{"© NHCarrigan"}</p> + </aside> + + <main className="game-content"> + <nav className="tab-bar"> + {baseTabs.map((tab) => { + const { id: tabId, label } = tab; + function handleTabClick(): void { + setActiveTab(tabId); + } + return ( + <button + className={`tab-button ${ + activeTab === tabId + ? "active" + : "" + }`} + key={tabId} + onClick={handleTabClick} + type="button" + > + {label} + {tabId === "codex" && codexBadgeCount > 0 + && <span className="tab-badge">{codexBadgeCount}</span> + } + {tabId === "story" && storyBadgeCount > 0 + && <span className="tab-badge">{storyBadgeCount}</span> + } + </button> + ); + })} + </nav> + + <div className="tab-content"> + {activeTab === "adventurers" && <AdventurerPanel />} + {activeTab === "upgrades" && <UpgradePanel />} + {activeTab === "quests" && <QuestPanel />} + {activeTab === "bosses" && <BossPanel />} + {activeTab === "equipment" && <EquipmentPanel />} + {activeTab === "achievements" && <AchievementPanel />} + {activeTab === "prestige" && <PrestigePanel />} + {activeTab === "transcendence" && <TranscendencePanel />} + {activeTab === "apotheosis" && <ApotheosisPanel />} + {activeTab === "exploration" && <ExplorationPanel />} + {activeTab === "crafting" && <CraftingPanel />} + {activeTab === "statistics" && <StatisticsPanel />} + {activeTab === "daily" && <DailyChallengePanel />} + {activeTab === "companions" && <CompanionPanel />} + {activeTab === "character" && <CharacterSheetPanel />} + {activeTab === "story" && <StoryPanel />} + {activeTab === "codex" && <CodexPanel />} + {activeTab === "about" && <AboutPanel />} + </div> + </main> + </div> + </div> + ); +}; + +export { GameLayout }; diff --git a/apps/web/src/components/game/leaderboardPage.tsx b/apps/web/src/components/game/leaderboardPage.tsx new file mode 100644 index 0000000..af06b2b --- /dev/null +++ b/apps/web/src/components/game/leaderboardPage.tsx @@ -0,0 +1,273 @@ +/** + * @file Leaderboard page component showing top players across categories. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Complex component with many render paths */ +/* eslint-disable complexity -- Many conditional render paths for categories and entries */ +import { useEffect, useState, type JSX } from "react"; +import type { LeaderboardCategory, LeaderboardEntry } from "@elysium/types"; + +interface CategoryConfig { + id: LeaderboardCategory; + label: string; + icon: string; + formatValue: (value: number)=> string; +} + +const goldSuffixes = [ + "", + "K", + "M", + "B", + "T", + "Qa", + "Qt", + "S", + "Sp", + "O", + "N", + "D", +]; + +/** + * Formats a gold value with a short suffix. + * @param value - The gold amount to format. + * @returns The formatted string. + */ +const formatGold = (value: number): string => { + if (value === 0) { + return "0"; + } + const tier = Math.floor(Math.log10(Math.abs(value)) / 3); + const clamped = Math.min(tier, goldSuffixes.length - 1); + const scaled = value / Math.pow(1000, clamped); + return `${String(Number.parseFloat(scaled.toFixed(2)))}${goldSuffixes[clamped] ?? ""}`; +}; + +const categories: Array<CategoryConfig> = [ + { + formatValue: (v): string => { + return formatGold(v); + }, + icon: "🪙", + id: "totalGold", + label: "Lifetime Gold", + }, + { + formatValue: (v): string => { + return v.toLocaleString(); + }, + icon: "💀", + id: "bossesDefeated", + label: "Bosses Defeated", + }, + { + formatValue: (v): string => { + return v.toLocaleString(); + }, + icon: "📜", + id: "questsCompleted", + label: "Quests Completed", + }, + { + formatValue: (v): string => { + return v.toLocaleString(); + }, + icon: "🏆", + id: "achievementsUnlocked", + label: "Achievements", + }, + { + formatValue: (v): string => { + return v.toLocaleString(); + }, + icon: "⭐", + id: "prestigeCount", + label: "Prestige", + }, + { + formatValue: (v): string => { + return v.toLocaleString(); + }, + icon: "🌌", + id: "transcendenceCount", + label: "Transcendence", + }, + { + formatValue: (v): string => { + return v.toLocaleString(); + }, + icon: "✨", + id: "apotheosisCount", + label: "Apotheosis", + }, +]; + +const rankBadges: Record<number, string> = { 1: "🥇", 2: "🥈", 3: "🥉" }; + +/** + * Renders the leaderboard page with category tabs and player rankings. + * @returns The JSX element. + */ +const LeaderboardPage = (): JSX.Element => { + const [ category, setCategory ] = useState<LeaderboardCategory>("totalGold"); + const [ entries, setEntries ] = useState<Array<LeaderboardEntry>>([]); + const [ loading, setLoading ] = useState(true); + const [ error, setError ] = useState<string | null>(null); + + useEffect(() => { + setLoading(true); + setError(null); + fetch(`/api/leaderboards?category=${category}&limit=100`). + then(async(response) => { + if (!response.ok) { + throw new Error("Failed to load leaderboard"); + } + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response cast + const data = (await response.json()) as { + entries: Array<LeaderboardEntry>; + }; + setEntries(data.entries); + }). + catch((error_: unknown) => { + setError( + error_ instanceof Error + ? error_.message + : "Failed to load leaderboard", + ); + }). + finally(() => { + setLoading(false); + }); + }, [ category ]); + + const currentConfig + = categories.find((cat) => { + return cat.id === category; + }) ?? categories[0]; + + return ( + <div className="leaderboard-page"> + <div className="leaderboard-card"> + <div className="leaderboard-header"> + <h1 className="leaderboard-title">{"🏆 Leaderboards"}</h1> + <p className="leaderboard-subtitle"> + {"The mightiest adventurers in Elysium"} + </p> + </div> + + <div className="leaderboard-tabs"> + {categories.map((cat) => { + function handleCategoryClick(): void { + setCategory(cat.id); + } + return ( + <button + className={`leaderboard-tab ${ + category === cat.id + ? "leaderboard-tab--active" + : "" + }`} + key={cat.id} + onClick={handleCategoryClick} + type="button" + > + {cat.icon} {cat.label} + </button> + ); + })} + </div> + + {loading + ? <div className="leaderboard-loading">{"Loading…"}</div> + : null} + + {error === null + ? null + : <div className="leaderboard-error"> + {"⚠️ "} + {error} + </div> + } + + {!loading && error === null && entries.length === 0 + && <div className="leaderboard-empty"> + {"No entries yet — be the first on the board!"} + </div> + } + + {!loading && error === null && entries.length > 0 + && <div className="leaderboard-table"> + <div className="leaderboard-table-header"> + <span className="leaderboard-col-rank">{"Rank"}</span> + <span className="leaderboard-col-player">{"Player"}</span> + <span className="leaderboard-col-value"> + {currentConfig?.icon} {currentConfig?.label} + </span> + </div> + {entries.map((entry) => { + const avatarUrl + = entry.avatar === null + ? `https://cdn.discordapp.com/embed/avatars/${String(Number.parseInt(entry.discordId, 10) % 5)}.png` + : `https://cdn.discordapp.com/avatars/${entry.discordId}/${entry.avatar}.png?size=32`; + const displayName + = entry.characterName === "" + ? entry.username + : entry.characterName; + + return ( + <a + className={`leaderboard-row ${ + entry.rank <= 3 + ? `leaderboard-row--top${String(entry.rank)}` + : "" + }`} + href={`/character/${entry.discordId}`} + key={entry.discordId} + > + <span className="leaderboard-col-rank"> + {rankBadges[entry.rank] ?? `#${String(entry.rank)}`} + </span> + <span className="leaderboard-col-player"> + <img + alt={displayName} + className="leaderboard-avatar" + src={avatarUrl} + /> + <span className="leaderboard-player-info"> + <span className="leaderboard-player-name"> + {displayName} + </span> + {entry.activeTitle === "" + ? null + : <span className="leaderboard-player-title"> + {entry.activeTitle} + </span> + } + </span> + </span> + <span className="leaderboard-col-value"> + {currentConfig?.formatValue(entry.value)} + </span> + </a> + ); + })} + </div> + } + + <div className="leaderboard-footer"> + <a className="leaderboard-play-link" href="/"> + {"⚔️ Play Elysium"} + </a> + <p className="leaderboard-privacy-note"> + {"Players can opt out via their profile settings."} + </p> + </div> + </div> + </div> + ); +}; + +export { LeaderboardPage }; diff --git a/apps/web/src/components/game/loginBonusModal.tsx b/apps/web/src/components/game/loginBonusModal.tsx new file mode 100644 index 0000000..225956f --- /dev/null +++ b/apps/web/src/components/game/loginBonusModal.tsx @@ -0,0 +1,135 @@ +/** + * @file Login bonus modal component displaying daily login rewards. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Complex modal with many render paths */ +import type { LoginBonusResult } from "@elysium/types"; +import type { JSX } from "react"; + +interface LoginBonusModalProperties { + readonly bonus: LoginBonusResult; + readonly onClose: ()=> void; +} + +const dayIcons = [ "🌱", "🌿", "⚔️", "🛡️", "💎", "👑", "🔥" ]; + +/** + * Formats a gold value with a short suffix. + * @param value - The gold amount to format. + * @returns The formatted string. + */ +const formatGold = (value: number): string => { + const suffixes = [ "", "K", "M", "B", "T" ]; + if (value < 1000) { + return value.toLocaleString(); + } + const tier = Math.min(Math.floor(Math.log10(value) / 3), suffixes.length - 1); + const scaled = value / Math.pow(1000, tier); + return `${String(Number.parseFloat(scaled.toFixed(1)))}${suffixes[tier] ?? ""}`; +}; + +/** + * Renders the login bonus modal showing daily reward details. + * @param props - The modal properties. + * @param props.bonus - The login bonus result data. + * @param props.onClose - Callback when the modal is closed. + * @returns The JSX element. + */ +const LoginBonusModal = ({ + bonus, + onClose, +}: LoginBonusModalProperties): JSX.Element => { + const isWeeklyBonus = bonus.day === 7; + const dayIcon = dayIcons[bonus.day - 1] ?? "⭐"; + + return ( + <div aria-modal="true" className="modal-overlay" role="dialog"> + <div className="modal login-bonus-modal"> + <div className="login-bonus-streak"> + <span className="login-bonus-fire">{"🔥"}</span> + <span className="login-bonus-streak-count">{bonus.streak}</span> + <span className="login-bonus-streak-label">{"Day Streak"}</span> + </div> + + <div className="login-bonus-day-badge"> + <span className="login-bonus-day-icon">{dayIcon}</span> + <span className="login-bonus-day-label"> + {"Day "} + {bonus.day} + {" Reward"} + </span> + {bonus.weekMultiplier > 1 + && <span className="login-bonus-week-tag"> + {"×"} + {bonus.weekMultiplier} + {" Week Bonus!"} + </span> + } + </div> + + <div className="login-bonus-rewards"> + <div className="login-bonus-reward-item"> + <span className="login-bonus-reward-icon">{"🪙"}</span> + <span className="login-bonus-reward-value"> + {"+"} + {formatGold(bonus.goldEarned)} + {" Gold"} + </span> + </div> + {bonus.crystalsEarned > 0 + && <div className="login-bonus-reward-item"> + <span className="login-bonus-reward-icon">{"💎"}</span> + <span className="login-bonus-reward-value"> + {"+"} + {bonus.crystalsEarned} + {" Crystals"} + </span> + </div> + } + </div> + + {isWeeklyBonus + ? <p className="login-bonus-weekly-message"> + {"🎉 Weekly bonus — keep the streak going!"} + </p> + : null} + + <div className="login-bonus-calendar"> + {dayIcons.map((icon, index) => { + const dayNumber = index + 1; + const isLastDayCompleted = bonus.day === 7 && dayNumber === 7; + const isCompleted = dayNumber < bonus.day || isLastDayCompleted; + const isToday = dayNumber === bonus.day; + return ( + <div + className={`login-bonus-cal-day ${ + isToday + ? "login-bonus-cal-day--today" + : "" + } ${isCompleted + ? "login-bonus-cal-day--done" + : ""}`} + key={dayNumber} + > + <span className="login-bonus-cal-icon">{icon}</span> + <span className="login-bonus-cal-num">{dayNumber}</span> + </div> + ); + })} + </div> + + <button + className="login-bonus-claim-btn" + onClick={onClose} + type="button" + > + {"Claim Reward"} + </button> + </div> + </div> + ); +}; + +export { LoginBonusModal }; diff --git a/apps/web/src/components/game/loginPage.tsx b/apps/web/src/components/game/loginPage.tsx new file mode 100644 index 0000000..87739b7 --- /dev/null +++ b/apps/web/src/components/game/loginPage.tsx @@ -0,0 +1,105 @@ +/** + * @file Login page component with Discord OAuth authentication. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Authentication flow requires many render paths */ +import { type JSX, useEffect, useState } from "react"; +import { getAuthUrl, handleAuthCallback } from "../../api/client.js"; + +interface LoginPageProperties { + readonly onLogin: ()=> void; +} + +/** + * Renders the login page with Discord OAuth authentication. + * @param props - The login page properties. + * @param props.onLogin - Callback when authentication completes successfully. + * @returns The JSX element. + */ +const LoginPage = ({ onLogin }: LoginPageProperties): JSX.Element => { + const [ authUrl, setAuthUrl ] = useState<string | null>(null); + const [ isLoading, setIsLoading ] = useState(true); + const [ error, setError ] = useState<string | null>(null); + + useEffect(() => { + const parameters = new URLSearchParams(window.location.search); + const code = parameters.get("code"); + + if (code !== null) { + setIsLoading(true); + handleAuthCallback(code). + then(() => { + window.history.replaceState({}, "", "/"); + onLogin(); + }). + catch((error_: unknown) => { + setError( + error_ instanceof Error + ? error_.message + : "Authentication failed", + ); + setIsLoading(false); + }); + return; + } + + getAuthUrl(). + then((url) => { + setAuthUrl(url); + setIsLoading(false); + }). + catch(() => { + setError("Failed to load authentication URL"); + setIsLoading(false); + }); + }, [ onLogin ]); + + if (isLoading) { + return ( + <div className="login-page"> + <div className="login-card"> + <p>{"Loading..."}</p> + </div> + </div> + ); + } + + if (error !== null) { + function handleReload(): void { + window.location.reload(); + } + return ( + <div className="login-page"> + <div className="login-card"> + <p className="error">{error}</p> + <button onClick={handleReload} type="button"> + {"Try Again"} + </button> + </div> + </div> + ); + } + + return ( + <div className="login-page"> + <div className="login-card"> + <h1>{"⚔️ Elysium"}</h1> + <p> + {"An idle fantasy RPG. Hire adventurers, defeat bosses," + + " and ascend to glory."} + </p> + <a className="discord-login-button" href={authUrl ?? "#"}> + {"Login with Discord"} + </a> + <p className="login-note"> + {"Your progress is saved to your Discord account and shareable" + + " with others!"} + </p> + </div> + </div> + ); +}; + +export { LoginPage }; diff --git a/apps/web/src/components/game/offlineModal.tsx b/apps/web/src/components/game/offlineModal.tsx new file mode 100644 index 0000000..cf2818c --- /dev/null +++ b/apps/web/src/components/game/offlineModal.tsx @@ -0,0 +1,62 @@ +/** + * @file Offline modal component showing gold earned while away. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { useGame } from "../../context/gameContext.js"; +import type { JSX } from "react"; + +/** + * Renders the offline earnings modal if the player earned resources offline. + * @returns The JSX element or null if no offline earnings. + */ +const OfflineModal = (): JSX.Element | null => { + const { offlineGold, offlineEssence, dismissOfflineGold, formatNumber } + = useGame(); + + if (offlineGold <= 0 && offlineEssence <= 0) { + return null; + } + + return ( + <div className="modal-overlay"> + <div className="modal"> + <h2>{"Welcome back!"}</h2> + <p> + {"Your adventurers kept working whilst you were away and earned:"} + </p> + {offlineGold > 0 + && <p> + <strong> + {"🪙 "} + {formatNumber(offlineGold)} + {" gold"} + </strong> + </p> + } + {offlineEssence > 0 + && <p> + <strong> + {"✨ "} + {formatNumber(offlineEssence)} + {" essence"} + </strong> + </p> + } + <p className="modal-note"> + {"Offline progress is calculated up to 8 hours."} + </p> + <button + className="modal-close-button" + onClick={dismissOfflineGold} + type="button" + > + {"Collect!"} + </button> + </div> + </div> + ); +}; + +export { OfflineModal }; diff --git a/apps/web/src/components/game/outdatedSchemaModal.tsx b/apps/web/src/components/game/outdatedSchemaModal.tsx new file mode 100644 index 0000000..3acad94 --- /dev/null +++ b/apps/web/src/components/game/outdatedSchemaModal.tsx @@ -0,0 +1,71 @@ +/** + * @file Outdated schema modal component warning about incompatible save data. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { type JSX, useState } from "react"; +import { useGame } from "../../context/gameContext.js"; + +interface OutdatedSchemaModalProperties { + readonly onDismiss: ()=> void; +} + +/** + * Renders the outdated schema modal prompting the user to reset or continue. + * @param props - The modal properties. + * @param props.onDismiss - Callback to dismiss the modal without resetting. + * @returns The JSX element. + */ +const OutdatedSchemaModal = ({ + onDismiss, +}: OutdatedSchemaModalProperties): JSX.Element => { + const { resetProgress } = useGame(); + const [ isResetting, setIsResetting ] = useState(false); + + async function handleReset(): Promise<void> { + setIsResetting(true); + await resetProgress(); + setIsResetting(false); + } + + function handleResetClick(): void { + void handleReset(); + } + + return ( + <div className="modal-overlay"> + <div className="modal offline-modal"> + <h2>{"⚠️ Outdated Save Data"}</h2> + <p> + {"Your save data is from an older version of Elysium and may cause" + + " bugs or unexpected behaviour. Cloud saves are "} + <strong>{"disabled"}</strong> + {" until you reset your progress."} + </p> + <p>{"Resetting will start you fresh — all progress will be lost."}</p> + <div className="outdated-modal-actions"> + <button + className="outdated-modal-reset-button" + disabled={isResetting} + onClick={handleResetClick} + type="button" + > + {isResetting + ? "Resetting…" + : "Reset Progress"} + </button> + <button + className="modal-close-button" + onClick={onDismiss} + type="button" + > + {"Proceed with Bugs"} + </button> + </div> + </div> + </div> + ); +}; + +export { OutdatedSchemaModal }; diff --git a/apps/web/src/components/game/prestigePanel.tsx b/apps/web/src/components/game/prestigePanel.tsx new file mode 100644 index 0000000..1c9d63d --- /dev/null +++ b/apps/web/src/components/game/prestigePanel.tsx @@ -0,0 +1,406 @@ +/** + * @file Prestige panel component for ascending and purchasing runestone upgrades. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Complex component with many render paths */ +/* eslint-disable complexity -- Many conditional render paths */ +/* eslint-disable max-lines -- Large panel with prestige and shop tabs */ +/* eslint-disable max-statements -- Prestige panel manages many local state variables */ +import { useState, type JSX } from "react"; +import { prestige } from "../../api/client.js"; +import { useGame } from "../../context/gameContext.js"; +import { + PRESTIGE_UPGRADES, + PRESTIGE_UPGRADE_CATEGORY_LABELS, +} from "../../data/prestigeUpgrades.js"; +import type { PrestigeUpgradeCategory } from "@elysium/types"; + +const baseThreshold = 1_000_000; +const thresholdScale = 5; +const runestonesPerLevel = 10; + +/** + * Calculates the prestige threshold for a given prestige count. + * @param prestigeCount - The current prestige count. + * @returns The required gold to prestige. + */ +const calculateThreshold = (prestigeCount: number): number => { + return baseThreshold * Math.pow(thresholdScale, prestigeCount); +}; + +/** + * Calculates the production multiplier for a given prestige count. + * @param prestigeCount - The number of times the player has prestiged. + * @returns The compounding multiplier applied to all income sources. + */ +const calculateProductionMultiplier = (prestigeCount: number): number => { + return Math.pow(1.15, prestigeCount); +}; + +/** + * Calculates the runestone preview for a prestige. + * @param totalGoldEarned - Total gold earned this run. + * @param prestigeCount - The current prestige count. + * @param purchasedUpgradeIds - IDs of purchased prestige upgrades. + * @returns The predicted runestone reward. + */ +const calculateRunestonePreview = ( + totalGoldEarned: number, + prestigeCount: number, + purchasedUpgradeIds: Array<string>, +): number => { + const threshold = calculateThreshold(prestigeCount); + const base + = Math.floor(Math.sqrt(totalGoldEarned / threshold)) * runestonesPerLevel; + const runestoneMult = PRESTIGE_UPGRADES.filter((upgrade) => { + return ( + upgrade.category === "runestones" + && purchasedUpgradeIds.includes(upgrade.id) + ); + }).reduce((mult, upgrade) => { + return mult * upgrade.multiplier; + }, 1); + return Math.floor(base * runestoneMult); +}; + +const categoryOrder: Array<PrestigeUpgradeCategory> = [ + "income", + "click", + "essence", + "crystals", + "runestones", + "utility", +]; + +/** + * Renders the prestige panel with ascension and runestone shop tabs. + * @returns The JSX element. + */ +const PrestigePanel = (): JSX.Element => { + const { + state, + reload, + formatNumber, + buyPrestigeUpgrade, + toggleAutoPrestige, + } = useGame(); + const [ isPending, setIsPending ] = useState(false); + const [ result, setResult ] = useState<{ + runestones: number; + count: number; + milestoneRunestones: number; + } | null>(null); + const [ prestigeError, setPrestigeError ] = useState<string | null>(null); + const [ buyingId, setBuyingId ] = useState<string | null>(null); + const [ activeTab, setActiveTab ] = useState<"prestige" | "shop">("prestige"); + + if (state === null) { + return ( + <section className="panel"> + <p>{"Loading..."}</p> + </section> + ); + } + + const { prestige: prestigeData, player } = state; + const threshold = calculateThreshold(prestigeData.count); + const isEligible = player.totalGoldEarned >= threshold; + const runestonePreview = calculateRunestonePreview( + player.totalGoldEarned, + prestigeData.count, + prestigeData.purchasedUpgradeIds, + ); + const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1); + + async function handlePrestige(): Promise<void> { + setIsPending(true); + setPrestigeError(null); + try { + const data = await prestige({}); + setResult({ + count: data.newPrestigeCount, + milestoneRunestones: data.milestoneRunestones, + runestones: data.runestones, + }); + await reload(); + } catch (error_: unknown) { + setPrestigeError( + error_ instanceof Error + ? error_.message + : "Prestige failed", + ); + } finally { + setIsPending(false); + } + } + + async function handleBuyUpgrade(upgradeId: string): Promise<void> { + setBuyingId(upgradeId); + try { + await buyPrestigeUpgrade(upgradeId); + } finally { + setBuyingId(null); + } + } + + const upgradesByCategory = categoryOrder.map((categoryId) => { + const label = PRESTIGE_UPGRADE_CATEGORY_LABELS[categoryId] ?? categoryId; + const upgrades = PRESTIGE_UPGRADES.filter((upgrade) => { + return upgrade.category === categoryId; + }); + return { categoryId, label, upgrades }; + }); + + function handlePrestigeClick(): void { + void handlePrestige(); + } + + function handleAutoPrestigeToggle(): void { + toggleAutoPrestige(); + } + + function handlePrestigeTabClick(): void { + setActiveTab("prestige"); + } + + function handleShopTabClick(): void { + setActiveTab("shop"); + } + + const progressRatio = player.totalGoldEarned / threshold; + const progressPct = (progressRatio * 100).toFixed(1); + + return ( + <section className="panel prestige-panel"> + <h2>{"⭐ Prestige"}</h2> + + <div className="prestige-tabs"> + <button + className={`prestige-tab ${activeTab === "prestige" + ? "active" + : ""}`} + onClick={handlePrestigeTabClick} + type="button" + > + {"Ascend"} + </button> + <button + className={`prestige-tab ${activeTab === "shop" + ? "active" + : ""}`} + onClick={handleShopTabClick} + type="button" + > + {"🔮 Runestone Shop ("} + {formatNumber(prestigeData.runestones)} + {" stones)"} + </button> + </div> + + {activeTab === "prestige" + && <> + <p> + {"Prestige resets your progress but grants "} + <strong>{"Runestones"}</strong> + {"— permanent currency used for powerful upgrades."} + {" Each prestige multiplies your global production by ×1.15"} + {" (compounding each run)."} + </p> + + <div className="prestige-status"> + <p> + {"Total gold this run: "} + <strong>{formatNumber(player.totalGoldEarned)}</strong> + </p> + <p> + {"Required to prestige: "} + <strong>{formatNumber(threshold)}</strong> + </p> + <p> + {"Prestige count: "} + <strong>{prestigeData.count}</strong> + </p> + <p> + {"Current production multiplier: "} + <strong> + {"×"} + {prestigeData.productionMultiplier.toFixed(2)} + </strong> + </p> + <p> + {"After next prestige: "} + <strong> + {"×"} + {nextMultiplier.toFixed(2)} + </strong> + </p> + <p> + {"Runestones: "} + <strong>{formatNumber(prestigeData.runestones)}</strong> + </p> + {isEligible + ? <p className="runestone-preview"> + {"Runestones on prestige: "} + <strong> + {"+"} + {formatNumber(runestonePreview)} + </strong> + </p> + : null} + {isEligible + ? null + : <p className="prestige-progress"> + {"Progress: "} + {formatNumber(player.totalGoldEarned)} + {" / "} + {formatNumber(threshold)} + {" ("} + {progressPct} + {"%"} + {")"} + </p> + } + </div> + + {isEligible + ? <div className="prestige-form"> + <p>{"You are ready to prestige!"}</p> + <button + className="prestige-button" + disabled={isPending} + onClick={handlePrestigeClick} + type="button" + > + {isPending + ? "Ascending..." + : `✨ Ascend (+${formatNumber(runestonePreview)} Runestones)`} + </button> + {prestigeError === null + ? null + : <p className="error">{prestigeError}</p> + } + {result === null + ? null + : <p className="success"> + {"Ascended to Prestige "} + {result.count} + {"! Earned "} + {formatNumber(result.runestones)} + {" Runestones."} + {result.milestoneRunestones > 0 + && <> + {" 🎉 Milestone bonus: +"} + {formatNumber(result.milestoneRunestones)} + {" Runestones!"} + </> + } + </p> + } + </div> + : <p className="prestige-locked"> + {"Earn "} + {formatNumber(threshold - player.totalGoldEarned)} + {" more gold to unlock prestige."} + </p> + } + </> + } + + {activeTab === "shop" + && <div className="runestone-shop"> + <p className="shop-balance"> + {"Balance: "} + <strong> + {formatNumber(prestigeData.runestones)} + {" Runestones"} + </strong> + </p> + + {upgradesByCategory.map(({ categoryId, label, upgrades }) => { + return ( + <div className="shop-category" key={categoryId}> + <h3>{label}</h3> + <div className="shop-upgrades"> + {upgrades.map((upgrade) => { + const purchased = prestigeData.purchasedUpgradeIds.includes( + upgrade.id, + ); + const canAfford + = prestigeData.runestones >= upgrade.runestonesCost; + const isLoading = buyingId === upgrade.id; + + const isAutoPrestigeToggle + = upgrade.id === "auto_prestige" && purchased; + const autoPrestigeEnabled + = prestigeData.autoPrestigeEnabled ?? false; + + function handleBuyClick(): void { + void handleBuyUpgrade(upgrade.id); + } + + return ( + <div + className={`shop-upgrade-card ${ + purchased + ? "purchased" + : "" + } ${!canAfford && !purchased + ? "unaffordable" + : ""}`} + key={upgrade.id} + > + <div className="shop-upgrade-info"> + <h4>{upgrade.name}</h4> + <p>{upgrade.description}</p> + <p className="upgrade-cost"> + {purchased + ? "✅ Purchased" + : `🔮 ${formatNumber(upgrade.runestonesCost)} Runestones`} + </p> + </div> + {isAutoPrestigeToggle + ? <button + className={`auto-prestige-toggle ${ + autoPrestigeEnabled + ? "enabled" + : "disabled" + }`} + onClick={handleAutoPrestigeToggle} + type="button" + > + {autoPrestigeEnabled + ? "⚡ Auto ON" + : "⏸ Auto OFF"} + </button> + : null} + {purchased + ? null + : <button + className="buy-upgrade-button" + disabled={ + !canAfford || isLoading || buyingId !== null + } + onClick={handleBuyClick} + type="button" + > + {isLoading + ? "Buying..." + : "Buy"} + </button> + } + </div> + ); + })} + </div> + </div> + ); + })} + </div> + } + </section> + ); +}; + +export { PrestigePanel }; diff --git a/apps/web/src/components/game/profilePage.tsx b/apps/web/src/components/game/profilePage.tsx new file mode 100644 index 0000000..8c1e834 --- /dev/null +++ b/apps/web/src/components/game/profilePage.tsx @@ -0,0 +1,293 @@ +/** + * @file Profile page component displaying a player's public profile. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Complex component with many render paths */ +/* eslint-disable complexity -- Many conditional stat visibility checks */ +import { useEffect, useState, type JSX } from "react"; +import { formatNumber } from "../../utils/format.js"; +import type { PublicProfileResponse } from "@elysium/types"; + +interface ProfilePageProperties { + readonly discordId: string; +} + +interface StatEntry { + icon: string; + value: string; + label: string; + date: boolean; +} + +/** + * Renders the public profile page for a given player. + * @param props - The profile page properties. + * @param props.discordId - The Discord ID of the player to display. + * @returns The JSX element. + */ +const ProfilePage = ({ discordId }: ProfilePageProperties): JSX.Element => { + const [ profile, setProfile ] = useState<PublicProfileResponse | null>(null); + const [ error, setError ] = useState<string | null>(null); + const [ copied, setCopied ] = useState(false); + + useEffect(() => { + fetch(`/api/profile/${discordId}`). + then(async(response) => { + if (!response.ok) { + throw new Error("Player not found"); + } + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response cast + return await (response.json() as Promise<PublicProfileResponse>); + }). + then(setProfile). + catch((error_: unknown) => { + setError( + error_ instanceof Error + ? error_.message + : "Failed to load profile", + ); + }); + }, [ discordId ]); + + function handleCopy(): void { + void navigator.clipboard.writeText(window.location.href).then(() => { + setCopied(true); + setTimeout(() => { + setCopied(false); + }, 2000); + }); + } + + if (error !== null) { + return ( + <div className="profile-page"> + <div className="profile-error"> + <p> + {"⚠️ "} + {error} + </p> + <a className="profile-play-link" href="/"> + {"← Play Elysium"} + </a> + </div> + </div> + ); + } + + if (profile === null) { + return ( + <div className="profile-page"> + <div className="profile-loading">{"Loading profile…"}</div> + </div> + ); + } + + const settings = profile.profileSettings; + function fmt(value: number): string { + return formatNumber(value, settings.numberFormat); + } + + const avatarUrl + = profile.avatar === null + ? `https://cdn.discordapp.com/embed/avatars/${String(Number.parseInt(discordId, 10) % 5)}.png` + : `https://cdn.discordapp.com/avatars/${discordId}/${profile.avatar}.png?size=128`; + + const memberSince = new Date(profile.createdAt).toLocaleDateString("en-GB", { + day: "numeric", + month: "long", + year: "numeric", + }); + + const currentRunStatsRaw: Array<StatEntry | false> = [ + settings.showCurrentGold && { + date: false, + icon: "🪙", + label: "Gold Earned", + value: fmt(profile.currentRunGold), + }, + settings.showCurrentClicks && { + date: false, + icon: "👆", + label: "Clicks", + value: fmt(profile.currentRunClicks), + }, + settings.showBossesDefeated && { + date: false, + icon: "💀", + label: "Bosses Defeated", + value: String(profile.bossesDefeated), + }, + settings.showQuestsCompleted && { + date: false, + icon: "📜", + label: "Quests Completed", + value: String(profile.questsCompleted), + }, + settings.showAdventurersRecruited && { + date: false, + icon: "⚔️", + label: "Adventurers Recruited", + value: fmt(profile.adventurersRecruited), + }, + settings.showAchievementsUnlocked && { + date: false, + icon: "🏆", + label: "Achievements Unlocked", + value: String(profile.achievementsUnlocked), + }, + ]; + const currentRunStats = currentRunStatsRaw.filter( + (entry): entry is StatEntry => { + return entry !== false; + }, + ); + + const allTimeStatsRaw: Array<StatEntry | false> = [ + settings.showTotalGold && { + date: false, + icon: "🪙", + label: "Total Gold Earned", + value: fmt(profile.totalGoldEarned), + }, + settings.showTotalClicks && { + date: false, + icon: "👆", + label: "Total Clicks", + value: fmt(profile.totalClicks), + }, + settings.showLifetimeBossesDefeated && { + date: false, + icon: "💀", + label: "Bosses Defeated", + value: String(profile.lifetimeBossesDefeated), + }, + settings.showLifetimeQuestsCompleted && { + date: false, + icon: "📜", + label: "Quests Completed", + value: String(profile.lifetimeQuestsCompleted), + }, + settings.showLifetimeAdventurersRecruited && { + date: false, + icon: "⚔️", + label: "Adventurers Recruited", + value: fmt(profile.lifetimeAdventurersRecruited), + }, + settings.showLifetimeAchievementsUnlocked && { + date: false, + icon: "🏆", + label: "Achievements Unlocked", + value: String(profile.lifetimeAchievementsUnlocked), + }, + settings.showGuildFounded && { + date: true, + icon: "📅", + label: "Guild Founded", + value: memberSince, + }, + ]; + const allTimeStats = allTimeStatsRaw.filter((entry): entry is StatEntry => { + return entry !== false; + }); + + function renderStats(stats: Array<StatEntry>): JSX.Element { + return ( + <div className="profile-stats"> + {stats.map((stat) => { + return ( + <div className="profile-stat" key={stat.label}> + <span className="profile-stat-icon">{stat.icon}</span> + <span + className={`profile-stat-value ${ + stat.date + ? "profile-stat-date" + : "" + }`} + > + {stat.value} + </span> + <span className="profile-stat-label">{stat.label}</span> + </div> + ); + })} + </div> + ); + } + + return ( + <div className="profile-page"> + <div className="profile-card"> + <div className="profile-header"> + <img + alt={`${profile.username}'s avatar`} + className="profile-avatar" + src={avatarUrl} + /> + <div className="profile-identity"> + <h1 className="profile-character-name">{profile.characterName}</h1> + <p className="profile-username"> + {"@"} + {profile.username} + </p> + {settings.showApotheosis && profile.apotheosisCount > 0 + ? <span className="profile-apotheosis-badge"> + {"✨ Apotheosis "} + {profile.apotheosisCount} + </span> + : null} + {settings.showTranscendence && profile.transcendenceCount > 0 + ? <span className="profile-transcendence-badge"> + {"🌌 Transcendence "} + {profile.transcendenceCount} + </span> + : null} + {settings.showPrestige && profile.prestigeCount > 0 + ? <span className="profile-prestige-badge"> + {"⭐ Prestige "} + {profile.prestigeCount} + </span> + : null} + </div> + </div> + + {profile.bio === "" + ? null + : <p className="profile-bio">{profile.bio}</p> + } + + {currentRunStats.length > 0 + && <div className="profile-stats-section"> + <h3 className="profile-stats-heading">{"Current Run"}</h3> + {renderStats(currentRunStats)} + </div> + } + + {allTimeStats.length > 0 + && <div className="profile-stats-section"> + <h3 className="profile-stats-heading">{"All Time"}</h3> + {renderStats(allTimeStats)} + </div> + } + + <div className="profile-actions"> + <button + className="profile-share-button" + onClick={handleCopy} + type="button" + > + {copied + ? "✓ Copied!" + : "🔗 Copy Profile Link"} + </button> + <a className="profile-play-link" href="/"> + {"⚔️ Play Elysium"} + </a> + </div> + </div> + </div> + ); +}; + +export { ProfilePage }; diff --git a/apps/web/src/components/game/questPanel.tsx b/apps/web/src/components/game/questPanel.tsx new file mode 100644 index 0000000..282b0e0 --- /dev/null +++ b/apps/web/src/components/game/questPanel.tsx @@ -0,0 +1,306 @@ +/** + * @file Quest panel component for managing and completing quests. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable react/no-multi-comp -- QuestCard sub-component is tightly coupled */ +/* eslint-disable max-lines-per-function -- Complex component with many render paths */ +/* eslint-disable complexity -- Many conditional render paths */ +/* eslint-disable max-statements -- Many local variables needed for quest state */ +import { useState, type JSX } from "react"; +import { useGame } from "../../context/gameContext.js"; +import { LockToggle } from "../ui/lockToggle.js"; +import { ZoneSelector } from "./zoneSelector.js"; +import type { Quest } from "@elysium/types"; + +/** + * Formats a duration in seconds to a human-readable string. + * @param seconds - The total number of seconds to format. + * @returns The formatted duration string. + */ +const formatDuration = (seconds: number): string => { + const secondsPerHour = 3600; + const secondsPerMinute = 60; + if (seconds >= secondsPerHour) { + const hours = Math.floor(seconds / secondsPerHour); + const remainderSeconds = seconds % secondsPerHour; + const minutes = Math.floor(remainderSeconds / secondsPerMinute); + return `${String(hours)}h ${String(minutes)}m`; + } + if (seconds >= secondsPerMinute) { + const minutes = Math.floor(seconds / secondsPerMinute); + const secs = seconds % secondsPerMinute; + return `${String(minutes)}m ${String(secs)}s`; + } + return `${String(seconds)}s`; +}; + +/** + * Computes the time remaining for an active quest. + * @param quest - The quest to check. + * @returns The remaining seconds. + */ +const questTimeRemaining = (quest: Quest): number => { + if (quest.status !== "active" || quest.startedAt === undefined) { + return 0; + } + const elapsed = (Date.now() - quest.startedAt) / 1000; + return Math.max(0, quest.durationSeconds - elapsed); +}; + +interface QuestCardProperties { + readonly quest: Quest; + readonly partyCombatPower: number; + readonly unlockHint: string | undefined; + readonly zoneHint: string | undefined; +} + +/** + * Renders a single quest card. + * @param props - The quest card properties. + * @param props.quest - The quest to display. + * @param props.partyCombatPower - The current party's combat power. + * @param props.unlockHint - Optional hint for how to unlock this quest. + * @param props.zoneHint - Optional hint for which zone to unlock. + * @returns The JSX element. + */ +const QuestCard = ({ + quest, + partyCombatPower, + unlockHint, + zoneHint, +}: QuestCardProperties): JSX.Element => { + const { startQuest, formatNumber } = useGame(); + const cpRequired = quest.combatPowerRequired ?? 0; + const meetsCP = partyCombatPower >= cpRequired; + + function handleStartQuest(): void { + startQuest(quest.id); + } + + return ( + <div className={`quest-card quest-${quest.status}`}> + <div className="quest-info"> + <h3>{quest.name}</h3> + <p>{quest.description}</p> + {cpRequired > 0 + && <p + className={`quest-cp-requirement ${ + meetsCP + ? "cp-met" + : "cp-unmet" + }`} + > + {"⚔️ Requires "} + {formatNumber(cpRequired)} + {" Combat Power"} + {quest.status === "available" + && (meetsCP + ? " ✓" + : ` (you have ${formatNumber(partyCombatPower)})`)} + </p> + } + <div className="quest-rewards"> + {quest.rewards.map((reward) => { + return ( + <span className="reward-tag" key={`${reward.type}-${String(reward.amount ?? "")}`}> + {reward.type === "gold" + && `🪙 ${formatNumber(reward.amount ?? 0)}`} + {reward.type === "essence" + && `✨ ${formatNumber(reward.amount ?? 0)}`} + {reward.type === "crystals" + && `💎 ${formatNumber(reward.amount ?? 0)}`} + {reward.type === "upgrade" && "🔓 Upgrade"} + {reward.type === "adventurer" && "👥 New Adventurer"} + </span> + ); + })} + </div> + </div> + <div className="quest-action"> + {quest.status === "locked" + && <> + <span className="quest-badge locked">{"🔒 Locked"}</span> + {zoneHint === undefined + ? null + : <p className="unlock-hint"> + {"🗺️ Unlock zone: "} + {zoneHint} + </p> + } + {zoneHint === undefined && unlockHint !== undefined + ? <p className="unlock-hint"> + {"📜 Complete: "} + {unlockHint} + </p> + : null} + </> + } + {quest.status === "available" && quest.lastFailedAt !== undefined + && <p className="quest-failed-hint">{"⚠️ Last attempt failed"}</p> + } + {quest.status === "available" + && <button + className="start-quest-button" + disabled={!meetsCP} + onClick={handleStartQuest} + title={ + meetsCP + ? undefined + : `Need ${formatNumber(cpRequired)} combat power` + } + type="button" + > + {"Send Party ("} + {formatDuration(quest.durationSeconds)} + {")"} + </button> + } + {quest.status === "active" + && <span className="quest-badge active"> + {"⏳ "} + {formatDuration(Math.ceil(questTimeRemaining(quest)))} + {" remaining"} + </span> + } + {quest.status === "completed" + && <span className="quest-badge completed">{"✅ Complete"}</span> + } + </div> + </div> + ); +}; + +/** + * Renders the quest panel with zone selection and quest list. + * @returns The JSX element. + */ +const QuestPanel = (): JSX.Element => { + const { state, toggleAutoQuest } = useGame(); + const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale"); + const [ showLocked, setShowLocked ] = useState(true); + + if (state === null) { + return ( + <section className="panel"> + <p>{"Loading..."}</p> + </section> + ); + } + + const { adventurers, autoQuest, quests, zones } = state; + // eslint-disable-next-line unicorn/no-array-reduce -- Need the total! + const partyCombatPower = adventurers.reduce((total, adventurer) => { + const power = total + adventurer.combatPower; + return power * adventurer.count; + }, 0); + const zoneQuests = quests.filter(({ zoneId }) => { + return zoneId === activeZoneId; + }); + const lockedCount = zoneQuests.filter(({ status }) => { + return status === "locked"; + }).length; + const visibleQuests = showLocked + ? zoneQuests + : zoneQuests.filter(({ status }) => { + return status !== "locked"; + }); + + const questNameById = new Map( + quests.map(({ id, name }) => { + return [ id, name ]; + }), + ); + const zoneById = new Map( + zones.map((zone) => { + return [ zone.id, zone ]; + }), + ); + const questUnlockHints = new Map<string, string>(); + const questZoneHints = new Map<string, string>(); + for (const { id: questId, status, zoneId, prerequisiteIds } of quests) { + if (status !== "locked") { + continue; + } + const zone = zoneById.get(zoneId); + if (zone?.status === "locked") { + questZoneHints.set(questId, zone.name); + } else if (prerequisiteIds.length > 0) { + const [ prereqId ] = prerequisiteIds; + if (prereqId !== undefined) { + const prereqName = questNameById.get(prereqId); + if (prereqName !== undefined) { + questUnlockHints.set(questId, prereqName); + } + } + } + } + + function handleToggle(): void { + setShowLocked((current) => { + return !current; + }); + } + + function handleAutoQuest(): void { + toggleAutoQuest(); + } + + const autoQuestOn = autoQuest === true; + + return ( + <section className="panel quest-panel"> + <div className="panel-header"> + <h2>{"Quests"}</h2> + <div className="panel-header-controls"> + <button + className={`auto-toggle-btn ${ + autoQuestOn + ? "auto-toggle-on" + : "auto-toggle-off" + }`} + onClick={handleAutoQuest} + title="Automatically send the party on the highest available quest" + type="button" + > + {"🤖 Auto: "} + {autoQuestOn + ? "ON" + : "OFF"} + </button> + <LockToggle + lockedCount={lockedCount} + onToggle={handleToggle} + showLocked={showLocked} + /> + </div> + </div> + + <ZoneSelector + activeZoneId={activeZoneId} + onSelectZone={setActiveZoneId} + zones={zones} + /> + + <div className="quest-list"> + {visibleQuests.map((quest) => { + return ( + <QuestCard + key={quest.id} + partyCombatPower={partyCombatPower} + quest={quest} + unlockHint={questUnlockHints.get(quest.id)} + zoneHint={questZoneHints.get(quest.id)} + /> + ); + })} + {visibleQuests.length === 0 + && <p className="empty-zone">{"No quests to show in this zone."}</p> + } + </div> + </section> + ); +}; + +export { QuestPanel }; diff --git a/apps/web/src/components/game/statisticsPanel.tsx b/apps/web/src/components/game/statisticsPanel.tsx new file mode 100644 index 0000000..97ee835 --- /dev/null +++ b/apps/web/src/components/game/statisticsPanel.tsx @@ -0,0 +1,212 @@ +/** + * @file Statistics panel component showing player progress and all-time stats. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */ +/* eslint-disable max-lines-per-function -- Complex component with many render paths */ +/* eslint-disable react/require-default-props -- TypeScript optional props with default parameters are sufficient */ +import { useGame } from "../../context/gameContext.js"; +import { PRESTIGE_UPGRADES } from "../../data/prestigeUpgrades.js"; +import type { JSX } from "react"; + +const formatDate = (timestamp: number): string => { + return new Date(timestamp).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }); +}; + +interface StatCardProperties { + readonly icon: string; + readonly label: string; + readonly value: string; + readonly sub?: string | undefined; +} + +/** + * Renders a single statistic card. + * @param props - The stat card properties. + * @param props.icon - The icon to display. + * @param props.label - The label for the stat. + * @param props.value - The value to display. + * @param props.sub - Optional sub-label. + * @returns The JSX element. + */ +const StatCard = ({ + icon, + label, + value, + sub = undefined, +}: StatCardProperties): JSX.Element => { + return ( + <div className="profile-stat"> + <span className="profile-stat-icon">{icon}</span> + <span className="profile-stat-value">{value}</span> + <span className="profile-stat-label">{label}</span> + {sub === undefined + ? null + : <span className="profile-stat-date">{sub}</span> + } + </div> + ); +}; + +/** + * Renders the statistics panel with player progress and all-time stats. + * @returns The JSX element. + */ +const StatisticsPanel = (): JSX.Element => { + const { state, formatNumber } = useGame(); + + if (state === null) { + return ( + <section className="panel"> + <p>{"Loading..."}</p> + </section> + ); + } + + const { + player, + resources, + prestige, + bosses, + quests, + zones, + adventurers, + upgrades, + equipment, + achievements, + } = state; + + const bossesDefeated = bosses.filter((boss) => { + return boss.status === "defeated"; + }).length; + const questsCompleted = quests.filter((quest) => { + return quest.status === "completed"; + }).length; + const zonesUnlocked = zones.filter((zone) => { + return zone.status === "unlocked"; + }).length; + const adventurersRecruited = adventurers.reduce((sum, adventurer) => { + return sum + adventurer.count; + }, 0); + const equipmentOwned = equipment.filter((item) => { + return item.owned; + }).length; + const upgradesPurchased = upgrades.filter((upgrade) => { + return upgrade.purchased; + }).length; + const achievementsUnlocked = achievements.filter((achievement) => { + return achievement.unlockedAt !== null; + }).length; + const prestigeUpgradesPurchased = prestige.purchasedUpgradeIds.length; + + return ( + <section className="panel statistics-panel"> + <h2>{"📊 Statistics"}</h2> + + <h3 className="stats-section-header">{"All-Time"}</h3> + <div className="profile-stats"> + <StatCard + icon="🪙" + label="Total Gold Earned" + sub="across all runs" + value={formatNumber(player.totalGoldEarned)} + /> + <StatCard + icon="👆" + label="Total Clicks" + value={formatNumber(player.totalClicks)} + /> + <StatCard icon="⭐" label="Prestiges" value={String(prestige.count)} /> + <StatCard + icon="📅" + label="Guild Founded" + value={formatDate(player.createdAt)} + /> + <StatCard + icon="☁️" + label="Last Cloud Save" + value={formatDate(player.lastSavedAt)} + /> + <StatCard + icon="✖️" + label="Production Multiplier" + sub="from prestige" + value={`×${prestige.productionMultiplier.toFixed(2)}`} + /> + </div> + + <h3 className="stats-section-header">{"Current Run"}</h3> + <div className="profile-stats"> + <StatCard icon="🪙" label="Gold" value={formatNumber(resources.gold)} /> + <StatCard + icon="✨" + label="Essence" + value={formatNumber(resources.essence)} + /> + <StatCard + icon="💎" + label="Crystals" + value={formatNumber(resources.crystals)} + /> + <StatCard + icon="🔮" + label="Runestones" + sub="permanent currency" + value={formatNumber(prestige.runestones)} + /> + </div> + + <h3 className="stats-section-header">{"Progress"}</h3> + <div className="profile-stats"> + <StatCard + icon="👹" + label="Bosses Defeated" + value={`${String(bossesDefeated)} / ${String(bosses.length)}`} + /> + <StatCard + icon="📜" + label="Quests Completed" + value={`${String(questsCompleted)} / ${String(quests.length)}`} + /> + <StatCard + icon="🗺️" + label="Zones Unlocked" + value={`${String(zonesUnlocked)} / ${String(zones.length)}`} + /> + <StatCard + icon="⚔️" + label="Adventurers Recruited" + value={formatNumber(adventurersRecruited)} + /> + <StatCard + icon="🗡️" + label="Equipment Owned" + value={`${String(equipmentOwned)} / ${String(equipment.length)}`} + /> + <StatCard + icon="🔧" + label="Upgrades Purchased" + value={`${String(upgradesPurchased)} / ${String(upgrades.length)}`} + /> + <StatCard + icon="🏆" + label="Achievements" + value={`${String(achievementsUnlocked)} / ${String(achievements.length)}`} + /> + <StatCard + icon="🔮" + label="Prestige Upgrades" + value={`${String(prestigeUpgradesPurchased)} / ${String(PRESTIGE_UPGRADES.length)}`} + /> + </div> + </section> + ); +}; + +export { StatisticsPanel }; diff --git a/apps/web/src/components/game/storyPanel.tsx b/apps/web/src/components/game/storyPanel.tsx new file mode 100644 index 0000000..e872f9b --- /dev/null +++ b/apps/web/src/components/game/storyPanel.tsx @@ -0,0 +1,179 @@ +/** + * @file Story panel component displaying the main questline narrative. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Complex component with many render paths */ +/* eslint-disable complexity -- Complex component with many conditional render paths */ +import { STORY_CHAPTERS } from "@elysium/types"; +import { type JSX, useState } from "react"; +import { useGame } from "../../context/gameContext.js"; + +/** + * Substitutes the character name placeholder in story text. + * @param text - The story text with placeholders. + * @param characterName - The player's character name. + * @returns The text with placeholders replaced. + */ +const substituteCharacterName = ( + text: string, + characterName: string, +): string => { + const fallback = characterName === "" + ? "the guild leader" + : characterName; + return text.replaceAll("{characterName}", fallback); +}; + +/** + * Renders the story panel with chapter navigation and content. + * @returns The JSX element. + */ +const StoryPanel = (): JSX.Element => { + const { state, completeChapter } = useGame(); + const [ activeChapterIndex, setActiveChapterIndex ] = useState(0); + + if (state === null) { + return ( + <div className="story-panel"> + <p>{"Loading…"}</p> + </div> + ); + } + + const unlockedIds = state.story?.unlockedChapterIds ?? []; + const completedChapters = state.story?.completedChapters ?? []; + const { characterName } = state.player; + + const activeChapter = STORY_CHAPTERS[activeChapterIndex]; + const isUnlocked = unlockedIds.includes(activeChapter?.id ?? ""); + const completion + = activeChapter === undefined + ? null + : completedChapters.find((completedChapter) => { + return completedChapter.chapterId === activeChapter.id; + }) ?? null; + const isUnread = isUnlocked && completion === null; + + return ( + <div className="story-panel"> + <div className="story-chapter-tabs"> + {STORY_CHAPTERS.map((chapter, index) => { + const unlocked = unlockedIds.includes(chapter.id); + const completed = completedChapters.some((completedChapter) => { + return completedChapter.chapterId === chapter.id; + }); + const unread = unlocked && !completed; + function handleChapterSelect(): void { + setActiveChapterIndex(index); + } + return ( + <button + aria-label={ + unlocked + ? chapter.title + : `Chapter ${String(index + 1)} (locked)` + } + className={[ + "story-tab-btn", + activeChapterIndex === index + ? "active" + : "", + unlocked + ? "" + : "locked", + ].join(" ")} + key={chapter.id} + onClick={handleChapterSelect} + type="button" + > + {index + 1} + {unread + ? <span className="story-unread-dot" /> + : null} + </button> + ); + })} + </div> + + {activeChapter === undefined + ? null + : <div className="story-chapter-view"> + {isUnlocked + ? <> + <h2 className="story-chapter-title"> + {"Chapter "} + {activeChapterIndex + 1} + {": "} + {activeChapter.title} + </h2> + <div className="story-chapter-content"> + {substituteCharacterName(activeChapter.content, characterName). + split("\n\n"). + map((paragraph, paraIndex) => { + // eslint-disable-next-line react/no-array-index-key -- Static content paragraphs have no stable id + return <p key={paraIndex}>{paragraph}</p>; + })} + </div> + + {completion === null && isUnread + ? <div className="story-choices"> + <p className="story-choices-prompt">{"What do you do?"}</p> + {activeChapter.choices.map((storyChoice) => { + const chapterForClosure = activeChapter; + function handleChoice(): void { + completeChapter(chapterForClosure.id, storyChoice.id); + } + return ( + <button + className="story-choice-btn" + key={storyChoice.id} + onClick={handleChoice} + type="button" + > + {storyChoice.label} + </button> + ); + })} + </div> + : null} + {completion === null + ? null + : <div className="story-choice-result"> + <p className="story-choice-label"> + <strong>{"Your choice:"}</strong>{" "} + { + activeChapter.choices.find((storyChoice) => { + return storyChoice.id === completion.choiceId; + })?.label + } + </p> + <p className="story-choice-outcome"> + {substituteCharacterName( + activeChapter.choices.find((storyChoice) => { + return storyChoice.id === completion.choiceId; + })?.outcome ?? "", + characterName, + )} + </p> + </div> + } + </> + : <div className="story-locked"> + <p className="story-locked-title"> + {"Chapter "} + {activeChapterIndex + 1} + </p> + <p className="story-locked-hint"> + {"🔒 This chapter has not yet been unlocked."} + </p> + </div> + } + </div> + } + </div> + ); +}; + +export { StoryPanel }; diff --git a/apps/web/src/components/game/storyToast.tsx b/apps/web/src/components/game/storyToast.tsx new file mode 100644 index 0000000..e33750b --- /dev/null +++ b/apps/web/src/components/game/storyToast.tsx @@ -0,0 +1,76 @@ +/** + * @file Story toast notification component for new chapter unlocks. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the toast container */ +import { STORY_CHAPTERS } from "@elysium/types"; +import { type JSX, useEffect } from "react"; +import { useGame } from "../../context/gameContext.js"; + +interface StoryToastItemProperties { + readonly chapterId: string; +} + +/** + * Renders a single story chapter toast notification. + * @param props - The toast item properties. + * @param props.chapterId - The chapter ID to display. + * @returns The JSX element or null if chapter is not found. + */ +const StoryToastItem = ({ + chapterId, +}: StoryToastItemProperties): JSX.Element | null => { + const { dismissStoryChapter } = useGame(); + const chapter = STORY_CHAPTERS.find((storyChapter) => { + return storyChapter.id === chapterId; + }); + + useEffect(() => { + const timer = setTimeout(() => { + dismissStoryChapter(chapterId); + }, 4000); + return (): void => { + clearTimeout(timer); + }; + }, [ chapterId, dismissStoryChapter ]); + + if (chapter === undefined) { + return null; + } + + function handleClick(): void { + dismissStoryChapter(chapterId); + } + + return ( + <button className="achievement-toast" onClick={handleClick} type="button"> + <span className="achievement-toast-icon">{"📖"}</span> + <div className="achievement-toast-content"> + <span className="achievement-toast-label">{"✨ New Chapter!"}</span> + <span className="achievement-toast-name">{chapter.title}</span> + </div> + </button> + ); +}; + +/** + * Renders the story toast container with pending chapter notifications. + * @returns The JSX element or null if there are no pending chapters. + */ +const StoryToast = (): JSX.Element | null => { + const { unlockedStoryChapterIds: pendingChapterIds } = useGame(); + if (pendingChapterIds.length === 0) { + return null; + } + return ( + <div className="achievement-toast-container"> + {pendingChapterIds.map((id) => { + return <StoryToastItem chapterId={id} key={id} />; + })} + </div> + ); +}; + +export { StoryToast }; diff --git a/apps/web/src/components/game/transcendencePanel.tsx b/apps/web/src/components/game/transcendencePanel.tsx new file mode 100644 index 0000000..aaa362e --- /dev/null +++ b/apps/web/src/components/game/transcendencePanel.tsx @@ -0,0 +1,341 @@ +/** + * @file Transcendence panel component for the second prestige layer. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Complex component with many render paths */ +/* eslint-disable complexity -- Many conditional render paths */ +/* eslint-disable max-statements -- Transcendence panel manages many local state variables */ +import { useState, type JSX } from "react"; +import { useGame } from "../../context/gameContext.js"; +import { + TRANSCENDENCE_UPGRADES, + TRANSCENDENCE_UPGRADE_CATEGORY_LABELS, +} from "../../data/transcendenceUpgrades.js"; +import type { TranscendenceUpgradeCategory } from "@elysium/types"; + +const echoFormulaConstant = 853; +const finalBossId = "the_absolute_one"; + +/** + * Calculates the echo preview for a transcendence. + * @param prestigeCount - The current prestige count. + * @param echoMetaMultiplier - The echo meta multiplier from upgrades. + * @returns The predicted echo reward. + */ +const calculateEchoPreview = ( + prestigeCount: number, + echoMetaMultiplier: number, +): number => { + const safeCount = Math.max(prestigeCount, 1); + return Math.floor( + // eslint-disable-next-line stylistic/no-extra-parens -- Required by no-mixed-operators rule + (echoFormulaConstant / Math.sqrt(safeCount)) * echoMetaMultiplier, + ); +}; + +const categoryOrder: Array<TranscendenceUpgradeCategory> = [ + "income", + "combat", + "prestige_threshold", + "prestige_runestones", + "echo_meta", +]; + +/** + * Renders the transcendence panel with transcendence and echo shop tabs. + * @returns The JSX element. + */ +const TranscendencePanel = (): JSX.Element => { + const { state, formatNumber, transcend, buyEchoUpgrade } = useGame(); + const [ isPending, setIsPending ] = useState(false); + const [ result, setResult ] = useState<{ + echoes: number; + count: number; + } | null>(null); + const [ error, setError ] = useState<string | null>(null); + const [ buyingId, setBuyingId ] = useState<string | null>(null); + type TranscendTab = "transcend" | "shop"; + const [ activeTab, setActiveTab ] = useState<TranscendTab>("transcend"); + + if (state === null) { + return ( + <section className="panel"> + <p>{"Loading..."}</p> + </section> + ); + } + + const { bosses, prestige: prestigeData, transcendence } = state; + const hasDefeatedFinalBoss = bosses.some((boss) => { + return boss.id === finalBossId && boss.status === "defeated"; + }); + const echoMetaMultiplier = transcendence?.echoMetaMultiplier ?? 1; + const echoPreview = calculateEchoPreview( + prestigeData.count, + echoMetaMultiplier, + ); + const currentEchoes = transcendence?.echoes ?? 0; + const transcendenceCount = transcendence?.count ?? 0; + + async function handleTranscend(): Promise<void> { + setIsPending(true); + setError(null); + try { + const data = await transcend(); + setResult({ count: data.newTranscendenceCount, echoes: data.echoes }); + } catch (error_: unknown) { + setError( + error_ instanceof Error + ? error_.message + : "Transcendence failed", + ); + } finally { + setIsPending(false); + } + } + + async function handleBuyUpgrade(upgradeId: string): Promise<void> { + setBuyingId(upgradeId); + try { + await buyEchoUpgrade(upgradeId); + } finally { + setBuyingId(null); + } + } + + const upgradesByCategory = categoryOrder.map((catId) => { + const categoryLabels = TRANSCENDENCE_UPGRADE_CATEGORY_LABELS; + const label = categoryLabels[catId] ?? catId; + const upgrades = TRANSCENDENCE_UPGRADES.filter((upgrade) => { + return upgrade.category === catId; + }); + return { catId, label, upgrades }; + }); + + function handleTranscendClick(): void { + void handleTranscend(); + } + + function handleTranscendTabClick(): void { + setActiveTab("transcend"); + } + + function handleShopTabClick(): void { + setActiveTab("shop"); + } + + return ( + <section className="panel transcendence-panel"> + <h2>{"🌌 Transcendence"}</h2> + + <div className="prestige-tabs"> + <button + className={`prestige-tab ${ + activeTab === "transcend" + ? "active" + : "" + }`} + onClick={handleTranscendTabClick} + type="button" + > + {"Transcend"} + </button> + <button + className={`prestige-tab ${activeTab === "shop" + ? "active" + : ""}`} + onClick={handleShopTabClick} + type="button" + > + {"✨ Echo Shop ("} + {formatNumber(currentEchoes)} + {" echoes)"} + </button> + </div> + + {activeTab === "transcend" + && <> + <p className="transcendence-intro"> + {"Transcendence is the ultimate reset. It wipes "} + <strong>{"everything"}</strong> + {" — resources, prestige, runestones, upgrades, and equipment" + + " — but grants "} + <strong>{"Echoes"}</strong> + {", a permanent currency that survives all future resets."} + {" Echoes power upgrades that permanently amplify every run."} + </p> + <p className="transcendence-intro"> + <em> + {"Fewer prestiges = more Echoes."} + {" Optimise your run for maximum yield!"} + </em> + </p> + + <div className="transcendence-status"> + {transcendenceCount > 0 + && <p> + {"Transcendence count: "} + <strong>{transcendenceCount}</strong> + </p> + } + <p> + {"Current Echoes: "} + <strong>{formatNumber(currentEchoes)}</strong> + </p> + <p> + {"Current prestige count: "} + <strong>{prestigeData.count}</strong> + </p> + {hasDefeatedFinalBoss + ? <p className="echo-preview"> + {"Echoes on transcendence: "} + <strong> + {"+"} + {formatNumber(echoPreview)} + </strong> + {echoMetaMultiplier > 1 + && <span className="echo-meta-bonus"> + {" (×"} + {echoMetaMultiplier.toFixed(2)} + {" meta bonus applied)"} + </span> + } + </p> + : null} + </div> + + {hasDefeatedFinalBoss + ? null + : <div className="transcendence-locked"> + <p> + {"🔒 "} + <strong>{"Defeat The Absolute One"}</strong> + {" to unlock transcendence."} + </p> + <p className="transcendence-hint"> + {"The Absolute One is the final boss of The Absolute zone," + + " requiring Prestige 90 to challenge."} + </p> + </div> + } + + {hasDefeatedFinalBoss + ? <div className="prestige-form"> + <p> + {"You are ready to transcend. This action is "} + <strong>{"irreversible"}</strong> + {"."} + </p> + <button + className="transcendence-button" + disabled={isPending} + onClick={handleTranscendClick} + type="button" + > + {isPending + ? "Transcending..." + : `🌌 Transcend (+${formatNumber(echoPreview)} Echoes)`} + </button> + {error === null + ? null + : <p className="error">{error}</p>} + {result === null + ? null + : <p className="success"> + {"Transcended! Earned "} + <strong> + {formatNumber(result.echoes)} + {" Echoes"} + </strong> + {". This is Transcendence "} + {result.count} + {". A new cycle begins."} + </p> + } + </div> + : null} + </> + } + + {activeTab === "shop" + && <div className="echo-shop"> + <p className="shop-balance"> + {"Balance: "} + <strong> + {formatNumber(currentEchoes)} + {" Echoes"} + </strong> + </p> + <p className="echo-shop-description"> + {"Echo upgrades are "} + <strong>{"permanent"}</strong> + {" — they survive all future prestiges and transcendences."} + </p> + + {upgradesByCategory.map(({ catId, label, upgrades }) => { + return ( + <div className="shop-category" key={catId}> + <h3>{label}</h3> + <div className="shop-upgrades"> + {upgrades.map((upgrade) => { + const purchased = ( + transcendence?.purchasedUpgradeIds ?? [] + ).includes(upgrade.id); + const canAfford = currentEchoes >= upgrade.cost; + const isLoading = buyingId === upgrade.id; + + function handleBuyClick(): void { + void handleBuyUpgrade(upgrade.id); + } + + return ( + <div + className={`shop-upgrade-card echo-upgrade-card ${ + purchased + ? "purchased" + : "" + } ${!canAfford && !purchased + ? "unaffordable" + : ""}`} + key={upgrade.id} + > + <div className="shop-upgrade-info"> + <h4>{upgrade.name}</h4> + <p>{upgrade.description}</p> + <p className="upgrade-cost"> + {purchased + ? "✅ Purchased" + : `✨ ${formatNumber(upgrade.cost)} Echoes`} + </p> + </div> + {purchased + ? null + : <button + className="buy-upgrade-button echo-buy-button" + disabled={ + !canAfford || isLoading || buyingId !== null + } + onClick={handleBuyClick} + type="button" + > + {isLoading + ? "Buying..." + : "Buy"} + </button> + } + </div> + ); + })} + </div> + </div> + ); + })} + </div> + } + </section> + ); +}; + +export { TranscendencePanel }; diff --git a/apps/web/src/components/game/upgradePanel.tsx b/apps/web/src/components/game/upgradePanel.tsx new file mode 100644 index 0000000..d004bc7 --- /dev/null +++ b/apps/web/src/components/game/upgradePanel.tsx @@ -0,0 +1,271 @@ +/** + * @file Upgrade panel component for purchasing game upgrades. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */ +/* eslint-disable max-lines-per-function -- Complex component with many render paths */ +/* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */ +import { type JSX, useState } from "react"; +import { useGame } from "../../context/gameContext.js"; +import { LockToggle } from "../ui/lockToggle.js"; +import type { Upgrade } from "@elysium/types"; + +interface UpgradeCardProperties { + readonly upgrade: Upgrade; + readonly currentGold: number; + readonly currentEssence: number; + readonly currentCrystals: number; + readonly unlockHint: string | undefined; + readonly formatNumber: (n: number)=> string; +} + +/** + * Renders a single upgrade card. + * @param props - The upgrade card properties. + * @param props.upgrade - The upgrade data. + * @param props.currentGold - The current gold amount. + * @param props.currentEssence - The current essence amount. + * @param props.currentCrystals - The current crystals amount. + * @param props.unlockHint - Optional hint for how to unlock this upgrade. + * @param props.formatNumber - The number formatting utility function. + * @returns The JSX element. + */ +const UpgradeCard = ({ + upgrade, + currentGold, + currentEssence, + currentCrystals, + unlockHint, + formatNumber, +}: UpgradeCardProperties): JSX.Element => { + const { buyUpgrade } = useGame(); + const canAfford + = currentGold >= upgrade.costGold + && currentEssence >= upgrade.costEssence + && currentCrystals >= upgrade.costCrystals; + + function handleBuy(): void { + buyUpgrade(upgrade.id); + } + + if (upgrade.unlocked && upgrade.purchased) { + return ( + <div className="upgrade-card purchased"> + <span className="upgrade-name"> + {"✅ "} + {upgrade.name} + </span> + <span className="upgrade-desc">{upgrade.description}</span> + </div> + ); + } + + if (upgrade.unlocked) { + return ( + <div className="upgrade-card"> + <div className="upgrade-info"> + <h3>{upgrade.name}</h3> + <p>{upgrade.description}</p> + <p className="upgrade-multiplier"> + {"×"} + {upgrade.multiplier} + {" multiplier"} + </p> + </div> + <div className="upgrade-cost"> + {upgrade.costGold > 0 + && <span> + {"🪙 "} + {formatNumber(upgrade.costGold)} + </span> + } + {upgrade.costEssence > 0 + && <span> + {"✨ "} + {formatNumber(upgrade.costEssence)} + </span> + } + {upgrade.costCrystals > 0 + && <span> + {"💎 "} + {formatNumber(upgrade.costCrystals)} + </span> + } + </div> + <button + className="buy-button" + disabled={!canAfford} + onClick={handleBuy} + type="button" + > + {"Buy"} + </button> + </div> + ); + } + + return ( + <div className="upgrade-card locked"> + <div className="upgrade-info"> + <h3> + {"🔒 "} + {upgrade.name} + </h3> + <p>{upgrade.description}</p> + <p className="upgrade-multiplier"> + {"×"} + {upgrade.multiplier} + {" multiplier"} + </p> + </div> + <div className="upgrade-cost"> + {upgrade.costGold > 0 + && <span> + {"🪙 "} + {formatNumber(upgrade.costGold)} + </span> + } + {upgrade.costEssence > 0 + && <span> + {"✨ "} + {formatNumber(upgrade.costEssence)} + </span> + } + {upgrade.costCrystals > 0 + && <span> + {"💎 "} + {formatNumber(upgrade.costCrystals)} + </span> + } + </div> + <span className="upgrade-locked-label">{"Locked"}</span> + {unlockHint === undefined + ? null + : <p className="unlock-hint">{unlockHint}</p> + } + </div> + ); +}; + +/** + * Renders the upgrade panel with all available, locked, and purchased upgrades. + * @returns The JSX element. + */ +const UpgradePanel = (): JSX.Element => { + const { state, formatNumber } = useGame(); + const [ showLocked, setShowLocked ] = useState(true); + + if (state === null) { + return ( + <section className="panel"> + <p>{"Loading..."}</p> + </section> + ); + } + + const { bosses, quests, upgrades, resources } = state; + const purchased = upgrades.filter((upgrade) => { + return upgrade.purchased; + }); + const available = upgrades.filter((upgrade) => { + return upgrade.unlocked && !upgrade.purchased; + }); + const locked = upgrades.filter((upgrade) => { + return !upgrade.unlocked; + }); + + const upgradeUnlockHints = new Map<string, string>(); + for (const { upgradeRewards, name: bossName } of bosses) { + for (const upgradeId of upgradeRewards) { + upgradeUnlockHints.set(upgradeId, `⚔️ Defeat: ${bossName}`); + } + } + for (const { rewards, name: questName } of quests) { + for (const reward of rewards) { + if ( + reward.type === "upgrade" + && reward.targetId !== undefined + && !upgradeUnlockHints.has(reward.targetId) + ) { + upgradeUnlockHints.set(reward.targetId, `📜 Complete: ${questName}`); + } + } + } + + function handleToggle(): void { + setShowLocked((current) => { + return !current; + }); + } + + return ( + <section className="panel upgrade-panel"> + <div className="panel-header"> + <h2>{"Upgrades"}</h2> + <LockToggle + lockedCount={locked.length} + onToggle={handleToggle} + showLocked={showLocked} + /> + </div> + <p className="upgrade-progress"> + {purchased.length} + {" / "} + {upgrades.length} + {" purchased"} + </p> + {upgrades.length === 0 + ? <p className="empty-state"> + {"No upgrades available yet — keep adventuring!"} + </p> + : <div className="upgrade-list"> + {available.map((upgrade) => { + return ( + <UpgradeCard + currentCrystals={resources.crystals} + currentEssence={resources.essence} + currentGold={resources.gold} + formatNumber={formatNumber} + key={upgrade.id} + unlockHint={undefined} + upgrade={upgrade} + /> + ); + })} + {purchased.map((upgrade) => { + return ( + <UpgradeCard + currentCrystals={resources.crystals} + currentEssence={resources.essence} + currentGold={resources.gold} + formatNumber={formatNumber} + key={upgrade.id} + unlockHint={undefined} + upgrade={upgrade} + /> + ); + })} + {showLocked + ? locked.map((upgrade) => { + return ( + <UpgradeCard + currentCrystals={resources.crystals} + currentEssence={resources.essence} + currentGold={resources.gold} + formatNumber={formatNumber} + key={upgrade.id} + unlockHint={upgradeUnlockHints.get(upgrade.id)} + upgrade={upgrade} + /> + ); + }) + : null} + </div> + } + </section> + ); +}; + +export { UpgradePanel }; diff --git a/apps/web/src/components/game/zoneSelector.tsx b/apps/web/src/components/game/zoneSelector.tsx new file mode 100644 index 0000000..da2a439 --- /dev/null +++ b/apps/web/src/components/game/zoneSelector.tsx @@ -0,0 +1,56 @@ +/** + * @file Zone selector component for choosing the active zone. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import type { Zone } from "@elysium/types"; +import type { JSX } from "react"; + +interface ZoneSelectorProperties { + readonly zones: Array<Zone>; + readonly activeZoneId: string; + readonly onSelectZone: (zoneId: string)=> void; +} + +/** + * Renders a zone selector with buttons for each available zone. + * @param props - The zone selector properties. + * @param props.zones - The list of zones to display. + * @param props.activeZoneId - The currently active zone ID. + * @param props.onSelectZone - Callback when a zone is selected. + * @returns The JSX element. + */ +const ZoneSelector = ({ + zones, + activeZoneId, + onSelectZone, +}: ZoneSelectorProperties): JSX.Element => { + return ( + <div className="zone-selector"> + {zones.map((zone) => { + function handleSelect(): void { + onSelectZone(zone.id); + } + return ( + <button + className={`zone-tab ${ + zone.id === activeZoneId + ? "zone-tab-active" + : "" + }`} + key={zone.id} + onClick={handleSelect} + title={zone.description} + type="button" + > + <span className="zone-emoji">{zone.emoji}</span> + <span className="zone-name">{zone.name}</span> + </button> + ); + })} + </div> + ); +}; + +export { ZoneSelector }; diff --git a/apps/web/src/components/ui/LockToggle.tsx b/apps/web/src/components/ui/LockToggle.tsx deleted file mode 100644 index c64089c..0000000 --- a/apps/web/src/components/ui/LockToggle.tsx +++ /dev/null @@ -1,20 +0,0 @@ -interface LockToggleProps { - showLocked: boolean; - onToggle: () => void; - lockedCount: number; -} - -export const LockToggle = ({ - showLocked, - onToggle, - lockedCount, -}: LockToggleProps): React.JSX.Element => ( - <button - className={`lock-toggle ${showLocked ? "lock-toggle-on" : "lock-toggle-off"}`} - onClick={onToggle} - title={showLocked ? "Hide locked items" : "Show locked items"} - type="button" - > - {showLocked ? "🔓" : "🔒"} {showLocked ? "Hide" : "Show"} locked ({lockedCount}) - </button> -); diff --git a/apps/web/src/components/ui/ResourceBar.tsx b/apps/web/src/components/ui/ResourceBar.tsx deleted file mode 100644 index ddf7dd5..0000000 --- a/apps/web/src/components/ui/ResourceBar.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import type { Resource } from "@elysium/types"; -import { useGame } from "../../context/GameContext.js"; -import { RESOURCE_CAP } from "../../engine/tick.js"; - -interface ResourceBarProps { - resources: Resource; - runestones: number; - prestigeCount: number; - transcendenceCount: number; - apotheosisCount: number; - profileUrl: string; - onEditProfile: () => void; - lastSavedAt: number | null; - isSyncing: boolean; - onForceSync: () => Promise<void>; -} - -const formatRelativeTime = (timestamp: number): string => { - const seconds = Math.floor((Date.now() - timestamp) / 1000); - if (seconds < 10) return "just now"; - if (seconds < 60) return `${seconds}s ago`; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - return `${hours}h ago`; -}; - -const RESOURCE_FULL_TOOLTIP = "This resource is full! Consider spending some or prestiging to keep earning."; - -export const ResourceBar = ({ - resources, - runestones, - prestigeCount, - transcendenceCount, - apotheosisCount, - profileUrl, - onEditProfile, - lastSavedAt, - isSyncing, - onForceSync, -}: ResourceBarProps): React.JSX.Element => { - const { formatNumber, syncError } = useGame(); - const anyFull = [resources.gold, resources.essence, resources.crystals].some((v) => v >= RESOURCE_CAP); - return ( - <> - <header className="resource-bar"> - <div className={`resource${resources.gold >= RESOURCE_CAP ? " resource-full" : ""}`}> - <span className="resource-icon">🪙</span> - <span className="resource-value">{formatNumber(resources.gold)}</span> - <span className="resource-label">Gold</span> - {resources.gold >= RESOURCE_CAP && <span className="resource-cap-badge" title={RESOURCE_FULL_TOOLTIP}>FULL</span>} - </div> - <div className={`resource${resources.essence >= RESOURCE_CAP ? " resource-full" : ""}`}> - <span className="resource-icon">✨</span> - <span className="resource-value">{formatNumber(resources.essence)}</span> - <span className="resource-label">Essence</span> - {resources.essence >= RESOURCE_CAP && <span className="resource-cap-badge" title={RESOURCE_FULL_TOOLTIP}>FULL</span>} - </div> - <div className={`resource${resources.crystals >= RESOURCE_CAP ? " resource-full" : ""}`}> - <span className="resource-icon">💎</span> - <span className="resource-value">{formatNumber(resources.crystals)}</span> - <span className="resource-label">Crystals</span> - {resources.crystals >= RESOURCE_CAP && <span className="resource-cap-badge" title={RESOURCE_FULL_TOOLTIP}>FULL</span>} - </div> - <div className="resource"> - <span className="resource-icon">🔮</span> - <span className="resource-value">{formatNumber(runestones)}</span> - <span className="resource-label">Runestones</span> - </div> - {apotheosisCount > 0 && ( - <div className="apotheosis-badge"> - ✨ Apotheosis {apotheosisCount} - </div> - )} - {transcendenceCount > 0 && ( - <div className="transcendence-badge"> - 🌌 Transcendence {transcendenceCount} - </div> - )} - {prestigeCount > 0 && ( - <div className="prestige-badge"> - ⭐ Prestige {prestigeCount} - </div> - )} - <div className="profile-buttons"> - <a - className="profile-link-button" - href="https://donate.nhcarrigan.com" - rel="noreferrer" - target="_blank" - title="Support the developer" - > - 💜 <span className="btn-label">Donate</span> - </a> - <a - className="profile-link-button" - href="https://chat.nhcarrigan.com" - rel="noreferrer" - target="_blank" - title="Join our Discord" - > - 💬 <span className="btn-label">Discord</span> - </a> - {syncError !== null ? ( - <span className="save-status save-error" title={syncError}> - ❌ Save failed - </span> - ) : lastSavedAt !== null ? ( - <span className="save-status" title={new Date(lastSavedAt).toLocaleString()}> - ☁️ {formatRelativeTime(lastSavedAt)} - </span> - ) : null} - <button - className="force-save-button" - disabled={isSyncing} - onClick={onForceSync} - title="Force cloud save" - type="button" - > - {isSyncing ? "⏳" : "💾"} - </button> - <a - className="profile-link-button" - href={profileUrl} - rel="noreferrer" - target="_blank" - title="View your public profile" - > - 👤 <span className="btn-label">Profile</span> - </a> - <button - className="profile-edit-button" - onClick={onEditProfile} - title="Edit your profile" - type="button" - > - ✏️ - </button> - </div> - </header> - {anyFull && ( - <div className="resource-cap-notice"> - ⚠️ One or more resources are full! Consider spending some or prestiging to keep earning. - </div> - )} - </> - ); -}; diff --git a/apps/web/src/components/ui/lockToggle.tsx b/apps/web/src/components/ui/lockToggle.tsx new file mode 100644 index 0000000..9fbf09f --- /dev/null +++ b/apps/web/src/components/ui/lockToggle.tsx @@ -0,0 +1,55 @@ +/** + * @file Lock toggle component for showing/hiding locked items. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import type { JSX } from "react"; + +interface LockToggleProperties { + readonly showLocked: boolean; + readonly onToggle: ()=> void; + readonly lockedCount: number; +} + +/** + * Renders a toggle button for showing or hiding locked items. + * @param props - The lock toggle properties. + * @param props.showLocked - Whether locked items are currently shown. + * @param props.onToggle - Callback when the toggle is clicked. + * @param props.lockedCount - The number of locked items. + * @returns The JSX element. + */ +const LockToggle = ({ + showLocked, + onToggle, + lockedCount, +}: LockToggleProperties): JSX.Element => { + const toggleIcon = showLocked + ? "🔓" + : "🔒"; + const toggleLabel = showLocked + ? "Hide" + : "Show"; + return ( + <button + className={`lock-toggle ${ + showLocked + ? "lock-toggle-on" + : "lock-toggle-off" + }`} + onClick={onToggle} + title={showLocked + ? "Hide locked items" + : "Show locked items"} + type="button" + > + {toggleIcon} {toggleLabel} + {" locked ("} + {lockedCount} + {")"} + </button> + ); +}; + +export { LockToggle }; diff --git a/apps/web/src/components/ui/resourceBar.tsx b/apps/web/src/components/ui/resourceBar.tsx new file mode 100644 index 0000000..67fef5a --- /dev/null +++ b/apps/web/src/components/ui/resourceBar.tsx @@ -0,0 +1,230 @@ +/** + * @file Resource bar component displaying player resources and profile actions. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Large header with many resource and action elements */ +/* eslint-disable complexity -- Many conditional resource and badge render paths */ +import { useGame } from "../../context/gameContext.js"; +import { RESOURCE_CAP } from "../../engine/tick.js"; +import type { Resource } from "@elysium/types"; +import type { JSX } from "react"; + +interface ResourceBarProperties { + readonly resources: Resource; + readonly runestones: number; + readonly prestigeCount: number; + readonly transcendenceCount: number; + readonly apotheosisCount: number; + readonly profileUrl: string; + readonly onEditProfile: ()=> void; + readonly lastSavedAt: number | null; + readonly isSyncing: boolean; + readonly onForceSync: ()=> Promise<void>; +} + +/** + * Formats a timestamp as a human-readable relative time string. + * @param timestamp - The Unix timestamp in milliseconds. + * @returns The relative time string. + */ +const formatRelativeTime = (timestamp: number): string => { + const seconds = Math.floor((Date.now() - timestamp) / 1000); + if (seconds < 10) { + return "just now"; + } + if (seconds < 60) { + return `${String(seconds)}s ago`; + } + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return `${String(minutes)}m ago`; + } + const hours = Math.floor(minutes / 60); + return `${String(hours)}h ago`; +}; + +const resourceFullTooltip = [ + "This resource is full!", + " Consider spending some or prestiging to keep earning.", +].join(""); + +/** + * Renders the resource bar with player resources and profile actions. + * @param props - The resource bar properties. + * @param props.resources - The current player resources. + * @param props.runestones - The current runestone count. + * @param props.prestigeCount - The number of prestiges completed. + * @param props.transcendenceCount - The number of transcendences completed. + * @param props.apotheosisCount - The number of apotheoses completed. + * @param props.profileUrl - The URL of the player's public profile. + * @param props.onEditProfile - Callback to open the edit profile modal. + * @param props.lastSavedAt - Timestamp of the last cloud save. + * @param props.isSyncing - Whether a sync is currently in progress. + * @param props.onForceSync - Callback to trigger a forced cloud sync. + * @returns The JSX element. + */ +const ResourceBar = ({ + resources, + runestones, + prestigeCount, + transcendenceCount, + apotheosisCount, + profileUrl, + onEditProfile, + lastSavedAt, + isSyncing, + onForceSync, +}: ResourceBarProperties): JSX.Element => { + const { formatNumber, syncError } = useGame(); + const { gold, essence, crystals } = resources; + const resourceValues = [ gold, essence, crystals ]; + const anyFull = resourceValues.some((v) => { + return v >= RESOURCE_CAP; + }); + const goldFull = gold >= RESOURCE_CAP; + const essenceFull = essence >= RESOURCE_CAP; + const crystalsFull = crystals >= RESOURCE_CAP; + + function handleForceSync(): void { + void onForceSync(); + } + + return ( + <> + <header className="resource-bar"> + <div className={`resource${goldFull + ? " resource-full" + : ""}`}> + <span className="resource-icon">{"🪙"}</span> + <span className="resource-value">{formatNumber(gold)}</span> + <span className="resource-label">{"Gold"}</span> + {goldFull + ? <span className="resource-cap-badge" title={resourceFullTooltip}> + {"FULL"} + </span> + : null} + </div> + <div className={`resource${essenceFull + ? " resource-full" + : ""}`}> + <span className="resource-icon">{"✨"}</span> + <span className="resource-value">{formatNumber(essence)}</span> + <span className="resource-label">{"Essence"}</span> + {essenceFull + ? <span className="resource-cap-badge" title={resourceFullTooltip}> + {"FULL"} + </span> + : null} + </div> + <div className={`resource${crystalsFull + ? " resource-full" + : ""}`}> + <span className="resource-icon">{"💎"}</span> + <span className="resource-value">{formatNumber(crystals)}</span> + <span className="resource-label">{"Crystals"}</span> + {crystalsFull + ? <span className="resource-cap-badge" title={resourceFullTooltip}> + {"FULL"} + </span> + : null} + </div> + <div className="resource"> + <span className="resource-icon">{"🔮"}</span> + <span className="resource-value">{formatNumber(runestones)}</span> + <span className="resource-label">{"Runestones"}</span> + </div> + {apotheosisCount > 0 + && <div className="apotheosis-badge"> + {"✨ Apotheosis "} + {apotheosisCount} + </div> + } + {transcendenceCount > 0 + && <div className="transcendence-badge"> + {"🌌 Transcendence "} + {transcendenceCount} + </div> + } + {prestigeCount > 0 + && <div className="prestige-badge"> + {"⭐ Prestige "} + {prestigeCount} + </div> + } + <div className="profile-buttons"> + <a + className="profile-link-button" + href="https://donate.nhcarrigan.com" + rel="noreferrer" + target="_blank" + title="Support the developer" + > + {"💜"} <span className="btn-label">{"Donate"}</span> + </a> + <a + className="profile-link-button" + href="https://chat.nhcarrigan.com" + rel="noreferrer" + target="_blank" + title="Join our Discord" + > + {"💬"} <span className="btn-label">{"Discord"}</span> + </a> + {syncError === null + ? null + : <span className="save-status save-error" title={syncError}> + {"❌ Save failed"} + </span> + } + {syncError === null && lastSavedAt !== null + ? <span + className="save-status" + title={new Date(lastSavedAt).toLocaleString()} + > + {"☁️ "} + {formatRelativeTime(lastSavedAt)} + </span> + : null} + <button + className="force-save-button" + disabled={isSyncing} + onClick={handleForceSync} + title="Force cloud save" + type="button" + > + {isSyncing + ? "⏳" + : "💾"} + </button> + <a + className="profile-link-button" + href={profileUrl} + rel="noreferrer" + target="_blank" + title="View your public profile" + > + {"👤"} <span className="btn-label">{"Profile"}</span> + </a> + <button + className="profile-edit-button" + onClick={onEditProfile} + title="Edit your profile" + type="button" + > + {"✏️"} + </button> + </div> + </header> + {anyFull + ? <div className="resource-cap-notice"> + {"⚠️ One or more resources are full! Consider spending some or" + + " prestiging to keep earning."} + </div> + : null} + </> + ); +}; + +export { ResourceBar }; diff --git a/apps/web/src/context/GameContext.tsx b/apps/web/src/context/GameContext.tsx deleted file mode 100644 index 7ce94ec..0000000 --- a/apps/web/src/context/GameContext.tsx +++ /dev/null @@ -1,1142 +0,0 @@ -import type { Achievement, BossChallengeResponse, ExploreCollectResponse, GameState, LoginBonusResult, NumberFormat } from "@elysium/types"; - -/** - * Pure function — applies a boss challenge result to the game state. - * Used by both the manual challengeBoss flow and the auto-boss tick logic. - */ -const applyBossResult = (prev: GameState, bossId: string, result: BossChallengeResponse): GameState => { - if (result.won) { - const defeatedBoss = prev.bosses.find((b) => b.id === bossId); - const zoneBosses = prev.bosses.filter((b) => b.zoneId === defeatedBoss?.zoneId); - const zoneIdx = zoneBosses.findIndex((b) => b.id === bossId); - const nextZoneBossId = zoneBosses[zoneIdx + 1]?.id; - - const newlyUnlockedZones = (prev.zones ?? []).filter((z) => { - if (z.status !== "locked" || z.unlockBossId !== bossId) return false; - const questOk = - z.unlockQuestId == null || - prev.quests.some((q) => q.id === z.unlockQuestId && q.status === "completed"); - return questOk; - }); - const newZoneFirstBossIds = newlyUnlockedZones.map((z) => { - const firstBoss = prev.bosses.find((b) => b.zoneId === z.id); - return firstBoss?.id; - }).filter(Boolean); - - const challengeUpdate = prev.dailyChallenges - ? updateChallengeProgress(prev.dailyChallenges, "bossesDefeated", 1) - : { updatedChallenges: undefined, crystalsAwarded: 0 }; - - return { - ...prev, - bosses: prev.bosses.map((b) => { - if (b.id === bossId) return { ...b, status: "defeated" as const, currentHp: 0 }; - if (b.id === nextZoneBossId && b.prestigeRequirement <= prev.prestige.count) { - return { ...b, status: "available" as const }; - } - if (newZoneFirstBossIds.includes(b.id) && b.prestigeRequirement <= prev.prestige.count) { - return { ...b, status: "available" as const }; - } - return b; - }), - zones: (prev.zones ?? []).map((z) => { - if (z.status !== "locked" || z.unlockBossId !== bossId) return z; - const questOk = - z.unlockQuestId == null || - prev.quests.some((q) => q.id === z.unlockQuestId && q.status === "completed"); - return questOk ? { ...z, status: "unlocked" as const } : z; - }), - ...(challengeUpdate.updatedChallenges !== undefined - ? { dailyChallenges: challengeUpdate.updatedChallenges } - : {}), - resources: result.rewards - ? { - ...prev.resources, - gold: prev.resources.gold + result.rewards.gold, - essence: prev.resources.essence + result.rewards.essence, - crystals: prev.resources.crystals + result.rewards.crystals + challengeUpdate.crystalsAwarded, - } - : { - ...prev.resources, - crystals: prev.resources.crystals + challengeUpdate.crystalsAwarded, - }, - prestige: result.rewards?.bountyRunestones - ? { ...prev.prestige, runestones: prev.prestige.runestones + result.rewards.bountyRunestones } - : prev.prestige, - player: result.rewards - ? { ...prev.player, totalGoldEarned: prev.player.totalGoldEarned + result.rewards.gold } - : prev.player, - upgrades: result.rewards - ? prev.upgrades.map((u) => - result.rewards!.upgradeIds.includes(u.id) ? { ...u, unlocked: true } : u, - ) - : prev.upgrades, - equipment: result.rewards - ? (prev.equipment ?? []).map((e) => { - if (!result.rewards!.equipmentIds.includes(e.id)) return e; - const slotEmpty = !(prev.equipment ?? []).some( - (other) => other.type === e.type && other.equipped, - ); - return { ...e, owned: true, equipped: slotEmpty || e.equipped }; - }) - : prev.equipment ?? [], - }; - } - - // Loss: reset boss HP and apply casualties - return { - ...prev, - bosses: prev.bosses.map((b) => - b.id === bossId ? { ...b, status: "available" as const, currentHp: b.maxHp } : b, - ), - adventurers: prev.adventurers.map((a) => { - const casualty = result.casualties?.find((c) => c.adventurerId === a.id); - if (!casualty) return a; - return { ...a, count: Math.max(0, a.count - casualty.killed) }; - }), - }; -}; -import { - createContext, - useCallback, - useContext, - useEffect, - useRef, - useState, -} from "react"; -import { - achieveApotheosis as achieveApotheosisApi, - buyEchoUpgrade as buyEchoUpgradeApi, - buyPrestigeUpgrade as buyPrestigeUpgradeApi, - challengeBoss as challengeBossApi, - collectExploration as collectExplorationApi, - craftRecipe as craftRecipeApi, - loadGame, - prestige as prestigeApi, - resetProgress as resetProgressApi, - saveGame, - startExploration as startExplorationApi, - transcend as transcendApi, -} from "../api/client.js"; -import { EXPLORATION_AREAS } from "../data/explorations.js"; -import { RECIPES } from "../data/recipes.js"; -import { CODEX_ENTRIES } from "../data/codex.js"; -import { STORY_CHAPTERS, isStoryChapterUnlocked } from "@elysium/types"; -import { RESOURCE_CAP, applyTick, calculateClickPower } from "../engine/tick.js"; -import { updateChallengeProgress } from "../utils/dailyChallenges.js"; -import { formatNumber as formatNumberUtil } from "../utils/format.js"; - - -export interface BattleResult { - bossName: string; - result: BossChallengeResponse; -} - -interface GameContextValue { - state: GameState | null; - isLoading: boolean; - error: string | null; - /** Click the crystal to earn gold */ - handleClick: () => void; - /** Buy one or more of an adventurer tier; quantity > 1 buys in batch */ - buyAdventurer: (adventurerId: string, quantity: number) => void; - /** Buy an upgrade */ - buyUpgrade: (upgradeId: string) => void; - /** Purchase a buyable equipment item */ - buyEquipment: (equipmentId: string) => void; - /** Start a quest */ - startQuest: (questId: string) => void; - /** Challenge a boss — runs full server-side simulation */ - challengeBoss: (bossId: string) => Promise<void>; - /** Equip an owned equipment item (auto-unequips the same slot) */ - equipItem: (equipmentId: string) => void; - /** Reload state from the server */ - reload: () => Promise<void>; - /** Unix timestamp of the last successful cloud save (null until first save response) */ - lastSavedAt: number | null; - /** True whilst a forced save is in-flight */ - isSyncing: boolean; - /** Immediately save to the server and reset the auto-save timer */ - forceSync: () => Promise<void>; - /** Error message from the last failed cloud save (null when no error) */ - syncError: string | null; - /** Offline gold earned on login */ - offlineGold: number; - /** Offline essence earned on login */ - offlineEssence: number; - /** Dismiss the offline earnings notification */ - dismissOfflineGold: () => void; - /** Battle result to display in the modal (null when no battle pending) */ - battleResult: BattleResult | null; - /** Dismiss the battle result modal */ - dismissBattle: () => void; - /** Queue of newly unlocked achievements (for toasts) */ - newAchievements: Achievement[]; - /** Remove an achievement from the toast queue */ - dismissAchievement: (id: string) => void; - /** The player's chosen number display format */ - numberFormat: NumberFormat; - /** Update the number format preference (persisted to server via profile save) */ - setNumberFormat: (format: NumberFormat) => void; - /** Format a number using the player's chosen notation style */ - formatNumber: (value: number) => string; - /** Buy a prestige upgrade from the runestone shop */ - buyPrestigeUpgrade: (upgradeId: string) => Promise<void>; - /** Toggle the auto-prestige setting on/off (requires auto_prestige upgrade) */ - toggleAutoPrestige: () => void; - /** Toggle the auto-quest setting on/off */ - toggleAutoQuest: () => void; - /** Toggle the auto-boss setting on/off */ - toggleAutoBoss: () => void; - /** Queue of newly unlocked codex entry IDs (for toast notifications) */ - newCodexEntryIds: string[]; - /** Remove a codex entry ID from the notification queue */ - dismissCodexEntry: (id: string) => void; - /** Perform a transcendence — nuclear reset, earning echoes */ - transcend: () => Promise<{ echoes: number; newTranscendenceCount: number }>; - /** Buy an echo upgrade from the transcendence shop */ - buyEchoUpgrade: (upgradeId: string) => Promise<void>; - /** Achieve Apotheosis — the ultimate nuclear reset, bragging rights only */ - apotheosis: () => Promise<{ newApotheosisCount: number }>; - /** Start an exploration in the given area */ - startExploration: (areaId: string) => Promise<void>; - /** Collect results of a completed exploration */ - collectExploration: (areaId: string) => Promise<ExploreCollectResponse>; - /** Craft a recipe using collected materials */ - craftRecipe: (recipeId: string) => Promise<void>; - /** Daily login bonus earned on this session load (null if already claimed today) */ - loginBonus: LoginBonusResult | null; - /** Player's current login streak (days) */ - loginStreak: number; - /** Dismiss the login bonus modal */ - dismissLoginBonus: () => void; - /** Set the active companion (null to deactivate) */ - setActiveCompanion: (companionId: string | null) => void; - /** Queue of newly unlocked story chapter IDs (for toast notifications) */ - newStoryChapterIds: string[]; - /** Remove a chapter ID from the story notification queue */ - dismissStoryChapter: (id: string) => void; - /** Record the player's choice for a story chapter */ - completeChapter: (chapterId: string, choiceId: string) => void; - /** True when the loaded save is from an older schema version */ - schemaOutdated: boolean; - /** The save's schema version (0 if missing) */ - saveSchemaVersion: number; - /** The server's current expected schema version */ - currentSchemaVersion: number; - /** Reset all progress to a fresh save state (resolves schema outdated) */ - resetProgress: () => Promise<void>; -} - -const GameContext = createContext<GameContextValue | null>(null); - -const AUTO_SAVE_INTERVAL_MS = 30_000; -const AUTO_PRESTIGE_THRESHOLD_BASE = 1_000_000; -const AUTO_PRESTIGE_THRESHOLD_SCALE = 5; - -export const GameProvider = ({ children }: { children: React.ReactNode }): React.JSX.Element => { - const [state, setState] = useState<GameState | null>(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState<string | null>(null); - const [offlineGold, setOfflineGold] = useState(0); - const [offlineEssence, setOfflineEssence] = useState(0); - const [loginBonus, setLoginBonus] = useState<LoginBonusResult | null>(null); - const [loginStreak, setLoginStreak] = useState(1); - const [battleResult, setBattleResult] = useState<BattleResult | null>(null); - const [newAchievements, setNewAchievements] = useState<Achievement[]>([]); - const [lastSavedAt, setLastSavedAt] = useState<number | null>(null); - const [isSyncing, setIsSyncing] = useState(false); - const [syncError, setSyncError] = useState<string | null>(null); - const syncErrorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); - const [numberFormat, setNumberFormat] = useState<NumberFormat>("suffix"); - const stateRef = useRef<GameState | null>(null); - const lastSaveRef = useRef<number>(Date.now()); - const isSyncingRef = useRef(false); - const rafRef = useRef<number | null>(null); - const newlyUnlockedRef = useRef<Achievement[]>([]); - const signatureRef = useRef<string | null>(localStorage.getItem("elysium_save_signature")); - const isAutoPrestigingRef = useRef(false); - const isAutoBossingRef = useRef(false); - const reloadRef = useRef<() => Promise<void>>(() => Promise.resolve()); - const [schemaOutdated, setSchemaOutdated] = useState(false); - const [saveSchemaVersion, setSaveSchemaVersion] = useState(0); - const [currentSchemaVersion, setCurrentSchemaVersion] = useState(0); - const [newCodexEntryIds, setNewCodexEntryIds] = useState<string[]>([]); - const codexProcessedRef = useRef<Set<string>>(new Set()); - const [newStoryChapterIds, setNewStoryChapterIds] = useState<string[]>([]); - const storyProcessedRef = useRef<Set<string>>(new Set()); - const storyFirstRunRef = useRef(true); - - stateRef.current = state; - - const reload = useCallback(async () => { - setIsLoading(true); - setError(null); - try { - const data = await loadGame(); - setState(data.state); - setLastSavedAt(data.state.player.lastSavedAt); - if (data.signature) { - signatureRef.current = data.signature; - localStorage.setItem("elysium_save_signature", data.signature); - } - if (data.offlineGold > 0) { - setOfflineGold(data.offlineGold); - } - if (data.offlineEssence > 0) { - setOfflineEssence(data.offlineEssence); - } - if (data.loginBonus) { - setLoginBonus(data.loginBonus); - } - setLoginStreak(data.loginStreak ?? 1); - setSchemaOutdated(data.schemaOutdated); - setSaveSchemaVersion(data.state.schemaVersion ?? 0); - setCurrentSchemaVersion(data.currentSchemaVersion); - // Fetch number format preference from profile (fire-and-forget, non-blocking) - void fetch(`/api/profile/${data.state.player.discordId}`) - .then(async (res) => { - if (!res.ok) return; - const profile = await res.json() as { profileSettings?: { numberFormat?: NumberFormat } }; - const fmt = profile.profileSettings?.numberFormat; - if (fmt === "suffix" || fmt === "scientific" || fmt === "engineering") { - setNumberFormat(fmt); - } - }) - .catch(() => { /* fall back to default "suffix" */ }); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load game"); - } finally { - setIsLoading(false); - } - }, []); - - reloadRef.current = reload; - - useEffect(() => { - void reload(); - }, [reload]); - - // Detect newly defeated bosses and completed quests to unlock Codex entries - useEffect(() => { - if (!state) return; - - const existingUnlocked = state.codex?.unlockedEntryIds ?? []; - // On first run (empty processed set), silently unlock existing completions - const isFirstRun = codexProcessedRef.current.size === 0; - const newIds: string[] = []; - - for (const boss of state.bosses) { - const codexId = `boss_${boss.id}`; - if (boss.status === "defeated" && !codexProcessedRef.current.has(codexId)) { - codexProcessedRef.current.add(codexId); - if (!existingUnlocked.includes(codexId) && CODEX_ENTRIES.some((e) => e.id === codexId)) { - newIds.push(codexId); - } - } - } - - for (const quest of state.quests) { - const codexId = `quest_${quest.id}`; - if (quest.status === "completed" && !codexProcessedRef.current.has(codexId)) { - codexProcessedRef.current.add(codexId); - if (!existingUnlocked.includes(codexId) && CODEX_ENTRIES.some((e) => e.id === codexId)) { - newIds.push(codexId); - } - } - } - - for (const zone of state.zones) { - const codexId = `zone_${zone.id}`; - if (zone.status === "unlocked" && !codexProcessedRef.current.has(codexId)) { - codexProcessedRef.current.add(codexId); - if (!existingUnlocked.includes(codexId) && CODEX_ENTRIES.some((e) => e.id === codexId)) { - newIds.push(codexId); - } - } - } - - for (const equip of state.equipment) { - const codexId = `equipment_${equip.id}`; - if (equip.owned && !codexProcessedRef.current.has(codexId)) { - codexProcessedRef.current.add(codexId); - if (!existingUnlocked.includes(codexId) && CODEX_ENTRIES.some((e) => e.id === codexId)) { - newIds.push(codexId); - } - } - } - - for (const adventurer of state.adventurers) { - const codexId = `adventurer_${adventurer.id}`; - if (adventurer.count > 0 && !codexProcessedRef.current.has(codexId)) { - codexProcessedRef.current.add(codexId); - if (!existingUnlocked.includes(codexId) && CODEX_ENTRIES.some((e) => e.id === codexId)) { - newIds.push(codexId); - } - } - } - - for (const upgrade of state.upgrades) { - const codexId = `upgrade_${upgrade.id}`; - if (upgrade.purchased && !codexProcessedRef.current.has(codexId)) { - codexProcessedRef.current.add(codexId); - if (!existingUnlocked.includes(codexId) && CODEX_ENTRIES.some((e) => e.id === codexId)) { - newIds.push(codexId); - } - } - } - - for (const prestigeId of state.prestige.purchasedUpgradeIds) { - const codexId = `prestige_${prestigeId}`; - if (!codexProcessedRef.current.has(codexId)) { - codexProcessedRef.current.add(codexId); - if (!existingUnlocked.includes(codexId) && CODEX_ENTRIES.some((e) => e.id === codexId)) { - newIds.push(codexId); - } - } - } - - for (const area of state.exploration?.areas ?? []) { - const codexId = `explore_${area.id}`; - if (area.completedOnce && !codexProcessedRef.current.has(codexId)) { - codexProcessedRef.current.add(codexId); - if (!existingUnlocked.includes(codexId) && CODEX_ENTRIES.some((e) => e.id === codexId)) { - newIds.push(codexId); - } - } - } - - for (const recipeId of state.exploration?.craftedRecipeIds ?? []) { - const codexId = `recipe_${recipeId}`; - if (!codexProcessedRef.current.has(codexId)) { - codexProcessedRef.current.add(codexId); - if (!existingUnlocked.includes(codexId) && CODEX_ENTRIES.some((e) => e.id === codexId)) { - newIds.push(codexId); - } - } - } - - if (newIds.length > 0) { - setState((prev) => { - if (!prev) return prev; - const existing = prev.codex?.unlockedEntryIds ?? []; - const toAdd = newIds.filter((id) => !existing.includes(id)); - if (toAdd.length === 0) return prev; - return { ...prev, codex: { unlockedEntryIds: [...existing, ...toAdd] } }; - }); - if (!isFirstRun) { - setNewCodexEntryIds((prev) => [...prev, ...newIds]); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally re-runs on state change to detect completions - }, [state]); - - // Detect newly unlocked story chapters - useEffect(() => { - if (!state) return; - - // On first run, populate the ref from existing unlocked IDs without toasting - if (storyFirstRunRef.current) { - storyFirstRunRef.current = false; - const existing = state.story?.unlockedChapterIds ?? []; - existing.forEach((id) => storyProcessedRef.current.add(id)); - return; - } - - const newIds: string[] = []; - for (const chapter of STORY_CHAPTERS) { - if (storyProcessedRef.current.has(chapter.id)) continue; - if (isStoryChapterUnlocked(chapter, state)) { - storyProcessedRef.current.add(chapter.id); - newIds.push(chapter.id); - } - } - - if (newIds.length === 0) return; - - setNewStoryChapterIds((prev) => [...prev, ...newIds]); - setState((prev) => { - if (!prev) return prev; - const existing = prev.story?.unlockedChapterIds ?? []; - const toAdd = newIds.filter((id) => !existing.includes(id)); - if (toAdd.length === 0) return prev; - return { - ...prev, - story: { - unlockedChapterIds: [...existing, ...toAdd], - completedChapters: prev.story?.completedChapters ?? [], - }, - }; - }); - // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally re-runs on state change to detect story unlocks - }, [state]); - - // Game loop via requestAnimationFrame - useEffect(() => { - if (!state) return; - - let lastTime = performance.now(); - - const tick = (now: number): void => { - const deltaSeconds = (now - lastTime) / 1000; - lastTime = now; - - setState((prev) => { - if (!prev) return prev; - let next = applyTick(prev, deltaSeconds); - - // Auto-quest: start the highest-zone available quest when none is active - if (next.autoQuest) { - const hasActiveQuest = next.quests.some((q) => q.status === "active"); - if (!hasActiveQuest) { - const partyCombatPower = next.adventurers.reduce( - (total, a) => total + a.combatPower * a.count, - 0, - ); - const zoneOrder = new Map(next.zones.map((z, i) => [z.id, i])); - const candidates = next.quests - .filter((q) => q.status === "available" && (q.combatPowerRequired ?? 0) <= partyCombatPower) - .sort((a, b) => (zoneOrder.get(b.zoneId) ?? 0) - (zoneOrder.get(a.zoneId) ?? 0)); - const best = candidates[0]; - if (best) { - next = { - ...next, - quests: next.quests.map((q) => - q.id === best.id - ? { ...q, status: "active" as const, startedAt: Date.now() } - : q, - ), - }; - } - } - } - - // Detect newly unlocked achievements - newlyUnlockedRef.current = next.achievements.filter((a, i) => { - const wasLocked = (prev.achievements ?? [])[i]?.unlockedAt === null; - return wasLocked && a.unlockedAt !== null; - }); - - return next; - }); - - if (newlyUnlockedRef.current.length > 0) { - setNewAchievements((prev) => [...prev, ...newlyUnlockedRef.current]); - newlyUnlockedRef.current = []; - } - - // Auto-save every 30 seconds (skip if a force sync is in-flight to avoid signature collisions) - if (Date.now() - lastSaveRef.current >= AUTO_SAVE_INTERVAL_MS) { - lastSaveRef.current = Date.now(); - if (stateRef.current && !isSyncingRef.current) { - void saveGame({ - state: stateRef.current, - ...(signatureRef.current !== null ? { signature: signatureRef.current } : {}), - }).then((response) => { - setLastSavedAt(response.savedAt); - if (response.signature) { - signatureRef.current = response.signature; - localStorage.setItem("elysium_save_signature", response.signature); - } - }).catch((err: unknown) => { - // Silently clear a bad signature so the next auto-save can proceed - if (err instanceof Error && err.message.includes("signature mismatch")) { - signatureRef.current = null; - localStorage.removeItem("elysium_save_signature"); - } - }); - } - } - - // Auto-prestige: fire when unlocked, enabled, and threshold is met - const autoState = stateRef.current; - if ( - !isAutoPrestigingRef.current && - autoState?.prestige.purchasedUpgradeIds.includes("auto_prestige") && - autoState.prestige.autoPrestigeEnabled && - autoState.player.totalGoldEarned >= - AUTO_PRESTIGE_THRESHOLD_BASE * - Math.pow(AUTO_PRESTIGE_THRESHOLD_SCALE, autoState.prestige.count) - ) { - isAutoPrestigingRef.current = true; - void prestigeApi({}) - .then(() => reloadRef.current()) - .catch(() => { /* silently ignore — will retry next tick */ }) - .finally(() => { isAutoPrestigingRef.current = false; }); - } - - // Auto-boss: challenge the highest-zone available boss when not already fighting - if (!isAutoBossingRef.current && autoState?.autoBoss) { - const prestigeCount = autoState.prestige.count; - const zoneOrder = new Map((autoState.zones ?? []).map((z, i) => [z.id, i])); - const availableBoss = autoState.bosses - .filter((b) => b.status === "available" && b.prestigeRequirement <= prestigeCount) - .sort((a, b) => (zoneOrder.get(b.zoneId) ?? 0) - (zoneOrder.get(a.zoneId) ?? 0))[0]; - if (availableBoss) { - const bossId = availableBoss.id; - const bossName = availableBoss.name; - isAutoBossingRef.current = true; - void challengeBossApi({ bossId }) - .then((result) => { - setState((prev) => { - if (!prev) return prev; - return applyBossResult(prev, bossId, result); - }); - setBattleResult({ bossName, result }); - }) - .catch(() => { /* silently ignore — will retry next tick */ }) - .finally(() => { isAutoBossingRef.current = false; }); - } - } - - rafRef.current = requestAnimationFrame(tick); - }; - - rafRef.current = requestAnimationFrame(tick); - return () => { - if (rafRef.current !== null) { - cancelAnimationFrame(rafRef.current); - } - }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- only run when state becomes available - }, [state !== null]); - - const showSyncError = useCallback((message: string) => { - setSyncError(message); - if (syncErrorTimerRef.current) clearTimeout(syncErrorTimerRef.current); - syncErrorTimerRef.current = setTimeout(() => { setSyncError(null); }, 5000); - }, []); - - const clearBadSignature = useCallback(() => { - signatureRef.current = null; - localStorage.removeItem("elysium_save_signature"); - }, []); - - const forceSync = useCallback(async () => { - if (!stateRef.current || isSyncingRef.current) return; - isSyncingRef.current = true; - lastSaveRef.current = Date.now(); // push auto-save timer back so it doesn't fire concurrently - setIsSyncing(true); - try { - const response = await saveGame({ - state: stateRef.current, - ...(signatureRef.current !== null ? { signature: signatureRef.current } : {}), - }); - setSyncError(null); - setLastSavedAt(response.savedAt); - lastSaveRef.current = Date.now(); - if (response.signature) { - signatureRef.current = response.signature; - localStorage.setItem("elysium_save_signature", response.signature); - } - } catch (err) { - const message = err instanceof Error ? err.message : "Save failed"; - showSyncError(message); - if (message.includes("signature mismatch")) clearBadSignature(); - } finally { - isSyncingRef.current = false; - setIsSyncing(false); - } - }, [showSyncError, clearBadSignature]); - - const handleClick = useCallback(() => { - setState((prev) => { - if (!prev) return prev; - const clickPower = calculateClickPower(prev); - const newGold = Math.min(prev.resources.gold + clickPower, RESOURCE_CAP); - - let updatedDailyChallenges = prev.dailyChallenges; - let challengeCrystals = 0; - if (updatedDailyChallenges) { - const result = updateChallengeProgress(updatedDailyChallenges, "clicks", 1); - updatedDailyChallenges = result.updatedChallenges; - challengeCrystals = result.crystalsAwarded; - } - - return { - ...prev, - resources: { - ...prev.resources, - gold: newGold, - crystals: Math.min(prev.resources.crystals + challengeCrystals, RESOURCE_CAP), - }, - player: { - ...prev.player, - totalGoldEarned: prev.player.totalGoldEarned + clickPower, - totalClicks: prev.player.totalClicks + 1, - }, - ...(updatedDailyChallenges !== undefined ? { dailyChallenges: updatedDailyChallenges } : {}), - }; - }); - }, []); - - const buyAdventurer = useCallback((adventurerId: string, quantity: number) => { - setState((prev) => { - if (!prev) return prev; - const adventurer = prev.adventurers.find((a) => a.id === adventurerId); - if (!adventurer || !adventurer.unlocked) return prev; - - let gold = prev.resources.gold; - let count = adventurer.count; - let purchased = 0; - - for (let i = 0; i < quantity; i++) { - const cost = adventurer.baseCost * Math.pow(1.15, count); - if (gold < cost) break; - gold -= cost; - count++; - purchased++; - } - - if (purchased === 0) return prev; - - return { - ...prev, - resources: { ...prev.resources, gold }, - adventurers: prev.adventurers.map((a) => - a.id === adventurerId ? { ...a, count } : a, - ), - }; - }); - }, []); - - const buyUpgrade = useCallback((upgradeId: string) => { - setState((prev) => { - if (!prev) return prev; - const upgrade = prev.upgrades.find((u) => u.id === upgradeId); - if (!upgrade || !upgrade.unlocked || upgrade.purchased) return prev; - if (prev.resources.gold < upgrade.costGold) return prev; - if (prev.resources.essence < upgrade.costEssence) return prev; - if (prev.resources.crystals < (upgrade.costCrystals ?? 0)) return prev; - - return { - ...prev, - resources: { - ...prev.resources, - gold: prev.resources.gold - upgrade.costGold, - essence: prev.resources.essence - upgrade.costEssence, - crystals: prev.resources.crystals - (upgrade.costCrystals ?? 0), - }, - upgrades: prev.upgrades.map((u) => - u.id === upgradeId ? { ...u, purchased: true } : u, - ), - }; - }); - }, []); - - const startQuest = useCallback((questId: string) => { - setState((prev) => { - if (!prev) return prev; - const quest = prev.quests.find((q) => q.id === questId); - if (!quest || quest.status !== "available") return prev; - - return { - ...prev, - quests: prev.quests.map((q) => - q.id === questId - ? { ...q, status: "active" as const, startedAt: Date.now() } - : q, - ), - }; - }); - }, []); - - const equipItem = useCallback((equipmentId: string) => { - setState((prev) => { - if (!prev) return prev; - const item = (prev.equipment ?? []).find((e) => e.id === equipmentId); - if (!item || !item.owned) return prev; - - return { - ...prev, - equipment: (prev.equipment ?? []).map((e) => { - if (e.id === equipmentId) return { ...e, equipped: true }; - // Unequip the previously-equipped item in the same slot - if (e.type === item.type && e.equipped) return { ...e, equipped: false }; - return e; - }), - }; - }); - }, []); - - const buyEquipment = useCallback((equipmentId: string) => { - setState((prev) => { - if (!prev) return prev; - const item = (prev.equipment ?? []).find((e) => e.id === equipmentId); - if (!item || item.owned || !item.cost) return prev; - - const { gold, essence, crystals } = item.cost; - if (prev.resources.gold < gold) return prev; - if (prev.resources.essence < essence) return prev; - if (prev.resources.crystals < crystals) return prev; - - const slotAlreadyEquipped = (prev.equipment ?? []).some( - (e) => e.type === item.type && e.equipped, - ); - - return { - ...prev, - resources: { - ...prev.resources, - gold: prev.resources.gold - gold, - essence: prev.resources.essence - essence, - crystals: prev.resources.crystals - crystals, - }, - equipment: (prev.equipment ?? []).map((e) => { - if (e.id === equipmentId) return { ...e, owned: true, equipped: !slotAlreadyEquipped }; - return e; - }), - }; - }); - }, []); - - const buyPrestigeUpgrade = useCallback(async (upgradeId: string) => { - try { - const result = await buyPrestigeUpgradeApi({ upgradeId }); - setState((prev) => { - if (!prev) return prev; - return { - ...prev, - prestige: { - ...prev.prestige, - runestones: result.runestonesRemaining, - purchasedUpgradeIds: result.purchasedUpgradeIds, - runestonesIncomeMultiplier: result.runestonesIncomeMultiplier, - runestonesClickMultiplier: result.runestonesClickMultiplier, - runestonesEssenceMultiplier: result.runestonesEssenceMultiplier, - runestonesCrystalMultiplier: result.runestonesCrystalMultiplier, - }, - }; - }); - } catch { - // Silently ignore — server errors shouldn't crash the UI - } - }, []); - - const transcend = useCallback(async () => { - const result = await transcendApi({}); - await reload(); - return result; - }, [reload]); - - const apotheosis = useCallback(async () => { - const result = await achieveApotheosisApi({}); - await reload(); - return result; - }, [reload]); - - const buyEchoUpgrade = useCallback(async (upgradeId: string) => { - try { - const result = await buyEchoUpgradeApi({ upgradeId }); - setState((prev) => { - if (!prev || !prev.transcendence) return prev; - return { - ...prev, - transcendence: { - ...prev.transcendence, - echoes: result.echoesRemaining, - purchasedUpgradeIds: result.purchasedUpgradeIds, - echoIncomeMultiplier: result.echoIncomeMultiplier, - echoCombatMultiplier: result.echoCombatMultiplier, - echoPrestigeThresholdMultiplier: result.echoPrestigeThresholdMultiplier, - echoPrestigeRunestoneMultiplier: result.echoPrestigeRunestoneMultiplier, - echoMetaMultiplier: result.echoMetaMultiplier, - }, - }; - }); - } catch { - // Silently ignore server errors - } - }, []); - - const startExploration = useCallback(async (areaId: string) => { - const response = await startExplorationApi({ areaId }); - const areaData = EXPLORATION_AREAS.find((a) => a.id === areaId); - if (!areaData) return; - const startedAt = response.endsAt - areaData.durationSeconds * 1000; - setState((prev) => { - if (!prev?.exploration) return prev; - return { - ...prev, - exploration: { - ...prev.exploration, - areas: prev.exploration.areas.map((a) => - a.id === areaId ? { ...a, status: "in_progress" as const, startedAt } : a, - ), - }, - }; - }); - }, []); - - const collectExploration = useCallback(async (areaId: string): Promise<ExploreCollectResponse> => { - const result = await collectExplorationApi({ areaId }); - setState((prev) => { - if (!prev?.exploration) return prev; - let materials = [...prev.exploration.materials]; - - // Apply material drops from the random loot roll - for (const drop of result.materialsFound) { - const existing = materials.find((m) => m.materialId === drop.materialId); - if (existing) { - materials = materials.map((m) => - m.materialId === drop.materialId ? { ...m, quantity: m.quantity + drop.quantity } : m, - ); - } else { - materials = [...materials, { materialId: drop.materialId, quantity: drop.quantity }]; - } - } - - // Apply material from event (if any) - if (result.event?.materialGained) { - const { materialId, quantity } = result.event.materialGained; - const existing = materials.find((m) => m.materialId === materialId); - if (existing) { - materials = materials.map((m) => - m.materialId === materialId ? { ...m, quantity: m.quantity + quantity } : m, - ); - } else { - materials = [...materials, { materialId, quantity }]; - } - } - - return { - ...prev, - resources: { - ...prev.resources, - gold: Math.max(0, prev.resources.gold + (result.event?.goldChange ?? 0)), - essence: prev.resources.essence + (result.event?.essenceChange ?? 0), - }, - player: { - ...prev.player, - totalGoldEarned: prev.player.totalGoldEarned + Math.max(0, result.event?.goldChange ?? 0), - }, - exploration: { - ...prev.exploration, - areas: prev.exploration.areas.map((a) => - a.id === areaId ? { ...a, status: "available" as const, completedOnce: true } : a, - ), - materials, - }, - }; - }); - return result; - }, []); - - const craftRecipe = useCallback(async (recipeId: string) => { - const recipe = RECIPES.find((r) => r.id === recipeId); - if (!recipe) return; - const result = await craftRecipeApi({ recipeId }); - setState((prev) => { - if (!prev?.exploration) return prev; - let materials = [...prev.exploration.materials]; - for (const req of recipe.requiredMaterials) { - materials = materials.map((m) => - m.materialId === req.materialId ? { ...m, quantity: m.quantity - req.quantity } : m, - ); - } - return { - ...prev, - exploration: { - ...prev.exploration, - craftedRecipeIds: [...prev.exploration.craftedRecipeIds, recipeId], - materials, - craftedGoldMultiplier: result.craftedGoldMultiplier, - craftedEssenceMultiplier: result.craftedEssenceMultiplier, - craftedClickMultiplier: result.craftedClickMultiplier, - craftedCombatMultiplier: result.craftedCombatMultiplier, - }, - }; - }); - }, []); - - const toggleAutoPrestige = useCallback(() => { - setState((prev) => { - if (!prev) return prev; - return { - ...prev, - prestige: { - ...prev.prestige, - autoPrestigeEnabled: !prev.prestige.autoPrestigeEnabled, - }, - }; - }); - }, []); - - const toggleAutoQuest = useCallback(() => { - setState((prev) => { - if (!prev) return prev; - return { ...prev, autoQuest: !prev.autoQuest }; - }); - }, []); - - const toggleAutoBoss = useCallback(() => { - setState((prev) => { - if (!prev) return prev; - return { ...prev, autoBoss: !prev.autoBoss }; - }); - }, []); - - const setActiveCompanion = useCallback((companionId: string | null) => { - setState((prev) => { - if (!prev) return prev; - const unlockedIds = prev.companions?.unlockedCompanionIds ?? []; - const validatedId = companionId !== null && unlockedIds.includes(companionId) ? companionId : null; - return { - ...prev, - companions: { - unlockedCompanionIds: unlockedIds, - activeCompanionId: validatedId, - }, - }; - }); - }, []); - - const challengeBoss = useCallback(async (bossId: string) => { - if (!stateRef.current) return; - const boss = stateRef.current.bosses.find((b) => b.id === bossId); - if (!boss) return; - - try { - const result = await challengeBossApi({ bossId }); - setState((prev) => { - if (!prev) return prev; - return applyBossResult(prev, bossId, result); - }); - setBattleResult({ bossName: boss.name, result }); - } catch { - // Silently ignore — server errors shouldn't crash the UI - } - }, []); - - const dismissOfflineGold = useCallback(() => { - setOfflineGold(0); - setOfflineEssence(0); - }, []); - - const dismissBattle = useCallback(() => { - setBattleResult(null); - }, []); - - const dismissAchievement = useCallback((id: string) => { - setNewAchievements((prev) => prev.filter((a) => a.id !== id)); - }, []); - - const dismissCodexEntry = useCallback((id: string) => { - setNewCodexEntryIds((prev) => prev.filter((e) => e !== id)); - }, []); - - const dismissStoryChapter = useCallback((id: string) => { - setNewStoryChapterIds((prev) => prev.filter((c) => c !== id)); - }, []); - - const completeChapter = useCallback((chapterId: string, choiceId: string) => { - setState((prev) => { - if (!prev) return prev; - const already = prev.story?.completedChapters ?? []; - if (already.some((c) => c.chapterId === chapterId)) return prev; - return { - ...prev, - story: { - unlockedChapterIds: prev.story?.unlockedChapterIds ?? [], - completedChapters: [...already, { chapterId, choiceId }], - }, - }; - }); - }, []); - - const resetProgress = useCallback(async () => { - setIsLoading(true); - setError(null); - try { - const data = await resetProgressApi(); - setState(data.state); - setLastSavedAt(data.state.player.lastSavedAt); - setSchemaOutdated(false); - setOfflineGold(0); - setOfflineEssence(0); - setLoginBonus(null); - if (data.signature) { - signatureRef.current = data.signature; - localStorage.setItem("elysium_save_signature", data.signature); - } - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to reset progress"); - } finally { - setIsLoading(false); - } - }, []); - - const dismissLoginBonus = useCallback(() => { - setLoginBonus(null); - }, []); - - const boundFormatNumber = useCallback( - (value: number) => formatNumberUtil(value, numberFormat), - [numberFormat], - ); - - return ( - <GameContext.Provider - value={{ - state, - isLoading, - error, - handleClick, - buyAdventurer, - buyUpgrade, - buyEquipment, - startQuest, - challengeBoss, - equipItem, - reload, - lastSavedAt, - isSyncing, - forceSync, - syncError, - offlineGold, - offlineEssence, - dismissOfflineGold, - battleResult, - dismissBattle, - newAchievements, - dismissAchievement, - numberFormat, - setNumberFormat, - formatNumber: boundFormatNumber, - buyPrestigeUpgrade, - toggleAutoPrestige, - toggleAutoQuest, - toggleAutoBoss, - newCodexEntryIds, - dismissCodexEntry, - transcend, - buyEchoUpgrade, - apotheosis, - startExploration, - collectExploration, - craftRecipe, - loginBonus, - loginStreak, - dismissLoginBonus, - setActiveCompanion, - newStoryChapterIds, - dismissStoryChapter, - completeChapter, - schemaOutdated, - saveSchemaVersion, - currentSchemaVersion, - resetProgress, - }} - > - {children} - </GameContext.Provider> - ); -}; - -export const useGame = (): GameContextValue => { - const context = useContext(GameContext); - if (!context) { - throw new Error("useGame must be used within a GameProvider"); - } - return context; -}; diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx new file mode 100644 index 0000000..c6a2aa0 --- /dev/null +++ b/apps/web/src/context/gameContext.tsx @@ -0,0 +1,1808 @@ +/** + * @file Game context providing global state and actions for the Elysium game. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines -- Large context file with many state management concerns */ +/* eslint-disable max-lines-per-function -- Context provider and callbacks require many declarations */ +/* eslint-disable max-statements -- Context provider requires many state initialisations */ +/* eslint-disable complexity -- Game logic has inherently high branching complexity */ +/* eslint-disable max-nested-callbacks -- Game state callbacks require deep nesting */ +/* eslint-disable import/exports-last -- BattleResult interface must be adjacent to GameContext */ +/* eslint-disable import/group-exports -- Context exports are naturally spread throughout the file */ +import { + STORY_CHAPTERS, + type Achievement, + type ApotheosisResponse, + type BossChallengeResponse, + type ExploreCollectResponse, + type GameState, + type LoginBonusResult, + type NumberFormat, + type TranscendenceResponse, + isStoryChapterUnlocked, +} from "@elysium/types"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type JSX, + type ReactNode, +} from "react"; +import { + achieveApotheosis as achieveApotheosisApi, + buyEchoUpgrade as buyEchoUpgradeApi, + buyPrestigeUpgrade as buyPrestigeUpgradeApi, + challengeBoss as challengeBossApi, + collectExploration as collectExplorationApi, + craftRecipe as craftRecipeApi, + loadGame, + prestige as prestigeApi, + resetProgress as resetProgressApi, + saveGame, + startExploration as startExplorationApi, + transcend as transcendApi, +} from "../api/client.js"; +import { CODEX_ENTRIES } from "../data/codex.js"; +import { EXPLORATION_AREAS } from "../data/explorations.js"; +import { RECIPES } from "../data/recipes.js"; +import { + RESOURCE_CAP, + applyTick, + calculateClickPower, +} from "../engine/tick.js"; +import { updateChallengeProgress } from "../utils/dailyChallenges.js"; +import { formatNumber as formatNumberUtil } from "../utils/format.js"; + +const autoSaveIntervalMs = 30_000; +const autoPrestigeThresholdBase = 1_000_000; +const autoPrestigeThresholdScale = 5; + +/** + * Pure function — applies a boss challenge result to the game state. + * Used by both the manual challengeBoss flow and the auto-boss tick logic. + * @param previous - The current game state. + * @param bossId - The ID of the boss being challenged. + * @param result - The result of the boss challenge. + * @returns The updated game state. + */ +const applyBossResult = ( + previous: GameState, + bossId: string, + result: BossChallengeResponse, +): GameState => { + if (result.won) { + const defeatedBoss = previous.bosses.find((b) => { + return b.id === bossId; + }); + const zoneBosses = previous.bosses.filter((b) => { + return b.zoneId === defeatedBoss?.zoneId; + }); + const zoneIndex = zoneBosses.findIndex((b) => { + return b.id === bossId; + }); + const nextZoneBossId = zoneBosses[zoneIndex + 1]?.id; + + const unlockedZones = previous.zones.filter((z) => { + if (z.status === "locked" && z.unlockBossId === bossId) { + const questOk + = z.unlockQuestId === null + || previous.quests.some((q) => { + return q.id === z.unlockQuestId && q.status === "completed"; + }); + return questOk; + } + return false; + }); + const zoneFirstBossIds = new Set( + unlockedZones. + map((z) => { + const firstBoss = previous.bosses.find((b) => { + return b.zoneId === z.id; + }); + return firstBoss?.id; + }). + filter(Boolean), + ); + + const challengeUpdate + = previous.dailyChallenges === undefined + ? { crystalsAwarded: 0, updatedChallenges: undefined } + : updateChallengeProgress( + previous.dailyChallenges, + "bossesDefeated", + 1, + ); + + const rewardIds: Array<string> = result.rewards?.upgradeIds ?? []; + const rewardEquipmentIds: Array<string> + = result.rewards?.equipmentIds ?? []; + + return { + ...previous, + bosses: previous.bosses.map((b) => { + if (b.id === bossId) { + return { ...b, currentHp: 0, status: "defeated" as const }; + } + if ( + b.id === nextZoneBossId + && b.prestigeRequirement <= previous.prestige.count + ) { + return { ...b, status: "available" as const }; + } + if ( + zoneFirstBossIds.has(b.id) + && b.prestigeRequirement <= previous.prestige.count + ) { + return { ...b, status: "available" as const }; + } + return b; + }), + zones: previous.zones.map((z) => { + if (z.status === "locked" && z.unlockBossId === bossId) { + const questOk + = z.unlockQuestId === null + || previous.quests.some((q) => { + return q.id === z.unlockQuestId && q.status === "completed"; + }); + return questOk + ? { ...z, status: "unlocked" as const } + : z; + } + return z; + }), + ...challengeUpdate.updatedChallenges === undefined + ? {} + : { dailyChallenges: challengeUpdate.updatedChallenges }, + equipment: previous.equipment.map((equip) => { + if (rewardEquipmentIds.includes(equip.id)) { + const slotEmpty = !previous.equipment.some((other) => { + return other.type === equip.type && other.equipped; + }); + return { + ...equip, + equipped: slotEmpty || equip.equipped, + owned: true, + }; + } + return equip; + }), + player: + result.rewards === undefined + ? previous.player + : { + ...previous.player, + totalGoldEarned: + previous.player.totalGoldEarned + result.rewards.gold, + }, + prestige: + result.rewards?.bountyRunestones === undefined + ? previous.prestige + : { + ...previous.prestige, + runestones: + previous.prestige.runestones + result.rewards.bountyRunestones, + }, + resources: + result.rewards === undefined + ? { + ...previous.resources, + crystals: + previous.resources.crystals + challengeUpdate.crystalsAwarded, + } + : { + ...previous.resources, + crystals: + previous.resources.crystals + + result.rewards.crystals + + challengeUpdate.crystalsAwarded, + essence: previous.resources.essence + result.rewards.essence, + gold: previous.resources.gold + result.rewards.gold, + }, + upgrades: previous.upgrades.map((u) => { + return rewardIds.includes(u.id) + ? { ...u, unlocked: true } + : u; + }), + }; + } + + // Loss: reset boss HP and apply casualties + return { + ...previous, + adventurers: previous.adventurers.map((a) => { + const casualty = result.casualties?.find((c) => { + return c.adventurerId === a.id; + }); + if (casualty === undefined) { + return a; + } + return { ...a, count: Math.max(0, a.count - casualty.killed) }; + }), + bosses: previous.bosses.map((b) => { + return b.id === bossId + ? { ...b, currentHp: b.maxHp, status: "available" as const } + : b; + }), + }; +}; + +interface GameContextValue { + state: GameState | null; + isLoading: boolean; + error: string | null; + + /** + * Click the crystal to earn gold. + */ + handleClick: ()=> void; + + /** + * Buy one or more of an adventurer tier; quantity > 1 buys in batch. + */ + buyAdventurer: (adventurerId: string, quantity: number)=> void; + + /** + * Buy an upgrade. + */ + buyUpgrade: (upgradeId: string)=> void; + + /** + * Purchase a buyable equipment item. + */ + buyEquipment: (equipmentId: string)=> void; + + /** + * Start a quest. + */ + startQuest: (questId: string)=> void; + + /** + * Challenge a boss — runs full server-side simulation. + */ + challengeBoss: (bossId: string)=> Promise<void>; + + /** + * Equip an owned equipment item (auto-unequips the same slot). + */ + equipItem: (equipmentId: string)=> void; + + /** + * Reload state from the server. + */ + reload: ()=> Promise<void>; + + /** + * Unix timestamp of the last successful cloud save (null until first save response). + */ + lastSavedAt: number | null; + + /** + * True whilst a forced save is in-flight. + */ + isSyncing: boolean; + + /** + * Immediately save to the server and reset the auto-save timer. + */ + forceSync: ()=> Promise<void>; + + /** + * Error message from the last failed cloud save (null when no error). + */ + syncError: string | null; + + /** + * Offline gold earned on login. + */ + offlineGold: number; + + /** + * Offline essence earned on login. + */ + offlineEssence: number; + + /** + * Dismiss the offline earnings notification. + */ + dismissOfflineGold: ()=> void; + + /** + * Battle result to display in the modal (null when no battle pending). + */ + battleResult: BattleResult | null; + + /** + * Dismiss the battle result modal. + */ + dismissBattle: ()=> void; + + /** + * Queue of newly unlocked achievements (for toasts). + */ + unlockedAchievements: Array<Achievement>; + + /** + * Remove an achievement from the toast queue. + */ + dismissAchievement: (id: string)=> void; + + /** + * The player's chosen number display format. + */ + numberFormat: NumberFormat; + + /** + * Update the number format preference (persisted to server via profile save). + */ + setNumberFormat: (format: NumberFormat)=> void; + + /** + * Format a number using the player's chosen notation style. + */ + formatNumber: (value: number)=> string; + + /** + * Buy a prestige upgrade from the runestone shop. + */ + buyPrestigeUpgrade: (upgradeId: string)=> Promise<void>; + + /** + * Toggle the auto-prestige setting on/off (requires auto_prestige upgrade). + */ + toggleAutoPrestige: ()=> void; + + /** + * Toggle the auto-quest setting on/off. + */ + toggleAutoQuest: ()=> void; + + /** + * Toggle the auto-boss setting on/off. + */ + toggleAutoBoss: ()=> void; + + /** + * Queue of newly unlocked codex entry IDs (for toast notifications). + */ + unlockedCodexEntryIds: Array<string>; + + /** + * Remove a codex entry ID from the notification queue. + */ + dismissCodexEntry: (id: string)=> void; + + /** + * Perform a transcendence — nuclear reset, earning echoes. + */ + transcend: ()=> Promise<TranscendenceResponse>; + + /** + * Buy an echo upgrade from the transcendence shop. + */ + buyEchoUpgrade: (upgradeId: string)=> Promise<void>; + + /** + * Achieve Apotheosis — the ultimate nuclear reset, bragging rights only. + */ + apotheosis: ()=> Promise<ApotheosisResponse>; + + /** + * Start an exploration in the given area. + */ + startExploration: (areaId: string)=> Promise<void>; + + /** + * Collect results of a completed exploration. + */ + collectExploration: (areaId: string)=> Promise<ExploreCollectResponse>; + + /** + * Craft a recipe using collected materials. + */ + craftRecipe: (recipeId: string)=> Promise<void>; + + /** + * Daily login bonus earned on this session load (null if already claimed today). + */ + loginBonus: LoginBonusResult | null; + + /** + * Player's current login streak (days). + */ + loginStreak: number; + + /** + * Dismiss the login bonus modal. + */ + dismissLoginBonus: ()=> void; + + /** + * Set the active companion (null to deactivate). + */ + setActiveCompanion: (companionId: string | null)=> void; + + /** + * Queue of newly unlocked story chapter IDs (for toast notifications). + */ + unlockedStoryChapterIds: Array<string>; + + /** + * Remove a chapter ID from the story notification queue. + */ + dismissStoryChapter: (id: string)=> void; + + /** + * Record the player's choice for a story chapter. + */ + completeChapter: (chapterId: string, choiceId: string)=> void; + + /** + * True when the loaded save is from an older schema version. + */ + schemaOutdated: boolean; + + /** + * The save's schema version (0 if missing). + */ + saveSchemaVersion: number; + + /** + * The server's current expected schema version. + */ + currentSchemaVersion: number; + + /** + * Reset all progress to a fresh save state (resolves schema outdated). + */ + resetProgress: ()=> Promise<void>; +} + +export interface BattleResult { + bossName: string; + result: BossChallengeResponse; +} + +const GameContext = createContext<GameContextValue | null>(null); + +/** + * Provides the game context to its children, managing all game state and actions. + * @param props - The provider properties. + * @param props.children - The child components that will have access to the game context. + * @returns The JSX element wrapping children with the game context. + */ +export const GameProvider = ({ + children, +}: { + readonly children: ReactNode; +}): JSX.Element => { + const [ state, setState ] = useState<GameState | null>(null); + const [ isLoading, setIsLoading ] = useState(true); + const [ error, setError ] = useState<string | null>(null); + const [ offlineGold, setOfflineGold ] = useState(0); + const [ offlineEssence, setOfflineEssence ] = useState(0); + const [ loginBonus, setLoginBonus ] = useState<LoginBonusResult | null>(null); + const [ loginStreak, setLoginStreak ] = useState(1); + const [ battleResult, setBattleResult ] = useState<BattleResult | null>(null); + const [ unlockedAchievements, setUnlockedAchievements ] = useState< + Array<Achievement> + >([]); + const [ lastSavedAt, setLastSavedAt ] = useState<number | null>(null); + const [ isSyncing, setIsSyncing ] = useState(false); + const [ syncError, setSyncError ] = useState<string | null>(null); + const syncErrorTimerReference = useRef<ReturnType<typeof setTimeout> | null>( + null, + ); + const [ numberFormat, setNumberFormat ] = useState<NumberFormat>("suffix"); + const stateReference = useRef<GameState | null>(null); + const lastSaveReference = useRef<number>(Date.now()); + const isSyncingReference = useRef(false); + const rafReference = useRef<number | null>(null); + const unlockedAchievementsReference = useRef<Array<Achievement>>([]); + const signatureReference = useRef<string | null>( + localStorage.getItem("elysium_save_signature"), + ); + const isAutoPrestigingReference = useRef(false); + const isAutoBossingReference = useRef(false); + const reloadReference = useRef<()=> Promise<void>>(async() => { + + /* No-op placeholder */ + }); + const [ schemaOutdated, setSchemaOutdated ] = useState(false); + const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0); + const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0); + const [ unlockedCodexEntryIds, setUnlockedCodexEntryIds ] = useState< + Array<string> + >([]); + const codexProcessedReference = useRef<Set<string>>(new Set()); + const [ unlockedStoryChapterIds, setUnlockedStoryChapterIds ] = useState< + Array<string> + >([]); + const storyProcessedReference = useRef<Set<string>>(new Set()); + const storyFirstRunReference = useRef(true); + + stateReference.current = state; + + const reload = useCallback(async() => { + setIsLoading(true); + setError(null); + try { + const data = await loadGame(); + setState(data.state); + setLastSavedAt(data.state.player.lastSavedAt); + if (data.signature !== undefined) { + signatureReference.current = data.signature; + localStorage.setItem("elysium_save_signature", data.signature); + } + if (data.offlineGold > 0) { + setOfflineGold(data.offlineGold); + } + if (data.offlineEssence > 0) { + setOfflineEssence(data.offlineEssence); + } + if (data.loginBonus !== null) { + setLoginBonus(data.loginBonus); + } + setLoginStreak(data.loginStreak); + setSchemaOutdated(data.schemaOutdated); + setSaveSchemaVersion(data.state.schemaVersion ?? 0); + setCurrentSchemaVersion(data.currentSchemaVersion); + + // Fetch number format preference from profile (fire-and-forget, non-blocking) + void fetch(`/api/profile/${data.state.player.discordId}`). + then(async(response) => { + if (!response.ok) { + return; + } + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response cast + const profile = (await response.json()) as { + profileSettings?: { numberFormat?: NumberFormat }; + }; + const fmt = profile.profileSettings?.numberFormat; + if ( + fmt === "suffix" + || fmt === "scientific" + || fmt === "engineering" + ) { + setNumberFormat(fmt); + } + }). + catch(() => { + + /* Fall back to default "suffix" */ + }); + } catch (error_: unknown) { + setError( + error_ instanceof Error + ? error_.message + : "Failed to load game", + ); + } finally { + setIsLoading(false); + } + }, []); + + reloadReference.current = reload; + + useEffect(() => { + void reload(); + }, [ reload ]); + + // Detect newly defeated bosses and completed quests to unlock Codex entries + useEffect(() => { + if (state === null) { + return; + } + + const existingUnlocked = state.codex?.unlockedEntryIds ?? []; + // On first run (empty processed set), silently unlock existing completions + const isFirstRun = codexProcessedReference.current.size === 0; + const addedIds: Array<string> = []; + + for (const boss of state.bosses) { + const codexId = `boss_${boss.id}`; + if ( + boss.status === "defeated" + && !codexProcessedReference.current.has(codexId) + ) { + codexProcessedReference.current.add(codexId); + if ( + !existingUnlocked.includes(codexId) + && CODEX_ENTRIES.some((entry) => { + return entry.id === codexId; + }) + ) { + addedIds.push(codexId); + } + } + } + + for (const quest of state.quests) { + const codexId = `quest_${quest.id}`; + if ( + quest.status === "completed" + && !codexProcessedReference.current.has(codexId) + ) { + codexProcessedReference.current.add(codexId); + if ( + !existingUnlocked.includes(codexId) + && CODEX_ENTRIES.some((entry) => { + return entry.id === codexId; + }) + ) { + addedIds.push(codexId); + } + } + } + + for (const zone of state.zones) { + const codexId = `zone_${zone.id}`; + if ( + zone.status === "unlocked" + && !codexProcessedReference.current.has(codexId) + ) { + codexProcessedReference.current.add(codexId); + if ( + !existingUnlocked.includes(codexId) + && CODEX_ENTRIES.some((entry) => { + return entry.id === codexId; + }) + ) { + addedIds.push(codexId); + } + } + } + + for (const equip of state.equipment) { + const codexId = `equipment_${equip.id}`; + if (equip.owned && !codexProcessedReference.current.has(codexId)) { + codexProcessedReference.current.add(codexId); + if ( + !existingUnlocked.includes(codexId) + && CODEX_ENTRIES.some((entry) => { + return entry.id === codexId; + }) + ) { + addedIds.push(codexId); + } + } + } + + for (const adventurer of state.adventurers) { + const codexId = `adventurer_${adventurer.id}`; + if ( + adventurer.count > 0 + && !codexProcessedReference.current.has(codexId) + ) { + codexProcessedReference.current.add(codexId); + if ( + !existingUnlocked.includes(codexId) + && CODEX_ENTRIES.some((entry) => { + return entry.id === codexId; + }) + ) { + addedIds.push(codexId); + } + } + } + + for (const upgrade of state.upgrades) { + const codexId = `upgrade_${upgrade.id}`; + if (upgrade.purchased && !codexProcessedReference.current.has(codexId)) { + codexProcessedReference.current.add(codexId); + if ( + !existingUnlocked.includes(codexId) + && CODEX_ENTRIES.some((entry) => { + return entry.id === codexId; + }) + ) { + addedIds.push(codexId); + } + } + } + + for (const prestigeId of state.prestige.purchasedUpgradeIds) { + const codexId = `prestige_${prestigeId}`; + if (!codexProcessedReference.current.has(codexId)) { + codexProcessedReference.current.add(codexId); + if ( + !existingUnlocked.includes(codexId) + && CODEX_ENTRIES.some((entry) => { + return entry.id === codexId; + }) + ) { + addedIds.push(codexId); + } + } + } + + for (const area of state.exploration?.areas ?? []) { + const codexId = `explore_${area.id}`; + if (area.completedOnce === true + && !codexProcessedReference.current.has(codexId) + ) { + codexProcessedReference.current.add(codexId); + if ( + !existingUnlocked.includes(codexId) + && CODEX_ENTRIES.some((entry) => { + return entry.id === codexId; + }) + ) { + addedIds.push(codexId); + } + } + } + + for (const recipeId of state.exploration?.craftedRecipeIds ?? []) { + const codexId = `recipe_${recipeId}`; + if (!codexProcessedReference.current.has(codexId)) { + codexProcessedReference.current.add(codexId); + if ( + !existingUnlocked.includes(codexId) + && CODEX_ENTRIES.some((entry) => { + return entry.id === codexId; + }) + ) { + addedIds.push(codexId); + } + } + } + + if (addedIds.length > 0) { + setState((previous) => { + if (previous === null) { + return previous; + } + const existing = previous.codex?.unlockedEntryIds ?? []; + const toAdd = addedIds.filter((id) => { + return !existing.includes(id); + }); + if (toAdd.length === 0) { + return previous; + } + return { + ...previous, + codex: { unlockedEntryIds: [ ...existing, ...toAdd ] }, + }; + }); + if (!isFirstRun) { + setUnlockedCodexEntryIds((previous) => { + return [ ...previous, ...addedIds ]; + }); + } + } + }, [ state ]); + + // Detect newly unlocked story chapters + useEffect(() => { + if (state === null) { + return; + } + + // On first run, populate the ref from existing unlocked IDs without toasting + if (storyFirstRunReference.current) { + storyFirstRunReference.current = false; + const existing = state.story?.unlockedChapterIds ?? []; + for (const id of existing) { + storyProcessedReference.current.add(id); + } + return; + } + + const addedChapterIds: Array<string> = []; + for (const chapter of STORY_CHAPTERS) { + if (storyProcessedReference.current.has(chapter.id)) { + continue; + } + if (isStoryChapterUnlocked(chapter, state)) { + storyProcessedReference.current.add(chapter.id); + addedChapterIds.push(chapter.id); + } + } + + if (addedChapterIds.length === 0) { + return; + } + + setUnlockedStoryChapterIds((previous) => { + return [ ...previous, ...addedChapterIds ]; + }); + setState((previous) => { + if (previous === null) { + return previous; + } + const existing = previous.story?.unlockedChapterIds ?? []; + const toAdd = addedChapterIds.filter((id) => { + return !existing.includes(id); + }); + if (toAdd.length === 0) { + return previous; + } + return { + ...previous, + story: { + completedChapters: previous.story?.completedChapters ?? [], + unlockedChapterIds: [ ...existing, ...toAdd ], + }, + }; + }); + }, [ state ]); + + // Game loop via requestAnimationFrame + + useEffect(() => { + if (state === null) { + return; + } + + let lastTime = performance.now(); + + const tick = (now: number): void => { + const deltaSeconds = (now - lastTime) / 1000; + lastTime = now; + + setState((previous) => { + if (previous === null) { + return previous; + } + let next = applyTick(previous, deltaSeconds); + + // Auto-quest: start the highest-zone available quest when none is active + if (next.autoQuest === true) { + const hasActiveQuest = next.quests.some((q) => { + return q.status === "active"; + }); + if (!hasActiveQuest) { + // eslint-disable-next-line unicorn/no-array-reduce -- Need the total! + const partyCombatPower = next.adventurers.reduce((total, a) => { + const power = total + a.combatPower; + return power * a.count; + }, 0); + const zoneOrder = new Map( + next.zones.map((z, index) => { + return [ z.id, index ]; + }), + ); + const candidates = next.quests. + filter((q) => { + return ( + q.status === "available" + && (q.combatPowerRequired ?? 0) <= partyCombatPower + ); + }). + sort((questA, questB) => { + return ( + (zoneOrder.get(questB.zoneId) ?? 0) + - (zoneOrder.get(questA.zoneId) ?? 0) + ); + }); + const [ best ] = candidates; + if (best !== undefined) { + next = { + ...next, + quests: next.quests.map((q) => { + return q.id === best.id + ? { ...q, startedAt: Date.now(), status: "active" as const } + : q; + }), + }; + } + } + } + + // Detect newly unlocked achievements + unlockedAchievementsReference.current = next.achievements.filter( + (a, index) => { + const wasLocked + = previous.achievements[index]?.unlockedAt === null; + return wasLocked && a.unlockedAt !== null; + }, + ); + + return next; + }); + + if (unlockedAchievementsReference.current.length > 0) { + setUnlockedAchievements((previous) => { + return [ ...previous, ...unlockedAchievementsReference.current ]; + }); + unlockedAchievementsReference.current = []; + } + + // Auto-save every 30 seconds (skip if a force sync is in-flight to avoid signature collisions) + if (Date.now() - lastSaveReference.current >= autoSaveIntervalMs) { + lastSaveReference.current = Date.now(); + if (stateReference.current !== null && !isSyncingReference.current) { + void saveGame({ + state: stateReference.current, + ...signatureReference.current === null + ? {} + : { signature: signatureReference.current }, + }). + then((response) => { + setLastSavedAt(response.savedAt); + if (response.signature !== undefined) { + signatureReference.current = response.signature; + localStorage.setItem( + "elysium_save_signature", + response.signature, + ); + } + }). + catch((error_: unknown) => { + // Silently clear a bad signature so the next auto-save can proceed + if ( + error_ instanceof Error + && error_.message.includes("signature mismatch") + ) { + signatureReference.current = null; + localStorage.removeItem("elysium_save_signature"); + } + }); + } + } + + // Auto-prestige: fire when unlocked, enabled, and threshold is met + const autoState = stateReference.current; + if ( + !isAutoPrestigingReference.current + && autoState?.prestige.purchasedUpgradeIds.includes("auto_prestige") + === true + && autoState.prestige.autoPrestigeEnabled === true + && autoState.player.totalGoldEarned + >= autoPrestigeThresholdBase + * Math.pow(autoPrestigeThresholdScale, autoState.prestige.count) + ) { + isAutoPrestigingReference.current = true; + void prestigeApi({}). + then(async() => { + await reloadReference.current(); + }). + catch(() => { + + /* Silently ignore — will retry next tick */ + }). + finally(() => { + isAutoPrestigingReference.current = false; + }); + } + + // Auto-boss: challenge the highest-zone available boss when not already fighting + if (!isAutoBossingReference.current && autoState?.autoBoss === true) { + const prestigeCount = autoState.prestige.count; + const zoneOrder = new Map( + autoState.zones.map((z, index) => { + return [ z.id, index ]; + }), + ); + const [ availableBoss ] = autoState.bosses. + filter((b) => { + return ( + b.status === "available" && b.prestigeRequirement <= prestigeCount + ); + }). + sort((bossA, bossB) => { + return ( + (zoneOrder.get(bossB.zoneId) ?? 0) + - (zoneOrder.get(bossA.zoneId) ?? 0) + ); + }); + if (availableBoss !== undefined) { + const { id: bossId, name: bossName } = availableBoss; + isAutoBossingReference.current = true; + void challengeBossApi({ bossId }). + then((result) => { + setState((previous) => { + if (previous === null) { + return previous; + } + return applyBossResult(previous, bossId, result); + }); + setBattleResult({ bossName, result }); + }). + catch(() => { + + /* Silently ignore — will retry next tick */ + }). + finally(() => { + isAutoBossingReference.current = false; + }); + } + } + + rafReference.current = requestAnimationFrame(tick); + }; + + rafReference.current = requestAnimationFrame(tick); + // eslint-disable-next-line consistent-return -- useEffect cleanup requires mixed return pattern + return (): void => { + if (rafReference.current !== null) { + cancelAnimationFrame(rafReference.current); + } + }; + }, [ state !== null ]); + + const showSyncError = useCallback((message: string) => { + setSyncError(message); + if (syncErrorTimerReference.current !== null) { + clearTimeout(syncErrorTimerReference.current); + } + syncErrorTimerReference.current = setTimeout(() => { + setSyncError(null); + }, 5000); + }, []); + + const clearBadSignature = useCallback(() => { + signatureReference.current = null; + localStorage.removeItem("elysium_save_signature"); + }, []); + + const forceSync = useCallback(async() => { + if (stateReference.current === null || isSyncingReference.current) { + return; + } + + isSyncingReference.current = true; + // Push auto-save timer back so it doesn't fire concurrently + lastSaveReference.current = Date.now(); + setIsSyncing(true); + try { + const response = await saveGame({ + state: stateReference.current, + ...signatureReference.current === null + ? {} + : { signature: signatureReference.current }, + }); + setSyncError(null); + setLastSavedAt(response.savedAt); + lastSaveReference.current = Date.now(); + if (response.signature !== undefined) { + // eslint-disable-next-line require-atomic-updates -- ref update is intentional after await + signatureReference.current = response.signature; + localStorage.setItem("elysium_save_signature", response.signature); + } + } catch (error_: unknown) { + const message = error_ instanceof Error + ? error_.message + : "Save failed"; + showSyncError(message); + if (message.includes("signature mismatch")) { + clearBadSignature(); + } + } finally { + // eslint-disable-next-line require-atomic-updates -- ref update is intentional in finally block + isSyncingReference.current = false; + setIsSyncing(false); + } + }, [ showSyncError, clearBadSignature ]); + + const handleClick = useCallback(() => { + setState((previous) => { + if (previous === null) { + return previous; + } + const clickPower = calculateClickPower(previous); + const clickedGold = Math.min( + previous.resources.gold + clickPower, + RESOURCE_CAP, + ); + + let updatedDailyChallenges = previous.dailyChallenges; + let challengeCrystals = 0; + if (updatedDailyChallenges !== undefined) { + const result = updateChallengeProgress( + updatedDailyChallenges, + "clicks", + 1, + ); + updatedDailyChallenges = result.updatedChallenges; + challengeCrystals = result.crystalsAwarded; + } + + return { + ...previous, + player: { + ...previous.player, + totalClicks: previous.player.totalClicks + 1, + totalGoldEarned: previous.player.totalGoldEarned + clickPower, + }, + resources: { + ...previous.resources, + crystals: Math.min( + previous.resources.crystals + challengeCrystals, + RESOURCE_CAP, + ), + gold: clickedGold, + }, + ...updatedDailyChallenges === undefined + ? {} + : { dailyChallenges: updatedDailyChallenges }, + }; + }); + }, []); + + const buyAdventurer = useCallback( + (adventurerId: string, quantity: number) => { + setState((previous) => { + if (previous === null) { + return previous; + } + const adventurer = previous.adventurers.find((a) => { + return a.id === adventurerId; + }); + if (adventurer?.unlocked !== true) { + return previous; + } + + let { gold } = previous.resources; + const { baseCost } = adventurer; + let { count } = adventurer; + let purchased = 0; + + for (let index = 0; index < quantity; index = index + 1) { + const cost = baseCost * Math.pow(1.15, count); + if (gold < cost) { + break; + } + gold = gold - cost; + count = count + 1; + purchased = purchased + 1; + } + + if (purchased === 0) { + return previous; + } + + return { + ...previous, + adventurers: previous.adventurers.map((a) => { + return a.id === adventurerId + ? { ...a, count } + : a; + }), + resources: { ...previous.resources, gold }, + }; + }); + }, + [], + ); + + const buyUpgrade = useCallback((upgradeId: string) => { + setState((previous) => { + if (previous === null) { + return previous; + } + const upgrade = previous.upgrades.find((u) => { + return u.id === upgradeId; + }); + if (upgrade === undefined || !upgrade.unlocked || upgrade.purchased) { + return previous; + } + if (previous.resources.gold < upgrade.costGold) { + return previous; + } + if (previous.resources.essence < upgrade.costEssence) { + return previous; + } + if (previous.resources.crystals < upgrade.costCrystals) { + return previous; + } + + return { + ...previous, + resources: { + ...previous.resources, + crystals: previous.resources.crystals - upgrade.costCrystals, + essence: previous.resources.essence - upgrade.costEssence, + gold: previous.resources.gold - upgrade.costGold, + }, + upgrades: previous.upgrades.map((u) => { + return u.id === upgradeId + ? { ...u, purchased: true } + : u; + }), + }; + }); + }, []); + + const startQuest = useCallback((questId: string) => { + setState((previous) => { + if (previous === null) { + return previous; + } + const quest = previous.quests.find((q) => { + return q.id === questId; + }); + if (quest === undefined || quest.status !== "available") { + return previous; + } + + return { + ...previous, + quests: previous.quests.map((q) => { + return q.id === questId + ? { ...q, startedAt: Date.now(), status: "active" as const } + : q; + }), + }; + }); + }, []); + + const equipItem = useCallback((equipmentId: string) => { + setState((previous) => { + if (previous === null) { + return previous; + } + const item = previous.equipment.find((equip) => { + return equip.id === equipmentId; + }); + if (item?.owned !== true) { + return previous; + } + + return { + ...previous, + equipment: previous.equipment.map((equip) => { + if (equip.id === equipmentId) { + return { ...equip, equipped: true }; + } + // Unequip the previously-equipped item in the same slot + if (equip.type === item.type && equip.equipped) { + return { ...equip, equipped: false }; + } + return equip; + }), + }; + }); + }, []); + + const buyEquipment = useCallback((equipmentId: string) => { + setState((previous) => { + if (previous === null) { + return previous; + } + const item = previous.equipment.find((equip) => { + return equip.id === equipmentId; + }); + if (item === undefined || item.owned || item.cost === undefined) { + return previous; + } + + const { gold, essence, crystals } = item.cost; + if (previous.resources.gold < gold) { + return previous; + } + if (previous.resources.essence < essence) { + return previous; + } + if (previous.resources.crystals < crystals) { + return previous; + } + + const slotAlreadyEquipped = previous.equipment.some((equip) => { + return equip.type === item.type && equip.equipped; + }); + + return { + ...previous, + equipment: previous.equipment.map((equip) => { + if (equip.id === equipmentId) { + return { ...equip, equipped: !slotAlreadyEquipped, owned: true }; + } + return equip; + }), + resources: { + ...previous.resources, + crystals: previous.resources.crystals - crystals, + essence: previous.resources.essence - essence, + gold: previous.resources.gold - gold, + }, + }; + }); + }, []); + + const buyPrestigeUpgrade = useCallback(async(upgradeId: string) => { + try { + const result = await buyPrestigeUpgradeApi({ upgradeId }); + setState((previous) => { + if (previous === null) { + return previous; + } + return { + ...previous, + prestige: { + ...previous.prestige, + purchasedUpgradeIds: result.purchasedUpgradeIds, + runestones: result.runestonesRemaining, + runestonesClickMultiplier: result.runestonesClickMultiplier, + runestonesCrystalMultiplier: result.runestonesCrystalMultiplier, + runestonesEssenceMultiplier: result.runestonesEssenceMultiplier, + runestonesIncomeMultiplier: result.runestonesIncomeMultiplier, + }, + }; + }); + } catch { + // Silently ignore — server errors shouldn't crash the UI + } + }, []); + + const transcend = useCallback(async() => { + const result = await transcendApi({}); + await reload(); + return result; + }, [ reload ]); + + const apotheosis = useCallback(async() => { + const result = await achieveApotheosisApi({}); + await reload(); + return result; + }, [ reload ]); + + const buyEchoUpgrade = useCallback(async(upgradeId: string) => { + try { + const result = await buyEchoUpgradeApi({ upgradeId }); + setState((previous) => { + if (previous?.transcendence === undefined) { + return previous; + } + return { + ...previous, + transcendence: { + ...previous.transcendence, + echoCombatMultiplier: result.echoCombatMultiplier, + echoIncomeMultiplier: result.echoIncomeMultiplier, + echoMetaMultiplier: result.echoMetaMultiplier, + echoPrestigeRunestoneMultiplier: + result.echoPrestigeRunestoneMultiplier, + echoPrestigeThresholdMultiplier: + result.echoPrestigeThresholdMultiplier, + echoes: result.echoesRemaining, + purchasedUpgradeIds: result.purchasedUpgradeIds, + }, + }; + }); + } catch { + // Silently ignore server errors + } + }, []); + + const startExploration = useCallback(async(areaId: string) => { + const response = await startExplorationApi({ areaId }); + const areaData = EXPLORATION_AREAS.find((a) => { + return a.id === areaId; + }); + if (areaData === undefined) { + return; + } + // eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear + const startedAt = response.endsAt - areaData.durationSeconds * 1000; + setState((previous) => { + if (previous?.exploration === undefined) { + return previous; + } + return { + ...previous, + exploration: { + ...previous.exploration, + areas: previous.exploration.areas.map((a) => { + return a.id === areaId + ? { ...a, startedAt: startedAt, status: "in_progress" as const } + : a; + }), + }, + }; + }); + }, []); + + const collectExploration = useCallback( + async(areaId: string): Promise<ExploreCollectResponse> => { + const result = await collectExplorationApi({ areaId }); + setState((previous) => { + if (previous?.exploration === undefined) { + return previous; + } + let materials = [ ...previous.exploration.materials ]; + + // Apply material drops from the random loot roll + for (const drop of result.materialsFound) { + const existing = materials.find((mat) => { + return mat.materialId === drop.materialId; + }); + if (existing === undefined) { + materials = [ + ...materials, + { materialId: drop.materialId, quantity: drop.quantity }, + ]; + } else { + materials = materials.map((mat) => { + return mat.materialId === drop.materialId + ? { ...mat, quantity: mat.quantity + drop.quantity } + : mat; + }); + } + } + + // Apply material from event (if any) + const materialGained = result.event?.materialGained; + if (materialGained !== null && materialGained !== undefined) { + const { materialId, quantity } = materialGained; + const existing = materials.find((mat) => { + return mat.materialId === materialId; + }); + if (existing === undefined) { + materials = [ ...materials, { materialId, quantity } ]; + } else { + materials = materials.map((mat) => { + return mat.materialId === materialId + ? { ...mat, quantity: mat.quantity + quantity } + : mat; + }); + } + } + + return { + ...previous, + exploration: { + ...previous.exploration, + areas: previous.exploration.areas.map((a) => { + return a.id === areaId + ? { ...a, completedOnce: true, status: "available" as const } + : a; + }), + materials: materials, + }, + player: { + ...previous.player, + totalGoldEarned: + previous.player.totalGoldEarned + + Math.max(0, result.event?.goldChange ?? 0), + }, + resources: { + ...previous.resources, + essence: + previous.resources.essence + (result.event?.essenceChange ?? 0), + gold: Math.max( + 0, + previous.resources.gold + (result.event?.goldChange ?? 0), + ), + }, + }; + }); + return result; + }, + [], + ); + + const craftRecipe = useCallback(async(recipeId: string) => { + const recipe = RECIPES.find((r) => { + return r.id === recipeId; + }); + if (recipe === undefined) { + return; + } + const result = await craftRecipeApi({ recipeId }); + setState((previous) => { + if (previous?.exploration === undefined) { + return previous; + } + let materials = [ ...previous.exploration.materials ]; + for (const request of recipe.requiredMaterials) { + materials = materials.map((mat) => { + return mat.materialId === request.materialId + ? { ...mat, quantity: mat.quantity - request.quantity } + : mat; + }); + } + return { + ...previous, + exploration: { + ...previous.exploration, + craftedClickMultiplier: result.craftedClickMultiplier, + craftedCombatMultiplier: result.craftedCombatMultiplier, + craftedEssenceMultiplier: result.craftedEssenceMultiplier, + craftedGoldMultiplier: result.craftedGoldMultiplier, + craftedRecipeIds: [ + ...previous.exploration.craftedRecipeIds, + recipeId, + ], + materials: materials, + }, + }; + }); + }, []); + + const toggleAutoPrestige = useCallback(() => { + setState((previous) => { + if (previous === null) { + return previous; + } + return { + ...previous, + prestige: { + ...previous.prestige, + autoPrestigeEnabled: previous.prestige.autoPrestigeEnabled !== true, + }, + }; + }); + }, []); + + const toggleAutoQuest = useCallback(() => { + setState((previous) => { + if (previous === null) { + return previous; + } + return { ...previous, autoQuest: previous.autoQuest !== true }; + }); + }, []); + + const toggleAutoBoss = useCallback(() => { + setState((previous) => { + if (previous === null) { + return previous; + } + return { ...previous, autoBoss: previous.autoBoss !== true }; + }); + }, []); + + const setActiveCompanion = useCallback((companionId: string | null) => { + setState((previous) => { + if (previous === null) { + return previous; + } + const unlockedIds = previous.companions?.unlockedCompanionIds ?? []; + const validatedId + = companionId !== null && unlockedIds.includes(companionId) + ? companionId + : null; + return { + ...previous, + companions: { + activeCompanionId: validatedId, + unlockedCompanionIds: unlockedIds, + }, + }; + }); + }, []); + + const challengeBoss = useCallback(async(bossId: string) => { + if (stateReference.current === null) { + return; + } + const boss = stateReference.current.bosses.find((b) => { + return b.id === bossId; + }); + if (boss === undefined) { + return; + } + + try { + const result = await challengeBossApi({ bossId }); + setState((previous) => { + if (previous === null) { + return previous; + } + return applyBossResult(previous, bossId, result); + }); + setBattleResult({ bossName: boss.name, result: result }); + } catch { + // Silently ignore — server errors shouldn't crash the UI + } + }, []); + + const dismissOfflineGold = useCallback(() => { + setOfflineGold(0); + setOfflineEssence(0); + }, []); + + const dismissBattle = useCallback(() => { + setBattleResult(null); + }, []); + + const dismissAchievement = useCallback((id: string) => { + setUnlockedAchievements((previous) => { + return previous.filter((a) => { + return a.id !== id; + }); + }); + }, []); + + const dismissCodexEntry = useCallback((id: string) => { + setUnlockedCodexEntryIds((previous) => { + return previous.filter((entry) => { + return entry !== id; + }); + }); + }, []); + + const dismissStoryChapter = useCallback((id: string) => { + setUnlockedStoryChapterIds((previous) => { + return previous.filter((chapter) => { + return chapter !== id; + }); + }); + }, []); + + const completeChapter = useCallback((chapterId: string, choiceId: string) => { + setState((previous) => { + if (previous === null) { + return previous; + } + const already = previous.story?.completedChapters ?? []; + if ( + already.some((chapter) => { + return chapter.chapterId === chapterId; + }) + ) { + return previous; + } + return { + ...previous, + story: { + completedChapters: [ ...already, { chapterId, choiceId } ], + unlockedChapterIds: previous.story?.unlockedChapterIds ?? [], + }, + }; + }); + }, []); + + const resetProgress = useCallback(async() => { + setIsLoading(true); + setError(null); + try { + const data = await resetProgressApi(); + setState(data.state); + setLastSavedAt(data.state.player.lastSavedAt); + setSchemaOutdated(false); + setOfflineGold(0); + setOfflineEssence(0); + setLoginBonus(null); + if (data.signature !== undefined) { + signatureReference.current = data.signature; + localStorage.setItem("elysium_save_signature", data.signature); + } + } catch (error_: unknown) { + setError( + error_ instanceof Error + ? error_.message + : "Failed to reset progress", + ); + } finally { + setIsLoading(false); + } + }, []); + + const dismissLoginBonus = useCallback(() => { + setLoginBonus(null); + }, []); + + const formatNumber = useCallback( + (value: number) => { + return formatNumberUtil(value, numberFormat); + }, + [ numberFormat ], + ); + + const contextValue = useMemo<GameContextValue>(() => { + return { + apotheosis, + battleResult, + buyAdventurer, + buyEchoUpgrade, + buyEquipment, + buyPrestigeUpgrade, + buyUpgrade, + challengeBoss, + collectExploration, + completeChapter, + craftRecipe, + currentSchemaVersion, + dismissAchievement, + dismissBattle, + dismissCodexEntry, + dismissLoginBonus, + dismissOfflineGold, + dismissStoryChapter, + equipItem, + error, + forceSync, + formatNumber, + handleClick, + isLoading, + isSyncing, + lastSavedAt, + loginBonus, + loginStreak, + numberFormat, + offlineEssence, + offlineGold, + reload, + resetProgress, + saveSchemaVersion, + schemaOutdated, + setActiveCompanion, + setNumberFormat, + startExploration, + startQuest, + state, + syncError, + toggleAutoBoss, + toggleAutoPrestige, + toggleAutoQuest, + transcend, + unlockedAchievements, + unlockedCodexEntryIds, + unlockedStoryChapterIds, + }; + }, [ + apotheosis, + battleResult, + formatNumber, + buyAdventurer, + buyEchoUpgrade, + buyEquipment, + buyPrestigeUpgrade, + buyUpgrade, + challengeBoss, + collectExploration, + completeChapter, + craftRecipe, + currentSchemaVersion, + dismissAchievement, + dismissBattle, + dismissCodexEntry, + dismissLoginBonus, + dismissOfflineGold, + dismissStoryChapter, + equipItem, + error, + forceSync, + handleClick, + isLoading, + isSyncing, + lastSavedAt, + loginBonus, + loginStreak, + numberFormat, + offlineEssence, + offlineGold, + reload, + resetProgress, + saveSchemaVersion, + schemaOutdated, + setActiveCompanion, + setNumberFormat, + startExploration, + startQuest, + state, + syncError, + toggleAutoBoss, + toggleAutoPrestige, + toggleAutoQuest, + transcend, + unlockedAchievements, + unlockedCodexEntryIds, + unlockedStoryChapterIds, + ]); + + return ( + <GameContext.Provider value={contextValue}>{children}</GameContext.Provider> + ); +}; + +/** + * Returns the game context value. + * @returns The game context value. + * @throws {Error} When used outside a GameProvider. + */ +export const useGame = (): GameContextValue => { + const context = useContext(GameContext); + if (context === null) { + throw new Error("useGame must be used within a GameProvider"); + } + return context; +}; diff --git a/apps/web/src/data/codex.ts b/apps/web/src/data/codex.ts index 13b61d0..3cc5fd9 100644 --- a/apps/web/src/data/codex.ts +++ b/apps/web/src/data/codex.ts @@ -1,4432 +1,4448 @@ +/** + * @file Codex data entries for all game lore and zone labels. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines -- Data file necessarily exceeds line limit */ +/* eslint-disable stylistic/max-len -- Long lore strings cannot be split */ +/* eslint-disable @typescript-eslint/naming-convention -- Snake_case IDs and SCREAMING_SNAKE are conventional for game data */ +/* eslint-disable sort-keys -- Game data uses natural game ordering */ +/* eslint-disable sort-keys-fix/sort-keys-fix -- Game data uses natural game ordering */ +/* eslint-disable import/group-exports -- Data file has multiple export declarations by design */ import type { CodexEntry } from "@elysium/types"; export const ZONE_LABELS: Record<string, string> = { - verdant_vale: "Verdant Vale", - shattered_ruins: "Shattered Ruins", - frozen_peaks: "Frozen Peaks", - shadow_marshes: "Shadow Marshes", - volcanic_depths: "Volcanic Depths", - astral_void: "Astral Void", - abyssal_trench: "Abyssal Trench", + abyssal_trench: "Abyssal Trench", + astral_void: "Astral Void", celestial_reaches: "Celestial Reaches", - cosmic_maelstrom: "Cosmic Maelstrom", + cosmic_maelstrom: "Cosmic Maelstrom", crystalline_spire: "Crystalline Spire", - eternal_throne: "Eternal Throne", - infernal_court: "Infernal Court", - infinite_expanse: "Infinite Expanse", - primeval_sanctum: "Primeval Sanctum", - primordial_chaos: "Primordial Chaos", + eternal_throne: "Eternal Throne", + frozen_peaks: "Frozen Peaks", + infernal_court: "Infernal Court", + shattered_ruins: "Shattered Ruins", + guild_arsenal: "⚙️ Guild Arsenal", + verdant_vale: "Verdant Vale", + guild_library: "🔧 Guild Library", + shadow_marshes: "Shadow Marshes", + infinite_expanse: "Infinite Expanse", + volcanic_depths: "Volcanic Depths", + prestige_archive: "🔮 Prestige Archive", + primeval_sanctum: "Primeval Sanctum", + primordial_chaos: "Primordial Chaos", + reality_forge: "Reality Forge", + the_absolute: "The Absolute", + + the_roster: "👥 The Roster", + void_sanctum: "Void Sanctum", // Non-zone sections - world_atlas: "🗺️ World Atlas", - guild_arsenal: "⚙️ Guild Arsenal", - the_roster: "👥 The Roster", - guild_library: "🔧 Guild Library", - prestige_archive: "🔮 Prestige Archive", + world_atlas: "🗺️ World Atlas", }; -export const CODEX_ENTRIES: CodexEntry[] = [ +export const CODEX_ENTRIES: Array<CodexEntry> = [ // ── Verdant Vale — Bosses ───────────────────────────────────────────────── { - id: "boss_troll_king", - title: "Gruk the Immovable: A History", content: "Gruk was no common troll — he had held the northern trade roads for thirty-seven years, extorting caravans with a patience unusual for his kind. Scholars believe he learned to count specifically so he could demand exact change. The merchants who commissioned his defeat quietly noted that his hoard, recovered from beneath the bridge, covered their losses twice over.", + id: "boss_troll_king", + sourceId: "troll_king", sourceType: "boss", - sourceId: "troll_king", - zoneId: "verdant_vale", + title: "Gruk the Immovable: A History", + zoneId: "verdant_vale", }, { - id: "boss_lich_queen", - title: "Seraphina the Undying: Her Final Death", content: "Seraphina had died twice before — once of fever at twenty-three, and once at the hands of her own apprentice at forty-one. Both times she chose to return, binding herself to her own phylactery with increasing desperation. When her bone throne finally collapsed, those who combed the wreckage found a locket containing a portrait of someone she had once loved, pressed against her chest for five centuries.", + id: "boss_lich_queen", + sourceId: "lich_queen", sourceType: "boss", - sourceId: "lich_queen", - zoneId: "verdant_vale", + title: "Seraphina the Undying: Her Final Death", + zoneId: "verdant_vale", }, { - id: "boss_forest_giant", - title: "The Colossus Beneath the Vale", content: "The Forest Giant had no name in any living language — the oldest folktales called it the Root-Walker, a guardian who predated the first human settlements in the Vale by millennia. Geologists later determined that its slumber had coincided exactly with the last ice age. Whatever woke it after all those centuries left no trace in the historical record.", + id: "boss_forest_giant", + sourceId: "forest_giant", sourceType: "boss", - sourceId: "forest_giant", - zoneId: "verdant_vale", + title: "The Colossus Beneath the Vale", + zoneId: "verdant_vale", }, // ── Verdant Vale — Quests ───────────────────────────────────────────────── { - id: "quest_first_steps", - title: "The First Chronicle Entry", content: "Every guild begins with a single venture into the unknown. The first adventurers dispatched from your hall returned with muddy boots, a handful of stories, and an unshakeable sense that the world was larger than they had previously assumed. This is always how it begins.", + id: "quest_first_steps", + sourceId: "first_steps", sourceType: "quest", - sourceId: "first_steps", - zoneId: "verdant_vale", + title: "The First Chronicle Entry", + zoneId: "verdant_vale", }, { - id: "quest_goblin_camp", - title: "The Eastern Goblin Clans", content: "What appeared to be a raiding camp turned out to be a semi-permanent settlement — the goblins had been there for two generations, farming root vegetables and trading stolen goods along a surprisingly organised route. The camp elder surrendered without resistance and asked only that their seed stores not be burned. Your adventurers honoured the request.", + id: "quest_goblin_camp", + sourceId: "goblin_camp", sourceType: "quest", - sourceId: "goblin_camp", - zoneId: "verdant_vale", + title: "The Eastern Goblin Clans", + zoneId: "verdant_vale", }, { - id: "quest_haunted_mine", - title: "Whispers in the Deep Crystal", content: "The ghosts of the Haunted Mine were the former workers who had sealed themselves inside when a collapse blocked all exits, rather than risk setting off the unstable crystal deposits. They did not resent the living — they simply had nowhere else to be. Your adventurers gave them the courtesy of acknowledgement before harvesting the crystals they had died protecting.", + id: "quest_haunted_mine", + sourceId: "haunted_mine", sourceType: "quest", - sourceId: "haunted_mine", - zoneId: "verdant_vale", + title: "Whispers in the Deep Crystal", + zoneId: "verdant_vale", }, { - id: "quest_ancient_ruins", - title: "Fragments of the Forgotten City", content: "The ruins predated any civilisation on the cartographers' maps by at least four thousand years. The inscriptions, partially translated by your guild's scholars, described a people who built cities not for permanence but for joy — wide plazas, impractical towers, markets designed to let sound travel perfectly. Whatever ended them left no weapons and no bodies. They simply ceased.", + id: "quest_ancient_ruins", + sourceId: "ancient_ruins", sourceType: "quest", - sourceId: "ancient_ruins", - zoneId: "verdant_vale", + title: "Fragments of the Forgotten City", + zoneId: "verdant_vale", }, // ── Shattered Ruins — Bosses ────────────────────────────────────────────── { - id: "boss_stone_golem", - title: "The Golem Without a Name", content: "This golem was never given a name — its creators believed that naming a weapon was an act of hubris that would bring misfortune. They were not wrong. The golem outlasted them by eight hundred years, faithfully executing its last command to guard an empty vault. When it finally fell, your adventurers found the vault contained only the deed to a property that no longer existed.", + id: "boss_stone_golem", + sourceId: "stone_golem", sourceType: "boss", - sourceId: "stone_golem", - zoneId: "shattered_ruins", + title: "The Golem Without a Name", + zoneId: "shattered_ruins", }, { - id: "boss_bone_colossus", - title: "Architecture of the Dead", content: "The Bone Colossus was not raised by any single necromancer but was the cumulative product of centuries of failed rituals — each attempt binding more remains to the structure until it achieved a terrible coherence of its own. By the time your guild encountered it, scholars estimated it incorporated the skeletal remains of over twelve thousand individuals. It had no master. It simply was.", + id: "boss_bone_colossus", + sourceId: "bone_colossus", sourceType: "boss", - sourceId: "bone_colossus", - zoneId: "shattered_ruins", + title: "Architecture of the Dead", + zoneId: "shattered_ruins", }, { - id: "boss_elder_dragon", - title: "Vaeltharox: The Last Memory", content: "Vaeltharox remembered the age before the Shattered Ruins were ruins at all — he had watched the city below his mountain rise, flourish, and fall within what felt to him like a single afternoon nap. Your guild's records note that he did not attack when your party entered his lair. He simply watched them for several hours before the battle began, as though committing them to memory.", + id: "boss_elder_dragon", + sourceId: "elder_dragon", sourceType: "boss", - sourceId: "elder_dragon", - zoneId: "shattered_ruins", + title: "Vaeltharox: The Last Memory", + zoneId: "shattered_ruins", }, // ── Shattered Ruins — Quests ────────────────────────────────────────────── { - id: "quest_necromancer_tower", - title: "The Tower's Final Lesson", content: "The Necromancer's Tower had been abandoned for sixty years before your guild explored it, but the traps were still active and the wards still fresh. Someone had been maintaining them from the inside. The only living occupant your adventurers found was an elderly apprentice who had outlived her master and continued teaching classes to a classroom of attentive skeletons who had never been told they could leave.", + id: "quest_necromancer_tower", + sourceId: "necromancer_tower", sourceType: "quest", - sourceId: "necromancer_tower", - zoneId: "shattered_ruins", + title: "The Tower's Final Lesson", + zoneId: "shattered_ruins", }, { - id: "quest_crumbling_fortress", - title: "What the Garrison Left Behind", content: "The fortress had not crumbled from battle damage but from neglect — its garrison had walked away one morning and simply never returned. Your adventurers found the mess hall still set for a meal that was never eaten, and a duty roster showing all posts staffed for a day that happened over two centuries ago. No explanation was ever found.", + id: "quest_crumbling_fortress", + sourceId: "crumbling_fortress", sourceType: "quest", - sourceId: "crumbling_fortress", - zoneId: "shattered_ruins", + title: "What the Garrison Left Behind", + zoneId: "shattered_ruins", }, { - id: "quest_cursed_library", - title: "The Books That Would Not Be Read", content: "The Cursed Library's curse was not malicious — the books were cursed against being read by anyone who had not been judged worthy, and the original curators had taken the criteria for worthiness to their graves. Your guild's scholars found that all the books became perfectly legible if you approached them while crying, which they assumed was a coincidence but were unwilling to test further.", + id: "quest_cursed_library", + sourceId: "cursed_library", sourceType: "quest", - sourceId: "cursed_library", - zoneId: "shattered_ruins", + title: "The Books That Would Not Be Read", + zoneId: "shattered_ruins", }, { - id: "quest_dragon_lair", - title: "Vaeltharox's Hoard: A Catalogue", content: "The dragon's hoard, meticulously catalogued by your guild's archivists, proved to be one of the strangest assemblages of objects ever recorded. Alongside the expected gold and gemstones were: seventeen children's toys, a complete set of diplomatic correspondence from a dissolved empire, nine hundred and forty-one pressed flowers, and a hand-written list labelled 'Things That Were Beautiful.' The list was the oldest item in the hoard by far.", + id: "quest_dragon_lair", + sourceId: "dragon_lair", sourceType: "quest", - sourceId: "dragon_lair", - zoneId: "shattered_ruins", + title: "Vaeltharox's Hoard: A Catalogue", + zoneId: "shattered_ruins", }, // ── Frozen Peaks — Bosses ───────────────────────────────────────────────── { - id: "boss_frost_wyrm", - title: "Ice and Hunger", content: "The Frost Wyrm had made its nest at the summit of the highest peak because no prey could escape downhill faster than it could descend. It had hunted this range for three centuries with methodical precision, and the bones of its meals formed a cairn visible from the valley below that local villagers had mistaken for a shrine. They were not entirely wrong — the wyrm had arranged the bones in a pattern that matched no known constellation.", + id: "boss_frost_wyrm", + sourceId: "frost_wyrm", sourceType: "boss", - sourceId: "frost_wyrm", - zoneId: "frozen_peaks", + title: "Ice and Hunger", + zoneId: "frozen_peaks", }, { - id: "boss_ice_queen", - title: "The Court That Froze Mid-Sentence", content: "The Ice Queen had not chosen her exile — she had been mid-sentence during a court celebration when the curse took her, and every guest, servant, and musician had been preserved alongside her in ice. Your adventurers found them all still in position, arrested in a moment of laughter that had lasted four hundred years. Breaking the curse thawed them. The ensuing panic lasted longer than the battle with the queen herself.", + id: "boss_ice_queen", + sourceId: "ice_queen", sourceType: "boss", - sourceId: "ice_queen", - zoneId: "frozen_peaks", + title: "The Court That Froze Mid-Sentence", + zoneId: "frozen_peaks", }, { - id: "boss_void_titan", - title: "The Thing from the Other Side", content: "The Void Titan was not native to the Frozen Peaks — it had emerged from a rift that had been sealed for millennia, drawn by some resonance your scholars could not identify. It spoke in no known language, but its utterances, recorded phonetically by a brave archivist standing at a safe distance, have never been successfully translated. The sounds, when played back, make observers inexplicably homesick for places they have never been.", + id: "boss_void_titan", + sourceId: "void_titan", sourceType: "boss", - sourceId: "void_titan", - zoneId: "frozen_peaks", + title: "The Thing from the Other Side", + zoneId: "frozen_peaks", }, // ── Frozen Peaks — Quests ───────────────────────────────────────────────── { - id: "quest_frozen_wastes", - title: "Life at the Edge of Everything", content: "The frozen wastes beyond the Peaks appeared empty on every map, but your adventurers found evidence of habitation everywhere: cold hearths, wind-shelters built and abandoned in sequence, and a trail of carved stones marking a path that led nowhere documented. Someone had been living here for a very long time, moving constantly, and had chosen not to be found.", + id: "quest_frozen_wastes", + sourceId: "frozen_wastes", sourceType: "quest", - sourceId: "frozen_wastes", - zoneId: "frozen_peaks", + title: "Life at the Edge of Everything", + zoneId: "frozen_peaks", }, { - id: "quest_ice_caves", - title: "The Preserved World Below", content: "The ice caves beneath the Peaks served as a natural archive — preserved within the ice were specimens of every creature that had ever lived in the range, many of them species unknown to any living naturalist. The most remarkable find was a perfectly preserved human hand, outstretched and open, as though offering something. Whatever it had been holding was gone, but the gesture remained, reaching upward through ten thousand years of ice.", + id: "quest_ice_caves", + sourceId: "ice_caves", sourceType: "quest", - sourceId: "ice_caves", - zoneId: "frozen_peaks", + title: "The Preserved World Below", + zoneId: "frozen_peaks", }, { - id: "quest_storm_citadel", - title: "The Citadel Built for a War That Never Came", content: "The Storm Citadel had been constructed in anticipation of an invasion that records suggest was cancelled at the last moment due to a diplomatic marriage. It had never been used for its intended purpose and instead became home to a community of scholars who prized its extreme isolation. When your adventurers arrived, the youngest scholar present had been there for forty-seven years and did not know the current monarch's name.", + id: "quest_storm_citadel", + sourceId: "storm_citadel", sourceType: "quest", - sourceId: "storm_citadel", - zoneId: "frozen_peaks", + title: "The Citadel Built for a War That Never Came", + zoneId: "frozen_peaks", }, // ── Shadow Marshes — Bosses ─────────────────────────────────────────────── { - id: "boss_swamp_witch", - title: "Morgantha's Long Accounting", content: "Morgantha had not always been a swamp witch — she had been a village healer first, and then a court physician, and then an exile. The marsh had been her home for forty years by the time your guild found her, and the creatures of the wetlands had grown genuinely fond of her. She cursed your adventurers twice before the battle ended and complimented their footwork once.", + id: "boss_swamp_witch", + sourceId: "swamp_witch", sourceType: "boss", - sourceId: "swamp_witch", - zoneId: "shadow_marshes", + title: "Morgantha's Long Accounting", + zoneId: "shadow_marshes", }, { - id: "boss_plague_lord", - title: "The Disease That Learned to Think", content: "The Plague Lord was not born but accreted — centuries of concentrated suffering and disease had gradually achieved a terrible coherence in the deepest part of the marsh. It did not hate the living; it regarded them with the clinical interest of a physician regarding a patient. It was the first enemy your guild encountered that paused during the battle to ask questions, and the answers your adventurers gave seemed to genuinely affect its strategy.", + id: "boss_plague_lord", + sourceId: "plague_lord", sourceType: "boss", - sourceId: "plague_lord", - zoneId: "shadow_marshes", + title: "The Disease That Learned to Think", + zoneId: "shadow_marshes", }, { - id: "boss_mud_kraken", - title: "The Marsh's Oldest Tenant", content: "The Mud Kraken predated the marsh itself — geological surveys after its defeat revealed that the wetland had formed around it over four thousand years as rivers changed course to avoid the creature's movements. Local legends described it as a god of lost things, and indeed your adventurers found the kraken's den littered with objects that had gone missing from villages across the region spanning three centuries: tools, keepsakes, and an enormous quantity of single shoes.", + id: "boss_mud_kraken", + sourceId: "mud_kraken", sourceType: "boss", - sourceId: "mud_kraken", - zoneId: "shadow_marshes", + title: "The Marsh's Oldest Tenant", + zoneId: "shadow_marshes", }, // ── Shadow Marshes — Quests ─────────────────────────────────────────────── { - id: "quest_shadow_mere", - title: "The Water That Remembers", content: "The Shadow Mere's water had unusual optical properties — objects submerged in it could be seen perfectly clearly from any angle, including angles that should have been impossible. Your scholars theorised that the water retained some echo of everything that had ever passed through it, though the images were fragmentary. Among the glimpses your adventurers reported were faces, roads, and at least three battles that occurred before any current record begins.", + id: "quest_shadow_mere", + sourceId: "shadow_mere", sourceType: "quest", - sourceId: "shadow_mere", - zoneId: "shadow_marshes", + title: "The Water That Remembers", + zoneId: "shadow_marshes", }, { - id: "quest_witch_coven", - title: "Seven Sisters of the Marsh", content: "The coven was not what your adventurers expected — three of its seven members were quite elderly and primarily interested in botanical research. The other four were younger, more combative, and deeply annoyed at being interrupted mid-ritual. The ritual, your scholars later determined, was a preventive working that had been keeping a much older and more dangerous entity in the deep marsh subdued. Your adventurers had excellent reflexes in completing it before fleeing.", + id: "quest_witch_coven", + sourceId: "witch_coven", sourceType: "quest", - sourceId: "witch_coven", - zoneId: "shadow_marshes", + title: "Seven Sisters of the Marsh", + zoneId: "shadow_marshes", }, { - id: "quest_sunken_temple", - title: "The Temple That Chose to Sink", content: "The Sunken Temple had not sunk by accident or flood — the priests had deliberately submerged it, flooding the lower chambers in a controlled sequence. The reasons were not documented, but the depth of the engineering suggested it was not done in haste. Whatever they had been guarding in the deepest vault, your adventurers found only water and the lingering sense that something had been very careful to keep itself hidden.", + id: "quest_sunken_temple", + sourceId: "sunken_temple", sourceType: "quest", - sourceId: "sunken_temple", - zoneId: "shadow_marshes", + title: "The Temple That Chose to Sink", + zoneId: "shadow_marshes", }, { - id: "quest_plague_ruins", - title: "The City That Cured Itself", content: "The plague ruins were the remains of a city that had experienced an outbreak of extraordinary virulence — and had survived it. Journals recovered from the ruins described how the city had reorganised itself during the crisis: dividing into sectors, developing new sanitation methods, and ultimately producing what appeared to be a cure through catastrophic trial and error. The city then collapsed anyway, twenty years later, from the administrative debt incurred during its salvation.", + id: "quest_plague_ruins", + sourceId: "plague_ruins", sourceType: "quest", - sourceId: "plague_ruins", - zoneId: "shadow_marshes", + title: "The City That Cured Itself", + zoneId: "shadow_marshes", }, // ── Volcanic Depths — Bosses ────────────────────────────────────────────── { - id: "boss_fire_elemental", - title: "The Elemental That Remembered the World's First Fire", content: "The Ancient Fire Elemental was old enough to remember the volcanic events that had formed the mountain range itself — it had been born from the first eruption and had persisted through every subsequent one, growing with each conflagration. It communicated in heat and pressure differentials that your scholars spent months attempting to interpret, eventually concluding that it was recounting geological history in reverse chronological order, and that it found the current epoch rather cold.", + id: "boss_fire_elemental", + sourceId: "fire_elemental", sourceType: "boss", - sourceId: "fire_elemental", - zoneId: "volcanic_depths", + title: "The Elemental That Remembered the World's First Fire", + zoneId: "volcanic_depths", }, { - id: "boss_magma_titan", - title: "Stone That Walked and Remembered", content: "The Magma Titan was composed of rock formations spanning three distinct geological eras — a fact that puzzled your scholars, since the eras in question were separated by over a million years. The current leading theory is that the Titan had been destroyed and reformed multiple times, incorporating new stone each time, and that the consciousness that animated it had somehow persisted through each destruction. It did not seem aware of this. Or perhaps it simply did not care.", + id: "boss_magma_titan", + sourceId: "magma_titan", sourceType: "boss", - sourceId: "magma_titan", - zoneId: "volcanic_depths", + title: "Stone That Walked and Remembered", + zoneId: "volcanic_depths", }, { - id: "boss_phoenix_lord", - title: "The Count of Deaths", content: "The Phoenix Lord kept meticulous track of how many times it had died — a count carved into the walls of its volcanic nest in a tally system your scholars needed three weeks to decipher. The final count, before your guild added to it, was four hundred and twelve. It had died by blade, by cold, by trickery, by exhaustion, by its own fire, and once, improbably, by paperwork. Each death was documented with apparent pride. Each return was documented with something that might have been relief.", + id: "boss_phoenix_lord", + sourceId: "phoenix_lord", sourceType: "boss", - sourceId: "phoenix_lord", - zoneId: "volcanic_depths", + title: "The Count of Deaths", + zoneId: "volcanic_depths", }, // ── Volcanic Depths — Quests ────────────────────────────────────────────── { - id: "quest_lava_flows", - title: "Reading the Stone Rivers", content: "The lava flows were not random — your geologists identified deliberate patterns in the channelling, evidence of ancient engineering designed to direct the flows away from structures that no longer existed. Whoever had built here had understood the mountain intimately, working with its forces rather than against them. The remnants of their drainage system were still partially functional after two thousand years, which says something about how they built things.", + id: "quest_lava_flows", + sourceId: "lava_flows", sourceType: "quest", - sourceId: "lava_flows", - zoneId: "volcanic_depths", + title: "Reading the Stone Rivers", + zoneId: "volcanic_depths", }, { - id: "quest_fire_temple", - title: "Prayers Carved in Obsidian", content: "The Temple of the Flame was not dedicated to fire as a destructive force but as a creative one — its inscriptions described fire as the first artist, the medium through which the world had originally been shaped. The priests who tended it had apparently spent most of their time in metalwork, producing objects of extraordinary craft that they then ceremonially returned to the volcano, as payment, or as praise. Your adventurers recovered three pieces that had been set aside, apparently rejected.", + id: "quest_fire_temple", + sourceId: "fire_temple", sourceType: "quest", - sourceId: "fire_temple", - zoneId: "volcanic_depths", + title: "Prayers Carved in Obsidian", + zoneId: "volcanic_depths", }, { - id: "quest_magma_caverns", - title: "The Ecosystem Below the Fire", content: "The magma caverns harboured a complete ecosystem that had never been exposed to sunlight — creatures that fed on heat gradients, fungi that metabolised sulphur, and at least one variety of blind fish that appeared to navigate by detecting electromagnetic fields in the rock. Your naturalists spent three weeks documenting it and returned changed in ways they found difficult to articulate. Several reported dreaming of warmth for months afterward.", + id: "quest_magma_caverns", + sourceId: "magma_caverns", sourceType: "quest", - sourceId: "magma_caverns", - zoneId: "volcanic_depths", + title: "The Ecosystem Below the Fire", + zoneId: "volcanic_depths", }, { - id: "quest_the_forge", - title: "Where the First Weapons Were Made", content: "The Primordial Forge predated every metallurgical tradition your scholars could identify — the techniques used there were not ancestors of any known craft but a parallel development, solving the same problems by entirely different means. The tools found there worked. Impeccably. One of your adventurers used a chisel recovered from the forge's surface to complete a carving job, then spent an hour refusing to put it down. The forge was sealed upon your return.", + id: "quest_the_forge", + sourceId: "the_forge", sourceType: "quest", - sourceId: "the_forge", - zoneId: "volcanic_depths", + title: "Where the First Weapons Were Made", + zoneId: "volcanic_depths", }, // ── Astral Void — Bosses ────────────────────────────────────────────────── { - id: "boss_astral_wraith", - title: "A Ghost Between Stars", content: "The Astral Wraith was the echo of something that had died in a place where death works differently — between the stars, where matter thins and the usual rules of dissolution do not apply. It retained the shape of whatever it had been, though that shape shifted constantly as if being recalled from imperfect memory. Your adventurers reported that looking at it directly caused them to briefly see their own reflections, and that the reflections blinked at different intervals.", + id: "boss_astral_wraith", + sourceId: "astral_wraith", sourceType: "boss", - sourceId: "astral_wraith", - zoneId: "astral_void", + title: "A Ghost Between Stars", + zoneId: "astral_void", }, { - id: "boss_cosmic_horror", - title: "The Shape That Should Not Be", content: "The Cosmic Horror had no true name in any language because naming requires a stable referent, and this entity had never been stable. Its geometry violated no single principle of architecture — it violated all of them simultaneously. Your chronicler's notes from the encounter are legible but contain several paragraphs that, when read aloud, cause listeners to temporarily forget their own names. The notes have since been sealed.", + id: "boss_cosmic_horror", + sourceId: "cosmic_horror", sourceType: "boss", - sourceId: "cosmic_horror", - zoneId: "astral_void", + title: "The Shape That Should Not Be", + zoneId: "astral_void", }, { - id: "boss_the_devourer", - title: "The Hunger at the End of Stars", content: "The Devourer of Worlds was not malicious in any way your scholars could comprehend — it was simply hungry, as fire is hungry, as the void itself is hungry. It had consumed seventeen star systems before your guild's intervention, each one vanishing without the violence of destruction but with the quiet disappearance of having been entirely eaten. That your guild was able to stop it at all is something your scholars have been arguing about ever since.", + id: "boss_the_devourer", + sourceId: "the_devourer", sourceType: "boss", - sourceId: "the_devourer", - zoneId: "astral_void", + title: "The Hunger at the End of Stars", + zoneId: "astral_void", }, // ── Astral Void — Quests ────────────────────────────────────────────────── { - id: "quest_void_rift", - title: "The Wound Between Places", content: "The Void Rift was not natural and not recent — analysis of its edges suggested it had been created intentionally, by something that understood spatial geometry far beyond current scholarship. Whatever had made it was gone, or was on the other side, and showed no interest in communication. Sealing it took seven days and left your scholars with seventeen new questions for every one they answered.", + id: "quest_void_rift", + sourceId: "void_rift", sourceType: "quest", - sourceId: "void_rift", - zoneId: "astral_void", + title: "The Wound Between Places", + zoneId: "astral_void", }, { - id: "quest_star_graveyard", - title: "The Stars That Fell and Remembered", content: "The Star Graveyard was a region of space where the debris of dead stars had accumulated over billions of years — each fragment still carrying, somehow, a faint resonance of the light it had once produced. Your scholars theorised that stars, like all complex systems, leave an impression on the fabric of space when they die. Walking through the graveyard, your adventurers reported feeling inexplicably watched, and that the watching felt patient and very old.", + id: "quest_star_graveyard", + sourceId: "star_graveyard", sourceType: "quest", - sourceId: "star_graveyard", - zoneId: "astral_void", + title: "The Stars That Fell and Remembered", + zoneId: "astral_void", }, { - id: "quest_between_worlds", - title: "The Space That Is Not Space", content: "Between the known worlds lies a region that does not appear on any map and cannot, because it exists outside the coordinate systems maps use. Your adventurers crossed it by following a principle your chief scholar described only as 'intention.' What they found there, they were reluctant to discuss in full, but the consensus was that it was not empty and that whatever lived there had been living there longer than any of the worlds it lay between.", + id: "quest_between_worlds", + sourceId: "between_worlds", sourceType: "quest", - sourceId: "between_worlds", - zoneId: "astral_void", + title: "The Space That Is Not Space", + zoneId: "astral_void", }, { - id: "quest_the_end", - title: "What Waits at the Edge", content: "At the farthest reaches of the charted Astral Void, your adventurers found not emptiness but a boundary — something that was not space and not time but the membrane between existence and whatever lies outside it. They did not cross it. They noted that the boundary was not a wall but a threshold, and that it did not feel hostile. It felt patient. And it felt, somehow, like it had been expecting them for a very long time.", + id: "quest_the_end", + sourceId: "the_end", sourceType: "quest", - sourceId: "the_end", - zoneId: "astral_void", + title: "What Waits at the Edge", + zoneId: "astral_void", }, // ── Abyssal Trench — Bosses ─────────────────────────────────────────────── { - id: "boss_depth_leviathan", - title: "Sovereign of Sunless Waters", content: "The Depth Leviathan had lived below the light threshold for so long that its eyes had repurposed themselves entirely, sensing heat and pressure rather than photons. It was the apex predator of an ecosystem that most surface-dwellers did not believe existed. Your naturalists, reviewing the recovered specimens, quietly revised several fundamental assumptions about where life was possible.", + id: "boss_depth_leviathan", + sourceId: "depth_leviathan", sourceType: "boss", - sourceId: "depth_leviathan", - zoneId: "abyssal_trench", + title: "Sovereign of Sunless Waters", + zoneId: "abyssal_trench", }, { - id: "boss_kraken_elder", - title: "The Archivist of the Deep", content: "The Elder Kraken had developed, over its millennia of solitude, a compulsive habit of collecting and arranging objects that fell to the ocean floor — shipwrecks, artefacts, lost cargo, the personal effects of the drowned. Your adventurers found it tending this collection with evident care when they arrived. The arrangement was not aesthetic; your scholars believe it was taxonomic, though the taxonomy it used has not been deciphered.", + id: "boss_kraken_elder", + sourceId: "kraken_elder", sourceType: "boss", - sourceId: "kraken_elder", - zoneId: "abyssal_trench", + title: "The Archivist of the Deep", + zoneId: "abyssal_trench", }, { - id: "boss_abyssal_colossus", - title: "Built from Pressure", content: "The Abyssal Colossus was not a creature but an aggregate — the crushing pressure of the deepest trench had, over geological time, compacted organic and mineral matter into a form that had achieved, improbably, locomotion and then something approaching awareness. It did not breathe. It did not need to. At the depths where it lived, the distinction between solid and liquid ceased to be meaningful.", + id: "boss_abyssal_colossus", + sourceId: "abyssal_colossus", sourceType: "boss", - sourceId: "abyssal_colossus", - zoneId: "abyssal_trench", + title: "Built from Pressure", + zoneId: "abyssal_trench", }, { - id: "boss_the_deep_one", - title: "That Which Was Not Named", content: "The Deep One predated the ocean itself — it had been in the trench when the waters first filled it, waiting with a patience that had no analogue in any surface-dwelling experience of time. The sailors' legends about it were all wrong in the details and all correct in tone. When your adventurers reached its chamber, they found it already aware of their approach, having detected them the moment they entered the water three days earlier.", + id: "boss_the_deep_one", + sourceId: "the_deep_one", sourceType: "boss", - sourceId: "the_deep_one", - zoneId: "abyssal_trench", + title: "That Which Was Not Named", + zoneId: "abyssal_trench", }, { - id: "boss_elder_abomination", - title: "The Confluence", content: "The Elder Abomination was the result of the trench's accumulated strangeness reaching a critical threshold — centuries of cosmic radiation, alien pressure, and dark biological material converging into a single entity that was less a creature than a statement the abyss was making about what it was capable of. Your scholars declined to speculate on what it would have become had your guild not intervened. The margin of their report reads: 'Better not to know.'", + id: "boss_elder_abomination", + sourceId: "elder_abomination", sourceType: "boss", - sourceId: "elder_abomination", - zoneId: "abyssal_trench", + title: "The Confluence", + zoneId: "abyssal_trench", }, // ── Abyssal Trench — Quests ─────────────────────────────────────────────── { - id: "quest_the_dark_waters", - title: "Learning to See Without Light", content: "The first descent into the Trench required your adventurers to unlearn everything their senses had taught them about navigation. Below the light threshold, sound became the primary map. Your chronicler's notes from this expedition are unusual in that they describe not what was seen but what was heard — and the sounds described do not match any known marine creature.", + id: "quest_the_dark_waters", + sourceId: "the_dark_waters", sourceType: "quest", - sourceId: "the_dark_waters", - zoneId: "abyssal_trench", + title: "Learning to See Without Light", + zoneId: "abyssal_trench", }, { - id: "quest_bioluminescent_ruins", - title: "The City That Lit Itself", content: "The ruins on the trench wall were covered in bioluminescent growth that had, over centuries, colonised the architecture so thoroughly that the buildings themselves appeared to glow. Your scholars could not determine whether the growth was native to the ruins or had been cultivated there deliberately. The patterns of light it produced changed with the current, creating the impression of motion. Your adventurers spent longer than planned simply watching.", + id: "quest_bioluminescent_ruins", + sourceId: "bioluminescent_ruins", sourceType: "quest", - sourceId: "bioluminescent_ruins", - zoneId: "abyssal_trench", + title: "The City That Lit Itself", + zoneId: "abyssal_trench", }, { - id: "quest_pressure_caves", - title: "Where Physics Negotiates with Itself", content: "The Pressure Caves occupied a region where the forces of the deep trench created unusual spatial effects — distances were inconsistent, surfaces curved in ways that made geometric sense only locally. Your adventurers navigated by feel and instinct, returning with samples that behaved differently at surface pressure than they had below, and with a shared inability to describe the caves without contradicting themselves.", + id: "quest_pressure_caves", + sourceId: "pressure_caves", sourceType: "quest", - sourceId: "pressure_caves", - zoneId: "abyssal_trench", + title: "Where Physics Negotiates with Itself", + zoneId: "abyssal_trench", }, { - id: "quest_leviathan_graveyard", - title: "The Bones of Titans", content: "The Leviathan Graveyard was a sediment plain where colossal creatures had, over millennia, come to die. The bones were so large and so numerous that the graveyard had become its own ecosystem — dozens of species lived exclusively in the niches created by the remains. Your naturalists catalogued forty-three new species in four days and estimated they had found perhaps a quarter of what was present.", + id: "quest_leviathan_graveyard", + sourceId: "leviathan_graveyard", sourceType: "quest", - sourceId: "leviathan_graveyard", - zoneId: "abyssal_trench", + title: "The Bones of Titans", + zoneId: "abyssal_trench", }, { - id: "quest_black_throne", - title: "Rule in Darkness", content: "The Black Throne sat at the deepest accessible point of the trench, carved from a material that absorbed all light rather than reflecting it. Your scholars believe it was a seat of genuine governance — an abyssal civilisation that had ruled the deep through some form of order your surface-based concepts of politics couldn't adequately translate. The throne was empty. It had been empty for a very long time. Whatever had sat in it had not simply died but had left.", + id: "quest_black_throne", + sourceId: "black_throne", sourceType: "quest", - sourceId: "black_throne", - zoneId: "abyssal_trench", + title: "Rule in Darkness", + zoneId: "abyssal_trench", }, { - id: "quest_abyssal_chronicle", - title: "The Record Kept in Stone", content: "The Abyssal Chronicle was a geological record inscribed in the trench walls over millions of years — not by any intelligence, but by the processes of the deep itself. Your scholars spent months translating the chemical and mineral signatures into events, ultimately producing a history of the ocean floor that predated life on the surface by several hundred million years. The Chronicle ends abruptly. What ended it left no explanation.", + id: "quest_abyssal_chronicle", + sourceId: "abyssal_chronicle", sourceType: "quest", - sourceId: "abyssal_chronicle", - zoneId: "abyssal_trench", + title: "The Record Kept in Stone", + zoneId: "abyssal_trench", }, // ── Celestial Reaches — Bosses ──────────────────────────────────────────── { - id: "boss_seraph_guardian", - title: "The First Gate's Warden", content: "The Seraph Guardian had held its post since the Celestial Reaches were established, executing its duty with a thoroughness that had, over millennia, calcified into something very close to dogma. It evaluated each challenger against criteria so old that the original reasoning had been lost — only the criteria remained, applied with perfect consistency to circumstances they had never been designed to address. Your guild met its standards. It seemed surprised.", + id: "boss_seraph_guardian", + sourceId: "seraph_guardian", sourceType: "boss", - sourceId: "seraph_guardian", - zoneId: "celestial_reaches", + title: "The First Gate's Warden", + zoneId: "celestial_reaches", }, { - id: "boss_fallen_archangel", - title: "The One Who Asked Questions", content: "The Fallen Archangel had not been cast down for disobedience or pride but for persistent, sincere questioning — it had asked, one too many times, whether the orders it was given were right rather than merely commanded. In its long exile, it had continued asking questions of anyone it encountered. Your adventurers found the interrogation relentless but not unfriendly. The battle that followed felt, strangely, like a dialogue continuing by other means.", + id: "boss_fallen_archangel", + sourceId: "fallen_archangel", sourceType: "boss", - sourceId: "fallen_archangel", - zoneId: "celestial_reaches", + title: "The One Who Asked Questions", + zoneId: "celestial_reaches", }, { - id: "boss_divine_judge", - title: "Justice Without Mercy or Cruelty", content: "The Divine Judge had presided over the weighing of souls for longer than written history, applying a standard that was perfectly consistent and entirely unmoved by context. Your scholars, reviewing its recorded judgements, found that it had never made an error — not because its standard was just, but because it applied its standard without deviation. Whether the standard was just was the question your philosophers were still arguing about when the expedition returned.", + id: "boss_divine_judge", + sourceId: "divine_judge", sourceType: "boss", - sourceId: "divine_judge", - zoneId: "celestial_reaches", + title: "Justice Without Mercy or Cruelty", + zoneId: "celestial_reaches", }, { - id: "boss_celestial_titan", - title: "The Architecture of Heaven", content: "The Celestial Titan was not a guardian so much as a structural element — it was woven into the fabric of the Celestial Reaches in a way that made conventional battle strange. Damaging it caused corresponding damage to the region itself, which repaired itself and then extended the same damage back in ways your adventurers described as 'argumentative.' Defeating it required convincing it, rather than destroying it. Your chief scholar refuses to explain how.", + id: "boss_celestial_titan", + sourceId: "celestial_titan", sourceType: "boss", - sourceId: "celestial_titan", - zoneId: "celestial_reaches", + title: "The Architecture of Heaven", + zoneId: "celestial_reaches", }, { - id: "boss_the_first_light", - title: "Before Darkness Was Named", content: "The First Light was the oldest thing your guild had ever encountered — it predated the concepts used to describe age, having been present before time had settled into its current behaviour. It communicated in impressions rather than language: warmth, the feeling of potential, the sensation of a moment before a decision is made. Defeating it felt less like victory and more like grief. Your adventurers returned quieter than they had left.", + id: "boss_the_first_light", + sourceId: "the_first_light", sourceType: "boss", - sourceId: "the_first_light", - zoneId: "celestial_reaches", + title: "Before Darkness Was Named", + zoneId: "celestial_reaches", }, // ── Celestial Reaches — Quests ──────────────────────────────────────────── { - id: "quest_heavens_gate", - title: "The First Threshold", content: "The passage into the Celestial Reaches was not difficult in any physical sense — the gate was open, the path was clear, and nothing opposed entry. The difficulty was entirely internal. Your adventurers reported a powerful compulsion to turn back, not from fear but from a sense that they were not yet finished with something below. Several returned to the surface briefly and then ascended again. Each said the same thing: they needed to be sure they had nothing left undone.", + id: "quest_heavens_gate", + sourceId: "heavens_gate", sourceType: "quest", - sourceId: "heavens_gate", - zoneId: "celestial_reaches", + title: "The First Threshold", + zoneId: "celestial_reaches", }, { - id: "quest_angelic_choir", - title: "The Song That Organises Reality", content: "The Angelic Choir's song was not aesthetic — it was functional, maintaining the resonant frequency that kept the Celestial Reaches coherent. Your scholars identified at least forty distinct harmonic layers, each corresponding to a different aspect of the region's stability. When one singer briefly faltered during your adventurers' visit, the local architecture flickered. The choir resumed without missing a beat and without acknowledging what had happened.", + id: "quest_angelic_choir", + sourceId: "angelic_choir", sourceType: "quest", - sourceId: "angelic_choir", - zoneId: "celestial_reaches", + title: "The Song That Organises Reality", + zoneId: "celestial_reaches", }, { - id: "quest_divine_library", - title: "Every Question Ever Asked", content: "The Divine Library contained a record of every question that had ever been sincerely asked by any conscious being — not the answers, only the questions. Your scholars spent two weeks reading and emerged with the consensus that most questions had been asked repeatedly, across every civilisation and era, and that the most common question of all, asked in thousands of languages over millions of years, was some variation of 'is anyone else out there?'", + id: "quest_divine_library", + sourceId: "divine_library", sourceType: "quest", - sourceId: "divine_library", - zoneId: "celestial_reaches", + title: "Every Question Ever Asked", + zoneId: "celestial_reaches", }, { - id: "quest_cloud_citadel", - title: "Built for Beings Without Weight", content: "The Cloud Citadel was designed for occupants who experienced gravity as optional — its architecture ignored load-bearing as a concern entirely, producing forms of stunning impracticality that were nevertheless perfectly suited to their original inhabitants. Your adventurers navigated it with difficulty and considerable humility. The views from every window were extraordinary.", + id: "quest_cloud_citadel", + sourceId: "cloud_citadel", sourceType: "quest", - sourceId: "cloud_citadel", - zoneId: "celestial_reaches", + title: "Built for Beings Without Weight", + zoneId: "celestial_reaches", }, { - id: "quest_trial_of_virtue", - title: "Tested Against Yourself", content: "The Trial of Virtue presented each of your adventurers with a version of themselves that had made the opposite choices at every significant turning point of their lives. The encounters were not hostile — the alternate selves were curious, even friendly. What the trial measured, your scholars determined afterward, was not virtue in any moral sense but consistency: the degree to which a person's choices reflected a coherent understanding of who they were.", + id: "quest_trial_of_virtue", + sourceId: "trial_of_virtue", sourceType: "quest", - sourceId: "trial_of_virtue", - zoneId: "celestial_reaches", + title: "Tested Against Yourself", + zoneId: "celestial_reaches", }, { - id: "quest_celestial_archive", - title: "The Shape of Everything", content: "The Celestial Archive contained not records but patterns — the underlying structures that appeared consistently across every domain your scholars had studied. Mathematics, music, biology, architecture, language: the same shapes recurred at every scale. The Archive's curators, when your adventurers asked what this meant, said only that the universe appeared to be made of a very small number of ideas, repeated with extraordinary patience.", + id: "quest_celestial_archive", + sourceId: "celestial_archive", sourceType: "quest", - sourceId: "celestial_archive", - zoneId: "celestial_reaches", + title: "The Shape of Everything", + zoneId: "celestial_reaches", }, // ── Cosmic Maelstrom — Bosses ───────────────────────────────────────────── { - id: "boss_storm_colossus", - title: "The Perpetual Tempest Given Form", content: "The Storm Colossus was not a creature that lived in a storm — it was the storm, condensed and animated by forces your scholars spent years arguing about. It had no memory and no intent, only the imperative of the tempest: to spin, to strike, to scatter. Your adventurers defeated it not by overpowering it but by finding the eye — the still point at its centre where, for a single suspended moment, it paused.", + id: "boss_storm_colossus", + sourceId: "storm_colossus", sourceType: "boss", - sourceId: "storm_colossus", - zoneId: "cosmic_maelstrom", + title: "The Perpetual Tempest Given Form", + zoneId: "cosmic_maelstrom", }, { - id: "boss_force_prime", - title: "The Law Made Manifest", content: "The Force Prime was what happened when a fundamental physical law achieved sufficient density to act independently. It governed with perfect consistency and no judgement, applying its principles to everything in its vicinity without exception. Your scholars found it philosophically interesting that the law it embodied — the conservation of energy — made it, in theory, impossible to destroy. They were wrong. It was merely very difficult.", + id: "boss_force_prime", + sourceId: "force_prime", sourceType: "boss", - sourceId: "force_prime", - zoneId: "cosmic_maelstrom", + title: "The Law Made Manifest", + zoneId: "cosmic_maelstrom", }, { - id: "boss_maelstrom_god", - title: "Worshipped Before Language", content: "The Maelstrom God was old enough to have been worshipped by species that left no other trace in the archaeological record — only the shrine structures built to face the Maelstrom, found on four continents, all identical in their orientation and all predating written language by vast spans of time. What the worshippers asked for is unknown. Whether they received it is more unknown still.", + id: "boss_maelstrom_god", + sourceId: "maelstrom_god", sourceType: "boss", - sourceId: "maelstrom_god", - zoneId: "cosmic_maelstrom", + title: "Worshipped Before Language", + zoneId: "cosmic_maelstrom", }, { - id: "boss_cosmic_annihilator", - title: "The Function of Ending", content: "The Cosmic Annihilator performed the same role at a cosmic scale that decomposers perform in a forest — it broke down accumulated matter and complexity so that its components could be recycled into new forms. It was, in a strict sense, necessary. Your guild's destruction of it has been noted in your scholars' internal records with the annotation: 'Long-term consequences: to be assessed.' Assessment is ongoing.", + id: "boss_cosmic_annihilator", + sourceId: "cosmic_annihilator", sourceType: "boss", - sourceId: "cosmic_annihilator", - zoneId: "cosmic_maelstrom", + title: "The Function of Ending", + zoneId: "cosmic_maelstrom", }, // ── Cosmic Maelstrom — Quests ───────────────────────────────────────────── { - id: "quest_maelstrom_entry", - title: "The First Foothold", content: "Establishing a foothold at the Maelstrom's edge required your adventurers to move in rhythms that matched the storm's own rotation — fighting the current was impossible, but moving with it at slightly different angles allowed progress. They described the experience as arguing with weather: technically impossible, but surprisingly negotiable once you stopped expecting it to agree with you.", + id: "quest_maelstrom_entry", + sourceId: "maelstrom_entry", sourceType: "quest", - sourceId: "maelstrom_entry", - zoneId: "cosmic_maelstrom", + title: "The First Foothold", + zoneId: "cosmic_maelstrom", }, { - id: "quest_force_nexus", - title: "Where Forces Converge", content: "The Force Nexus was the point where the Maelstrom's various energies met and briefly cancelled each other, creating a stable zone of relative calm. Your scholars found it invaluable for study, since at the Nexus the forces could be observed without being immediately subjected to them. They also found it unsettling, since the stability felt artificial — as though something was maintaining it deliberately, for reasons of its own.", + id: "quest_force_nexus", + sourceId: "force_nexus", sourceType: "quest", - sourceId: "force_nexus", - zoneId: "cosmic_maelstrom", + title: "Where Forces Converge", + zoneId: "cosmic_maelstrom", }, { - id: "quest_storm_cauldron", - title: "Where New Storms Are Born", content: "The Storm Cauldron was the generative core of the Maelstrom — the zone where raw energy was condensed into the organised turbulence that spread outward. Your scholars observed the formation of three distinct storm systems during their visit, each self-organising from apparent chaos into structures of considerable mathematical elegance. They described watching it as being present at a birth. Several found it unexpectedly moving.", + id: "quest_storm_cauldron", + sourceId: "storm_cauldron", sourceType: "quest", - sourceId: "storm_cauldron", - zoneId: "cosmic_maelstrom", + title: "Where New Storms Are Born", + zoneId: "cosmic_maelstrom", }, { - id: "quest_annihilation_fields", - title: "The Frontier of Existence", content: "The Annihilation Fields marked the zone where matter and antimatter met at the Maelstrom's most extreme boundaries. Nothing survived direct exposure. Your adventurers navigated the edges through shielding and timing, recovering data on the boundary conditions that your scholars described as 'the most expensive discovery in guild history, measured in protective equipment.' The data was worth it. Probably.", + id: "quest_annihilation_fields", + sourceId: "annihilation_fields", sourceType: "quest", - sourceId: "annihilation_fields", - zoneId: "cosmic_maelstrom", + title: "The Frontier of Existence", + zoneId: "cosmic_maelstrom", }, { - id: "quest_convergence_point", - title: "Everything at Once", content: "The Convergence Point was the Maelstrom's singularity — the place where every component of the storm was present simultaneously, where the complexity resolved briefly into simplicity before expanding back outward. Your adventurers who stood at the point reported the same experience: complete stillness, complete awareness, and then a return to the ordinary world that felt, for days afterward, like waking up too early.", + id: "quest_convergence_point", + sourceId: "convergence_point", sourceType: "quest", - sourceId: "convergence_point", - zoneId: "cosmic_maelstrom", + title: "Everything at Once", + zoneId: "cosmic_maelstrom", }, { - id: "quest_maelstrom_codex", - title: "A Record of Impossible Forces", content: "The Maelstrom Codex was assembled from the data recovered across all your guild's expeditions into the storm — a document that attempted to describe forces which, your scholars admitted freely, did not have adequate notation in any existing system of measurement. They invented seven new symbols for the purpose. Three of them were later found to be identical to symbols used in a completely different context by a civilisation that had studied the Maelstrom ten thousand years earlier.", + id: "quest_maelstrom_codex", + sourceId: "maelstrom_codex", sourceType: "quest", - sourceId: "maelstrom_codex", - zoneId: "cosmic_maelstrom", + title: "A Record of Impossible Forces", + zoneId: "cosmic_maelstrom", }, // ── Crystalline Spire — Bosses ──────────────────────────────────────────── { - id: "boss_prism_golem", - title: "Light's Obedient Servant", content: "The Prism Golem was built to manage the Spire's optical systems — directing light into specific channels, maintaining resonance frequencies, and ensuring that no wavelength went to waste. It had performed this function so faithfully and for so long that it had begun to extend its management beyond light, attempting to organise all phenomena in the Spire according to the same precise principles. Your adventurers were, from its perspective, simply another frequency to be redirected.", + id: "boss_prism_golem", + sourceId: "prism_golem", sourceType: "boss", - sourceId: "prism_golem", - zoneId: "crystalline_spire", + title: "Light's Obedient Servant", + zoneId: "crystalline_spire", }, { - id: "boss_crystal_drake", - title: "The Dragon That Grew in Stone", content: "The Crystal Drake had not been born but grown — it was a natural formation that had, over geological timescales, developed the internal complexity necessary for locomotion and, eventually, predation. Its crystals were alive in a way that made your naturalists extremely careful with their terminology. It shed facets instead of scales, and the shed facets continued to vibrate at frequencies that your scholars found produced measurable effects on adjacent matter.", + id: "boss_crystal_drake", + sourceId: "crystal_drake", sourceType: "boss", - sourceId: "crystal_drake", - zoneId: "crystalline_spire", + title: "The Dragon That Grew in Stone", + zoneId: "crystalline_spire", }, { - id: "boss_the_faceted", - title: "Every Angle at Once", content: "The Faceted was a being of pure crystalline consciousness — it perceived the world through every surface simultaneously, with no blind spots, no angles hidden, no perspective privileged over any other. This gave it a form of perfect awareness that your philosophers found both enviable and unnerving. It had no concept of deception, not because it was incapable of it, but because it had never encountered a situation in which something could be hidden from it.", + id: "boss_the_faceted", + sourceId: "the_faceted", sourceType: "boss", - sourceId: "the_faceted", - zoneId: "crystalline_spire", + title: "Every Angle at Once", + zoneId: "crystalline_spire", }, { - id: "boss_diamond_colossus", - title: "The Hardest Thing", content: "The Diamond Colossus was, physically, the hardest material your engineers had ever worked against — its exterior resisted every conventional weapon, and even unconventional ones made only cosmetic impressions. The solution your adventurers ultimately employed was to attack the lattice structure at the molecular scale, introducing a resonant frequency that propagated through the crystal until it shattered from within. The sound, witnesses reported, was the most beautiful thing they had ever heard.", + id: "boss_diamond_colossus", + sourceId: "diamond_colossus", sourceType: "boss", - sourceId: "diamond_colossus", - zoneId: "crystalline_spire", + title: "The Hardest Thing", + zoneId: "crystalline_spire", }, { - id: "boss_crystal_sovereign", - title: "The Kingdom of Refracted Light", content: "The Crystal Sovereign ruled the Spire as a symphony is ruled by its key — everything within it resonated with the Sovereign's fundamental frequency, and any dissonance was corrected, absorbed, or expelled. Your guild was the first external force the Spire had accepted and then rejected, having initially admitted you as a new resonance and then determined you were incompatible. The battle was essentially an argument about belonging.", + id: "boss_crystal_sovereign", + sourceId: "crystal_sovereign", sourceType: "boss", - sourceId: "crystal_sovereign", - zoneId: "crystalline_spire", + title: "The Kingdom of Refracted Light", + zoneId: "crystalline_spire", }, // ── Crystalline Spire — Quests ──────────────────────────────────────────── { - id: "quest_prism_gate", - title: "Entry Through Light", content: "The Prism Gate admitted passage only to those whose physical presence did not disturb its optical calibration — a criterion that translated, in practice, to extraordinary stillness of movement and intention. Your adventurers spent three days learning to approach the Gate before their first successful crossing. Several described the experience of passing through as feeling briefly transparent.", + id: "quest_prism_gate", + sourceId: "prism_gate", sourceType: "quest", - sourceId: "prism_gate", - zoneId: "crystalline_spire", + title: "Entry Through Light", + zoneId: "crystalline_spire", }, { - id: "quest_crystal_labyrinth", - title: "The Maze That Reflects You", content: "The Crystal Labyrinth reflected not just light but memory — each passage showed the traveller images from their own past, chosen with an apparent awareness that your scholars found difficult to attribute to any known mechanism. The labyrinth was not trying to disorient; your adventurers came to believe it was trying to ensure that anyone who reached its centre had been thoroughly introduced to themselves. Most found the experience educational. None found it comfortable.", + id: "quest_crystal_labyrinth", + sourceId: "crystal_labyrinth", sourceType: "quest", - sourceId: "crystal_labyrinth", - zoneId: "crystalline_spire", + title: "The Maze That Reflects You", + zoneId: "crystalline_spire", }, { - id: "quest_faceted_realm", - title: "Where Every Truth Is Visible", content: "The Faceted Realm was a region of the Spire where the crystal's reflective properties reached their maximum density — every surface showed every other surface, recursively, so that the apparent depth of any wall was infinite. Your adventurers navigated by sound rather than sight. They returned with the shared conviction that they had seen things in the reflections that they were not yet prepared to describe, but that they would need to address eventually.", + id: "quest_faceted_realm", + sourceId: "faceted_realm", sourceType: "quest", - sourceId: "faceted_realm", - zoneId: "crystalline_spire", + title: "Where Every Truth Is Visible", + zoneId: "crystalline_spire", }, { - id: "quest_diamond_vault", - title: "What Was Worth Protecting Forever", content: "The Diamond Vault's walls were impenetrable by any means your engineers could apply — they had been designed to last indefinitely. What they contained, when your adventurers finally found the interior entrance, was a complete record of a civilisation that had known it was ending and had chosen what to preserve: not its greatest achievements, but its everyday life. Recipes. Songs. The way they described the weather. The names of pets.", + id: "quest_diamond_vault", + sourceId: "diamond_vault", sourceType: "quest", - sourceId: "diamond_vault", - zoneId: "crystalline_spire", + title: "What Was Worth Protecting Forever", + zoneId: "crystalline_spire", }, { - id: "quest_sovereign_spire", - title: "The Peak of the Known World", content: "The summit of the Crystalline Spire was not a place but a perspective — from it, the structure of the world became briefly legible as pattern rather than territory, and the connections between all things were, for a moment, visible. Your adventurers could not maintain the perspective for long. They returned to base unable to agree on what they had seen, but in perfect agreement that they had seen the same thing.", + id: "quest_sovereign_spire", + sourceId: "sovereign_spire", sourceType: "quest", - sourceId: "sovereign_spire", - zoneId: "crystalline_spire", + title: "The Peak of the Known World", + zoneId: "crystalline_spire", }, { - id: "quest_the_prism_vault", - title: "Light as Language", content: "The Prism Vault contained a library encoded not in text but in light — information stored in specific wavelength combinations that required a specialised optical reader to decode. Your scholars spent weeks building one from recovered components. The library it revealed was vast and systematic, recording centuries of observation of both the physical world and the interior world of the civilisation that had built it. They had been trying to understand the same things your scholars were trying to understand. They had gotten further.", + id: "quest_the_prism_vault", + sourceId: "the_prism_vault", sourceType: "quest", - sourceId: "the_prism_vault", - zoneId: "crystalline_spire", + title: "Light as Language", + zoneId: "crystalline_spire", }, // ── Eternal Throne — Bosses ─────────────────────────────────────────────── { - id: "boss_throne_warden", - title: "Guard Without a Purpose", content: "The Throne Warden had been assigned to protect the approaches to the Eternal Throne when the Throne still had an occupant. That had been a very long time ago. It had continued its rounds regardless, maintaining a security posture against threats that had long since ceased to exist, in service of an authority that had not communicated with it in what your scholars estimated was several thousand years. It seemed genuinely unsure how to stop.", + id: "boss_throne_warden", + sourceId: "throne_warden", sourceType: "boss", - sourceId: "throne_warden", - zoneId: "eternal_throne", + title: "Guard Without a Purpose", + zoneId: "eternal_throne", }, { - id: "boss_eternal_knight", - title: "The Champion of a Dissolved Kingdom", content: "The Eternal Knight had sworn an oath so comprehensive and so deeply binding that it had outlasted the kingdom it served, the monarch it protected, and the cause it had championed. What remained was the oath itself, animated by a will so stubborn that your adventurers' most experienced negotiator spent four hours attempting to explain, diplomatically, that the war was over. The Knight eventually acknowledged this. Then asked what it was supposed to do now. Your adventurers did not have a good answer.", + id: "boss_eternal_knight", + sourceId: "eternal_knight", sourceType: "boss", - sourceId: "eternal_knight", - zoneId: "eternal_throne", + title: "The Champion of a Dissolved Kingdom", + zoneId: "eternal_throne", }, { - id: "boss_the_undying", - title: "The Persistence of Being", content: "The Undying had discovered immortality not through magic or technology but through a quality your scholars struggled to name — a refusal of ending so fundamental to its nature that the universe had eventually accommodated it. It was not invulnerable; it could be harmed and diminished. It simply persisted anyway, reassembling itself with the patient stubbornness of water finding its way downhill. Defeating it required not destruction but convincing it that the effort of continuing had outweighed the alternative.", + id: "boss_the_undying", + sourceId: "the_undying", sourceType: "boss", - sourceId: "the_undying", - zoneId: "eternal_throne", + title: "The Persistence of Being", + zoneId: "eternal_throne", }, { - id: "boss_apex_sovereign", - title: "The Final Authority", content: "The Apex Sovereign had ruled the Eternal Throne for so long that it had ceased to govern anything specific and had become governance itself — the abstract principle of authority made manifest. It issued edicts to no subjects, enforced laws in no territory, and collected taxes from no one. It had, your philosophers observed, achieved a perfect and completely useless form of power: total sovereignty over nothing at all.", + id: "boss_apex_sovereign", + sourceId: "apex_sovereign", sourceType: "boss", - sourceId: "apex_sovereign", - zoneId: "eternal_throne", + title: "The Final Authority", + zoneId: "eternal_throne", }, { - id: "boss_the_apex", - title: "The Summit of What Is", content: "The Apex was not a being that ruled the Eternal Throne but the culmination of what every ruler of it had been building toward — the final form of a project that had begun before any of the throne's individual occupants could remember. What the project was building toward, your scholars concluded after extensive analysis, was an answer to a question that had never been explicitly asked. The question, they believe, was: 'What does it mean to have been?'", + id: "boss_the_apex", + sourceId: "the_apex", sourceType: "boss", - sourceId: "the_apex", - zoneId: "eternal_throne", + title: "The Summit of What Is", + zoneId: "eternal_throne", }, // ── Eternal Throne — Quests ─────────────────────────────────────────────── { - id: "quest_throne_antechamber", - title: "The Waiting Room of Power", content: "The Throne Antechamber was lined with the portraits of every supplicant who had ever waited for an audience — a tradition that had continued long after audiences ceased to be granted. The most recent portrait was dated four hundred years ago. The subject looked, your adventurers noted, not impatient but hopeful. The hope was the thing that was difficult to look at for long.", + id: "quest_throne_antechamber", + sourceId: "throne_antechamber", sourceType: "quest", - sourceId: "throne_antechamber", - zoneId: "eternal_throne", + title: "The Waiting Room of Power", + zoneId: "eternal_throne", }, { - id: "quest_eternal_gauntlet", - title: "The Test That Outlasted Its Purpose", content: "The Eternal Gauntlet had been designed to prove worthiness for admission to the Throne — a series of challenges that tested strength, wisdom, compassion, and something the original designers had called 'appropriate humility.' Your adventurers cleared it in record time, which the Gauntlet's evaluation mechanism flagged as suspicious. It had never been cleared quickly before. Apparently, previous challengers had spent considerable time on the compassion portion.", + id: "quest_eternal_gauntlet", + sourceId: "eternal_gauntlet", sourceType: "quest", - sourceId: "eternal_gauntlet", - zoneId: "eternal_throne", + title: "The Test That Outlasted Its Purpose", + zoneId: "eternal_throne", }, { - id: "quest_apex_trials", - title: "Judged by Standards Older Than Memory", content: "The Apex Trials evaluated your adventurers against criteria that predated any known philosophical tradition — standards that appeared, when your scholars analysed them, to have been derived not from any moral theory but from observation. Someone had watched a great many beings over a very long time and had developed, from that watching, a sense of what made them worth knowing. The criteria were kinder than expected.", + id: "quest_apex_trials", + sourceId: "apex_trials", sourceType: "quest", - sourceId: "apex_trials", - zoneId: "eternal_throne", + title: "Judged by Standards Older Than Memory", + zoneId: "eternal_throne", }, { - id: "quest_sovereign_hall", - title: "The Hall That Echoes All Who Passed", content: "The Sovereign's Hall retained acoustic memory — sounds made within it were preserved in the walls and replayed at intervals, so that walking its length meant walking through a layered history of every ceremony, argument, plea, and declaration that had ever taken place there. Your adventurers reported hearing voices in languages they did not recognise and one voice, near the end, speaking something very close to a language one of them knew. They did not mention this to the others.", + id: "quest_sovereign_hall", + sourceId: "sovereign_hall", sourceType: "quest", - sourceId: "sovereign_hall", - zoneId: "eternal_throne", + title: "The Hall That Echoes All Who Passed", + zoneId: "eternal_throne", }, { - id: "quest_the_final_ascent", - title: "The Last Steps", content: "The final ascent to the Throne itself was unguarded — every defence had been cleared, every gate opened, every warden dealt with. The path was clear, and the Throne was visible. Your adventurers described stopping at this point, unexpectedly, and sitting down on the steps. Not from exhaustion. They had simply needed a moment to understand that they were about to do something that, once done, could not be undone. They sat for an hour. Then they continued.", + id: "quest_the_final_ascent", + sourceId: "the_final_ascent", sourceType: "quest", - sourceId: "the_final_ascent", - zoneId: "eternal_throne", + title: "The Last Steps", + zoneId: "eternal_throne", }, { - id: "quest_eternal_dominion", - title: "What Power Looks Like When Nobody Is Watching", content: "With the Throne finally accessible, your guild's scholars spent three weeks documenting what they found: the mechanisms of a dominion that had governed itself automatically for centuries, allocating resources, resolving conflicts, and maintaining infrastructure for a population that no longer existed. It had done this with extraordinary competence and absolute indifference to whether anyone was there to benefit. Power, your chief scholar noted in her report, has a momentum of its own.", + id: "quest_eternal_dominion", + sourceId: "eternal_dominion", sourceType: "quest", - sourceId: "eternal_dominion", - zoneId: "eternal_throne", + title: "What Power Looks Like When Nobody Is Watching", + zoneId: "eternal_throne", }, // ── Infernal Court — Bosses ─────────────────────────────────────────────── { - id: "boss_demon_prince", - title: "The Politics of Hell", content: "The Demon Prince had navigated the Infernal Court's intricate hierarchy for three millennia through a combination of genuine intelligence, strategic cruelty, and an understanding of bureaucracy that your administrative scholars found genuinely impressive. He had survived seventeen coups, four reorganisations, and one complete regime change by being, at every moment, more useful than dangerous. He met his end not in battle but in an audit he could not falsify.", + id: "boss_demon_prince", + sourceId: "demon_prince", sourceType: "boss", - sourceId: "demon_prince", - zoneId: "infernal_court", + title: "The Politics of Hell", + zoneId: "infernal_court", }, { - id: "boss_hellfire_titan", - title: "The Enforcement Arm", content: "The Hellfire Titan was the Court's blunt instrument — it did not strategise, negotiate, or compromise. It was the final consequence, deployed when all other options had been exhausted. Your adventurers noted that it seemed to find its role uncomfortable, performing its function with the manner of someone doing a job they did not love but were very good at and could not imagine giving up. This, your philosophers observed, made it more relatable than most of what they encountered below.", + id: "boss_hellfire_titan", + sourceId: "hellfire_titan", sourceType: "boss", - sourceId: "hellfire_titan", - zoneId: "infernal_court", + title: "The Enforcement Arm", + zoneId: "infernal_court", }, { - id: "boss_lord_of_sin", - title: "The Taxonomy of Want", content: "The Lord of Sin had catalogued every variety of mortal desire over a career spanning an incomprehensible number of years, and had developed, from this encyclopaedic knowledge, a deep and unexpected sympathy. It understood want so completely that it could no longer find it contemptible. The battle with your adventurers was punctuated by the Lord's apparently genuine observations about your party's individual desires, all of which were accurate, none of which were weaponised. It seemed to consider this beneath it.", + id: "boss_lord_of_sin", + sourceId: "lord_of_sin", sourceType: "boss", - sourceId: "lord_of_sin", - zoneId: "infernal_court", + title: "The Taxonomy of Want", + zoneId: "infernal_court", }, { - id: "boss_infernal_sovereign", - title: "The Oldest Authority Below", content: "The Infernal Sovereign had held its position since before the Court had been a court — it was the original power that everything else had been built around, the axiom from which the hierarchy derived. Challenging it was, your guild's philosophers noted, technically a challenge to the premise of the Infernal Court's existence rather than to any individual within it. The Sovereign seemed to find this interesting. It was the first time in several millennia that it had found anything interesting.", + id: "boss_infernal_sovereign", + sourceId: "infernal_sovereign", sourceType: "boss", - sourceId: "infernal_sovereign", - zoneId: "infernal_court", + title: "The Oldest Authority Below", + zoneId: "infernal_court", }, { - id: "boss_the_fallen", - title: "The One Who Chose", content: "The Fallen was distinguished from every other resident of the Infernal Court by one fact: it had chosen to be there. Every other entity in the Court had arrived by necessity, compulsion, or mistake. The Fallen had surveyed the options available to it and had selected the Infernal Court as the most honest of them. Your scholars found this both philosophically provocative and practically very annoying, since 'honestly chosen evil' presented complications for every framework they had.", + id: "boss_the_fallen", + sourceId: "the_fallen", sourceType: "boss", - sourceId: "the_fallen", - zoneId: "infernal_court", + title: "The One Who Chose", + zoneId: "infernal_court", }, // ── Infernal Court — Quests ─────────────────────────────────────────────── { - id: "quest_brimstone_wastes", - title: "The Floor of Consequence", content: "The Brimstone Wastes were the outermost layer of the Infernal Court — not yet organised into its hierarchies, just raw consequence, the accumulated result of choices made and not unmade. Your adventurers moved through it carefully, noting that the landscape reflected their own progress: the further in they went, the more the terrain seemed to respond to specific things they had done. Your chronicler did not record the details. The relevant parties were informed privately.", + id: "quest_brimstone_wastes", + sourceId: "brimstone_wastes", sourceType: "quest", - sourceId: "brimstone_wastes", - zoneId: "infernal_court", + title: "The Floor of Consequence", + zoneId: "infernal_court", }, { - id: "quest_pit_of_souls", - title: "The Queue That Never Moves", content: "The Pit of Souls was not a place of torment but of bureaucratic stagnation — souls awaiting processing in a system that had backed up over centuries due to incomplete forms, missing documentation, and several classification disputes that had never been resolved. Your adventurers, upon realising what they were witnessing, spent an afternoon helping to clear the backlog. Your chief administrator later noted this was the most morally productive thing your guild had ever done, pound for pound.", + id: "quest_pit_of_souls", + sourceId: "pit_of_souls", sourceType: "quest", - sourceId: "pit_of_souls", - zoneId: "infernal_court", + title: "The Queue That Never Moves", + zoneId: "infernal_court", }, { - id: "quest_court_of_blood", - title: "Justice in the Infernal Style", content: "The Court of Blood was the Infernal Court's judicial body, presiding over disputes with a rigorousness that your legal scholars found simultaneously horrifying and admirable. Its standards of evidence were impeccable; its punishments were not. Your adventurers observed three trials during their visit and noted that in each case the verdict was technically correct. The technical correctness, they agreed, was somehow the worst part.", + id: "quest_court_of_blood", + sourceId: "court_of_blood", sourceType: "quest", - sourceId: "court_of_blood", - zoneId: "infernal_court", + title: "Justice in the Infernal Style", + zoneId: "infernal_court", }, { - id: "quest_nine_hells", - title: "A Tour of What Was Decided", content: "The Nine Hells were not, as your adventurers expected, nine distinct places — they were nine perspectives on the same place, each emphasising a different aspect of consequence. Navigating between them required understanding not geography but emphasis. The most unsettling was the ninth, which was identical to the first in every observable way except that the inhabitants of the ninth were fully aware of the difference, and the inhabitants of the first were not.", + id: "quest_nine_hells", + sourceId: "nine_hells", sourceType: "quest", - sourceId: "nine_hells", - zoneId: "infernal_court", + title: "A Tour of What Was Decided", + zoneId: "infernal_court", }, { - id: "quest_demon_forge", - title: "Where Evil Is Made Efficient", content: "The Demon Forge was the Court's manufacturing centre — the place where abstract malice was converted into concrete mechanism. Your engineers, reviewing the recovered schematics, noted that the designs were elegant, efficient, and entirely without redundancy. Every component served exactly one function and served it perfectly. They also noted, quietly, that several of the principles used in the designs were applicable to benign purposes, and were uncertain whether to say so.", + id: "quest_demon_forge", + sourceId: "demon_forge", sourceType: "quest", - sourceId: "demon_forge", - zoneId: "infernal_court", + title: "Where Evil Is Made Efficient", + zoneId: "infernal_court", }, { - id: "quest_infernal_codex", - title: "The Rules of the Underworld", content: "The Infernal Codex was the Court's governing document — a legal text of extraordinary complexity that had been amended, annotated, and reinterpreted over millennia until the original text was almost entirely buried beneath commentary. Your legal scholars needed six months to read it and produced a summary that was itself four hundred pages long. The most significant finding: the Codex contained, in Appendix 7 of Amendment 394, explicit provisions for the circumstances under which your guild's actions would be considered lawful. Someone had anticipated you.", + id: "quest_infernal_codex", + sourceId: "infernal_codex", sourceType: "quest", - sourceId: "infernal_codex", - zoneId: "infernal_court", + title: "The Rules of the Underworld", + zoneId: "infernal_court", }, // ── Infinite Expanse — Bosses ───────────────────────────────────────────── { - id: "boss_expanse_drifter", - title: "Lost in the Between", content: "The Expanse Drifter had been moving through the Infinite Expanse for so long that it had lost any sense of origin or destination. It drifted not aimlessly but purposefully, following currents that your scholars could not detect, toward a destination that it could not name. When your adventurers intercepted it, it fought with the mechanical fury of something that had been interrupted mid-sentence, unable to explain what it had been about to say.", + id: "boss_expanse_drifter", + sourceId: "expanse_drifter", sourceType: "boss", - sourceId: "expanse_drifter", - zoneId: "infinite_expanse", + title: "Lost in the Between", + zoneId: "infinite_expanse", }, { - id: "boss_horizon_beast", - title: "What Lives at the Edge of Sight", content: "The Horizon Beast could never be approached directly — it existed specifically at the boundary of perception, visible only at the limit of sight. Your adventurers developed an oblique method of engagement, never looking directly at it, advancing by feel and inference. Several reported that when they finally reached it, they understood briefly why the horizon is always the same distance away: because the world keeps making more of it.", + id: "boss_horizon_beast", + sourceId: "horizon_beast", sourceType: "boss", - sourceId: "horizon_beast", - zoneId: "infinite_expanse", + title: "What Lives at the Edge of Sight", + zoneId: "infinite_expanse", }, { - id: "boss_infinity_construct", - title: "A Machine for Making Space", content: "The Infinity Construct was not a guardian of the Expanse but its generator — the mechanism by which the Infinite Expanse remained infinite. Your scholars spent considerable time on the philosophical implications of defeating it, concluding that since the Expanse continued to be infinite afterward, the Construct was either not actually the generator or the Expanse had become self-sustaining. They prefer the second explanation. It is more interesting.", + id: "boss_infinity_construct", + sourceId: "infinity_construct", sourceType: "boss", - sourceId: "infinity_construct", - zoneId: "infinite_expanse", + title: "A Machine for Making Space", + zoneId: "infinite_expanse", }, { - id: "boss_expanse_sovereign", - title: "The Ruler of Nothing in Particular", content: "The Expanse Sovereign ruled over territory that was, by definition, without boundary — which made its sovereignty simultaneously absolute and entirely meaningless. It had resolved this paradox by ruling intensely over a very small area at the centre of the Expanse, with tremendous attention to detail, while claiming nominal dominion over everything outside. Your adventurers found it in its small kingdom, attending carefully to a garden. The battle interrupted the watering schedule.", + id: "boss_expanse_sovereign", + sourceId: "expanse_sovereign", sourceType: "boss", - sourceId: "expanse_sovereign", - zoneId: "infinite_expanse", + title: "The Ruler of Nothing in Particular", + zoneId: "infinite_expanse", }, // ── Infinite Expanse — Quests ───────────────────────────────────────────── { - id: "quest_first_horizon", - title: "The Limit That Moves", content: "The first expedition into the Infinite Expanse established immediately that conventional cartography was inadequate — the Expanse did not have fixed coordinates in any useful sense, and maps of it described only the mapper's path, not any stable geography. Your cartographers produced a document titled 'A Record of Where We Were, In the Order We Were There.' It is considered a masterpiece of cartographic honesty.", + id: "quest_first_horizon", + sourceId: "first_horizon", sourceType: "quest", - sourceId: "first_horizon", - zoneId: "infinite_expanse", + title: "The Limit That Moves", + zoneId: "infinite_expanse", }, { - id: "quest_endless_sea", - title: "Water Without Shore", content: "The Endless Sea was precisely what its name suggested — a body of water that extended in all directions without reaching any shore, fed by rainfall and lost to evaporation in a balance that had maintained itself for geological time. Your adventurers sailed it for seventeen days without finding land, then stopped expecting to. On the twenty-second day they found a message in a bottle. Inside was a map. The map led back to the message.", + id: "quest_endless_sea", + sourceId: "endless_sea", sourceType: "quest", - sourceId: "endless_sea", - zoneId: "infinite_expanse", + title: "Water Without Shore", + zoneId: "infinite_expanse", }, { - id: "quest_expanse_ruins", - title: "What Was Built in Emptiness", content: "The ruins in the Expanse raised questions that your scholars found deeply unsatisfying because they could not be answered: who builds in a place with no neighbours, no resources, and no apparent purpose? The ruins were well-constructed, clearly inhabited, and completely inexplicable. The most your scholars could determine was that whoever had lived there had been happy. The domestic evidence was unambiguous on this point. Everything else was not.", + id: "quest_expanse_ruins", + sourceId: "expanse_ruins", sourceType: "quest", - sourceId: "expanse_ruins", - zoneId: "infinite_expanse", + title: "What Was Built in Emptiness", + zoneId: "infinite_expanse", }, { - id: "quest_infinite_archive", - title: "A Library With No Last Page", content: "The Infinite Archive was what its name implied — a collection that, by the rules of the Expanse, had no terminus. Your scholars entered through one door and spent two weeks exploring before they agreed to stop. They had found, in that time, texts in over a hundred distinct writing systems, records spanning multiple geological eras, and several sections that appeared to document events that had not yet happened. They did not read the latter in detail. Your chief scholar maintains it was an ethical decision. Your chronicler notes it may have been a practical one.", + id: "quest_infinite_archive", + sourceId: "infinite_archive", sourceType: "quest", - sourceId: "infinite_archive", - zoneId: "infinite_expanse", + title: "A Library With No Last Page", + zoneId: "infinite_expanse", }, { - id: "quest_paradox_plains", - title: "Where Logic Takes a Different Path", content: "The Paradox Plains occupied a region of the Expanse where self-referential logic was physically real — things that contradicted themselves existed in stable tension rather than collapsing. Your philosophers found it professionally exciting and personally very uncomfortable. Navigation required accepting multiple contradictory facts simultaneously, which your adventurers described as 'like holding your breath, but for your assumptions.'", + id: "quest_paradox_plains", + sourceId: "paradox_plains", sourceType: "quest", - sourceId: "paradox_plains", - zoneId: "infinite_expanse", + title: "Where Logic Takes a Different Path", + zoneId: "infinite_expanse", }, { - id: "quest_expanse_codex", - title: "Notes Toward Understanding Infinity", content: "The Expanse Codex was assembled from the collective observations of every expedition your guild had sent into the Infinite Expanse — an attempt to find patterns in a place specifically designed to resist them. Your scholars eventually concluded that the Expanse had one consistent property: it accommodated. Whatever was brought into it was given room. Whatever was looked for was eventually found. The Expanse, your chief scholar wrote in her conclusion, appears to be fundamentally hospitable. This raised more questions than it answered.", + id: "quest_expanse_codex", + sourceId: "expanse_codex", sourceType: "quest", - sourceId: "expanse_codex", - zoneId: "infinite_expanse", + title: "Notes Toward Understanding Infinity", + zoneId: "infinite_expanse", }, // ── Primeval Sanctum — Bosses ───────────────────────────────────────────── { - id: "boss_ancient_sentinel", - title: "Memory's First Guard", content: "The Ancient Sentinel had guarded the Primeval Sanctum since before the Sanctum had a name — it was among the first things to exist and had been set to its purpose by something that no longer existed to rescind the order. It recognised your adventurers as the first new things it had encountered in an incalculable span of time and regarded them with an attention that felt, somehow, like relief. The battle was vigorous but brief. Afterward, it seemed satisfied.", + id: "boss_ancient_sentinel", + sourceId: "ancient_sentinel", sourceType: "boss", - sourceId: "ancient_sentinel", - zoneId: "primeval_sanctum", + title: "Memory's First Guard", + zoneId: "primeval_sanctum", }, { - id: "boss_time_elder", - title: "The One Who Was There First", content: "The Time Elder had been present at the beginning of time and had spent every subsequent moment being very tired of questions about what it had been like. It answered your scholars with the patience of someone who has explained the same thing to many generations of curious visitors and has long since accepted that the explanation will never be adequate. What time was like before there was time, it said, was not something that had a like. It simply was. Your scholars found this unhelpful. The Time Elder found their finding this unhelpful unsurprising.", + id: "boss_time_elder", + sourceId: "time_elder", sourceType: "boss", - sourceId: "time_elder", - zoneId: "primeval_sanctum", + title: "The One Who Was There First", + zoneId: "primeval_sanctum", }, { - id: "boss_origin_beast", - title: "The First Creature", content: "The Origin Beast was, in the most literal sense, the first animal — the original instance of the template from which all subsequent life had been derived, directly or indirectly. It did not know this, having no capacity for the kind of self-awareness that would make the knowledge meaningful. But it moved through the Sanctum with an authority that your naturalists said was not territorial but fundamental — the confidence of something that had been there first and knew, in its bones, that this applied to everything.", + id: "boss_origin_beast", + sourceId: "origin_beast", sourceType: "boss", - sourceId: "origin_beast", - zoneId: "primeval_sanctum", + title: "The First Creature", + zoneId: "primeval_sanctum", }, { - id: "boss_primeval_god", - title: "Before Belief Was Invented", content: "The Primeval God predated the concept of divinity — it had existed before there was any mind to define what a god was, and therefore occupied the category without fitting any of the definitions. It did not demand worship because worship had not yet been invented when it formed. Your theologians found the encounter professionally destabilising and personally illuminating in ways they were reluctant to specify in official reports.", + id: "boss_primeval_god", + sourceId: "primeval_god", sourceType: "boss", - sourceId: "primeval_god", - zoneId: "primeval_sanctum", + title: "Before Belief Was Invented", + zoneId: "primeval_sanctum", }, // ── Primeval Sanctum — Quests ───────────────────────────────────────────── { - id: "quest_sanctum_gate", - title: "The Threshold Before Thresholds", content: "The Sanctum Gate was the first door — the original threshold that the concept of entry and exit had been derived from. Passing through it felt, your adventurers reported, like understanding something they had always known without being able to articulate it. What was on the other side was identical to what had been on this side. The difference was entirely in the passing.", + id: "quest_sanctum_gate", + sourceId: "sanctum_gate", sourceType: "quest", - sourceId: "sanctum_gate", - zoneId: "primeval_sanctum", + title: "The Threshold Before Thresholds", + zoneId: "primeval_sanctum", }, { - id: "quest_memory_vaults", - title: "What Was Kept from the Beginning", content: "The Memory Vaults stored impressions from the earliest period of the world's existence — not records, but actual memories, preserved in a medium your scholars could not identify and accessed by a process they could not describe as anything other than remembering something you had never experienced. Your adventurers spent three days in the Vaults and emerged unable to determine which of their oldest childhood memories were genuinely theirs.", + id: "quest_memory_vaults", + sourceId: "memory_vaults", sourceType: "quest", - sourceId: "memory_vaults", - zoneId: "primeval_sanctum", + title: "What Was Kept from the Beginning", + zoneId: "primeval_sanctum", }, { - id: "quest_origin_halls", - title: "The Rooms Where Things Began", content: "Each hall in the Origin complex was the location where a specific thing had first occurred — the first fire, the first rain, the first decision. The halls were not large, and they were not remarkable in appearance. Your adventurers found this appropriate. The most significant moments in history rarely announce themselves with proportionate grandeur. They happen in ordinary rooms, which only become extraordinary in retrospect.", + id: "quest_origin_halls", + sourceId: "origin_halls", sourceType: "quest", - sourceId: "origin_halls", - zoneId: "primeval_sanctum", + title: "The Rooms Where Things Began", + zoneId: "primeval_sanctum", }, { - id: "quest_first_light_hall", - title: "When the World First Saw Itself", content: "The Hall of First Light preserved the resonance of the first moment that light had existed in the world — not the physical light, which was long gone, but the fact of it: the quality of being the first. Your scholars spent considerable time attempting to define what the first fact of existence implied, and concluded, eventually, that it implied that there had been a second, which was the more important thing. The first implied that things could begin. The second implied that they could continue.", + id: "quest_first_light_hall", + sourceId: "first_light_hall", sourceType: "quest", - sourceId: "first_light_hall", - zoneId: "primeval_sanctum", + title: "When the World First Saw Itself", + zoneId: "primeval_sanctum", }, { - id: "quest_before_time", - title: "The Moment Before the First Moment", content: "Before Time was a region of the Sanctum that existed in the interval before time had begun — a concept that your philosophers found formally impossible and your adventurers found merely very strange. Within it, nothing moved in the conventional sense, because movement requires time. But things were different at different points within it, which meant that some kind of sequence obtained, governed by a principle your scholars ultimately labelled 'not-yet-time' and chose not to investigate further.", + id: "quest_before_time", + sourceId: "before_time", sourceType: "quest", - sourceId: "before_time", - zoneId: "primeval_sanctum", + title: "The Moment Before the First Moment", + zoneId: "primeval_sanctum", }, { - id: "quest_sanctum_chronicle", - title: "The History Before History", content: "The Sanctum Chronicle recorded the period before any conscious being had existed to record anything — events that had occurred without observers, processes that had unfolded without purpose. Your scholars found it the most humbling document they had ever read: irrefutable evidence that the universe had been extremely busy and extraordinarily indifferent long before anyone arrived to have opinions about it.", + id: "quest_sanctum_chronicle", + sourceId: "sanctum_chronicle", sourceType: "quest", - sourceId: "sanctum_chronicle", - zoneId: "primeval_sanctum", + title: "The History Before History", + zoneId: "primeval_sanctum", }, // ── Primordial Chaos — Bosses ───────────────────────────────────────────── { - id: "boss_chaos_wyrm", - title: "Serpent of Unformed Things", content: "The Chaos Wyrm swam through the Primordial Chaos as fish swim through water — effortlessly, naturally, belonging completely to its medium. Its form was not fixed because form itself was not yet fixed in the regions it inhabited. Your adventurers engaged it in a location where reality had settled sufficiently to sustain the encounter, but even there, the edges of the battle kept shifting in ways that required constant recalibration.", + id: "boss_chaos_wyrm", + sourceId: "chaos_wyrm", sourceType: "boss", - sourceId: "chaos_wyrm", - zoneId: "primordial_chaos", + title: "Serpent of Unformed Things", + zoneId: "primordial_chaos", }, { - id: "boss_creation_engine", - title: "The Machine That Makes Things Real", content: "The Creation Engine processed the raw material of the Primordial Chaos into stable forms — converting potential into actuality, possibility into fact. It did not choose what to create; it responded to gradients in the chaos, producing whatever the surrounding conditions called for. Your engineers found its mechanisms beautiful in a way that made them uncomfortable. It was doing what they did, without tools or intention, through the pure operation of consequence.", + id: "boss_creation_engine", + sourceId: "creation_engine", sourceType: "boss", - sourceId: "creation_engine", - zoneId: "primordial_chaos", + title: "The Machine That Makes Things Real", + zoneId: "primordial_chaos", }, { - id: "boss_entropy_avatar", - title: "The Direction of Everything", content: "The Entropy Avatar was not a destructive force but a directional one — the principle by which time had a preferred direction, the reason why broken things do not spontaneously reassemble. Your physicists found the encounter philosophically clarifying: entropy was not the enemy of order but its context, the canvas on which order was visible precisely because it was rare. The Avatar, when they explained this to it, seemed neither pleased nor displeased. It was simply the principle, continuing.", + id: "boss_entropy_avatar", + sourceId: "entropy_avatar", sourceType: "boss", - sourceId: "entropy_avatar", - zoneId: "primordial_chaos", + title: "The Direction of Everything", + zoneId: "primordial_chaos", }, { - id: "boss_primordial_titan", - title: "Older Than the Laws", content: "The Primordial Titan predated the physical laws that would eventually govern it — it had existed in the period when those laws were still forming, and had therefore never fully conformed to them. Your scholars documented seventeen instances during the battle where the Titan did things that the laws of physics did not permit. The laws were not violated; they were simply disregarded with the ease of someone ignoring rules they had never agreed to.", + id: "boss_primordial_titan", + sourceId: "primordial_titan", sourceType: "boss", - sourceId: "primordial_titan", - zoneId: "primordial_chaos", + title: "Older Than the Laws", + zoneId: "primordial_chaos", }, // ── Primordial Chaos — Quests ───────────────────────────────────────────── { - id: "quest_chaos_entry", - title: "Into Unformed Country", content: "Entering the Primordial Chaos required abandoning every navigational and perceptual assumption your adventurers had developed over their careers. Direction was not fixed. Causality was probabilistic. Identity was subject to negotiation. Your chronicler's notes from the expedition's first day contain the observation: 'Everything is happening and nothing has happened yet, simultaneously and without contradiction.' Later entries become more technical and less existential, which your scholars read as a sign of successful adaptation.", + id: "quest_chaos_entry", + sourceId: "chaos_entry", sourceType: "quest", - sourceId: "chaos_entry", - zoneId: "primordial_chaos", + title: "Into Unformed Country", + zoneId: "primordial_chaos", }, { - id: "quest_chaos_currents", - title: "Navigating Before Navigation Existed", content: "The Chaos Currents were the flow patterns that had eventually, over immense spans of time, organised into the laws of physics — but here, near the source, they were still fluid. Your adventurers learned to move with them rather than against them, finding that the currents had a logic, just not one that could be expressed in any existing notation. Your lead navigator produced a practical guide that consists entirely of diagrams. Words, she noted, were inadequate.", + id: "quest_chaos_currents", + sourceId: "chaos_currents", sourceType: "quest", - sourceId: "chaos_currents", - zoneId: "primordial_chaos", + title: "Navigating Before Navigation Existed", + zoneId: "primordial_chaos", }, { - id: "quest_unformed_wastes", - title: "Everything at Once and Nothing Yet", content: "The Unformed Wastes were regions where the Primordial Chaos had not yet resolved into any stable configuration — where every possible form existed in superposition, waiting for something to collapse them into actuality. Walking through them felt, your adventurers reported, like walking through every place simultaneously and belonging to none of them. One adventurer sat down in the Wastes, closed her eyes, and when she opened them again, she was on the other side. She does not know how long it took.", + id: "quest_unformed_wastes", + sourceId: "unformed_wastes", sourceType: "quest", - sourceId: "unformed_wastes", - zoneId: "primordial_chaos", + title: "Everything at Once and Nothing Yet", + zoneId: "primordial_chaos", }, { - id: "quest_potential_vaults", - title: "Stored Impossibilities", content: "The Vaults of Potential contained forms that the Primordial Chaos had generated but that had never been instantiated in the actual world — shapes, structures, and entities that had existed as possibilities but had never been selected by the processes of creation. Your naturalists found species that had almost been real, physics that had almost been law, mathematics that had almost been true. The Vaults smelled, several adventurers agreed, like everything that had never happened.", + id: "quest_potential_vaults", + sourceId: "potential_vaults", sourceType: "quest", - sourceId: "potential_vaults", - zoneId: "primordial_chaos", + title: "Stored Impossibilities", + zoneId: "primordial_chaos", }, { - id: "quest_creation_cradle", - title: "Where the World Began to Decide", content: "The Creation Cradle was the specific location where the Primordial Chaos had first begun to differentiate — where the first distinctions had been drawn and the first stable patterns had formed. Your scholars described it as standing at the moment of the first decision, except the decision had no decider and no deliberation. It was, they wrote, the most profound thing they had witnessed, and they refused to say more than that.", + id: "quest_creation_cradle", + sourceId: "creation_cradle", sourceType: "quest", - sourceId: "creation_cradle", - zoneId: "primordial_chaos", + title: "Where the World Began to Decide", + zoneId: "primordial_chaos", }, { - id: "quest_chaos_chronicle", - title: "The Record No One Was There to Keep", content: "The Chaos Chronicle was not written by any author — it was recovered from the accumulated impressions of the Primordial Chaos itself, translated by your scholars into a narrative that was necessarily incomplete and necessarily distorted. What they recovered was not history but geology: the record of forces acting on forces, with no intelligence to give them meaning. Your chief scholar's note at the document's end reads: 'The universe did not need us to begin. This does not diminish us. It contextualises us.'", + id: "quest_chaos_chronicle", + sourceId: "chaos_chronicle", sourceType: "quest", - sourceId: "chaos_chronicle", - zoneId: "primordial_chaos", + title: "The Record No One Was There to Keep", + zoneId: "primordial_chaos", }, // ── Reality Forge — Bosses ──────────────────────────────────────────────── { - id: "boss_forge_guardian", - title: "Protector of the Blueprint", content: "The Forge Guardian was the first and most literal of its kind — it guarded not a treasure but a process, ensuring that the Reality Forge continued to function without interference. It had denied entry to countless beings over its tenure, using a criterion that your adventurers ultimately determined was not strength or worthiness but purpose: it admitted those who came to understand, and refused those who came to take. Your guild was the first admitted group in four hundred years.", + id: "boss_forge_guardian", + sourceId: "forge_guardian", sourceType: "boss", - sourceId: "forge_guardian", - zoneId: "reality_forge", + title: "Protector of the Blueprint", + zoneId: "reality_forge", }, { - id: "boss_reality_shaper", - title: "The Sculptor of What Is", content: "The Reality Shaper worked in the Forge's active chambers, adjusting the parameters of existence with the casual competence of a craftsperson who has done the same job for a very long time. It did not consider itself powerful, which your philosophers found instructive — power, it seemed, is most absolute when it is most routine. The battle disrupted several local physical constants temporarily. They resolved, but your engineers noted that one of them resolved into a slightly different value.", + id: "boss_reality_shaper", + sourceId: "reality_shaper", sourceType: "boss", - sourceId: "reality_shaper", - zoneId: "reality_forge", + title: "The Sculptor of What Is", + zoneId: "reality_forge", }, { - id: "boss_creation_prime", - title: "The First Among Makers", content: "The Creation Prime was the Reality Forge's senior architect — the entity responsible for the highest-level decisions about what was physically possible and what was not. It had made these decisions for so long that it had ceased to experience them as choices, executing the work of creation with a craft discipline that your engineers described, with evident admiration and slight terror, as 'technically perfect.' The battle required your adventurers to make several things impossible that had previously been possible. The corrections took two days.", + id: "boss_creation_prime", + sourceId: "creation_prime", sourceType: "boss", - sourceId: "creation_prime", - zoneId: "reality_forge", + title: "The First Among Makers", + zoneId: "reality_forge", }, { - id: "boss_reality_architect", - title: "Who Drew the Plans", content: "The Reality Architect had designed the fundamental structure of physical law — not invented it, your scholars noted carefully, but designed it, which implied intent. What the intent was had been the subject of seven centuries of philosophical debate before your guild found the Architect still working in the Forge. When your chief scholar asked it directly what the intent was, the Architect looked at her for a long time and then said: 'Durability.' Your scholars are still arguing about what this means.", + id: "boss_reality_architect", + sourceId: "reality_architect", sourceType: "boss", - sourceId: "reality_architect", - zoneId: "reality_forge", + title: "Who Drew the Plans", + zoneId: "reality_forge", }, // ── Reality Forge — Quests ──────────────────────────────────────────────── { - id: "quest_forge_entrance", - title: "Standing at the Source", content: "The entrance to the Reality Forge was unremarkable in appearance — a door, ordinary in every respect, set into a wall that appeared to be made of possibility rather than any specific material. Passing through it changed nothing observable about your adventurers. They confirmed this by checking carefully. What changed was subtler: each reported that the world on the other side felt more deliberate, as though the Forge's intentionality had permeated even the space adjacent to it.", + id: "quest_forge_entrance", + sourceId: "forge_entrance", sourceType: "quest", - sourceId: "forge_entrance", - zoneId: "reality_forge", + title: "Standing at the Source", + zoneId: "reality_forge", }, { - id: "quest_blueprint_vault", - title: "The Plans for Everything", content: "The Blueprint Vault contained the original specifications for every aspect of physical reality — not as documents but as actualised principles, directly accessible to those who knew how to read them. Your scholars spent three weeks in the Vault and emerged with the shared conviction that the physical laws they had spent their careers studying were not discovered but chosen, and that the chooser had been thoughtful, rigorous, and had left very detailed notes.", + id: "quest_blueprint_vault", + sourceId: "blueprint_vault", sourceType: "quest", - sourceId: "blueprint_vault", - zoneId: "reality_forge", + title: "The Plans for Everything", + zoneId: "reality_forge", }, { - id: "quest_creation_workshop", - title: "Where Things Are Still Being Made", content: "The Creation Workshop was the Forge's active production area — the space where new aspects of reality were still being refined and tested. Your adventurers moved through carefully, aware that disturbing the work might have consequences extending far beyond the Workshop itself. They were also aware that the work being done around them was extraordinary. Your chronicler's notes are meticulous on the technical details and entirely silent on how it felt to be present for them.", + id: "quest_creation_workshop", + sourceId: "creation_workshop", sourceType: "quest", - sourceId: "creation_workshop", - zoneId: "reality_forge", + title: "Where Things Are Still Being Made", + zoneId: "reality_forge", }, { - id: "quest_laws_engine", - title: "The Mechanism of Must", content: "The Laws Engine maintained the consistency of physical law across all of creation — a function so fundamental that your engineers initially refused to believe it was a single mechanism. The engine had never required maintenance in its operational history because it had been designed, they determined, to require none. Your engineers found three components they believed were redundant. They were not. The Forge politely corrected the misunderstanding before any harm was done.", + id: "quest_laws_engine", + sourceId: "laws_engine", sourceType: "quest", - sourceId: "laws_engine", - zoneId: "reality_forge", + title: "The Mechanism of Must", + zoneId: "reality_forge", }, { - id: "quest_forge_heart", - title: "The Centre of Making", content: "The Forge Heart was the core from which all creative activity radiated — the original source, still generating after immeasurable time, still producing the foundational impulse that kept reality coherent and creative rather than merely static. Standing in it, your adventurers reported, felt like standing inside a sentence that was still being spoken: complete so far, but not finished, with more coming. They all felt, briefly and without explanation, hopeful.", + id: "quest_forge_heart", + sourceId: "forge_heart", sourceType: "quest", - sourceId: "forge_heart", - zoneId: "reality_forge", + title: "The Centre of Making", + zoneId: "reality_forge", }, { - id: "quest_forge_chronicle", - title: "The Making of the Made", content: "The Forge Chronicle documented the entire history of the Reality Forge's operation — every decision, adjustment, and refinement made to the structure of physical law from its inception to the present. Your scholars read it with the attention it deserved, which took four months. The concluding section, the most recent, contained a note that had been added after your guild's first expedition to the Forge. It read: 'External access granted. Evaluation ongoing.'", + id: "quest_forge_chronicle", + sourceId: "forge_chronicle", sourceType: "quest", - sourceId: "forge_chronicle", - zoneId: "reality_forge", + title: "The Making of the Made", + zoneId: "reality_forge", }, // ── The Absolute — Bosses ───────────────────────────────────────────────── { - id: "boss_absolute_herald", - title: "The Messenger of Nothing", content: "The Absolute Herald arrived before the Absolute itself, announcing it by its absence — by the way everything around it became slightly less than it had been. Your adventurers described fighting it as arguing with a conclusion: the Herald was the announcement that something was over, and defeating it required insisting, in the most physical possible terms, that it was not. The insistence worked. The Herald seemed to find this unusual.", + id: "boss_absolute_herald", + sourceId: "absolute_herald", sourceType: "boss", - sourceId: "absolute_herald", - zoneId: "the_absolute", + title: "The Messenger of Nothing", + zoneId: "the_absolute", }, { - id: "boss_void_convergence", - title: "All Roads Ending Here", content: "The Void Convergence was the point where all paths that led nowhere led — the terminal node of every journey that had ended in absence. It was very quiet. Your adventurers found it less frightening than they expected and more melancholy: a place dense with the accumulated weight of things that had tried and not succeeded, gathered together at the last point before gone. They were quiet for a long time after the battle. Nobody asked why.", + id: "boss_void_convergence", + sourceId: "void_convergence", sourceType: "boss", - sourceId: "void_convergence", - zoneId: "the_absolute", + title: "All Roads Ending Here", + zoneId: "the_absolute", }, { - id: "boss_eternal_end", - title: "The Conclusion That Keeps Concluding", content: "The Eternal End was paradoxical by nature — an ending that persisted, which meant it was not quite an ending, which meant it could not stop trying to end. Your philosophers found its existence both logically problematic and intuitively resonant: things, they noted, often end by degrees, and a conclusion that cannot fully conclude is a more honest representation of most actual endings than a clean terminus would be.", + id: "boss_eternal_end", + sourceId: "eternal_end", sourceType: "boss", - sourceId: "eternal_end", - zoneId: "the_absolute", + title: "The Conclusion That Keeps Concluding", + zoneId: "the_absolute", }, { - id: "boss_the_absolute_one", - title: "The Final Thing", content: "The Absolute One was the last of everything — the entity that would remain after all other things had concluded, if any entity could be said to remain when there was nothing to remain in relation to. Meeting it before that time was, your scholars agreed, deeply irregular. The Absolute One seemed to share this assessment. It had been expecting to meet your guild, it said, but had expected to meet you at the end of everything, not before. It asked how you had found it. Your chief scholar said: persistently. The Absolute One appeared to find this appropriate.", + id: "boss_the_absolute_one", + sourceId: "the_absolute_one", sourceType: "boss", - sourceId: "the_absolute_one", - zoneId: "the_absolute", + title: "The Final Thing", + zoneId: "the_absolute", }, // ── The Absolute — Quests ───────────────────────────────────────────────── { - id: "quest_absolute_threshold", - title: "The Last Door", content: "The Absolute Threshold was the furthest point that any expedition had previously reached — not because it was defended, but because those who arrived there had universally decided to turn back. Your adventurers stood at the Threshold for six hours before proceeding. They did not discuss what they were considering during those six hours. They simply considered it, and then continued.", + id: "quest_absolute_threshold", + sourceId: "absolute_threshold", sourceType: "quest", - sourceId: "absolute_threshold", - zoneId: "the_absolute", + title: "The Last Door", + zoneId: "the_absolute", }, { - id: "quest_nothing_wastes", - title: "Where Absence Is the Landscape", content: "The Nothing Wastes were not empty — emptiness is a quality of space, and the Wastes were a quality of absence. There was no distinction to be made between the foreground and background, no texture to navigate by, no reference points. Your adventurers moved through by holding onto each other and agreeing, out loud, to continue moving forward. They counted their steps. They reached eleven thousand, four hundred and seventy-two before something was there again.", + id: "quest_nothing_wastes", + sourceId: "nothing_wastes", sourceType: "quest", - sourceId: "nothing_wastes", - zoneId: "the_absolute", + title: "Where Absence Is the Landscape", + zoneId: "the_absolute", }, { - id: "quest_final_paradox", - title: "The Question That Answers Itself by Being Asked", content: "The Final Paradox was the Absolute's central contradiction: that the end of everything must contain everything that ends, and therefore cannot be empty, and therefore cannot be the end of everything. Your philosophers had been aware of this paradox theoretically for centuries. Standing inside it was, they reported, not illuminating but clarifying — they no longer found it troubling. They found it, instead, generative. The Absolute was not the end of things. It was the edge that made things legible.", + id: "quest_final_paradox", + sourceId: "final_paradox", sourceType: "quest", - sourceId: "final_paradox", - zoneId: "the_absolute", + title: "The Question That Answers Itself by Being Asked", + zoneId: "the_absolute", }, { - id: "quest_end_vault", - title: "What Is Kept at the End", content: "The Vault of Ends contained everything that had been preserved against the eventual conclusion of all things — not the greatest or the most significant, but the most irreplaceable: the things that would exist nowhere else once they were gone. Your adventurers found, among other things, the last memory of a person who had lived alone for fifty years and died without telling anyone what they knew. The vault knew. It had been keeping it in case someone came who was worthy of hearing it. Your adventurers listened for a long time.", + id: "quest_end_vault", + sourceId: "end_vault", sourceType: "quest", - sourceId: "end_vault", - zoneId: "the_absolute", + title: "What Is Kept at the End", + zoneId: "the_absolute", }, { - id: "quest_terminal_approach", - title: "The Final Mile", content: "The approach to the Absolute's deepest point took your adventurers through a region where every step forward corresponded to something left behind — not destroyed, but placed outside the scope of forward motion. By the time they reached the terminal point, each had shed something, without quite being aware of what it was. On the return journey, they recovered it. None of them found it unchanged.", + id: "quest_terminal_approach", + sourceId: "terminal_approach", sourceType: "quest", - sourceId: "terminal_approach", - zoneId: "the_absolute", + title: "The Final Mile", + zoneId: "the_absolute", }, { - id: "quest_absolute_dominion", - title: "To Have Stood Here", content: "The final record from your guild's expedition to the Absolute is brief. Your chronicler wrote only this: 'We went to the end of things and found that it was also a beginning. We cannot explain this. We have come back. We intend to try again.' The expedition log is sealed by order of your chief scholar, not to prevent access but because she felt the record deserved the weight of a closed door behind it. It can be opened. It simply should not be opened lightly.", + id: "quest_absolute_dominion", + sourceId: "absolute_dominion", sourceType: "quest", - sourceId: "absolute_dominion", - zoneId: "the_absolute", + title: "To Have Stood Here", + zoneId: "the_absolute", }, // ── Void Sanctum — Bosses ───────────────────────────────────────────────── { - id: "boss_void_herald", - title: "Announcer of the Unspoken", content: "The Void Herald communicated not in language but in the absence of language — in the spaces between words where meaning either resided or was lost. Your adventurers engaged it by responding to its silences rather than its sounds, which proved both more effective and more unsettling. Afterward, several reported having understood something during the battle that they could not subsequently articulate, and that the inability to articulate it felt significant rather than simply frustrating.", + id: "boss_void_herald", + sourceId: "void_herald", sourceType: "boss", - sourceId: "void_herald", - zoneId: "void_sanctum", + title: "Announcer of the Unspoken", + zoneId: "void_sanctum", }, { - id: "boss_eternal_shade", - title: "Shadow That Outlasted Its Source", content: "The Eternal Shade was the shadow of something that no longer existed — its source had been gone for millennia, but the shadow had persisted, cast by nothing, falling across surfaces in directions that made no geometric sense. Your physicists found it professionally catastrophic and personally fascinating. Defeating it required introducing a source of light intense enough to redefine what a shadow was in the local region. This worked. They prefer not to speculate on what it did to the thing that had originally cast the shadow.", + id: "boss_eternal_shade", + sourceId: "eternal_shade", sourceType: "boss", - sourceId: "eternal_shade", - zoneId: "void_sanctum", + title: "Shadow That Outlasted Its Source", + zoneId: "void_sanctum", }, { - id: "boss_the_unmaker", - title: "The Opposite of a Story", content: "The Unmaker did not destroy things — it unmade them, which is different. Destruction leaves debris; unmaking leaves nothing, not even the memory of absence. Your philosophers spent considerable time on the distinction and concluded that the Unmaker was, in a technical sense, more dangerous than any entity that merely destroyed, because destruction is reversible in principle, and unmaking is not. Your adventurers defeated it through a method your chief scholar refuses to document on the grounds that some techniques should not be repeatable.", + id: "boss_the_unmaker", + sourceId: "the_unmaker", sourceType: "boss", - sourceId: "the_unmaker", - zoneId: "void_sanctum", + title: "The Opposite of a Story", + zoneId: "void_sanctum", }, { - id: "boss_void_progenitor", - title: "The Original Emptiness", content: "The Void Progenitor was the source — the original nothing from which all subsequent voids derived. It was not aggressive, not territorial, not anything that required the language of intent. It was simply the first absence, still present, still encompassing, and still, in some fundamental sense, prior to everything that had filled it. Your adventurers defeated it without fully understanding what they had defeated, which your scholars consider the most honest outcome possible.", + id: "boss_void_progenitor", + sourceId: "void_progenitor", sourceType: "boss", - sourceId: "void_progenitor", - zoneId: "void_sanctum", + title: "The Original Emptiness", + zoneId: "void_sanctum", }, { - id: "boss_void_emperor", - title: "Sovereignty Over Nothing", content: "The Void Emperor ruled the Sanctum with a completeness that made it the most absolute ruler your guild had ever encountered, precisely because it ruled over nothing — and nothing is infinite in a way that something can never quite manage. Your adventurers found it neither cruel nor kind, neither interested in them nor indifferent to them. It was simply comprehensive. When it was defeated, the Sanctum did not change. This, your scholars noted, suggested the Emperor had not been the source of the void but merely its most senior occupant.", + id: "boss_void_emperor", + sourceId: "void_emperor", sourceType: "boss", - sourceId: "void_emperor", - zoneId: "void_sanctum", + title: "Sovereignty Over Nothing", + zoneId: "void_sanctum", }, // ── Void Sanctum — Quests ───────────────────────────────────────────────── { - id: "quest_void_threshold", - title: "The Step Into Absence", content: "Crossing the Void Threshold required your adventurers to step deliberately into a space that their senses insisted was not there. The technique, your lead navigator developed, was to stop trying to sense the space and to simply proceed as though it were there, trusting the record of others who had crossed it rather than the immediate testimony of their own perception. It worked. Afterward, three of the five adventurers found it difficult to trust their senses in ordinary situations for several weeks.", + id: "quest_void_threshold", + sourceId: "void_threshold", sourceType: "quest", - sourceId: "void_threshold", - zoneId: "void_sanctum", + title: "The Step Into Absence", + zoneId: "void_sanctum", }, { - id: "quest_eternal_dark", - title: "Dark That Does Not Wait for Light", content: "The Eternal Dark was not the absence of light but the predecessor of it — a dark that had existed before light had been invented and had therefore never defined itself in opposition to light. It was its own thing. Your naturalists found it structurally distinct from ordinary darkness in ways that required new terminology to describe. Your chronicler, who had to write about it, produced a seventeen-page account that contains the word 'dark' forty-three times and admits twice that the word was never adequate.", + id: "quest_eternal_dark", + sourceId: "eternal_dark", sourceType: "quest", - sourceId: "eternal_dark", - zoneId: "void_sanctum", + title: "Dark That Does Not Wait for Light", + zoneId: "void_sanctum", }, { - id: "quest_sanctum_depths", - title: "Deeper Into the Nothing", content: "The Sanctum's deeper levels were not deeper in any spatial sense — they were deeper in the sense of being more thoroughly void, each level representing a further refinement of absence until the absence itself became, at sufficient depth, a kind of presence. Your philosophers found this progression unsurprising and your adventurers found it very difficult to navigate, since the usual method of navigating by what is there becomes increasingly unreliable when what is there is increasingly nothing.", + id: "quest_sanctum_depths", + sourceId: "sanctum_depths", sourceType: "quest", - sourceId: "sanctum_depths", - zoneId: "void_sanctum", + title: "Deeper Into the Nothing", + zoneId: "void_sanctum", }, { - id: "quest_unmaking_grounds", - title: "Where Things Stop Being", content: "The Unmaking Grounds were where things came to cease — voluntarily or otherwise, by design or by depletion, with drama or in silence. Your adventurers moved carefully, aware that the Grounds did not distinguish between intended and unintended cessation. What they found there, among the evidence of everything that had ever stopped, was also evidence of what stopped being before it stopped: the last thoughts, the last movements, the last intentions. The Grounds were, your chronicler wrote, unexpectedly intimate.", + id: "quest_unmaking_grounds", + sourceId: "unmaking_grounds", sourceType: "quest", - sourceId: "unmaking_grounds", - zoneId: "void_sanctum", + title: "Where Things Stop Being", + zoneId: "void_sanctum", }, { - id: "quest_emperor_approach", - title: "The Road to Emptiness", content: "The approach to the Void Emperor's throne was the longest corridor your adventurers had ever traversed — not because of its physical length, which was measurable, but because of what it felt like to walk it. Each step felt like a considered decision. The corridor offered no obstacles, no defences, no difficulty. Only the sustained, deliberate choice to continue toward something that was definitively aware of your approach and that was, in every meaningful sense, the end of the road.", + id: "quest_emperor_approach", + sourceId: "emperor_approach", sourceType: "quest", - sourceId: "emperor_approach", - zoneId: "void_sanctum", + title: "The Road to Emptiness", + zoneId: "void_sanctum", }, { - id: "quest_heart_of_void", - title: "The Centre of Absence", content: "The Heart of the Void was the most complete absence your guild had ever encountered or would ever encounter. Your adventurers stood in it for one hour by the clock they had brought, and reported afterward that the hour had felt like both an instant and an eternity — which your philosophers identified as the characteristic signature of an experience that existed outside the ordinary framework of time. What they found there, they keep in common. They have not shared it. They say there are no words, which your chronicler accepts as the first fully satisfying explanation they have ever received.", + id: "quest_heart_of_void", + sourceId: "heart_of_void", sourceType: "quest", - sourceId: "heart_of_void", - zoneId: "void_sanctum", + title: "The Centre of Absence", + zoneId: "void_sanctum", }, // ── Guild Arsenal — Equipment ────────────────────────────────────────────── { - id: "equipment_rusty_sword", - title: "The Rusty Sword: First Blade of the Guild", content: "Every great guild begins somewhere, and yours began with this. The blade was salvaged from a drainage ditch behind the original guildhall and sharpened on a whetstone borrowed from the neighbouring cobbler. It is not a good sword. It is, however, the sword that was there when everything else was not, and your chronicler has decreed that counts for something.", + id: "equipment_rusty_sword", + sourceId: "rusty_sword", sourceType: "equipment", - sourceId: "rusty_sword", - zoneId: "guild_arsenal", + title: "The Rusty Sword: First Blade of the Guild", + zoneId: "guild_arsenal", }, { - id: "equipment_iron_sword", - title: "The Iron Sword: Guild Standard", content: "When the guild grew large enough to afford proper armaments, the first bulk order was iron swords — reliable, maintainable, and uncomplaining. The smith who made them charged fair prices and asked no questions about what the guild intended to fight. Your chronicler notes that is the second most important quality in a blacksmith, after not botching the tempering.", + id: "equipment_iron_sword", + sourceId: "iron_sword", sourceType: "equipment", - sourceId: "iron_sword", - zoneId: "guild_arsenal", + title: "The Iron Sword: Guild Standard", + zoneId: "guild_arsenal", }, { - id: "equipment_enchanted_blade", - title: "The Enchanted Blade: On Purchasing Magic", content: "The enchantment was applied by a mage who assured the guild it was standard imbuing work, nothing experimental. The blade has since struck true in ninety-three consecutive engagements, which the mage's apprentice calls a coincidence and the guild's fighters call a miracle they intend to keep. Your chronicler notes that the mage's invoice used the phrase 'satisfaction guaranteed' in a way that suggests it was legally enforceable.", + id: "equipment_enchanted_blade", + sourceId: "enchanted_blade", sourceType: "equipment", - sourceId: "enchanted_blade", - zoneId: "guild_arsenal", + title: "The Enchanted Blade: On Purchasing Magic", + zoneId: "guild_arsenal", }, { - id: "equipment_shadow_dagger", - title: "The Shadow Dagger: A Study in Darkness", content: "The Shadow Marshes produce very little that most people would call useful, and a great deal that specialists call invaluable. The blacksmiths who work condensed marsh-dark into weapons operate in conditions that destroy normal tools, use techniques they decline to document, and charge prices that suggest they are aware of their monopoly. The blade they produced for your guild cuts before it is aimed, which the fighters appreciate and the philosophers find troubling.", + id: "equipment_shadow_dagger", + sourceId: "shadow_dagger", sourceType: "equipment", - sourceId: "shadow_dagger", - zoneId: "guild_arsenal", + title: "The Shadow Dagger: A Study in Darkness", + zoneId: "guild_arsenal", }, { - id: "equipment_flame_lance", - title: "The Flame Lance: Forged in Eternity", content: "The shard at the tip of this weapon was recovered from the Primordial Forge at significant cost and considerable singeing. The artificers who mounted it into the lance did so using handling tools, protective eyewear, and a great deal of prayer to deities associated with fire safety. It has been burning continuously since the day it was made. The guild has begun scheduling its cleaning rotations around the fact that proximity to the tip is inadvisable.", + id: "equipment_flame_lance", + sourceId: "flame_lance", sourceType: "equipment", - sourceId: "flame_lance", - zoneId: "guild_arsenal", + title: "The Flame Lance: Forged in Eternity", + zoneId: "guild_arsenal", }, { - id: "equipment_vorpal_sword", - title: "The Vorpal Sword: On Legendary Blades", content: "The legends say the Vorpal Sword can sever any bond. The guild's fighters have tested this against armour (correct), enchantments (largely correct), and one memorable dispute over a property boundary (legally complicated). The blade appears to have opinions about what constitutes a severable bond, and these opinions are not always aligned with the wielder's. Your chronicler recommends against pointing it at anything sentimental.", + id: "equipment_vorpal_sword", + sourceId: "vorpal_sword", sourceType: "equipment", - sourceId: "vorpal_sword", - zoneId: "guild_arsenal", + title: "The Vorpal Sword: On Legendary Blades", + zoneId: "guild_arsenal", }, { - id: "equipment_soul_reaper", - title: "The Soul Reaper: A Difficult Acquisition", content: "The Soul Reaper does not harvest flesh. This was clarified upfront by the previous owner, who sold it under circumstances your guild's legal team described as 'technically voluntary.' The scythe drains the will to resist, which is efficient but creates philosophical complications regarding the nature of consent in combat. Your fighters use it anyway. Your philosophers have been asked to take their concerns to a different meeting.", + id: "equipment_soul_reaper", + sourceId: "soul_reaper", sourceType: "equipment", - sourceId: "soul_reaper", - zoneId: "guild_arsenal", + title: "The Soul Reaper: A Difficult Acquisition", + zoneId: "guild_arsenal", }, { - id: "equipment_celestial_blade", - title: "The Celestial Blade: Three Realities", content: "The Cosmic Horror that forged this weapon worked with materials from a dying star and a philosophy of craftsmanship that required the finished edge to exist simultaneously in three adjacent realities. The practical result is that the blade strikes in ways that defenders cannot anticipate because the blow comes from directions that are not in this universe. Your fighters have learned to look slightly to the left of where they intend to swing, which takes adjustment.", + id: "equipment_celestial_blade", + sourceId: "celestial_blade", sourceType: "equipment", - sourceId: "celestial_blade", - zoneId: "guild_arsenal", + title: "The Celestial Blade: Three Realities", + zoneId: "guild_arsenal", }, { - id: "equipment_void_edge", - title: "The Void Edge: A Weapon of Absence", content: "The Void Edge is made of nothing, which is not the same as being made of nothing in particular. The compressed nothingness that constitutes the blade is extremely specific nothing, carefully shaped and maintained at tremendous difficulty. It does not cut so much as remove — the thing the blade passes through simply is no longer there. The guild's armourer has requested that fighters please not absent-mindedly wave it near the weapon rack.", + id: "equipment_void_edge", + sourceId: "void_edge", sourceType: "equipment", - sourceId: "void_edge", - zoneId: "guild_arsenal", + title: "The Void Edge: A Weapon of Absence", + zoneId: "guild_arsenal", }, { - id: "equipment_leather_armour", - title: "The Leather Armour: Practical Protection", content: "The first armour the guild purchased was leather, because leather is cheap, repairable, and does not require a specialist to maintain. The tanner who made it used the hide of animals whose names your chronicler has not verified, and the stitching is by someone who described themselves as 'good enough.' It has protected the guild's fighters from cuts, scrapes, and minor bludgeoning for longer than anyone expected when they bought it.", + id: "equipment_leather_armour", + sourceId: "leather_armour", sourceType: "equipment", - sourceId: "leather_armour", - zoneId: "guild_arsenal", + title: "The Leather Armour: Practical Protection", + zoneId: "guild_arsenal", }, { - id: "equipment_chainmail", - title: "Chainmail: The Mathematics of Defence", content: "Every ring in a chainmail shirt is a small decision made by a craftsperson about the relationship between metal, movement, and the probability of being stabbed. Your armourer, who has an appreciation for this sort of thinking, notes that a standard shirt contains approximately twenty-four thousand rings, which is twenty-four thousand individual points of failure and twenty-four thousand individual points of success, and the art lies in making those numbers mean the same thing.", + id: "equipment_chainmail", + sourceId: "chainmail", sourceType: "equipment", - sourceId: "chainmail", - zoneId: "guild_arsenal", + title: "Chainmail: The Mathematics of Defence", + zoneId: "guild_arsenal", }, { - id: "equipment_hide_armour", - title: "Giant's Hide Armour: A Matter of Provenance", content: "The Forest Giant whose hide became this armour was, by all accounts, enormous. The curing process required a facility that your guild did not have, a smell that your guild's neighbours have not forgiven, and a period of several months during which the finished product was described as 'technically armour.' It radiates primal authority in the way that things that were recently alive and very large tend to do.", + id: "equipment_hide_armour", + sourceId: "hide_armour", sourceType: "equipment", - sourceId: "hide_armour", - zoneId: "guild_arsenal", + title: "Giant's Hide Armour: A Matter of Provenance", + zoneId: "guild_arsenal", }, { - id: "equipment_plate_armour", - title: "Plate Armour: The Full Investment", content: "Plate armour is expensive, maintenance-intensive, requires a squire or equivalent to don correctly, and limits certain types of mobility in ways that fighters learn to work around or simply to ignore. It is also, by a significant margin, the most reassuring thing a fighter can wear into a serious engagement. The guild's accountant objected to the cost. The guild's fighters objected to the accountant's objection, and the accountant eventually let the matter drop.", + id: "equipment_plate_armour", + sourceId: "plate_armour", sourceType: "equipment", - sourceId: "plate_armour", - zoneId: "guild_arsenal", + title: "Plate Armour: The Full Investment", + zoneId: "guild_arsenal", }, { - id: "equipment_void_shroud", - title: "The Void Shroud: Commerce in Concealment", content: "The fabric of the Shadow Marshes possesses the property of making the wearer difficult to locate, quantify, or remember. Merchants who have worn it report that customers are unable to recall having seen them, which is commercially excellent until the customers cannot find their way back to make a second purchase. The guild has adapted it for broader use, with satisfying results in the wealth-accumulation department.", + id: "equipment_void_shroud", + sourceId: "void_shroud", sourceType: "equipment", - sourceId: "void_shroud", - zoneId: "guild_arsenal", + title: "The Void Shroud: Commerce in Concealment", + zoneId: "guild_arsenal", }, { - id: "equipment_volcanic_plate", - title: "Volcanic Plate: Neither Metal Nor Stone", content: "The armourers who work the volcanic forges of the Volcanic Depths have developed materials that do not fit the standard metallurgical categories. What they make begins as metal, is quenched in active lava at temperatures that should destroy it, and emerges as something that has opinions about heat. The resulting armour burns with an inner warmth that its wearers report as 'uncomfortable for the first week' and 'actually quite pleasant thereafter.'", + id: "equipment_volcanic_plate", + sourceId: "volcanic_plate", sourceType: "equipment", - sourceId: "volcanic_plate", - zoneId: "guild_arsenal", + title: "Volcanic Plate: Neither Metal Nor Stone", + zoneId: "guild_arsenal", }, { - id: "equipment_dragon_scale", - title: "Dragon Scale Armour: On Defeating Dragons", content: "The elder dragon whose scales became this armour was, in its time, considered uncollectable. The guild collected it anyway. The scales required specialist processing — they do not behave like ordinary material and the armourer who attempted standard techniques reports a series of events she declines to describe in writing. The finished armour has retained a certain presence that other fighters describe as 'like wearing a warning.'", + id: "equipment_dragon_scale", + sourceId: "dragon_scale", sourceType: "equipment", - sourceId: "dragon_scale", - zoneId: "guild_arsenal", + title: "Dragon Scale Armour: On Defeating Dragons", + zoneId: "guild_arsenal", }, { - id: "equipment_titan_aegis", - title: "Titan's Aegis: A Celestial Investment", content: "The celestials who blessed this shield-armour hybrid did so in a ceremony that took three days and required the shield to be present for all of it, which the shield apparently found acceptable. The blessing is permanent, comprehensive, and includes a sub-clause regarding the bearer's continued commitment to not misusing the aegis's protections for unworthy purposes. The guild's lawyers reviewed this clause and declared it 'unenforceable but worth noting.'", + id: "equipment_titan_aegis", + sourceId: "titan_aegis", sourceType: "equipment", - sourceId: "titan_aegis", - zoneId: "guild_arsenal", + title: "Titan's Aegis: A Celestial Investment", + zoneId: "guild_arsenal", }, { - id: "equipment_astral_robe", - title: "The Astral Robe: Commerce of the Cosmos", content: "The Astral Wraith from whom the thread was harvested did not consent to this, but then the Astral Wraith did not consent to very much, having spent its existence as a creature of pure acquisitive malice. The thread it produced was starlight compressed into fibre, and the weavers who worked it report that the finished robe genuinely hums when the guild is making money — a feature that was not in the specification but which no one has objected to.", + id: "equipment_astral_robe", + sourceId: "astral_robe", sourceType: "equipment", - sourceId: "astral_robe", - zoneId: "guild_arsenal", + title: "The Astral Robe: Commerce of the Cosmos", + zoneId: "guild_arsenal", }, { - id: "equipment_lucky_coin", - title: "The Lucky Coin: A Statistical Anomaly", content: "The coin has been flipped eleven thousand times in controlled conditions by researchers who were convinced there was a mundane explanation. It has not yet produced the distribution their models predicted. The coin's previous owner described it as 'lucky' and the researchers have run out of alternative explanations, though several of them continue to use the phrase 'apparent luck' when writing up their findings, because the alternative requires accepting that luck is a real and measurable force.", + id: "equipment_lucky_coin", + sourceId: "lucky_coin", sourceType: "equipment", - sourceId: "lucky_coin", - zoneId: "guild_arsenal", + title: "The Lucky Coin: A Statistical Anomaly", + zoneId: "guild_arsenal", }, { - id: "equipment_mages_focus", - title: "Mage's Focus: Precision in Crystal", content: "The lens was ground by a mage who spent forty years perfecting the technique of embedding magical intent into transparent crystal. She will not sell these to anyone she judges insufficiently focused, which is most people. The guild's buyer made the purchase by demonstrating focus in a way she found acceptable, though they have since refused to describe what that demonstration involved. The lens sharpens whatever it is pointed at, which turns out to be useful in several disciplines beyond magic.", + id: "equipment_mages_focus", + sourceId: "mages_focus", sourceType: "equipment", - sourceId: "mages_focus", - zoneId: "guild_arsenal", + title: "Mage's Focus: Precision in Crystal", + zoneId: "guild_arsenal", }, { - id: "equipment_frost_rune", - title: "The Frost Rune: Ice and Intention", content: "The Bone Colossus carved this rune from its own ice-dense skeleton over the course of what historians have estimated was several centuries of patient, purposeful work. It did not intend to make something that amplified strikes — it intended to make something that expressed the relationship between cold and precision, which turned out to be functionally identical. The carving is extraordinarily fine for something produced by appendages the size of small trees.", + id: "equipment_frost_rune", + sourceId: "frost_rune", sourceType: "equipment", - sourceId: "frost_rune", - zoneId: "guild_arsenal", + title: "The Frost Rune: Ice and Intention", + zoneId: "guild_arsenal", }, { - id: "equipment_arcane_orb", - title: "The Arcane Orb: On Concentrated Energy", content: "The theoretical maximum density of arcane energy was a thought experiment until someone made this orb. The mages who study it professionally describe it as 'not dangerous if handled correctly' in a tone that suggests considerable debate about what 'correctly' means. The orb hums at a frequency that the guild's more sensitive members report as 'pleasant, actually' and which the less sensitive ones report as 'inaudible, stop asking.'", + id: "equipment_arcane_orb", + sourceId: "arcane_orb", sourceType: "equipment", - sourceId: "arcane_orb", - zoneId: "guild_arsenal", + title: "The Arcane Orb: On Concentrated Energy", + zoneId: "guild_arsenal", }, { - id: "equipment_runestone_amulet", - title: "The Runestone Amulet: Forgotten Inscriptions", content: "The plague ruins are called plague ruins and not treasure ruins or discovery ruins, which tells you something about the attitude of those who named them. Hidden among the devastation, these runestones survived whatever ended everything else, still inscribed with a power that scholars cannot fully decode. The amulet hums on a frequency just below conscious hearing, which your fighters describe as either reassuring or unsettling depending on the day.", + id: "equipment_runestone_amulet", + sourceId: "runestone_amulet", sourceType: "equipment", - sourceId: "runestone_amulet", - zoneId: "guild_arsenal", + title: "The Runestone Amulet: Forgotten Inscriptions", + zoneId: "guild_arsenal", }, { - id: "equipment_crystal_shard", - title: "The Crystal Shard: Essence Made Solid", content: "The Mud Kraken's crystallised essence should not exist by any conventional theory of biology. The creature was not known to produce crystalline materials; the crystal was not there before it died; and the crystal is absolutely, measurably there now, focusing power with a precision that no natural process explains. The guild's philosophers have added this to their list of things to think about later, a list which has become extremely long.", + id: "equipment_crystal_shard", + sourceId: "crystal_shard", sourceType: "equipment", - sourceId: "crystal_shard", - zoneId: "guild_arsenal", + title: "The Crystal Shard: Essence Made Solid", + zoneId: "guild_arsenal", }, { - id: "equipment_void_compass", - title: "The Void Compass: Finding Power", content: "Standard compasses point north. This compass points toward the greatest concentration of power in the local area, which is a different direction entirely and changes as power concentrations shift. It was made by someone who considered north to be a parochial concept and power to be a universal one. The guild finds it useful for navigation in places where north is less relevant than 'toward the thing that wants to kill us.'", + id: "equipment_void_compass", + sourceId: "void_compass", sourceType: "equipment", - sourceId: "void_compass", - zoneId: "guild_arsenal", + title: "The Void Compass: Finding Power", + zoneId: "guild_arsenal", }, { - id: "equipment_frost_crystal", - title: "The Frost Crystal: Cold Enough to Burn", content: "The Ice Queen's throne room operates at temperatures below what most materials can maintain in a solid state. The crystal that formed there has become something that is cold in an active rather than passive sense — it does not merely lack heat, it aggressively removes it. The guild's fighters describe the experience of wielding it as 'wearing very good insulated gloves,' which is a lie they tell people who ask and do not need to know the truth.", + id: "equipment_frost_crystal", + sourceId: "frost_crystal", sourceType: "equipment", - sourceId: "frost_crystal", - zoneId: "guild_arsenal", + title: "The Frost Crystal: Cold Enough to Burn", + zoneId: "guild_arsenal", }, { - id: "equipment_philosophers_stone", - title: "The Philosopher's Stone: Legend and Reality", content: "Alchemists spent centuries searching for the Philosopher's Stone, theorising that it could transmute lead to gold and grant immortality. The stone the guild acquired does neither of these things. It does grant mastery over the conversion of effort into gold and the application of force into combat outcomes, which is arguably more useful and considerably less complicated from a regulatory perspective. Several alchemists have asked to study it. The guild has declined.", + id: "equipment_philosophers_stone", + sourceId: "philosophers_stone", sourceType: "equipment", - sourceId: "philosophers_stone", - zoneId: "guild_arsenal", + title: "The Philosopher's Stone: Legend and Reality", + zoneId: "guild_arsenal", }, { - id: "equipment_eternal_flame", - title: "The Eternal Flame: What the Phoenix Kept", content: "The Phoenix Lord did not give this flame willingly, and 'sealed in crystal' is the guild's diplomatic phrasing for a containment process that the Phoenix Lord found deeply objectionable. The flame has been burning since before the Volcanic Depths existed as a distinct geographic feature, and the Phoenix Lord was sustaining it through methods it has declined to explain. It burns with rebirth energy, which the guild's fighters find invigorating and the guild's fire safety officer finds nerve-wracking.", + id: "equipment_eternal_flame", + sourceId: "eternal_flame", sourceType: "equipment", - sourceId: "eternal_flame", - zoneId: "guild_arsenal", + title: "The Eternal Flame: What the Phoenix Kept", + zoneId: "guild_arsenal", }, { - id: "equipment_infinity_gem", - title: "The Infinity Gem: A Universe Within", content: "The gem contains a universe. This is not metaphorical. The universe is small by cosmological standards — approximately the size of a moderately large apple — and its internal physics appear to operate on a slightly different set of constants than the external universe. The beings living within it, if there are any, have not yet been contacted. The guild's cosmologists would like very much to try. The guild's fighters have asked them to please wait until after the campaign.", + id: "equipment_infinity_gem", + sourceId: "infinity_gem", sourceType: "equipment", - sourceId: "infinity_gem", - zoneId: "guild_arsenal", + title: "The Infinity Gem: A Universe Within", + zoneId: "guild_arsenal", }, { - id: "equipment_seraph_wing", - title: "Seraph's Wing: The Cost of Divinity", content: "The seraph from whose primary feather this blade was forged fell in the opening engagement of your guild's advance into the Celestial Reaches. It fell deliberately, as an act of something that the celestials who witnessed it described as sacrifice and that your fighters described as 'extremely helpful.' The feather was impossibly sharp before the armourer worked it — the armourer's contribution was shaping what was already there into something wieldy.", + id: "equipment_seraph_wing", + sourceId: "seraph_wing", sourceType: "equipment", - sourceId: "seraph_wing", - zoneId: "guild_arsenal", + title: "Seraph's Wing: The Cost of Divinity", + zoneId: "guild_arsenal", }, { - id: "equipment_angels_halo", - title: "The Angel's Halo: Grief and Power", content: "The Fallen Archangel wore this halo for longer than the Celestial Reaches have existed as a navigable location. It radiates with equal measures of grief and power because the Archangel considered these to be aspects of the same thing — that grief was what power felt when it remembered what it had cost. Your fighters who wear it report a weight that is not physical. They also report substantially improved results in every measurable category.", + id: "equipment_angels_halo", + sourceId: "angels_halo", sourceType: "equipment", - sourceId: "angels_halo", - zoneId: "guild_arsenal", + title: "The Angel's Halo: Grief and Power", + zoneId: "guild_arsenal", }, { - id: "equipment_celestial_armour", - title: "Celestial Armour: Light Made Solid", content: "The smithies of the celestial realm operate under conditions that are difficult to describe using the vocabulary of ordinary metallurgy. The material they work with begins as light — specific, concentrated light that has been held under sufficient pressure for sufficient time to become solid — and the finished product retains the light's fundamental properties: warmth, visibility, and a tendency to make nearby gold income flow more freely, for reasons that celestial economists find obvious and mortal economists find baffling.", + id: "equipment_celestial_armour", + sourceId: "celestial_armour", sourceType: "equipment", - sourceId: "celestial_armour", - zoneId: "guild_arsenal", + title: "Celestial Armour: Light Made Solid", + zoneId: "guild_arsenal", }, { - id: "equipment_divine_edge", - title: "The Divine Edge: A Declaration", content: "The First Light made this blade for itself and used it to declare things rather than to cut them — a distinction the First Light considered important and that its opponents found academic. What the blade declares varies by wielder: it has declared victory, inevitability, and on one notable occasion a complicated position on the nature of justice that took three days to fully express. Against the targets your guild has aimed it at, it has consistently declared the engagement over.", + id: "equipment_divine_edge", + sourceId: "divine_edge", sourceType: "equipment", - sourceId: "divine_edge", - zoneId: "guild_arsenal", + title: "The Divine Edge: A Declaration", + zoneId: "guild_arsenal", }, { - id: "equipment_heaven_mantle", - title: "Heaven's Mantle: The Outermost Layer", content: "The celestial realm is not a place in the ordinary sense, and its outermost garment is not fabric in the ordinary sense. It was woven from starlight and divine intention by beings whose craft predates the concept of craft, and it has served as the boundary between what the celestials are and what they are not for longer than the boundary has needed to exist. On a mortal wearer it is simply the most effective garment for making gold income flow that has ever been recorded.", + id: "equipment_heaven_mantle", + sourceId: "heaven_mantle", sourceType: "equipment", - sourceId: "heaven_mantle", - zoneId: "guild_arsenal", + title: "Heaven's Mantle: The Outermost Layer", + zoneId: "guild_arsenal", }, { - id: "equipment_depth_blade", - title: "The Depth Blade: Venom Crystallised", content: "The Depth Leviathan's venom dissolves most materials on contact. The process by which it was crystallised into a functional blade required conditions that do not naturally occur at pressures compatible with human survival, and the artificers who figured out how to create those conditions artificially are now very expensive consultants who will not discuss the project. The blade strikes through armour as if armour is a conceptual position the target has decided to abandon.", + id: "equipment_depth_blade", + sourceId: "depth_blade", sourceType: "equipment", - sourceId: "depth_blade", - zoneId: "guild_arsenal", + title: "The Depth Blade: Venom Crystallised", + zoneId: "guild_arsenal", }, { - id: "equipment_leviathan_eye", - title: "The Leviathan's Eye: Deep Sight", content: "The Elder Kraken spent ten thousand years in the deepest trench of the Abyssal depths developing the capacity to see through deception in all its forms — not as a moral stance, but as a survival mechanism in an environment where everything is trying to be something it isn't. The eye, preserved in the brine it was formed in, retains this capacity. Your fighters who carry it report that they can no longer be effectively lied to, which they describe as both useful and occasionally overwhelming.", + id: "equipment_leviathan_eye", + sourceId: "leviathan_eye", sourceType: "equipment", - sourceId: "leviathan_eye", - zoneId: "guild_arsenal", + title: "The Leviathan's Eye: Deep Sight", + zoneId: "guild_arsenal", }, { - id: "equipment_pressure_plate", - title: "Pressure Plate: The Deepest Forge", content: "This armour was forged at pressures that would reduce a city to a flat disc. The technique was developed by artisans in the Abyssal Trench who had access to pressures that the surface world can only approximate, and who discovered that materials worked at such extremes acquire properties that surface metallurgy cannot replicate. What comes out of the deepest forge cannot be broken by anything that operates at surface pressures, which covers most threats.", + id: "equipment_pressure_plate", + sourceId: "pressure_plate", sourceType: "equipment", - sourceId: "pressure_plate", - zoneId: "guild_arsenal", + title: "Pressure Plate: The Deepest Forge", + zoneId: "guild_arsenal", }, { - id: "equipment_abyssal_edge", - title: "The Abyssal Edge: Remade", content: "The Elder Abomination's appendage was not a weapon when it was attached to the Elder Abomination. It was something else entirely — a sense organ, a communication device, or possibly both, depending on which scholar you ask. Your artificers remade it into something that passes for a blade, which required working with material that does not behave like any known substance and that the artificers have described as 'cooperative, once you explain what you want.' The Elder Abomination had no comment.", + id: "equipment_abyssal_edge", + sourceId: "abyssal_edge", sourceType: "equipment", - sourceId: "abyssal_edge", - zoneId: "guild_arsenal", + title: "The Abyssal Edge: Remade", + zoneId: "guild_arsenal", }, { - id: "equipment_abyss_shroud", - title: "The Abyss Shroud: Bottom of Everything", content: "There is a place at the bottom of the Abyssal Trench that is darker than the absence of light — darker, philosophers argue, than the space where light has never been, because it is the space from which light has been actively expelled. The darkness from that place, woven into fabric by methods that require both extreme technical skill and a certain comfort with the existential, produces something that makes wealth flow to those who wear it. The reasons for this are unclear but well-documented.", + id: "equipment_abyss_shroud", + sourceId: "abyss_shroud", sourceType: "equipment", - sourceId: "abyss_shroud", - zoneId: "guild_arsenal", + title: "The Abyss Shroud: Bottom of Everything", + zoneId: "guild_arsenal", }, { - id: "equipment_demon_hide", - title: "Demon Hide Armour: Ten Thousand Campaigns", content: "The Demon Prince had held the Infernal Court for longer than the concept of 'holding' something had existed, and in that time had fought ten thousand campaigns and lost none of them. The hide that became this armour was infused with the strategic memory of those victories, and the armour whispers campaign plans to its wearer — not always relevant ones, because the Demon Prince's experience skews toward managing infernal logistics, but occasionally exactly what was needed.", + id: "equipment_demon_hide", + sourceId: "demon_hide", sourceType: "equipment", - sourceId: "demon_hide", - zoneId: "guild_arsenal", + title: "Demon Hide Armour: Ten Thousand Campaigns", + zoneId: "guild_arsenal", }, { - id: "equipment_hellfire_edge", - title: "The Hellfire Edge: The Titan's Core", content: "The Hellfire Titan was composed almost entirely of fire — not ordinary fire, which is a chemical reaction, but the kind of fire that is a statement about the nature of destruction. A fragment of its core was recovered, cooled to a temperature where it could be handled with specialised equipment, and mounted in a weapon that has been burning continuously since. The heat it produces ignores armour, which your fighters describe as efficient and your armourer describes as a maintenance challenge.", + id: "equipment_hellfire_edge", + sourceId: "hellfire_edge", sourceType: "equipment", - sourceId: "hellfire_edge", - zoneId: "guild_arsenal", + title: "The Hellfire Edge: The Titan's Core", + zoneId: "guild_arsenal", }, { - id: "equipment_soul_gem", - title: "The Soul Gem: The First Tears", content: "The Lord of Sin had never wept before the moment your guild defeated it. The gem crystallised from those tears is therefore the rarest substance in the Infernal Court: the product of a first time. Demonologists who have examined it report that it contains the full emotional weight of ten thousand years of calculated detachment, compressed into a single moment of genuine feeling. It improves your fighters' output in ways the demonologists find philosophically uncomfortable.", + id: "equipment_soul_gem", + sourceId: "soul_gem", sourceType: "equipment", - sourceId: "soul_gem", - zoneId: "guild_arsenal", + title: "The Soul Gem: The First Tears", + zoneId: "guild_arsenal", }, { - id: "equipment_infernal_edge", - title: "The Infernal Edge: What The Fallen Was", content: "The Fallen was once something good. The blade made from what it had been retains that memory — the hardness of good intentions that have been through enough to become absolute, the edge of a purpose that has been refined by everything trying to stop it. Your fighters who have wielded it report that it is the most serious weapon they have ever carried, which is not about weight but about the quality of attention it demands.", + id: "equipment_infernal_edge", + sourceId: "infernal_edge", sourceType: "equipment", - sourceId: "infernal_edge", - zoneId: "guild_arsenal", + title: "The Infernal Edge: What The Fallen Was", + zoneId: "guild_arsenal", }, { - id: "equipment_sinslayer_aegis", - title: "The Sinslayer Aegis: Assembled Regret", content: "The Fallen accumulated regrets over the course of its existence that it refused to process, storing them instead in the material of itself. The armour assembled from those regrets therefore knows, in some sense that armour is not supposed to know anything, what righteousness feels like. Fighters who wear it report that it makes them more deliberate in their choices, which is either a feature or a side effect depending on the urgency of the situation.", + id: "equipment_sinslayer_aegis", + sourceId: "sinslayer_aegis", sourceType: "equipment", - sourceId: "sinslayer_aegis", - zoneId: "guild_arsenal", + title: "The Sinslayer Aegis: Assembled Regret", + zoneId: "guild_arsenal", }, { - id: "equipment_prism_blade", - title: "The Prism Blade: Simultaneous Angles", content: "The Crystalline Spire produces refractive effects that are well-understood in optics and deeply puzzling in metallurgy. A blade made from Spire crystal refracts into multiple simultaneous strikes — not copies, but distributed aspects of the same blow arriving from geometrically irreconcilable angles. Defenders have tested every known blocking technique. None of them cover all the angles. The fighters who use it have been asked not to describe the physics because it distresses people.", + id: "equipment_prism_blade", + sourceId: "prism_blade", sourceType: "equipment", - sourceId: "prism_blade", - zoneId: "guild_arsenal", + title: "The Prism Blade: Simultaneous Angles", + zoneId: "guild_arsenal", }, { - id: "equipment_faceted_armour", - title: "The Faceted Armour: Adjacent Realities", content: "The facets of this armour intersect with adjacent realities — not in a way the wearer experiences, but in a way that makes them very difficult to hit, because some of the strikes land on versions of them that chose differently and are consequently somewhere else. The temporal mechanics are poorly understood by everyone involved, including the armour. Your fighters have stopped asking questions and simply appreciate not being hit as often.", + id: "equipment_faceted_armour", + sourceId: "faceted_armour", sourceType: "equipment", - sourceId: "faceted_armour", - zoneId: "guild_arsenal", + title: "The Faceted Armour: Adjacent Realities", + zoneId: "guild_arsenal", }, { - id: "equipment_prism_eye", - title: "The Prism Eye: All Moments", content: "The Diamond Colossus processed information through this lens in a way that allowed it to perceive all moments simultaneously — past, present, and the immediate futures branching from each decision. It found this useful for tactical planning and exhausting for everything else. The guild uses the lens for simultaneous perception of income opportunities, which the Diamond Colossus would have found a pedestrian application but which produces genuinely remarkable results.", + id: "equipment_prism_eye", + sourceId: "prism_eye", sourceType: "equipment", - sourceId: "prism_eye", - zoneId: "guild_arsenal", + title: "The Prism Eye: All Moments", + zoneId: "guild_arsenal", }, { - id: "equipment_crystal_sovereign_blade", - title: "The Sovereign's Blade: The Last Calculation", content: "The Crystal Sovereign's final act before your guild defeated it was to calculate whether defeat was inevitable. It was. The calculation took seven minutes, covered seventeen dimensions of probability, and produced an answer the Sovereign then handed over along with its blade, because the calculation had also revealed that resistance from that point forward would be inefficient. The blade itself was the Sovereign's primary analytical instrument, which means it now serves as a weapon of exceptional precision.", + id: "equipment_crystal_sovereign_blade", + sourceId: "crystal_sovereign_blade", sourceType: "equipment", - sourceId: "crystal_sovereign_blade", - zoneId: "guild_arsenal", + title: "The Sovereign's Blade: The Last Calculation", + zoneId: "guild_arsenal", }, { - id: "equipment_diamond_plate", - title: "Diamond Plate: Optimal Configuration", content: "The Crystalline Spire's analytical capabilities extended to defensive architecture: Diamond Plate is the product of calculating the optimal configuration of crystallised possibilities across all possible timelines and selecting the arrangement that minimised damage across all of them. The result is armour that the wearers describe as 'extremely protective' and the engineers who study it describe as 'optimal in ways we don't fully understand but can verify empirically.'", + id: "equipment_diamond_plate", + sourceId: "diamond_plate", sourceType: "equipment", - sourceId: "diamond_plate", - zoneId: "guild_arsenal", + title: "Diamond Plate: Optimal Configuration", + zoneId: "guild_arsenal", }, { - id: "equipment_void_annihilator", - title: "The Void Annihilator: Simple Removal", content: "The Void Emperor's primary weapon does not strike. Striking implies contact, and contact implies that the thing struck continues to exist in a form that could experience the contact. This weapon removes. The target was there; the weapon passes; the target is not there. The mechanics of this are documented in papers that exist in the Void Sanctum's library and nowhere else, because the scholars who wrote them declined to share something they found, in their words, 'too tidy to release irresponsibly.'", + id: "equipment_void_annihilator", + sourceId: "void_annihilator", sourceType: "equipment", - sourceId: "void_annihilator", - zoneId: "guild_arsenal", + title: "The Void Annihilator: Simple Removal", + zoneId: "guild_arsenal", }, { - id: "equipment_eternal_shroud", - title: "The Eternal Shroud: Simultaneous Existence", content: "The Eternal Shade from which this shroud was woven exists in every moment simultaneously — not sequentially across time, but genuinely present in all points of time at once. Armour woven from it inherits this property to a limited degree: it is impossible to find in any given moment because it is distributed across all of them. Your fighters who wear it describe the experience as 'fine once you stop thinking about it,' which is advice your chronicler considers wisely practical.", + id: "equipment_eternal_shroud", + sourceId: "eternal_shroud", sourceType: "equipment", - sourceId: "eternal_shroud", - zoneId: "guild_arsenal", + title: "The Eternal Shroud: Simultaneous Existence", + zoneId: "guild_arsenal", }, { - id: "equipment_void_heart_gem", - title: "The Void Heart Gem: The Original Absence", content: "The Void Progenitor's core was the original absence — the first nothing, from which all subsequent nothings derived. It crystallised at the moment of the Progenitor's defeat into something that paradoxically exists, which the Void Progenitor would have considered an insult if it were capable of considering anything at this point. The gem makes the impossible routine for those who carry it, which is either a very good thing or a philosophically troubling precedent.", + id: "equipment_void_heart_gem", + sourceId: "void_heart_gem", sourceType: "equipment", - sourceId: "void_heart_gem", - zoneId: "guild_arsenal", + title: "The Void Heart Gem: The Original Absence", + zoneId: "guild_arsenal", }, { - id: "equipment_sanctum_breaker", - title: "The Sanctum Breaker: Authority Seized", content: "The Void Emperor's sceptre of authority commanded nothingness for longer than nothingness had been a distinct category. In the moment of its defeat your guild seized the sceptre, which was a practical decision with significant symbolic implications that scholars of the Void Sanctum are still working through. The sceptre commands even nothingness, which means it is effective against a category of opponent that most weapons cannot address at all.", + id: "equipment_sanctum_breaker", + sourceId: "sanctum_breaker", sourceType: "equipment", - sourceId: "sanctum_breaker", - zoneId: "guild_arsenal", + title: "The Sanctum Breaker: Authority Seized", + zoneId: "guild_arsenal", }, { - id: "equipment_void_emperor_plate", - title: "Void Emperor's Plate: All of Existence", content: "The Void Emperor wore this armour for all of existence — not for a long time, but for the entirety of time's duration, which is a different kind of persistence. The armour remembers everything it has seen, which is everything. It knows your guild wore it next. This is either a mark of approval or a logical inevitability, and the Void Emperor's scholars are divided on which it is, though they agree it amounts to the same thing.", + id: "equipment_void_emperor_plate", + sourceId: "void_emperor_plate", sourceType: "equipment", - sourceId: "void_emperor_plate", - zoneId: "guild_arsenal", + title: "Void Emperor's Plate: All of Existence", + zoneId: "guild_arsenal", }, { - id: "equipment_eternal_armour", - title: "Eternal Armour: Never Breached", content: "The Throne Warden's armour has not been breached across all of time. This is not a record in the conventional sense because the armour has been subjected to every attack that has ever been or will be attempted in the entire span of existence, and none of them have succeeded. Your guild's fighters wear it with an awareness that they are wearing something that has already survived everything that will ever be aimed at it, which is either reassuring or paradoxical. Probably both.", + id: "equipment_eternal_armour", + sourceId: "eternal_armour", sourceType: "equipment", - sourceId: "eternal_armour", - zoneId: "guild_arsenal", + title: "Eternal Armour: Never Breached", + zoneId: "guild_arsenal", }, { - id: "equipment_throne_blade", - title: "The Throne Blade: Service Since Service Existed", content: "The Eternal Knight's sword has served the throne since before the concept of service was invented. It predates loyalty as a named virtue, predates the throne as a political institution, and predates the idea that a blade should be held by someone rather than simply existing. That it now serves your guild is not a betrayal of its nature — service is what it is, and your guild is what it serves.", + id: "equipment_throne_blade", + sourceId: "throne_blade", sourceType: "equipment", - sourceId: "throne_blade", - zoneId: "guild_arsenal", + title: "The Throne Blade: Service Since Service Existed", + zoneId: "guild_arsenal", }, { - id: "equipment_apex_sword", - title: "The Apex Sword: Beyond Category", content: "The Apex's instrument is not a weapon in any sense your guild's armoury can categorise. It does not have a blade in the conventional sense, a handle in the conventional sense, or a relationship to combat that your fighters can fully articulate. It functions as a weapon because your guild has decided it does, and the Apex found this approach to categorisation perfectly reasonable — the Apex itself was not a thing in any conventional sense either.", + id: "equipment_apex_sword", + sourceId: "apex_sword", sourceType: "equipment", - sourceId: "apex_sword", - zoneId: "guild_arsenal", + title: "The Apex Sword: Beyond Category", + zoneId: "guild_arsenal", }, { - id: "equipment_apex_plate", - title: "The Apex Plate: The Throne Itself", content: "The Eternal Throne was not supposed to be worn. It was the absolute seat of all authority in the universe — a conceptual object with physical presence, the point around which everything organised itself. When your guild claimed it and your armourer asked if it could be worn, the answer turned out to be yes, in the same way that the answer to 'can the guild that defeated the Apex do this?' is always yes, by definition.", + id: "equipment_apex_plate", + sourceId: "apex_plate", sourceType: "equipment", - sourceId: "apex_plate", - zoneId: "guild_arsenal", + title: "The Apex Plate: The Throne Itself", + zoneId: "guild_arsenal", }, { - id: "equipment_eternity_stone", - title: "The Eternity Stone: The Source of Forever", content: "The Eternity Stone is what makes the Eternal Throne eternal. Without it the throne is a very large piece of furniture with historical significance; with it, the throne exists at every point in time simultaneously and cannot be destroyed by anything that operates within time. The guild now carries that permanence in a trinket that fits in a pocket, which says something about the difference between what things are and what they do.", + id: "equipment_eternity_stone", + sourceId: "eternity_stone", sourceType: "equipment", - sourceId: "eternity_stone", - zoneId: "guild_arsenal", + title: "The Eternity Stone: The Source of Forever", + zoneId: "guild_arsenal", }, { - id: "equipment_celestial_focus", - title: "Celestial Focus: Compressed Light", content: "The process of compressing celestial light into a lens requires a celestial forge and approximately four hundred years of patient reduction. The lens that results focuses not just light but intention, precision, and the particular quality of attention that celestial beings bring to every task. Fighters who look through it report that they can see exactly where they need to strike, which sounds straightforward until you understand that this applies in the dark, at distance, and in situations where there is nothing to see.", + id: "equipment_celestial_focus", + sourceId: "celestial_focus", sourceType: "equipment", - sourceId: "celestial_focus", - zoneId: "guild_arsenal", + title: "Celestial Focus: Compressed Light", + zoneId: "guild_arsenal", }, { - id: "equipment_abyssal_tome", - title: "Abyssal Tome: Deep Language", content: "The language of the deep is not spoken — it is applied, like pressure. The tome that contains it was written by beings for whom writing was a pressure-based medium, using materials that compress rather than absorb, in a script that your scholars can read only by learning to apply rather than observe. What it says, approximately translated, is operational guidance for extracting maximum efficiency from a guild, and it works.", + id: "equipment_abyssal_tome", + sourceId: "abyssal_tome", sourceType: "equipment", - sourceId: "abyssal_tome", - zoneId: "guild_arsenal", + title: "Abyssal Tome: Deep Language", + zoneId: "guild_arsenal", }, { - id: "equipment_void_conduit", - title: "Void Conduit: The Absence of Resistance", content: "Void energy is, strictly speaking, nothing — not emptiness, not vacuum, but the category of thing that means an absence so complete that even resistance has been removed. A weapon that channels it does not overcome resistance; it finds the places where resistance is absent and moves through them entirely. The strike lands not because it pushed through defence but because it went around the concept of defence entirely.", + id: "equipment_void_conduit", + sourceId: "void_conduit", sourceType: "equipment", - sourceId: "void_conduit", - zoneId: "guild_arsenal", + title: "Void Conduit: The Absence of Resistance", + zoneId: "guild_arsenal", }, { - id: "equipment_infernal_gem", - title: "Infernal Gem: The Productive Fury", content: "The Infernal Court's fires are not merely destructive — they are the fires of purpose, of consequence, of things that must be done done with absolute commitment. The gem forged in those fires carries that quality: everything within the guild's operations happens with infernal efficiency, which means it happens with the urgency of something that the infernal court has decided must be done. The results are, by all measures, excellent.", + id: "equipment_infernal_gem", + sourceId: "infernal_gem", sourceType: "equipment", - sourceId: "infernal_gem", - zoneId: "guild_arsenal", + title: "Infernal Gem: The Productive Fury", + zoneId: "guild_arsenal", }, { - id: "equipment_crystal_matrix", - title: "Crystal Matrix: The Optimal Path", content: "The lattice structure of this armour was designed by a computational crystal that had no other goal than to find the configuration that maximised income speed. It found it. The resulting structure looks nothing like conventional armour and operates by principles that your engineers have verified empirically without fully understanding theoretically. Every gold piece finds the wearer faster. Why? The crystal's notes say 'because the lattice said so,' and the lattice appears to be right.", + id: "equipment_crystal_matrix", + sourceId: "crystal_matrix", sourceType: "equipment", - sourceId: "crystal_matrix", - zoneId: "guild_arsenal", + title: "Crystal Matrix: The Optimal Path", + zoneId: "guild_arsenal", }, { - id: "equipment_eternal_prism", - title: "The Eternal Prism: Beyond All Planes", content: "The Eternal Prism came from somewhere that does not appear in any cartographic record the guild possesses, carried by something that arrived and declined to explain itself. It refracts power through all dimensions simultaneously, which means the power it amplifies is not the power of one universe but the combined power of every universe through which it refracts. The guild uses it. Your chronicler has decided not to investigate further.", + id: "equipment_eternal_prism", + sourceId: "eternal_prism", sourceType: "equipment", - sourceId: "eternal_prism", - zoneId: "guild_arsenal", + title: "The Eternal Prism: Beyond All Planes", + zoneId: "guild_arsenal", }, // ── The Roster — Adventurers ─────────────────────────────────────────────── { - id: "adventurer_peasant", - title: "The Peasant: Where Every Guild Begins", content: "The first adventurers your guild recruited were peasants — people who had never held a sword in anger, who knew more about crop rotation than combat, and who were nonetheless willing to show up when asked. History is written by those who commission historians, and those historians rarely mention the peasants. Your chronicler has decided to mention them anyway.", + id: "adventurer_peasant", + sourceId: "peasant", sourceType: "adventurer", - sourceId: "peasant", - zoneId: "the_roster", + title: "The Peasant: Where Every Guild Begins", + zoneId: "the_roster", }, { - id: "adventurer_militia", - title: "The Militia: Organised Willingness", content: "Militia are peasants who have received training, which is different from soldiers in the way that a plan is different from a strategy: the distinction is meaningful at scale. Your militia were willing before they were capable, and became capable through the kind of repetition that converts willingness into competence. They remain, at heart, people who decided to try.", + id: "adventurer_militia", + sourceId: "militia", sourceType: "adventurer", - sourceId: "militia", - zoneId: "the_roster", + title: "The Militia: Organised Willingness", + zoneId: "the_roster", }, { - id: "adventurer_apprentice", - title: "Apprentice Mage: The Beginning of Magic", content: "Every archmage was once an apprentice who did not know what they were doing. The apprentice mages in your guild's service are at that exact stage — aware enough of magic to use it, not yet experienced enough to use it wisely. They make mistakes that are occasionally spectacular and consistently educational. The guild has found this a reasonable trade for the income they generate.", + id: "adventurer_apprentice", + sourceId: "apprentice", sourceType: "adventurer", - sourceId: "apprentice", - zoneId: "the_roster", + title: "Apprentice Mage: The Beginning of Magic", + zoneId: "the_roster", }, { - id: "adventurer_scout", - title: "The Scout: Information as Income", content: "Scouts know things. They know where the roads are safe, where the bounties concentrate, where the guild's efforts will be most productive. This knowledge converts to gold at a rate that surprises people who think of scouts as merely mobile. Your chronicler notes that the scout's most important skill is not moving quietly but noticing accurately, and then returning to report.", + id: "adventurer_scout", + sourceId: "scout", sourceType: "adventurer", - sourceId: "scout", - zoneId: "the_roster", + title: "The Scout: Information as Income", + zoneId: "the_roster", }, { - id: "adventurer_acolyte", - title: "The Acolyte: Faith and Function", content: "The acolytes who joined your guild did so with faith in things that their various temples would not have approved of the guild doing with. They bring the discipline of religious practice — consistency, devotion, the willingness to repeat the same action indefinitely in service of a larger purpose — to the business of generating income. Their deities have, so far, not complained.", + id: "adventurer_acolyte", + sourceId: "acolyte", sourceType: "adventurer", - sourceId: "acolyte", - zoneId: "the_roster", + title: "The Acolyte: Faith and Function", + zoneId: "the_roster", }, { - id: "adventurer_ranger", - title: "The Ranger: Distance and Patience", content: "Rangers operate at distances that make most adventurers uncomfortable. They are comfortable with patience, with waiting, with the knowledge that the right moment to act will arrive if you are positioned to recognise it. The guild finds them valuable not just for their combat contributions but for their institutional patience, which is a quality that meetings benefit from having in the room.", + id: "adventurer_ranger", + sourceId: "ranger", sourceType: "adventurer", - sourceId: "ranger", - zoneId: "the_roster", + title: "The Ranger: Distance and Patience", + zoneId: "the_roster", }, { - id: "adventurer_knight", - title: "The Knight: Honour and Accounting", content: "The knightly tradition your guild's fighters come from emphasises honour, duty, and the correct application of force according to an elaborate code. The guild has found that this code aligns surprisingly well with its operational requirements, once you understand that the code's primary function is ensuring that force is applied at the right time, in the right amount, for the right reasons — which is also the guild's primary requirement.", + id: "adventurer_knight", + sourceId: "knight", sourceType: "adventurer", - sourceId: "knight", - zoneId: "the_roster", + title: "The Knight: Honour and Accounting", + zoneId: "the_roster", }, { - id: "adventurer_archmage", - title: "The Archmage: Power and Responsibility", content: "The archmages in your guild's service have, between them, enough accumulated magical power to reshape large portions of the local geography. They do not do this because they are employed in more productive directions. Your chronicler has been asked to note that this restraint is voluntary and contingent on continued satisfactory employment conditions, which the guild has agreed to treat as a binding commitment.", + id: "adventurer_archmage", + sourceId: "archmage", sourceType: "adventurer", - sourceId: "archmage", - zoneId: "the_roster", + title: "The Archmage: Power and Responsibility", + zoneId: "the_roster", }, { - id: "adventurer_paladin", - title: "The Paladin: Divine Contract", content: "Paladins operate under a divine contract that specifies their powers, their obligations, and the conditions under which both are forfeit. The guild's legal team reviewed these contracts before hiring and found them, on balance, favourable to all parties. The paladins' deities were consulted and expressed no objections to their champions being employed in income generation, though they have asked to be kept informed of any projects involving significant quantities of undead.", + id: "adventurer_paladin", + sourceId: "paladin", sourceType: "adventurer", - sourceId: "paladin", - zoneId: "the_roster", + title: "The Paladin: Divine Contract", + zoneId: "the_roster", }, { - id: "adventurer_dragon_rider", - title: "Dragon Rider: The Bond", content: "Dragon riders do not simply ride dragons. The bond between rider and dragon is a negotiated relationship between two beings who have each decided the other is worth the commitment. The dragons in your guild's service chose their riders; the riders chose the guild; and the resulting arrangement is one in which everyone involved has decided, independently, that this is where they want to be. Your chronicler finds this touching.", + id: "adventurer_dragon_rider", + sourceId: "dragon_rider", sourceType: "adventurer", - sourceId: "dragon_rider", - zoneId: "the_roster", + title: "Dragon Rider: The Bond", + zoneId: "the_roster", }, { - id: "adventurer_shadow_assassin", - title: "Shadow Assassin: Silence as Profession", content: "The shadow assassins recruited into your guild operate by principles that they decline to discuss in detail, which your guild has accepted as a reasonable professional boundary. What can be documented is their effectiveness: problems that were present before their deployment are reliably absent afterward, and the methods of absence are clean enough that the absence is the only evidence. Your chronicler has decided to appreciate this without investigating it.", + id: "adventurer_shadow_assassin", + sourceId: "shadow_assassin", sourceType: "adventurer", - sourceId: "shadow_assassin", - zoneId: "the_roster", + title: "Shadow Assassin: Silence as Profession", + zoneId: "the_roster", }, { - id: "adventurer_arcane_scholar", - title: "Arcane Scholar: Forbidden Knowledge", content: "The arcane scholars in your guild have read things that the academies they trained at would not have permitted them to read. This is why they are not at those academies anymore. The knowledge they carry is powerful, specific, and occasionally alarming when they mention it in conversation. The guild employs them for the power and has learned to manage the occasionally alarming parts through the strategic scheduling of meetings.", + id: "adventurer_arcane_scholar", + sourceId: "arcane_scholar", sourceType: "adventurer", - sourceId: "arcane_scholar", - zoneId: "the_roster", + title: "Arcane Scholar: Forbidden Knowledge", + zoneId: "the_roster", }, { - id: "adventurer_void_walker", - title: "Void Walker: Between Places", content: "Void walkers move through the space between places — not the distance between locations but the conceptual gap that exists between one thing and another. They have learned to use this gap as a highway and can arrive at destinations before they have technically left their starting point, which the guild finds useful and the void walkers find unremarkable. They have been walking between places long enough to find it ordinary.", + id: "adventurer_void_walker", + sourceId: "void_walker", sourceType: "adventurer", - sourceId: "void_walker", - zoneId: "the_roster", + title: "Void Walker: Between Places", + zoneId: "the_roster", }, { - id: "adventurer_celestial_guard", - title: "Celestial Guard: Blessed Service", content: "The celestial blessing that distinguishes the Celestial Guard from ordinary paladins is difficult to describe in mortal terms. It is not merely more powerful but more complete — the guard operates with a certainty about their actions and a protection from the consequences of those actions that ordinary fighters do not have. The guild treats this as an asset and has asked the guards not to discuss the nature of the blessing with the guild's philosophers.", + id: "adventurer_celestial_guard", + sourceId: "celestial_guard", sourceType: "adventurer", - sourceId: "celestial_guard", - zoneId: "the_roster", + title: "Celestial Guard: Blessed Service", + zoneId: "the_roster", }, { - id: "adventurer_divine_champion", - title: "Divine Champion: An Unbreakable Oath", content: "The divine champions in your guild's service made an oath that is, by definition, unbreakable. The guild's lawyers were curious about the enforcement mechanism for such an oath and were told, with considerable patience, that an unbreakable oath does not have an enforcement mechanism because it does not need one. The champion simply cannot break it. The guild found this satisfying from a contract management perspective.", + id: "adventurer_divine_champion", + sourceId: "divine_champion", sourceType: "adventurer", - sourceId: "divine_champion", - zoneId: "the_roster", + title: "Divine Champion: An Unbreakable Oath", + zoneId: "the_roster", }, { - id: "adventurer_seraph_knight", - title: "Seraph Knight: Wings of Conviction", content: "The wings of a Seraph Knight are not metaphorical. They grew from the knight's own conviction — slowly, over years of accumulated commitment — and they fly in proportion to how strongly that conviction is held. The knights in your guild's service have convictions that your armourer describes as 'structurally impressive' and that your philosophers describe as 'something to aspire to.' They are, in every measurable way, twice the fighter they were before they grew them.", + id: "adventurer_seraph_knight", + sourceId: "seraph_knight", sourceType: "adventurer", - sourceId: "seraph_knight", - zoneId: "the_roster", + title: "Seraph Knight: Wings of Conviction", + zoneId: "the_roster", }, { - id: "adventurer_abyss_diver", - title: "Abyss Diver: Adapted to Pressure", content: "Full adaptation to abyssal pressure takes years of incremental descent, a willingness to let your body become something it was not before, and a psychological relationship with being crushed that most people never develop. The abyss divers who work for your guild emerged from that process as something that is technically still human but that operates in conditions that are incompatible with human survival. They find this interesting rather than troubling.", + id: "adventurer_abyss_diver", + sourceId: "abyss_diver", sourceType: "adventurer", - sourceId: "abyss_diver", - zoneId: "the_roster", + title: "Abyss Diver: Adapted to Pressure", + zoneId: "the_roster", }, { - id: "adventurer_infernal_warden", - title: "Infernal Warden: Tempered in Hellfire", content: "Hellfire does not merely heat. It tests. Everything that passes through hellfire either burns away or becomes something that hellfire cannot burn, and the infernal wardens in your guild have been through hellfire enough times that they are now definitively in the second category. They are not invulnerable — they simply cannot be unmade by the thing that tried to unmake them, which covers a significant portion of threats.", + id: "adventurer_infernal_warden", + sourceId: "infernal_warden", sourceType: "adventurer", - sourceId: "infernal_warden", - zoneId: "the_roster", + title: "Infernal Warden: Tempered in Hellfire", + zoneId: "the_roster", }, { - id: "adventurer_crystal_sage", - title: "Crystal Sage: Prismatic Mastery", content: "Prismatic crystallomancy is the discipline of working with crystals that refract not just light but every form of energy, including kinds of energy that do not have names yet. The crystal sages in your guild have mastered it completely, which means they can redirect any incoming force through angles that the original force did not plan for. They are also very good at generating income, which turns out to use many of the same principles.", + id: "adventurer_crystal_sage", + sourceId: "crystal_sage", sourceType: "adventurer", - sourceId: "crystal_sage", - zoneId: "the_roster", + title: "Crystal Sage: Prismatic Mastery", + zoneId: "the_roster", }, { - id: "adventurer_void_sentinel", - title: "Void Sentinel: Perfect Resonance", content: "Void resonance is the state of being so precisely tuned to the void that you can feel its movements the way ordinary people feel wind. The void sentinels in your guild have achieved this state through a process that took years and that they describe, in writing, as 'not recommended without guidance.' In this state they can sense threats through the void before those threats manifest, which makes them extremely effective and somewhat unsettling at dinner parties.", + id: "adventurer_void_sentinel", + sourceId: "void_sentinel", sourceType: "adventurer", - sourceId: "void_sentinel", - zoneId: "the_roster", + title: "Void Sentinel: Perfect Resonance", + zoneId: "the_roster", }, { - id: "adventurer_eternal_champion", - title: "Eternal Champion: An Oath Across Time", content: "The eternal champions took an oath that transcends time — not metaphorically, as oaths of loyalty and commitment typically do, but literally, such that the oath exists at every point in time simultaneously and the champion's commitment to it is therefore constant regardless of when you ask. The guild finds this useful because it means you do not need to worry about the champion changing their mind, which is not a common concern but saves considerable administrative effort.", + id: "adventurer_eternal_champion", + sourceId: "eternal_champion", sourceType: "adventurer", - sourceId: "eternal_champion", - zoneId: "the_roster", + title: "Eternal Champion: An Oath Across Time", + zoneId: "the_roster", }, { - id: "adventurer_aether_weaver", - title: "Aether Weaver: The Invisible Medium", content: "Aether is what fills the space between all other things, and aether weavers are people who have learned to work with it as other craftspeople work with metal or wood. The weavers in your guild manipulate aether with the casual fluency of people who have done it so long that the manipulation is no longer conscious. The effects are as if reality has been revised by someone who does not feel the need to explain the revision.", + id: "adventurer_aether_weaver", + sourceId: "aether_weaver", sourceType: "adventurer", - sourceId: "aether_weaver", - zoneId: "the_roster", + title: "Aether Weaver: The Invisible Medium", + zoneId: "the_roster", }, { - id: "adventurer_titan_warrior", - title: "Titan Warrior: The Fury of Scale", content: "Titan warriors are not large in the way that large people are large. They are large in the way that things that were never intended to fit in ordinary spaces are large — they exist at a scale where the forces they bring to bear are categorically different from what ordinary strength can produce. The fury they fight with is the fury of scale: not anger, but the simple fact that at their size, motion carries consequences.", + id: "adventurer_titan_warrior", + sourceId: "titan_warrior", sourceType: "adventurer", - sourceId: "titan_warrior", - zoneId: "the_roster", + title: "Titan Warrior: The Fury of Scale", + zoneId: "the_roster", }, { - id: "adventurer_nexus_sage", - title: "Nexus Sage: Where All Lines Meet", content: "Ley lines are the channels through which magical energy flows across and between worlds. The nexus sages in your guild have stopped standing at the intersections of ley lines and started standing at the point where they all converge, which is a distinction that sounds minor and produces results that are not. They channel the combined magical output of every line through their bodies simultaneously, which they describe as 'intense but manageable.'", + id: "adventurer_nexus_sage", + sourceId: "nexus_sage", sourceType: "adventurer", - sourceId: "nexus_sage", - zoneId: "the_roster", + title: "Nexus Sage: Where All Lines Meet", + zoneId: "the_roster", }, { - id: "adventurer_cosmos_knight", - title: "Cosmos Knight: Stellar Tempering", content: "The cosmos knights were tempered not in forges but in the hearts of dying stars, which is a process that takes place at temperatures and timescales that require explanation. The knight undergoes a consciousness transfer into a body that then undergoes the stellar process, and what returns is something that was a knight before and is now something that shares a knight's values but operates at stellar tolerances. The transition is voluntary. Barely.", + id: "adventurer_cosmos_knight", + sourceId: "cosmos_knight", sourceType: "adventurer", - sourceId: "cosmos_knight", - zoneId: "the_roster", + title: "Cosmos Knight: Stellar Tempering", + zoneId: "the_roster", }, { - id: "adventurer_astral_sovereign", - title: "Astral Sovereign: Dominion of the Stars", content: "True sovereignty over the astral plane is not achieved through combat or politics but through the astral plane's own recognition that one is, in fact, sovereign. The astral sovereigns in your guild's service arrived at this recognition through a process that took lifetimes and that they decline to detail, because the details include several centuries of failures that they have moved past. The astral plane now acts as if they own it, which amounts to the same thing.", + id: "adventurer_astral_sovereign", + sourceId: "astral_sovereign", sourceType: "adventurer", - sourceId: "astral_sovereign", - zoneId: "the_roster", + title: "Astral Sovereign: Dominion of the Stars", + zoneId: "the_roster", }, { - id: "adventurer_primordial_mage", - title: "Primordial Mage: Ancient Power Remembered", content: "The primordial mages carry power that predates the formal study of magic. It is inherited rather than learned — passed down through bloodlines that trace back to the first people who noticed that intention could affect reality, before there was a vocabulary for what they were doing. When this heritage awakens fully, the mage ceases to be someone who uses magic and becomes someone through whom magic moves.", + id: "adventurer_primordial_mage", + sourceId: "primordial_mage", sourceType: "adventurer", - sourceId: "primordial_mage", - zoneId: "the_roster", + title: "Primordial Mage: Ancient Power Remembered", + zoneId: "the_roster", }, { - id: "adventurer_reality_warden", - title: "Reality Warden: Bound to Structure", content: "The reality wardens bound themselves to the structure of reality — not to a specific place in it but to its underlying architecture, the rules by which things are what they are and cannot be otherwise. This binding made them defenders of reality's consistency at a fundamental level. The guild employs them as fighters, which the reality wardens have noted is technically within their purview because maintaining reality's consistency includes preventing its violent disruption.", + id: "adventurer_reality_warden", + sourceId: "reality_warden", sourceType: "adventurer", - sourceId: "reality_warden", - zoneId: "the_roster", + title: "Reality Warden: Bound to Structure", + zoneId: "the_roster", }, { - id: "adventurer_infinity_ranger", - title: "Infinity Ranger: Arrows Through Forever", content: "The infinity rangers shoot arrows that travel through infinity to reach their targets, which sounds like a roundabout way to hit something close by but means that the arrow's path cannot be intercepted by anything that exists in finite space. Their aim is described as infinite, which the rangers themselves find a bit much — they would say they simply take the shot and wait for it to land, and it always does, eventually.", + id: "adventurer_infinity_ranger", + sourceId: "infinity_ranger", sourceType: "adventurer", - sourceId: "infinity_ranger", - zoneId: "the_roster", + title: "Infinity Ranger: Arrows Through Forever", + zoneId: "the_roster", }, { - id: "adventurer_oblivion_paladin", - title: "Oblivion Paladin: Consecrated by Nothing", content: "The void between all things is not empty — it is the space where the rules of things do not apply, the gap in which nothing is required to be anything. The oblivion paladins were consecrated by this void in a ceremony that they can only partly remember, because memory is one of the things that does not apply there. They returned with a conviction that operates below the level where conviction can be questioned, which makes them extraordinarily effective.", + id: "adventurer_oblivion_paladin", + sourceId: "oblivion_paladin", sourceType: "adventurer", - sourceId: "oblivion_paladin", - zoneId: "the_roster", + title: "Oblivion Paladin: Consecrated by Nothing", + zoneId: "the_roster", }, { - id: "adventurer_transcendent_rogue", - title: "Transcendent Rogue: Beyond All States", content: "The transcendent rogues have moved past the physical state of being present in a specific location at a specific time. They exist, instead, in the space between states — not here, not there, not now, not then, but in the transitions between all of these. From this position they can act with a precision that ordinary presence cannot achieve, arriving at outcomes without having been seen taking the steps that led to them.", + id: "adventurer_transcendent_rogue", + sourceId: "transcendent_rogue", sourceType: "adventurer", - sourceId: "transcendent_rogue", - zoneId: "the_roster", + title: "Transcendent Rogue: Beyond All States", + zoneId: "the_roster", }, { - id: "adventurer_omniversal_champion", - title: "Omniversal Champion: All Versions, One Victory", content: "The omniversal champions hold dominion over all versions of themselves in all universes — not through control but through unanimity. Every version of them made the same choices, arrived at the same place, and stands here now having converged from every possible timeline. The victory they represent is not a single instance but a statistical certainty: in every universe where your guild could exist, this champion stands in it.", + id: "adventurer_omniversal_champion", + sourceId: "omniversal_champion", sourceType: "adventurer", - sourceId: "omniversal_champion", - zoneId: "the_roster", + title: "Omniversal Champion: All Versions, One Victory", + zoneId: "the_roster", }, // ── Guild Library — Upgrades ─────────────────────────────────────────────── { - id: "upgrade_click_1", - title: "Keen Eye: The First Refinement", content: "The first systematic improvement to your guild's striking technique came from a retired soldier who noticed that the fighters were aiming at armour rather than at the joints of armour. The resulting coaching — documented in a one-page memorandum that your chronicler has preserved — doubled effective output through the simple application of the observation that you do not need to pierce the armour, you need to find where the armour isn't.", + id: "upgrade_click_1", + sourceId: "click_1", sourceType: "upgrade", - sourceId: "click_1", - zoneId: "guild_library", + title: "Keen Eye: The First Refinement", + zoneId: "guild_library", }, { - id: "upgrade_click_2", - title: "Battle Hardened: The Education of Years", content: "Battle hardening is not training — it is the accumulated result of surviving enough combat that the nervous system stops treating it as an emergency. Your guild's fighters reached this point at different times and by different paths, but the institutional effect is measurable: they hit harder, waste less motion, and make fewer of the errors that the body makes when it is afraid. The fear is still there. The errors are not.", + id: "upgrade_click_2", + sourceId: "click_2", sourceType: "upgrade", - sourceId: "click_2", - zoneId: "guild_library", + title: "Battle Hardened: The Education of Years", + zoneId: "guild_library", }, { - id: "upgrade_click_3", - title: "Legendary Weapon: Procurement Notes", content: "The acquisition of a weapon of ancient power requires contacts, resources, and a willingness to negotiate with people who are accustomed to saying no. Your guild navigated all three. The weapon your quartermaster eventually obtained was described in the provenance documentation as 'legendary,' which is a category that exists between 'very good' and 'physically impossible to account for on a standard inventory form.' It tripled click output, which justified the paperwork.", + id: "upgrade_click_3", + sourceId: "click_3", sourceType: "upgrade", - sourceId: "click_3", - zoneId: "guild_library", + title: "Legendary Weapon: Procurement Notes", + zoneId: "guild_library", }, { - id: "upgrade_crystal_focus", - title: "Crystal Focus: Channelling Crystalline Power", content: "The guild's discovery that crystallised power could be channelled into personal strikes rather than simply held or traded was, by general account, transformative. The technique requires crystals that most guilds treat as currency and a focusing method that took your scholars three months to develop. The result is that every strike now carries a fragment of crystalline force, which your fighters describe as 'like hitting with something that has decided to hit.'", + id: "upgrade_crystal_focus", + sourceId: "crystal_focus", sourceType: "upgrade", - sourceId: "crystal_focus", - zoneId: "guild_library", + title: "Crystal Focus: Channelling Crystalline Power", + zoneId: "guild_library", }, { - id: "upgrade_click_4", - title: "Celestial Strike: A Blessing Negotiated", content: "Obtaining a celestial blessing for a combat technique required the guild to make several arguments to several celestial beings about why the technique deserved their endorsement. The celestials were not opposed in principle but wanted to understand the reasoning. Your guild's theologian prepared a seventeen-page brief. The celestials read it, found it adequate, and bestowed a blessing that quadrupled click power, which the theologian considers a satisfying result.", + id: "upgrade_click_4", + sourceId: "click_4", sourceType: "upgrade", - sourceId: "click_4", - zoneId: "guild_library", + title: "Celestial Strike: A Blessing Negotiated", + zoneId: "guild_library", }, { - id: "upgrade_click_5", - title: "Infernal Slash: The Fire Technique", content: "Infernal fire does not merely burn — it burns with the quality of something that has decided to burn, which is categorically more thorough. The technique your guild developed for channelling it into strikes required consultation with infernal sources who were cooperative once the fee was established, and produced a method that quintuples strike force. The training sessions were described by participants as 'warm.'", + id: "upgrade_click_5", + sourceId: "click_5", sourceType: "upgrade", - sourceId: "click_5", - zoneId: "guild_library", + title: "Infernal Slash: The Fire Technique", + zoneId: "guild_library", }, { - id: "upgrade_global_1", - title: "Guild Charter: The First Document", content: "The guild's formal charter was written by your founding members in a session that lasted two days and produced a document that is, in retrospect, more prophetic than anyone realised at the time. It established the guild's structure, its obligations, and its income-sharing protocols. The formalisation of structure increased all income by a quarter, which your economists attribute to the fact that people work differently when they understand that their work is part of something documented.", + id: "upgrade_global_1", + sourceId: "global_1", sourceType: "upgrade", - sourceId: "global_1", - zoneId: "guild_library", + title: "Guild Charter: The First Document", + zoneId: "guild_library", }, { - id: "upgrade_global_2", - title: "Merchant Alliance: The Trade Network", content: "The alliance with the merchant network required your guild to offer something the merchants valued: reliable security along the routes the merchants cared about. What the merchants offered in return was access to trade infrastructure — buying and selling channels that your guild had not previously known existed. Income increased by half, and your guild gained relationships that have since proven useful in ways that had nothing to do with income.", + id: "upgrade_global_2", + sourceId: "global_2", sourceType: "upgrade", - sourceId: "global_2", - zoneId: "guild_library", + title: "Merchant Alliance: The Trade Network", + zoneId: "guild_library", }, { - id: "upgrade_global_3", - title: "Royal Patronage: The Crown's Endorsement", content: "The king's backing came with strings, as royal backing does, but the strings were manageable and the endorsement was not. A guild that operates under royal patronage operates differently than one that does not: suppliers give better terms, clients pay faster, and threats reconsider their approach. All income doubled, which your guild's accountant attributes to the endorsement and your guild's lawyer attributes to the specific contractual terms of the patronage arrangement, and both of them are right.", + id: "upgrade_global_3", + sourceId: "global_3", sourceType: "upgrade", - sourceId: "global_3", - zoneId: "guild_library", + title: "Royal Patronage: The Crown's Endorsement", + zoneId: "guild_library", }, { - id: "upgrade_essence_guild", - title: "Essence Guild: The Mage Partnership", content: "The mage guilds across the realm had resources your guild needed and vice versa. The partnership took months to negotiate because mages negotiate carefully and document everything, but the result was a formalised relationship that increased all income by half through the sharing of channels, contacts, and knowledge that neither party had been able to access alone. The mage guilds found the arrangement mutually beneficial and have since referred your guild to several additional clients.", + id: "upgrade_essence_guild", + sourceId: "essence_guild", sourceType: "upgrade", - sourceId: "essence_guild", - zoneId: "guild_library", + title: "Essence Guild: The Mage Partnership", + zoneId: "guild_library", }, { - id: "upgrade_grand_council", - title: "Grand Council: Organised Intelligence", content: "The greatest minds of the realm agreed to advise your guild in exchange for terms that your chronicler describes as 'reasonable considering who they are.' What the Council provided was not new information but organised information — the same data your guild already had, restructured so that decisions followed logically from it rather than being made around it. All income doubled, and your guild made fewer expensive mistakes, which is sometimes worth more than the income.", + id: "upgrade_grand_council", + sourceId: "grand_council", sourceType: "upgrade", - sourceId: "grand_council", - zoneId: "guild_library", + title: "Grand Council: Organised Intelligence", + zoneId: "guild_library", }, { - id: "upgrade_crystal_resonance", - title: "Crystal Resonance: Aligned Frequencies", content: "The guild's discovery that crystalline frequencies could be aligned across its operations required a specialist in crystalline harmonics who had been working on the problem for a decade with no obvious application. The application turned out to be your guild. The alignment increased all income by half through a mechanism that the specialist describes as 'the crystals agreeing with each other,' which the guild's accountant has accepted as a sufficient explanation.", + id: "upgrade_crystal_resonance", + sourceId: "crystal_resonance", sourceType: "upgrade", - sourceId: "crystal_resonance", - zoneId: "guild_library", + title: "Crystal Resonance: Aligned Frequencies", + zoneId: "guild_library", }, { - id: "upgrade_crystal_mastery", - title: "Crystal Mastery: The Full Art", content: "Mastering crystal amplification required your guild to move beyond using crystals as tools and begin using them as partners — objects with their own logic that could be negotiated with rather than simply operated. The resulting relationship doubled all income because crystals that are fully understood provide more than crystals that are merely used, in the same way that colleagues provide more than employees.", + id: "upgrade_crystal_mastery", + sourceId: "crystal_mastery", sourceType: "upgrade", - sourceId: "crystal_mastery", - zoneId: "guild_library", + title: "Crystal Mastery: The Full Art", + zoneId: "guild_library", }, { - id: "upgrade_divine_covenant", - title: "Divine Covenant: Celestial Multiplication", content: "The covenant with celestial forces required your guild to commit to purposes that the celestials found acceptable. This was less restrictive than expected: the celestials are not interested in dictating tactics but in ensuring that the guild's overall direction is compatible with theirs. The resulting covenant doubled all income by opening channels that divine endorsement makes available and that cannot be accessed any other way.", + id: "upgrade_divine_covenant", + sourceId: "divine_covenant", sourceType: "upgrade", - sourceId: "divine_covenant", - zoneId: "guild_library", + title: "Divine Covenant: Celestial Multiplication", + zoneId: "guild_library", }, { - id: "upgrade_global_4", - title: "Imperial Decree: The Highest Endorsement", content: "The empire's formal sponsorship at the highest level is not a thing that is given — it is a thing that is negotiated, and the negotiations required your guild to demonstrate a track record that the imperial accountants found compelling. The resulting decree increased all income by two and a half times, because an entity with imperial backing occupies a different position in the commercial ecosystem than one without. The ecosystem reorganised itself accordingly.", + id: "upgrade_global_4", + sourceId: "global_4", sourceType: "upgrade", - sourceId: "global_4", - zoneId: "guild_library", + title: "Imperial Decree: The Highest Endorsement", + zoneId: "guild_library", }, { - id: "upgrade_abyssal_pact", - title: "Abyssal Pact: The Deep Bargain", content: "The denizens of the deepest trench do not make pacts lightly. They have been making pacts for longer than most civilisations have existed, and they have developed very precise terms. What your guild offered was access to surface resources the trench lacks; what the trench offered was access to deep channels that surface commerce cannot use. All income doubled, and your chronicler notes that the pact's terms include a clause about mutual non-interference that both parties have found it easy to honour.", + id: "upgrade_abyssal_pact", + sourceId: "abyssal_pact", sourceType: "upgrade", - sourceId: "abyssal_pact", - zoneId: "guild_library", + title: "Abyssal Pact: The Deep Bargain", + zoneId: "guild_library", }, { - id: "upgrade_celestial_mandate", - title: "Celestial Mandate: Dominion Decreed", content: "The celestials decreed your guild's dominion over all realms in a ceremony that your attending representative described as 'brief, enormous, and difficult to convey in writing.' The mandate tripled all income by making your guild's operations the default choice in contexts where an alternative exists, because celestial mandates do not merely suggest — they establish. The alternative choices remain available. Nobody uses them.", + id: "upgrade_celestial_mandate", + sourceId: "celestial_mandate", sourceType: "upgrade", - sourceId: "celestial_mandate", - zoneId: "guild_library", + title: "Celestial Mandate: Dominion Decreed", + zoneId: "guild_library", }, { - id: "upgrade_void_ascendancy", - title: "Void Ascendancy: Beyond Mortal Limits", content: "Transcending mortal limits via void energy is a process that the guild's philosophers had theoretical objections to and that your fighters found practically obvious once the void energy was available. The limits that fell were not physical — they were the assumptions about what was possible, which turned out to be more limiting than any physical constraint. All income tripled, and several previously impossible income sources became routine.", + id: "upgrade_void_ascendancy", + sourceId: "void_ascendancy", sourceType: "upgrade", - sourceId: "void_ascendancy", - zoneId: "guild_library", + title: "Void Ascendancy: Beyond Mortal Limits", + zoneId: "guild_library", }, { - id: "upgrade_divine_harmony", - title: "Divine Harmony: Perfect Alignment", content: "Perfect harmony with celestial forces is not achieved by doing what the celestials want — it is achieved by wanting the same things they want, which is a different relationship entirely. Your guild arrived at this alignment over the course of its accumulated experience and found that celestial forces, once aligned with rather than appealed to, amplify everything by two and a half times. The alignment is not a constraint. It is a resonance.", + id: "upgrade_divine_harmony", + sourceId: "divine_harmony", sourceType: "upgrade", - sourceId: "divine_harmony", - zoneId: "guild_library", + title: "Divine Harmony: Perfect Alignment", + zoneId: "guild_library", }, { - id: "upgrade_infernal_fury", - title: "Infernal Fury: The Productive Rage", content: "Infernal fury is not wrath — it is the channelled application of absolute commitment to an outcome. The infernal realm has perfected this into a kind of power that doubles everything it is applied to, because the fury itself is not destructive but productive: it destroys the gap between what is being done and what needs to be done. Your guild applied it to income generation. The infernal court was, against all expectations, supportive.", + id: "upgrade_infernal_fury", + sourceId: "infernal_fury", sourceType: "upgrade", - sourceId: "infernal_fury", - zoneId: "guild_library", + title: "Infernal Fury: The Productive Rage", + zoneId: "guild_library", }, { - id: "upgrade_essence_nexus", - title: "Essence Nexus: The Network of Flows", content: "Essence does not accumulate in one place — it flows through networks that span the realm and extend beyond it, and the guild that learns to tap these networks rather than waiting for essence to arrive finds that the flows are vast. Your scholars mapped the nexus over three years and developed tapping techniques that increased all income by half, not by generating more essence but by accessing the essence that was already flowing past the guild every day.", + id: "upgrade_essence_nexus", + sourceId: "essence_nexus", sourceType: "upgrade", - sourceId: "essence_nexus", - zoneId: "guild_library", + title: "Essence Nexus: The Network of Flows", + zoneId: "guild_library", }, { - id: "upgrade_essence_overdrive", - title: "Essence Overdrive: The Flood", content: "Once the guild could tap the essence networks, the question became how much to tap. The answer your engineers arrived at was 'more than is comfortable and slightly more than seems safe,' which doubled all income and produced no adverse effects despite the predictions of the people who described it as slightly more than seems safe. The essence flows without being depleted. This surprised everyone.", + id: "upgrade_essence_overdrive", + sourceId: "essence_overdrive", sourceType: "upgrade", - sourceId: "essence_overdrive", - zoneId: "guild_library", + title: "Essence Overdrive: The Flood", + zoneId: "guild_library", }, { - id: "upgrade_primal_essence", - title: "Primal Essence: The Oldest Power", content: "The oldest essence in existence predates the formal study of essence and the systems that have since been built around it. It flows through channels that are not on any map and that your scholars found by following older, stranger signals that the modern infrastructure had built over. Tapping it tripled all income, because primal essence is not constrained by the bottlenecks that the modern network has built in over centuries of incremental expansion.", + id: "upgrade_primal_essence", + sourceId: "primal_essence", sourceType: "upgrade", - sourceId: "primal_essence", - zoneId: "guild_library", + title: "Primal Essence: The Oldest Power", + zoneId: "guild_library", }, { - id: "upgrade_crystal_overdrive", - title: "Crystal Overdrive: The Edge of Resonance", content: "The crystal resonance that runs through the guild's operations has a maximum, and pushing beyond that maximum requires techniques that the crystals themselves had to be consulted about. They agreed — somewhat reluctantly, your crystal harmonics specialist reports — to operate beyond their standard resonant peak. The result doubled all income, and the crystals have, over time, adjusted to their new normal.", + id: "upgrade_crystal_overdrive", + sourceId: "crystal_overdrive", sourceType: "upgrade", - sourceId: "crystal_overdrive", - zoneId: "guild_library", + title: "Crystal Overdrive: The Edge of Resonance", + zoneId: "guild_library", }, { - id: "upgrade_eternal_bond", - title: "Eternal Bond: The Permanent Pact", content: "The eternal bond is a pact that exists outside time and therefore cannot be terminated by anything that operates within it. Your guild made this pact with forces that were, in some sense, everything that had ever happened and everything that would ever happen — the entirety of existence agreeing, in a moment, to triple all income permanently. The ceremony was brief. The implications are still being worked through by your guild's philosophers.", + id: "upgrade_eternal_bond", + sourceId: "eternal_bond", sourceType: "upgrade", - sourceId: "eternal_bond", - zoneId: "guild_library", + title: "Eternal Bond: The Permanent Pact", + zoneId: "guild_library", }, { - id: "upgrade_apex_mandate", - title: "Apex Mandate: The Supreme Decree", content: "The supreme decree from the Eternal Throne is not a request. It is not an endorsement. It is the statement that your guild's operations are what the Eternal Throne has determined they should be, which means they have the support of the most absolute authority that has ever existed or will ever exist. All income quintupled. The mandate requires nothing of your guild except that it continue existing, which your guild considers an acceptable obligation.", + id: "upgrade_apex_mandate", + sourceId: "apex_mandate", sourceType: "upgrade", - sourceId: "apex_mandate", - zoneId: "guild_library", + title: "Apex Mandate: The Supreme Decree", + zoneId: "guild_library", }, { - id: "upgrade_peasant_1", - title: "Better Tools: The Productive Difference", content: "The difference between a peasant with inadequate tools and a peasant with proper tools is not enthusiasm — both groups have plenty of that. It is the conversion rate of effort into result. Your guild's investment in equipping its peasant workers properly doubled their output not because they worked harder but because working hard stopped producing the same diminishing returns that working hard with bad tools produces.", + id: "upgrade_peasant_1", + sourceId: "peasant_1", sourceType: "upgrade", - sourceId: "peasant_1", - zoneId: "guild_library", + title: "Better Tools: The Productive Difference", + zoneId: "guild_library", }, { - id: "upgrade_militia_1", - title: "Militia Training: Formalising Willingness", content: "Your militia were willing before they were trained. What formal training provided was a structure for their willingness — a vocabulary of movement and decision that converted individual effort into collective effectiveness. The output doubled not because each person did more but because the same people doing the same things in coordination produced results that cannot be achieved by any number of people doing the same things separately.", + id: "upgrade_militia_1", + sourceId: "militia_1", sourceType: "upgrade", - sourceId: "militia_1", - zoneId: "guild_library", + title: "Militia Training: Formalising Willingness", + zoneId: "guild_library", }, { - id: "upgrade_mage_1", - title: "Arcane Tomes: The Written Knowledge", content: "The ancient books of magic acquired for the guild's mages contained techniques that their trainers had either not known or had chosen not to teach. The omission, in most cases, appeared to be deliberate — the techniques worked but produced results that the academies found uncomfortable to endorse. Your guild finds them extremely comfortable to have, and the mage output doubled from the application of knowledge that had been sitting in books waiting for someone to act on it.", + id: "upgrade_mage_1", + sourceId: "mage_1", sourceType: "upgrade", - sourceId: "mage_1", - zoneId: "guild_library", + title: "Arcane Tomes: The Written Knowledge", + zoneId: "guild_library", }, { - id: "upgrade_cleric_1", - title: "Holy Rites: The Sacred Routine", content: "The sacred ceremonies that your clerics now perform before and during operations were developed by your head cleric over six months of experimentation that their deity appears to have sanctioned, based on the results. The rites formalise the relationship between divine power and operational output into a repeatable process. Doubled cleric output is the result of making the exceptional ordinary through the discipline of ceremony.", + id: "upgrade_cleric_1", + sourceId: "cleric_1", sourceType: "upgrade", - sourceId: "cleric_1", - zoneId: "guild_library", + title: "Holy Rites: The Sacred Routine", + zoneId: "guild_library", }, { - id: "upgrade_scout_1", - title: "Stealth Training: The Art of Unseen Movement", content: "Advanced scouting techniques are not about becoming invisible — they are about becoming uninteresting. The training your scouts received taught them to move in ways that do not attract attention, to be in places that don't look like useful places to be, and to gather information from positions that information-gatherers are not expected to occupy. Ranger effectiveness doubled because effective scouting doubles the value of everything downstream of it.", + id: "upgrade_scout_1", + sourceId: "scout_1", sourceType: "upgrade", - sourceId: "scout_1", - zoneId: "guild_library", + title: "Stealth Training: The Art of Unseen Movement", + zoneId: "guild_library", }, { - id: "upgrade_knight_1", - title: "Tempered Steel: The Superior Edge", content: "Superior forging technique is the difference between a blade that holds its edge through one campaign and one that holds it through ten. The smiths your guild contracted for this work were not cheaper than the previous smiths; they were better, which is a different value proposition. Knight output doubled not through any change in the knights but through the compounding advantage of tools that do not fail when failure matters.", + id: "upgrade_knight_1", + sourceId: "knight_1", sourceType: "upgrade", - sourceId: "knight_1", - zoneId: "guild_library", + title: "Tempered Steel: The Superior Edge", + zoneId: "guild_library", }, { - id: "upgrade_archmage_1", - title: "Leyline Binding: The World's Own Power", content: "The world's leylines carry magical energy in quantities that individual mages cannot generate and that trained archmages can access only partially without binding techniques. Your archmages, having learned to bind themselves to the leylines rather than simply drawing from them, doubled their output by ceasing to limit themselves to what they could generate and beginning to use what the world was already producing.", + id: "upgrade_archmage_1", + sourceId: "archmage_1", sourceType: "upgrade", - sourceId: "archmage_1", - zoneId: "guild_library", + title: "Leyline Binding: The World's Own Power", + zoneId: "guild_library", }, { - id: "upgrade_paladin_1", - title: "Holy Vanguard: The Gods' Own Blessing", content: "A blessing from the gods themselves is not the same as a blessing from a divine-adjacent source or a blessing from a very good cleric. The gods' blessing operates at a different level of reality and produces different results. Your guild's paladins received these blessings through a process that required the paladins to articulate, in language the gods found precise, what the blessing was for. They did. The output doubled.", + id: "upgrade_paladin_1", + sourceId: "paladin_1", sourceType: "upgrade", - sourceId: "paladin_1", - zoneId: "guild_library", + title: "Holy Vanguard: The Gods' Own Blessing", + zoneId: "guild_library", }, { - id: "upgrade_dragon_rider_1", - title: "Bond of Wings: The Unbreakable Union", content: "The bond between rider and dragon is unbreakable not because it is mandated but because, once formed, it is what both the rider and the dragon are. Deepening this bond requires practices that are personal to each pair and that no external authority can prescribe. Your riders deepened their bonds through years of shared experience that your chronicler has documented and that the dragons have declined to comment on. Combined output doubled.", + id: "upgrade_dragon_rider_1", + sourceId: "dragon_rider_1", sourceType: "upgrade", - sourceId: "dragon_rider_1", - zoneId: "guild_library", + title: "Bond of Wings: The Unbreakable Union", + zoneId: "guild_library", }, { - id: "upgrade_shadow_assassin_1", - title: "Shadow Arts: The Full Mastery", content: "The shadow arts that your assassins mastered are the complete discipline rather than the subset that most practitioners learn. The full practice includes techniques that the partial practice does not contain and that cannot be extrapolated from what the partial practice teaches — they must be discovered through the discipline itself, at a depth most practitioners do not reach. Your assassins reached it. Output doubled.", + id: "upgrade_shadow_assassin_1", + sourceId: "shadow_assassin_1", sourceType: "upgrade", - sourceId: "shadow_assassin_1", - zoneId: "guild_library", + title: "Shadow Arts: The Full Mastery", + zoneId: "guild_library", }, { - id: "upgrade_arcane_scholar_1", - title: "Ancient Tomes: Forbidden Libraries", content: "The libraries your arcane scholars were given access to are called forbidden not because access is prohibited but because the information in them cannot be unlearned. Your scholars went in knowing this and came out different — more capable, more precise, and with a new category of questions they find it best not to ask in front of other people. Output doubled, and your librarians have been asked to stop cataloguing what the scholars specifically checked out.", + id: "upgrade_arcane_scholar_1", + sourceId: "arcane_scholar_1", sourceType: "upgrade", - sourceId: "arcane_scholar_1", - zoneId: "guild_library", + title: "Ancient Tomes: Forbidden Libraries", + zoneId: "guild_library", }, { - id: "upgrade_void_walker_1", - title: "Void Step: Walking Between", content: "The full technique of walking through the void itself — not just navigating the void but becoming, briefly, part of it — is not taught because it cannot be taught. It is discovered by void walkers who walk far enough between places that they stop being in any particular place at all. Your void walkers found this state and found that operating from it doubled their output, because the void does not impose the constraints that ordinary positions impose.", + id: "upgrade_void_walker_1", + sourceId: "void_walker_1", sourceType: "upgrade", - sourceId: "void_walker_1", - zoneId: "guild_library", + title: "Void Step: Walking Between", + zoneId: "guild_library", }, { - id: "upgrade_celestial_guard_1", - title: "Divine Ward: The Complete Shield", content: "The celestial blessing that creates a divine ward is the full version of the protection that lesser blessings approximate. Where a partial blessing provides defence against specific categories of harm, a divine ward provides defence against harm as a concept — not just against particular attacks but against the outcomes that attacks are intended to produce. Your guards received this blessing and their output doubled, because operating from behind a divine ward is simply different.", + id: "upgrade_celestial_guard_1", + sourceId: "celestial_guard_1", sourceType: "upgrade", - sourceId: "celestial_guard_1", - zoneId: "guild_library", + title: "Divine Ward: The Complete Shield", + zoneId: "guild_library", }, { - id: "upgrade_divine_champion_1", - title: "Champion's Oath: The Unbreakable Word", content: "An unbreakable oath is distinguished from a very strong intention by the fact that breaking it is simply not possible, which removes the category of decision-making that consumes energy in ordinary commitments. Your champions, having taken such an oath to the divine, operate with a focus that commitment-with-reservations cannot produce. Output doubled, and the champions themselves describe the experience of having no option to reconsider as 'clarifying.'", + id: "upgrade_divine_champion_1", + sourceId: "divine_champion_1", sourceType: "upgrade", - sourceId: "divine_champion_1", - zoneId: "guild_library", + title: "Champion's Oath: The Unbreakable Word", + zoneId: "guild_library", }, { - id: "upgrade_seraph_knight_1", - title: "Seraphic Wings: Flight and Consequence", content: "The wings that seraph knights grow from conviction become, at full development, wings that fly — not metaphorically but in the physical sense, carrying the knight over terrain that fighting without wings would require navigating through. The doubled effectiveness comes from the access that flight provides: angles of approach, positions of advantage, and the simple fact that things below you are in a different strategic relationship to you than things at the same level.", + id: "upgrade_seraph_knight_1", + sourceId: "seraph_knight_1", sourceType: "upgrade", - sourceId: "seraph_knight_1", - zoneId: "guild_library", + title: "Seraphic Wings: Flight and Consequence", + zoneId: "guild_library", }, { - id: "upgrade_abyss_diver_1", - title: "Pressure Adaptation: The Body Rebuilt", content: "Full adaptation to abyssal pressure involves a physical rebuilding that the divers undergo incrementally over years and that, when complete, means the diver's body no longer has a preference for surface conditions. They can function at depth or at surface with equal ease, which means they have doubled their operational range at the cost of becoming something that fits neither environment entirely. They have made peace with this.", + id: "upgrade_abyss_diver_1", + sourceId: "abyss_diver_1", sourceType: "upgrade", - sourceId: "abyss_diver_1", - zoneId: "guild_library", + title: "Pressure Adaptation: The Body Rebuilt", + zoneId: "guild_library", }, { - id: "upgrade_infernal_warden_1", - title: "Infernal Tempering: The Fire's Graduate", content: "Infernal tempering is the process of passing through hellfire enough times that the fire has nothing left to remove. What remains after this process is everything that the fire could not take — which, in a person, turns out to be the core of their effectiveness, freed from everything that was merely adjacent to it. Your wardens completed this process and their output doubled, because there is nothing left in them that does not contribute to what they do.", + id: "upgrade_infernal_warden_1", + sourceId: "infernal_warden_1", sourceType: "upgrade", - sourceId: "infernal_warden_1", - zoneId: "guild_library", + title: "Infernal Tempering: The Fire's Graduate", + zoneId: "guild_library", }, { - id: "upgrade_crystal_sage_1", - title: "Prismatic Mastery: The Complete Art", content: "Complete mastery of prismatic crystallomancy means that the crystal and the mage are no longer in a practitioner-tool relationship but in a collaborative one, where the crystal's own properties inform the mage's techniques and the mage's intentions inform the crystal's expression. Your sages achieved this collaboration and their output doubled, because collaboration with a crystal that has opinions about how to be used is more effective than direction of a crystal that doesn't.", + id: "upgrade_crystal_sage_1", + sourceId: "crystal_sage_1", sourceType: "upgrade", - sourceId: "crystal_sage_1", - zoneId: "guild_library", + title: "Prismatic Mastery: The Complete Art", + zoneId: "guild_library", }, { - id: "upgrade_void_sentinel_1", - title: "Void Resonance: The Perfect Attunement", content: "Perfect void resonance is the state in which the sentinel and the void are not in contact but in harmony — where the sentinel does not reach into the void but the void moves through the sentinel as its natural extension. Your sentinels achieved this state through a process they describe as 'a long argument that the void eventually won,' and their output doubled because a sentinel who is the void's extension perceives things that a sentinel who merely contacts the void does not.", + id: "upgrade_void_sentinel_1", + sourceId: "void_sentinel_1", sourceType: "upgrade", - sourceId: "void_sentinel_1", - zoneId: "guild_library", + title: "Void Resonance: The Perfect Attunement", + zoneId: "guild_library", }, { - id: "upgrade_eternal_champion_1", - title: "Eternal Oath: The Commitment Across All Time", content: "An oath that transcends time is not just a very strong commitment — it is an oath whose terms exist simultaneously in the past, present, and future, meaning the champion has already kept it in every context in which they will ever be tested. This produces a psychological and physical state that your guild's researchers describe as 'optimised for output in a way that removes the overhead of ongoing commitment.' The doubling of output is the overhead being converted to productivity.", + id: "upgrade_eternal_champion_1", + sourceId: "eternal_champion_1", sourceType: "upgrade", - sourceId: "eternal_champion_1", - zoneId: "guild_library", + title: "Eternal Oath: The Commitment Across All Time", + zoneId: "guild_library", }, { - id: "upgrade_aether_weaver_1", - title: "Aetheric Mastery: Fluency in the Invisible", content: "Complete aetheric mastery is the point at which working with aether becomes as natural as breathing — not something the weaver does but something the weaver is. Your weavers reached this point and their output doubled because fluency in any medium removes the effort that partial fluency expends on navigation, leaving the full capacity available for what the navigation was meant to reach.", + id: "upgrade_aether_weaver_1", + sourceId: "aether_weaver_1", sourceType: "upgrade", - sourceId: "aether_weaver_1", - zoneId: "guild_library", + title: "Aetheric Mastery: Fluency in the Invisible", + zoneId: "guild_library", }, { - id: "upgrade_titan_warrior_1", - title: "Titanic Fury: The Scale of Anger", content: "Titanic fury is not ordinary fury at a large scale — it is fury that operates at a scale where the effects are environmental rather than personal. When a titan warrior channels this fury, the immediate area reorganises around the fact that a titan is furious, which doubles their output not because they hit harder but because the opposition's operational capacity is compromised by proximity to something that is angry at that scale.", + id: "upgrade_titan_warrior_1", + sourceId: "titan_warrior_1", sourceType: "upgrade", - sourceId: "titan_warrior_1", - zoneId: "guild_library", + title: "Titanic Fury: The Scale of Anger", + zoneId: "guild_library", }, { - id: "upgrade_nexus_sage_1", - title: "Nexus Convergence: All Lines Through One", content: "The point at which all ley lines converge is also the point at which a nexus sage who can occupy it becomes the conduit for everything those ley lines carry. Your sages achieved this convergence through a technique that required them to simultaneously release and absorb every magical flow in their vicinity, which their description makes sound straightforward and which your chronicler suspects was not. Output doubled because a conduit for everything is more than a conduit for something.", + id: "upgrade_nexus_sage_1", + sourceId: "nexus_sage_1", sourceType: "upgrade", - sourceId: "nexus_sage_1", - zoneId: "guild_library", + title: "Nexus Convergence: All Lines Through One", + zoneId: "guild_library", }, { - id: "upgrade_cosmos_knight_1", - title: "Cosmic Tempering: The Heat of Stars", content: "The tempering process that cosmos knights undergo requires the temperature of a dying star, which is a constraint that limited the technique to people willing to undergo consciousness transfer and a body reconstruction that most people, when the process is explained to them, decline to pursue. Your knights pursued it, emerged from it with the tolerances of stellar material, and doubled their effectiveness because stellar tolerances handle threats that normal tolerances classify as unsurvivable.", + id: "upgrade_cosmos_knight_1", + sourceId: "cosmos_knight_1", sourceType: "upgrade", - sourceId: "cosmos_knight_1", - zoneId: "guild_library", + title: "Cosmic Tempering: The Heat of Stars", + zoneId: "guild_library", }, { - id: "upgrade_astral_sovereign_1", - title: "Sovereign Ascension: The Plane's Recognition", content: "Ascending to true sovereignty over the astral plane requires the plane to agree, which the astral plane communicates through the simple mechanism of behaving as if you are sovereign — of reorganising itself around your presence the way that a domain reorganises around its ruler. Your sovereigns achieved this ascension and their output doubled because the astral plane's resources are now their resources, and the astral plane has significant resources.", + id: "upgrade_astral_sovereign_1", + sourceId: "astral_sovereign_1", sourceType: "upgrade", - sourceId: "astral_sovereign_1", - zoneId: "guild_library", + title: "Sovereign Ascension: The Plane's Recognition", + zoneId: "guild_library", }, { - id: "upgrade_primordial_mage_1", - title: "Primordial Awakening: The Heritage Released", content: "The primordial heritage that your mages carry had been dormant in the way that power waits to be needed — present but not active, available but not accessed. The awakening technique your guild developed with the mages' cooperation released that heritage completely, doubling their output by adding to their conscious practice everything that had been operating below it. They describe the awakening as feeling like 'remembering something you forgot you knew.'", + id: "upgrade_primordial_mage_1", + sourceId: "primordial_mage_1", sourceType: "upgrade", - sourceId: "primordial_mage_1", - zoneId: "guild_library", + title: "Primordial Awakening: The Heritage Released", + zoneId: "guild_library", }, { - id: "upgrade_reality_warden_1", - title: "Reality Binding: The Structure and the Guardian", content: "Binding to the structure of reality — not to a location in it but to its underlying rules — makes the warden the same kind of thing as the rules they enforce. Where an ordinary fighter pushes against threats, a reality-bound warden is what threats push against, and what threats push against is reality itself. Output doubled because the warden's capacity is no longer their own but the capacity of what they are bound to, which is everything.", + id: "upgrade_reality_warden_1", + sourceId: "reality_warden_1", sourceType: "upgrade", - sourceId: "reality_warden_1", - zoneId: "guild_library", + title: "Reality Binding: The Structure and the Guardian", + zoneId: "guild_library", }, { - id: "upgrade_infinity_ranger_1", - title: "Infinite Aim: The Arrow That Always Lands", content: "Infinite aim is not perfect aim — it is aim that operates through infinity such that the arrow's path is not through space but through possibility, landing not at the target in this moment but at the target through every moment simultaneously. Your rangers achieved this and their output doubled because an arrow that travels through infinity cannot miss: it simply arrives when all the possible versions of arriving collapse into the one that matters.", + id: "upgrade_infinity_ranger_1", + sourceId: "infinity_ranger_1", sourceType: "upgrade", - sourceId: "infinity_ranger_1", - zoneId: "guild_library", + title: "Infinite Aim: The Arrow That Always Lands", + zoneId: "guild_library", }, { - id: "upgrade_oblivion_paladin_1", - title: "Oblivion Consecration: The Void's Own Blessing", content: "Consecration by the void between all things is the paladin equivalent of divine blessing from the space where divinity does not apply — a consecration from the absence of consecration, which is either paradoxical or perfectly consistent depending on the metaphysical framework. Your paladins were consecrated in this manner and their output doubled because they now carry the authority of the space that no authority can claim, which turns out to be considerable.", + id: "upgrade_oblivion_paladin_1", + sourceId: "oblivion_paladin_1", sourceType: "upgrade", - sourceId: "oblivion_paladin_1", - zoneId: "guild_library", + title: "Oblivion Consecration: The Void's Own Blessing", + zoneId: "guild_library", }, { - id: "upgrade_transcendent_rogue_1", - title: "Transcendent Shadow: The Space Between States", content: "A rogue who has become one with the space between states does not merely move unseen — they move between the moments in which observation could occur. Their effectiveness doubled not because they are better at existing in a place but because they have become better at existing in the not-place that transitions create. Your chronicler has asked the rogues to explain this more clearly. They have, politely but firmly, declined.", + id: "upgrade_transcendent_rogue_1", + sourceId: "transcendent_rogue_1", sourceType: "upgrade", - sourceId: "transcendent_rogue_1", - zoneId: "guild_library", + title: "Transcendent Shadow: The Space Between States", + zoneId: "guild_library", }, { - id: "upgrade_omniversal_champion_1", - title: "Omniversal Dominion: Victory in Every Version", content: "Dominion over all versions of all universes means that every possible version of your guild's omniversal champions has already won in their respective universe, and the champion you employ is simply the one from the universe where winning led here. Their output doubled because they carry the combined experience of winning in every possible variation of the campaign — they have done this before, in every possible way it could be done, and they remember.", + id: "upgrade_omniversal_champion_1", + sourceId: "omniversal_champion_1", sourceType: "upgrade", - sourceId: "omniversal_champion_1", - zoneId: "guild_library", + title: "Omniversal Dominion: Victory in Every Version", + zoneId: "guild_library", }, // ── Prestige Archive — Runestone Upgrades ───────────────────────────────── { - id: "prestige_income_1", - title: "Runestone Blessing I: The First Awakening", content: "The first runestone your guild used as more than currency awoke something that had been dormant in the guild's operations — a potential that the runestone's resonance activated rather than created. Scholars of runestone lore describe this as the guild 'becoming aware of itself,' which your chronicler finds both accurate and slightly unsettling. All production increased by a quarter from this first awakening alone.", + id: "prestige_income_1", + sourceId: "income_1", sourceType: "prestige", - sourceId: "income_1", - zoneId: "prestige_archive", + title: "Runestone Blessing I: The First Awakening", + zoneId: "prestige_archive", }, { - id: "prestige_income_2", - title: "Runestone Blessing II: Deeper Resonance", content: "The second runestone blessing reaches deeper into the guild's accumulated capability and draws up things that the first blessing could not reach — layers of potential that require the first awakening before they can respond. Runestone scholars use the metaphor of roots and deeper roots, and note that the second layer is always richer than the first, because it has been there longer and undisturbed.", + id: "prestige_income_2", + sourceId: "income_2", sourceType: "prestige", - sourceId: "income_2", - zoneId: "prestige_archive", + title: "Runestone Blessing II: Deeper Resonance", + zoneId: "prestige_archive", }, { - id: "prestige_income_3", - title: "Runestone Blessing III: The Runes Sing", content: "There is a point in runestone attunement at which the relationship between the guild and its runestones stops being an activation and becomes a conversation. The runes do not merely respond to the guild's investment; they express accumulated wisdom of their own — lessons from the guild's previous runs, preserved in the runestones and fed back into the current one. Production doubled, and the guild's veterans report that they can sometimes hear it.", + id: "prestige_income_3", + sourceId: "income_3", sourceType: "prestige", - sourceId: "income_3", - zoneId: "prestige_archive", + title: "Runestone Blessing III: The Runes Sing", + zoneId: "prestige_archive", }, { - id: "prestige_income_4", - title: "Runic Surge I: The Energy Rises", content: "The surge that runestone energy produces when it moves through an established guild is not a steady increase but a genuine surge — a wave of productive force that is qualitatively different from the steady resonance of the blessings. Scholars distinguish between runestone resonance and runestone surge the way they distinguish between a river and a flood: both water, very different experiences. Production tripled.", + id: "prestige_income_4", + sourceId: "income_4", sourceType: "prestige", - sourceId: "income_4", - zoneId: "prestige_archive", + title: "Runic Surge I: The Energy Rises", + zoneId: "prestige_archive", }, { - id: "prestige_income_5", - title: "Runic Surge II: The Impossible Intensified", content: "The second surge pushes through limits that the first surge merely approached. Runestone scholars who study the progression report that each surge exceeds what the previous state suggested was possible, which is consistent with the fundamental property of runestone power: the limits it pushes are not physical but assumed, and assumptions are easier to exceed than physicality.", + id: "prestige_income_5", + sourceId: "income_5", sourceType: "prestige", - sourceId: "income_5", - zoneId: "prestige_archive", + title: "Runic Surge II: The Impossible Intensified", + zoneId: "prestige_archive", }, { - id: "prestige_income_6", - title: "Runic Surge III: The Overwhelming Tide", content: "The third surge is categorically different from the first two — not an intensification but a transformation. Where the previous surges accelerated what the guild was doing, this one changes the context in which the guild operates. The guild's operations did not speed up: they became something that operates at a speed that the previous context could not contain. Production increased tenfold.", + id: "prestige_income_6", + sourceId: "income_6", sourceType: "prestige", - sourceId: "income_6", - zoneId: "prestige_archive", + title: "Runic Surge III: The Overwhelming Tide", + zoneId: "prestige_archive", }, { - id: "prestige_income_7", - title: "Ancient Inscription I: The First Secret", content: "The ancient inscriptions on runestones older than the guild's history contain techniques and knowledge that were not passed down through any other medium — things that the people who made those runestones chose to encode in stone rather than teach, either because they could not find worthy students or because they believed the worthy student would find the stone when ready. Your guild was ready.", + id: "prestige_income_7", + sourceId: "income_7", sourceType: "prestige", - sourceId: "income_7", - zoneId: "prestige_archive", + title: "Ancient Inscription I: The First Secret", + zoneId: "prestige_archive", }, { - id: "prestige_income_8", - title: "Ancient Inscription II: Primordial Secrets", content: "The deeper inscriptions access a layer of runestone lore that predates the inscriptions' authors — knowledge that those ancient scholars were themselves translating from something older, in a chain of transmission that goes back further than historical records. What your guild accesses at this depth is not inherited knowledge but primordial power that the inscriptions merely point at.", + id: "prestige_income_8", + sourceId: "income_8", sourceType: "prestige", - sourceId: "income_8", - zoneId: "prestige_archive", + title: "Ancient Inscription II: Primordial Secrets", + zoneId: "prestige_archive", }, { - id: "prestige_income_9", - title: "Ancient Inscription III: The Full Blazing", content: "The complete inscription, fully deciphered and fully applied, blazes with power that the partial inscriptions suggested but could not deliver. Your scholars who worked on the decipherment describe the experience of reading the final lines as 'like a text that was waiting for someone to finish it, and we were the ones it was waiting for.' Production increased one hundredfold, and the inscription has not been dimmer since.", + id: "prestige_income_9", + sourceId: "income_9", sourceType: "prestige", - sourceId: "income_9", - zoneId: "prestige_archive", + title: "Ancient Inscription III: The Full Blazing", + zoneId: "prestige_archive", }, { - id: "prestige_income_10", - title: "Eternal Rune I: Before Memory", content: "The oldest runes were carved before there were people to remember who carved them. They predate the traditions that studied them, the scholars who decoded them, and the guilds that used them. What they contain is power that has been sitting in stone since before the concept of power had a name, and accessing it requires the guild to be something that the stone recognises as a worthy recipient. Your guild was recognised.", + id: "prestige_income_10", + sourceId: "income_10", sourceType: "prestige", - sourceId: "income_10", - zoneId: "prestige_archive", + title: "Eternal Rune I: Before Memory", + zoneId: "prestige_archive", }, { - id: "prestige_income_11", - title: "Eternal Rune II: The Heartbeat of Creation", content: "The eternal runes resonate with creation itself — not a metaphor, but a direct sympathetic vibration between the rune and the fundamental force that made everything exist. When a guild achieves this resonance, it becomes part of creation's ongoing production of itself, and creation is very productive. Your chronicler notes that this is the point at which runestone scholarship requires a vocabulary that runestone scholars are still developing.", + id: "prestige_income_11", + sourceId: "income_11", sourceType: "prestige", - sourceId: "income_11", - zoneId: "prestige_archive", + title: "Eternal Rune II: The Heartbeat of Creation", + zoneId: "prestige_archive", }, { - id: "prestige_click_power_1", - title: "Runic Strike I: The Personal Infusion", content: "Infusing personal strikes with runestone energy is the first and most direct application of runestone power — bypassing the guild's infrastructure entirely and adding runestone force directly to the individual action. The doubled click power comes from the runestone not merely amplifying what was already there but adding a parallel force that acts alongside the original, as a second hand on the same task.", + id: "prestige_click_power_1", + sourceId: "click_power_1", sourceType: "prestige", - sourceId: "click_power_1", - zoneId: "prestige_archive", + title: "Runic Strike I: The Personal Infusion", + zoneId: "prestige_archive", }, { - id: "prestige_click_power_2", - title: "Runic Strike II: Compounded Force", content: "The second runic strike compounds on the first — not adding to what was there but multiplying it, the way interest compounds on principal. The crackling that fighters report on their strikes is the sound of runestone energy interacting with the forces already present, and the fivefold increase in click power is what you get when multiple runestone-sourced forces compound in the same moment.", + id: "prestige_click_power_2", + sourceId: "click_power_2", sourceType: "prestige", - sourceId: "click_power_2", - zoneId: "prestige_archive", + title: "Runic Strike II: Compounded Force", + zoneId: "prestige_archive", }, { - id: "prestige_click_power_3", - title: "Runic Strike III: The Weight of All Lives", content: "Each prestige run adds its weight to the runestones that carry it through to the next. The third runic strike channels not just current runestone energy but the accumulated weight of everything the guild has been through — every run, every defeat, every victory — into the moment of contact. The click power multiplied by twenty because the past is heavy and your guild carries all of it.", + id: "prestige_click_power_3", + sourceId: "click_power_3", sourceType: "prestige", - sourceId: "click_power_3", - zoneId: "prestige_archive", + title: "Runic Strike III: The Weight of All Lives", + zoneId: "prestige_archive", }, { - id: "prestige_click_power_4", - title: "World-Breaker Click: The Force of Empires", content: "The final runic strike upgrade makes every click carry the force of a falling empire — not metaphorically, but because empires fall through the accumulation of forces over time, and runestones carry the accumulated force of the guild's accumulated time. A single click now channels all of that accumulation into a single moment of contact. Your fighters have been advised to choose their targets thoughtfully.", + id: "prestige_click_power_4", + sourceId: "click_power_4", sourceType: "prestige", - sourceId: "click_power_4", - zoneId: "prestige_archive", + title: "World-Breaker Click: The Force of Empires", + zoneId: "prestige_archive", }, { - id: "prestige_essence_1", - title: "Essence Attunement I: The Runestone Channel", content: "Runestone resonance and essence flow operate on compatible frequencies — a fact that essence scholars knew theoretically and that your guild proved practically by achieving an attunement that doubled essence production. The runestones do not produce essence; they amplify the guild's sensitivity to the essence that was already flowing through, allowing more of it to be caught.", + id: "prestige_essence_1", + sourceId: "essence_1", sourceType: "prestige", - sourceId: "essence_1", - zoneId: "prestige_archive", + title: "Essence Attunement I: The Runestone Channel", + zoneId: "prestige_archive", }, { - id: "prestige_essence_2", - title: "Essence Attunement II: The Invisible Sources", content: "Deep attunement reveals essence sources that standard attunement cannot detect — not because they are hidden but because standard attunement lacks the resolution to distinguish them from background noise. At the depth your guild reached, the 'noise' turns out to be rich with essence that was there all along, and production quintupled by paying attention to what had been previously written off.", + id: "prestige_essence_2", + sourceId: "essence_2", sourceType: "prestige", - sourceId: "essence_2", - zoneId: "prestige_archive", + title: "Essence Attunement II: The Invisible Sources", + zoneId: "prestige_archive", }, { - id: "prestige_essence_3", - title: "Essence Attunement III: Natural as Breathing", content: "The third attunement reaches the point where gathering essence is not an act but a state — where the guild does not reach for essence but exists in a relationship with it that makes essence flow as naturally as breathing. Production increased twentyfold because the effort of gathering was converted to the ease of receiving, and the guild's capacity for receiving is very large.", + id: "prestige_essence_3", + sourceId: "essence_3", sourceType: "prestige", - sourceId: "essence_3", - zoneId: "prestige_archive", + title: "Essence Attunement III: Natural as Breathing", + zoneId: "prestige_archive", }, { - id: "prestige_essence_4", - title: "Essence Attunement IV: Every Corner of Every World", content: "The fourth attunement extends across every corner of every world — not every world the guild has visited but every world that exists, because essence does not respect cosmological borders. The torrent of essence that flows into a guild at this attunement level comes from everywhere simultaneously, and the hundredfold production increase is the result of tapping a supply that is, for practical purposes, infinite.", + id: "prestige_essence_4", + sourceId: "essence_4", sourceType: "prestige", - sourceId: "essence_4", - zoneId: "prestige_archive", + title: "Essence Attunement IV: Every Corner of Every World", + zoneId: "prestige_archive", }, { - id: "prestige_crystal_1", - title: "Crystal Resonance I: The First Vibration", content: "Runestones and crystals vibrate at related frequencies — a property that crystal harmonics scholars had catalogued but not exploited until your guild demonstrated the practical application. The first resonance alignment doubled crystal rewards by converting previously wasted sympathetic vibration into productive amplification: the crystals were already singing with the runestones; your guild learned to listen, and then to harvest the song.", + id: "prestige_crystal_1", + sourceId: "crystal_1", sourceType: "prestige", - sourceId: "crystal_1", - zoneId: "prestige_archive", + title: "Crystal Resonance I: The First Vibration", + zoneId: "prestige_archive", }, { - id: "prestige_crystal_2", - title: "Crystal Resonance II: The Barriers Shatter", content: "The deeper resonance that the second crystal upgrade achieves does not merely harmonise with existing crystal barriers — it uses the harmonics to shatter them, turning what were previously limits on crystal reward into amplifiers. Production quintupled because the barriers that were in place turned out to be temporary structures that the right frequency dissolves.", + id: "prestige_crystal_2", + sourceId: "crystal_2", sourceType: "prestige", - sourceId: "crystal_2", - zoneId: "prestige_archive", + title: "Crystal Resonance II: The Barriers Shatter", + zoneId: "prestige_archive", }, { - id: "prestige_crystal_3", - title: "Crystal Resonance III: Reality Crystallised", content: "The third resonance reaches the point where the guild's crystal attunement causes reality itself to crystallise around its needs — where the conditions for crystal generation arise in response to the guild's resonance rather than being found by it. Production increased twentyfivefold not because the guild got better at finding crystals but because crystals got better at finding the guild.", + id: "prestige_crystal_3", + sourceId: "crystal_3", sourceType: "prestige", - sourceId: "crystal_3", - zoneId: "prestige_archive", + title: "Crystal Resonance III: Reality Crystallised", + zoneId: "prestige_archive", }, { - id: "prestige_auto_prestige", - title: "Autonomous Ascension: The Self-Completing Cycle", content: "The guild that has learned to ascend autonomously has internalised the cycle of prestige so completely that the threshold and the crossing of it have become continuous — the guild does not stop to ascend; it ascends as an ongoing property of its existence. The knowledge encoded in this upgrade is less a technique than a recognition: that the guild is no longer the thing that crosses the threshold but the thing that the threshold is for.", + id: "prestige_auto_prestige", + sourceId: "auto_prestige", sourceType: "prestige", - sourceId: "auto_prestige", - zoneId: "prestige_archive", + title: "Autonomous Ascension: The Self-Completing Cycle", + zoneId: "prestige_archive", }, { - id: "prestige_runestone_gain_1", - title: "Runic Legacy: The Accumulating Attunement", content: "A legacy is not a gift — it is an accumulation that one generation passes to the next, compounding with each transfer. Your guild's runic legacy is the growing attunement that each prestige run develops and that the runestones carry forward, meaning each new run begins more attuned than the last. The twenty-five percent increase in runestone gain is the mathematical expression of an attunement that grows because it has been growing.", + id: "prestige_runestone_gain_1", + sourceId: "runestone_gain_1", sourceType: "prestige", - sourceId: "runestone_gain_1", - zoneId: "prestige_archive", + title: "Runic Legacy: The Accumulating Attunement", + zoneId: "prestige_archive", }, { - id: "prestige_runestone_gain_2", - title: "Eternal Legacy: Beyond Individual Lives", content: "The eternal legacy transcends what any single lifetime could accumulate. Your guild's legend is no longer contained in its current run — it exists in the runestones, in the history that the runestones encode, in the accumulated experience of every version of the guild that has ever been. Each prestige earns fifty percent more runestones because the guild doing the earning is, in some meaningful sense, all of the guilds that have ever earned them.", + id: "prestige_runestone_gain_2", + sourceId: "runestone_gain_2", sourceType: "prestige", - sourceId: "runestone_gain_2", - zoneId: "prestige_archive", + title: "Eternal Legacy: Beyond Individual Lives", + zoneId: "prestige_archive", }, // ── World Atlas — Zones ──────────────────────────────────────────────────── { - id: "zone_verdant_vale", - title: "Verdant Vale: Where Every Journey Begins", content: "The Verdant Vale is, by common consensus, the most reassuringly ordinary place a guild can operate in. The fields are green, the trade roads are functional, the local wildlife is merely dangerous rather than cosmologically threatening, and the problems that arise are the kind that can be solved by people who have only recently decided to solve problems professionally. Your chronicler notes that every guild that has ever reached the void sanctum started here, walking the same roads, picking the same fights, discovering that they were capable of more than they had expected.", + id: "zone_verdant_vale", + sourceId: "verdant_vale", sourceType: "zone", - sourceId: "verdant_vale", - zoneId: "world_atlas", + title: "Verdant Vale: Where Every Journey Begins", + zoneId: "world_atlas", }, { - id: "zone_shattered_ruins", - title: "Shattered Ruins: The Memory of Collapse", content: "The Shattered Ruins are the remains of something large enough that its collapse created an entire geographic region. Scholars debate what was here before — a city, an empire, a civilisation that operated at a scale requiring terminology that has since been lost. What is agreed is that it ended badly and that the ruins retain something of what it was: a gravitational weight, a sense that the ground you walk on was once the floor of something that mattered. The monsters that have moved in seem to feel this too, and to be improved by proximity to it.", + id: "zone_shattered_ruins", + sourceId: "shattered_ruins", sourceType: "zone", - sourceId: "shattered_ruins", - zoneId: "world_atlas", + title: "Shattered Ruins: The Memory of Collapse", + zoneId: "world_atlas", }, { - id: "zone_frozen_peaks", - title: "Frozen Peaks: The Patience of Cold", content: "The Frozen Peaks have been cold for longer than they have had names, and the cold has developed opinions about the things that try to exist within it. Not malice — cold is not malicious, merely consistent — but a kind of pressure that tests everything against the question of whether it deserves to be here. The creatures that have survived this testing are extraordinary. Your guild has joined them in this zone, which the cold has noted and not objected to, which your fighters interpret as a provisional welcome.", + id: "zone_frozen_peaks", + sourceId: "frozen_peaks", sourceType: "zone", - sourceId: "frozen_peaks", - zoneId: "world_atlas", + title: "Frozen Peaks: The Patience of Cold", + zoneId: "world_atlas", }, { - id: "zone_shadow_marshes", - title: "Shadow Marshes: The Territory of Dark", content: "The Shadow Marshes produce darkness as a resource rather than an absence. The dark here is thick, purposeful, and territorial — it fills spaces that light has not claimed and actively resists attempts to illuminate it. The creatures that live in this relationship with darkness have become part of it: not hidden in shadow but made of it, expressing themselves through an absence that has its own textures and grammar. Your guild learned to move through it rather than against it, which the darkness found acceptable.", + id: "zone_shadow_marshes", + sourceId: "shadow_marshes", sourceType: "zone", - sourceId: "shadow_marshes", - zoneId: "world_atlas", + title: "Shadow Marshes: The Territory of Dark", + zoneId: "world_atlas", }, { - id: "zone_volcanic_depths", - title: "Volcanic Depths: Where Creation Continues", content: "The Volcanic Depths are not a place where things have ended but a place where things are still beginning. The geology here is active, the heat is a feature rather than a hazard for those adapted to it, and the creatures that live here exist in an ongoing conversation with the process of creation that the surface world no longer participates in. Your guild arrived as visitors from a finished world and spent its time here learning from a world that has never considered itself complete.", + id: "zone_volcanic_depths", + sourceId: "volcanic_depths", sourceType: "zone", - sourceId: "volcanic_depths", - zoneId: "world_atlas", + title: "Volcanic Depths: Where Creation Continues", + zoneId: "world_atlas", }, { - id: "zone_astral_void", - title: "Astral Void: Space That Thinks", content: "The Astral Void is not empty — it is filled with something that does not register as matter but that has presence, will, and a tendency to affect the things that pass through it. Scholars call it astral medium and treat it as a substance; navigators call it the drift and treat it as a current; the creatures that live here do not call it anything because they have no concept of it as something separate from themselves. Your guild passed through this zone and arrived on the other side changed in ways it is still cataloguing.", + id: "zone_astral_void", + sourceId: "astral_void", sourceType: "zone", - sourceId: "astral_void", - zoneId: "world_atlas", + title: "Astral Void: Space That Thinks", + zoneId: "world_atlas", }, { - id: "zone_abyssal_trench", - title: "Abyssal Trench: The Weight of Everything Above", content: "At the bottom of the Abyssal Trench, the weight of everything above becomes a physical fact rather than a metaphor. The creatures that live here have been shaped by pressure into forms that surface biology cannot produce and that surface biology would find incomprehensible. The darkness at these depths is not merely dark but is actively, specifically dark in ways that require new vocabulary to describe. Your guild went down and came back, which the trench records without comment in its long history of things that tried.", + id: "zone_abyssal_trench", + sourceId: "abyssal_trench", sourceType: "zone", - sourceId: "abyssal_trench", - zoneId: "world_atlas", + title: "Abyssal Trench: The Weight of Everything Above", + zoneId: "world_atlas", }, { - id: "zone_celestial_reaches", - title: "Celestial Reaches: The Upper Country", content: "The Celestial Reaches are what the sky becomes if you go high enough that it stops being sky and starts being something else — a domain governed by rules that approximate the rules below but with a fundamental orientation toward light, order, and the long view. The beings here have been here for long enough that they think in centuries and act in decades, which makes them patient allies and patient enemies in equal measure. Your guild's arrival was noted, evaluated, and eventually endorsed.", + id: "zone_celestial_reaches", + sourceId: "celestial_reaches", sourceType: "zone", - sourceId: "celestial_reaches", - zoneId: "world_atlas", + title: "Celestial Reaches: The Upper Country", + zoneId: "world_atlas", }, { - id: "zone_cosmic_maelstrom", - title: "Cosmic Maelstrom: Where Forces Meet", content: "The Cosmic Maelstrom is the point where several cosmic forces intersect without resolving — a permanent conflict in the fabric of things that generates energy, matter, and creatures in the spaces where the forces grind against each other. It is unstable in the way that productive things often are: not random but continuously generative, producing new forms as fast as the conflict consumes old ones. Your guild fought in the eye of this and found that the instability, properly navigated, is a kind of power.", + id: "zone_cosmic_maelstrom", + sourceId: "cosmic_maelstrom", sourceType: "zone", - sourceId: "cosmic_maelstrom", - zoneId: "world_atlas", + title: "Cosmic Maelstrom: Where Forces Meet", + zoneId: "world_atlas", }, { - id: "zone_crystalline_spire", - title: "Crystalline Spire: The Tower of Perfect Thought", content: "The Crystalline Spire is a structure so large that calling it a structure requires abandoning most of what the word usually means. It was grown rather than built, emerged rather than was designed, and it continues to grow according to principles that its inhabitants study but do not fully govern. Inside it, thinking is different — cleaner, more precise, more aware of its own processes — because the crystalline medium that fills the Spire amplifies cognition the way a lens amplifies light. Your guild thought more clearly here than it had ever thought before.", + id: "zone_crystalline_spire", + sourceId: "crystalline_spire", sourceType: "zone", - sourceId: "crystalline_spire", - zoneId: "world_atlas", + title: "Crystalline Spire: The Tower of Perfect Thought", + zoneId: "world_atlas", }, { - id: "zone_eternal_throne", - title: "Eternal Throne: The End of Everything's Authority", content: "The Eternal Throne is not a room or a palace or even a location in the conventional sense — it is the place where authority terminates, the point toward which all questions of 'who decides?' converge. Everything in the universe that has ever exercised authority has done so in some sense on behalf of or in reference to the Eternal Throne, whether it knew this or not. Your guild reached it, which means your guild has been in the presence of the thing that makes all authority possible. This has implications that your chronicler is still working through.", + id: "zone_eternal_throne", + sourceId: "eternal_throne", sourceType: "zone", - sourceId: "eternal_throne", - zoneId: "world_atlas", + title: "Eternal Throne: The End of Everything's Authority", + zoneId: "world_atlas", }, { - id: "zone_infernal_court", - title: "Infernal Court: The Bureaucracy of Consequence", content: "The Infernal Court is not merely a place of punishment or power — it is an administrative institution that has been processing consequence for longer than consequence has had a name. The demons here are functionaries as much as they are monsters: they file, they adjudicate, they enforce the terms of agreements that the parties to those agreements would prefer to have forgotten. Your guild entered their domain, engaged their inhabitants on their own terms, and left with a grudging respect from entities that do not give grudging respect easily.", + id: "zone_infernal_court", + sourceId: "infernal_court", sourceType: "zone", - sourceId: "infernal_court", - zoneId: "world_atlas", + title: "Infernal Court: The Bureaucracy of Consequence", + zoneId: "world_atlas", }, { - id: "zone_infinite_expanse", - title: "Infinite Expanse: The Space With No Boundary", content: "The Infinite Expanse is infinite in the literal sense — there is no edge, no wall, no point at which it becomes something else. Cartographers who have attempted to map it have produced documents that grow as fast as they are written, which they describe as professionally frustrating and philosophically interesting. The creatures that live in it have adapted to infinity by having no concept of distance: everything is equally here. Your guild navigated it by picking a direction and walking until the direction produced results, which took longer than expected.", + id: "zone_infinite_expanse", + sourceId: "infinite_expanse", sourceType: "zone", - sourceId: "infinite_expanse", - zoneId: "world_atlas", + title: "Infinite Expanse: The Space With No Boundary", + zoneId: "world_atlas", }, { - id: "zone_primeval_sanctum", - title: "Primeval Sanctum: Before the Names of Things", content: "The Primeval Sanctum is older than the names of the things within it. The creatures here predate their own classifications; the plants predate the concept of plants; the ground itself precedes the geological vocabulary that would be used to describe it. This is not a ruin of something that was once more organised — it is a place that was always like this, that has persisted in its original state through every era that has produced and then abandoned systems for understanding it. Your guild moved through it carefully, aware that it was in something that had never needed them.", + id: "zone_primeval_sanctum", + sourceId: "primeval_sanctum", sourceType: "zone", - sourceId: "primeval_sanctum", - zoneId: "world_atlas", + title: "Primeval Sanctum: Before the Names of Things", + zoneId: "world_atlas", }, { - id: "zone_primordial_chaos", - title: "Primordial Chaos: Before Order Was Decided", content: "Primordial Chaos is not disorder — it is the state before the decision between order and disorder was made. Here, things are simultaneously all of their possible configurations, not randomly but with a kind of pre-intentional completeness that order later simplified into one. The creatures that exist here are correspondingly multiple: not unstable but genuinely, simultaneously all of what they could be. Your guild entered, operated within the logic of the place rather than trying to impose external logic, and emerged with an understanding of what came before beginnings.", + id: "zone_primordial_chaos", + sourceId: "primordial_chaos", sourceType: "zone", - sourceId: "primordial_chaos", - zoneId: "world_atlas", + title: "Primordial Chaos: Before Order Was Decided", + zoneId: "world_atlas", }, { - id: "zone_reality_forge", - title: "Reality Forge: Where the Rules Are Made", content: "The Reality Forge is the place where the rules that govern existence were established and where they continue to be maintained. It is not a factory in any industrial sense — the rules do not require manufacturing, only formulation — but it operates with the purposefulness of a place that knows its function and has been performing it since function was a concept. Your guild fought through the forge and, in doing so, demonstrated that the rules it maintains permit a guild like yours to exist and to win. The forge acknowledged this by not preventing it.", + id: "zone_reality_forge", + sourceId: "reality_forge", sourceType: "zone", - sourceId: "reality_forge", - zoneId: "world_atlas", + title: "Reality Forge: Where the Rules Are Made", + zoneId: "world_atlas", }, { - id: "zone_the_absolute", - title: "The Absolute: The Last Defined Place", content: "The Absolute is the last place that can still be described as a place — the final zone before existence becomes something that words cannot accurately point at. It is 'absolute' in the sense of being the endpoint of every series, the limit of every sequence, the point at which further progress requires abandoning the framework that made all previous progress possible. Your guild reached it and then, remarkably, passed through it, into zones that exist beyond the reach of names for things that have gone beyond the last named thing.", + id: "zone_the_absolute", + sourceId: "the_absolute", sourceType: "zone", - sourceId: "the_absolute", - zoneId: "world_atlas", + title: "The Absolute: The Last Defined Place", + zoneId: "world_atlas", }, { - id: "zone_void_sanctum", - title: "Void Sanctum: The Territory of Nothing", content: "The Void Sanctum is the place where absence has organised itself into something. The void here is not merely the absence of things but a domain with its own geography, inhabitants, and governing logic — a logic that is the inverse of the logic that governs everywhere else. Scholars have noted that the Void Sanctum's existence is itself a paradox: a place built from the material of no-place, a sanctuary defined by the absence of everything that sanctuaries are usually defined by. Your guild entered and found that absence, when sufficient of it gathers in one location, becomes its own kind of presence.", + id: "zone_void_sanctum", + sourceId: "void_sanctum", sourceType: "zone", - sourceId: "void_sanctum", - zoneId: "world_atlas", + title: "Void Sanctum: The Territory of Nothing", + zoneId: "world_atlas", }, // ── Verdant Vale — Explorations ─────────────────────────────────────────── { - id: "explore_verdant_meadow", - title: "The Verdant Meadow: Observations in Green", content: "The meadow appears, at first glance, to be entirely ordinary — which is precisely what makes it suspicious. Your naturalists spent three days cataloguing the flora and found sixteen species that should not coexist in the same soil type, growing together in cheerful defiance of botanical law. The working theory is that something under the meadow wants them there, and has for a very long time.", + id: "explore_verdant_meadow", + sourceId: "verdant_meadow", sourceType: "exploration", - sourceId: "verdant_meadow", - zoneId: "verdant_vale", + title: "The Verdant Meadow: Observations in Green", + zoneId: "verdant_vale", }, { - id: "explore_whispering_forest", - title: "The Whispering Forest: An Acoustic Survey", content: "The forest does not whisper in any language your scholars have been able to identify, though it has been recorded extensively and the recordings compared against every known tongue, living and dead. The most unsettling finding was not the content but the structure: the whispers follow grammatical rules. Something is speaking. It simply hasn't chosen to be understood yet.", + id: "explore_whispering_forest", + sourceId: "whispering_forest", sourceType: "exploration", - sourceId: "whispering_forest", - zoneId: "verdant_vale", + title: "The Whispering Forest: An Acoustic Survey", + zoneId: "verdant_vale", }, { - id: "explore_ancient_grove", - title: "The Ancient Grove: Roots and Reckoning", content: "The oldest tree in the grove has been dated at eleven thousand years — which predates the last glacial retreat by a considerable margin and makes it, strictly speaking, impossible. Your arborists have measured it three times and arrived at the same answer each time. The tree itself offers no comment, though it has been observed to lean very slightly toward the guild hall on clear days.", + id: "explore_ancient_grove", + sourceId: "ancient_grove", sourceType: "exploration", - sourceId: "ancient_grove", - zoneId: "verdant_vale", + title: "The Ancient Grove: Roots and Reckoning", + zoneId: "verdant_vale", }, { - id: "explore_forbidden_glen", - title: "The Forbidden Glen: A Study in Prohibitions", content: "No one alive can say who first forbade entry to the glen, but the prohibition is old enough to appear in records that predate the current kingdom by four centuries. Your adventurers found it pleasant: wildflowers, a brook, birdsong, and a stone marker inscribed with a warning so weathered as to be illegible. Whatever was once forbidden has either resolved itself or learned to wait.", + id: "explore_forbidden_glen", + sourceId: "forbidden_glen", sourceType: "exploration", - sourceId: "forbidden_glen", - zoneId: "verdant_vale", + title: "The Forbidden Glen: A Study in Prohibitions", + zoneId: "verdant_vale", }, // ── Shattered Ruins — Explorations ──────────────────────────────────────── { - id: "explore_collapsed_outpost", - title: "The Collapsed Outpost: Last Dispatches", content: "The outpost's collapse was catastrophic and apparently instantaneous — the soldiers inside had no time to evacuate, and were found preserved beneath the rubble in the precise positions they had occupied at the moment of failure. The duty log's last entry, written mid-sentence, described everything as normal. Your engineers spent a week studying the collapse and could not determine a cause.", + id: "explore_collapsed_outpost", + sourceId: "collapsed_outpost", sourceType: "exploration", - sourceId: "collapsed_outpost", - zoneId: "shattered_ruins", + title: "The Collapsed Outpost: Last Dispatches", + zoneId: "shattered_ruins", }, { - id: "explore_cursed_lake", - title: "The Cursed Lake: Waters of Ill Report", content: "The lake's curse is specific and inconvenient: anything submerged in it emerges perfectly clean but faintly luminescent for approximately two weeks. Your scholars have been unable to determine whether this is a curse in the traditional sense or simply a property of the water, and the distinction matters more to them than to anyone who has had to explain to a merchant why their entire shipment of wool is glowing.", + id: "explore_cursed_lake", + sourceId: "cursed_lake", sourceType: "exploration", - sourceId: "cursed_lake", - zoneId: "shattered_ruins", + title: "The Cursed Lake: Waters of Ill Report", + zoneId: "shattered_ruins", }, { - id: "explore_runic_archive", - title: "The Runic Archive: Cataloguing the Uncatalogued", content: "The archive held seventeen thousand distinct runes, of which your scholars had previously documented forty-three. The remaining sixteen thousand nine hundred and fifty-seven represent either a vastly more complex runic tradition than any currently known, or evidence that the archive's creators used runes the way a composer uses notes — individually simple, meaningful only in combination. Your scholars have been arguing about this for years and show no signs of stopping.", + id: "explore_runic_archive", + sourceId: "runic_archive", sourceType: "exploration", - sourceId: "runic_archive", - zoneId: "shattered_ruins", + title: "The Runic Archive: Cataloguing the Uncatalogued", + zoneId: "shattered_ruins", }, { - id: "explore_dragon_throne", - title: "The Dragon Throne: Archaeology of Dominion", content: "The throne was not built by dragons but for one — the craftsmanship is unmistakably human, scaled to proportions no human monarch would occupy. Whoever commissioned it either served the dragon willingly or was very good at hiding how they felt about the project. The armrests are polished to a mirror finish on the inside where claws would have rested, worn smooth by what your archaeologists estimate was several centuries of regular use.", + id: "explore_dragon_throne", + sourceId: "dragon_throne", sourceType: "exploration", - sourceId: "dragon_throne", - zoneId: "shattered_ruins", + title: "The Dragon Throne: Archaeology of Dominion", + zoneId: "shattered_ruins", }, // ── Frozen Peaks — Explorations ─────────────────────────────────────────── { - id: "explore_glacial_cave", - title: "The Glacial Cave: A Record Written in Ice", content: "Each layer of the cave's ice represents a different century of accumulation, and your scholars have been reading it like a book — tracing climate shifts, ash falls from distant eruptions, and at one depth, a layer of particulate matter they cannot identify that deposited uniformly across the entire glacier in what they estimate was a single afternoon. The ice above that layer is chemically distinct from the ice below it.", + id: "explore_glacial_cave", + sourceId: "glacial_cave", sourceType: "exploration", - sourceId: "glacial_cave", - zoneId: "frozen_peaks", + title: "The Glacial Cave: A Record Written in Ice", + zoneId: "frozen_peaks", }, { - id: "explore_frozen_tundra", - title: "The Frozen Tundra: Nothing and Everything", content: "The tundra appears empty and is not. Your surveyors catalogued the tracks of seventeen distinct species crossing the plain during a single day's observation, none of which revealed themselves to the observers directly. The tundra sustains a hidden abundance — creatures that have learned, over generations, that visibility in a featureless white landscape is an evolutionary disadvantage. Whatever lives here has made itself into a rumour.", + id: "explore_frozen_tundra", + sourceId: "frozen_tundra", sourceType: "exploration", - sourceId: "frozen_tundra", - zoneId: "frozen_peaks", + title: "The Frozen Tundra: Nothing and Everything", + zoneId: "frozen_peaks", }, { - id: "explore_void_rift", - title: "The Void Rift at the Summit: A Proximity Report", content: "The rift atop the Frozen Peaks is smaller than the one your guild sealed in the Astral Void, but considerably more accessible, which makes it more alarming. Your scholars maintain a monitoring station at a distance they have agreed to describe as 'respectful.' The rift has not grown in the two years since its discovery. It has also not shrunk. It appears to be waiting, though for what, and whether 'waiting' is even the correct verb, remains under review.", + id: "explore_void_rift", + sourceId: "void_rift", sourceType: "exploration", - sourceId: "void_rift", - zoneId: "frozen_peaks", + title: "The Void Rift at the Summit: A Proximity Report", + zoneId: "frozen_peaks", }, { - id: "explore_summit_shrine", - title: "The Summit Shrine: Offerings to Altitude", content: "The shrine at the peak's summit has received offerings continuously for at least two thousand years — your archaeologists identified seventeen distinct cultural layers in the accumulated gifts, from traditions so old no contemporary scholar recognises them. The curious detail is that every offering found there, regardless of origin or era, was left by someone who clearly made the climb alone. Whatever this shrine is for, it has never been a communal practice.", + id: "explore_summit_shrine", + sourceId: "summit_shrine", sourceType: "exploration", - sourceId: "summit_shrine", - zoneId: "frozen_peaks", + title: "The Summit Shrine: Offerings to Altitude", + zoneId: "frozen_peaks", }, // ── Shadow Marshes — Explorations ───────────────────────────────────────── { - id: "explore_fog_hollow", - title: "The Fog Hollow: Visibility Report", content: "The fog in the hollow does not behave like fog. It does not thin at higher altitudes, does not respond to wind, and maintains a precisely consistent density at all hours regardless of temperature. Your naturalists have collected samples; the collected fog behaves exactly like ordinary water vapour in every test they have run. The fog in the hollow itself, meanwhile, simply continues not to behave like fog.", + id: "explore_fog_hollow", + sourceId: "fog_hollow", sourceType: "exploration", - sourceId: "fog_hollow", - zoneId: "shadow_marshes", + title: "The Fog Hollow: Visibility Report", + zoneId: "shadow_marshes", }, { - id: "explore_dark_grotto", - title: "The Dark Grotto: Notes from Below", content: "The grotto is not dark because light fails to reach it but because something in the walls absorbs light without radiating heat. Your scholars spent a week attempting to determine the mechanism and concluded that whatever the walls are made of, it is not any material currently in the geological literature. The grotto is perfectly navigable by those who learn to move by sound, smell, and the particular texture of the moss underfoot — three senses the dark makes suddenly eloquent.", + id: "explore_dark_grotto", + sourceId: "dark_grotto", sourceType: "exploration", - sourceId: "dark_grotto", - zoneId: "shadow_marshes", + title: "The Dark Grotto: Notes from Below", + zoneId: "shadow_marshes", }, { - id: "explore_cursed_barrow", - title: "The Cursed Barrow: An Unquiet Burial", content: "The barrow contains seven individuals interred with extraordinary care and extraordinary hostility — the burial rites performed over them were simultaneously honours and bindings, designed to mark the occupants as important whilst preventing them from leaving. Your scholars disagree about whether this represents reverence or precaution. Given what your adventurers encountered when the outer seals were broken, the consensus is slowly shifting toward precaution.", + id: "explore_cursed_barrow", + sourceId: "cursed_barrow", sourceType: "exploration", - sourceId: "cursed_barrow", - zoneId: "shadow_marshes", + title: "The Cursed Barrow: An Unquiet Burial", + zoneId: "shadow_marshes", }, { - id: "explore_marsh_depths", - title: "The Marsh Depths: Below the Below", content: "The deepest navigable point of the Shadow Marshes is not a geographic feature but a threshold — below a certain depth, the character of the water changes in ways your naturalists find difficult to quantify. Things live there that have adapted to conditions that should not exist at marsh depth. Your lead naturalist, after reviewing the samples, requested a sabbatical. The request was granted without discussion.", + id: "explore_marsh_depths", + sourceId: "marsh_depths", sourceType: "exploration", - sourceId: "marsh_depths", - zoneId: "shadow_marshes", + title: "The Marsh Depths: Below the Below", + zoneId: "shadow_marshes", }, // ── Volcanic Depths — Explorations ──────────────────────────────────────── { - id: "explore_magma_tunnel", - title: "The Magma Tunnel: Engineering in Extremis", content: "The tunnel was carved through active lava flow — not around it, through it — using techniques your engineers cannot fully reconstruct. The walls are perfectly smooth, cooled in a way that suggests the cooling was directed rather than incidental. Whoever built this understood heat the way an artist understands their medium: not as an obstacle but as a collaborator. The tunnel has remained structurally sound for an estimated three thousand years.", + id: "explore_magma_tunnel", + sourceId: "magma_tunnel", sourceType: "exploration", - sourceId: "magma_tunnel", - zoneId: "volcanic_depths", + title: "The Magma Tunnel: Engineering in Extremis", + zoneId: "volcanic_depths", }, { - id: "explore_forge_chamber", - title: "The Forge Chamber: Where Things Were Made", content: "The forge chamber is large enough to produce objects at a scale your guild has not needed to contemplate — the anvils alone stand four metres high, and the bellows mechanisms are still partially functional. What was made here left no finished products in the room itself, only slag and the characteristic marks of work. Whatever was produced was taken away, and the absence of records about where it went is either an oversight or very deliberate.", + id: "explore_forge_chamber", + sourceId: "forge_chamber", sourceType: "exploration", - sourceId: "forge_chamber", - zoneId: "volcanic_depths", + title: "The Forge Chamber: Where Things Were Made", + zoneId: "volcanic_depths", }, { - id: "explore_fire_temple", - title: "The Fire Temple: Devotion at High Temperature", content: "The temple's inner sanctum reaches temperatures that should preclude unprotected human presence, and yet the inscriptions on its innermost walls were clearly made at close range by a careful hand. Your scholars theorise the priests had developed physiological adaptations over generations, or had access to protective methods now lost. The inscriptions themselves are prayers of gratitude — not for protection from fire, but for fire itself, described throughout as a generous and patient teacher.", + id: "explore_fire_temple", + sourceId: "fire_temple", sourceType: "exploration", - sourceId: "fire_temple", - zoneId: "volcanic_depths", + title: "The Fire Temple: Devotion at High Temperature", + zoneId: "volcanic_depths", }, { - id: "explore_core_descent", - title: "The Core Descent: How Far Down Is Down", content: "The descent route into the volcanic core goes further than any geological survey suggested was possible — at the point where bedrock should give way to conditions incompatible with any known material, the passage simply continues. Your surveyors measured the depth at three times the predicted limit before equipment failures made further measurement impossible. Whatever is at the bottom has not, so far, shown any interest in coming up.", + id: "explore_core_descent", + sourceId: "core_descent", sourceType: "exploration", - sourceId: "core_descent", - zoneId: "volcanic_depths", + title: "The Core Descent: How Far Down Is Down", + zoneId: "volcanic_depths", }, // ── Astral Void — Explorations ──────────────────────────────────────────── { - id: "explore_star_field", - title: "The Star Field: Navigation Without Ground", content: "Moving through the star field requires abandoning every navigational instinct developed for terrestrial travel — there is no up, no horizon, no landmark that stays fixed. Your first expedition returned three days later than projected because they had been travelling in what they were certain was a straight line. Subsequent expeditions have learned to navigate by resonance rather than direction, a skill that takes months to develop and is reportedly impossible to explain to anyone who hasn't done it.", + id: "explore_star_field", + sourceId: "star_field", sourceType: "exploration", - sourceId: "star_field", - zoneId: "astral_void", + title: "The Star Field: Navigation Without Ground", + zoneId: "astral_void", }, { - id: "explore_probability_sea", - title: "The Probability Sea: Where Things Might Be", content: "The Probability Sea is not a place in any conventional sense but a region where the usual commitment to singular outcomes becomes negotiable. Objects in it exist in multiple states simultaneously until observed, at which point they commit to one — usually but not always the expected one. Your philosophers find this fascinating. Your adventurers find it alarming. The distinction between their reactions tells you something about the relative importance of theory versus experience.", + id: "explore_probability_sea", + sourceId: "probability_sea", sourceType: "exploration", - sourceId: "probability_sea", - zoneId: "astral_void", + title: "The Probability Sea: Where Things Might Be", + zoneId: "astral_void", }, { - id: "explore_void_current", - title: "The Void Current: Currents of Absence", content: "The currents that flow through the deeper reaches of the Void do not carry matter or energy but rather the absence of both — they are rivers of negation, and anything caught in them experiences a temporary but complete erasure of self that survivors describe, bafflingly, as peaceful. Your scholars recommend against extended exposure. Several expeditioners have requested it anyway.", + id: "explore_void_current", + sourceId: "void_current", sourceType: "exploration", - sourceId: "void_current", - zoneId: "astral_void", + title: "The Void Current: Currents of Absence", + zoneId: "astral_void", }, { - id: "explore_null_zenith", - title: "The Null Zenith: The Highest Point of Nothing", content: "At the furthest navigable point of the Astral Void, your expedition found a location where all forces — gravitational, electromagnetic, temporal — cancel precisely to zero. It is the most perfectly quiet place any member of the team had ever experienced, and the most unsettling. In the complete absence of all external input, your adventurers reported hearing only their own thoughts, which several of them found to be unexpectedly loud.", + id: "explore_null_zenith", + sourceId: "null_zenith", sourceType: "exploration", - sourceId: "null_zenith", - zoneId: "astral_void", + title: "The Null Zenith: The Highest Point of Nothing", + zoneId: "astral_void", }, // ── Celestial Reaches — Explorations ────────────────────────────────────── { - id: "explore_light_spire", - title: "The Light Spire: Illumination Without Source", content: "The spire does not cast light so much as it simply is light — every surface radiates evenly, without heat, without shadow, without any discernible origin. Your scholars have established that the light predates any known illumination method by at least several divine ages and have quietly tabled the question of mechanism as 'above current pay grade.' The spire is, despite everything, pleasant to be in. The light feels like being remembered fondly by something very large.", + id: "explore_light_spire", + sourceId: "light_spire", sourceType: "exploration", - sourceId: "light_spire", - zoneId: "celestial_reaches", + title: "The Light Spire: Illumination Without Source", + zoneId: "celestial_reaches", }, { - id: "explore_choir_hall", - title: "The Choir Hall: Harmonics of the Unreachable", content: "The hall's acoustics are not physically explicable — sound produced in any corner arrives at every other point simultaneously, without decay, and with the character of the original sound enhanced rather than diffused. Your musicians, sent to test this, emerged four hours later than scheduled and somewhat altered. The compositions they produced afterward were unanimously their best work. They were also unanimously reluctant to discuss what they had heard the hall adding to their music.", + id: "explore_choir_hall", + sourceId: "choir_hall", sourceType: "exploration", - sourceId: "choir_hall", - zoneId: "celestial_reaches", + title: "The Choir Hall: Harmonics of the Unreachable", + zoneId: "celestial_reaches", }, { - id: "explore_divine_court", - title: "The Divine Court: Empty Thrones and Full Questions", content: "The court is furnished for occupants who are no longer present — every seat, every dais, every position of honour is intact and maintained, though by what or whom remains unclear. The dust does not settle here. The flowers — if that word applies to what grows in celestial soil — do not wilt. Whatever convened in this court has not formally adjourned. Your adventurers found it politic to observe the protocols of guests regardless.", + id: "explore_divine_court", + sourceId: "divine_court", sourceType: "exploration", - sourceId: "divine_court", - zoneId: "celestial_reaches", + title: "The Divine Court: Empty Thrones and Full Questions", + zoneId: "celestial_reaches", }, { - id: "explore_celestial_vault", - title: "The Celestial Vault: Keeping Things Safe from Heaven", content: "The vault contains objects your scholars cannot identify and are not entirely certain are objects in the usual sense — some of them seem to be events, or relationships, or the memory of things that no longer exist in material form. The locks are not mechanical but conceptual; your best locksmith spent a day in front of one before concluding that what was required was not a key but a specific feeling, which she eventually produced on her third attempt and has refused to name.", + id: "explore_celestial_vault", + sourceId: "celestial_vault", sourceType: "exploration", - sourceId: "celestial_vault", - zoneId: "celestial_reaches", + title: "The Celestial Vault: Keeping Things Safe from Heaven", + zoneId: "celestial_reaches", }, // ── Abyssal Trench — Explorations ───────────────────────────────────────── { - id: "explore_pressure_depths", - title: "The Pressure Depths: Where Physics Becomes Opinion", content: "At the depths of the Abyssal Trench, pressure stops being a force and starts being a philosophy — everything that enters is reshaped according to principles that the surface world does not use. Your engineers designed the expedition's equipment to withstand it; the equipment survived, though in forms slightly different from their original specifications. The engineers have been studying these changes for months and remain, in their own words, 'usefully confused.'", + id: "explore_pressure_depths", + sourceId: "pressure_depths", sourceType: "exploration", - sourceId: "pressure_depths", - zoneId: "abyssal_trench", + title: "The Pressure Depths: Where Physics Becomes Opinion", + zoneId: "abyssal_trench", }, { - id: "explore_bioluminescent_garden", - title: "The Bioluminescent Garden: Light from Living Things", content: "The garden is the only source of light at this depth, and it is generous — every surface pulses with the quiet, patient glow of organisms that have made light a part of their biology rather than a luxury. Your naturalists identified over two hundred distinct species, each producing light of a slightly different frequency. Together, they produce something that is, for reasons no one has formally explained, deeply calming to observe.", + id: "explore_bioluminescent_garden", + sourceId: "bioluminescent_garden", sourceType: "exploration", - sourceId: "bioluminescent_garden", - zoneId: "abyssal_trench", + title: "The Bioluminescent Garden: Light from Living Things", + zoneId: "abyssal_trench", }, { - id: "explore_the_maw", - title: "The Maw: An Opening With Opinions", content: "The Maw is a geological feature at the deepest point of the trench — a fissure that continues below any depth your instruments could register. It produces no sound, no current, and no emanation your scholars can detect. It does produce, reliably, a strong reluctance in every person who approaches it: not fear exactly, but a considered, reasonable sense that some thresholds exist for good reasons. Your most decorated adventurer turned around at fifty metres. She does not discuss why.", + id: "explore_the_maw", + sourceId: "the_maw", sourceType: "exploration", - sourceId: "the_maw", - zoneId: "abyssal_trench", + title: "The Maw: An Opening With Opinions", + zoneId: "abyssal_trench", }, { - id: "explore_trenching_station", - title: "The Trenching Station: Someone Was Here First", content: "The station predates your guild's descent by at least two centuries — the equipment found there is recognisably technological but ahead of anything in your current catalogue of known instruments, suggesting whoever built it was working from knowledge your engineers don't yet have. The logs are partially readable; whoever operated here was systematic, professional, and stopped recording mid-sentence on the same date as an unrelated surface-world catastrophe your historians know well.", + id: "explore_trenching_station", + sourceId: "trenching_station", sourceType: "exploration", - sourceId: "trenching_station", - zoneId: "abyssal_trench", + title: "The Trenching Station: Someone Was Here First", + zoneId: "abyssal_trench", }, // ── Infernal Court — Explorations ───────────────────────────────────────── { - id: "explore_court_antechamber", - title: "The Court Antechamber: Waiting as a Power Structure", content: "The antechamber was designed to make visitors wait — the seating is slightly too uncomfortable, the air slightly too warm, the distance to the inner door slightly too far to be casual about. Every element communicates, with considerable craft, that those inside have time and those outside do not. Your adventurers found the effect impressive even knowing it was deliberate, which speaks either to the design's quality or to something older than design working through it.", + id: "explore_court_antechamber", + sourceId: "court_antechamber", sourceType: "exploration", - sourceId: "court_antechamber", - zoneId: "infernal_court", + title: "The Court Antechamber: Waiting as a Power Structure", + zoneId: "infernal_court", }, { - id: "explore_brimstone_market", - title: "The Brimstone Market: Commerce in the Depths", content: "The market operates on currencies your economists find instructive and alarming: memories, years, the right to certain emotions, the recollection of specific faces. The exchange rates are not arbitrary — they follow a consistent internal logic that your scholars have been mapping, and which suggests that whoever established this market had very precise views about what things are actually worth. Your adventurers returned with several unusual purchases and declined to specify the prices paid.", + id: "explore_brimstone_market", + sourceId: "brimstone_market", sourceType: "exploration", - sourceId: "brimstone_market", - zoneId: "infernal_court", + title: "The Brimstone Market: Commerce in the Depths", + zoneId: "infernal_court", }, { - id: "explore_the_pit", - title: "The Pit: Accountability at Depth", content: "The Pit is not a dungeon in the conventional sense — its occupants are not imprisoned by bars or locks but by the sheer specificity of the judgements against them, each one tailored with a thoroughness that suggests its authors had access to very complete records. Your scholars reviewed several of the judgements and found them, discomfitingly, difficult to argue with. The occupants, when questioned, were mostly resigned. A few seemed relieved.", + id: "explore_the_pit", + sourceId: "the_pit", sourceType: "exploration", - sourceId: "the_pit", - zoneId: "infernal_court", + title: "The Pit: Accountability at Depth", + zoneId: "infernal_court", }, { - id: "explore_demon_throne", - title: "The Demon Throne: Succession and Its Complications", content: "The throne has changed hands forty-seven times in recorded infernal history, by your archivists' count, and the manner of each succession is documented in the court records with a frankness that would be scandalous in any surface-world archive. What emerges from reading them is not a picture of chaos but of a remarkably consistent institution — the methods change, but the priorities remain eerily stable. The Infernal Court, it seems, has a very clear sense of what it is for.", + id: "explore_demon_throne", + sourceId: "demon_throne", sourceType: "exploration", - sourceId: "demon_throne", - zoneId: "infernal_court", + title: "The Demon Throne: Succession and Its Complications", + zoneId: "infernal_court", }, // ── Crystalline Spire — Explorations ────────────────────────────────────── { - id: "explore_facet_hall", - title: "The Facet Hall: Many Angles on One Truth", content: "Every surface in the hall is a precisely angled crystal face, and every face reflects not the current moment but a slightly different version of it — the same room, the same people, but from angles and at moments that don't quite match. Your philosophers have spent considerable time debating whether the reflections are showing the past, alternate presents, or something the Spire believes should be happening. The crystals themselves offer no clarification.", + id: "explore_facet_hall", + sourceId: "facet_hall", sourceType: "exploration", - sourceId: "facet_hall", - zoneId: "crystalline_spire", + title: "The Facet Hall: Many Angles on One Truth", + zoneId: "crystalline_spire", }, { - id: "explore_calculation_chamber", - title: "The Calculation Chamber: What the Spire Computed", content: "The chamber is the Spire's working mind — its walls covered in arrays of crystal that process information by means your scholars are still working to understand. They have managed to read fragments of current calculations and found that the Spire is working on problems of extraordinary complexity, though what exactly it is trying to solve has not been determined. The Spire has been calculating, without pause, for longer than any civilisation your historians have documented.", + id: "explore_calculation_chamber", + sourceId: "calculation_chamber", sourceType: "exploration", - sourceId: "calculation_chamber", - zoneId: "crystalline_spire", + title: "The Calculation Chamber: What the Spire Computed", + zoneId: "crystalline_spire", }, { - id: "explore_possibility_archive", - title: "The Possibility Archive: Everything That Might Have Been", content: "The archive does not store what happened but what could have — every decision point your scholars have examined corresponds to a branching archive of outcomes, each preserved in crystalline stasis with the same care as the actual historical record. The implication, which your philosophers are taking seriously, is that the Spire considers unrealised possibilities as historically significant as realised ones. The archive for any given moment contains more unchosen paths than chosen ones, by a considerable margin.", + id: "explore_possibility_archive", + sourceId: "possibility_archive", sourceType: "exploration", - sourceId: "possibility_archive", - zoneId: "crystalline_spire", + title: "The Possibility Archive: Everything That Might Have Been", + zoneId: "crystalline_spire", }, { - id: "explore_spire_apex", - title: "The Spire Apex: The Top of Thought", content: "At the apex, the crystalline structure reaches its maximum complexity — every facet there reflects not a version of the room but something further away, something your scholars can only describe as the distance between things rather than the things themselves. Standing at the apex, your adventurers reported a brief but profound sensation of understanding everything, followed immediately by a complete inability to describe what they had understood. Several have been trying to recover the memory for years.", + id: "explore_spire_apex", + sourceId: "spire_apex", sourceType: "exploration", - sourceId: "spire_apex", - zoneId: "crystalline_spire", + title: "The Spire Apex: The Top of Thought", + zoneId: "crystalline_spire", }, // ── Eternal Throne — Explorations ───────────────────────────────────────── { - id: "explore_throne_antechamber", - title: "The Throne Antechamber: The Last Waiting Room", content: "The antechamber of the Eternal Throne feels different from other waiting rooms — the waiting here is not anxious but conclusive, as if every person who has ever sat in it arrived knowing it would be the last place they waited before something irrevocable. The stone of the benches is worn smooth by the passage of uncountable figures across geological time. Your adventurers sat for a moment and found themselves, unexpectedly, at peace with things they had not resolved.", + id: "explore_throne_antechamber", + sourceId: "throne_antechamber", sourceType: "exploration", - sourceId: "throne_antechamber", - zoneId: "eternal_throne", + title: "The Throne Antechamber: The Last Waiting Room", + zoneId: "eternal_throne", }, { - id: "explore_crown_vault", - title: "The Crown Vault: What Sovereignty Accumulates", content: "The vault holds not one crown but thousands — the regalia of every sovereign entity that has ever sat the Eternal Throne, preserved in precise chronological order from oldest to most recent. The oldest pieces are not recognisable as crowns by any human standard; they are simply the things that the entities of that era used to signify authority, whatever form that took. The vault is, among other things, a history of what power has decided it looks like.", + id: "explore_crown_vault", + sourceId: "crown_vault", sourceType: "exploration", - sourceId: "crown_vault", - zoneId: "eternal_throne", + title: "The Crown Vault: What Sovereignty Accumulates", + zoneId: "eternal_throne", }, { - id: "explore_eternity_well", - title: "The Eternity Well: Drinking from What Endures", content: "The well does not contain water but something your scholars are calling 'duration' for lack of a better term — a substance that embodies the property of continuing rather than any particular content. Looking into it, your adventurers saw not a reflection but a view straight through time, all of it at once, which was too much information for human perception to process and resolved, for each of them, into the single image that felt most essentially true. No two saw the same thing.", + id: "explore_eternity_well", + sourceId: "eternity_well", sourceType: "exploration", - sourceId: "eternity_well", - zoneId: "eternal_throne", + title: "The Eternity Well: Drinking from What Endures", + zoneId: "eternal_throne", }, { - id: "explore_the_eternal_seat", - title: "The Eternal Seat: Where Everything Converges", content: "The throne itself is smaller than expected — a modest seat of unadorned stone that looks as though it was made for a person of ordinary proportions, which raises the question of what ordinary means at this altitude of existence. Sitting in it is not something your adventurers attempted, following a collectively instinctive decision that was made without discussion. They did, however, stand near it for several minutes, and reported that it radiated not power but something closer to patience.", + id: "explore_the_eternal_seat", + sourceId: "the_eternal_seat", sourceType: "exploration", - sourceId: "the_eternal_seat", - zoneId: "eternal_throne", + title: "The Eternal Seat: Where Everything Converges", + zoneId: "eternal_throne", }, // ── Primordial Chaos — Explorations ─────────────────────────────────────── { - id: "explore_chaos_vortex", - title: "The Chaos Vortex: Entropy Made Visible", content: "The vortex is where the rules go when they are not being applied — not broken, simply resting, temporarily inapplicable. Your scholars prepared extensively for this encounter and found their preparations mostly irrelevant, not because they were wrong but because the vortex doesn't respect categories like 'right' and 'wrong.' What they found instead was more interesting: chaos, it turns out, has a texture, and navigating by texture rather than logic is a learnable skill.", + id: "explore_chaos_vortex", + sourceId: "chaos_vortex", sourceType: "exploration", - sourceId: "chaos_vortex", - zoneId: "primordial_chaos", + title: "The Chaos Vortex: Entropy Made Visible", + zoneId: "primordial_chaos", }, { - id: "explore_creation_engine", - title: "The Creation Engine: How Things Began to Be", content: "The engine does not create things so much as it creates the conditions in which things become possible — a distinction your philosophers find essential and your adventurers find academic. It has been running since before any civilisation has records to count, and its output has included, according to your best analysis, every physical constant, every natural law, and at least three things that function as neither. It does not appear to need maintenance. It does not appear to have an off switch.", + id: "explore_creation_engine", + sourceId: "creation_engine", sourceType: "exploration", - sourceId: "creation_engine", - zoneId: "primordial_chaos", + title: "The Creation Engine: How Things Began to Be", + zoneId: "primordial_chaos", }, { - id: "explore_primal_forge", - title: "The Primal Forge: Before the First Fire Was Named", content: "The forge here predates the one in the Volcanic Depths by a margin your geologists describe as 'not expressible in geological terms.' What is made here is not objects but properties — hardness, conductivity, resonance, the capacity for a material to hold a shape. The raw chaos of the surrounding region feeds it constantly, and it processes that chaos into something usable, which is either a very elegant system or a very old obligation.", + id: "explore_primal_forge", + sourceId: "primal_forge", sourceType: "exploration", - sourceId: "primal_forge", - zoneId: "primordial_chaos", + title: "The Primal Forge: Before the First Fire Was Named", + zoneId: "primordial_chaos", }, { - id: "explore_the_unmaking", - title: "The Unmaking: Where Things Return", content: "At the heart of the Primordial Chaos is the process that precedes creation — the reduction of everything complex back to its simplest possible state, not as destruction but as preparation. Your philosophers have found this deeply reassuring in a way they struggle to articulate and slightly alarming in a way they can articulate very clearly. The Unmaking is not an ending; it is, in the most technical sense, the beginning of a beginning.", + id: "explore_the_unmaking", + sourceId: "the_unmaking", sourceType: "exploration", - sourceId: "the_unmaking", - zoneId: "primordial_chaos", + title: "The Unmaking: Where Things Return", + zoneId: "primordial_chaos", }, // ── Infinite Expanse — Explorations ─────────────────────────────────────── { - id: "explore_horizon_edge", - title: "The Horizon Edge: Where Distance Becomes Meaningful", content: "The edge of the Infinite Expanse is not a boundary but a perspective — the point at which the space ahead of you becomes distinguishable from the space you have already traversed. Your expedition reached it and found, on the other side, more Expanse, identical in every measurable way. The conceptual distance between 'having gone nowhere' and 'being somewhere new' has occupied your philosophers ever since.", + id: "explore_horizon_edge", + sourceId: "horizon_edge", sourceType: "exploration", - sourceId: "horizon_edge", - zoneId: "infinite_expanse", + title: "The Horizon Edge: Where Distance Becomes Meaningful", + zoneId: "infinite_expanse", }, { - id: "explore_distance_field", - title: "The Distance Field: The Space Between Everything", content: "In the Distance Field, the intervals between objects are themselves objects — measurable, navigable, with their own properties distinct from the things they separate. Your surveyors found this initially maddening and eventually liberating: it is, they report, the only place they have been where the journey is as substantial as the destination. Several returned with a profoundly different relationship to transit time.", + id: "explore_distance_field", + sourceId: "distance_field", sourceType: "exploration", - sourceId: "distance_field", - zoneId: "infinite_expanse", + title: "The Distance Field: The Space Between Everything", + zoneId: "infinite_expanse", }, { - id: "explore_expanse_core", - title: "The Expanse Core: The Middle of Everything", content: "The core of the Infinite Expanse is paradoxically reachable — it is the point equidistant from all edges, which in an infinite space means it is everywhere and your scholars remain divided on whether your expedition found it or simply decided to be there. What they found at that decision was a quiet resonance, as if the Expanse was acknowledging them: not with welcome, exactly, but with the particular attention one pays to the first visitor in a very long time.", + id: "explore_expanse_core", + sourceId: "expanse_core", sourceType: "exploration", - sourceId: "expanse_core", - zoneId: "infinite_expanse", + title: "The Expanse Core: The Middle of Everything", + zoneId: "infinite_expanse", }, { - id: "explore_infinity_point", - title: "The Infinity Point: The End of Counting", content: "The Infinity Point is where the Expanse stops pretending to be finite — not a location but an experience, a moment in which the scale of the space becomes legible all at once and the human nervous system does something for which there is no established vocabulary. Your adventurers described it variously as terror, awe, grief, joy, and one memorable entry that simply reads 'yes.' They all agreed it was worth it. None of them would go back.", + id: "explore_infinity_point", + sourceId: "infinity_point", sourceType: "exploration", - sourceId: "infinity_point", - zoneId: "infinite_expanse", + title: "The Infinity Point: The End of Counting", + zoneId: "infinite_expanse", }, // ── Reality Forge — Explorations ────────────────────────────────────────── { - id: "explore_forge_entrance", - title: "The Forge Entrance: Where Possibility Becomes Serious", content: "The entrance to the Reality Forge has a weight to it — not gravitational but existential, the sense that what lies ahead operates at a level of consequence beyond what most places permit. Your adventurers described crossing the threshold as making a commitment, though none could specify to what. The entrance itself is unmarked, unguarded, and slightly too easy to find, which your chief scout found the most alarming thing about it.", + id: "explore_forge_entrance", + sourceId: "forge_entrance", sourceType: "exploration", - sourceId: "forge_entrance", - zoneId: "reality_forge", + title: "The Forge Entrance: Where Possibility Becomes Serious", + zoneId: "reality_forge", }, { - id: "explore_creation_workshop", - title: "The Creation Workshop: Drafts of the World", content: "The workshop contains early versions of things — prototypes of physical laws, first attempts at materials that were later refined, iterations of fundamental forces that were tried and abandoned. Your scholars found this profoundly instructive: the universe, it appears, went through several drafts, and the discarded ones are not destroyed but merely set aside, their existence neither confirmed nor denied by the reality that superseded them.", + id: "explore_creation_workshop", + sourceId: "creation_workshop", sourceType: "exploration", - sourceId: "creation_workshop", - zoneId: "reality_forge", + title: "The Creation Workshop: Drafts of the World", + zoneId: "reality_forge", }, { - id: "explore_reality_crucible", - title: "The Reality Crucible: Melting What Is to Make What Could Be", content: "The crucible is where existing reality is processed — not destroyed, but rendered back into its component possibilities before being reformed. Your adventurers spent eight minutes inside it during a dormant cycle and emerged with memories of a childhood approximately fifteen percent different from their actual ones, which resolved back to normal within an hour. The scholars are still arguing about whether those alternative memories were invented or retrieved.", + id: "explore_reality_crucible", + sourceId: "reality_crucible", sourceType: "exploration", - sourceId: "reality_crucible", - zoneId: "reality_forge", + title: "The Reality Crucible: Melting What Is to Make What Could Be", + zoneId: "reality_forge", }, { - id: "explore_the_prime_forge", - title: "The Prime Forge: Where the First Reality Was Struck", content: "At the heart of the Reality Forge is the original apparatus — the mechanism by which the current universe was produced. It is still hot from the work. Your scholars, confronting this, became temporarily unable to speak, which was the most articulate response available to them. The Prime Forge is not operating, but it is not off, and the distinction, your chief theorist insists, matters enormously.", + id: "explore_the_prime_forge", + sourceId: "the_prime_forge", sourceType: "exploration", - sourceId: "the_prime_forge", - zoneId: "reality_forge", + title: "The Prime Forge: Where the First Reality Was Struck", + zoneId: "reality_forge", }, // ── Cosmic Maelstrom — Explorations ─────────────────────────────────────── { - id: "explore_maelstrom_edge", - title: "The Maelstrom Edge: The Outer Boundary of Fury", content: "At the edge of the Cosmic Maelstrom, the forces are still comprehensible — barely, and only to specialists, but comprehensible. Your expedition established a base camp here and used it as a research station for three weeks before any deeper penetration was attempted. The edge is also where most of what the Maelstrom throws outward ends up — debris, energy, occasional objects of origin too complex to be coincidental.", + id: "explore_maelstrom_edge", + sourceId: "maelstrom_edge", sourceType: "exploration", - sourceId: "maelstrom_edge", - zoneId: "cosmic_maelstrom", + title: "The Maelstrom Edge: The Outer Boundary of Fury", + zoneId: "cosmic_maelstrom", }, { - id: "explore_force_nexus", - title: "The Force Nexus: Where All Powers Converge", content: "The nexus is the point at which all four fundamental forces of the universe intersect at maximum intensity — a convergence your physicists had theorised was impossible and were forced to revise upon encountering it. Standing at the nexus, your adventurers could feel each force individually, which is an experience the human nervous system was not designed for and managed anyway, which says something either admirable or concerning about the human nervous system.", + id: "explore_force_nexus", + sourceId: "force_nexus", sourceType: "exploration", - sourceId: "force_nexus", - zoneId: "cosmic_maelstrom", + title: "The Force Nexus: Where All Powers Converge", + zoneId: "cosmic_maelstrom", }, { - id: "explore_cosmic_eye", - title: "The Cosmic Eye: Calm at the Centre of Everything", content: "The eye of the Maelstrom is quiet in the way that matters — not peaceful but suspended, every force in equilibrium so complete that nothing moves. Your adventurers found it the most disorienting location in the entire expedition, because the human body navigates by resistance: by air against skin, by ground underfoot, by the gentle tug of gravity. In the Eye, none of these applied. They were, briefly, in a place that did not insist on them.", + id: "explore_cosmic_eye", + sourceId: "cosmic_eye", sourceType: "exploration", - sourceId: "cosmic_eye", - zoneId: "cosmic_maelstrom", + title: "The Cosmic Eye: Calm at the Centre of Everything", + zoneId: "cosmic_maelstrom", }, { - id: "explore_maelstrom_core", - title: "The Maelstrom Core: The Engine of Universal Turbulence", content: "The core drives everything — the Maelstrom is not chaos but an engine, and its core is not its wildest point but its most purposeful. Your scholars spent two days studying it from a safe distance and concluded that the Maelstrom is producing something, though what exactly that product is requires equipment your civilisation does not yet possess to measure. The core does not acknowledge observation. It simply continues.", + id: "explore_maelstrom_core", + sourceId: "maelstrom_core", sourceType: "exploration", - sourceId: "maelstrom_core", - zoneId: "cosmic_maelstrom", + title: "The Maelstrom Core: The Engine of Universal Turbulence", + zoneId: "cosmic_maelstrom", }, // ── Primeval Sanctum — Explorations ─────────────────────────────────────── { - id: "explore_first_chamber", - title: "The First Chamber: The Room That Was There Before Rooms", content: "The First Chamber is the oldest enclosed space your scholars have ever encountered or expect to encounter — it predates the concept of architecture by a margin that makes the word 'ancient' feel inadequate. The walls did not need to be built because they simply are, as mountains simply are, as oceans simply are. Your adventurers found it impossible to determine whether they had entered the chamber or whether the chamber had, after all this time, decided to include them.", + id: "explore_first_chamber", + sourceId: "first_chamber", sourceType: "exploration", - sourceId: "first_chamber", - zoneId: "primeval_sanctum", + title: "The First Chamber: The Room That Was There Before Rooms", + zoneId: "primeval_sanctum", }, { - id: "explore_memory_hall", - title: "The Memory Hall: Every Moment That Mattered", content: "The hall contains the memories of the world itself — not of any individual creature but of the geological, ecological, and existential events that constitute the planet's own experience of time. Your scholars found these accessible with some concentration, and came away with a perspective they describe as 'clarifying' and 'occasionally devastating.' The hall does not distinguish between good events and bad ones; it simply remembers with equal care.", + id: "explore_memory_hall", + sourceId: "memory_hall", sourceType: "exploration", - sourceId: "memory_hall", - zoneId: "primeval_sanctum", + title: "The Memory Hall: Every Moment That Mattered", + zoneId: "primeval_sanctum", }, { - id: "explore_ancient_vault", - title: "The Ancient Vault: What the First Keepers Kept", content: "The vault predates the concept of ownership — what it contains was placed here not because anyone claimed it but because someone understood that some things must be preserved regardless of who they belong to. Your adventurers found objects, concepts, and at least one living thing that appeared to be very old and very patient. The living thing looked at them for a long moment and then returned to whatever it had been doing.", + id: "explore_ancient_vault", + sourceId: "ancient_vault", sourceType: "exploration", - sourceId: "ancient_vault", - zoneId: "primeval_sanctum", + title: "The Ancient Vault: What the First Keepers Kept", + zoneId: "primeval_sanctum", }, { - id: "explore_the_first_place", - title: "The First Place: Before Everything, Here", content: "At the sanctum's innermost point is a location that your expedition's theorist describes as the original coordinate — the point from which all other locations are, in some sense, measured. It has no special appearance. It does not glow or hum or radiate. It is simply the first place something was, before anything else was anywhere, and the fact of that priority is, somehow, still present in it. Standing there, your adventurers each had the strange sensation of being very precisely located.", + id: "explore_the_first_place", + sourceId: "the_first_place", sourceType: "exploration", - sourceId: "the_first_place", - zoneId: "primeval_sanctum", + title: "The First Place: Before Everything, Here", + zoneId: "primeval_sanctum", }, // ── Void Sanctum — Explorations ─────────────────────────────────────────── { - id: "explore_null_gate", - title: "The Null Gate: An Entrance That Leads Inside Absence", content: "The gate does not open onto a place but onto the negation of place — passing through it, your adventurers reported a moment of complete non-existence followed by re-existence in the sanctum's interior, which is either a very fast transit or a very precise demonstration of what nothing is like from the inside. The duration was universally reported as both instantaneous and very long. Your chronologists have been attempting to reconcile these accounts for months.", + id: "explore_null_gate", + sourceId: "null_gate", sourceType: "exploration", - sourceId: "null_gate", - zoneId: "void_sanctum", + title: "The Null Gate: An Entrance That Leads Inside Absence", + zoneId: "void_sanctum", }, { - id: "explore_resonance_hall", - title: "The Resonance Hall: Frequencies of the Void", content: "The hall amplifies nothing — not silence, but the specific property of the void, which is distinct from silence the way that a vacuum is distinct from an empty room. In the Resonance Hall, your adventurers could hear what the void says when it is given optimal acoustics: an extremely deep, extremely quiet note that your musicians identified as a pitch that no instrument in your guild's possession can produce. Several of them are working on building one that can.", + id: "explore_resonance_hall", + sourceId: "resonance_hall", sourceType: "exploration", - sourceId: "resonance_hall", - zoneId: "void_sanctum", + title: "The Resonance Hall: Frequencies of the Void", + zoneId: "void_sanctum", }, { - id: "explore_sanctum_inner", - title: "The Inner Sanctum: The Void's Most Private Room", content: "The inner sanctum is where the void keeps its own records — not of what exists but of what has ceased to exist, a catalogue of absences maintained with the same care a great library maintains its holdings. Your scholars found this moving in a way they hadn't anticipated. Every absence recorded here was once a presence; the sanctum remembers them without sentimentality and without forgetting.", + id: "explore_sanctum_inner", + sourceId: "sanctum_inner", sourceType: "exploration", - sourceId: "sanctum_inner", - zoneId: "void_sanctum", + title: "The Inner Sanctum: The Void's Most Private Room", + zoneId: "void_sanctum", }, { - id: "explore_void_heart", - title: "The Void Heart: The Core of Emptiness", content: "At the void sanctum's centre is the point where the void is most itself — the most perfectly empty location in existence, which is also, paradoxically, the most full of potential. Your philosophers have been arguing about this paradox for some time. Your adventurers experienced it directly, and reported that it felt like standing at the beginning of something enormous that had not yet decided what it was going to be.", + id: "explore_void_heart", + sourceId: "void_heart", sourceType: "exploration", - sourceId: "void_heart", - zoneId: "void_sanctum", + title: "The Void Heart: The Core of Emptiness", + zoneId: "void_sanctum", }, // ── The Absolute — Explorations ─────────────────────────────────────────── { - id: "explore_boundary_threshold", - title: "The Boundary Threshold: The Last Legible Place", content: "The threshold is the last point where the laws of the explored universe still apply in their familiar forms. Beyond it, your scholars warn, is not somewhere those laws break down so much as somewhere they become optional, which is both more frightening and more interesting. Your expedition stood at the threshold for a long time before proceeding, not from fear but from a collective, unspoken desire to remember what ordinary felt like.", + id: "explore_boundary_threshold", + sourceId: "boundary_threshold", sourceType: "exploration", - sourceId: "boundary_threshold", - zoneId: "the_absolute", + title: "The Boundary Threshold: The Last Legible Place", + zoneId: "the_absolute", }, { - id: "explore_final_approach", - title: "The Final Approach: The Path That Gets More Serious", content: "The approach to the Absolute is not dangerous in any physical sense — there are no guardians, no traps, no environmental hazards. It is, simply, increasingly consequential. Your adventurers described it as the difference between walking and deciding: each step felt like a choice, and the choices felt permanent in a way that even irreversible physical actions do not. Several stopped and considered turning back. None of them could articulate a good reason to.", + id: "explore_final_approach", + sourceId: "final_approach", sourceType: "exploration", - sourceId: "final_approach", - zoneId: "the_absolute", + title: "The Final Approach: The Path That Gets More Serious", + zoneId: "the_absolute", }, { - id: "explore_last_light", - title: "The Last Light: Illumination at the End of Everything", content: "The last light is a single point of illumination at the boundary of the Absolute — a light that is not produced by any combustion or radiation your scholars recognise, but simply persists. It illuminates the approach and, once one arrives and looks back, can be seen all the way from the threshold, impossibly small and impossibly clear. Your chief archivist wrote in her report that finding a light at the edge of everything felt, improbably, like evidence of someone's care.", + id: "explore_last_light", + sourceId: "last_light", sourceType: "exploration", - sourceId: "last_light", - zoneId: "the_absolute", + title: "The Last Light: Illumination at the End of Everything", + zoneId: "the_absolute", }, { - id: "explore_the_absolute_point", - title: "The Absolute Point: This Is Where It Ends", content: "The Absolute is not a place but a conclusion — the final resolution of every question your expedition had brought with it, and several they hadn't known they were carrying. Your adventurers arrived there and stood in silence for a duration none of them recorded, then returned without discussing what they had found. In each of their subsequent reports, filed separately and without collaboration, the last line is identical: 'It was enough.'", + id: "explore_the_absolute_point", + sourceId: "the_absolute_point", sourceType: "exploration", - sourceId: "the_absolute_point", - zoneId: "the_absolute", + title: "The Absolute Point: This Is Where It Ends", + zoneId: "the_absolute", }, // ── Verdant Vale — Recipes ──────────────────────────────────────────────── { - id: "recipe_heartwood_tincture", - title: "Heartwood Tincture: An Old Remedy", content: "The tincture is prepared from the innermost wood of trees that have survived at least two centuries — a distinction the recipe's original author apparently believed was meaningful, and which subsequent herbalists have been unable to disprove. The process of extraction takes three days of careful attention and smells, at various stages, of rain, of old libraries, and of something your guild's apothecary describes only as 'time.' The resulting liquid is clear and slightly warm to the touch.", + id: "recipe_heartwood_tincture", + sourceId: "heartwood_tincture", sourceType: "recipe", - sourceId: "heartwood_tincture", - zoneId: "verdant_vale", + title: "Heartwood Tincture: An Old Remedy", + zoneId: "verdant_vale", }, { - id: "recipe_elder_bark_shield", - title: "The Elder Bark Shield: Armour from the Ancient Grove", content: "The bark used in this shield's construction comes from the grove's oldest trees, which shed it willingly — the trees in the Ancient Grove appear to have opinions about what their bark is used for, and do not resist this particular purpose. The curing process involves no binding agents; the bark adheres to the shaped backing through what your craftspeople diplomatically call 'inherent structural affinity' and what your scholars more precisely call 'something we don't understand yet.'", + id: "recipe_elder_bark_shield", + sourceId: "elder_bark_shield", sourceType: "recipe", - sourceId: "elder_bark_shield", - zoneId: "verdant_vale", + title: "The Elder Bark Shield: Armour from the Ancient Grove", + zoneId: "verdant_vale", }, // ── Shattered Ruins — Recipes ───────────────────────────────────────────── { - id: "recipe_runic_binding", - title: "Runic Binding: The Art of Making Permanent", content: "The binding process translates the runes of the archive into a physical medium — a delicate operation that your scholars liken to transcription and your craftspeople liken to argument. Each rune must be convinced to hold its meaning in a new context, which requires a practitioner who understands what the rune actually means rather than simply what it looks like. Your guild has three people capable of this. They are all extremely well paid.", + id: "recipe_runic_binding", + sourceId: "runic_binding", sourceType: "recipe", - sourceId: "runic_binding", - zoneId: "shattered_ruins", + title: "Runic Binding: The Art of Making Permanent", + zoneId: "shattered_ruins", }, { - id: "recipe_dragon_scale_charm", - title: "Dragon Scale Charm: The Weight of History", content: "The scales used in this charm were shed rather than taken — Vaeltharox left several behind in the aftermath of the battle, which your scholars interpreted as either involuntary or deliberate and eventually settled on deliberate, given the dragon's apparent habit of memorialising things. The charm retains a faint warmth that cannot be accounted for thermodynamically, and which the wearer typically stops noticing after the first week.", + id: "recipe_dragon_scale_charm", + sourceId: "dragon_scale_charm", sourceType: "recipe", - sourceId: "dragon_scale_charm", - zoneId: "shattered_ruins", + title: "Dragon Scale Charm: The Weight of History", + zoneId: "shattered_ruins", }, // ── Frozen Peaks — Recipes ──────────────────────────────────────────────── { - id: "recipe_glacial_lens", - title: "Glacial Lens: Seeing Through Time's Layers", content: "The lens is ground from ice-core samples taken at specific depths — each depth corresponding to a century your guild wished to examine optically. The grinding process requires temperatures that preclude most workshop environments and a cutter with sufficiently cold hands to work bare, which has narrowed the available craftspeople considerably. The finished lens allows the user to see the layer of time the ice was taken from, briefly and unclearly, which your scholars consider more than enough.", + id: "recipe_glacial_lens", + sourceId: "glacial_lens", sourceType: "recipe", - sourceId: "glacial_lens", - zoneId: "frozen_peaks", + title: "Glacial Lens: Seeing Through Time's Layers", + zoneId: "frozen_peaks", }, { - id: "recipe_void_fragment_amulet", - title: "Void Fragment Amulet: A Piece of the Rift", content: "The fragments used in this amulet were collected from the edges of the Void Rift at the summit, where the rift deposits small crystallised portions of whatever it is made of. The collection process requires a very steady hand and a very firm commitment to not thinking about what you're holding. The amulet is cold to the touch regardless of ambient temperature and causes watches worn near it to run approximately seven seconds fast per day, which your scholars have noted but not explained.", + id: "recipe_void_fragment_amulet", + sourceId: "void_fragment_amulet", sourceType: "recipe", - sourceId: "void_fragment_amulet", - zoneId: "frozen_peaks", + title: "Void Fragment Amulet: A Piece of the Rift", + zoneId: "frozen_peaks", }, // ── Shadow Marshes — Recipes ────────────────────────────────────────────── { - id: "recipe_shadow_extract", - title: "Shadow Extract: What the Fog Leaves Behind", content: "The extract is obtained by condensing the marsh's anomalous fog under controlled conditions — a process your alchemists describe as 'arguing with vapour until it cooperates.' What remains after the non-fog components are removed is a dense, dark liquid that behaves like oil but isn't, and which has the useful property of absorbing light in its immediate vicinity without producing heat. Its exact composition remains, appropriately, somewhat unclear.", + id: "recipe_shadow_extract", + sourceId: "shadow_extract", sourceType: "recipe", - sourceId: "shadow_extract", - zoneId: "shadow_marshes", + title: "Shadow Extract: What the Fog Leaves Behind", + zoneId: "shadow_marshes", }, { - id: "recipe_cursed_focus", - title: "Cursed Focus: Channelling What Should Not Be Held", content: "The focus incorporates elements from the cursed barrow — materials that have absorbed centuries of the binding rites performed over the occupants and can, when properly shaped, redirect that binding energy toward the holder's purposes. The crafting process requires the maker to understand, specifically, what they are containing and to consent to the weight of it. Your two certified Focus-crafters both have notably steady voices and eyes that have seen too many things to be surprised by much.", + id: "recipe_cursed_focus", + sourceId: "cursed_focus", sourceType: "recipe", - sourceId: "cursed_focus", - zoneId: "shadow_marshes", + title: "Cursed Focus: Channelling What Should Not Be Held", + zoneId: "shadow_marshes", }, // ── Volcanic Depths — Recipes ───────────────────────────────────────────── { - id: "recipe_magma_core_seal", - title: "Magma Core Seal: The Mountain's Consent", content: "The seal is forged at the deepest accessible point of the volcanic core, using heat that cannot be replicated in any surface forge. The process requires the smith to work quickly — not because the material cools but because at that depth, the material has opinions and they shift. Seals produced here are structurally identical to surface-forged ones and functionally distinct in ways that your engineers can measure but not model.", + id: "recipe_magma_core_seal", + sourceId: "magma_core_seal", sourceType: "recipe", - sourceId: "magma_core_seal", - zoneId: "volcanic_depths", + title: "Magma Core Seal: The Mountain's Consent", + zoneId: "volcanic_depths", }, { - id: "recipe_elemental_ore_ingot", - title: "Elemental Ore Ingot: Smelting the Fundamental", content: "The ore is sourced from veins that run through the Magma Titan's former domain — rock that has been in contact with an elemental consciousness for centuries and has retained, in its crystalline structure, traces of that contact. Smelting it requires a furnace of a very specific temperature and a smith who is comfortable with the ingot occasionally radiating warmth after being removed from the heat. The resulting metal is excellent and slightly opinionated about how it is used.", + id: "recipe_elemental_ore_ingot", + sourceId: "elemental_ore_ingot", sourceType: "recipe", - sourceId: "elemental_ore_ingot", - zoneId: "volcanic_depths", + title: "Elemental Ore Ingot: Smelting the Fundamental", + zoneId: "volcanic_depths", }, // ── Astral Void — Recipes ───────────────────────────────────────────────── { - id: "recipe_star_chart", - title: "Star Chart: Mapping the Unmappable", content: "The chart is not drawn but grown — the materials used have a resonance with the stellar regions they depict, and the mapping process is less cartography than cultivation, a matter of creating the right conditions and allowing the chart to develop its own accuracy. The finished product updates itself, which your cartographers find professionally alarming and personally fascinating. It has been wrong exactly once, and that one error led to the discovery of something no existing chart had included.", + id: "recipe_star_chart", + sourceId: "star_chart", sourceType: "recipe", - sourceId: "star_chart", - zoneId: "astral_void", + title: "Star Chart: Mapping the Unmappable", + zoneId: "astral_void", }, { - id: "recipe_void_crystal_matrix", - title: "Void Crystal Matrix: Structure from Absence", content: "The matrix is constructed from crystals that formed in the deep void — structures that developed in the complete absence of the pressures and minerals that normally produce crystallisation, and which therefore grow according to entirely different principles. Shaping them requires abandoning conventional lapidary technique and learning, as your guild's crystal-workers describe it, 'to suggest rather than cut.' The resulting matrix holds a charge of pure negation that proves extraordinarily useful.", + id: "recipe_void_crystal_matrix", + sourceId: "void_crystal_matrix", sourceType: "recipe", - sourceId: "void_crystal_matrix", - zoneId: "astral_void", + title: "Void Crystal Matrix: Structure from Absence", + zoneId: "astral_void", }, // ── Celestial Reaches — Recipes ─────────────────────────────────────────── { - id: "recipe_celestial_lens", - title: "Celestial Lens: Ground from the Light Itself", content: "The lens is shaped from solidified celestial light — a material that should not exist but does, in the vaults of the Celestial Reaches, in precisely the quantities needed for this recipe and no more. The grinding process is performed in the dark, because the lens amplifies whatever light is present during its making, and working in full celestial illumination produces a lens that most users find overwhelming. The subdued version is merely remarkable.", + id: "recipe_celestial_lens", + sourceId: "celestial_lens", sourceType: "recipe", - sourceId: "celestial_lens", - zoneId: "celestial_reaches", + title: "Celestial Lens: Ground from the Light Itself", + zoneId: "celestial_reaches", }, { - id: "recipe_choir_resonator", - title: "Choir Resonator: A Piece of the Hall's Voice", content: "The resonator is tuned to the frequencies produced by the Choir Hall — a process that requires extended time in the hall itself, listening, until the craftsperson can reproduce the exact pitch of the hall's enhancement. Three people have achieved this. Their resonators each have slightly different characters, corresponding to the three different aspects of the hall's voice that each of them found most essential. They have not reached consensus on which is correct, and have agreed this is appropriate.", + id: "recipe_choir_resonator", + sourceId: "choir_resonator", sourceType: "recipe", - sourceId: "choir_resonator", - zoneId: "celestial_reaches", + title: "Choir Resonator: A Piece of the Hall's Voice", + zoneId: "celestial_reaches", }, // ── Abyssal Trench — Recipes ────────────────────────────────────────────── { - id: "recipe_pressure_forged_core", - title: "Pressure-Forged Core: Shaped by Depth", content: "The core is not forged by heat but by pressure — lowered to the deepest accessible point of the trench and left there for the exact duration your engineers have calculated is required for the ambient pressure to perform the shaping. The result is denser than anything produced in a surface forge and has a grain structure your metallurgists describe as 'compressed patience.' Cores produced this way do not fail under strain. They simply become more themselves.", + id: "recipe_pressure_forged_core", + sourceId: "pressure_forged_core", sourceType: "recipe", - sourceId: "pressure_forged_core", - zoneId: "abyssal_trench", + title: "Pressure-Forged Core: Shaped by Depth", + zoneId: "abyssal_trench", }, { - id: "recipe_ancient_fang_talisman", - title: "Ancient Fang Talisman: The Archivist's Tooth", content: "The fang used in this talisman was shed by the Elder Kraken at some point in the last century — your naturalists found it in the creature's collection, arranged with the same care as the human artefacts around it, which suggests the Kraken considered it significant. Shaped into a talisman, it retains the creature's particular quality of attention — the wearer finds it easier to notice things that have been overlooked, to see the organisational logic in apparent disorder.", + id: "recipe_ancient_fang_talisman", + sourceId: "ancient_fang_talisman", sourceType: "recipe", - sourceId: "ancient_fang_talisman", - zoneId: "abyssal_trench", + title: "Ancient Fang Talisman: The Archivist's Tooth", + zoneId: "abyssal_trench", }, // ── Infernal Court — Recipes ────────────────────────────────────────────── { - id: "recipe_court_seal", - title: "Court Seal: Authority, Formalised", content: "The seal incorporates materials from the Court Antechamber — stone worn smooth by the passage of countless supplicants, infused with the accumulated weight of all the decisions that were made after passing through that room. Wax pressed with this seal carries a subtle authority that your scholars attribute to the materials and your diplomats attribute to reputation and both groups agree is useful. Letters sealed with it are opened with slightly more care than letters sealed without it.", + id: "recipe_court_seal", + sourceId: "court_seal", sourceType: "recipe", - sourceId: "court_seal", - zoneId: "infernal_court", + title: "Court Seal: Authority, Formalised", + zoneId: "infernal_court", }, { - id: "recipe_soul_bound_catalyst", - title: "Soul-Bound Catalyst: A Careful Acquisition", content: "The catalyst is assembled from materials purchased in the Brimstone Market at prices your guild's treasurer prefers not to disclose in detail. The assembly process requires a practitioner with a very clear sense of what they are willing to spend and what they are not, because the market's materials have a tendency to negotiate further in the crafting process if given the opportunity. Your two certified Catalyst-makers have both described it as 'a conversation you need to be prepared to win.'", + id: "recipe_soul_bound_catalyst", + sourceId: "soul_bound_catalyst", sourceType: "recipe", - sourceId: "soul_bound_catalyst", - zoneId: "infernal_court", + title: "Soul-Bound Catalyst: A Careful Acquisition", + zoneId: "infernal_court", }, // ── Crystalline Spire — Recipes ─────────────────────────────────────────── { - id: "recipe_prism_array", - title: "Prism Array: Refracting the Possible", content: "The array is assembled from crystals sourced from the Facet Hall — pieces that reflect alternate moments rather than the current one, and which, when arranged in the correct configuration, allow those reflections to interact. The resulting device does not show any single alternative but a composite, a kind of average of the paths not taken, which your scholars find philosophically interesting and your tacticians find practically useful for identifying which decisions carry the most divergent consequences.", + id: "recipe_prism_array", + sourceId: "prism_array", sourceType: "recipe", - sourceId: "prism_array", - zoneId: "crystalline_spire", + title: "Prism Array: Refracting the Possible", + zoneId: "crystalline_spire", }, { - id: "recipe_possibility_engine", - title: "Possibility Engine: Small-Scale Creation", content: "The engine is a miniaturised echo of the Spire's Calculation Chamber — not capable of the Spire's full scope, but able to perform possibility calculations at a human-relevant scale, answering questions about the near future with a precision that your scholars acknowledge they don't fully understand but cannot argue with. The engine occasionally produces answers to questions that weren't asked, which your research division has learned to file carefully rather than ignore.", + id: "recipe_possibility_engine", + sourceId: "possibility_engine", sourceType: "recipe", - sourceId: "possibility_engine", - zoneId: "crystalline_spire", + title: "Possibility Engine: Small-Scale Creation", + zoneId: "crystalline_spire", }, // ── Void Sanctum — Recipes ──────────────────────────────────────────────── { - id: "recipe_null_field_generator", - title: "Null Field Generator: Manufacturing Absence", content: "The generator produces a localised null field — an area in which the void's properties apply, and the usual properties of matter, energy, and causality become suggestions rather than requirements. The construction requires materials from the Void Heart, handled through a process of indirect manipulation your engineers developed over two years of failed direct approaches. The generator is extraordinarily useful and requires a user who is comfortable with the possibility that what happens inside the field may not have an explanation.", + id: "recipe_null_field_generator", + sourceId: "null_field_generator", sourceType: "recipe", - sourceId: "null_field_generator", - zoneId: "void_sanctum", + title: "Null Field Generator: Manufacturing Absence", + zoneId: "void_sanctum", }, { - id: "recipe_sanctum_key", - title: "Sanctum Key: Permission Made Tangible", content: "The key does not open any physical lock but grants the holder a quality of presence that the Void Sanctum recognises as legitimate — a resonance that the sanctum's deeper chambers respond to by becoming navigable. Without it, the inner chambers are not locked so much as disorienting; they simply decline to arrange themselves into a coherent path. The key is less an object than a credential, and the process of making it is essentially the process of establishing why you should be allowed in.", + id: "recipe_sanctum_key", + sourceId: "sanctum_key", sourceType: "recipe", - sourceId: "sanctum_key", - zoneId: "void_sanctum", + title: "Sanctum Key: Permission Made Tangible", + zoneId: "void_sanctum", }, // ── Eternal Throne — Recipes ────────────────────────────────────────────── { - id: "recipe_crown_circlet", - title: "Crown Circlet: A Modest Authority", content: "The circlet is made from materials recovered from the Crown Vault — not the great regalia, but the smaller, less ceremonial pieces that sovereigns wore in private. Your craftspeople found these more interesting than the formal crowns: they showed wear, adjustment, personalisation. The circlet produced from them carries authority of the kind used in small decisions rather than grand ones, which is, your guild's administrator notes, the kind used most often.", + id: "recipe_crown_circlet", + sourceId: "crown_circlet", sourceType: "recipe", - sourceId: "crown_circlet", - zoneId: "eternal_throne", + title: "Crown Circlet: A Modest Authority", + zoneId: "eternal_throne", }, { - id: "recipe_eternity_bound_ring", - title: "Eternity-Bound Ring: A Promise That Means It", content: "The ring is bound during its making with a drop of water from the Eternity Well — a process that takes approximately three seconds and requires the maker to be thinking, at that exact moment, about what they want the ring to represent. The well is not discriminating; it will bind any intention. The binding it applies is not permanent in a physical sense but in a deeper one, which your philosophers describe as 'metaphysically adhesive' and your jewellers describe as 'takes to it like nothing else.'", + id: "recipe_eternity_bound_ring", + sourceId: "eternity_bound_ring", sourceType: "recipe", - sourceId: "eternity_bound_ring", - zoneId: "eternal_throne", + title: "Eternity-Bound Ring: A Promise That Means It", + zoneId: "eternal_throne", }, // ── Primordial Chaos — Recipes ──────────────────────────────────────────── { - id: "recipe_chaos_lens", - title: "Chaos Lens: Seeing Possibility Before It Commits", content: "The lens is shaped from a piece of chaos caught in the Chaos Vortex at the precise moment of incipient structure — when it was about to become something specific but hadn't yet decided what. Working with it requires a craftsperson comfortable with materials that change their minds during the process. The finished lens shows the user not the current state of observed objects but their potential states, all of them at once, as a kind of shimmering superposition that requires practice to read.", + id: "recipe_chaos_lens", + sourceId: "chaos_lens", sourceType: "recipe", - sourceId: "chaos_lens", - zoneId: "primordial_chaos", + title: "Chaos Lens: Seeing Possibility Before It Commits", + zoneId: "primordial_chaos", }, { - id: "recipe_creation_core", - title: "Creation Core: The Beginning of Something", content: "The core contains a fragment of the Creation Engine's output — not the Engine itself, which is not portable, but a piece of the generative medium it produces. This medium has the property of making other things want to become more fully themselves, which sounds pleasant and is, in controlled quantities, extremely useful. In uncontrolled quantities, your safety officer notes, it has caused three office plants, two filing cabinets, and an entire corridor to become considerably more thoroughly whatever they were.", + id: "recipe_creation_core", + sourceId: "creation_core", sourceType: "recipe", - sourceId: "creation_core", - zoneId: "primordial_chaos", + title: "Creation Core: The Beginning of Something", + zoneId: "primordial_chaos", }, // ── Infinite Expanse — Recipes ──────────────────────────────────────────── { - id: "recipe_distance_coil", - title: "Distance Coil: Compressing the Space Between", content: "The coil is wound from a material found only in the Distance Field — a substance that exists in the intervals between things and which, when shaped into a coil, retains the property of being about to arrive somewhere without yet having left. Activating it produces a compression of distance that your engineers describe as 'not teleportation but an extreme form of being almost there.' The distinction is technical and the results are practically equivalent.", + id: "recipe_distance_coil", + sourceId: "distance_coil", sourceType: "recipe", - sourceId: "distance_coil", - zoneId: "infinite_expanse", + title: "Distance Coil: Compressing the Space Between", + zoneId: "infinite_expanse", }, { - id: "recipe_infinity_prism", - title: "Infinity Prism: Refracting the Endless", content: "The prism is cut from crystal found at the Infinity Point — material that has been at the edge of scale for long enough to have absorbed something of infinity's particular quality of proportion. Light passed through it does not simply refract but extends, each wavelength continuing slightly further than physics would suggest before returning. The effect is subtle but measurable, and the applications your researchers have found for 'light that goes a bit further' are more numerous than they initially expected.", + id: "recipe_infinity_prism", + sourceId: "infinity_prism", sourceType: "recipe", - sourceId: "infinity_prism", - zoneId: "infinite_expanse", + title: "Infinity Prism: Refracting the Endless", + zoneId: "infinite_expanse", }, // ── Reality Forge — Recipes ─────────────────────────────────────────────── { - id: "recipe_reality_ingot", - title: "Reality Ingot: Substance Given Absolute Solidity", content: "The ingot is smelted in the Reality Crucible from ore that has been confirmed to be definitively real — a process that sounds redundant and involves a verification procedure your scholars developed over several months of philosophy that most of them would rather not relive. The resulting metal is exceptionally stable, resistant to any effect that operates by making things less certain about being what they are. It is the most honest material your guild has ever worked with.", + id: "recipe_reality_ingot", + sourceId: "reality_ingot", sourceType: "recipe", - sourceId: "reality_ingot", - zoneId: "reality_forge", + title: "Reality Ingot: Substance Given Absolute Solidity", + zoneId: "reality_forge", }, { - id: "recipe_universe_seed", - title: "Universe Seed: The Smallest Possible Beginning", content: "The seed is a fragment of material from the Prime Forge's working surface — the residue left by the production of the current reality, too small to constitute a universe on its own but containing, in concentrated form, the same generative potential. Your scholars have been debating for two years what to do with it. The debate has been remarkably civil, which they attribute to the seed's apparent tendency to make everything in its vicinity more genuinely itself, including their best impulses.", + id: "recipe_universe_seed", + sourceId: "universe_seed", sourceType: "recipe", - sourceId: "universe_seed", - zoneId: "reality_forge", + title: "Universe Seed: The Smallest Possible Beginning", + zoneId: "reality_forge", }, // ── Cosmic Maelstrom — Recipes ──────────────────────────────────────────── { - id: "recipe_force_lens", - title: "Force Lens: Focusing the Fundamental", content: "The lens is shaped from crystal recovered from the Force Nexus, where all four fundamental forces converge. Each crystal naturally carries a trace of all four forces, and the grinding process selectively enhances whichever the maker focuses on during the shaping — meaning two lenses made from identical raw material by different makers will have different dominant properties. Your grinding instructors have found this a reliable measure of what their students consider fundamental.", + id: "recipe_force_lens", + sourceId: "force_lens", sourceType: "recipe", - sourceId: "force_lens", - zoneId: "cosmic_maelstrom", + title: "Force Lens: Focusing the Fundamental", + zoneId: "cosmic_maelstrom", }, { - id: "recipe_maelstrom_eye", - title: "Maelstrom Eye: Calm Captured from Chaos", content: "The eye-stone is formed in the Cosmic Eye — a region of perfect equilibrium within the Maelstrom where matter sometimes crystallises from the balance of forces. Recovering these crystals requires timing the Maelstrom's cycles precisely and moving quickly during the equilibrium windows. The resulting stone carries the Eye's quality of stillness and makes the holder resistant to external forces, physical and otherwise, in a way your scholars describe as 'the Maelstrom's silence, miniaturised.'", + id: "recipe_maelstrom_eye", + sourceId: "maelstrom_eye", sourceType: "recipe", - sourceId: "maelstrom_eye", - zoneId: "cosmic_maelstrom", + title: "Maelstrom Eye: Calm Captured from Chaos", + zoneId: "cosmic_maelstrom", }, // ── Primeval Sanctum — Recipes ──────────────────────────────────────────── { - id: "recipe_ancient_memory_array", - title: "Ancient Memory Array: The World's Own Recollection", content: "The array is assembled from materials taken from the Memory Hall — fragments that have absorbed specific moments from the world's own geological and existential memory. Arranging them requires a practitioner sensitive enough to determine which memories are adjacent, and who can work with the hall's archivist, who does not speak but communicates through which memories it makes accessible when you stand in certain places. Your three certified Array-builders all describe this as the most collaborative work they have ever done.", + id: "recipe_ancient_memory_array", + sourceId: "ancient_memory_array", sourceType: "recipe", - sourceId: "ancient_memory_array", - zoneId: "primeval_sanctum", + title: "Ancient Memory Array: The World's Own Recollection", + zoneId: "primeval_sanctum", }, { - id: "recipe_first_artefact", - title: "The First Artefact: Made at the Beginning of Making", content: "The artefact is produced by a process learned from studying the Ancient Vault's oldest contents — a technique of crafting so old that it predates any named tradition and must be reconstructed each time from first principles, because it cannot be taught directly. The maker must arrive at it through genuine understanding rather than instruction. Your most recent successful maker spent four months in preparation. The artefact she produced was modest in appearance and tested at the highest functional grade your guild has ever recorded.", + id: "recipe_first_artefact", + sourceId: "first_artefact", sourceType: "recipe", - sourceId: "first_artefact", - zoneId: "primeval_sanctum", + title: "The First Artefact: Made at the Beginning of Making", + zoneId: "primeval_sanctum", }, // ── The Absolute — Recipes ──────────────────────────────────────────────── { - id: "recipe_final_truth_lens", - title: "Final Truth Lens: Seeing Without Qualification", content: "The lens is made from material found only at the Absolute Point — a substance that exists in a state of complete factual commitment, having resolved every quantum ambiguity and every logical possibility into a single, unequivocal version of itself. Light through it does not illuminate so much as it reveals: not what things appear to be, or what they could be, but what they are, unambiguously and without flattery. It is an extremely useful tool. It is not a comfortable one.", + id: "recipe_final_truth_lens", + sourceId: "final_truth_lens", sourceType: "recipe", - sourceId: "final_truth_lens", - zoneId: "the_absolute", + title: "Final Truth Lens: Seeing Without Qualification", + zoneId: "the_absolute", }, { - id: "recipe_omega_convergence", - title: "Omega Convergence: Everything, Together, Finally", content: "The convergence is the most complex item in your guild's crafting catalogue — it requires materials from every zone your guild has explored, combined in a process that your chief artificer describes as 'less crafting than conducting, as though the materials knew what they were supposed to become and simply needed someone to give them the opportunity.' The finished object is difficult to describe because it is simultaneously many things, each one complete, and the combination is not a compromise but a resolution.", + id: "recipe_omega_convergence", + sourceId: "omega_convergence", sourceType: "recipe", - sourceId: "omega_convergence", - zoneId: "the_absolute", + title: "Omega Convergence: Everything, Together, Finally", + zoneId: "the_absolute", }, -]; \ No newline at end of file +]; diff --git a/apps/web/src/data/equipmentSets.ts b/apps/web/src/data/equipmentSets.ts index cb6645d..9652f5a 100644 --- a/apps/web/src/data/equipmentSets.ts +++ b/apps/web/src/data/equipmentSets.ts @@ -1,94 +1,111 @@ +/** + * @file Equipment set data for Elysium. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable @typescript-eslint/naming-convention -- Snake_case IDs and numeric keys are conventional for game data */ +/* eslint-disable stylistic/max-len -- Long description strings cannot be split */ import type { EquipmentSet } from "@elysium/types"; -export const EQUIPMENT_SETS: EquipmentSet[] = [ +export const EQUIPMENT_SETS: 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" ], }, ]; diff --git a/apps/web/src/data/explorations.ts b/apps/web/src/data/explorations.ts index f584eb0..dfb64d3 100644 --- a/apps/web/src/data/explorations.ts +++ b/apps/web/src/data/explorations.ts @@ -1,117 +1,630 @@ +/** + * @file Exploration area data for Elysium. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable @typescript-eslint/naming-convention -- Snake_case IDs are conventional for game data */ +/* eslint-disable stylistic/max-len -- Long description strings cannot be split */ +/* eslint-disable max-lines -- Data file necessarily exceeds line limit */ export interface ExplorationAreaSummary { - id: string; - name: string; - description: string; - zoneId: string; + id: string; + name: string; + description: string; + zoneId: string; durationSeconds: number; } -export const EXPLORATION_AREAS: ExplorationAreaSummary[] = [ +export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [ // Zone 1: verdant_vale - { id: "verdant_meadow", name: "The Verdant Meadow", description: "Rolling fields of wildflowers at the edge of the guild's territory. Travellers pass through often, and occasionally leave things behind.", zoneId: "verdant_vale", durationSeconds: 3600 }, - { id: "whispering_forest", name: "The Whispering Forest", description: "Ancient trees whose canopy blocks out most of the light. The forest whispers things your scouts swear they understand, just not when they try to remember later.", zoneId: "verdant_vale", durationSeconds: 7200 }, - { id: "ancient_grove", name: "The Ancient Grove", description: "A circle of trees so old they predate the kingdom. Druids once held ceremonies here. The trees remember, and their bark holds echoes of old power.", zoneId: "verdant_vale", durationSeconds: 10800 }, - { id: "forbidden_glen", name: "The Forbidden Glen", description: "A clearing the locals will not enter after dark. Something about the bark of the trees here is different. Your scouts feel watched the entire time.", zoneId: "verdant_vale", durationSeconds: 14400 }, + { + description: + "Rolling fields of wildflowers at the edge of the guild's territory. Travellers pass through often, and occasionally leave things behind.", + durationSeconds: 3600, + id: "verdant_meadow", + name: "The Verdant Meadow", + zoneId: "verdant_vale", + }, + { + description: + "Ancient trees whose canopy blocks out most of the light. The forest whispers things your scouts swear they understand, just not when they try to remember later.", + durationSeconds: 7200, + id: "whispering_forest", + name: "The Whispering Forest", + zoneId: "verdant_vale", + }, + { + description: + "A circle of trees so old they predate the kingdom. Druids once held ceremonies here. The trees remember, and their bark holds echoes of old power.", + durationSeconds: 10_800, + id: "ancient_grove", + name: "The Ancient Grove", + zoneId: "verdant_vale", + }, + { + description: + "A clearing the locals will not enter after dark. Something about the bark of the trees here is different. Your scouts feel watched the entire time.", + durationSeconds: 14_400, + id: "forbidden_glen", + name: "The Forbidden Glen", + zoneId: "verdant_vale", + }, // Zone 2: shattered_ruins - { id: "collapsed_outpost", name: "The Collapsed Outpost", description: "What was once a military garrison, now half-buried in rubble and wild growth. The previous occupants left in a hurry and did not take everything.", zoneId: "shattered_ruins", durationSeconds: 7200 }, - { id: "cursed_lake", name: "The Cursed Lake", description: "The water here reflects things that aren't there. Something is at the bottom that doesn't want to be found, which means your scouts want very much to find it.", zoneId: "shattered_ruins", durationSeconds: 14400 }, - { id: "runic_archive", name: "The Runic Archive", description: "Buried walls covered in script no living scholar can read. The knowledge is lost but the enchantments remain, faded but still murmuring in the stone.", zoneId: "shattered_ruins", durationSeconds: 21600 }, - { id: "dragon_throne", name: "The Dragon's Throne", description: "The chamber the elder dragon called his own before your guild deposed him. He won't be back soon. Probably. The heat of his presence lingers in the stone.", zoneId: "shattered_ruins", durationSeconds: 28800 }, + { + description: + "What was once a military garrison, now half-buried in rubble and wild growth. The previous occupants left in a hurry and did not take everything.", + durationSeconds: 7200, + id: "collapsed_outpost", + name: "The Collapsed Outpost", + zoneId: "shattered_ruins", + }, + { + description: + "The water here reflects things that aren't there. Something is at the bottom that doesn't want to be found, which means your scouts want very much to find it.", + durationSeconds: 14_400, + id: "cursed_lake", + name: "The Cursed Lake", + zoneId: "shattered_ruins", + }, + { + description: + "Buried walls covered in script no living scholar can read. The knowledge is lost but the enchantments remain, faded but still murmuring in the stone.", + durationSeconds: 21_600, + id: "runic_archive", + name: "The Runic Archive", + zoneId: "shattered_ruins", + }, + { + description: + "The chamber the elder dragon called his own before your guild deposed him. He won't be back soon. Probably. The heat of his presence lingers in the stone.", + durationSeconds: 28_800, + id: "dragon_throne", + name: "The Dragon's Throne", + zoneId: "shattered_ruins", + }, // Zone 3: frozen_peaks - { id: "glacial_cave", name: "The Glacial Cave", description: "A cave carved by a glacier over thousands of years. The ice walls are so clear you can see things preserved within them from before the kingdom existed.", zoneId: "frozen_peaks", durationSeconds: 10800 }, - { id: "frozen_tundra", name: "The Frozen Tundra", description: "Flat, white, and vast. The tundra looks featureless until you know what to look for. Under the ice, there are things that were buried with intent.", zoneId: "frozen_peaks", durationSeconds: 21600 }, - { id: "void_rift", name: "The Void Rift", description: "A tear in reality that appeared after the Void Titan's defeat, miles above the world. Something leaks through it constantly. Mostly harmless. Mostly.", zoneId: "frozen_peaks", durationSeconds: 32400 }, - { id: "summit_shrine", name: "The Summit Shrine", description: "At the absolute peak, a shrine nobody remembers building. The prayers still tied to its poles are in a language no scholar has identified. Offerings remain.", zoneId: "frozen_peaks", durationSeconds: 43200 }, + { + description: + "A cave carved by a glacier over thousands of years. The ice walls are so clear you can see things preserved within them from before the kingdom existed.", + durationSeconds: 10_800, + id: "glacial_cave", + name: "The Glacial Cave", + zoneId: "frozen_peaks", + }, + { + description: + "Flat, white, and vast. The tundra looks featureless until you know what to look for. Under the ice, there are things that were buried with intent.", + durationSeconds: 21_600, + id: "frozen_tundra", + name: "The Frozen Tundra", + zoneId: "frozen_peaks", + }, + { + description: + "A tear in reality that appeared after the Void Titan's defeat, miles above the world. Something leaks through it constantly. Mostly harmless. Mostly.", + durationSeconds: 32_400, + id: "void_rift", + name: "The Void Rift", + zoneId: "frozen_peaks", + }, + { + description: + "At the absolute peak, a shrine nobody remembers building. The prayers still tied to its poles are in a language no scholar has identified. Offerings remain.", + durationSeconds: 43_200, + id: "summit_shrine", + name: "The Summit Shrine", + zoneId: "frozen_peaks", + }, // Zone 4: shadow_marshes - { id: "fog_hollow", name: "The Fog Hollow", description: "A depression in the marsh where the fog never fully lifts. Sound behaves differently here. Your scouts can hear things they probably should not.", zoneId: "shadow_marshes", durationSeconds: 18000 }, - { id: "dark_grotto", name: "The Dark Grotto", description: "A cave system beneath the marsh floor. The water drips through the ceiling in patterns that look deliberate. Nothing down here needs eyes to find you.", zoneId: "shadow_marshes", durationSeconds: 36000 }, - { id: "cursed_barrow", name: "The Cursed Barrow", description: "A burial mound. Something was interred here that should not have been — or perhaps something interred itself, which is a different and more troubling problem.", zoneId: "shadow_marshes", durationSeconds: 54000 }, - { id: "marsh_depths", name: "The Marsh Depths", description: "The bottommost point of the Shadow Marshes, where the water is perfectly still and perfectly black. Your scouts can see the bottom. The bottom is very far down.", zoneId: "shadow_marshes", durationSeconds: 72000 }, + { + description: + "A depression in the marsh where the fog never fully lifts. Sound behaves differently here. Your scouts can hear things they probably should not.", + durationSeconds: 18_000, + id: "fog_hollow", + name: "The Fog Hollow", + zoneId: "shadow_marshes", + }, + { + description: + "A cave system beneath the marsh floor. The water drips through the ceiling in patterns that look deliberate. Nothing down here needs eyes to find you.", + durationSeconds: 36_000, + id: "dark_grotto", + name: "The Dark Grotto", + zoneId: "shadow_marshes", + }, + { + description: + "A burial mound. Something was interred here that should not have been — or perhaps something interred itself, which is a different and more troubling problem.", + durationSeconds: 54_000, + id: "cursed_barrow", + name: "The Cursed Barrow", + zoneId: "shadow_marshes", + }, + { + description: + "The bottommost point of the Shadow Marshes, where the water is perfectly still and perfectly black. Your scouts can see the bottom. The bottom is very far down.", + durationSeconds: 72_000, + id: "marsh_depths", + name: "The Marsh Depths", + zoneId: "shadow_marshes", + }, // Zone 5: volcanic_depths - { id: "magma_tunnel", name: "The Magma Tunnel", description: "A natural tunnel cut by ancient lava flows. Still warm. The walls glow faintly orange in some sections, which is either residual heat or something else.", zoneId: "volcanic_depths", durationSeconds: 25200 }, - { id: "forge_chamber", name: "The Forge Chamber", description: "An ancient workshop space, built into the volcano by whoever the fire elementals served before they served no one. The fires here never went out.", zoneId: "volcanic_depths", durationSeconds: 50400 }, - { id: "fire_temple", name: "The Fire Temple", description: "A place of worship for entities that have never met a god but found the general idea appealing and decided to be worshipped instead. The fire elementals receive visitors here.", zoneId: "volcanic_depths", durationSeconds: 75600 }, - { id: "core_descent", name: "The Core Descent", description: "The lowest point your guild can reach — close enough to the planet's core that the rocks bleed metal and the air shimmers with heat haze that never quite resolves into anything.", zoneId: "volcanic_depths", durationSeconds: 100800 }, + { + description: + "A natural tunnel cut by ancient lava flows. Still warm. The walls glow faintly orange in some sections, which is either residual heat or something else.", + durationSeconds: 25_200, + id: "magma_tunnel", + name: "The Magma Tunnel", + zoneId: "volcanic_depths", + }, + { + description: + "An ancient workshop space, built into the volcano by whoever the fire elementals served before they served no one. The fires here never went out.", + durationSeconds: 50_400, + id: "forge_chamber", + name: "The Forge Chamber", + zoneId: "volcanic_depths", + }, + { + description: + "A place of worship for entities that have never met a god but found the general idea appealing and decided to be worshipped instead. The fire elementals receive visitors here.", + durationSeconds: 75_600, + id: "fire_temple", + name: "The Fire Temple", + zoneId: "volcanic_depths", + }, + { + description: + "The lowest point your guild can reach — close enough to the planet's core that the rocks bleed metal and the air shimmers with heat haze that never quite resolves into anything.", + durationSeconds: 100_800, + id: "core_descent", + name: "The Core Descent", + zoneId: "volcanic_depths", + }, // Zone 6: astral_void - { id: "star_field", name: "The Star Field", description: "Open void between reality and whatever lies beyond it. Stars in various states of life and death drift past. Your scouts learn very quickly not to touch them.", zoneId: "astral_void", durationSeconds: 36000 }, - { id: "probability_sea", name: "The Probability Sea", description: "A region where every possible outcome is equally real and they jostle each other for space. Your scouts exist in several states simultaneously here and find it disorienting.", zoneId: "astral_void", durationSeconds: 72000 }, - { id: "void_current", name: "The Void Current", description: "A river of nothing flowing through the void. It carries things from everywhere to nowhere. Some of those things are valuable, if you know how to fish from a river of nothing.", zoneId: "astral_void", durationSeconds: 108000 }, - { id: "null_zenith", name: "The Null Zenith", description: "The highest point of the astral void, where nothing exists so thoroughly that it becomes a kind of substance. Your scouts feel, for a moment, what it is like to be absolutely alone in all of existence.", zoneId: "astral_void", durationSeconds: 144000 }, + { + description: + "Open void between reality and whatever lies beyond it. Stars in various states of life and death drift past. Your scouts learn very quickly not to touch them.", + durationSeconds: 36_000, + id: "star_field", + name: "The Star Field", + zoneId: "astral_void", + }, + { + description: + "A region where every possible outcome is equally real and they jostle each other for space. Your scouts exist in several states simultaneously here and find it disorienting.", + durationSeconds: 72_000, + id: "probability_sea", + name: "The Probability Sea", + zoneId: "astral_void", + }, + { + description: + "A river of nothing flowing through the void. It carries things from everywhere to nowhere. Some of those things are valuable, if you know how to fish from a river of nothing.", + durationSeconds: 108_000, + id: "void_current", + name: "The Void Current", + zoneId: "astral_void", + }, + { + description: + "The highest point of the astral void, where nothing exists so thoroughly that it becomes a kind of substance. Your scouts feel, for a moment, what it is like to be absolutely alone in all of existence.", + durationSeconds: 144_000, + id: "null_zenith", + name: "The Null Zenith", + zoneId: "astral_void", + }, // Zone 7: celestial_reaches - { id: "light_spire", name: "The Light Spire", description: "A tower of compressed light older than the concept of architecture. The celestial host uses it as a marker. Your guild uses it as a starting point.", zoneId: "celestial_reaches", durationSeconds: 43200 }, - { id: "choir_hall", name: "The Choir Hall", description: "Where the celestial choir rehearses, continuously, for a performance that has been ongoing since before your world had an audience. The harmonics do things to objects in the vicinity.", zoneId: "celestial_reaches", durationSeconds: 86400 }, - { id: "divine_court", name: "The Divine Court", description: "Where the celestial host adjudicates disputes that have been ongoing since before your sun was lit. The proceedings are extremely formal. Interrupting them is inadvisable.", zoneId: "celestial_reaches", durationSeconds: 129600 }, - { id: "celestial_vault", name: "The Celestial Vault", description: "Where the celestial host stores things they consider too valuable to use and too important to discard. Your guild has different ideas about what 'valuable' means.", zoneId: "celestial_reaches", durationSeconds: 172800 }, + { + description: + "A tower of compressed light older than the concept of architecture. The celestial host uses it as a marker. Your guild uses it as a starting point.", + durationSeconds: 43_200, + id: "light_spire", + name: "The Light Spire", + zoneId: "celestial_reaches", + }, + { + description: + "Where the celestial choir rehearses, continuously, for a performance that has been ongoing since before your world had an audience. The harmonics do things to objects in the vicinity.", + durationSeconds: 86_400, + id: "choir_hall", + name: "The Choir Hall", + zoneId: "celestial_reaches", + }, + { + description: + "Where the celestial host adjudicates disputes that have been ongoing since before your sun was lit. The proceedings are extremely formal. Interrupting them is inadvisable.", + durationSeconds: 129_600, + id: "divine_court", + name: "The Divine Court", + zoneId: "celestial_reaches", + }, + { + description: + "Where the celestial host stores things they consider too valuable to use and too important to discard. Your guild has different ideas about what 'valuable' means.", + durationSeconds: 172_800, + id: "celestial_vault", + name: "The Celestial Vault", + zoneId: "celestial_reaches", + }, // Zone 8: abyssal_trench - { id: "trench_entrance", name: "The Trench Entrance", description: "The lip of the trench, where the shelf drops away into depths that swallow light entirely. Your scouts can hear something breathing, very slowly, from far below.", zoneId: "abyssal_trench", durationSeconds: 50400 }, - { id: "deep_current", name: "The Deep Current", description: "An underwater river at a depth that should be impossible to survive. Your scouts have learned, by necessity, to survive it anyway.", zoneId: "abyssal_trench", durationSeconds: 100800 }, - { id: "sunless_chamber", name: "The Sunless Chamber", description: "A space at the bottom of the trench so far from light that light has no meaning here. Something has been in this chamber for so long it no longer needs to breathe.", zoneId: "abyssal_trench", durationSeconds: 151200 }, - { id: "the_waiting_place", name: "The Waiting Place", description: "The absolute bottom of the trench. Something is here. It has been here since before your world was made. It is, today, patient. Your scouts are not sure this is always the case.", zoneId: "abyssal_trench", durationSeconds: 201600 }, + { + description: + "The lip of the trench, where the shelf drops away into depths that swallow light entirely. Your scouts can hear something breathing, very slowly, from far below.", + durationSeconds: 50_400, + id: "trench_entrance", + name: "The Trench Entrance", + zoneId: "abyssal_trench", + }, + { + description: + "An underwater river at a depth that should be impossible to survive. Your scouts have learned, by necessity, to survive it anyway.", + durationSeconds: 100_800, + id: "deep_current", + name: "The Deep Current", + zoneId: "abyssal_trench", + }, + { + description: + "A space at the bottom of the trench so far from light that light has no meaning here. Something has been in this chamber for so long it no longer needs to breathe.", + durationSeconds: 151_200, + id: "sunless_chamber", + name: "The Sunless Chamber", + zoneId: "abyssal_trench", + }, + { + description: + "The absolute bottom of the trench. Something is here. It has been here since before your world was made. It is, today, patient. Your scouts are not sure this is always the case.", + durationSeconds: 201_600, + id: "the_waiting_place", + name: "The Waiting Place", + zoneId: "abyssal_trench", + }, // Zone 9: infernal_court - { id: "demon_market", name: "The Demon Market", description: "An open-air market in the court's outer districts. The vendors sell things that were not legally obtained, in exchange for things that should not legally exist.", zoneId: "infernal_court", durationSeconds: 57600 }, - { id: "torment_hall", name: "The Torment Hall", description: "Where the court processes those who lost their cases. Your scouts move through it quickly and look at nothing. They still hear everything.", zoneId: "infernal_court", durationSeconds: 115200 }, - { id: "soul_forge", name: "The Soul Forge", description: "The court's industrial district, where deals are processed and the residue of completed contracts is extracted. The machinery runs on something the court considers renewable.", zoneId: "infernal_court", durationSeconds: 172800 }, - { id: "lords_chamber", name: "The Lords' Chamber", description: "The inner sanctum of the infernal court, where the demon lords make decisions that echo across aeons. Your guild should not be here. Your guild is here anyway.", zoneId: "infernal_court", durationSeconds: 230400 }, + { + description: + "An open-air market in the court's outer districts. The vendors sell things that were not legally obtained, in exchange for things that should not legally exist.", + durationSeconds: 57_600, + id: "demon_market", + name: "The Demon Market", + zoneId: "infernal_court", + }, + { + description: + "Where the court processes those who lost their cases. Your scouts move through it quickly and look at nothing. They still hear everything.", + durationSeconds: 115_200, + id: "torment_hall", + name: "The Torment Hall", + zoneId: "infernal_court", + }, + { + description: + "The court's industrial district, where deals are processed and the residue of completed contracts is extracted. The machinery runs on something the court considers renewable.", + durationSeconds: 172_800, + id: "soul_forge", + name: "The Soul Forge", + zoneId: "infernal_court", + }, + { + description: + "The inner sanctum of the infernal court, where the demon lords make decisions that echo across aeons. Your guild should not be here. Your guild is here anyway.", + durationSeconds: 230_400, + id: "lords_chamber", + name: "The Lords' Chamber", + zoneId: "infernal_court", + }, // Zone 10: crystalline_spire - { id: "facet_approach", name: "The Facet Approach", description: "The outer surface of the spire, where thousands of crystal facets reflect realities that are not the one you arrived in. Your scouts learn to focus on the ground in front of them.", zoneId: "crystalline_spire", durationSeconds: 64800 }, - { id: "calculation_chamber", name: "The Calculation Chamber", description: "A room inside the spire where the intelligence runs its oldest and most complex calculations. The numbers on the walls change too fast to read. The calculations are always correct.", zoneId: "crystalline_spire", durationSeconds: 129600 }, - { id: "mirror_hall", name: "The Mirror Hall", description: "A corridor of perfect mirrors that show not reflections but what might have been. Your scouts avoid eye contact with their alternates. The alternates do not always extend the same courtesy.", zoneId: "crystalline_spire", durationSeconds: 194400 }, - { id: "core_access", name: "The Core Access", description: "The deepest point of the spire, where the intelligence's primary substrate runs continuously. The hum of calculation is felt in the bones. Numbers that have never been numbers drift past.", zoneId: "crystalline_spire", durationSeconds: 259200 }, + { + description: + "The outer surface of the spire, where thousands of crystal facets reflect realities that are not the one you arrived in. Your scouts learn to focus on the ground in front of them.", + durationSeconds: 64_800, + id: "facet_approach", + name: "The Facet Approach", + zoneId: "crystalline_spire", + }, + { + description: + "A room inside the spire where the intelligence runs its oldest and most complex calculations. The numbers on the walls change too fast to read. The calculations are always correct.", + durationSeconds: 129_600, + id: "calculation_chamber", + name: "The Calculation Chamber", + zoneId: "crystalline_spire", + }, + { + description: + "A corridor of perfect mirrors that show not reflections but what might have been. Your scouts avoid eye contact with their alternates. The alternates do not always extend the same courtesy.", + durationSeconds: 194_400, + id: "mirror_hall", + name: "The Mirror Hall", + zoneId: "crystalline_spire", + }, + { + description: + "The deepest point of the spire, where the intelligence's primary substrate runs continuously. The hum of calculation is felt in the bones. Numbers that have never been numbers drift past.", + durationSeconds: 259_200, + id: "core_access", + name: "The Core Access", + zoneId: "crystalline_spire", + }, // Zone 11: void_sanctum - { id: "threshold", name: "The Threshold", description: "The entrance to the void sanctum, where the rules of existence become suggestions. Your scouts describe the crossing as like stepping sideways and arriving somewhere that was always there.", zoneId: "void_sanctum", durationSeconds: 72000 }, - { id: "inner_silence", name: "The Inner Silence", description: "A place inside the sanctum where everything is perfectly quiet because nothing exists to make noise. Your scouts can hear their own thoughts very clearly here. Some of them find this unsettling.", zoneId: "void_sanctum", durationSeconds: 144000 }, - { id: "resonance_chamber", name: "The Resonance Chamber", description: "A space inside the sanctum where something is calling out, continuously, to something that has not yet answered. The call is beautiful and deeply wrong.", zoneId: "void_sanctum", durationSeconds: 216000 }, - { id: "sanctum_heart", name: "The Sanctum Heart", description: "The source of the call. Something here has been reaching out for so long it no longer remembers what it is reaching toward. Your guild's arrival is, perhaps, an answer.", zoneId: "void_sanctum", durationSeconds: 288000 }, + { + description: + "The entrance to the void sanctum, where the rules of existence become suggestions. Your scouts describe the crossing as like stepping sideways and arriving somewhere that was always there.", + durationSeconds: 72_000, + id: "threshold", + name: "The Threshold", + zoneId: "void_sanctum", + }, + { + description: + "A place inside the sanctum where everything is perfectly quiet because nothing exists to make noise. Your scouts can hear their own thoughts very clearly here. Some of them find this unsettling.", + durationSeconds: 144_000, + id: "inner_silence", + name: "The Inner Silence", + zoneId: "void_sanctum", + }, + { + description: + "A space inside the sanctum where something is calling out, continuously, to something that has not yet answered. The call is beautiful and deeply wrong.", + durationSeconds: 216_000, + id: "resonance_chamber", + name: "The Resonance Chamber", + zoneId: "void_sanctum", + }, + { + description: + "The source of the call. Something here has been reaching out for so long it no longer remembers what it is reaching toward. Your guild's arrival is, perhaps, an answer.", + durationSeconds: 288_000, + id: "sanctum_heart", + name: "The Sanctum Heart", + zoneId: "void_sanctum", + }, // Zone 12: eternal_throne - { id: "throne_approach", name: "The Throne Approach", description: "The long road to the eternal throne. Countless beings have walked it, seeking audience, seeking power, seeking something the throne has always already decided about them.", zoneId: "eternal_throne", durationSeconds: 79200 }, - { id: "dominion_hall", name: "The Dominion Hall", description: "The ante-chamber of absolute power. Records are kept here of everything that has ever been ruled and everything that has ever been lost. The records go back further than memory.", zoneId: "eternal_throne", durationSeconds: 158400 }, - { id: "eternity_vault", name: "The Eternity Vault", description: "Where things are stored that have nowhere else to go. Objects of power that cannot be used, secrets that cannot be shared, and wealth that belongs to entities that stopped existing before your world was born.", zoneId: "eternal_throne", durationSeconds: 237600 }, - { id: "the_seat", name: "The Seat", description: "The eternal throne itself. Whoever sits here has sat here since the beginning. They observe your guild's presence with neither surprise nor emotion. They have been expecting you. They have been expecting everyone.", zoneId: "eternal_throne", durationSeconds: 316800 }, + { + description: + "The long road to the eternal throne. Countless beings have walked it, seeking audience, seeking power, seeking something the throne has always already decided about them.", + durationSeconds: 79_200, + id: "throne_approach", + name: "The Throne Approach", + zoneId: "eternal_throne", + }, + { + description: + "The ante-chamber of absolute power. Records are kept here of everything that has ever been ruled and everything that has ever been lost. The records go back further than memory.", + durationSeconds: 158_400, + id: "dominion_hall", + name: "The Dominion Hall", + zoneId: "eternal_throne", + }, + { + description: + "Where things are stored that have nowhere else to go. Objects of power that cannot be used, secrets that cannot be shared, and wealth that belongs to entities that stopped existing before your world was born.", + durationSeconds: 237_600, + id: "eternity_vault", + name: "The Eternity Vault", + zoneId: "eternal_throne", + }, + { + description: + "The eternal throne itself. Whoever sits here has sat here since the beginning. They observe your guild's presence with neither surprise nor emotion. They have been expecting you. They have been expecting everyone.", + durationSeconds: 316_800, + id: "the_seat", + name: "The Seat", + zoneId: "eternal_throne", + }, // Zone 13: primordial_chaos - { id: "creation_storm", name: "The Creation Storm", description: "A permanent storm at the edge of the chaos zone where things are constantly being made and unmade simultaneously. Your scouts move through it quickly and try not to look at what they might become.", zoneId: "primordial_chaos", durationSeconds: 86400 }, - { id: "unmaking_sea", name: "The Unmaking Sea", description: "A vast ocean of something that is exactly the opposite of matter. Your scouts cross it by not thinking too hard about what they are standing on.", zoneId: "primordial_chaos", durationSeconds: 172800 }, - { id: "probability_void", name: "The Probability Void", description: "A space where all possible outcomes already happened and none of them mattered. Your scouts find this philosophically challenging and practically navigable.", zoneId: "primordial_chaos", durationSeconds: 259200 }, - { id: "chaos_core", name: "The Chaos Core", description: "The centre of all primordial chaos. Everything is here and nothing is here and both statements are entirely accurate. Your scouts report the experience as indescribable, then describe it for three hours.", zoneId: "primordial_chaos", durationSeconds: 345600 }, + { + description: + "A permanent storm at the edge of the chaos zone where things are constantly being made and unmade simultaneously. Your scouts move through it quickly and try not to look at what they might become.", + durationSeconds: 86_400, + id: "creation_storm", + name: "The Creation Storm", + zoneId: "primordial_chaos", + }, + { + description: + "A vast ocean of something that is exactly the opposite of matter. Your scouts cross it by not thinking too hard about what they are standing on.", + durationSeconds: 172_800, + id: "unmaking_sea", + name: "The Unmaking Sea", + zoneId: "primordial_chaos", + }, + { + description: + "A space where all possible outcomes already happened and none of them mattered. Your scouts find this philosophically challenging and practically navigable.", + durationSeconds: 259_200, + id: "probability_void", + name: "The Probability Void", + zoneId: "primordial_chaos", + }, + { + description: + "The centre of all primordial chaos. Everything is here and nothing is here and both statements are entirely accurate. Your scouts report the experience as indescribable, then describe it for three hours.", + durationSeconds: 345_600, + id: "chaos_core", + name: "The Chaos Core", + zoneId: "primordial_chaos", + }, // Zone 14: infinite_expanse - { id: "first_horizon", name: "The First Horizon", description: "The first horizon you reach in the infinite expanse, which looks exactly like the starting point from behind but is provably, mathematically, somewhere else. Your scouts are sceptical but cannot argue with the math.", zoneId: "infinite_expanse", durationSeconds: 93600 }, - { id: "middle_nowhere", name: "The Middle of Nowhere", description: "There is no centre of the infinite expanse. This is the centre of the infinite expanse. Both things are true. Your scouts have stopped asking questions and started collecting samples.", zoneId: "infinite_expanse", durationSeconds: 187200 }, - { id: "edge_approach", name: "The Edge Approach", description: "The road toward the edge that the expanse does not have. Your scouts know it does not exist. They are getting closer to it anyway.", zoneId: "infinite_expanse", durationSeconds: 280800 }, - { id: "the_furthest", name: "The Furthest", description: "As far as any being has ever gone in the infinite expanse. Your scouts hold this record now. They are not entirely sure whether to be proud or frightened.", zoneId: "infinite_expanse", durationSeconds: 374400 }, + { + description: + "The first horizon you reach in the infinite expanse, which looks exactly like the starting point from behind but is provably, mathematically, somewhere else. Your scouts are sceptical but cannot argue with the math.", + durationSeconds: 93_600, + id: "first_horizon", + name: "The First Horizon", + zoneId: "infinite_expanse", + }, + { + description: + "There is no centre of the infinite expanse. This is the centre of the infinite expanse. Both things are true. Your scouts have stopped asking questions and started collecting samples.", + durationSeconds: 187_200, + id: "middle_nowhere", + name: "The Middle of Nowhere", + zoneId: "infinite_expanse", + }, + { + description: + "The road toward the edge that the expanse does not have. Your scouts know it does not exist. They are getting closer to it anyway.", + durationSeconds: 280_800, + id: "edge_approach", + name: "The Edge Approach", + zoneId: "infinite_expanse", + }, + { + description: + "As far as any being has ever gone in the infinite expanse. Your scouts hold this record now. They are not entirely sure whether to be proud or frightened.", + durationSeconds: 374_400, + id: "the_furthest", + name: "The Furthest", + zoneId: "infinite_expanse", + }, // Zone 15: reality_forge - { id: "workshop_entrance", name: "The Workshop Entrance", description: "The outer area of the reality forge, where the overflow of unrealised realities pools and cools. Things that never quite existed are everywhere here, and some of them are extremely useful.", zoneId: "reality_forge", durationSeconds: 100800 }, - { id: "creation_floor", name: "The Creation Floor", description: "Where realities are assembled from the raw components of existence. The work here is continuous and has been going on since before your universe was queued.", zoneId: "reality_forge", durationSeconds: 201600 }, - { id: "master_forge", name: "The Master Forge", description: "The primary forging station, where major realities are hammered into their final shape. The hammers are larger than planets. The anvil has never been named because no one has ever successfully described it.", zoneId: "reality_forge", durationSeconds: 302400 }, - { id: "forge_core", name: "The Forge Core", description: "The energy source that powers the entire reality forge. It has been running since before time was a meaningful concept. What powers it is not a question that has been answered by anyone who came here to ask it.", zoneId: "reality_forge", durationSeconds: 403200 }, + { + description: + "The outer area of the reality forge, where the overflow of unrealised realities pools and cools. Things that never quite existed are everywhere here, and some of them are extremely useful.", + durationSeconds: 100_800, + id: "workshop_entrance", + name: "The Workshop Entrance", + zoneId: "reality_forge", + }, + { + description: + "Where realities are assembled from the raw components of existence. The work here is continuous and has been going on since before your universe was queued.", + durationSeconds: 201_600, + id: "creation_floor", + name: "The Creation Floor", + zoneId: "reality_forge", + }, + { + description: + "The primary forging station, where major realities are hammered into their final shape. The hammers are larger than planets. The anvil has never been named because no one has ever successfully described it.", + durationSeconds: 302_400, + id: "master_forge", + name: "The Master Forge", + zoneId: "reality_forge", + }, + { + description: + "The energy source that powers the entire reality forge. It has been running since before time was a meaningful concept. What powers it is not a question that has been answered by anyone who came here to ask it.", + durationSeconds: 403_200, + id: "forge_core", + name: "The Forge Core", + zoneId: "reality_forge", + }, // Zone 16: cosmic_maelstrom - { id: "outer_current", name: "The Outer Current", description: "The outermost spiral of the cosmic maelstrom, where the forces are at their most navigable — which still means they routinely shatter planets that wander too close.", zoneId: "cosmic_maelstrom", durationSeconds: 108000 }, - { id: "debris_field", name: "The Debris Field", description: "The accumulated wreckage of everything the maelstrom has consumed, compressed into a navigable (mostly) field. Your scouts move through it quickly. Things in debris fields become part of the debris field.", zoneId: "cosmic_maelstrom", durationSeconds: 216000 }, - { id: "force_confluence", name: "The Force Confluence", description: "Where the fundamental forces of the cosmos intersect inside the maelstrom. Gravity and electromagnetism and things that do not have names yet jostle each other here with consequences that exceed polite description.", zoneId: "cosmic_maelstrom", durationSeconds: 324000 }, - { id: "eye_approach", name: "The Eye Approach", description: "The path to the maelstrom's impossible centre — the one point of absolute calm surrounded by forces that make galaxies look fragile. Your scouts have never been so far in. They are doing this anyway.", zoneId: "cosmic_maelstrom", durationSeconds: 432000 }, + { + description: + "The outermost spiral of the cosmic maelstrom, where the forces are at their most navigable — which still means they routinely shatter planets that wander too close.", + durationSeconds: 108_000, + id: "outer_current", + name: "The Outer Current", + zoneId: "cosmic_maelstrom", + }, + { + description: + "The accumulated wreckage of everything the maelstrom has consumed, compressed into a navigable (mostly) field. Your scouts move through it quickly. Things in debris fields become part of the debris field.", + durationSeconds: 216_000, + id: "debris_field", + name: "The Debris Field", + zoneId: "cosmic_maelstrom", + }, + { + description: + "Where the fundamental forces of the cosmos intersect inside the maelstrom. Gravity and electromagnetism and things that do not have names yet jostle each other here with consequences that exceed polite description.", + durationSeconds: 324_000, + id: "force_confluence", + name: "The Force Confluence", + zoneId: "cosmic_maelstrom", + }, + { + description: + "The path to the maelstrom's impossible centre — the one point of absolute calm surrounded by forces that make galaxies look fragile. Your scouts have never been so far in. They are doing this anyway.", + durationSeconds: 432_000, + id: "eye_approach", + name: "The Eye Approach", + zoneId: "cosmic_maelstrom", + }, // Zone 17: primeval_sanctum - { id: "first_steps", name: "The First Steps", description: "The entrance to the oldest place. The floor here was walked before walking was invented, which is philosophically impossible and physically evident.", zoneId: "primeval_sanctum", durationSeconds: 115200 }, - { id: "ancient_archive", name: "The Ancient Archive", description: "A collection of records that predate the concept of records. The information stored here concerns things that no longer exist, but the records persist because the sanctum will not let them stop.", zoneId: "primeval_sanctum", durationSeconds: 230400 }, - { id: "memory_chamber", name: "The Memory Chamber", description: "Where the sanctum stores the memory of the first moment of existence. The memory is perfect, complete, and overwhelming. Your scouts spend the minimum time here and speak little for some time after.", zoneId: "primeval_sanctum", durationSeconds: 345600 }, - { id: "the_oldest_place", name: "The Oldest Place", description: "There is nothing older than this. The sanctum's deepest point, where the very first thing that ever was still is, unchanged, because nothing in the universe has had long enough to change it.", zoneId: "primeval_sanctum", durationSeconds: 460800 }, + { + description: + "The entrance to the oldest place. The floor here was walked before walking was invented, which is philosophically impossible and physically evident.", + durationSeconds: 115_200, + id: "first_steps", + name: "The First Steps", + zoneId: "primeval_sanctum", + }, + { + description: + "A collection of records that predate the concept of records. The information stored here concerns things that no longer exist, but the records persist because the sanctum will not let them stop.", + durationSeconds: 230_400, + id: "ancient_archive", + name: "The Ancient Archive", + zoneId: "primeval_sanctum", + }, + { + description: + "Where the sanctum stores the memory of the first moment of existence. The memory is perfect, complete, and overwhelming. Your scouts spend the minimum time here and speak little for some time after.", + durationSeconds: 345_600, + id: "memory_chamber", + name: "The Memory Chamber", + zoneId: "primeval_sanctum", + }, + { + description: + "There is nothing older than this. The sanctum's deepest point, where the very first thing that ever was still is, unchanged, because nothing in the universe has had long enough to change it.", + durationSeconds: 460_800, + id: "the_oldest_place", + name: "The Oldest Place", + zoneId: "primeval_sanctum", + }, // Zone 18: the_absolute - { id: "edge_of_everything", name: "The Edge of Everything", description: "The boundary between existence and non-existence. On one side: everything there is. On the other: everything there isn't. The view from here is indescribable and has been described by your scouts at length.", zoneId: "the_absolute", durationSeconds: 129600 }, - { id: "truth_approach", name: "The Truth Approach", description: "The road to the final truth, which your guild has been walking toward since the first step in the Verdant Vale. It looks like every other road your guild has walked. It feels different.", zoneId: "the_absolute", durationSeconds: 259200 }, - { id: "final_antechamber", name: "The Final Antechamber", description: "One step from the absolute. The door ahead is the last door. Your guild has opened every other door. This one opens when you are ready, which is something only the absolute can determine.", zoneId: "the_absolute", durationSeconds: 388800 }, - { id: "the_absolute_heart", name: "The Absolute Heart", description: "The final truth, at the end of all things. There is nothing beyond this. Your guild stands here, at the end, and finds that the end is not empty. It has been waiting for you specifically.", zoneId: "the_absolute", durationSeconds: 518400 }, + { + description: + "The boundary between existence and non-existence. On one side: everything there is. On the other: everything there isn't. The view from here is indescribable and has been described by your scouts at length.", + durationSeconds: 129_600, + id: "edge_of_everything", + name: "The Edge of Everything", + zoneId: "the_absolute", + }, + { + description: + "The road to the final truth, which your guild has been walking toward since the first step in the Verdant Vale. It looks like every other road your guild has walked. It feels different.", + durationSeconds: 259_200, + id: "truth_approach", + name: "The Truth Approach", + zoneId: "the_absolute", + }, + { + description: + "One step from the absolute. The door ahead is the last door. Your guild has opened every other door. This one opens when you are ready, which is something only the absolute can determine.", + durationSeconds: 388_800, + id: "final_antechamber", + name: "The Final Antechamber", + zoneId: "the_absolute", + }, + { + description: + "The final truth, at the end of all things. There is nothing beyond this. Your guild stands here, at the end, and finds that the end is not empty. It has been waiting for you specifically.", + durationSeconds: 518_400, + id: "the_absolute_heart", + name: "The Absolute Heart", + zoneId: "the_absolute", + }, ]; diff --git a/apps/web/src/data/materials.ts b/apps/web/src/data/materials.ts index 9a0d592..7cb8af5 100644 --- a/apps/web/src/data/materials.ts +++ b/apps/web/src/data/materials.ts @@ -1,93 +1,480 @@ +/** + * @file Crafting material data for Elysium. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable @typescript-eslint/naming-convention -- Snake_case IDs are conventional for game data */ +/* eslint-disable stylistic/max-len -- Long description strings cannot be split */ +/* eslint-disable max-lines -- Data file necessarily exceeds line limit */ import type { Material } from "@elysium/types"; -export const MATERIALS: Material[] = [ +export const MATERIALS: 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", + }, ]; diff --git a/apps/web/src/data/prestigeUpgrades.ts b/apps/web/src/data/prestigeUpgrades.ts index 6927a44..e10ff3d 100644 --- a/apps/web/src/data/prestigeUpgrades.ts +++ b/apps/web/src/data/prestigeUpgrades.ts @@ -1,220 +1,252 @@ +/** + * @file Prestige upgrade data for Elysium. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable @typescript-eslint/naming-convention -- Snake_case IDs and SCREAMING_SNAKE are conventional for game data */ +/* eslint-disable stylistic/max-len -- Long description strings cannot be split */ +/* eslint-disable import/group-exports -- Multiple exports are required for this data module */ import type { PrestigeUpgrade } from "@elysium/types"; -export const PRESTIGE_UPGRADES: PrestigeUpgrade[] = [ +export const PRESTIGE_UPGRADES: 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", - description: "You decipher ancient runic inscriptions that unlock vast potential. All production ×25.", category: "income", - runestonesCost: 2_000, - multiplier: 25, + description: + "You decipher ancient runic inscriptions that unlock vast potential. All production ×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", - description: "The oldest runes, carved before memory began, yield their secrets at last. All production ×500.", category: "income", + description: + "The oldest runes, carved before memory began, yield their secrets at last. All production ×500.", + id: "income_10", + multiplier: 500, + name: "Eternal Rune I", runestonesCost: 30_000, - multiplier: 500, }, { - id: "income_11", - name: "Eternal Rune II", - description: "Eternal runes resonate with the heartbeat of creation itself. All production ×1,000.", category: "income", + description: + "Eternal runes resonate with the heartbeat of creation itself. All production ×1,000.", + 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-Upgrades ─────────────────────────────────────────────── { - id: "runestone_gain_1", - name: "Runic Legacy", - description: "Your runestone attunement grows with each prestige. Earn 25% more runestones from future prestiges.", category: "runestones", + description: + "Your runestone attunement grows with each prestige. Earn 25% more runestones from future prestiges.", + id: "runestone_gain_1", + multiplier: 1.25, + name: "Runic Legacy", runestonesCost: 50, - multiplier: 1.25, }, { - id: "runestone_gain_2", - name: "Eternal Legacy", - description: "Your legend transcends individual lifetimes. Earn 50% more runestones from future prestiges.", category: "runestones", + description: + "Your legend transcends individual lifetimes. Earn 50% more runestones from future prestiges.", + id: "runestone_gain_2", + multiplier: 1.5, + name: "Eternal Legacy", runestonesCost: 500, - multiplier: 1.5, }, ]; export const PRESTIGE_UPGRADE_CATEGORY_LABELS: Record<string, string> = { - income: "🪙 Global Income", - click: "👆 Click Power", - essence: "✨ Essence Production", - crystals: "💎 Crystal Rewards", + click: "👆 Click Power", + crystals: "💎 Crystal Rewards", + essence: "✨ Essence Production", + income: "🪙 Global Income", runestones: "🔮 Runestone Gain", - utility: "⚙️ Utility", + utility: "⚙️ Utility", }; diff --git a/apps/web/src/data/recipes.ts b/apps/web/src/data/recipes.ts index 6262ba9..16b454b 100644 --- a/apps/web/src/data/recipes.ts +++ b/apps/web/src/data/recipes.ts @@ -1,327 +1,480 @@ +/** + * @file Crafting recipe data for Elysium. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable @typescript-eslint/naming-convention -- Snake_case IDs are conventional for game data */ +/* eslint-disable stylistic/max-len -- Long description strings cannot be split */ +/* eslint-disable max-lines -- Data file necessarily exceeds line limit */ import type { CraftingRecipe } from "@elysium/types"; -export const RECIPES: CraftingRecipe[] = [ +export const RECIPES: 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 }, }, ]; diff --git a/apps/web/src/data/transcendenceUpgrades.ts b/apps/web/src/data/transcendenceUpgrades.ts index dcf35bd..9c0b9bf 100644 --- a/apps/web/src/data/transcendenceUpgrades.ts +++ b/apps/web/src/data/transcendenceUpgrades.ts @@ -1,142 +1,166 @@ +/** + * @file Transcendence upgrade data for Elysium. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable @typescript-eslint/naming-convention -- Snake_case IDs and SCREAMING_SNAKE are conventional for game data */ +/* eslint-disable stylistic/max-len -- Long description strings cannot be split */ +/* eslint-disable import/group-exports -- Multiple exports are required for this data module */ import type { TranscendenceUpgrade } from "@elysium/types"; -export const DEFAULT_TRANSCENDENCE_UPGRADES: TranscendenceUpgrade[] = [ +export const DEFAULT_TRANSCENDENCE_UPGRADES: 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", }, ]; export const TRANSCENDENCE_UPGRADES = DEFAULT_TRANSCENDENCE_UPGRADES; export const TRANSCENDENCE_UPGRADE_CATEGORY_LABELS: Record<string, string> = { - income: "✨ Income Multipliers", - combat: "⚔️ Combat Multipliers", - prestige_threshold: "🎯 Prestige Quality of Life — Threshold", + combat: "⚔️ Combat Multipliers", + echo_meta: "🌌 Echo Meta Upgrades", + income: "✨ Income Multipliers", prestige_runestones: "🔮 Prestige Quality of Life — Runestones", - echo_meta: "🌌 Echo Meta Upgrades", + prestige_threshold: "🎯 Prestige Quality of Life — Threshold", }; diff --git a/apps/web/src/engine/tick.ts b/apps/web/src/engine/tick.ts index 2f96823..975dc36 100644 --- a/apps/web/src/engine/tick.ts +++ b/apps/web/src/engine/tick.ts @@ -1,16 +1,40 @@ -import type { Achievement, Equipment, GameState } from "@elysium/types"; -import { computeSetBonuses, getActiveCompanionBonus } from "@elysium/types"; +/** + * @file Game tick engine for Elysium. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable @typescript-eslint/naming-convention -- Snake_case IDs and SCREAMING_SNAKE are conventional for game data */ +/* eslint-disable complexity -- Tick engine is inherently complex with many game systems */ +/* eslint-disable max-lines-per-function -- Tick engine processes many systems in one pass for performance */ +/* eslint-disable max-statements -- Tick engine requires many state variables across all game systems */ +/* eslint-disable max-lines -- Engine file necessarily exceeds line limit */ +/* eslint-disable import/group-exports -- Exports appear alongside their definitions for readability */ +/* eslint-disable import/exports-last -- Exports appear alongside their definitions for readability */ +/* eslint-disable unicorn/no-array-reduce -- reduce is the most readable approach for multiplier chains */ +/* eslint-disable max-nested-callbacks -- Tick engine requires nested array operations for game logic */ +import { + type Achievement, + type Equipment, + type GameState, + computeSetBonuses, + getActiveCompanionBonus, +} from "@elysium/types"; import { EQUIPMENT_SETS } from "../data/equipmentSets.js"; import { updateChallengeProgress } from "../utils/dailyChallenges.js"; /** * Checks all achievements against the current game state and returns an updated * achievements array, marking newly-met conditions with the current timestamp. + * @param state - The current game state to check achievements against. + * @returns Updated achievements array with newly unlocked achievements timestamped. */ -const checkAchievements = (state: GameState): Achievement[] => { +const checkAchievements = (state: GameState): Array<Achievement> => { const now = Date.now(); - return (state.achievements ?? []).map((achievement) => { - if (achievement.unlockedAt !== null) return achievement; + return state.achievements.map((achievement) => { + if (achievement.unlockedAt !== null) { + return achievement; + } const { condition } = achievement; let met = false; @@ -23,27 +47,45 @@ const checkAchievements = (state: GameState): Achievement[] => { met = state.player.totalClicks >= condition.amount; break; case "bossesDefeated": - met = state.bosses.filter((b) => b.status === "defeated").length >= condition.amount; + met + = state.bosses.filter((boss) => { + return boss.status === "defeated"; + }).length >= condition.amount; break; case "questsCompleted": - met = state.quests.filter((q) => q.status === "completed").length >= condition.amount; + met + = state.quests.filter((quest) => { + return quest.status === "completed"; + }).length >= condition.amount; break; case "adventurerTotal": - met = state.adventurers.reduce((sum, a) => sum + a.count, 0) >= condition.amount; + met + = state.adventurers.reduce((sum, adventurer) => { + return sum + adventurer.count; + }, 0) >= condition.amount; break; case "prestigeCount": met = state.prestige.count >= condition.amount; break; case "equipmentOwned": - met = (state.equipment ?? []).filter((e) => e.owned).length >= condition.amount; + met + = state.equipment.filter((item) => { + return item.owned; + }).length >= condition.amount; break; + default: + /* V8 ignore next -- @preserve */ break; } - return met ? { ...achievement, unlockedAt: now } : achievement; + return met + ? { ...achievement, unlockedAt: now } + : achievement; }); }; -/** Maximum value any resource can accumulate to. Beyond this JS floats lose all useful precision. */ +/** + * Maximum value any resource can accumulate to. Beyond this JS floats lose all useful precision. + */ export const RESOURCE_CAP = 1e300; /** @@ -51,56 +93,85 @@ export const RESOURCE_CAP = 1e300; * On failure the quest resets to "available" with no rewards; the player must wait the * full duration again on their next attempt. */ -const ZONE_FAILURE_CHANCE: Record<string, number> = { - verdant_vale: 0.10, - shattered_ruins: 0.12, - frozen_peaks: 0.14, - shadow_marshes: 0.16, - volcanic_depths: 0.18, - astral_void: 0.20, +const zoneFailureChance: Record<string, number> = { + abyssal_trench: 0.24, + astral_void: 0.2, celestial_reaches: 0.22, - abyssal_trench: 0.24, - infernal_court: 0.26, + cosmic_maelstrom: 0.4, crystalline_spire: 0.28, - void_sanctum: 0.30, - eternal_throne: 0.32, - primordial_chaos: 0.34, - infinite_expanse: 0.36, - reality_forge: 0.38, - cosmic_maelstrom: 0.40, - primeval_sanctum: 0.40, - the_absolute: 0.40, + eternal_throne: 0.32, + frozen_peaks: 0.14, + infernal_court: 0.26, + infinite_expanse: 0.36, + primeval_sanctum: 0.4, + primordial_chaos: 0.34, + reality_forge: 0.38, + shadow_marshes: 0.16, + shattered_ruins: 0.12, + the_absolute: 0.4, + verdant_vale: 0.1, + void_sanctum: 0.3, + volcanic_depths: 0.18, }; -const capResource = (value: number): number => Math.min(value, RESOURCE_CAP); +/** + * Caps a resource value at RESOURCE_CAP. + * @param value - The resource value to cap. + * @returns The capped value. + */ +const capResource = (value: number): number => { + return Math.min(value, RESOURCE_CAP); +}; /** * Pure function — applies one game tick to the state. - * deltaSeconds: time elapsed since last tick. + * DeltaSeconds: time elapsed since last tick. * Returns a new GameState (does not mutate the original). + * @param state - The current game state. + * @param deltaSeconds - Time elapsed since last tick in seconds. + * @returns A new GameState with the tick applied. */ -export const applyTick = (state: GameState, deltaSeconds: number): GameState => { - const equippedItems: Equipment[] = (state.equipment ?? []).filter((e) => e.equipped); - const equipmentGoldMultiplier = equippedItems.reduce( - (mult, e) => mult * (e.bonus.goldMultiplier ?? 1), - 1, - ); - const setGoldMultiplier = computeSetBonuses(equippedItems.map((e) => e.id), EQUIPMENT_SETS).goldMultiplier; +export const applyTick = ( + state: GameState, + deltaSeconds: number, +): GameState => { + const equippedItems: Array<Equipment> = state.equipment.filter((item) => { + return item.equipped; + }); + const equipmentGoldMultiplier = equippedItems.reduce((mult, item) => { + return mult * (item.bonus.goldMultiplier ?? 1); + }, 1); + const setGoldMultiplier = computeSetBonuses( + equippedItems.map((item) => { + return item.id; + }), + EQUIPMENT_SETS, + ).goldMultiplier; const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1; const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1; const runestonesCrystal = state.prestige.runestonesCrystalMultiplier ?? 1; const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1; const craftedGoldMultiplier = state.exploration?.craftedGoldMultiplier ?? 1; - const craftedEssenceMultiplier = state.exploration?.craftedEssenceMultiplier ?? 1; + const craftedEssenceMultiplier + = state.exploration?.craftedEssenceMultiplier ?? 1; const companionBonus = getActiveCompanionBonus( state.companions?.activeCompanionId, state.companions?.unlockedCompanionIds ?? [], ); - const companionGoldMult = companionBonus?.type === "passiveGold" ? 1 + companionBonus.value : 1; - const companionEssenceMult = companionBonus?.type === "essenceIncome" ? 1 + companionBonus.value : 1; - const companionQuestTimeReduction = companionBonus?.type === "questTime" ? companionBonus.value : 0; + const companionGoldMult + = companionBonus?.type === "passiveGold" + ? 1 + companionBonus.value + : 1; + const companionEssenceMult + = companionBonus?.type === "essenceIncome" + ? 1 + companionBonus.value + : 1; + const companionQuestTimeReduction + = companionBonus?.type === "questTime" + ? companionBonus.value + : 0; let goldGained = 0; let essenceGained = 0; @@ -110,39 +181,44 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState => continue; } - const upgradeMultiplier = state.upgrades - .filter( - (u) => - u.purchased && - (u.target === "global" || - (u.target === "adventurer" && u.adventurerId === adventurer.id)), - ) - .reduce((mult, upgrade) => mult * upgrade.multiplier, 1); + const upgradeMultiplier = state.upgrades. + filter((upgrade) => { + const isGlobal = upgrade.target === "global"; + const isThisAdventurer + = upgrade.target === "adventurer" + && upgrade.adventurerId === adventurer.id; + return upgrade.purchased && (isGlobal || isThisAdventurer); + }). + reduce((mult, upgrade) => { + return mult * upgrade.multiplier; + }, 1); const prestige = state.prestige.productionMultiplier; - goldGained += - adventurer.goldPerSecond * - adventurer.count * - upgradeMultiplier * - prestige * - runestonesIncome * - echoIncome * - equipmentGoldMultiplier * - setGoldMultiplier * - craftedGoldMultiplier * - companionGoldMult * - deltaSeconds; + const goldPerTick + = adventurer.goldPerSecond + * adventurer.count + * upgradeMultiplier + * prestige + * runestonesIncome + * echoIncome + * equipmentGoldMultiplier + * setGoldMultiplier + * craftedGoldMultiplier + * companionGoldMult + * deltaSeconds; + goldGained = goldGained + goldPerTick; - essenceGained += - adventurer.essencePerSecond * - adventurer.count * - upgradeMultiplier * - prestige * - runestonesEssence * - craftedEssenceMultiplier * - companionEssenceMult * - deltaSeconds; + const essencePerTick + = adventurer.essencePerSecond + * adventurer.count + * upgradeMultiplier + * prestige + * runestonesEssence + * craftedEssenceMultiplier + * companionEssenceMult + * deltaSeconds; + essenceGained = essenceGained + essencePerTick; } // Complete active quests and apply their rewards @@ -153,47 +229,68 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState => let updatedUpgrades = state.upgrades; let updatedAdventurers = state.adventurers; - let updatedEquipment = state.equipment ?? []; + let updatedEquipmentReference = state.equipment; const updatedQuests = state.quests.map((quest) => { - const effectiveQuestMs = quest.durationSeconds * (1 - companionQuestTimeReduction) * 1000; + const effectiveQuestMs + = quest.durationSeconds * (1 - companionQuestTimeReduction) * 1000; if ( - quest.status !== "active" || - quest.startedAt == null || - now < quest.startedAt + effectiveQuestMs + quest.status !== "active" + || quest.startedAt === undefined + || now < quest.startedAt + effectiveQuestMs ) { return quest; } - const failureChance = ZONE_FAILURE_CHANCE[quest.zoneId] ?? 0.20; + const failureChance = zoneFailureChance[quest.zoneId] ?? 0.2; if (Math.random() < failureChance) { const { startedAt: _dropped, ...questWithoutStartedAt } = quest; - return { ...questWithoutStartedAt, status: "available" as const, lastFailedAt: now }; + return { + ...questWithoutStartedAt, + lastFailedAt: now, + status: "available" as const, + }; } for (const reward of quest.rewards) { - if (reward.type === "gold" && reward.amount != null) { - questGold += reward.amount; - } else if (reward.type === "essence" && reward.amount != null) { - questEssence += reward.amount; - } else if (reward.type === "crystals" && reward.amount != null) { - questCrystals += reward.amount * runestonesCrystal; - } else if (reward.type === "upgrade" && reward.targetId != null) { - updatedUpgrades = updatedUpgrades.map((u) => - u.id === reward.targetId ? { ...u, unlocked: true } : u, - ); - } else if (reward.type === "adventurer" && reward.targetId != null) { - updatedAdventurers = updatedAdventurers.map((a) => - a.id === reward.targetId ? { ...a, unlocked: true } : a, - ); - } else if (reward.type === "equipment" && reward.targetId != null) { - const targetId = reward.targetId; - updatedEquipment = updatedEquipment.map((e) => { - if (e.id !== targetId) return e; - const slotEmpty = !updatedEquipment.some( - (other) => other.type === e.type && other.equipped, - ); - return { ...e, owned: true, equipped: slotEmpty || e.equipped }; + if (reward.type === "gold" && reward.amount !== undefined) { + questGold = questGold + reward.amount; + } else if (reward.type === "essence" && reward.amount !== undefined) { + questEssence = questEssence + reward.amount; + } else if (reward.type === "crystals" && reward.amount !== undefined) { + const crystalAmount = reward.amount; + const crystalGain = crystalAmount * runestonesCrystal; + questCrystals = questCrystals + crystalGain; + } else if (reward.type === "upgrade" && reward.targetId !== undefined) { + updatedUpgrades = updatedUpgrades.map((upgrade) => { + return upgrade.id === reward.targetId + ? { ...upgrade, unlocked: true } + : upgrade; + }); + } else if ( + reward.type === "adventurer" + && reward.targetId !== undefined + ) { + updatedAdventurers = updatedAdventurers.map((adventurer) => { + return adventurer.id === reward.targetId + ? { ...adventurer, unlocked: true } + : adventurer; + }); + } else if (reward.type === "equipment" && reward.targetId !== undefined) { + const rewardTargetId = reward.targetId; + const currentEquipment = updatedEquipmentReference; + updatedEquipmentReference = currentEquipment.map((item) => { + if (item.id !== rewardTargetId) { + return item; + } + const slotEmpty = !currentEquipment.some((other) => { + return other.type === item.type && other.equipped; + }); + return { + ...item, + equipped: slotEmpty || item.equipped, + owned: true, + }; }); } } @@ -203,27 +300,49 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState => // Unlock quests whose prerequisites are now all completed and whose zone is unlocked const completedIds = new Set( - updatedQuests.filter((q) => q.status === "completed").map((q) => q.id), + updatedQuests. + filter((quest) => { + return quest.status === "completed"; + }). + map((quest) => { + return quest.id; + }), ); const fullyUpdatedQuests = updatedQuests.map((quest) => { - if (quest.status !== "locked") return quest; - const zone = state.zones.find((z) => z.id === quest.zoneId); - if (zone?.status === "locked") return quest; - if (quest.prerequisiteIds.every((id) => completedIds.has(id))) { + if (quest.status !== "locked") { + return quest; + } + const questZone = state.zones.find((searchZone) => { + return searchZone.id === quest.zoneId; + }); + if (questZone?.status === "locked") { + return quest; + } + if ( + quest.prerequisiteIds.every((id) => { + return completedIds.has(id); + }) + ) { return { ...quest, status: "available" as const }; } return quest; }); - // Unlock zones whose both conditions are now satisfied after quest completion: - // (1) the gate boss has been defeated, (2) the gate quest is now completed + /* + * Unlock zones whose both conditions are now satisfied after quest completion: + * (1) the gate boss has been defeated, (2) the gate quest is now completed + */ const updatedZones = state.zones.map((zone) => { - if (zone.status === "unlocked") return zone; - const bossOk = - zone.unlockBossId == null || - state.bosses.some((b) => b.id === zone.unlockBossId && b.status === "defeated"); - const questOk = - zone.unlockQuestId == null || completedIds.has(zone.unlockQuestId); + if (zone.status === "unlocked") { + return zone; + } + const bossOk + = zone.unlockBossId === null + || state.bosses.some((boss) => { + return boss.id === zone.unlockBossId && boss.status === "defeated"; + }); + const questOk + = zone.unlockQuestId === null || completedIds.has(zone.unlockQuestId); if (bossOk && questOk) { return { ...zone, status: "unlocked" as const }; } @@ -232,34 +351,43 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState => // Activate the first boss in any zone that just became unlocked this tick const newlyUnlockedZoneIds = new Set( - updatedZones - .filter((z) => { - const wasLocked = state.zones.find((oz) => oz.id === z.id)?.status === "locked"; - return z.status === "unlocked" && wasLocked; - }) - .map((z) => z.id), + updatedZones. + filter((zone) => { + const wasLocked + = state.zones.find((originalZone) => { + return originalZone.id === zone.id; + })?.status === "locked"; + return zone.status === "unlocked" && wasLocked; + }). + map((zone) => { + return zone.id; + }), ); let updatedBosses = state.bosses; if (newlyUnlockedZoneIds.size > 0) { updatedBosses = state.bosses.map((boss) => { - if (!newlyUnlockedZoneIds.has(boss.zoneId ?? "")) return boss; - const zoneBosses = state.bosses.filter((b) => b.zoneId === boss.zoneId); - const firstBoss = zoneBosses[0]; - if (firstBoss?.id === boss.id && boss.status === "locked") { - return { ...boss, status: "available" as const }; + if (newlyUnlockedZoneIds.has(boss.zoneId)) { + const zoneBosses = state.bosses.filter((zoneBoss) => { + return zoneBoss.zoneId === boss.zoneId; + }); + const [ firstBoss ] = zoneBosses; + if (firstBoss?.id === boss.id && boss.status === "locked") { + return { ...boss, status: "available" as const }; + } } return boss; }); } // Count quests newly completed this tick and update daily challenge progress - const newlyCompletedQuestCount = updatedQuests.filter( - (q, i) => q.status === "completed" && state.quests[i]?.status !== "completed", - ).length; + const newlyCompletedQuestCount = updatedQuests.filter((quest, index) => { + const wasNotCompleted = state.quests[index]?.status !== "completed"; + return quest.status === "completed" && wasNotCompleted; + }).length; let updatedDailyChallenges = state.dailyChallenges; let challengeCrystals = 0; - if (updatedDailyChallenges && newlyCompletedQuestCount > 0) { + if (updatedDailyChallenges !== undefined && newlyCompletedQuestCount > 0) { const result = updateChallengeProgress( updatedDailyChallenges, "questsCompleted", @@ -269,66 +397,95 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState => challengeCrystals = result.crystalsAwarded; } - const newGold = capResource(state.resources.gold + goldGained + questGold); - const newEssence = capResource(state.resources.essence + essenceGained + questEssence); - const newTotalGoldEarned = state.player.totalGoldEarned + goldGained + questGold; + const goldValue = capResource(state.resources.gold + goldGained + questGold); + const essenceValue = capResource( + state.resources.essence + essenceGained + questEssence, + ); + const totalGoldEarnedValue + = state.player.totalGoldEarned + goldGained + questGold; const partialState: GameState = { ...state, resources: { ...state.resources, - gold: newGold, - essence: newEssence, - crystals: capResource(state.resources.crystals + questCrystals + challengeCrystals), + crystals: capResource( + state.resources.crystals + questCrystals + challengeCrystals, + ), + essence: essenceValue, + gold: goldValue, }, - ...(updatedDailyChallenges !== undefined ? { dailyChallenges: updatedDailyChallenges } : {}), - player: { - ...state.player, - totalGoldEarned: newTotalGoldEarned, - }, - quests: fullyUpdatedQuests, - upgrades: updatedUpgrades, + ...updatedDailyChallenges === undefined + ? {} + : { dailyChallenges: updatedDailyChallenges }, adventurers: updatedAdventurers, - equipment: updatedEquipment, - bosses: updatedBosses, - zones: updatedZones, - lastTickAt: now, + bosses: updatedBosses, + equipment: updatedEquipmentReference, + lastTickAt: now, + player: { + ...state.player, + totalGoldEarned: totalGoldEarnedValue, + }, + quests: fullyUpdatedQuests, + upgrades: updatedUpgrades, + zones: updatedZones, }; // Check achievements and apply crystal rewards for newly unlocked ones const updatedAchievements = checkAchievements(partialState); - const crystalsFromAchievements = updatedAchievements.reduce((sum, a, i) => { - const wasLocked = (state.achievements ?? [])[i]?.unlockedAt === null; - const isNowUnlocked = a.unlockedAt !== null; - if (wasLocked && isNowUnlocked) { - return sum + (a.reward?.crystals ?? 0); - } - return sum; - }, 0); + const crystalsFromAchievements = updatedAchievements.reduce( + (sum, achievement, index) => { + const wasLocked = state.achievements[index]?.unlockedAt === null; + const isNowUnlocked = achievement.unlockedAt !== null; + if (wasLocked && isNowUnlocked) { + return sum + (achievement.reward?.crystals ?? 0); + } + return sum; + }, + 0, + ); return { ...partialState, achievements: updatedAchievements, - resources: { + resources: { ...partialState.resources, - crystals: capResource(partialState.resources.crystals + crystalsFromAchievements), + crystals: capResource( + partialState.resources.crystals + crystalsFromAchievements, + ), }, }; }; /** * Calculates the effective click power, including upgrades and equipped trinkets. + * @param state - The current game state. + * @returns The calculated click power value. */ export const calculateClickPower = (state: GameState): number => { - const clickMultiplier = state.upgrades - .filter((u) => u.purchased && u.target === "click") - .reduce((mult, upgrade) => mult * upgrade.multiplier, 1); + const clickMultiplier = state.upgrades. + filter((upgrade) => { + return upgrade.purchased && upgrade.target === "click"; + }). + reduce((mult, upgrade) => { + return mult * upgrade.multiplier; + }, 1); - const equippedItems = (state.equipment ?? []).filter((e) => e.equipped); - const equipmentClickMultiplier = equippedItems - .filter((e) => e.bonus.clickMultiplier != null) - .reduce((mult, e) => mult * (e.bonus.clickMultiplier ?? 1), 1); - const setClickMultiplier = computeSetBonuses(equippedItems.map((e) => e.id), EQUIPMENT_SETS).clickMultiplier; + const equippedItems = state.equipment.filter((item) => { + return item.equipped; + }); + const equipmentClickMultiplier = equippedItems. + filter((item) => { + return item.bonus.clickMultiplier !== undefined; + }). + reduce((mult, item) => { + return mult * (item.bonus.clickMultiplier ?? 1); + }, 1); + const setClickMultiplier = computeSetBonuses( + equippedItems.map((item) => { + return item.id; + }), + EQUIPMENT_SETS, + ).clickMultiplier; const runestonesClick = state.prestige.runestonesClickMultiplier ?? 1; const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1; @@ -338,17 +495,20 @@ export const calculateClickPower = (state: GameState): number => { state.companions?.activeCompanionId, state.companions?.unlockedCompanionIds ?? [], ); - const companionClickMult = companionClickBonus?.type === "clickGold" ? 1 + companionClickBonus.value : 1; + const companionClickMult + = companionClickBonus?.type === "clickGold" + ? 1 + companionClickBonus.value + : 1; return ( - state.baseClickPower * - clickMultiplier * - state.prestige.productionMultiplier * - runestonesClick * - echoIncome * - equipmentClickMultiplier * - setClickMultiplier * - craftedClickMultiplier * - companionClickMult + state.baseClickPower + * clickMultiplier + * state.prestige.productionMultiplier + * runestonesClick + * echoIncome + * equipmentClickMultiplier + * setClickMultiplier + * craftedClickMultiplier + * companionClickMult ); }; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 06c5c6a..4f73ada 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,6 +1,13 @@ +/** + * @file Application entry point. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable import/no-unassigned-import -- CSS import has no exports to assign */ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { App } from "./App.js"; +import { App } from "./app.js"; import "./styles.css"; const rootElement = document.getElementById("root"); diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index aa6555d..72343e5 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1,5 +1,7 @@ /* ===================== RESET & BASE ===================== */ -*, *::before, *::after { +*, +*::before, +*::after { box-sizing: border-box; margin: 0; padding: 0; @@ -64,7 +66,11 @@ body { .prestige-badge { margin-left: auto; - background: linear-gradient(135deg, var(--colour-accent), var(--colour-accent-light)); + background: linear-gradient( + 135deg, + var(--colour-accent), + var(--colour-accent-light) + ); padding: 0.25rem 0.75rem; border-radius: 999px; font-size: 0.85rem; @@ -168,7 +174,11 @@ body { .game-title { font-size: 1.5rem; font-weight: 800; - background: linear-gradient(135deg, var(--colour-accent), var(--colour-accent-light)); + background: linear-gradient( + 135deg, + var(--colour-accent), + var(--colour-accent-light) + ); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; @@ -215,7 +225,9 @@ body { height: 120px; overflow: hidden; padding: 0; - transition: transform 0.1s, box-shadow 0.1s; + transition: + transform 0.1s, + box-shadow 0.1s; width: 120px; box-shadow: 0 0 20px rgba(124, 58, 237, 0.4); } @@ -271,7 +283,10 @@ body { font-size: 0.8rem; font-weight: 600; padding: 0.3rem 0.6rem; - transition: background 0.15s, border-color 0.15s, color 0.15s; + transition: + background 0.15s, + border-color 0.15s, + color 0.15s; } .batch-button:hover { @@ -493,9 +508,15 @@ body { white-space: nowrap; } -.quest-badge.locked { color: var(--colour-text-muted); } -.quest-badge.active { color: var(--colour-warning); } -.quest-badge.completed { color: var(--colour-success); } +.quest-badge.locked { + color: var(--colour-text-muted); +} +.quest-badge.active { + color: var(--colour-warning); +} +.quest-badge.completed { + color: var(--colour-success); +} /* ===================== BOSSES ===================== */ .boss-list { @@ -719,7 +740,9 @@ body { font-size: 0.85rem; font-weight: 600; padding: 0.4rem 0.8rem; - transition: background 0.2s, border-color 0.2s; + transition: + background 0.2s, + border-color 0.2s; white-space: nowrap; } @@ -1053,8 +1076,14 @@ body { /* ===================== CLICK FLOAT ===================== */ @keyframes float-up { - 0% { opacity: 1; transform: translate(-50%, 0); } - 100% { opacity: 0; transform: translate(-50%, -70px); } + 0% { + opacity: 1; + transform: translate(-50%, 0); + } + 100% { + opacity: 0; + transform: translate(-50%, -70px); + } } .click-button-wrapper { @@ -1069,7 +1098,7 @@ body { font-weight: 700; pointer-events: none; position: absolute; - text-shadow: 0 1px 4px rgba(0,0,0,0.5); + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5); user-select: none; } @@ -1162,10 +1191,18 @@ body { } /* Rarity border-left colours */ -.equipment-card.rarity-common { border-left-color: #9ca3af; } -.equipment-card.rarity-rare { border-left-color: #3b82f6; } -.equipment-card.rarity-epic { border-left-color: #a855f7; } -.equipment-card.rarity-legendary { border-left-color: #f59e0b; } +.equipment-card.rarity-common { + border-left-color: #9ca3af; +} +.equipment-card.rarity-rare { + border-left-color: #3b82f6; +} +.equipment-card.rarity-epic { + border-left-color: #a855f7; +} +.equipment-card.rarity-legendary { + border-left-color: #f59e0b; +} .equipment-icon { font-size: 1.5rem; @@ -1197,10 +1234,22 @@ body { padding: 0.1rem 0.45rem; } -.rarity-badge.rarity-common { background: rgba(156, 163, 175, 0.2); color: #9ca3af; } -.rarity-badge.rarity-rare { background: rgba(59, 130, 246, 0.2); color: #60a5fa; } -.rarity-badge.rarity-epic { background: rgba(168, 85, 247, 0.2); color: #c084fc; } -.rarity-badge.rarity-legendary { background: rgba(245, 158, 11, 0.2); color: #fbbf24; } +.rarity-badge.rarity-common { + background: rgba(156, 163, 175, 0.2); + color: #9ca3af; +} +.rarity-badge.rarity-rare { + background: rgba(59, 130, 246, 0.2); + color: #60a5fa; +} +.rarity-badge.rarity-epic { + background: rgba(168, 85, 247, 0.2); + color: #c084fc; +} +.rarity-badge.rarity-legendary { + background: rgba(245, 158, 11, 0.2); + color: #fbbf24; +} .equipment-description { color: var(--colour-text-muted); @@ -1363,8 +1412,14 @@ body { /* ===================== ACHIEVEMENT TOAST ===================== */ @keyframes slide-in-right { - from { opacity: 0; transform: translateX(120%); } - to { opacity: 1; transform: translateX(0); } + from { + opacity: 0; + transform: translateX(120%); + } + to { + opacity: 1; + transform: translateX(0); + } } .achievement-toast-container { @@ -1383,7 +1438,7 @@ body { background: var(--colour-surface); border: 1px solid var(--colour-gold); border-radius: var(--radius); - box-shadow: 0 4px 20px rgba(0,0,0,0.4); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); cursor: pointer; display: flex; gap: 0.75rem; @@ -1574,7 +1629,11 @@ body { } .profile-apotheosis-badge { - background: linear-gradient(135deg, rgba(120, 53, 15, 0.2), rgba(217, 119, 6, 0.2)); + background: linear-gradient( + 135deg, + rgba(120, 53, 15, 0.2), + rgba(217, 119, 6, 0.2) + ); border: 1px solid rgba(217, 119, 6, 0.5); border-radius: 1rem; color: #fbbf24; @@ -2304,7 +2363,11 @@ body { } .codex-progress-fill { - background: linear-gradient(90deg, var(--colour-essence), var(--colour-accent-light)); + background: linear-gradient( + 90deg, + var(--colour-essence), + var(--colour-accent-light) + ); border-radius: 4px; height: 100%; transition: width 0.4s ease; @@ -2425,7 +2488,7 @@ body { background: var(--colour-surface); border: 1px solid var(--colour-essence); border-radius: var(--radius); - box-shadow: 0 4px 20px rgba(0,0,0,0.4); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); cursor: pointer; display: flex; gap: 0.75rem; @@ -2449,7 +2512,6 @@ body { vertical-align: middle; } - /* ── Transcendence ─────────────────────────────────────────────────────── */ .transcendence-badge { @@ -2588,7 +2650,11 @@ body { } .apotheosis-reward { - background: linear-gradient(135deg, rgba(120, 53, 15, 0.2), rgba(217, 119, 6, 0.2)); + background: linear-gradient( + 135deg, + rgba(120, 53, 15, 0.2), + rgba(217, 119, 6, 0.2) + ); border: 1px solid #d97706; border-radius: var(--radius); font-size: 1rem; @@ -3128,7 +3194,9 @@ body { cursor: pointer; font-size: 0.85rem; padding: 0.3rem 0.75rem; - transition: border-color 0.15s, color 0.15s; + transition: + border-color 0.15s, + color 0.15s; } .character-sheet-edit-btn:hover { @@ -3815,7 +3883,9 @@ body { font-size: 0.8rem; font-weight: 600; padding: 0.3rem 0.7rem; - transition: background 0.15s, color 0.15s; + transition: + background 0.15s, + color 0.15s; } .auto-toggle-off { @@ -4120,7 +4190,9 @@ body { font-size: 0.85rem; font-weight: 600; cursor: pointer; - transition: background 0.15s, color 0.15s; + transition: + background 0.15s, + color 0.15s; } .companion-select-btn:hover { @@ -4166,7 +4238,9 @@ body { color: var(--colour-text); font-size: 0.75rem; cursor: pointer; - transition: background 0.15s, color 0.15s; + transition: + background 0.15s, + color 0.15s; } .story-tab-btn:hover:not(.locked) { @@ -4247,7 +4321,9 @@ body { color: var(--colour-text); text-align: left; cursor: pointer; - transition: background 0.15s, border-color 0.15s; + transition: + background 0.15s, + border-color 0.15s; } .story-choice-btn:hover { diff --git a/apps/web/src/utils/dailyChallenges.ts b/apps/web/src/utils/dailyChallenges.ts index 88c814c..f81a433 100644 --- a/apps/web/src/utils/dailyChallenges.ts +++ b/apps/web/src/utils/dailyChallenges.ts @@ -1,3 +1,9 @@ +/** + * @file Daily challenge progress utilities for Elysium. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ import type { DailyChallengeState, DailyChallengeType } from "@elysium/types"; /** @@ -6,6 +12,10 @@ import type { DailyChallengeState, DailyChallengeType } from "@elysium/types"; * * Note: challenge generation and daily resets are handled server-side only. * This utility is purely for client-side progress tracking. + * @param challengeState - The current state of all daily challenges. + * @param type - The type of challenge to update progress for. + * @param amount - The amount to increment progress by. + * @returns The updated challenges and total crystals awarded. */ export const updateChallengeProgress = ( challengeState: DailyChallengeState, @@ -17,16 +27,27 @@ 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 progressValue = Math.min( + challenge.progress + amount, + challenge.target, + ); + const nowCompleted = progressValue >= challenge.target; - if (nowCompleted) crystalsAwarded += challenge.rewardCrystals; + if (nowCompleted) { + crystalsAwarded = crystalsAwarded + challenge.rewardCrystals; + } - return { ...challenge, progress: newProgress, completed: nowCompleted }; + return { + ...challenge, + completed: nowCompleted, + progress: progressValue, + }; }), }; - return { updatedChallenges, crystalsAwarded }; + return { crystalsAwarded, updatedChallenges }; }; diff --git a/apps/web/src/utils/format.ts b/apps/web/src/utils/format.ts index 3b9b445..f4bfa60 100644 --- a/apps/web/src/utils/format.ts +++ b/apps/web/src/utils/format.ts @@ -1,47 +1,74 @@ +/* eslint-disable stylistic/lines-around-comment -- Need the comment! */ +/** + * @file Number formatting utilities for Elysium. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ import type { NumberFormat } from "@elysium/types"; // Named suffixes up to 1e33 (Decillion). Letter-based suffixes take over from 1e36 onwards. -const NAMED_SUFFIXES: { threshold: number; suffix: string }[] = [ - { threshold: 1e33, suffix: "Dc" }, // Decillion - { threshold: 1e30, suffix: "No" }, // Nonillion - { threshold: 1e27, suffix: "Oc" }, // Octillion - { threshold: 1e24, suffix: "Sp" }, // Septillion - { threshold: 1e21, suffix: "Sx" }, // Sextillion - { threshold: 1e18, suffix: "Qi" }, // Quintillion - { threshold: 1e15, suffix: "Qa" }, // Quadrillion - { threshold: 1e12, suffix: "T" }, // Trillion - { threshold: 1e9, suffix: "B" }, // Billion - { threshold: 1e6, suffix: "M" }, // Million - { threshold: 1e3, suffix: "K" }, // Thousand +const namedSuffixes: Array<{ threshold: number; suffix: string }> = [ + // Decillion + { suffix: "Dc", threshold: 1e33 }, + // Nonillion + { suffix: "No", threshold: 1e30 }, + // Octillion + { suffix: "Oc", threshold: 1e27 }, + // Septillion + { suffix: "Sp", threshold: 1e24 }, + // Sextillion + { suffix: "Sx", threshold: 1e21 }, + // Quintillion + { suffix: "Qi", threshold: 1e18 }, + // Quadrillion + { suffix: "Qa", threshold: 1e15 }, + // Trillion + { suffix: "T", threshold: 1e12 }, + // Billion + { suffix: "B", threshold: 1e9 }, + // Million + { suffix: "M", threshold: 1e6 }, + // Thousand + { suffix: "K", threshold: 1e3 }, ]; // Letter suffixes start at 1e36 ("a"), stepping by 1000 each time (i.e. +3 exponent per letter). -const LETTER_BASE_EXP = 36; +const letterBaseExp = 36; /** * Generates an alphabetic suffix for a given index: - * 0 → "a", 1 → "b", ..., 25 → "z", - * 26 → "aa", 27 → "ab", ..., 701 → "zz", 702 → "aaa", ... + * 0 → "a", 1 → "b", ..., 25 → "z", + * 26 → "aa", 27 → "ab", ..., 701 → "zz", 702 → "aaa", ... + * @param index - The zero-based index to convert to a letter suffix. + * @returns The alphabetic suffix string. */ const getLetterSuffix = (index: number): string => { let result = ""; let n = index; do { - result = String.fromCharCode(97 + (n % 26)) + result; + const percent = n % 26; + result = String.fromCodePoint(97 + percent) + result; n = Math.floor(n / 26) - 1; } while (n >= 0); return result; }; +/** + * Formats a number with a named or letter-based suffix. + * @param value - The number to format. + * @returns The formatted string with suffix. + */ const formatSuffix = (value: number): string => { - if (value >= Math.pow(10, LETTER_BASE_EXP)) { + if (value >= Math.pow(10, letterBaseExp)) { const exp = Math.floor(Math.log10(value)); - const stepsAboveBase = Math.floor((exp - LETTER_BASE_EXP) / 3); - const divisorExp = LETTER_BASE_EXP + stepsAboveBase * 3; + const stepsAboveBase = Math.floor((exp - letterBaseExp) / 3); + const steps = stepsAboveBase * 3; + const divisorExp = letterBaseExp + steps; const divisor = Math.pow(10, divisorExp); return `${(value / divisor).toFixed(2)}${getLetterSuffix(stepsAboveBase)}`; } - for (const { threshold, suffix } of NAMED_SUFFIXES) { + for (const { threshold, suffix } of namedSuffixes) { if (value >= threshold) { return `${(value / threshold).toFixed(2)}${suffix}`; } @@ -52,39 +79,61 @@ const formatSuffix = (value: number): string => { /** * Formats a number in scientific notation: e.g. 1.23e15. * Falls back to K/M/B/T style below 1 million. + * @param value - The number to format. + * @returns The formatted string in scientific notation. */ const formatScientific = (value: number): string => { - if (value < 1e6) return formatSuffix(value); - // toExponential handles all magnitudes JS can represent (up to ~1.8e308) + if (value < 1e6) { + return formatSuffix(value); + } + // ToExponential handles all magnitudes JS can represent (up to ~1.8e308) return value.toExponential(2).replace("e+", "e"); }; /** * Formats a number in engineering notation (exponent always a multiple of 3): * e.g. 12.35E12, 1.23E300. Falls back to K/M/B/T style below 1 million. + * @param value - The number to format. + * @returns The formatted string in engineering notation. */ const formatEngineering = (value: number): string => { - if (value < 1e6) return formatSuffix(value); + if (value < 1e6) { + return formatSuffix(value); + } const exp = Math.floor(Math.log10(value)); const engExp = Math.floor(exp / 3) * 3; const mantissa = value / Math.pow(10, engExp); - return `${mantissa.toFixed(2)}E${engExp}`; + return `${mantissa.toFixed(2)}E${String(engExp)}`; }; /** * Formats a number for display using the player's chosen notation style. * Negative values are formatted with a leading minus sign. + * @param value - The number to format. + * @param format - The notation style to use. + * @returns The formatted number string. */ -export const formatNumber = (value: number, format: NumberFormat = "suffix"): string => { - if (!isFinite(value) || isNaN(value)) return "0"; - if (value < 0) return `-${formatNumber(-value, format)}`; +export const formatNumber = ( + value: number, + format: NumberFormat = "suffix", +): string => { + if (!Number.isFinite(value) || Number.isNaN(value)) { + return "0"; + } + if (value < 0) { + return `-${formatNumber(-value, format)}`; + } switch (format) { case "scientific": return formatScientific(value); case "engineering": return formatEngineering(value); - default: + case "suffix": return formatSuffix(value); + default: { + /* V8 ignore next -- @preserve */ + return formatSuffix(value); + } } }; diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts deleted file mode 100644 index cad8a0a..0000000 --- a/apps/web/src/vite-env.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -/// <reference types="vite/client" /> - -declare const __WEB_VERSION__: string; diff --git a/apps/web/src/viteEnvironment.d.ts b/apps/web/src/viteEnvironment.d.ts new file mode 100644 index 0000000..8fdec58 --- /dev/null +++ b/apps/web/src/viteEnvironment.d.ts @@ -0,0 +1,11 @@ +/** + * @file Vite environment type declarations. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable @typescript-eslint/naming-convention -- Vite define replacement requires double underscores */ +/* eslint-disable no-underscore-dangle -- Vite define replacement requires double underscores */ +/// <reference types="vite/client" /> + +declare const __WEB_VERSION__: string; diff --git a/apps/web/test/dailyChallenges.spec.ts b/apps/web/test/dailyChallenges.spec.ts new file mode 100644 index 0000000..5956247 --- /dev/null +++ b/apps/web/test/dailyChallenges.spec.ts @@ -0,0 +1,103 @@ +/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ +/* eslint-disable max-nested-callbacks -- Vitest structure requires nesting */ +/** + * @file Tests for daily challenge progress utilities. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { describe, expect, it } from "vitest"; +import { updateChallengeProgress } from "../src/utils/dailyChallenges.js"; +import type { DailyChallengeState } from "@elysium/types"; + +const makeState = (overrides: Partial<DailyChallengeState> = {}): DailyChallengeState => ({ + challenges: [], + lastResetDay: "2024-01-01", + ...overrides, +}); + +describe("updateChallengeProgress", () => { + it("should return unchanged state when there are no challenges", () => { + const state = makeState(); + const { crystalsAwarded, updatedChallenges } = updateChallengeProgress(state, "clicks", 10); + expect(crystalsAwarded).toBe(0); + expect(updatedChallenges.challenges).toHaveLength(0); + }); + + it("should skip challenges of a different type", () => { + const state = makeState({ + challenges: [ + { completed: false, progress: 0, rewardCrystals: 5, target: 10, type: "gold_earned" }, + ], + }); + const { crystalsAwarded, updatedChallenges } = updateChallengeProgress(state, "clicks", 5); + expect(crystalsAwarded).toBe(0); + expect(updatedChallenges.challenges[0].progress).toBe(0); + }); + + it("should skip already-completed challenges", () => { + const state = makeState({ + challenges: [ + { completed: true, progress: 10, rewardCrystals: 5, target: 10, type: "clicks" }, + ], + }); + const { crystalsAwarded, updatedChallenges } = updateChallengeProgress(state, "clicks", 5); + expect(crystalsAwarded).toBe(0); + expect(updatedChallenges.challenges[0].progress).toBe(10); + }); + + it("should increment progress without completing the challenge", () => { + const state = makeState({ + challenges: [ + { completed: false, progress: 0, rewardCrystals: 5, target: 10, type: "clicks" }, + ], + }); + const { crystalsAwarded, updatedChallenges } = updateChallengeProgress(state, "clicks", 4); + expect(crystalsAwarded).toBe(0); + expect(updatedChallenges.challenges[0].progress).toBe(4); + expect(updatedChallenges.challenges[0].completed).toBe(false); + }); + + it("should complete the challenge and award crystals when progress meets target", () => { + const state = makeState({ + challenges: [ + { completed: false, progress: 8, rewardCrystals: 5, target: 10, type: "clicks" }, + ], + }); + const { crystalsAwarded, updatedChallenges } = updateChallengeProgress(state, "clicks", 2); + expect(crystalsAwarded).toBe(5); + expect(updatedChallenges.challenges[0].completed).toBe(true); + expect(updatedChallenges.challenges[0].progress).toBe(10); + }); + + it("should cap progress at the target when amount overshoots", () => { + const state = makeState({ + challenges: [ + { completed: false, progress: 0, rewardCrystals: 3, target: 10, type: "clicks" }, + ], + }); + const { crystalsAwarded, updatedChallenges } = updateChallengeProgress(state, "clicks", 100); + expect(crystalsAwarded).toBe(3); + expect(updatedChallenges.challenges[0].progress).toBe(10); + expect(updatedChallenges.challenges[0].completed).toBe(true); + }); + + it("should accumulate crystals from multiple completed challenges", () => { + const state = makeState({ + challenges: [ + { completed: false, progress: 0, rewardCrystals: 5, target: 10, type: "clicks" }, + { completed: false, progress: 0, rewardCrystals: 10, target: 10, type: "clicks" }, + ], + }); + const { crystalsAwarded, updatedChallenges } = updateChallengeProgress(state, "clicks", 10); + expect(crystalsAwarded).toBe(15); + expect(updatedChallenges.challenges[0].completed).toBe(true); + expect(updatedChallenges.challenges[1].completed).toBe(true); + }); + + it("should preserve the lastResetDay from the original state", () => { + const state = makeState({ lastResetDay: "2024-06-15" }); + const { updatedChallenges } = updateChallengeProgress(state, "clicks", 1); + expect(updatedChallenges.lastResetDay).toBe("2024-06-15"); + }); +}); diff --git a/apps/web/test/format.spec.ts b/apps/web/test/format.spec.ts new file mode 100644 index 0000000..73c78fe --- /dev/null +++ b/apps/web/test/format.spec.ts @@ -0,0 +1,140 @@ +/* eslint-disable max-lines -- Test suites naturally have many cases */ +/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ +/* eslint-disable max-nested-callbacks -- Vitest structure requires nesting */ +/** + * @file Tests for number formatting utilities. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { describe, expect, it } from "vitest"; +import { formatNumber } from "../src/utils/format.js"; + +describe("formatNumber", () => { + describe("edge cases", () => { + it("should return '0' for NaN", () => { + expect(formatNumber(Number.NaN)).toBe("0"); + }); + + it("should return '0' for Infinity", () => { + expect(formatNumber(Infinity)).toBe("0"); + }); + + it("should return '0' for -Infinity", () => { + expect(formatNumber(-Infinity)).toBe("0"); + }); + + it("should format negative numbers with a leading minus sign", () => { + expect(formatNumber(-1000)).toBe("-1.00K"); + }); + + it("should format zero as '0.0'", () => { + expect(formatNumber(0)).toBe("0.0"); + }); + }); + + describe("suffix format (default)", () => { + it("should format small numbers with one decimal place", () => { + expect(formatNumber(999)).toBe("999.0"); + }); + + it("should format thousands with K suffix", () => { + expect(formatNumber(1000)).toBe("1.00K"); + }); + + it("should format millions with M suffix", () => { + expect(formatNumber(1_000_000)).toBe("1.00M"); + }); + + it("should format billions with B suffix", () => { + expect(formatNumber(1_000_000_000)).toBe("1.00B"); + }); + + it("should format trillions with T suffix", () => { + expect(formatNumber(1e12)).toBe("1.00T"); + }); + + it("should format quadrillions with Qa suffix", () => { + expect(formatNumber(1e15)).toBe("1.00Qa"); + }); + + it("should format quintillions with Qi suffix", () => { + expect(formatNumber(1e18)).toBe("1.00Qi"); + }); + + it("should format sextillions with Sx suffix", () => { + expect(formatNumber(1e21)).toBe("1.00Sx"); + }); + + it("should format septillions with Sp suffix", () => { + expect(formatNumber(1e24)).toBe("1.00Sp"); + }); + + it("should format octillions with Oc suffix", () => { + expect(formatNumber(1e27)).toBe("1.00Oc"); + }); + + it("should format nonillions with No suffix", () => { + expect(formatNumber(1e30)).toBe("1.00No"); + }); + + it("should format decillions with Dc suffix", () => { + expect(formatNumber(1e33)).toBe("1.00Dc"); + }); + + it("should format values >= 1e36 with letter suffix 'a'", () => { + expect(formatNumber(1e36)).toBe("1.00a"); + }); + + it("should format values >= 1e39 with letter suffix 'b'", () => { + expect(formatNumber(1e39)).toBe("1.00b"); + }); + + it("should format values at 26th letter step with 'z'", () => { + expect(formatNumber(1e36 * Math.pow(10, 25 * 3))).toBe("1.00z"); + }); + + it("should format values at 27th letter step with 'aa'", () => { + expect(formatNumber(1e36 * Math.pow(10, 26 * 3))).toBe("1.00aa"); + }); + }); + + describe("scientific format", () => { + it("should fall back to suffix format below 1e6", () => { + expect(formatNumber(500, "scientific")).toBe("500.0"); + }); + + it("should format values >= 1e6 in scientific notation", () => { + expect(formatNumber(1_230_000, "scientific")).toBe("1.23e6"); + }); + + it("should format large values in scientific notation", () => { + expect(formatNumber(1e18, "scientific")).toBe("1.00e18"); + }); + }); + + describe("engineering format", () => { + it("should fall back to suffix format below 1e6", () => { + expect(formatNumber(500, "engineering")).toBe("500.0"); + }); + + it("should format values >= 1e6 with exponent multiple of 3", () => { + expect(formatNumber(1_230_000, "engineering")).toBe("1.23E6"); + }); + + it("should format 1e9 correctly in engineering notation", () => { + expect(formatNumber(1e9, "engineering")).toBe("1.00E9"); + }); + + it("should format 12350000 correctly in engineering notation", () => { + expect(formatNumber(12_350_000, "engineering")).toBe("12.35E6"); + }); + }); + + describe("unknown format (default branch)", () => { + it("should fall back to suffix format for an unrecognised format string", () => { + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Testing unreachable default branch */ + expect(formatNumber(1000, "unknown" as never)).toBe("1.00K"); + }); + }); +}); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 5443714..3043865 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -2,18 +2,20 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - environment: "jsdom", coverage: { + exclude: [], + include: [ + "src/utils/format.ts", + "src/utils/dailyChallenges.ts", + ], provider: "v8", - include: ["src/**/*.ts", "src/**/*.tsx"], - exclude: ["src/types/**/*.ts", "src/main.tsx"], thresholds: { - statements: 100, branches: 100, functions: 100, lines: 100, + statements: 100, }, }, - include: ["test/**/*.spec.ts", "test/**/*.spec.tsx"], + include: ["test/**/*.spec.ts"], }, }); diff --git a/package.json b/package.json index 8c009e4..e9fd21a 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "elysium", "version": "0.0.0", "private": true, + "type": "module", "scripts": { "lint": "pnpm -r lint", "build": "pnpm -r build", diff --git a/packages/types/eslint.config.js b/packages/types/eslint.config.js index d82a8eb..76c74be 100644 --- a/packages/types/eslint.config.js +++ b/packages/types/eslint.config.js @@ -1,3 +1,3 @@ -import { nhcarrigan } from "@nhcarrigan/eslint-config"; +import config from "@nhcarrigan/eslint-config"; -export default [...(await nhcarrigan())]; +export default [...config]; diff --git a/packages/types/package.json b/packages/types/package.json index 0280926..af33d74 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -2,6 +2,7 @@ "name": "@elysium/types", "version": "0.0.0", "private": true, + "type": "module", "main": "./prod/src/index.js", "types": "./prod/src/index.d.ts", "scripts": { diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 96c3d51..ffbbe02 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,4 +1,10 @@ -export type { ApotheosisData } from "./interfaces/Apotheosis.js"; +/** + * @file Public API for the @elysium/types package. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +export type { ApotheosisData } from "./interfaces/apotheosis.js"; export type { Companion, CompanionBonus, @@ -6,9 +12,17 @@ export type { CompanionState, CompanionUnlockCondition, CompanionUnlockType, -} from "./interfaces/Companion.js"; -export { COMPANIONS, computeUnlockedCompanionIds, getActiveCompanionBonus } from "./interfaces/Companion.js"; -export type { CraftingBonusType, CraftingMaterialRequirement, CraftingRecipe } from "./interfaces/CraftingRecipe.js"; +} from "./interfaces/companion.js"; +export { + COMPANIONS, + computeUnlockedCompanionIds, + getActiveCompanionBonus, +} from "./interfaces/companion.js"; +export type { + CraftingBonusType, + CraftingMaterialRequirement, + CraftingRecipe, +} from "./interfaces/craftingRecipe.js"; export type { ExplorationArea, ExplorationAreaState, @@ -17,16 +31,16 @@ export type { ExplorationEventEffectType, ExplorationMaterialDrop, ExplorationState, -} from "./interfaces/Exploration.js"; -export type { Material, MaterialRarity } from "./interfaces/Material.js"; -export type { CodexEntry, CodexState } from "./interfaces/Codex.js"; +} from "./interfaces/exploration.js"; +export type { Material, MaterialRarity } from "./interfaces/material.js"; +export type { CodexEntry, CodexState } from "./interfaces/codex.js"; export type { Achievement, AchievementCondition, AchievementConditionType, AchievementReward, -} from "./interfaces/Achievement.js"; -export type { Adventurer, AdventurerClass } from "./interfaces/Adventurer.js"; +} from "./interfaces/achievement.js"; +export type { Adventurer, AdventurerClass } from "./interfaces/adventurer.js"; export type { AboutResponse, ApiError, @@ -61,48 +75,55 @@ export type { TranscendenceResponse, UpdateProfileRequest, UpdateProfileResponse, -} from "./interfaces/Api.js"; -export type { Boss, BossStatus } from "./interfaces/Boss.js"; +} from "./interfaces/api.js"; +export type { Boss, BossStatus } from "./interfaces/boss.js"; export type { DailyChallenge, DailyChallengeState, DailyChallengeType, -} from "./interfaces/DailyChallenge.js"; +} from "./interfaces/dailyChallenge.js"; export type { Equipment, EquipmentBonus, EquipmentRarity, EquipmentType, -} from "./interfaces/Equipment.js"; -export type { EquipmentSet, EquipmentSetBonus } from "./interfaces/EquipmentSet.js"; -export { computeSetBonuses } from "./interfaces/EquipmentSet.js"; -export type { GameState } from "./interfaces/GameState.js"; -export type { Player } from "./interfaces/Player.js"; -export type { PrestigeData } from "./interfaces/Prestige.js"; +} from "./interfaces/equipment.js"; +export type { + EquipmentSet, + EquipmentSetBonus, +} from "./interfaces/equipmentSet.js"; +export { computeSetBonuses } from "./interfaces/equipmentSet.js"; +export type { GameState } from "./interfaces/gameState.js"; +export type { Player } from "./interfaces/player.js"; +export type { PrestigeData } from "./interfaces/prestige.js"; export type { PrestigeUpgrade, PrestigeUpgradeCategory, -} from "./interfaces/PrestigeUpgrade.js"; +} from "./interfaces/prestigeUpgrade.js"; export type { Quest, QuestReward, QuestRewardType, QuestStatus, -} from "./interfaces/Quest.js"; -export type { Resource } from "./interfaces/Resource.js"; +} from "./interfaces/quest.js"; +export type { Resource } from "./interfaces/resource.js"; +export type { Upgrade, UpgradeTarget } from "./interfaces/upgrade.js"; +export type { Zone, ZoneStatus } from "./interfaces/zone.js"; export type { - Upgrade, - UpgradeTarget, -} from "./interfaces/Upgrade.js"; -export type { Zone, ZoneStatus } from "./interfaces/Zone.js"; -export type { NumberFormat, ProfileSettings } from "./interfaces/ProfileSettings.js"; -export { DEFAULT_PROFILE_SETTINGS } from "./interfaces/ProfileSettings.js"; + NumberFormat, + ProfileSettings, +} from "./interfaces/profileSettings.js"; +export { DEFAULT_PROFILE_SETTINGS } from "./interfaces/profileSettings.js"; export type { TranscendenceData, TranscendenceUpgrade, TranscendenceUpgradeCategory, -} from "./interfaces/Transcendence.js"; -export type { Title, TitleCondition, TitleConditionType } from "./interfaces/Title.js"; +} from "./interfaces/transcendence.js"; +export type { + Title, + TitleCondition, + TitleConditionType, +} from "./interfaces/title.js"; export type { CompletedChapter, StoryChapter, @@ -110,5 +131,5 @@ export type { StoryState, StoryUnlockCondition, StoryUnlockType, -} from "./interfaces/Story.js"; -export { STORY_CHAPTERS, isStoryChapterUnlocked } from "./interfaces/Story.js"; +} from "./interfaces/story.js"; +export { STORY_CHAPTERS, isStoryChapterUnlocked } from "./interfaces/story.js"; diff --git a/packages/types/src/interfaces/Achievement.ts b/packages/types/src/interfaces/Achievement.ts deleted file mode 100644 index 4eebaa6..0000000 --- a/packages/types/src/interfaces/Achievement.ts +++ /dev/null @@ -1,28 +0,0 @@ -export type AchievementConditionType = - | "totalGoldEarned" - | "totalClicks" - | "bossesDefeated" - | "questsCompleted" - | "adventurerTotal" - | "prestigeCount" - | "equipmentOwned"; - -export interface AchievementCondition { - type: AchievementConditionType; - amount: number; -} - -export interface AchievementReward { - crystals?: number; -} - -export interface Achievement { - id: string; - name: string; - description: string; - icon: string; - condition: AchievementCondition; - reward?: AchievementReward; - /** Unix timestamp when unlocked, null if not yet unlocked */ - unlockedAt: number | null; -} diff --git a/packages/types/src/interfaces/Adventurer.ts b/packages/types/src/interfaces/Adventurer.ts deleted file mode 100644 index 7625dfc..0000000 --- a/packages/types/src/interfaces/Adventurer.ts +++ /dev/null @@ -1,24 +0,0 @@ -export type AdventurerClass = - | "warrior" - | "mage" - | "rogue" - | "cleric" - | "ranger" - | "paladin"; - -export interface Adventurer { - id: string; - name: string; - class: AdventurerClass; - level: number; - /** Base cost for the first purchase of this tier (scales by 1.15× per count) */ - baseCost: number; - /** Base gold generated per second */ - goldPerSecond: number; - /** Base essence generated per second */ - essencePerSecond: number; - /** Combat power per unit — used in boss battle simulation */ - combatPower: number; - count: number; - unlocked: boolean; -} diff --git a/packages/types/src/interfaces/Api.ts b/packages/types/src/interfaces/Api.ts deleted file mode 100644 index 72f9ac6..0000000 --- a/packages/types/src/interfaces/Api.ts +++ /dev/null @@ -1,286 +0,0 @@ -import type { EquipmentBonus, EquipmentRarity, EquipmentType } from "./Equipment.js"; -import type { GameState } from "./GameState.js"; -import type { Player } from "./Player.js"; -import type { ProfileSettings } from "./ProfileSettings.js"; - -export interface AuthResponse { - token: string; - player: Player; - isNew: boolean; -} - -export interface SaveRequest { - state: GameState; - /** HMAC-SHA256 signature of the previous save's state, for anti-cheat chain verification */ - signature?: string; -} - -export interface SaveResponse { - savedAt: number; - /** HMAC-SHA256 signature of the saved state — store and include in next save request */ - signature?: string; -} - -export interface LoginBonusResult { - /** Current login streak day count */ - streak: number; - /** Gold awarded for today's login */ - goldEarned: number; - /** Crystals awarded (day 7 bonus, scaled by week multiplier) */ - crystalsEarned: number; - /** Day within the 7-day cycle (1–7) */ - day: number; - /** Week number multiplier (week 1 = ×1, week 2 = ×2, …) */ - weekMultiplier: number; -} - -export interface LoadResponse { - state: GameState; - /** Offline gold earned since last save (server-calculated) */ - offlineGold: number; - /** Offline essence earned since last save (server-calculated) */ - offlineEssence: number; - /** Seconds the player was offline (capped at 8 hours) */ - offlineSeconds: number; - /** HMAC-SHA256 signature of the loaded state — store and include in next save request */ - signature?: string; - /** Daily login bonus awarded on this load (null if already claimed today) */ - loginBonus: LoginBonusResult | null; - /** Current login streak (always present) */ - loginStreak: number; - /** True when the player's save data is from an older schema version */ - schemaOutdated: boolean; - /** The current expected schema version from the server */ - currentSchemaVersion: number; -} - -export interface BossChallengeRequest { - bossId: string; -} - -export interface BossChallengeResponse { - won: boolean; - partyDPS: number; - bossDPS: number; - /** Boss HP immediately before the battle */ - bossHpBefore: number; - /** Boss maximum HP */ - bossMaxHp: number; - /** Boss HP at end of battle before any state reset (0 on win) */ - bossHpAtBattleEnd: number; - /** Boss HP stored in game after the result (0 on win, maxHp on loss) */ - bossNewHp: number; - /** Total party HP at start of battle */ - partyMaxHp: number; - /** Party HP remaining after battle (0 on loss) */ - partyHpRemaining: number; - rewards?: { - gold: number; - essence: number; - crystals: number; - upgradeIds: string[]; - equipmentIds: string[]; - /** Runestone bounty awarded for defeating this boss for the very first time */ - bountyRunestones: number; - }; - casualties?: Array<{ - adventurerId: string; - killed: number; - }>; -} - -export type PrestigeRequest = Record<string, never>; - -export interface PrestigeResponse { - runestones: number; - newPrestigeCount: number; - /** Bonus runestones awarded for reaching a milestone prestige (every 5th), 0 if not a milestone */ - milestoneRunestones: number; -} - -export interface BuyPrestigeUpgradeRequest { - upgradeId: string; -} - -export interface BuyPrestigeUpgradeResponse { - runestonesRemaining: number; - purchasedUpgradeIds: string[]; - runestonesIncomeMultiplier: number; - runestonesClickMultiplier: number; - runestonesEssenceMultiplier: number; - runestonesCrystalMultiplier: number; -} - -export interface PublicProfileResponse { - characterName: string; - pronouns: string; - characterRace: string; - characterClass: string; - username: string; - avatar: string | null; - bio: string; - guildName: string; - guildDescription: string; - profileSettings: ProfileSettings; - createdAt: number; - /** All Time stats — cumulative across all runs, never reset */ - totalGoldEarned: number; - totalClicks: number; - lifetimeBossesDefeated: number; - lifetimeQuestsCompleted: number; - lifetimeAdventurersRecruited: number; - lifetimeAchievementsUnlocked: number; - /** Current Run stats — sourced from the live GameState, reset on prestige & transcendence */ - currentRunGold: number; - currentRunClicks: number; - prestigeCount: number; - transcendenceCount: number; - apotheosisCount: number; - bossesDefeated: number; - questsCompleted: number; - adventurersRecruited: number; - achievementsUnlocked: number; - /** Titles this player has unlocked, as {id, name} pairs for display */ - unlockedTitles: Array<{ id: string; name: string }>; - /** The player's active title display name (empty string if none set) */ - activeTitle: string; - /** Items the player currently has equipped */ - equippedItems: Array<{ name: string; type: EquipmentType; rarity: EquipmentRarity; bonus: EquipmentBonus }>; -} - -export interface UpdateProfileRequest { - characterName: string; - pronouns?: string; - characterRace?: string; - characterClass?: string; - bio?: string; - guildName?: string; - guildDescription?: string; - profileSettings: ProfileSettings; - /** Title ID to set as active (empty string to clear) */ - activeTitle?: string; -} - -export interface UpdateProfileResponse { - characterName: string; - pronouns: string; - characterRace: string; - characterClass: string; - bio: string; - guildName: string; - guildDescription: string; - activeTitle: string; - profileSettings: ProfileSettings; -} - -export type TranscendenceRequest = Record<string, never>; - -export interface TranscendenceResponse { - echoes: number; - newTranscendenceCount: number; -} - -export interface BuyEchoUpgradeRequest { - upgradeId: string; -} - -export interface BuyEchoUpgradeResponse { - echoesRemaining: number; - purchasedUpgradeIds: string[]; - echoIncomeMultiplier: number; - echoCombatMultiplier: number; - echoPrestigeThresholdMultiplier: number; - echoPrestigeRunestoneMultiplier: number; - echoMetaMultiplier: number; -} - -export type ApotheosisRequest = Record<string, never>; - -export interface ApotheosisResponse { - newApotheosisCount: number; -} - -export interface ApiError { - error: string; -} - -export type LeaderboardCategory = - | "totalGold" - | "bossesDefeated" - | "questsCompleted" - | "achievementsUnlocked" - | "prestigeCount" - | "transcendenceCount" - | "apotheosisCount"; - -export interface LeaderboardEntry { - rank: number; - discordId: string; - characterName: string; - username: string; - avatar: string | null; - activeTitle: string; - value: number; -} - -export interface LeaderboardResponse { - category: LeaderboardCategory; - entries: LeaderboardEntry[]; -} - -export interface GiteaRelease { - tag_name: string; - name: string; - body: string; - published_at: string; -} - -export interface AboutResponse { - apiVersion: string; - releases: GiteaRelease[]; -} - -export interface ExploreStartRequest { - areaId: string; -} - -export interface ExploreStartResponse { - areaId: string; - endsAt: number; -} - -export interface ExploreCollectRequest { - areaId: string; -} - -export interface ExploreCollectEventResult { - text: string; - goldChange: number; - essenceChange: number; - materialGained: { materialId: string; quantity: number } | null; - adventurerLostCount: number; -} - -export interface ExploreCollectResponse { - foundNothing: boolean; - nothingMessage?: string; - materialsFound: Array<{ materialId: string; quantity: number }>; - event: ExploreCollectEventResult | null; -} - -export interface CraftRecipeRequest { - recipeId: string; -} - -export interface CraftRecipeResponse { - recipeId: string; - bonusType: string; - bonusValue: number; - craftedGoldMultiplier: number; - craftedEssenceMultiplier: number; - craftedClickMultiplier: number; - craftedCombatMultiplier: number; -} - -// Re-export for convenience -export type { ProfileSettings }; diff --git a/packages/types/src/interfaces/Apotheosis.ts b/packages/types/src/interfaces/Apotheosis.ts deleted file mode 100644 index 6d462b8..0000000 --- a/packages/types/src/interfaces/Apotheosis.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface ApotheosisData { - /** Number of times the player has achieved Apotheosis */ - count: number; -} diff --git a/packages/types/src/interfaces/Boss.ts b/packages/types/src/interfaces/Boss.ts deleted file mode 100644 index 2df28e8..0000000 --- a/packages/types/src/interfaces/Boss.ts +++ /dev/null @@ -1,28 +0,0 @@ -export type BossStatus = "locked" | "available" | "in_progress" | "defeated"; - -export interface Boss { - id: string; - name: string; - description: string; - status: BossStatus; - maxHp: number; - currentHp: number; - /** Damage dealt to adventurers per second whilst the fight is active */ - damagePerSecond: number; - /** Gold reward on defeat */ - goldReward: number; - /** Essence reward on defeat */ - essenceReward: number; - /** Crystal reward on defeat */ - crystalReward: number; - /** IDs of upgrades unlocked on defeat */ - upgradeRewards: string[]; - /** IDs of equipment items granted on defeat */ - equipmentRewards: string[]; - /** Minimum prestige level required to access this boss */ - prestigeRequirement: number; - /** Zone this boss belongs to */ - zoneId: string; - /** One-time runestone bounty awarded on first-ever defeat */ - bountyRunestones: number; -} diff --git a/packages/types/src/interfaces/Codex.ts b/packages/types/src/interfaces/Codex.ts deleted file mode 100644 index 41c0968..0000000 --- a/packages/types/src/interfaces/Codex.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface CodexEntry { - id: string; - title: string; - content: string; - sourceType: "boss" | "quest" | "equipment" | "adventurer" | "upgrade" | "prestige" | "zone" | "exploration" | "recipe"; - sourceId: string; - zoneId: string; -} - -export interface CodexState { - unlockedEntryIds: string[]; -} diff --git a/packages/types/src/interfaces/Companion.ts b/packages/types/src/interfaces/Companion.ts deleted file mode 100644 index 9ad369b..0000000 --- a/packages/types/src/interfaces/Companion.ts +++ /dev/null @@ -1,163 +0,0 @@ -export type CompanionBonusType = - | "passiveGold" - | "clickGold" - | "bossDamage" - | "essenceIncome" - | "questTime"; - -export interface CompanionBonus { - type: CompanionBonusType; - /** Fractional value: for multiplier types, adds this fraction (0.25 = +25%). For questTime, reduces duration by this fraction (0.15 = 15% faster). */ - value: number; -} - -export type CompanionUnlockType = - | "lifetimeBosses" - | "lifetimeQuests" - | "lifetimeGold" - | "prestige" - | "transcendence" - | "apotheosis"; - -export interface CompanionUnlockCondition { - type: CompanionUnlockType; - threshold: number; -} - -export interface Companion { - id: string; - name: string; - title: string; - description: string; - bonus: CompanionBonus; - unlock: CompanionUnlockCondition; -} - -export interface CompanionState { - /** Companion IDs the player has unlocked — recomputed server-side on every save. */ - unlockedCompanionIds: string[]; - /** The ID of the currently active companion, or null for none. */ - activeCompanionId: string | null; -} - -export const COMPANIONS: Companion[] = [ - { - id: "lyra", - name: "Lyra", - title: "Wandering Minstrel", - description: "A cheerful bard whose uplifting songs inspire your adventurers to work harder for better coin.", - bonus: { type: "passiveGold", value: 0.25 }, - unlock: { type: "lifetimeBosses", threshold: 100 }, - }, - { - id: "finn", - name: "Finn", - title: "Quick-Fingered Rogue", - description: "A nimble rogue whose sleight of hand ensures far more gold lands in your coffers with every strike.", - bonus: { type: "clickGold", value: 0.50 }, - unlock: { type: "lifetimeQuests", threshold: 100 }, - }, - { - id: "wren", - name: "Wren", - title: "Hedge Witch", - description: "A resourceful hedge witch who weaves minor enchantments that accelerate your quest parties.", - bonus: { type: "questTime", value: 0.15 }, - unlock: { type: "lifetimeQuests", threshold: 500 }, - }, - { - id: "aldric", - name: "Aldric", - title: "Veteran Knight", - description: "A battle-hardened knight who leads your party with years of tactical experience against fearsome foes.", - bonus: { type: "bossDamage", value: 0.20 }, - unlock: { type: "lifetimeBosses", threshold: 200 }, - }, - { - id: "sera", - name: "Sera", - title: "Arcane Alchemist", - description: "A brilliant alchemist who transmutes ambient magic into pure essence, bolstering your income.", - bonus: { type: "essenceIncome", value: 0.30 }, - unlock: { type: "prestige", threshold: 10 }, - }, - { - id: "kael", - name: "Kael", - title: "Battle Mage", - description: "A powerful battle mage whose devastating spells tear through even the toughest boss encounters.", - bonus: { type: "bossDamage", value: 0.40 }, - unlock: { type: "lifetimeBosses", threshold: 720 }, - }, - { - id: "zuri", - name: "Zuri", - title: "Chrono Weaver", - description: "A time mage who bends the threads of time itself, significantly hastening your quest parties.", - bonus: { type: "questTime", value: 0.30 }, - unlock: { type: "lifetimeQuests", threshold: 950 }, - }, - { - id: "mira", - name: "Mira", - title: "Merchant Queen", - description: "A wealthy merchant whose golden touch and trade empire dramatically boosts your passive earnings.", - bonus: { type: "passiveGold", value: 0.75 }, - unlock: { type: "lifetimeGold", threshold: 1e18 }, - }, - { - id: "vex", - name: "Vex", - title: "Shadow Broker", - description: "A shadowy information broker who channels essence from the void through forbidden knowledge.", - bonus: { type: "essenceIncome", value: 0.75 }, - unlock: { type: "transcendence", threshold: 5 }, - }, - { - id: "pria", - name: "Pria", - title: "Celestial Oracle", - description: "A divine oracle whose celestial blessing transforms the very air around you into golden fortune.", - bonus: { type: "passiveGold", value: 1.00 }, - unlock: { type: "apotheosis", threshold: 1 }, - }, -]; - -/** - * Computes which companion IDs the player has unlocked based on their lifetime stats. - * Called server-side on every save using DB-authoritative player stats. - */ -export const computeUnlockedCompanionIds = (params: { - lifetimeBossesDefeated: number; - lifetimeQuestsCompleted: number; - lifetimeGoldEarned: number; - prestigeCount: number; - transcendenceCount: number; - apotheosisCount: number; -}): string[] => - COMPANIONS - .filter((companion) => { - const { type, threshold } = companion.unlock; - switch (type) { - case "lifetimeBosses": return params.lifetimeBossesDefeated >= threshold; - case "lifetimeQuests": return params.lifetimeQuestsCompleted >= threshold; - case "lifetimeGold": return params.lifetimeGoldEarned >= threshold; - case "prestige": return params.prestigeCount >= threshold; - case "transcendence": return params.transcendenceCount >= threshold; - case "apotheosis": return params.apotheosisCount >= threshold; - } - }) - .map((companion) => companion.id); - -/** - * Returns the bonus of the active companion if it is unlocked, otherwise null. - * Safe to call with undefined/null activeCompanionId. - */ -export const getActiveCompanionBonus = ( - activeCompanionId: string | null | undefined, - unlockedCompanionIds: string[], -): CompanionBonus | null => { - if (!activeCompanionId) return null; - if (!unlockedCompanionIds.includes(activeCompanionId)) return null; - return COMPANIONS.find((c) => c.id === activeCompanionId)?.bonus ?? null; -}; diff --git a/packages/types/src/interfaces/CraftingRecipe.ts b/packages/types/src/interfaces/CraftingRecipe.ts deleted file mode 100644 index 194c24c..0000000 --- a/packages/types/src/interfaces/CraftingRecipe.ts +++ /dev/null @@ -1,19 +0,0 @@ -export type CraftingBonusType = "gold_income" | "essence_income" | "click_power" | "combat_power"; - -export interface CraftingMaterialRequirement { - materialId: string; - quantity: number; -} - -export interface CraftingRecipe { - id: string; - name: string; - description: string; - zoneId: string; - requiredMaterials: CraftingMaterialRequirement[]; - bonus: { - type: CraftingBonusType; - /** Multiplicative bonus value, e.g. 1.1 = +10% */ - value: number; - }; -} diff --git a/packages/types/src/interfaces/DailyChallenge.ts b/packages/types/src/interfaces/DailyChallenge.ts deleted file mode 100644 index ec8edd8..0000000 --- a/packages/types/src/interfaces/DailyChallenge.ts +++ /dev/null @@ -1,17 +0,0 @@ -export type DailyChallengeType = "clicks" | "bossesDefeated" | "questsCompleted" | "prestige"; - -export interface DailyChallenge { - id: string; - type: DailyChallengeType; - label: string; - target: number; - progress: number; - completed: boolean; - rewardCrystals: number; -} - -export interface DailyChallengeState { - /** ISO date string (e.g. "2026-03-06") used to detect when to reset */ - date: string; - challenges: DailyChallenge[]; -} diff --git a/packages/types/src/interfaces/Equipment.ts b/packages/types/src/interfaces/Equipment.ts deleted file mode 100644 index 4c05496..0000000 --- a/packages/types/src/interfaces/Equipment.ts +++ /dev/null @@ -1,29 +0,0 @@ -export type EquipmentType = "weapon" | "armour" | "trinket"; - -export type EquipmentRarity = "common" | "rare" | "epic" | "legendary"; - -export interface EquipmentBonus { - /** Multiplier applied to all gold/s income (e.g. 1.1 = +10%) */ - goldMultiplier?: number; - /** Multiplier applied to all combat power (e.g. 1.25 = +25%) */ - combatMultiplier?: number; - /** Multiplier applied to click power (e.g. 1.5 = +50%) */ - clickMultiplier?: number; -} - -export interface Equipment { - id: string; - name: string; - description: string; - type: EquipmentType; - rarity: EquipmentRarity; - bonus: EquipmentBonus; - /** Whether the player has acquired this item */ - owned: boolean; - /** Whether this item is currently equipped (only one per type can be equipped) */ - equipped: boolean; - /** If set, this item can be purchased directly rather than obtained via boss drops */ - cost?: { gold: number; essence: number; crystals: number }; - /** Equipment set this item belongs to, if any */ - setId?: string; -} diff --git a/packages/types/src/interfaces/EquipmentSet.ts b/packages/types/src/interfaces/EquipmentSet.ts deleted file mode 100644 index 23b389d..0000000 --- a/packages/types/src/interfaces/EquipmentSet.ts +++ /dev/null @@ -1,44 +0,0 @@ -export interface EquipmentSetBonus { - goldMultiplier?: number; - combatMultiplier?: number; - clickMultiplier?: number; -} - -export interface EquipmentSet { - id: string; - name: string; - description: string; - /** Equipment IDs that make up this set */ - pieces: string[]; - bonuses: { - 2: EquipmentSetBonus; - 3: EquipmentSetBonus; - }; -} - -/** - * Given a list of equipped item IDs and a set catalogue, returns the combined - * multiplicative bonuses granted by all active set bonuses. - */ -export const computeSetBonuses = ( - equippedItemIds: string[], - sets: EquipmentSet[], -): { goldMultiplier: number; combatMultiplier: number; clickMultiplier: number } => { - let goldMultiplier = 1; - let combatMultiplier = 1; - let clickMultiplier = 1; - - for (const set of sets) { - const count = set.pieces.filter((id) => equippedItemIds.includes(id)).length; - for (const threshold of [2, 3] as const) { - if (count >= threshold) { - const bonus = set.bonuses[threshold]; - goldMultiplier *= bonus.goldMultiplier ?? 1; - combatMultiplier *= bonus.combatMultiplier ?? 1; - clickMultiplier *= bonus.clickMultiplier ?? 1; - } - } - } - - return { goldMultiplier, combatMultiplier, clickMultiplier }; -}; diff --git a/packages/types/src/interfaces/Exploration.ts b/packages/types/src/interfaces/Exploration.ts deleted file mode 100644 index 58af01c..0000000 --- a/packages/types/src/interfaces/Exploration.ts +++ /dev/null @@ -1,67 +0,0 @@ -export type ExplorationEventEffectType = - | "gold_gain" - | "gold_loss" - | "essence_gain" - | "material_gain" - | "adventurer_loss"; - -export interface ExplorationEventEffect { - type: ExplorationEventEffectType; - /** Gold amount for gold_gain / gold_loss */ - amount?: number; - /** Material ID for material_gain */ - materialId?: string; - /** Quantity for material_gain */ - quantity?: number; - /** Fraction (0–1) of total adventurers lost for adventurer_loss */ - fraction?: number; -} - -export interface ExplorationEvent { - id: string; - text: string; - effect: ExplorationEventEffect; -} - -export interface ExplorationMaterialDrop { - materialId: string; - minQuantity: number; - maxQuantity: number; - /** Relative probability weight — higher = more likely */ - weight: number; -} - -export interface ExplorationArea { - id: string; - name: string; - description: string; - zoneId: string; - durationSeconds: number; - possibleMaterials: ExplorationMaterialDrop[]; - events: ExplorationEvent[]; -} - -export interface ExplorationAreaState { - id: string; - status: "locked" | "available" | "in_progress" | "completed"; - /** Unix timestamp when exploration started (set when status becomes in_progress) */ - startedAt?: number; - /** True after the first successful collect — used for codex unlock detection */ - completedOnce?: boolean; -} - -export interface ExplorationState { - areas: ExplorationAreaState[]; - /** Current material inventory */ - materials: Array<{ materialId: string; quantity: number }>; - /** IDs of crafting recipes that have been crafted (resets on prestige) */ - craftedRecipeIds: string[]; - /** Pre-computed gold income multiplier from all crafted recipes */ - craftedGoldMultiplier: number; - /** Pre-computed essence income multiplier from all crafted recipes */ - craftedEssenceMultiplier: number; - /** Pre-computed click power multiplier from all crafted recipes */ - craftedClickMultiplier: number; - /** Pre-computed combat power multiplier from all crafted recipes */ - craftedCombatMultiplier: number; -} diff --git a/packages/types/src/interfaces/GameState.ts b/packages/types/src/interfaces/GameState.ts deleted file mode 100644 index 71078b0..0000000 --- a/packages/types/src/interfaces/GameState.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { Achievement } from "./Achievement.js"; -import type { Adventurer } from "./Adventurer.js"; -import type { Boss } from "./Boss.js"; -import type { ApotheosisData } from "./Apotheosis.js"; -import type { CodexState } from "./Codex.js"; -import type { CompanionState } from "./Companion.js"; -import type { StoryState } from "./Story.js"; -import type { DailyChallengeState } from "./DailyChallenge.js"; -import type { ExplorationState } from "./Exploration.js"; -import type { TranscendenceData } from "./Transcendence.js"; -import type { Equipment } from "./Equipment.js"; -import type { Player } from "./Player.js"; -import type { PrestigeData } from "./Prestige.js"; -import type { Quest } from "./Quest.js"; -import type { Resource } from "./Resource.js"; -import type { Upgrade } from "./Upgrade.js"; -import type { Zone } from "./Zone.js"; - -export interface GameState { - player: Player; - resources: Resource; - adventurers: Adventurer[]; - upgrades: Upgrade[]; - quests: Quest[]; - bosses: Boss[]; - equipment: Equipment[]; - achievements: Achievement[]; - prestige: PrestigeData; - zones: Zone[]; - /** Click power (gold per click, before upgrades) */ - baseClickPower: number; - /** Unix timestamp of the last client-side tick */ - lastTickAt: number; - /** Daily challenge progress — optional for backwards compatibility with old saves */ - dailyChallenges?: DailyChallengeState; - /** Lore codex unlock state — optional for backwards compatibility with old saves */ - codex?: CodexState; - /** Transcendence (second prestige layer) state — optional for backwards compatibility */ - transcendence?: TranscendenceData; - /** Apotheosis (third prestige layer) state — optional for backwards compatibility */ - apotheosis?: ApotheosisData; - /** Exploration and crafting state — optional for backwards compatibility */ - exploration?: ExplorationState; - /** When true, the tick engine automatically starts the highest-zone available quest */ - autoQuest?: boolean; - /** When true, the tick engine automatically challenges the highest available boss */ - autoBoss?: boolean; - /** Companion unlock and active selection state — optional for backwards compatibility */ - companions?: CompanionState; - /** Story chapter unlock and completion state — optional for backwards compatibility */ - story?: StoryState; - /** Schema version — used to detect saves from older game versions */ - schemaVersion?: number; -} diff --git a/packages/types/src/interfaces/Material.ts b/packages/types/src/interfaces/Material.ts deleted file mode 100644 index 53fc372..0000000 --- a/packages/types/src/interfaces/Material.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type MaterialRarity = "common" | "uncommon" | "rare"; - -export interface Material { - id: string; - name: string; - description: string; - zoneId: string; - rarity: MaterialRarity; -} diff --git a/packages/types/src/interfaces/Player.ts b/packages/types/src/interfaces/Player.ts deleted file mode 100644 index 887786f..0000000 --- a/packages/types/src/interfaces/Player.ts +++ /dev/null @@ -1,28 +0,0 @@ -export interface Player { - discordId: string; - username: string; - discriminator: string; - avatar: string | null; - /** Player's chosen in-game character name */ - characterName: string; - /** Unix timestamp when the account was created */ - createdAt: number; - /** Unix timestamp of the last server-side save */ - lastSavedAt: number; - /** Gold earned this run (reset on prestige; used for prestige eligibility) */ - totalGoldEarned: number; - /** Clicks this run (reset on prestige) */ - totalClicks: number; - /** Cumulative gold earned across all runs — never reset */ - lifetimeGoldEarned: number; - /** Cumulative clicks across all runs — never reset */ - lifetimeClicks: number; - /** Cumulative bosses defeated across all runs — never reset */ - lifetimeBossesDefeated: number; - /** Cumulative quests completed across all runs — never reset */ - lifetimeQuestsCompleted: number; - /** Cumulative adventurers recruited across all runs — never reset */ - lifetimeAdventurersRecruited: number; - /** Cumulative achievements unlocked across all runs — never reset */ - lifetimeAchievementsUnlocked: number; -} diff --git a/packages/types/src/interfaces/Prestige.ts b/packages/types/src/interfaces/Prestige.ts deleted file mode 100644 index e0e427a..0000000 --- a/packages/types/src/interfaces/Prestige.ts +++ /dev/null @@ -1,22 +0,0 @@ -export interface PrestigeData { - /** Number of times the player has prestiged */ - count: number; - /** Runestones carried over between prestiges */ - runestones: number; - /** Multiplier applied to all production (based on prestige count) */ - productionMultiplier: number; - /** IDs of prestige upgrades purchased with runestones */ - purchasedUpgradeIds: string[]; - /** Unix timestamp of last prestige */ - lastPrestigedAt?: number; - /** Pre-computed multiplier from "income" runestone upgrades */ - runestonesIncomeMultiplier?: number; - /** Pre-computed multiplier from "click" runestone upgrades */ - runestonesClickMultiplier?: number; - /** Pre-computed multiplier from "essence" runestone upgrades */ - runestonesEssenceMultiplier?: number; - /** Pre-computed multiplier from "crystals" runestone upgrades */ - runestonesCrystalMultiplier?: number; - /** Whether the auto-prestige feature is currently enabled (requires auto_prestige upgrade) */ - autoPrestigeEnabled?: boolean; -} diff --git a/packages/types/src/interfaces/PrestigeUpgrade.ts b/packages/types/src/interfaces/PrestigeUpgrade.ts deleted file mode 100644 index c8511d1..0000000 --- a/packages/types/src/interfaces/PrestigeUpgrade.ts +++ /dev/null @@ -1,17 +0,0 @@ -export type PrestigeUpgradeCategory = - | "income" - | "click" - | "essence" - | "crystals" - | "runestones" - | "utility"; - -export interface PrestigeUpgrade { - id: string; - name: string; - description: string; - category: PrestigeUpgradeCategory; - runestonesCost: number; - /** Multiplier applied when this upgrade is purchased */ - multiplier: number; -} diff --git a/packages/types/src/interfaces/ProfileSettings.ts b/packages/types/src/interfaces/ProfileSettings.ts deleted file mode 100644 index bfcd8a0..0000000 --- a/packages/types/src/interfaces/ProfileSettings.ts +++ /dev/null @@ -1,46 +0,0 @@ -export type NumberFormat = "suffix" | "scientific" | "engineering"; - -export interface ProfileSettings { - /** All Time section */ - showTotalGold: boolean; - showTotalClicks: boolean; - showLifetimeBossesDefeated: boolean; - showLifetimeQuestsCompleted: boolean; - showLifetimeAdventurersRecruited: boolean; - showLifetimeAchievementsUnlocked: boolean; - showGuildFounded: boolean; - /** Current Run section */ - showCurrentGold: boolean; - showCurrentClicks: boolean; - showPrestige: boolean; - showTranscendence: boolean; - showApotheosis: boolean; - showBossesDefeated: boolean; - showQuestsCompleted: boolean; - showAdventurersRecruited: boolean; - showAchievementsUnlocked: boolean; - numberFormat: NumberFormat; - /** Whether this player appears on the public leaderboards */ - showOnLeaderboards: boolean; -} - -export const DEFAULT_PROFILE_SETTINGS: ProfileSettings = { - showTotalGold: true, - showTotalClicks: true, - showLifetimeBossesDefeated: true, - showLifetimeQuestsCompleted: true, - showLifetimeAdventurersRecruited: true, - showLifetimeAchievementsUnlocked: true, - showGuildFounded: true, - showCurrentGold: true, - showCurrentClicks: true, - showPrestige: true, - showTranscendence: true, - showApotheosis: true, - showBossesDefeated: true, - showQuestsCompleted: true, - showAdventurersRecruited: true, - showAchievementsUnlocked: true, - numberFormat: "suffix", - showOnLeaderboards: true, -}; diff --git a/packages/types/src/interfaces/Quest.ts b/packages/types/src/interfaces/Quest.ts deleted file mode 100644 index c438bba..0000000 --- a/packages/types/src/interfaces/Quest.ts +++ /dev/null @@ -1,30 +0,0 @@ -export type QuestStatus = "locked" | "available" | "active" | "completed"; - -export type QuestRewardType = "gold" | "essence" | "crystals" | "upgrade" | "adventurer" | "equipment"; - -export interface QuestReward { - type: QuestRewardType; - amount?: number; - /** ID of the upgrade or adventurer to unlock (if applicable) */ - targetId?: string; -} - -export interface Quest { - id: string; - name: string; - description: string; - status: QuestStatus; - /** Unix timestamp when quest was started (if active) */ - startedAt?: number; - /** Duration in seconds */ - durationSeconds: number; - rewards: QuestReward[]; - /** IDs of quests that must be completed before this one unlocks */ - prerequisiteIds: string[]; - /** Zone this quest belongs to */ - zoneId: string; - /** Minimum party combat power required to start this quest */ - combatPowerRequired?: number; - /** Unix timestamp of the most recent failed attempt (if any) */ - lastFailedAt?: number; -} diff --git a/packages/types/src/interfaces/Resource.ts b/packages/types/src/interfaces/Resource.ts deleted file mode 100644 index 4d5f015..0000000 --- a/packages/types/src/interfaces/Resource.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface Resource { - gold: number; - essence: number; - crystals: number; - runestones: number; -} diff --git a/packages/types/src/interfaces/Story.ts b/packages/types/src/interfaces/Story.ts deleted file mode 100644 index 24e0a46..0000000 --- a/packages/types/src/interfaces/Story.ts +++ /dev/null @@ -1,648 +0,0 @@ -import type { GameState } from "./GameState.js"; - -export interface StoryChoice { - id: string; - label: string; - outcome: string; -} - -export interface StoryChapter { - id: string; - title: string; - content: string; - choices: [StoryChoice, StoryChoice, StoryChoice]; - unlock: StoryUnlockCondition; -} - -export type StoryUnlockType = "bossDefeated" | "prestige" | "transcendence" | "apotheosis"; - -export interface StoryUnlockCondition { - type: StoryUnlockType; - bossId?: string; - threshold?: number; -} - -export interface CompletedChapter { - chapterId: string; - choiceId: string; -} - -export interface StoryState { - unlockedChapterIds: string[]; - completedChapters: CompletedChapter[]; -} - -export const isStoryChapterUnlocked = (chapter: StoryChapter, state: GameState): boolean => { - const { unlock } = chapter; - if (unlock.type === "bossDefeated") { - return state.bosses.some((b) => b.id === unlock.bossId && b.status === "defeated"); - } - if (unlock.type === "prestige") { - /* v8 ignore next -- @preserve */ - return (state.prestige?.count ?? 0) >= (unlock.threshold ?? 1); - } - if (unlock.type === "transcendence") { - return (state.transcendence?.count ?? 0) >= (unlock.threshold ?? 1); - } - if (unlock.type === "apotheosis") { - return (state.apotheosis?.count ?? 0) >= (unlock.threshold ?? 1); - } - return false; -}; - -export const STORY_CHAPTERS: StoryChapter[] = [ - { - id: "story_ch_01", - title: "Roots and Steel", - content: - "The Verdant Vale was supposed to be simple — a proving ground, nothing more. {characterName} hadn't set out to become a legend. The guild had been small then: a handful of hungry fighters and one stubborn dream. The forest was ancient and watchful, and the Forest Giant that lurked at its heart was older than any map. When your party stood over its fallen form at last, the trees went quiet in a way that felt almost like respect.\n\nThe air smelled of pine and fresh blood. Somewhere above, a crow called out and then fell silent. {characterName} looked back at the faces of those who had followed — tired, scraped raw, but alive. Something had shifted. The Vale had tested you, and you had not broken.\n\nA stranger approached through the tree line: a weathered cartographer who had been mapping these woods for years. She pressed a folded chart into {characterName}'s hands — the first detailed map of lands further east. \"The world is larger than this forest,\" she said, studying you with curious eyes. \"I wonder what else you'll find at the end of it.\"", - choices: [ - { - id: "resolve", - label: "Accept the map with quiet resolve", - outcome: - "You folded the map carefully and tucked it away. Resolve was the only currency you had in abundance. The cartographer watched you go and thought: this one has the look of someone who finishes things.", - }, - { - id: "people", - label: "Return immediately to your people", - outcome: - "Your first thought was of your guild — of wounds to tend and rest hard-earned. The cartographer smiled at your back. Some leaders are built for glory; some are built for their people. You were becoming the latter.", - }, - { - id: "plan", - label: "Study it in silence, already planning", - outcome: - "Your eyes moved across the map before she'd even finished speaking. The forest had only been the first line of a much longer story. You were already writing the next.", - }, - ], - unlock: { type: "bossDefeated", bossId: "forest_giant" }, - }, - { - id: "story_ch_02", - title: "What the Ruins Remember", - content: - "The Shattered Ruins had been a city once — vast and proud, before something tore it apart. The stones still carried the memory: carved friezes of robed figures, towers that had fallen so long ago that saplings grew through their rubble. And at the heart of it all, Vaeltharox — a dragon old enough to have watched the city fall, who had made his home in its bones and grown fat on centuries of silence.\n\nHe was gone now. The fight had been brutal, and {characterName} would carry the scars of it for a long time. But standing in the great collapsed hall at the ruin's center, surrounded by ancient frescoes still bright with pigment, there was something almost solemn in the stillness.\n\nA scholar emerged from behind a pillar — one of those fearless academic types who follows adventuring parties at a safe distance. She was breathless and wide-eyed. \"Do you know what this place was?\" she whispered, gesturing at the walls. \"The inscriptions — this was a guild hall. Thousands of years ago, there were others like you. Something ended them.\" She paused. \"The question is what.\"", - choices: [ - { - id: "listen", - label: "Ask the scholar what she has learned", - outcome: - "You stayed long enough to listen. The scholar was cautious with her theories but certain of one thing: the people who had built this place had been powerful, and their end had come from somewhere far beyond the Vale. You filed that knowledge away like a sharp blade.", - }, - { - id: "claim", - label: "Claim the hall as a guild waystation", - outcome: - "The ruins needed purpose more than they needed silence. Your guild cleared rubble, shored up walls, and lit fires in hearths that hadn't been warm in an age. Whatever had ended the people here, it would not end you.", - }, - { - id: "press", - label: "Mark it on your chart and press on", - outcome: - "There would be time for history later. You marked the ruin on your chart with a careful hand and turned your face toward the horizon. The past could wait; the future wouldn't.", - }, - ], - unlock: { type: "bossDefeated", bossId: "elder_dragon" }, - }, - { - id: "story_ch_03", - title: "The Dark Between Stars", - content: - "The Shadow Marshes were never quiet. At night the water moved in slow, strange currents and lights bobbed at the edge of visibility — not lanterns, but something older and stranger. The locals had a name for the deep things that lived in the mire, and they spoke it only in whispers. The Mud Kraken was only the largest of them; the marshes hid worse things in their silt.\n\n{characterName} had led the guild through three weeks of rot and fog before they found the creature's lair, and the battle had been fought half-submerged, in darkness, against something that treated the surface world as a foreign country it was only briefly visiting. When it finally sank for the last time, the marsh went unnervingly still.\n\nA ferryman appeared at dawn, poling a flat boat out of the mist. He looked at the still water where the Kraken had thrashed and then at {characterName}. \"First time something's died out there in a hundred years,\" he said slowly. \"The villages will sleep better.\" He set a small lantern down at the prow of his boat — a gift, by the gesture of it. \"Where do you go next? There are dark places further in, and darker still beyond.\"", - choices: [ - { - id: "ask", - label: "Ask what lies deeper in the marshes", - outcome: - "He told you what the marsh-folk knew: that the darkness didn't end at the Kraken, that there were seams of shadow that ran all the way to the world's edge. You thanked him and kept that information close.", - }, - { - id: "lantern", - label: "Accept the lantern and move on", - outcome: - "You took the lantern. Light against darkness — it was a simple philosophy, but it had served you well enough so far. The ferryman watched your guild disappear into the mist and smiled, alone.", - }, - { - id: "rest", - label: "Rest with the marsh villages first", - outcome: - "Three days of sleeping on dry ground and eating hot food did more for your guild than any potion. The marsh-folk gave generously and asked nothing. You left them safer than you'd found them.", - }, - ], - unlock: { type: "bossDefeated", bossId: "mud_kraken" }, - }, - { - id: "story_ch_04", - title: "A Cold That Burns", - content: - "The Void Titan had not been native to the Frozen Peaks. That was the first clue that something had changed. The great creature — all sharp angles and wrongness, like a shadow given the weight of stone — had descended from somewhere above the cloud line, and the mountain itself seemed to recoil from its presence. Where it walked, ice formed on the underside of rocks and compasses spun without purpose.\n\n{characterName} had fought things before that wanted to kill you. The Void Titan was the first that seemed to want to unmake you — to pull you apart and scatter the pieces across dimensions you had no name for. The battle left the survivors unsettled in ways that took days to articulate.\n\nAt the summit, after the Titan's dissolution into cold light, you found an old monk who had lived on the peak for decades. He had watched the battle from a sheltered cave, wrapped in skins, calm as stone. \"The Void leaks through,\" he said, without preamble. \"It always has. But it is leaking faster now.\" He handed {characterName} a worn journal — his own observations across thirty years. \"I am too old to carry this further. You are not.\"", - choices: [ - { - id: "study", - label: "Take the journal and study it carefully", - outcome: - "The journal became essential reading for your strongest strategists. The monk had been meticulous; his observations mapped a pattern that wasn't comforting. You began preparing for something larger than any single battle.", - }, - { - id: "promise", - label: "Promise to return with answers", - outcome: - "You couldn't take the old man down the mountain, but you could carry his question. The promise you made on that peak became something you returned to often, in the quiet hours — a compass of its own.", - }, - { - id: "inquire", - label: "Ask the monk what he believes is causing it", - outcome: - "He didn't answer immediately. When he did, the words were careful: 'I think something learned that it could come here. And now it knows the way.' You descended the mountain knowing that the way in was also the way back.", - }, - ], - unlock: { type: "bossDefeated", bossId: "void_titan" }, - }, - { - id: "story_ch_05", - title: "Ash and Ascension", - content: - "Fire, as a teacher, is merciless. The Volcanic Depths had claimed members of other guilds — those who underestimated the heat, the gas pockets, the stone that moved like water. {characterName} had read every account, taken every precaution, and still arrived at the Phoenix Lord's chamber scorched and gasping, having lost things along the way that couldn't be replaced.\n\nThe Phoenix Lord itself was ancient, and it did not fight like something that feared death — it fought like something that expected it, that had died so many times that the battle was almost ritual. When it finally burned out, it did not fall. It dissolved into cinders that rose with the updraft, spiraling upward through the caldera into open sky.\n\nIn the silence that followed, {characterName} found a feather that hadn't burned — improbably, impossibly intact, vivid red-gold in the ash. At the caldera's edge, a young fire-tender who had guided your guild through the vents sat quietly, watching the cinders rise. \"The Phoenix Lord was a guardian once,\" she said. \"Before the Void. It lost itself somewhere along the way and forgot what it was protecting.\" She looked at {characterName} steadily. \"I wonder if you'll remember, when it's your turn.\"", - choices: [ - { - id: "feather", - label: "Keep the feather as a reminder", - outcome: - "You carried the feather in a sealed case from that day forward — not as a trophy, but as a question you hadn't answered yet. What are you protecting? The question sharpened you.", - }, - { - id: "people", - label: "Tell her: you protect your people", - outcome: - "'Then don't lose them,' she said simply. It wasn't a warning. It was the closest thing to a blessing the volcanic depths had to offer.", - }, - { - id: "beyond", - label: "Ask what she thinks lies beyond the fire", - outcome: - "'Something that cannot burn,' she said, after a long pause. 'Something that has never needed to.' You weren't sure if that was reassuring. You carried the uncertainty with you like a coal.", - }, - ], - unlock: { type: "bossDefeated", bossId: "phoenix_lord" }, - }, - { - id: "story_ch_06", - title: "The Hungry Dark", - content: - "No expedition into the Astral Void came back entirely unchanged. The rules that governed the material world — distance, time, the reliability of your own shadow — loosened here, and what replaced them was a vast and indifferent silence that pressed against the edges of your mind. The stars in the Astral Void were wrong: too close, too many, and some of them moved.\n\nThe Devourer of Worlds was not the first thing of its kind. {characterName} had found evidence of others, older and smaller, whose meal had been stars instead of whole realities. The Devourer was simply the largest, and the hungriest, and the most immediately present. The battle that ended it was unlike anything that could be described to someone who hadn't stood in the airless dark and watched something the size of a continent simply stop.\n\nComing back down was disorienting. {characterName} stood in the entry camp for a long time, watching the normal sky, feeling the gravity of a world that was still whole. A philosopher was waiting — one of a growing number who followed the guild's path. She set down her pen and looked at you very seriously. \"You understand now, don't you?\" she said. \"That we are small? That the things we've been fighting are small, compared to what exists beyond?\"", - choices: [ - { - id: "fight", - label: "Yes — and we fight anyway", - outcome: - "The philosopher wrote that down. She published it later, in an obscure academic tract that circulated far wider than she'd expected. Small, and yet. And yet. And yet.", - }, - { - id: "further", - label: "Ask what she thinks is further out", - outcome: - "She smiled, the way people smile when they've been waiting for the question. 'Minds,' she said. 'Ancient, patient, watching. The question is whether they've noticed us yet.' You decided to make sure, when they did, that noticing you would be a mistake.", - }, - { - id: "honest", - label: "Admit the silence still echoes in you", - outcome: - "She nodded, unsurprised. 'It does that. To everyone who goes there and comes back.' She poured two cups of something hot and handed you one. 'The trick is to let the sound fill back in. Give it time.'", - }, - ], - unlock: { type: "bossDefeated", bossId: "the_devourer" }, - }, - { - id: "story_ch_07", - title: "Above the Storm", - content: - "The Celestial Reaches existed at the boundary where the world ended and something else began — not void, but luminescence; not emptiness, but a fullness that the mind struggled to accommodate. The creatures here were ancient and radiant, and the First Light most of all: a being that had been burning since before the age of names, that had watched civilisations rise and collapse and rise again like tides.\n\nIt had not been hostile, exactly. It had been testing. {characterName} understood that somewhere in the middle of the confrontation — that the First Light was not trying to destroy your guild, but to see if it could be ended by something that had not existed when it first ignited. When it finally went dark, there was no triumph in it. Only a strange, ringing silence.\n\nThe light that remained was different. Softer. Permanent-seeming. {characterName} stood at the peak of the celestial shelf and looked back down at the world — at the green and grey and blue of it, impossibly small and impossibly precious from this height. A voice that was not quite a voice said: You are farther now than any of your kind have come. This is a threshold. What you carry forward from here, carry with intention.", - choices: [ - { - id: "memory", - label: "Carry forward the memory of those lost", - outcome: - "The names. The faces. The ones who hadn't made it as far as this height. You held them as a weight and a compass both, and continued with your eyes open.", - }, - { - id: "will", - label: "Carry forward the will to finish it", - outcome: - "The work was not done. The scale of it had grown, but the work remained: take one more step, and then another, and do not stop until the last thing is settled. You were not built to leave things undone.", - }, - { - id: "wonder", - label: "Carry forward wonder, against hardness", - outcome: - "It would have been easy, up here, to become something cold and certain. You chose differently. The capacity to be astonished — by starlight, by loyalty, by the improbable fact of still being alive — you held on to that deliberately.", - }, - ], - unlock: { type: "bossDefeated", bossId: "the_first_light" }, - }, - { - id: "story_ch_08", - title: "What Sleeps Below", - content: - "Depth has a texture to it. The Abyssal Trench was not merely dark or cold — it was weighted, as if the water above had accumulated all the years of everything that had ever fallen into it. Strange bioluminescent creatures drifted past the guild's descent vessels, curious and enormous, paying no more attention to the expedition than tide pays to a stone.\n\nThe Elder Abomination slept at the very bottom. Or it had slept. Something had woken it — some disturbance in the pattern of the world that had disturbed even this ancient, unfathomable thing from its millennia of stillness. By the time {characterName}'s guild reached it, the creature was fully awake and deeply unhappy about it.\n\nReturning to the surface felt like being born. {characterName} sat on the deck of the recovery vessel for a long time, listening to the sea. The guild's resident naturalist finally came to sit nearby. \"It shouldn't have been awake,\" he said quietly. \"That thing has been sleeping since before the Shattered Ruins were built. Something disturbed it. Something from above.\" He paused. \"Something falling down.\"", - choices: [ - { - id: "ask", - label: "Ask what he thinks is falling", - outcome: - "'Pressure,' he said. 'The kind that builds when too many powers concentrate in one place. When too much of the world's weight tips in a single direction.' He looked at you with an expression that was half-admiration, half-concern. You noted that he did not look away.", - }, - { - id: "accept", - label: "Accept that some things can't be predicted", - outcome: - "Not everything could be prepared for. This was a truth you had learned the hard way, and you'd learned it well enough to stop fighting it. You watched the surface settle and held the uncertainty like ballast.", - }, - { - id: "document", - label: "Document everything for whoever comes next", - outcome: - "If something woke what slept below, there would be others who needed to know. You spent the return voyage writing — a record not of victory, but of pattern, for the eyes of whoever followed after.", - }, - ], - unlock: { type: "bossDefeated", bossId: "elder_abomination" }, - }, - { - id: "story_ch_09", - title: "A Throne of Ashes", - content: - "The Infernal Court had once been magnificent. You could see it still in the architecture — in the carved colonnades now pitted with heat damage, in the great vaulted ceilings painted with scenes of dominion that the paint was slowly abandoning. Whatever power had ruled here had been absolute, for a long time, before the falling.\n\nThe Infernal Sovereign was what remained of that power: immense, bitter, and still dangerously capable despite everything it had lost. It fought with the rage of something that remembered being greater, and that memory gave its strikes a wild, desperate edge that made it more unpredictable than raw power alone would have. {characterName} had prepared for strength; what you met was grief armoured in fury.\n\nIn the court's throne room — the throne itself melted into a lump of cooled metal — a spirit lingered. Not hostile. Only old, and sad, and somehow unable to leave. It regarded {characterName} with hollow eyes and said: \"We were warned. We chose not to listen. Does that happen where you come from?\" A pause. \"Of course it does. It always does. The shape of the mistake is always the same.\" It pointed at the throne. \"Power that forgets it is borrowed. That is what we were.\"", - choices: [ - { - id: "learn", - label: "Ask what they were warned about", - outcome: - "The spirit answered slowly, in the manner of things that have had too much time to think. The warning had been about the Void — about the hunger at the edge of everything. They had believed themselves beyond reach. You filed this away as a lesson.", - }, - { - id: "silence", - label: "Acknowledge the warning and leave in silence", - outcome: - "Some moments asked for silence. You gave it. The spirit seemed grateful, in its way — acknowledged rather than dismissed. You left the court with a weight on you that was not unearned.", - }, - { - id: "vow", - label: "Vow your guild won't make the same mistake", - outcome: - "The spirit looked at you for a long time. 'That is what they said too,' it finally replied. But it did not say it unkindly. And it watched you all the way to the door.", - }, - ], - unlock: { type: "bossDefeated", bossId: "infernal_sovereign" }, - }, - { - id: "story_ch_10", - title: "Truth in Glass", - content: - "The Crystalline Spire was remarkable in a particular, unsettling way: everything within it was visible. The crystal walls, the crystal floor, the crystal columns — all of it perfectly transparent, layered and angled in ways that meant you were always visible from multiple directions at once. There was nowhere to hide. The Spire rejected concealment.\n\nThe Diamond Colossus had been something like a curator — a being that maintained the Spire's integrity and guarded the thing at its heart. What was at its heart, {characterName} discovered after the Colossus's defeat: a chamber in which everything was perfectly reflected, where the angles of crystal showed you not what was around you but what was true about you.\n\n{characterName} stood in the chamber alone, for a moment. The reflections were not flattering, exactly, but they were honest — showing the cost of every decision alongside the decision, the weight alongside the achievement. Then the crystal dimmed and returned to mere transparency. A crystallographer pressed her forehead against the outer wall and murmured: \"They say it shows you your ledger. Credits and debits both. How was the balance?\"", - choices: [ - { - id: "better", - label: "Not as bad as I feared", - outcome: - "The crystallographer looked relieved in a way that surprised you — as though your answer was the one she'd needed to hear too. The balance of your guild was its people, more than its victories. You had not forgotten that. Not yet.", - }, - { - id: "expected", - label: "Exactly what I expected", - outcome: - "'Then you have been paying attention,' she said, quietly approving. 'That is rarer than it should be.' Honesty about your own ledger was its own form of discipline.", - }, - { - id: "quiet", - label: "I don't think I'm the one who should say", - outcome: - "She nodded slowly. 'The ones who say nothing are usually telling the truth,' she said. There was no judgment in it. Only recognition.", - }, - ], - unlock: { type: "bossDefeated", bossId: "diamond_colossus" }, - }, - { - id: "story_ch_11", - title: "The Hollow Crown", - content: - "The Void Sanctum was not a place that had been built. It was a place that had formed — the way a scar forms, or a callus: in response to repeated pressure, over a very long time. At its heart, the Void had pressed so hard against the membrane of reality that reality had simply organised itself around the pressure, creating a space that belonged to neither.\n\nThe Void Emperor was the Void's attempt to give itself a face. It wore the shape of something almost humanoid — a crown, a throne, robes of pure dark — as if by imitating the structures of power it could claim legitimacy. The imitation was close enough to be disturbing. {characterName} had fought rulers before. Never one that was made of rulership, that had absorbed every quality of dominion without any of the responsibility.\n\nAfter the battle, the Sanctum dimmed. The Void withdrew, slightly, behind its veil. {characterName} sat in the emptiness and thought: this was the Void's best answer to us. Its best argument for what it could become. It had not been good enough. But it had been close.", - choices: [ - { - id: "sit", - label: "Let the silence sit before leaving", - outcome: - "Wisdom, sometimes, is the willingness to remain still in an uncomfortable place long enough to understand it. You sat. The silence told you what it could. When you left, you took that understanding with you.", - }, - { - id: "record", - label: "Record the Void Emperor's nature carefully", - outcome: - "If the Void had sent its best, it would send something different next time. Documentation was not heroism, but it was its own form of readiness. You filled pages on the return.", - }, - { - id: "rally", - label: "Rally the guild — the work isn't done", - outcome: - "There was no room for relief yet. The Void had pulled back, but pulling back was not retreating. You said this to your guild and they already knew it. That was the measure of how far you had all come.", - }, - ], - unlock: { type: "bossDefeated", bossId: "void_emperor" }, - }, - { - id: "story_ch_12", - title: "The Weight of Forever", - content: - "The Eternal Throne had been contested since before recorded history: a seat of power that drew the powerful toward it by a force like gravity, that transformed whoever held it into something the throne itself wanted, rather than what the holder intended. Every civilisation that had ever reached the Eternal Throne had either bent to its will or been broken by it.\n\nThe Apex was the throne's current answer to the question of power — an entity that had fully surrendered to the throne's nature, that had become its instrument rather than its occupant. The battle for the Throne was therefore not a battle for territory, but for the assertion that some things should not be occupied, that some seats corrupt by design.\n\n{characterName} stood before the throne after the Apex's dissolution. It hummed. Not with malice. With potential. With the weight of every person who had ever sat in it and believed themselves equal to it. The question was simple and said nothing aloud: And you? What would you do with all of this?", - choices: [ - { - id: "walk", - label: "Walk away from the throne", - outcome: - "You turned your back on it and led your guild out. Not every power needs to be claimed. Not every throne needs an occupant. The room was quieter when you left. You thought it might have been grateful.", - }, - { - id: "stand", - label: "Stand at its foot and make a decision", - outcome: - "You did not sit. But you acknowledged it — the gravity of everything it represented, the cost and the weight and the long history. And then you looked away from it and toward the door, and that was its own kind of answer.", - }, - { - id: "declare", - label: "Declare that power is held in trust", - outcome: - "The throne hummed louder, then quieter. You weren't sure if that was agreement or only vibration. But your guild heard you, and they held onto those words for a long time afterward.", - }, - ], - unlock: { type: "bossDefeated", bossId: "the_apex" }, - }, - { - id: "story_ch_13", - title: "Before the Word", - content: - "In the Primordial Chaos, the world had not yet decided what it was. Or rather, it remembered not having decided — there was a quality to the place of incompletion, of options not yet foreclosed, of reality in draft form. The creatures here were not woven from the world's final fabric but from its early sketches, and the Primordial Titan was the largest of those sketches: vast, contradictory, capable of being several incompatible things at once.\n\nDefeating it was less a battle than a negotiation of what was real. {characterName} had led the guild through experiences that prepared you for almost anything. This prepared you for almost nothing, and you handled it anyway.\n\nStanding in the chaos afterward — which was somehow quieter now, though no less chaotic — {characterName} had the strange sensation of being at the beginning of something. Not an ending. A voice from somewhere said: You are made of what came after. We are what came before. We wondered if the after was stable. A pause. It is. You are proof of it.", - choices: [ - { - id: "before", - label: "Ask what came before the before", - outcome: - "Silence. Then: That is not a question with a shape yet. You decided to accept that as an answer and move forward.", - }, - { - id: "worth", - label: "Affirm that what was built is worth defending", - outcome: - "Yes, said the voice. That is why it has lasted. You were not sure what to do with a compliment from the primordial chaos, but you received it with the sincerity it was offered.", - }, - { - id: "fixed", - label: "Stand in the chaos and feel your own solidity", - outcome: - "Whatever you were — guild leader, fighter, something increasingly harder to categorise — you were specific. Named. Decided. In the midst of all this undecidedness, you were a fixed point, and that was enough.", - }, - ], - unlock: { type: "bossDefeated", bossId: "primordial_titan" }, - }, - { - id: "story_ch_14", - title: "The Scale of Things", - content: - "The Infinite Expanse lived up to its name in the most literal possible way. {characterName} had fought in many places: claustrophobic dungeon corridors, open battlefield, the vertigo of the Astral Void. Nothing had prepared you for a space that simply continued, that had no visible boundary in any direction, that defeated navigation not by obscuring the path but by making every direction equivalent.\n\nThe Expanse Sovereign had not been hostile in the conventional sense. It was vast and the guild was small and it handled the discrepancy the way a person handles a splinter: with focused, dispassionate attention, before returning to larger concerns. That it was defeated at all was a thing {characterName} still wasn't entirely certain was real.\n\nComing back to anything finite felt like arriving home after a very long journey. {characterName} sat in the expedition camp and found it tremendously, overwhelmingly comforting. One of your guild's scouts — who had a reputation for being unflappable — was quietly crying with relief in the corner. You said nothing. Some things did not require commentary, only presence.", - choices: [ - { - id: "stay", - label: "Sit with your scout until the feeling passed", - outcome: - "You stayed. There was no trick to it, no words that helped more than the simple fact of not being alone. The scout looked at you later with a complicated expression that was mostly gratitude.", - }, - { - id: "small", - label: "Acknowledge the scale — and your smallness", - outcome: - "Big was not the same as better. The Expanse was infinite. Your guild was finite. And yet something in you had the audacity to persist in finite space and say: we are still here. You could live with that audacity.", - }, - { - id: "plan", - label: "Begin immediately planning the next move", - outcome: - "Movement was your steadiest anchor. Your scout caught you making notes and shook their head, half exasperated and half relieved to see you so thoroughly yourself. You both knew it meant you were going to be all right.", - }, - ], - unlock: { type: "bossDefeated", bossId: "expanse_sovereign" }, - }, - { - id: "story_ch_15", - title: "The Maker's Bones", - content: - "The Reality Forge was where the laws of the world had been written, eons ago, by something that was no longer present — only the Forge itself remained, and the Architect who had appointed themselves its guardian. The Forge did not make things so much as it made the conditions for things: the rules of physics, the logic of cause and effect, the consistency that allowed anything to be relied upon at all.\n\nThe Reality Architect had not wanted to fight. It had wanted {characterName} to understand what would happen if the Forge was disrupted, if the rules it maintained were allowed to slip. The confrontation had been as much argument as battle — and {characterName} had had to win both.\n\nAfterward, in the Forge's warm, humming workshop, you found what looked like blueprints — not for things, but for principles. The principle of consequence. The principle of memory. The principle of growth. Beside the blueprints, in handwriting that was startlingly mundane, was a single note: These are not laws. They are invitations. What you do with them is yours.", - choices: [ - { - id: "intact", - label: "Accept the invitation; leave the Forge intact", - outcome: - "The Forge continued its quiet work. You left it as you found it, not because you lacked the power to change it, but because some things had been put in place by wiser hands than yours, and wisdom lay in knowing the difference.", - }, - { - id: "add", - label: "Add a small note to the blueprints", - outcome: - "Your addition was modest — almost invisible. A small notation in the margin of the principle of memory: and what is remembered by those who choose to remember. Whether it had any effect, you never knew. You left it there anyway.", - }, - { - id: "write", - label: "Write down what you observed, for others", - outcome: - "Documentation felt inadequate for what the Forge was. You did it anyway. The notes would be strange, but they would be accurate, and accuracy was the only thing the Forge itself seemed to care about.", - }, - ], - unlock: { type: "bossDefeated", bossId: "reality_architect" }, - }, - { - id: "story_ch_16", - title: "When Stars Scream", - content: - "The Cosmic Maelstrom was not catastrophe in progress — it was the shape that catastrophe left behind. A wound in the fabric of space-time, still bleeding along its edges, where something had pulled too hard and torn. The things that lived in the Maelstrom had adapted to destruction as an environment: they had evolved within the wound, made their ecology from annihilation.\n\nThe Cosmic Annihilator was their apex: a creature that not only survived destruction but was destruction, in the way that a predator is starvation given motion. It could not be reasoned with. It could only be answered. {characterName}'s guild answered it, at cost, and stood in the Maelstrom's strange, violent quiet when it was done.\n\nStars were visible through the tear — real stars, on the other side of the wound. They seemed very small from here. {characterName} looked at them for a long time and thought: if something tears the world apart, the stars outside it will keep burning. That was either comforting or terrible, and you had not yet decided which.", - choices: [ - { - id: "comfort", - label: "Find it comforting — the universe persists", - outcome: - "The permanence of the stars was a kind of promise. What existed before you would exist after you, and what you did in the time between was not erased by scale. You held onto this.", - }, - { - id: "grief", - label: "Find it terrible — your losses are not small", - outcome: - "Your guild had bled for this. The grief of it was real and specific and theirs, and the indifference of the cosmos did not diminish it. You turned away from the stars and toward your people.", - }, - { - id: "present", - label: "Find it neither — just be present", - outcome: - "Sometimes a moment did not need interpretation. You stood in it. It was what it was. The stars were what they were. That was enough, for now.", - }, - ], - unlock: { type: "bossDefeated", bossId: "cosmic_annihilator" }, - }, - { - id: "story_ch_17", - title: "The First Name", - content: - "The Primeval Sanctum predated every text, every tradition, every story that had tried to explain where things came from. The gods worshipped in the world's temples were, most of them, echoes of things that had originated here — derivative, sincere copies of an original that no living theology had ever accurately described.\n\nThe Primeval God was not what {characterName} had expected, which, given the nature of the place, should have been expected. It was older than expectation. Older than the structure of surprise. It fought with the economy of something that had done this before, across spans of time so vast they had lost meaning, and yet it was not bored — there was a quality to its attention that suggested this battle, specifically, mattered to it.\n\nWhen it ended, the Sanctum settled into a peace that felt earned. A presence that had not taken part in the fight spoke in a register that bypassed language entirely and arrived as understanding: You have reached what was first. Few do. What you carry from here is yours, made of everything that came before you and everything you chose to do with it.", - choices: [ - { - id: "weight", - label: "Carry the weight of all that came before", - outcome: - "The generations that had built the world — the forgotten, the unnamed, the ones whose courage made your existence possible — you acknowledged them. You were not the beginning. You were what they had been working toward. That felt like enough.", - }, - { - id: "chosen", - label: "Carry only what you chose", - outcome: - "You could not carry everything. The weight would have stopped you where you stood. You chose carefully — the things that were yours, the things that mattered, the things that would survive the carrying.", - }, - { - id: "waste", - label: "Carry the intention not to waste this", - outcome: - "You had arrived somewhere very few had. What you did next would define what arriving here meant. You did not intend to waste it.", - }, - ], - unlock: { type: "bossDefeated", bossId: "primeval_god" }, - }, - { - id: "story_ch_18", - title: "Beyond the Last Door", - content: - "There was no dramatic approach to the Absolute. No great architecture, no fanfare, no threshold that announced itself as the final one. The path simply led here, and here was where it ended, and the Absolute One waited in the way that fundamental truths wait: patiently, completely, indifferent to whether you were ready.\n\n{characterName} had been preparing for this, in some sense, since the Vale. Every battle, every choice, every loss and triumph had been a step toward this point. Standing at the end of it, that knowledge did not make the confrontation smaller — it made it more coherent. The guild understood what they were fighting for. They had not forgotten.\n\nThe Absolute One was defeated. Or resolved. Or answered. The word for what happened was not quite any of those. What remained was {characterName}, in the silence of the absolute, and the realisation that the journey had changed you so thoroughly that the person who had begun it would not entirely recognise who you were now. The question was: was what you'd become worth what you'd spent to become it? You stood in the silence. You knew the answer. You always had.", - choices: [ - { - id: "yes", - label: "Yes — without hesitation", - outcome: - "There was nothing complicated in it. The weight, the cost, the long road — you would have done it again. Would do it again. The certainty was quiet and complete, and that was the most honest thing you had ever known.", - }, - { - id: "cost", - label: "Yes — though the cost was real", - outcome: - "The acknowledgement of loss did not diminish the worth of it. Things had been spent that could not be recovered. That was true. And the answer was still yes. Holding both of those things at once was the truest thing you had ever managed.", - }, - { - id: "becoming", - label: "I am still becoming the answer", - outcome: - "The journey had not ended. The Absolute was a chapter, not a conclusion. You were still writing the rest of it. That was neither modesty nor avoidance — it was honesty. You left the silence of the Absolute and walked forward, because walking forward was what you did.", - }, - ], - unlock: { type: "bossDefeated", bossId: "the_absolute_one" }, - }, - { - id: "story_ch_19", - title: "The Cycle Begins", - content: - "The scholars who had followed the guild's ascent called it various things: renewal, iteration, the loop of power. The word that the guild's oldest member used was simpler — beginning again. Which was exactly what it was. {characterName} had brought the guild from its first uncertain steps to a height most would consider the summit, and then made the choice to dissolve that height and start over — not because the achievement was worthless, but because the shape of growth required it.\n\nPrestige was not defeat and it was not retreat. It was the choice to let the accumulated work settle into something structural — to let every lesson, every hard-won understanding, become the new foundation rather than the old ceiling. The guild had been rebuilt on knowledge rather than innocence, and knowledge was a far sturdier material.\n\nThe first morning after the prestige, {characterName} stood in the guild hall with its reduced numbers and its scant resources and felt something unexpected: not disappointment, but anticipation. The road was familiar now. You knew where the dangers hid and where the opportunities were. The second walk was going to be faster, and harder, and better. You knew this with a certainty that only experience could manufacture.", - choices: [ - { - id: "know", - label: "Tell the guild: we know the way", - outcome: - "The veterans who had made this choice with you nodded. The newer members looked uncertain. You had both in your guild, and that was the point — the knowledge passed forward, the lessons given to those who hadn't yet paid for them. That was the real economy of prestige.", - }, - { - id: "work", - label: "Begin immediately, without ceremony", - outcome: - "There was a kind of respect in not making a production of it. The work was what mattered. The ceremony could wait for a summit that didn't keep moving. You set to work, and your guild followed, and that was the whole of the ritual.", - }, - { - id: "rest", - label: "Take a single day to rest before restarting", - outcome: - "One day. You had earned it, and so had they. The guild rested, and healed, and ate without rushing, and said things to each other that the urgency of the climb hadn't left room for. On the second morning you began again, and you began stronger.", - }, - ], - unlock: { type: "prestige", threshold: 1 }, - }, - { - id: "story_ch_20", - title: "A Familiar Road", - content: - "By the fifth time, the road had a texture to it. {characterName} knew its rhythms — knew where the ground softened before the climb, knew which obstacles yielded to patience and which to force, knew which members of the guild would struggle at which stages and what they would need. The fifth prestige was not easier than the first, but it was more legible, and legibility was its own form of ease.\n\nThere was a danger here, too, that {characterName} had begun to notice: the danger of mastery becoming habit. The road could be walked competently in a kind of fog, each step correct but automatic. The guild members who had made this journey fewer times still found it new. They were still surprised. You had to choose, consciously, to let their surprise be contagious — to see what they saw, rather than what you had already catalogued.\n\nOn the night before the fifth return, the guild held a gathering that no one had organised and no one had asked for — it had simply happened, the way meaningful things sometimes do. Stories traded, laughter at shared memory, a kind of warmth that had nothing to do with strategy or achievement. {characterName} sat in the middle of it and thought: this is what the power has always been for.", - choices: [ - { - id: "speak", - label: "Speak to the guild about why you keep going", - outcome: - "You hadn't planned to say anything, and what you said wasn't polished. But it was honest, and your guild heard it that way, and the room got quieter in the good way — the way of people deciding to believe in something together.", - }, - { - id: "listen", - label: "Let the gathering speak for itself", - outcome: - "Sometimes leadership was knowing when not to speak. The guild had found its own reason to celebrate, its own meaning in the repetition. You listened and were grateful.", - }, - { - id: "store", - label: "Commit the moment to memory, for hard times", - outcome: - "There would be difficult nights later. There always were. You stored this one carefully — the warmth of it, the sound of laughter, the proof that your people were still whole — so that you could return to it when the cold came in.", - }, - ], - unlock: { type: "prestige", threshold: 5 }, - }, - { - id: "story_ch_21", - title: "The Shedding", - content: - "Transcendence was the end of a particular kind of certainty. Everything {characterName} had built — the guild in its current form, the resources accumulated, the structures of power carefully constructed through prestige after prestige — was released. Not destroyed. Released, the way water is released when a dam is removed, allowed to flow where it had always wanted to flow.\n\nWhat remained afterward was something harder to name. Not less, though it was certainly different. The echoes of what had been done crystallised into something structural, something that would shape what came next in ways that the doing itself hadn't. Transcendence was the distance between experience and wisdom, and {characterName} now understood why so few managed it: it required the genuine willingness to let go of what you had made.\n\nStanding in the new beginning, which was stranger than any previous new beginning because there was so much more to rebuild, {characterName} felt the shape of a larger pattern — one that required more of you than any single achievement. This was not the end of the story. This was the moment the story understood its own scale.", - choices: [ - { - id: "begin", - label: "Accept the strangeness and begin", - outcome: - "The unfamiliarity was not your enemy. It was proof that you were somewhere genuinely new. You held that discomfort lightly and took the first step.", - }, - { - id: "grieve", - label: "Sit with what was released before moving on", - outcome: - "Loss and choice were not incompatible. You had chosen to release, and what you had released had been real and worth having. Acknowledging that before turning forward was not weakness. It was honesty.", - }, - { - id: "pattern", - label: "Find the shape of the new pattern immediately", - outcome: - "Your mind moved the way it always had, already mapping the new terrain. The guild watched you and felt steadier for it. Pattern-finding was its own form of courage — the refusal to be lost.", - }, - ], - unlock: { type: "transcendence", threshold: 1 }, - }, - { - id: "story_ch_22", - title: "Becoming", - content: - "There was no adequate language for what {characterName} had become. The old categories — leader, fighter, guild master — had been accurate once, and were now technically still correct, the way a childhood home is technically still the same structure as the one you remember, even though the scale of everything has shifted.\n\nApotheosis was not the end of growth. It was the moment growth changed kind. Everything prior had been accumulation: skill, power, understanding, experience. What lay on the other side of this threshold was something that could not be accumulated, only inhabited. A relationship with existence that was less about acquiring and more about being — about what it meant to occupy a place in the world when that place had become genuinely extraordinary.\n\n{characterName} looked at the guild — at every person who had walked some or all of this road — and felt something that did not have a simple name. Gratitude was part of it. Pride was part of it. The weight of responsibility was part of it. Most of all it was the recognition that you had arrived somewhere none of you had initially imagined, together, and that together was the only word that did the thing justice.", - choices: [ - { - id: "given", - label: "Acknowledge what was given as much as earned", - outcome: - "You had not walked this road alone. Every person who had followed you, every ally who had helped, every predecessor whose failures had mapped the path — their contribution was woven into what you were now. You remembered them, and it mattered.", - }, - { - id: "forward", - label: "Look forward to what this makes possible", - outcome: - "The horizon had not disappeared. It had moved — further, broader, stranger. What you were now could do things that what you had been could only approach. You looked at the new horizon and felt something you had almost forgotten: excitement.", - }, - { - id: "be", - label: "Simply be what you have become, for now", - outcome: - "Not every threshold needed to be rushed past. You were here. You were this. You let the weight of that settle before you took the next step. Presence was its own kind of power.", - }, - ], - unlock: { type: "apotheosis", threshold: 1 }, - }, -]; diff --git a/packages/types/src/interfaces/Title.ts b/packages/types/src/interfaces/Title.ts deleted file mode 100644 index acc4d68..0000000 --- a/packages/types/src/interfaces/Title.ts +++ /dev/null @@ -1,26 +0,0 @@ -export type TitleConditionType = - | "totalClicks" - | "totalGoldEarned" - | "bossesDefeated" - | "questsCompleted" - | "prestigeCount" - | "transcendenceCount" - | "apotheosisCount" - | "adventurerTotal" - | "achievementsUnlocked" - | "guildFounded" - | "playedDays"; - -export interface TitleCondition { - type: TitleConditionType; - /** Threshold required to unlock (not used for guildFounded) */ - amount?: number; -} - -export interface Title { - id: string; - name: string; - /** Human-readable description shown as the unlock hint */ - description: string; - condition: TitleCondition; -} diff --git a/packages/types/src/interfaces/Transcendence.ts b/packages/types/src/interfaces/Transcendence.ts deleted file mode 100644 index e205859..0000000 --- a/packages/types/src/interfaces/Transcendence.ts +++ /dev/null @@ -1,36 +0,0 @@ -export type TranscendenceUpgradeCategory = - | "income" - | "combat" - | "prestige_threshold" - | "prestige_runestones" - | "echo_meta"; - -export interface TranscendenceUpgrade { - id: string; - name: string; - description: string; - category: TranscendenceUpgradeCategory; - /** Echo cost to purchase */ - cost: number; - /** Multiplicative effect of this upgrade */ - multiplier: number; -} - -export interface TranscendenceData { - /** Number of times the player has transcended */ - count: number; - /** Echoes accumulated across all transcendences */ - echoes: number; - /** IDs of echo upgrades purchased with echoes */ - purchasedUpgradeIds: string[]; - /** Pre-computed: multiplier applied to all passive gold income */ - echoIncomeMultiplier: number; - /** Pre-computed: multiplier applied to party DPS in boss fights */ - echoCombatMultiplier: number; - /** Pre-computed: multiplier applied to the prestige gold threshold (< 1 lowers requirement) */ - echoPrestigeThresholdMultiplier: number; - /** Pre-computed: multiplier applied to runestones earned per prestige */ - echoPrestigeRunestoneMultiplier: number; - /** Pre-computed: multiplier applied to echo yield on future transcendences */ - echoMetaMultiplier: number; -} diff --git a/packages/types/src/interfaces/Upgrade.ts b/packages/types/src/interfaces/Upgrade.ts deleted file mode 100644 index 75282af..0000000 --- a/packages/types/src/interfaces/Upgrade.ts +++ /dev/null @@ -1,22 +0,0 @@ -export type UpgradeTarget = - | "click" - | "adventurer" - | "global" - | "prestige" - | "boss"; - -export interface Upgrade { - id: string; - name: string; - description: string; - target: UpgradeTarget; - /** ID of the adventurer this applies to (if target is "adventurer") */ - adventurerId?: string; - /** Multiplier applied to the target's output */ - multiplier: number; - costGold: number; - costEssence: number; - costCrystals: number; - purchased: boolean; - unlocked: boolean; -} diff --git a/packages/types/src/interfaces/Zone.ts b/packages/types/src/interfaces/Zone.ts deleted file mode 100644 index 789b467..0000000 --- a/packages/types/src/interfaces/Zone.ts +++ /dev/null @@ -1,13 +0,0 @@ -export type ZoneStatus = "locked" | "unlocked"; - -export interface Zone { - id: string; - name: string; - description: string; - emoji: string; - status: ZoneStatus; - /** Boss ID whose defeat is required to unlock this zone (null for the starter zone) */ - unlockBossId: string | null; - /** Quest ID that must be completed to unlock this zone (null for the starter zone) */ - unlockQuestId: string | null; -} diff --git a/packages/types/src/interfaces/achievement.ts b/packages/types/src/interfaces/achievement.ts new file mode 100644 index 0000000..b40bb44 --- /dev/null +++ b/packages/types/src/interfaces/achievement.ts @@ -0,0 +1,45 @@ +/** + * @file Achievement types for the Elysium game. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +type AchievementConditionType = + | "totalGoldEarned" + | "totalClicks" + | "bossesDefeated" + | "questsCompleted" + | "adventurerTotal" + | "prestigeCount" + | "equipmentOwned"; + +interface AchievementCondition { + type: AchievementConditionType; + amount: number; +} + +interface AchievementReward { + crystals?: number; +} + +interface Achievement { + id: string; + name: string; + description: string; + icon: string; + condition: AchievementCondition; + reward?: AchievementReward; + + /** + * Unix timestamp when unlocked, null if not yet unlocked. + */ + unlockedAt: number | null; +} + +export type { + Achievement, + AchievementCondition, + AchievementConditionType, + AchievementReward, +}; diff --git a/packages/types/src/interfaces/adventurer.ts b/packages/types/src/interfaces/adventurer.ts new file mode 100644 index 0000000..063fa54 --- /dev/null +++ b/packages/types/src/interfaces/adventurer.ts @@ -0,0 +1,45 @@ +/** + * @file Adventurer types for the Elysium game. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +type AdventurerClass = + | "warrior" + | "mage" + | "rogue" + | "cleric" + | "ranger" + | "paladin"; + +interface Adventurer { + id: string; + name: string; + class: AdventurerClass; + level: number; + + /** + * Base cost for the first purchase of this tier (scales by 1.15× per count). + */ + baseCost: number; + + /** + * Base gold generated per second. + */ + goldPerSecond: number; + + /** + * Base essence generated per second. + */ + essencePerSecond: number; + + /** + * Combat power per unit — used in boss battle simulation. + */ + combatPower: number; + count: number; + unlocked: boolean; +} + +export type { Adventurer, AdventurerClass }; diff --git a/packages/types/src/interfaces/api.ts b/packages/types/src/interfaces/api.ts new file mode 100644 index 0000000..22d056e --- /dev/null +++ b/packages/types/src/interfaces/api.ts @@ -0,0 +1,429 @@ +/** + * @file API request and response types for the Elysium game. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import type { + EquipmentBonus, + EquipmentRarity, + EquipmentType, +} from "./equipment.js"; +import type { GameState } from "./gameState.js"; +import type { Player } from "./player.js"; +import type { ProfileSettings } from "./profileSettings.js"; + +interface AuthResponse { + token: string; + player: Player; + isNew: boolean; +} + +interface SaveRequest { + state: GameState; + + /** + * HMAC-SHA256 signature of the previous save's state, for anti-cheat chain verification. + */ + signature?: string; +} + +interface SaveResponse { + savedAt: number; + + /** + * HMAC-SHA256 signature of the saved state — store and include in next save request. + */ + signature?: string; +} + +interface LoginBonusResult { + + /** + * Current login streak day count. + */ + streak: number; + + /** + * Gold awarded for today's login. + */ + goldEarned: number; + + /** + * Crystals awarded (day 7 bonus, scaled by week multiplier). + */ + crystalsEarned: number; + + /** + * Day within the 7-day cycle (1–7). + */ + day: number; + + /** + * Week number multiplier (week 1 = ×1, week 2 = ×2, …). + */ + weekMultiplier: number; +} + +interface LoadResponse { + state: GameState; + + /** + * Offline gold earned since last save (server-calculated). + */ + offlineGold: number; + + /** + * Offline essence earned since last save (server-calculated). + */ + offlineEssence: number; + + /** + * Seconds the player was offline (capped at 8 hours). + */ + offlineSeconds: number; + + /** + * HMAC-SHA256 signature of the loaded state — store and include in next save request. + */ + signature?: string; + + /** + * Daily login bonus awarded on this load (null if already claimed today). + */ + loginBonus: LoginBonusResult | null; + + /** + * Current login streak (always present). + */ + loginStreak: number; + + /** + * True when the player's save data is from an older schema version. + */ + schemaOutdated: boolean; + + /** + * The current expected schema version from the server. + */ + currentSchemaVersion: number; +} + +interface BossChallengeRequest { + bossId: string; +} + +interface BossChallengeResponse { + won: boolean; + partyDPS: number; + bossDPS: number; + + /** + * Boss HP immediately before the battle. + */ + bossHpBefore: number; + + /** + * Boss maximum HP. + */ + bossMaxHp: number; + + /** + * Boss HP at end of battle before any state reset (0 on win). + */ + bossHpAtBattleEnd: number; + + /** + * Boss HP stored in game after the result (0 on win, maxHp on loss). + */ + bossNewHp: number; + + /** + * Total party HP at start of battle. + */ + partyMaxHp: number; + + /** + * Party HP remaining after battle (0 on loss). + */ + partyHpRemaining: number; + rewards?: { + gold: number; + essence: number; + crystals: number; + upgradeIds: Array<string>; + equipmentIds: Array<string>; + + /** + * Runestone bounty awarded for defeating this boss for the very first time. + */ + bountyRunestones: number; + }; + casualties?: Array<{ + adventurerId: string; + killed: number; + }>; +} + +type PrestigeRequest = Record<string, never>; + +interface PrestigeResponse { + runestones: number; + + // eslint-disable-next-line unicorn/no-keyword-prefix -- API field name matches server response + newPrestigeCount: number; + + /** + * Bonus runestones awarded for reaching a milestone prestige (every 5th), 0 if not a milestone. + */ + milestoneRunestones: number; +} + +interface BuyPrestigeUpgradeRequest { + upgradeId: string; +} + +interface BuyPrestigeUpgradeResponse { + runestonesRemaining: number; + purchasedUpgradeIds: Array<string>; + runestonesIncomeMultiplier: number; + runestonesClickMultiplier: number; + runestonesEssenceMultiplier: number; + runestonesCrystalMultiplier: number; +} + +interface PublicProfileResponse { + characterName: string; + pronouns: string; + characterRace: string; + characterClass: string; + username: string; + avatar: string | null; + bio: string; + guildName: string; + guildDescription: string; + profileSettings: ProfileSettings; + createdAt: number; + + /** + * All Time stats — cumulative across all runs, never reset. + */ + totalGoldEarned: number; + totalClicks: number; + lifetimeBossesDefeated: number; + lifetimeQuestsCompleted: number; + lifetimeAdventurersRecruited: number; + lifetimeAchievementsUnlocked: number; + + /** + * Current Run stats — sourced from the live GameState, reset on prestige & transcendence. + */ + currentRunGold: number; + currentRunClicks: number; + prestigeCount: number; + transcendenceCount: number; + apotheosisCount: number; + bossesDefeated: number; + questsCompleted: number; + adventurersRecruited: number; + achievementsUnlocked: number; + + /** + * Titles this player has unlocked, as {id, name} pairs for display. + */ + unlockedTitles: Array<{ id: string; name: string }>; + + /** + * The player's active title display name (empty string if none set). + */ + activeTitle: string; + + /** + * Items the player currently has equipped. + */ + equippedItems: Array<{ + name: string; + type: EquipmentType; + rarity: EquipmentRarity; + bonus: EquipmentBonus; + }>; +} + +interface UpdateProfileRequest { + characterName: string; + pronouns?: string; + characterRace?: string; + characterClass?: string; + bio?: string; + guildName?: string; + guildDescription?: string; + profileSettings: ProfileSettings; + + /** + * Title ID to set as active (empty string to clear). + */ + activeTitle?: string; +} + +interface UpdateProfileResponse { + characterName: string; + pronouns: string; + characterRace: string; + characterClass: string; + bio: string; + guildName: string; + guildDescription: string; + activeTitle: string; + profileSettings: ProfileSettings; +} + +type TranscendenceRequest = Record<string, never>; + +interface TranscendenceResponse { + echoes: number; + + // eslint-disable-next-line unicorn/no-keyword-prefix -- API field name matches server response + newTranscendenceCount: number; +} + +interface BuyEchoUpgradeRequest { + upgradeId: string; +} + +interface BuyEchoUpgradeResponse { + echoesRemaining: number; + purchasedUpgradeIds: Array<string>; + echoIncomeMultiplier: number; + echoCombatMultiplier: number; + echoPrestigeThresholdMultiplier: number; + echoPrestigeRunestoneMultiplier: number; + echoMetaMultiplier: number; +} + +type ApotheosisRequest = Record<string, never>; + +interface ApotheosisResponse { + + // eslint-disable-next-line unicorn/no-keyword-prefix -- API field name matches server response + newApotheosisCount: number; +} + +interface ApiError { + error: string; +} + +type LeaderboardCategory = + | "totalGold" + | "bossesDefeated" + | "questsCompleted" + | "achievementsUnlocked" + | "prestigeCount" + | "transcendenceCount" + | "apotheosisCount"; + +interface LeaderboardEntry { + rank: number; + discordId: string; + characterName: string; + username: string; + avatar: string | null; + activeTitle: string; + value: number; +} + +interface LeaderboardResponse { + category: LeaderboardCategory; + entries: Array<LeaderboardEntry>; +} + +interface GiteaRelease { + // eslint-disable-next-line @typescript-eslint/naming-convention -- external API field name + tag_name: string; + name: string; + body: string; + // eslint-disable-next-line @typescript-eslint/naming-convention -- external API field name + published_at: string; +} + +interface AboutResponse { + apiVersion: string; + releases: Array<GiteaRelease>; +} + +interface ExploreStartRequest { + areaId: string; +} + +interface ExploreStartResponse { + areaId: string; + endsAt: number; +} + +interface ExploreCollectRequest { + areaId: string; +} + +interface ExploreCollectEventResult { + text: string; + goldChange: number; + essenceChange: number; + materialGained: { materialId: string; quantity: number } | null; + adventurerLostCount: number; +} + +interface ExploreCollectResponse { + foundNothing: boolean; + nothingMessage?: string; + materialsFound: Array<{ materialId: string; quantity: number }>; + event: ExploreCollectEventResult | null; +} + +interface CraftRecipeRequest { + recipeId: string; +} + +interface CraftRecipeResponse { + recipeId: string; + bonusType: string; + bonusValue: number; + craftedGoldMultiplier: number; + craftedEssenceMultiplier: number; + craftedClickMultiplier: number; + craftedCombatMultiplier: number; +} + +export type { + AboutResponse, + ApiError, + ApotheosisRequest, + ApotheosisResponse, + AuthResponse, + BossChallengeRequest, + BossChallengeResponse, + BuyEchoUpgradeRequest, + BuyEchoUpgradeResponse, + BuyPrestigeUpgradeRequest, + BuyPrestigeUpgradeResponse, + CraftRecipeRequest, + CraftRecipeResponse, + ExploreCollectEventResult, + ExploreCollectRequest, + ExploreCollectResponse, + ExploreStartRequest, + ExploreStartResponse, + GiteaRelease, + LeaderboardCategory, + LeaderboardEntry, + LeaderboardResponse, + LoadResponse, + LoginBonusResult, + PrestigeRequest, + PrestigeResponse, + PublicProfileResponse, + SaveRequest, + SaveResponse, + TranscendenceRequest, + TranscendenceResponse, + UpdateProfileRequest, + UpdateProfileResponse, +}; diff --git a/packages/types/src/interfaces/apotheosis.ts b/packages/types/src/interfaces/apotheosis.ts new file mode 100644 index 0000000..ece184f --- /dev/null +++ b/packages/types/src/interfaces/apotheosis.ts @@ -0,0 +1,16 @@ +/** + * @file Apotheosis types for the Elysium game. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +interface ApotheosisData { + + /** + * Number of times the player has achieved Apotheosis. + */ + count: number; +} + +export type { ApotheosisData }; diff --git a/packages/types/src/interfaces/boss.ts b/packages/types/src/interfaces/boss.ts new file mode 100644 index 0000000..eabf646 --- /dev/null +++ b/packages/types/src/interfaces/boss.ts @@ -0,0 +1,64 @@ +/** + * @file Boss types for the Elysium game. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +type BossStatus = "locked" | "available" | "in_progress" | "defeated"; + +interface Boss { + id: string; + name: string; + description: string; + status: BossStatus; + maxHp: number; + currentHp: number; + + /** + * Damage dealt to adventurers per second whilst the fight is active. + */ + damagePerSecond: number; + + /** + * Gold reward on defeat. + */ + goldReward: number; + + /** + * Essence reward on defeat. + */ + essenceReward: number; + + /** + * Crystal reward on defeat. + */ + crystalReward: number; + + /** + * IDs of upgrades unlocked on defeat. + */ + upgradeRewards: Array<string>; + + /** + * IDs of equipment items granted on defeat. + */ + equipmentRewards: Array<string>; + + /** + * Minimum prestige level required to access this boss. + */ + prestigeRequirement: number; + + /** + * Zone this boss belongs to. + */ + zoneId: string; + + /** + * One-time runestone bounty awarded on first-ever defeat. + */ + bountyRunestones: number; +} + +export type { Boss, BossStatus }; diff --git a/packages/types/src/interfaces/codex.ts b/packages/types/src/interfaces/codex.ts new file mode 100644 index 0000000..528d8e2 --- /dev/null +++ b/packages/types/src/interfaces/codex.ts @@ -0,0 +1,30 @@ +/** + * @file Codex types for the Elysium game. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +interface CodexEntry { + id: string; + title: string; + content: string; + sourceType: + | "boss" + | "quest" + | "equipment" + | "adventurer" + | "upgrade" + | "prestige" + | "zone" + | "exploration" + | "recipe"; + sourceId: string; + zoneId: string; +} + +interface CodexState { + unlockedEntryIds: Array<string>; +} + +export type { CodexEntry, CodexState }; diff --git a/packages/types/src/interfaces/companion.ts b/packages/types/src/interfaces/companion.ts new file mode 100644 index 0000000..21d32b4 --- /dev/null +++ b/packages/types/src/interfaces/companion.ts @@ -0,0 +1,242 @@ +/** + * @file Companion types and data for the Elysium game. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +type CompanionBonusType = + | "passiveGold" + | "clickGold" + | "bossDamage" + | "essenceIncome" + | "questTime"; + +interface CompanionBonus { + type: CompanionBonusType; + + /** + * Fractional value: for multiplier types, adds this fraction (0.25 = +25%). For questTime, + * reduces duration by this fraction (0.15 = 15% faster). + */ + value: number; +} + +type CompanionUnlockType = + | "lifetimeBosses" + | "lifetimeQuests" + | "lifetimeGold" + | "prestige" + | "transcendence" + | "apotheosis"; + +interface CompanionUnlockCondition { + type: CompanionUnlockType; + threshold: number; +} + +interface Companion { + id: string; + name: string; + title: string; + description: string; + bonus: CompanionBonus; + unlock: CompanionUnlockCondition; +} + +interface CompanionState { + + /** + * Companion IDs the player has unlocked — recomputed server-side on every save. + */ + unlockedCompanionIds: Array<string>; + + /** + * The ID of the currently active companion, or null for none. + */ + activeCompanionId: string | null; +} + +// eslint-disable-next-line @typescript-eslint/naming-convention -- intentional constant name +const COMPANIONS: Array<Companion> = [ + { + bonus: { type: "passiveGold", value: 0.25 }, + description: + "A cheerful bard whose uplifting songs inspire your adventurers to work" + + " harder for better coin.", + id: "lyra", + name: "Lyra", + title: "Wandering Minstrel", + unlock: { threshold: 100, type: "lifetimeBosses" }, + }, + { + bonus: { type: "clickGold", value: 0.5 }, + description: + "A nimble rogue whose sleight of hand ensures far more gold lands in" + + " your coffers with every strike.", + id: "finn", + name: "Finn", + title: "Quick-Fingered Rogue", + unlock: { threshold: 100, type: "lifetimeQuests" }, + }, + { + bonus: { type: "questTime", value: 0.15 }, + description: + "A resourceful hedge witch who weaves minor enchantments that accelerate" + + " your quest parties.", + id: "wren", + name: "Wren", + title: "Hedge Witch", + unlock: { threshold: 500, type: "lifetimeQuests" }, + }, + { + bonus: { type: "bossDamage", value: 0.2 }, + description: + "A battle-hardened knight who leads your party with years of tactical" + + " experience against fearsome foes.", + id: "aldric", + name: "Aldric", + title: "Veteran Knight", + unlock: { threshold: 200, type: "lifetimeBosses" }, + }, + { + bonus: { type: "essenceIncome", value: 0.3 }, + description: + "A brilliant alchemist who transmutes ambient magic into pure essence," + + " bolstering your income.", + id: "sera", + name: "Sera", + title: "Arcane Alchemist", + unlock: { threshold: 10, type: "prestige" }, + }, + { + bonus: { type: "bossDamage", value: 0.4 }, + description: + "A powerful battle mage whose devastating spells tear through even the" + + " toughest boss encounters.", + id: "kael", + name: "Kael", + title: "Battle Mage", + unlock: { threshold: 720, type: "lifetimeBosses" }, + }, + { + bonus: { type: "questTime", value: 0.3 }, + description: + "A time mage who bends the threads of time itself, significantly" + + " hastening your quest parties.", + id: "zuri", + name: "Zuri", + title: "Chrono Weaver", + unlock: { threshold: 950, type: "lifetimeQuests" }, + }, + { + bonus: { type: "passiveGold", value: 0.75 }, + description: + "A wealthy merchant whose golden touch and trade empire dramatically" + + " boosts your passive earnings.", + id: "mira", + name: "Mira", + title: "Merchant Queen", + unlock: { threshold: 1e18, type: "lifetimeGold" }, + }, + { + bonus: { type: "essenceIncome", value: 0.75 }, + description: + "A shadowy information broker who channels essence from the void through" + + " forbidden knowledge.", + id: "vex", + name: "Vex", + title: "Shadow Broker", + unlock: { threshold: 5, type: "transcendence" }, + }, + { + bonus: { type: "passiveGold", value: 1 }, + description: + "A divine oracle whose celestial blessing transforms the very air around" + + " you into golden fortune.", + id: "pria", + name: "Pria", + title: "Celestial Oracle", + unlock: { threshold: 1, type: "apotheosis" }, + }, +]; + +/** + * Computes which companion IDs the player has unlocked based on their lifetime stats. + * Called server-side on every save using DB-authoritative player stats. + * @param parameters - The player's lifetime stats used to evaluate unlock conditions. + * @param parameters.lifetimeBossesDefeated - Total bosses defeated across all runs. + * @param parameters.lifetimeQuestsCompleted - Total quests completed across all runs. + * @param parameters.lifetimeGoldEarned - Total gold earned across all runs. + * @param parameters.prestigeCount - Number of prestiges performed. + * @param parameters.transcendenceCount - Number of transcendences performed. + * @param parameters.apotheosisCount - Number of apotheoses performed. + * @returns Array of companion IDs the player has unlocked. + */ +const computeUnlockedCompanionIds = (parameters: { + lifetimeBossesDefeated: number; + lifetimeQuestsCompleted: number; + lifetimeGoldEarned: number; + prestigeCount: number; + transcendenceCount: number; + apotheosisCount: number; +}): Array<string> => { + return COMPANIONS.filter((companion) => { + const { type, threshold } = companion.unlock; + switch (type) { + case "lifetimeBosses": + return parameters.lifetimeBossesDefeated >= threshold; + case "lifetimeQuests": + return parameters.lifetimeQuestsCompleted >= threshold; + case "lifetimeGold": + return parameters.lifetimeGoldEarned >= threshold; + case "prestige": + return parameters.prestigeCount >= threshold; + case "transcendence": + return parameters.transcendenceCount >= threshold; + case "apotheosis": + return parameters.apotheosisCount >= threshold; + + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 2 -- @preserve */ + default: + return false; + } + }).map((companion) => { + return companion.id; + }); +}; + +/** + * Returns the bonus of the active companion if it is unlocked, otherwise null. + * Safe to call with undefined/null activeCompanionId. + * @param activeCompanionId - The ID of the currently active companion. + * @param unlockedCompanionIds - Array of companion IDs the player has unlocked. + * @returns The active companion's bonus, or null if none is active or unlocked. + */ +const getActiveCompanionBonus = ( + activeCompanionId: string | null | undefined, + unlockedCompanionIds: Array<string>, +): CompanionBonus | null => { + if (activeCompanionId === null || activeCompanionId === undefined) { + return null; + } + if (!unlockedCompanionIds.includes(activeCompanionId)) { + return null; + } + return ( + COMPANIONS.find((c) => { + return c.id === activeCompanionId; + })?.bonus ?? null + ); +}; + +export type { + Companion, + CompanionBonus, + CompanionBonusType, + CompanionState, + CompanionUnlockCondition, + CompanionUnlockType, +}; +export { COMPANIONS, computeUnlockedCompanionIds, getActiveCompanionBonus }; diff --git a/packages/types/src/interfaces/craftingRecipe.ts b/packages/types/src/interfaces/craftingRecipe.ts new file mode 100644 index 0000000..f674d6a --- /dev/null +++ b/packages/types/src/interfaces/craftingRecipe.ts @@ -0,0 +1,35 @@ +/** + * @file Crafting recipe types for the Elysium game. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +type CraftingBonusType = + | "gold_income" + | "essence_income" + | "click_power" + | "combat_power"; + +interface CraftingMaterialRequirement { + materialId: string; + quantity: number; +} + +interface CraftingRecipe { + id: string; + name: string; + description: string; + zoneId: string; + requiredMaterials: Array<CraftingMaterialRequirement>; + bonus: { + type: CraftingBonusType; + + /** + * Multiplicative bonus value, e.g. 1.1 = +10%. + */ + value: number; + }; +} + +export type { CraftingBonusType, CraftingMaterialRequirement, CraftingRecipe }; diff --git a/packages/types/src/interfaces/dailyChallenge.ts b/packages/types/src/interfaces/dailyChallenge.ts new file mode 100644 index 0000000..0ea947f --- /dev/null +++ b/packages/types/src/interfaces/dailyChallenge.ts @@ -0,0 +1,33 @@ +/** + * @file Daily challenge types for the Elysium game. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +type DailyChallengeType = + | "clicks" + | "bossesDefeated" + | "questsCompleted" + | "prestige"; + +interface DailyChallenge { + id: string; + type: DailyChallengeType; + label: string; + target: number; + progress: number; + completed: boolean; + rewardCrystals: number; +} + +interface DailyChallengeState { + + /** + * ISO date string (e.g. "2026-03-06") used to detect when to reset. + */ + date: string; + challenges: Array<DailyChallenge>; +} + +export type { DailyChallenge, DailyChallengeState, DailyChallengeType }; diff --git a/packages/types/src/interfaces/equipment.ts b/packages/types/src/interfaces/equipment.ts new file mode 100644 index 0000000..ea6ec50 --- /dev/null +++ b/packages/types/src/interfaces/equipment.ts @@ -0,0 +1,59 @@ +/** + * @file Equipment types for the Elysium game. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +type EquipmentType = "weapon" | "armour" | "trinket"; + +type EquipmentRarity = "common" | "rare" | "epic" | "legendary"; + +interface EquipmentBonus { + + /** + * Multiplier applied to all gold/s income (e.g. 1.1 = +10%). + */ + goldMultiplier?: number; + + /** + * Multiplier applied to all combat power (e.g. 1.25 = +25%). + */ + combatMultiplier?: number; + + /** + * Multiplier applied to click power (e.g. 1.5 = +50%). + */ + clickMultiplier?: number; +} + +interface Equipment { + id: string; + name: string; + description: string; + type: EquipmentType; + rarity: EquipmentRarity; + bonus: EquipmentBonus; + + /** + * Whether the player has acquired this item. + */ + owned: boolean; + + /** + * Whether this item is currently equipped (only one per type can be equipped). + */ + equipped: boolean; + + /** + * If set, this item can be purchased directly rather than obtained via boss drops. + */ + cost?: { gold: number; essence: number; crystals: number }; + + /** + * Equipment set this item belongs to, if any. + */ + setId?: string; +} + +export type { Equipment, EquipmentBonus, EquipmentRarity, EquipmentType }; diff --git a/packages/types/src/interfaces/equipmentSet.ts b/packages/types/src/interfaces/equipmentSet.ts new file mode 100644 index 0000000..d6e6c6c --- /dev/null +++ b/packages/types/src/interfaces/equipmentSet.ts @@ -0,0 +1,68 @@ +/** + * @file Equipment set types for the Elysium game. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +interface EquipmentSetBonus { + goldMultiplier?: number; + combatMultiplier?: number; + clickMultiplier?: number; +} + +interface EquipmentSet { + id: string; + name: string; + description: string; + + /** + * Equipment IDs that make up this set. + */ + pieces: Array<string>; + bonuses: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys + 2: EquipmentSetBonus; + // eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys + 3: EquipmentSetBonus; + }; +} + +/** + * Given a list of equipped item IDs and a set catalogue, returns the combined + * multiplicative bonuses granted by all active set bonuses. + * @param equippedItemIds - The IDs of items currently equipped by the player. + * @param sets - The full catalogue of equipment sets to evaluate against. + * @returns The combined gold, combat, and click multipliers from active set bonuses. + */ +const computeSetBonuses = ( + equippedItemIds: Array<string>, + sets: Array<EquipmentSet>, +): { + goldMultiplier: number; + combatMultiplier: number; + clickMultiplier: number; +} => { + let goldMultiplier = 1; + let combatMultiplier = 1; + let clickMultiplier = 1; + + for (const set of sets) { + const count = set.pieces.filter((id) => { + return equippedItemIds.includes(id); + }).length; + for (const threshold of [ 2, 3 ] as const) { + if (count >= threshold) { + const bonus = set.bonuses[threshold]; + goldMultiplier = goldMultiplier * (bonus.goldMultiplier ?? 1); + combatMultiplier = combatMultiplier * (bonus.combatMultiplier ?? 1); + clickMultiplier = clickMultiplier * (bonus.clickMultiplier ?? 1); + } + } + } + + return { clickMultiplier, combatMultiplier, goldMultiplier }; +}; + +export type { EquipmentSet, EquipmentSetBonus }; +export { computeSetBonuses }; diff --git a/packages/types/src/interfaces/exploration.ts b/packages/types/src/interfaces/exploration.ts new file mode 100644 index 0000000..6b35b77 --- /dev/null +++ b/packages/types/src/interfaces/exploration.ts @@ -0,0 +1,123 @@ +/** + * @file Exploration types for the Elysium game. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +type ExplorationEventEffectType = + | "gold_gain" + | "gold_loss" + | "essence_gain" + | "material_gain" + | "adventurer_loss"; + +interface ExplorationEventEffect { + type: ExplorationEventEffectType; + + /** + * Gold amount for gold_gain / gold_loss. + */ + amount?: number; + + /** + * Material ID for material_gain. + */ + materialId?: string; + + /** + * Quantity for material_gain. + */ + quantity?: number; + + /** + * Fraction (0–1) of total adventurers lost for adventurer_loss. + */ + fraction?: number; +} + +interface ExplorationEvent { + id: string; + text: string; + effect: ExplorationEventEffect; +} + +interface ExplorationMaterialDrop { + materialId: string; + minQuantity: number; + maxQuantity: number; + + /** + * Relative probability weight — higher = more likely. + */ + weight: number; +} + +interface ExplorationArea { + id: string; + name: string; + description: string; + zoneId: string; + durationSeconds: number; + possibleMaterials: Array<ExplorationMaterialDrop>; + events: Array<ExplorationEvent>; +} + +interface ExplorationAreaState { + id: string; + status: "locked" | "available" | "in_progress" | "completed"; + + /** + * Unix timestamp when exploration started (set when status becomes in_progress). + */ + startedAt?: number; + + /** + * True after the first successful collect — used for codex unlock detection. + */ + completedOnce?: boolean; +} + +interface ExplorationState { + areas: Array<ExplorationAreaState>; + + /** + * Current material inventory. + */ + materials: Array<{ materialId: string; quantity: number }>; + + /** + * IDs of crafting recipes that have been crafted (resets on prestige). + */ + craftedRecipeIds: Array<string>; + + /** + * Pre-computed gold income multiplier from all crafted recipes. + */ + craftedGoldMultiplier: number; + + /** + * Pre-computed essence income multiplier from all crafted recipes. + */ + craftedEssenceMultiplier: number; + + /** + * Pre-computed click power multiplier from all crafted recipes. + */ + craftedClickMultiplier: number; + + /** + * Pre-computed combat power multiplier from all crafted recipes. + */ + craftedCombatMultiplier: number; +} + +export type { + ExplorationArea, + ExplorationAreaState, + ExplorationEvent, + ExplorationEventEffect, + ExplorationEventEffectType, + ExplorationMaterialDrop, + ExplorationState, +}; diff --git a/packages/types/src/interfaces/gameState.ts b/packages/types/src/interfaces/gameState.ts new file mode 100644 index 0000000..a84fd21 --- /dev/null +++ b/packages/types/src/interfaces/gameState.ts @@ -0,0 +1,98 @@ +/** + * @file GameState type for the Elysium game. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import type { Achievement } from "./achievement.js"; +import type { Adventurer } from "./adventurer.js"; +import type { ApotheosisData } from "./apotheosis.js"; +import type { Boss } from "./boss.js"; +import type { CodexState } from "./codex.js"; +import type { CompanionState } from "./companion.js"; +import type { DailyChallengeState } from "./dailyChallenge.js"; +import type { Equipment } from "./equipment.js"; +import type { ExplorationState } from "./exploration.js"; +import type { Player } from "./player.js"; +import type { PrestigeData } from "./prestige.js"; +import type { Quest } from "./quest.js"; +import type { Resource } from "./resource.js"; +import type { StoryState } from "./story.js"; +import type { TranscendenceData } from "./transcendence.js"; +import type { Upgrade } from "./upgrade.js"; +import type { Zone } from "./zone.js"; + +interface GameState { + player: Player; + resources: Resource; + adventurers: Array<Adventurer>; + upgrades: Array<Upgrade>; + quests: Array<Quest>; + bosses: Array<Boss>; + equipment: Array<Equipment>; + achievements: Array<Achievement>; + prestige: PrestigeData; + zones: Array<Zone>; + + /** + * Click power (gold per click, before upgrades). + */ + baseClickPower: number; + + /** + * Unix timestamp of the last client-side tick. + */ + lastTickAt: number; + + /** + * Daily challenge progress — optional for backwards compatibility with old saves. + */ + dailyChallenges?: DailyChallengeState; + + /** + * Lore codex unlock state — optional for backwards compatibility with old saves. + */ + codex?: CodexState; + + /** + * Transcendence (second prestige layer) state — optional for backwards compatibility. + */ + transcendence?: TranscendenceData; + + /** + * Apotheosis (third prestige layer) state — optional for backwards compatibility. + */ + apotheosis?: ApotheosisData; + + /** + * Exploration and crafting state — optional for backwards compatibility. + */ + exploration?: ExplorationState; + + /** + * When true, the tick engine automatically starts the highest-zone available quest. + */ + autoQuest?: boolean; + + /** + * When true, the tick engine automatically challenges the highest available boss. + */ + autoBoss?: boolean; + + /** + * Companion unlock and active selection state — optional for backwards compatibility. + */ + companions?: CompanionState; + + /** + * Story chapter unlock and completion state — optional for backwards compatibility. + */ + story?: StoryState; + + /** + * Schema version — used to detect saves from older game versions. + */ + schemaVersion?: number; +} + +export type { GameState }; diff --git a/packages/types/src/interfaces/material.ts b/packages/types/src/interfaces/material.ts new file mode 100644 index 0000000..0899dbe --- /dev/null +++ b/packages/types/src/interfaces/material.ts @@ -0,0 +1,18 @@ +/** + * @file Material types for the Elysium game. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +type MaterialRarity = "common" | "uncommon" | "rare"; + +interface Material { + id: string; + name: string; + description: string; + zoneId: string; + rarity: MaterialRarity; +} + +export type { Material, MaterialRarity }; diff --git a/packages/types/src/interfaces/player.ts b/packages/types/src/interfaces/player.ts new file mode 100644 index 0000000..ec46c25 --- /dev/null +++ b/packages/types/src/interfaces/player.ts @@ -0,0 +1,70 @@ +/** + * @file Player types for the Elysium game. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +interface Player { + discordId: string; + username: string; + discriminator: string; + avatar: string | null; + + /** + * Player's chosen in-game character name. + */ + characterName: string; + + /** + * Unix timestamp when the account was created. + */ + createdAt: number; + + /** + * Unix timestamp of the last server-side save. + */ + lastSavedAt: number; + + /** + * Gold earned this run (reset on prestige; used for prestige eligibility). + */ + totalGoldEarned: number; + + /** + * Clicks this run (reset on prestige). + */ + totalClicks: number; + + /** + * Cumulative gold earned across all runs — never reset. + */ + lifetimeGoldEarned: number; + + /** + * Cumulative clicks across all runs — never reset. + */ + lifetimeClicks: number; + + /** + * Cumulative bosses defeated across all runs — never reset. + */ + lifetimeBossesDefeated: number; + + /** + * Cumulative quests completed across all runs — never reset. + */ + lifetimeQuestsCompleted: number; + + /** + * Cumulative adventurers recruited across all runs — never reset. + */ + lifetimeAdventurersRecruited: number; + + /** + * Cumulative achievements unlocked across all runs — never reset. + */ + lifetimeAchievementsUnlocked: number; +} + +export type { Player }; diff --git a/packages/types/src/interfaces/prestige.ts b/packages/types/src/interfaces/prestige.ts new file mode 100644 index 0000000..cab808c --- /dev/null +++ b/packages/types/src/interfaces/prestige.ts @@ -0,0 +1,61 @@ +/** + * @file Prestige types for the Elysium game. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +interface PrestigeData { + + /** + * Number of times the player has prestiged. + */ + count: number; + + /** + * Runestones carried over between prestiges. + */ + runestones: number; + + /** + * Multiplier applied to all production (based on prestige count). + */ + productionMultiplier: number; + + /** + * IDs of prestige upgrades purchased with runestones. + */ + purchasedUpgradeIds: Array<string>; + + /** + * Unix timestamp of last prestige. + */ + lastPrestigedAt?: number; + + /** + * Pre-computed multiplier from "income" runestone upgrades. + */ + runestonesIncomeMultiplier?: number; + + /** + * Pre-computed multiplier from "click" runestone upgrades. + */ + runestonesClickMultiplier?: number; + + /** + * Pre-computed multiplier from "essence" runestone upgrades. + */ + runestonesEssenceMultiplier?: number; + + /** + * Pre-computed multiplier from "crystals" runestone upgrades. + */ + runestonesCrystalMultiplier?: number; + + /** + * Whether the auto-prestige feature is currently enabled (requires auto_prestige upgrade). + */ + autoPrestigeEnabled?: boolean; +} + +export type { PrestigeData }; diff --git a/packages/types/src/interfaces/prestigeUpgrade.ts b/packages/types/src/interfaces/prestigeUpgrade.ts new file mode 100644 index 0000000..bba4dfe --- /dev/null +++ b/packages/types/src/interfaces/prestigeUpgrade.ts @@ -0,0 +1,29 @@ +/** + * @file Prestige upgrade types for the Elysium game. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +type PrestigeUpgradeCategory = + | "income" + | "click" + | "essence" + | "crystals" + | "runestones" + | "utility"; + +interface PrestigeUpgrade { + id: string; + name: string; + description: string; + category: PrestigeUpgradeCategory; + runestonesCost: number; + + /** + * Multiplier applied when this upgrade is purchased. + */ + multiplier: number; +} + +export type { PrestigeUpgrade, PrestigeUpgradeCategory }; diff --git a/packages/types/src/interfaces/profileSettings.ts b/packages/types/src/interfaces/profileSettings.ts new file mode 100644 index 0000000..c294b9b --- /dev/null +++ b/packages/types/src/interfaces/profileSettings.ts @@ -0,0 +1,66 @@ +/** + * @file Profile settings types for the Elysium game. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +type NumberFormat = "suffix" | "scientific" | "engineering"; + +interface ProfileSettings { + + /** + * All Time section. + */ + showTotalGold: boolean; + showTotalClicks: boolean; + showLifetimeBossesDefeated: boolean; + showLifetimeQuestsCompleted: boolean; + showLifetimeAdventurersRecruited: boolean; + showLifetimeAchievementsUnlocked: boolean; + showGuildFounded: boolean; + + /** + * Current Run section. + */ + showCurrentGold: boolean; + showCurrentClicks: boolean; + showPrestige: boolean; + showTranscendence: boolean; + showApotheosis: boolean; + showBossesDefeated: boolean; + showQuestsCompleted: boolean; + showAdventurersRecruited: boolean; + showAchievementsUnlocked: boolean; + numberFormat: NumberFormat; + + /** + * Whether this player appears on the public leaderboards. + */ + showOnLeaderboards: boolean; +} + +// eslint-disable-next-line @typescript-eslint/naming-convention -- intentional constant name +const DEFAULT_PROFILE_SETTINGS: ProfileSettings = { + numberFormat: "suffix", + showAchievementsUnlocked: true, + showAdventurersRecruited: true, + showApotheosis: true, + showBossesDefeated: true, + showCurrentClicks: true, + showCurrentGold: true, + showGuildFounded: true, + showLifetimeAchievementsUnlocked: true, + showLifetimeAdventurersRecruited: true, + showLifetimeBossesDefeated: true, + showLifetimeQuestsCompleted: true, + showOnLeaderboards: true, + showPrestige: true, + showQuestsCompleted: true, + showTotalClicks: true, + showTotalGold: true, + showTranscendence: true, +}; + +export type { NumberFormat, ProfileSettings }; +export { DEFAULT_PROFILE_SETTINGS }; diff --git a/packages/types/src/interfaces/quest.ts b/packages/types/src/interfaces/quest.ts new file mode 100644 index 0000000..27138c2 --- /dev/null +++ b/packages/types/src/interfaces/quest.ts @@ -0,0 +1,66 @@ +/** + * @file Quest types for the Elysium game. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +type QuestStatus = "locked" | "available" | "active" | "completed"; + +type QuestRewardType = + | "gold" + | "essence" + | "crystals" + | "upgrade" + | "adventurer" + | "equipment"; + +interface QuestReward { + type: QuestRewardType; + amount?: number; + + /** + * ID of the upgrade or adventurer to unlock (if applicable). + */ + targetId?: string; +} + +interface Quest { + id: string; + name: string; + description: string; + status: QuestStatus; + + /** + * Unix timestamp when quest was started (if active). + */ + startedAt?: number; + + /** + * Duration in seconds. + */ + durationSeconds: number; + rewards: Array<QuestReward>; + + /** + * IDs of quests that must be completed before this one unlocks. + */ + prerequisiteIds: Array<string>; + + /** + * Zone this quest belongs to. + */ + zoneId: string; + + /** + * Minimum party combat power required to start this quest. + */ + combatPowerRequired?: number; + + /** + * Unix timestamp of the most recent failed attempt (if any). + */ + lastFailedAt?: number; +} + +export type { Quest, QuestReward, QuestRewardType, QuestStatus }; diff --git a/packages/types/src/interfaces/resource.ts b/packages/types/src/interfaces/resource.ts new file mode 100644 index 0000000..2b3af1b --- /dev/null +++ b/packages/types/src/interfaces/resource.ts @@ -0,0 +1,15 @@ +/** + * @file Resource types for the Elysium game. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +interface Resource { + gold: number; + essence: number; + crystals: number; + runestones: number; +} + +export type { Resource }; diff --git a/packages/types/src/interfaces/story.ts b/packages/types/src/interfaces/story.ts new file mode 100644 index 0000000..43ecb71 --- /dev/null +++ b/packages/types/src/interfaces/story.ts @@ -0,0 +1,1031 @@ +/* eslint-disable max-lines -- story data file necessarily exceeds line limit */ +/** + * @file Story chapter types and data for the Elysium game. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import type { Boss } from "./boss.js"; +import type { GameState } from "./gameState.js"; + +interface StoryChoice { + id: string; + label: string; + outcome: string; +} + +interface StoryChapter { + id: string; + title: string; + content: string; + choices: [StoryChoice, StoryChoice, StoryChoice]; + unlock: StoryUnlockCondition; +} + +type StoryUnlockType = + | "bossDefeated" + | "prestige" + | "transcendence" + | "apotheosis"; + +interface StoryUnlockCondition { + type: StoryUnlockType; + bossId?: string; + threshold?: number; +} + +interface CompletedChapter { + chapterId: string; + choiceId: string; +} + +interface StoryState { + unlockedChapterIds: Array<string>; + completedChapters: Array<CompletedChapter>; +} + +/** + * Returns true if the given boss ID has been defeated in the provided boss list. + * @param unlock - The story unlock condition specifying which boss must be defeated. + * @param bosses - The list of bosses to check against. + * @returns True if the specified boss has been defeated. + */ +const isBossDefeated = ( + unlock: StoryUnlockCondition, + bosses: Array<Boss>, +): boolean => { + return bosses.some((b) => { + return b.id === unlock.bossId && b.status === "defeated"; + }); +}; + +/** + * Checks whether a story chapter's unlock condition is met by the current game state. + * @param chapter - The story chapter to evaluate. + * @param state - The current game state to check against. + * @returns True if the chapter's unlock condition has been met. + */ +const isStoryChapterUnlocked = ( + chapter: StoryChapter, + state: GameState, +): boolean => { + const { unlock } = chapter; + const threshold = unlock.threshold ?? 1; + if (unlock.type === "bossDefeated") { + return isBossDefeated(unlock, state.bosses); + } + if (unlock.type === "prestige") { + return state.prestige.count >= threshold; + } + if (unlock.type === "transcendence") { + return (state.transcendence?.count ?? 0) >= threshold; + } + return (state.apotheosis?.count ?? 0) >= threshold; +}; + +// eslint-disable-next-line @typescript-eslint/naming-convention -- intentional constant name +const STORY_CHAPTERS: Array<StoryChapter> = [ + { + choices: [ + { + id: "resolve", + label: "Accept the map with quiet resolve", + outcome: `You folded the map carefully and tucked it away. Resolve was the only` + + ` currency you had in abundance. The cartographer watched you go and thought:` + + ` this one has the look of someone who finishes things.`, + }, + { + id: "people", + label: "Return immediately to your people", + outcome: `Your first thought was of your guild — of wounds to tend and rest` + + ` hard-earned. The cartographer smiled at your back. Some leaders are built for` + + ` glory; some are built for their people. You were becoming the latter.`, + }, + { + id: "plan", + label: "Study it in silence, already planning", + outcome: `Your eyes moved across the map before she'd even finished speaking. The` + + ` forest had only been the first line of a much longer story. You were already` + + ` writing the next.`, + }, + ], + content: `The Verdant Vale was supposed to be simple — a proving ground, nothing more.` + + ` {characterName} hadn't set out to become a legend. The guild had been small then:` + + ` a handful of hungry fighters and one stubborn dream. The forest was ancient and` + + ` watchful, and the Forest Giant that lurked at its heart was older than any map.` + + ` When your party stood over its fallen form at last, the trees went quiet in a way` + + ` that felt almost like respect.\n\nThe air smelled of pine and fresh blood. Somewhere` + + ` above, a crow called out and then fell silent. {characterName} looked back at the` + + ` faces of those who had followed — tired, scraped raw, but alive. Something had` + + ` shifted. The Vale had tested you, and you had not broken.\n\nA stranger approached` + + ` through the tree line: a weathered cartographer who had been mapping these woods for` + + ` years. She pressed a folded chart into {characterName}'s hands — the first detailed` + + ` map of lands further east. "The world is larger than this forest," she said, studying` + + ` you with curious eyes. "I wonder what else you'll find at the end of it."`, + id: "story_ch_01", + title: "Roots and Steel", + unlock: { bossId: "forest_giant", type: "bossDefeated" }, + }, + { + choices: [ + { + id: "listen", + label: "Ask the scholar what she has learned", + outcome: `You stayed long enough to listen. The scholar was cautious with her theories` + + ` but certain of one thing: the people who had built this place had been powerful,` + + ` and their end had come from somewhere far beyond the Vale. You filed that` + + ` knowledge away like a sharp blade.`, + }, + { + id: "claim", + label: "Claim the hall as a guild waystation", + outcome: `The ruins needed purpose more than they needed silence. Your guild cleared` + + ` rubble, shored up walls, and lit fires in hearths that hadn't been warm in an` + + ` age. Whatever had ended the people here, it would not end you.`, + }, + { + id: "press", + label: "Mark it on your chart and press on", + outcome: `There would be time for history later. You marked the ruin on your chart` + + ` with a careful hand and turned your face toward the horizon. The past could` + + ` wait; the future wouldn't.`, + }, + ], + content: `The Shattered Ruins had been a city once — vast and proud, before something` + + ` tore it apart. The stones still carried the memory: carved friezes of robed figures,` + + ` towers that had fallen so long ago that saplings grew through their rubble. And at` + + ` the heart of it all, Vaeltharox — a dragon old enough to have watched the city fall,` + + ` who had made his home in its bones and grown fat on centuries of silence.\n\nHe was` + + ` gone now. The fight had been brutal, and {characterName} would carry the scars of it` + + ` for a long time. But standing in the great collapsed hall at the ruin's center,` + + ` surrounded by ancient frescoes still bright with pigment, there was something almost` + + ` solemn in the stillness.\n\nA scholar emerged from behind a pillar — one of those` + + ` fearless academic types who follows adventuring parties at a safe distance. She was` + + ` breathless and wide-eyed. "Do you know what this place was?" she whispered, gesturing` + + ` at the walls. "The inscriptions — this was a guild hall. Thousands of years ago, there` + + ` were others like you. Something ended them." She paused. "The question is what."`, + id: "story_ch_02", + title: "What the Ruins Remember", + unlock: { bossId: "elder_dragon", type: "bossDefeated" }, + }, + { + choices: [ + { + id: "ask", + label: "Ask what lies deeper in the marshes", + outcome: `He told you what the marsh-folk knew: that the darkness didn't end at the` + + ` Kraken, that there were seams of shadow that ran all the way to the world's edge.` + + ` You thanked him and kept that information close.`, + }, + { + id: "lantern", + label: "Accept the lantern and move on", + outcome: `You took the lantern. Light against darkness — it was a simple philosophy,` + + ` but it had served you well enough so far. The ferryman watched your guild` + + ` disappear into the mist and smiled, alone.`, + }, + { + id: "rest", + label: "Rest with the marsh villages first", + outcome: `Three days of sleeping on dry ground and eating hot food did more for your` + + ` guild than any potion. The marsh-folk gave generously and asked nothing. You left` + + ` them safer than you'd found them.`, + }, + ], + content: `The Shadow Marshes were never quiet. At night the water moved in slow, strange` + + ` currents and lights bobbed at the edge of visibility — not lanterns, but something` + + ` older and stranger. The locals had a name for the deep things that lived in the mire,` + + ` and they spoke it only in whispers. The Mud Kraken was only the largest of them; the` + + ` marshes hid worse things in their silt.\n\n{characterName} had led the guild through` + + ` three weeks of rot and fog before they found the creature's lair, and the battle had` + + ` been fought half-submerged, in darkness, against something that treated the surface` + + ` world as a foreign country it was only briefly visiting. When it finally sank for the` + + ` last time, the marsh went unnervingly still.\n\nA ferryman appeared at dawn, poling a` + + ` flat boat out of the mist. He looked at the still water where the Kraken had thrashed` + + ` and then at {characterName}. "First time something's died out there in a hundred` + + ` years," he said slowly. "The villages will sleep better." He set a small lantern down` + + ` at the prow of his boat — a gift, by the gesture of it. "Where do you go next? There` + + ` are dark places further in, and darker still beyond."`, + id: "story_ch_03", + title: "The Dark Between Stars", + unlock: { bossId: "mud_kraken", type: "bossDefeated" }, + }, + { + choices: [ + { + id: "study", + label: "Take the journal and study it carefully", + outcome: `The journal became essential reading for your strongest strategists. The` + + ` monk had been meticulous; his observations mapped a pattern that wasn't` + + ` comforting. You began preparing for something larger than any single battle.`, + }, + { + id: "promise", + label: "Promise to return with answers", + outcome: `You couldn't take the old man down the mountain, but you could carry his` + + ` question. The promise you made on that peak became something you returned to` + + ` often, in the quiet hours — a compass of its own.`, + }, + { + id: "inquire", + label: "Ask the monk what he believes is causing it", + outcome: `He didn't answer immediately. When he did, the words were careful: 'I think` + + ` something learned that it could come here. And now it knows the way.' You` + + ` descended the mountain knowing that the way in was also the way back.`, + }, + ], + content: `The Void Titan had not been native to the Frozen Peaks. That was the first clue` + + ` that something had changed. The great creature — all sharp angles and wrongness, like` + + ` a shadow given the weight of stone — had descended from somewhere above the cloud` + + ` line, and the mountain itself seemed to recoil from its presence. Where it walked, ice` + + ` formed on the underside of rocks and compasses spun without purpose.\n\n` + + `{characterName} had fought things before that wanted to kill you. The Void Titan was` + + ` the first that seemed to want to unmake you — to pull you apart and scatter the pieces` + + ` across dimensions you had no name for. The battle left the survivors unsettled in ways` + + ` that took days to articulate.\n\nAt the summit, after the Titan's dissolution into` + + ` cold light, you found an old monk who had lived on the peak for decades. He had` + + ` watched the battle from a sheltered cave, wrapped in skins, calm as stone. "The Void` + + ` leaks through," he said, without preamble. "It always has. But it is leaking faster` + + ` now." He handed {characterName} a worn journal — his own observations across thirty` + + ` years. "I am too old to carry this further. You are not."`, + id: "story_ch_04", + title: "A Cold That Burns", + unlock: { bossId: "void_titan", type: "bossDefeated" }, + }, + { + choices: [ + { + id: "feather", + label: "Keep the feather as a reminder", + outcome: `You carried the feather in a sealed case from that day forward — not as a` + + ` trophy, but as a question you hadn't answered yet. What are you protecting? The` + + ` question sharpened you.`, + }, + { + id: "people", + label: "Tell her: you protect your people", + outcome: `'Then don't lose them,' she said simply. It wasn't a warning. It was the` + + ` closest thing to a blessing the volcanic depths had to offer.`, + }, + { + id: "beyond", + label: "Ask what she thinks lies beyond the fire", + outcome: `'Something that cannot burn,' she said, after a long pause. 'Something that` + + ` has never needed to.' You weren't sure if that was reassuring. You carried the` + + ` uncertainty with you like a coal.`, + }, + ], + content: `Fire, as a teacher, is merciless. The Volcanic Depths had claimed members of` + + ` other guilds — those who underestimated the heat, the gas pockets, the stone that` + + ` moved like water. {characterName} had read every account, taken every precaution, and` + + ` still arrived at the Phoenix Lord's chamber scorched and gasping, having lost things` + + ` along the way that couldn't be replaced.\n\nThe Phoenix Lord itself was ancient, and` + + ` it did not fight like something that feared death — it fought like something that` + + ` expected it, that had died so many times that the battle was almost ritual. When it` + + ` finally burned out, it did not fall. It dissolved into cinders that rose with the` + + ` updraft, spiraling upward through the caldera into open sky.\n\nIn the silence that` + + ` followed, {characterName} found a feather that hadn't burned — improbably, impossibly` + + ` intact, vivid red-gold in the ash. At the caldera's edge, a young fire-tender who had` + + ` guided your guild through the vents sat quietly, watching the cinders rise. "The` + + ` Phoenix Lord was a guardian once," she said. "Before the Void. It lost itself` + + ` somewhere along the way and forgot what it was protecting." She looked at` + + ` {characterName} steadily. "I wonder if you'll remember, when it's your turn."`, + id: "story_ch_05", + title: "Ash and Ascension", + unlock: { bossId: "phoenix_lord", type: "bossDefeated" }, + }, + { + choices: [ + { + id: "fight", + label: "Yes — and we fight anyway", + outcome: `The philosopher wrote that down. She published it later, in an obscure` + + ` academic tract that circulated far wider than she'd expected. Small, and yet. And` + + ` yet. And yet.`, + }, + { + id: "further", + label: "Ask what she thinks is further out", + outcome: `She smiled, the way people smile when they've been waiting for the question.` + + ` 'Minds,' she said. 'Ancient, patient, watching. The question is whether they've` + + ` noticed us yet.' You decided to make sure, when they did, that noticing you would` + + ` be a mistake.`, + }, + { + id: "honest", + label: "Admit the silence still echoes in you", + outcome: `She nodded, unsurprised. 'It does that. To everyone who goes there and comes` + + ` back.' She poured two cups of something hot and handed you one. 'The trick is to` + + ` let the sound fill back in. Give it time.'`, + }, + ], + content: `No expedition into the Astral Void came back entirely unchanged. The rules that` + + ` governed the material world — distance, time, the reliability of your own shadow —` + + ` loosened here, and what replaced them was a vast and indifferent silence that pressed` + + ` against the edges of your mind. The stars in the Astral Void were wrong: too close,` + + ` too many, and some of them moved.\n\nThe Devourer of Worlds was not the first thing` + + ` of its kind. {characterName} had found evidence of others, older and smaller, whose` + + ` meal had been stars instead of whole realities. The Devourer was simply the largest,` + + ` and the hungriest, and the most immediately present. The battle that ended it was` + + ` unlike anything that could be described to someone who hadn't stood in the airless` + + ` dark and watched something the size of a continent simply stop.\n\nComing back down` + + ` was disorienting. {characterName} stood in the entry camp for a long time, watching` + + ` the normal sky, feeling the gravity of a world that was still whole. A philosopher` + + ` was waiting — one of a growing number who followed the guild's path. She set down her` + + ` pen and looked at you very seriously. "You understand now, don't you?" she said. "That` + + ` we are small? That the things we've been fighting are small, compared to what exists` + + ` beyond?"`, + id: "story_ch_06", + title: "The Hungry Dark", + unlock: { bossId: "the_devourer", type: "bossDefeated" }, + }, + { + choices: [ + { + id: "memory", + label: "Carry forward the memory of those lost", + outcome: `The names. The faces. The ones who hadn't made it as far as this height. You` + + ` held them as a weight and a compass both, and continued with your eyes open.`, + }, + { + id: "will", + label: "Carry forward the will to finish it", + outcome: `The work was not done. The scale of it had grown, but the work remained:` + + ` take one more step, and then another, and do not stop until the last thing is` + + ` settled. You were not built to leave things undone.`, + }, + { + id: "wonder", + label: "Carry forward wonder, against hardness", + outcome: `It would have been easy, up here, to become something cold and certain. You` + + ` chose differently. The capacity to be astonished — by starlight, by loyalty, by` + + ` the improbable fact of still being alive — you held on to that deliberately.`, + }, + ], + content: `The Celestial Reaches existed at the boundary where the world ended and something` + + ` else began — not void, but luminescence; not emptiness, but a fullness that the mind` + + ` struggled to accommodate. The creatures here were ancient and radiant, and the First` + + ` Light most of all: a being that had been burning since before the age of names, that` + + ` had watched civilisations rise and collapse and rise again like tides.\n\nIt had not` + + ` been hostile, exactly. It had been testing. {characterName} understood that somewhere` + + ` in the middle of the confrontation — that the First Light was not trying to destroy` + + ` your guild, but to see if it could be ended by something that had not existed when it` + + ` first ignited. When it finally went dark, there was no triumph in it. Only a strange,` + + ` ringing silence.\n\nThe light that remained was different. Softer. Permanent-seeming.` + + ` {characterName} stood at the peak of the celestial shelf and looked back down at the` + + ` world — at the green and grey and blue of it, impossibly small and impossibly precious` + + ` from this height. A voice that was not quite a voice said: You are farther now than` + + ` any of your kind have come. This is a threshold. What you carry forward from here,` + + ` carry with intention.`, + id: "story_ch_07", + title: "Above the Storm", + unlock: { bossId: "the_first_light", type: "bossDefeated" }, + }, + { + choices: [ + { + id: "ask", + label: "Ask what he thinks is falling", + outcome: `'Pressure,' he said. 'The kind that builds when too many powers concentrate` + + ` in one place. When too much of the world's weight tips in a single direction.' He` + + ` looked at you with an expression that was half-admiration, half-concern. You noted` + + ` that he did not look away.`, + }, + { + id: "accept", + label: "Accept that some things can't be predicted", + outcome: `Not everything could be prepared for. This was a truth you had learned the` + + ` hard way, and you'd learned it well enough to stop fighting it. You watched the` + + ` surface settle and held the uncertainty like ballast.`, + }, + { + id: "document", + label: "Document everything for whoever comes next", + outcome: `If something woke what slept below, there would be others who needed to` + + ` know. You spent the return voyage writing — a record not of victory, but of` + + ` pattern, for the eyes of whoever followed after.`, + }, + ], + content: `Depth has a texture to it. The Abyssal Trench was not merely dark or cold — it` + + ` was weighted, as if the water above had accumulated all the years of everything that` + + ` had ever fallen into it. Strange bioluminescent creatures drifted past the guild's` + + ` descent vessels, curious and enormous, paying no more attention to the expedition` + + ` than tide pays to a stone.\n\nThe Elder Abomination slept at the very bottom. Or it` + + ` had slept. Something had woken it — some disturbance in the pattern of the world that` + + ` had disturbed even this ancient, unfathomable thing from its millennia of stillness.` + + ` By the time {characterName}'s guild reached it, the creature was fully awake and` + + ` deeply unhappy about it.\n\nReturning to the surface felt like being born.` + + ` {characterName} sat on the deck of the recovery vessel for a long time, listening to` + + ` the sea. The guild's resident naturalist finally came to sit nearby. "It shouldn't` + + ` have been awake," he said quietly. "That thing has been sleeping since before the` + + ` Shattered Ruins were built. Something disturbed it. Something from above." He paused.` + + ` "Something falling down."`, + id: "story_ch_08", + title: "What Sleeps Below", + unlock: { bossId: "elder_abomination", type: "bossDefeated" }, + }, + { + choices: [ + { + id: "learn", + label: "Ask what they were warned about", + outcome: `The spirit answered slowly, in the manner of things that have had too much` + + ` time to think. The warning had been about the Void — about the hunger at the edge` + + ` of everything. They had believed themselves beyond reach. You filed this away as` + + ` a lesson.`, + }, + { + id: "silence", + label: "Acknowledge the warning and leave in silence", + outcome: `Some moments asked for silence. You gave it. The spirit seemed grateful, in` + + ` its way — acknowledged rather than dismissed. You left the court with a weight on` + + ` you that was not unearned.`, + }, + { + id: "vow", + label: "Vow your guild won't make the same mistake", + outcome: `The spirit looked at you for a long time. 'That is what they said too,' it` + + ` finally replied. But it did not say it unkindly. And it watched you all the way` + + ` to the door.`, + }, + ], + content: `The Infernal Court had once been magnificent. You could see it still in the` + + ` architecture — in the carved colonnades now pitted with heat damage, in the great` + + ` vaulted ceilings painted with scenes of dominion that the paint was slowly abandoning.` + + ` Whatever power had ruled here had been absolute, for a long time, before the` + + ` falling.\n\nThe Infernal Sovereign was what remained of that power: immense, bitter,` + + ` and still dangerously capable despite everything it had lost. It fought with the rage` + + ` of something that remembered being greater, and that memory gave its strikes a wild,` + + ` desperate edge that made it more unpredictable than raw power alone would have.` + + ` {characterName} had prepared for strength; what you met was grief armoured in` + + ` fury.\n\nIn the court's throne room — the throne itself melted into a lump of cooled` + + ` metal — a spirit lingered. Not hostile. Only old, and sad, and somehow unable to` + + ` leave. It regarded {characterName} with hollow eyes and said: "We were warned. We` + + ` chose not to listen. Does that happen where you come from?" A pause. "Of course it` + + ` does. It always does. The shape of the mistake is always the same." It pointed at the` + + ` throne. "Power that forgets it is borrowed. That is what we were."`, + id: "story_ch_09", + title: "A Throne of Ashes", + unlock: { bossId: "infernal_sovereign", type: "bossDefeated" }, + }, + { + choices: [ + { + id: "better", + label: "Not as bad as I feared", + outcome: `The crystallographer looked relieved in a way that surprised you — as though` + + ` your answer was the one she'd needed to hear too. The balance of your guild was` + + ` its people, more than its victories. You had not forgotten that. Not yet.`, + }, + { + id: "expected", + label: "Exactly what I expected", + outcome: `'Then you have been paying attention,' she said, quietly approving. 'That is` + + ` rarer than it should be.' Honesty about your own ledger was its own form of` + + ` discipline.`, + }, + { + id: "quiet", + label: "I don't think I'm the one who should say", + outcome: `She nodded slowly. 'The ones who say nothing are usually telling the truth,'` + + ` she said. There was no judgment in it. Only recognition.`, + }, + ], + content: `The Crystalline Spire was remarkable in a particular, unsettling way: everything` + + ` within it was visible. The crystal walls, the crystal floor, the crystal columns —` + + ` all of it perfectly transparent, layered and angled in ways that meant you were` + + ` always visible from multiple directions at once. There was nowhere to hide. The Spire` + + ` rejected concealment.\n\nThe Diamond Colossus had been something like a curator — a` + + ` being that maintained the Spire's integrity and guarded the thing at its heart. What` + + ` was at its heart, {characterName} discovered after the Colossus's defeat: a chamber` + + ` in which everything was perfectly reflected, where the angles of crystal showed you` + + ` not what was around you but what was true about you.\n\n{characterName} stood in the` + + ` chamber alone, for a moment. The reflections were not flattering, exactly, but they` + + ` were honest — showing the cost of every decision alongside the decision, the weight` + + ` alongside the achievement. Then the crystal dimmed and returned to mere transparency.` + + ` A crystallographer pressed her forehead against the outer wall and murmured: "They` + + ` say it shows you your ledger. Credits and debits both. How was the balance?"`, + id: "story_ch_10", + title: "Truth in Glass", + unlock: { bossId: "diamond_colossus", type: "bossDefeated" }, + }, + { + choices: [ + { + id: "sit", + label: "Let the silence sit before leaving", + outcome: `Wisdom, sometimes, is the willingness to remain still in an uncomfortable` + + ` place long enough to understand it. You sat. The silence told you what it could.` + + ` When you left, you took that understanding with you.`, + }, + { + id: "record", + label: "Record the Void Emperor's nature carefully", + outcome: `If the Void had sent its best, it would send something different next time.` + + ` Documentation was not heroism, but it was its own form of readiness. You filled` + + ` pages on the return.`, + }, + { + id: "rally", + label: "Rally the guild — the work isn't done", + outcome: `There was no room for relief yet. The Void had pulled back, but pulling back` + + ` was not retreating. You said this to your guild and they already knew it. That` + + ` was the measure of how far you had all come.`, + }, + ], + content: `The Void Sanctum was not a place that had been built. It was a place that had` + + ` formed — the way a scar forms, or a callus: in response to repeated pressure, over a` + + ` very long time. At its heart, the Void had pressed so hard against the membrane of` + + ` reality that reality had simply organised itself around the pressure, creating a space` + + ` that belonged to neither.\n\nThe Void Emperor was the Void's attempt to give itself a` + + ` face. It wore the shape of something almost humanoid — a crown, a throne, robes of` + + ` pure dark — as if by imitating the structures of power it could claim legitimacy. The` + + ` imitation was close enough to be disturbing. {characterName} had fought rulers` + + ` before. Never one that was made of rulership, that had absorbed every quality of` + + ` dominion without any of the responsibility.\n\nAfter the battle, the Sanctum dimmed.` + + ` The Void withdrew, slightly, behind its veil. {characterName} sat in the emptiness` + + ` and thought: this was the Void's best answer to us. Its best argument for what it` + + ` could become. It had not been good enough. But it had been close.`, + id: "story_ch_11", + title: "The Hollow Crown", + unlock: { bossId: "void_emperor", type: "bossDefeated" }, + }, + { + choices: [ + { + id: "walk", + label: "Walk away from the throne", + outcome: `You turned your back on it and led your guild out. Not every power needs to` + + ` be claimed. Not every throne needs an occupant. The room was quieter when you` + + ` left. You thought it might have been grateful.`, + }, + { + id: "stand", + label: "Stand at its foot and make a decision", + outcome: `You did not sit. But you acknowledged it — the gravity of everything it` + + ` represented, the cost and the weight and the long history. And then you looked` + + ` away from it and toward the door, and that was its own kind of answer.`, + }, + { + id: "declare", + label: "Declare that power is held in trust", + outcome: `The throne hummed louder, then quieter. You weren't sure if that was` + + ` agreement or only vibration. But your guild heard you, and they held onto those` + + ` words for a long time afterward.`, + }, + ], + content: `The Eternal Throne had been contested since before recorded history: a seat of` + + ` power that drew the powerful toward it by a force like gravity, that transformed` + + ` whoever held it into something the throne itself wanted, rather than what the holder` + + ` intended. Every civilisation that had ever reached the Eternal Throne had either bent` + + ` to its will or been broken by it.\n\nThe Apex was the throne's current answer to the` + + ` question of power — an entity that had fully surrendered to the throne's nature, that` + + ` had become its instrument rather than its occupant. The battle for the Throne was` + + ` therefore not a battle for territory, but for the assertion that some things should` + + ` not be occupied, that some seats corrupt by design.\n\n{characterName} stood before` + + ` the throne after the Apex's dissolution. It hummed. Not with malice. With potential.` + + ` With the weight of every person who had ever sat in it and believed themselves equal` + + ` to it. The question was simple and said nothing aloud: And you? What would you do` + + ` with all of this?`, + id: "story_ch_12", + title: "The Weight of Forever", + unlock: { bossId: "the_apex", type: "bossDefeated" }, + }, + { + choices: [ + { + id: "before", + label: "Ask what came before the before", + outcome: `Silence. Then: That is not a question with a shape yet. You decided to` + + ` accept that as an answer and move forward.`, + }, + { + id: "worth", + label: "Affirm that what was built is worth defending", + outcome: `Yes, said the voice. That is why it has lasted. You were not sure what to` + + ` do with a compliment from the primordial chaos, but you received it with the` + + ` sincerity it was offered.`, + }, + { + id: "fixed", + label: "Stand in the chaos and feel your own solidity", + outcome: `Whatever you were — guild leader, fighter, something increasingly harder to` + + ` categorise — you were specific. Named. Decided. In the midst of all this` + + ` undecidedness, you were a fixed point, and that was enough.`, + }, + ], + content: `In the Primordial Chaos, the world had not yet decided what it was. Or rather,` + + ` it remembered not having decided — there was a quality to the place of incompletion,` + + ` of options not yet foreclosed, of reality in draft form. The creatures here were not` + + ` woven from the world's final fabric but from its early sketches, and the Primordial` + + ` Titan was the largest of those sketches: vast, contradictory, capable of being` + + ` several incompatible things at once.\n\nDefeating it was less a battle than a` + + ` negotiation of what was real. {characterName} had led the guild through experiences` + + ` that prepared you for almost anything. This prepared you for almost nothing, and you` + + ` handled it anyway.\n\nStanding in the chaos afterward — which was somehow quieter` + + ` now, though no less chaotic — {characterName} had the strange sensation of being at` + + ` the beginning of something. Not an ending. A voice from somewhere said: You are made` + + ` of what came after. We are what came before. We wondered if the after was stable. A` + + ` pause. It is. You are proof of it.`, + id: "story_ch_13", + title: "Before the Word", + unlock: { bossId: "primordial_titan", type: "bossDefeated" }, + }, + { + choices: [ + { + id: "stay", + label: "Sit with your scout until the feeling passed", + outcome: `You stayed. There was no trick to it, no words that helped more than the` + + ` simple fact of not being alone. The scout looked at you later with a complicated` + + ` expression that was mostly gratitude.`, + }, + { + id: "small", + label: "Acknowledge the scale — and your smallness", + outcome: `Big was not the same as better. The Expanse was infinite. Your guild was` + + ` finite. And yet something in you had the audacity to persist in finite space and` + + ` say: we are still here. You could live with that audacity.`, + }, + { + id: "plan", + label: "Begin immediately planning the next move", + outcome: `Movement was your steadiest anchor. Your scout caught you making notes and` + + ` shook their head, half exasperated and half relieved to see you so thoroughly` + + ` yourself. You both knew it meant you were going to be all right.`, + }, + ], + content: `The Infinite Expanse lived up to its name in the most literal possible way.` + + ` {characterName} had fought in many places: claustrophobic dungeon corridors, open` + + ` battlefield, the vertigo of the Astral Void. Nothing had prepared you for a space` + + ` that simply continued, that had no visible boundary in any direction, that defeated` + + ` navigation not by obscuring the path but by making every direction equivalent.\n\nThe` + + ` Expanse Sovereign had not been hostile in the conventional sense. It was vast and the` + + ` guild was small and it handled the discrepancy the way a person handles a splinter:` + + ` with focused, dispassionate attention, before returning to larger concerns. That it` + + ` was defeated at all was a thing {characterName} still wasn't entirely certain was` + + ` real.\n\nComing back to anything finite felt like arriving home after a very long` + + ` journey. {characterName} sat in the expedition camp and found it tremendously,` + + ` overwhelmingly comforting. One of your guild's scouts — who had a reputation for` + + ` being unflappable — was quietly crying with relief in the corner. You said nothing.` + + ` Some things did not require commentary, only presence.`, + id: "story_ch_14", + title: "The Scale of Things", + unlock: { bossId: "expanse_sovereign", type: "bossDefeated" }, + }, + { + choices: [ + { + id: "intact", + label: "Accept the invitation; leave the Forge intact", + outcome: `The Forge continued its quiet work. You left it as you found it, not because` + + ` you lacked the power to change it, but because some things had been put in place` + + ` by wiser hands than yours, and wisdom lay in knowing the difference.`, + }, + { + id: "add", + label: "Add a small note to the blueprints", + outcome: `Your addition was modest — almost invisible. A small notation in the margin` + + ` of the principle of memory: and what is remembered by those who choose to` + + ` remember. Whether it had any effect, you never knew. You left it there anyway.`, + }, + { + id: "write", + label: "Write down what you observed, for others", + outcome: `Documentation felt inadequate for what the Forge was. You did it anyway. The` + + ` notes would be strange, but they would be accurate, and accuracy was the only` + + ` thing the Forge itself seemed to care about.`, + }, + ], + content: `The Reality Forge was where the laws of the world had been written, eons ago, by` + + ` something that was no longer present — only the Forge itself remained, and the` + + ` Architect who had appointed themselves its guardian. The Forge did not make things so` + + ` much as it made the conditions for things: the rules of physics, the logic of cause` + + ` and effect, the consistency that allowed anything to be relied upon at` + + ` all.\n\nThe Reality Architect had not wanted to fight. It had wanted {characterName}` + + ` to understand what would happen if the Forge was disrupted, if the rules it` + + ` maintained were allowed to slip. The confrontation had been as much argument as` + + ` battle — and {characterName} had had to win both.\n\nAfterward, in the Forge's warm,` + + ` humming workshop, you found what looked like blueprints — not for things, but for` + + ` principles. The principle of consequence. The principle of memory. The principle of` + + ` growth. Beside the blueprints, in handwriting that was startlingly mundane, was a` + + ` single note: These are not laws. They are invitations. What you do with them is` + + ` yours.`, + id: "story_ch_15", + title: "The Maker's Bones", + unlock: { bossId: "reality_architect", type: "bossDefeated" }, + }, + { + choices: [ + { + id: "comfort", + label: "Find it comforting — the universe persists", + outcome: `The permanence of the stars was a kind of promise. What existed before you` + + ` would exist after you, and what you did in the time between was not erased by` + + ` scale. You held onto this.`, + }, + { + id: "grief", + label: "Find it terrible — your losses are not small", + outcome: `Your guild had bled for this. The grief of it was real and specific and` + + ` theirs, and the indifference of the cosmos did not diminish it. You turned away` + + ` from the stars and toward your people.`, + }, + { + id: "present", + label: "Find it neither — just be present", + outcome: `Sometimes a moment did not need interpretation. You stood in it. It was what` + + ` it was. The stars were what they were. That was enough, for now.`, + }, + ], + content: `The Cosmic Maelstrom was not catastrophe in progress — it was the shape that` + + ` catastrophe left behind. A wound in the fabric of space-time, still bleeding along` + + ` its edges, where something had pulled too hard and torn. The things that lived in the` + + ` Maelstrom had adapted to destruction as an environment: they had evolved within the` + + ` wound, made their ecology from annihilation.\n\nThe Cosmic Annihilator was their` + + ` apex: a creature that not only survived destruction but was destruction, in the way` + + ` that a predator is starvation given motion. It could not be reasoned with. It could` + + ` only be answered. {characterName}'s guild answered it, at cost, and stood in the` + + ` Maelstrom's strange, violent quiet when it was done.\n\nStars were visible through` + + ` the tear — real stars, on the other side of the wound. They seemed very small from` + + ` here. {characterName} looked at them for a long time and thought: if something tears` + + ` the world apart, the stars outside it will keep burning. That was either comforting` + + ` or terrible, and you had not yet decided which.`, + id: "story_ch_16", + title: "When Stars Scream", + unlock: { bossId: "cosmic_annihilator", type: "bossDefeated" }, + }, + { + choices: [ + { + id: "weight", + label: "Carry the weight of all that came before", + outcome: `The generations that had built the world — the forgotten, the unnamed, the` + + ` ones whose courage made your existence possible — you acknowledged them. You were` + + ` not the beginning. You were what they had been working toward. That felt like` + + ` enough.`, + }, + { + id: "chosen", + label: "Carry only what you chose", + outcome: `You could not carry everything. The weight would have stopped you where you` + + ` stood. You chose carefully — the things that were yours, the things that mattered,` + + ` the things that would survive the carrying.`, + }, + { + id: "waste", + label: "Carry the intention not to waste this", + outcome: `You had arrived somewhere very few had. What you did next would define what` + + ` arriving here meant. You did not intend to waste it.`, + }, + ], + content: `The Primeval Sanctum predated every text, every tradition, every story that had` + + ` tried to explain where things came from. The gods worshipped in the world's temples` + + ` were, most of them, echoes of things that had originated here — derivative, sincere` + + ` copies of an original that no living theology had ever accurately` + + ` described.\n\nThe Primeval God was not what {characterName} had expected, which,` + + ` given the nature of the place, should have been expected. It was older than` + + ` expectation. Older than the structure of surprise. It fought with the economy of` + + ` something that had done this before, across spans of time so vast they had lost` + + ` meaning, and yet it was not bored — there was a quality to its attention that` + + ` suggested this battle, specifically, mattered to it.\n\nWhen it ended, the Sanctum` + + ` settled into a peace that felt earned. A presence that had not taken part in the` + + ` fight spoke in a register that bypassed language entirely and arrived as` + + ` understanding: You have reached what was first. Few do. What you carry from here is` + + ` yours, made of everything that came before you and everything you chose to do with` + + ` it.`, + id: "story_ch_17", + title: "The First Name", + unlock: { bossId: "primeval_god", type: "bossDefeated" }, + }, + { + choices: [ + { + id: "yes", + label: "Yes — without hesitation", + outcome: `There was nothing complicated in it. The weight, the cost, the long road —` + + ` you would have done it again. Would do it again. The certainty was quiet and` + + ` complete, and that was the most honest thing you had ever known.`, + }, + { + id: "cost", + label: "Yes — though the cost was real", + outcome: `The acknowledgement of loss did not diminish the worth of it. Things had` + + ` been spent that could not be recovered. That was true. And the answer was still` + + ` yes. Holding both of those things at once was the truest thing you had ever` + + ` managed.`, + }, + { + id: "becoming", + label: "I am still becoming the answer", + outcome: `The journey had not ended. The Absolute was a chapter, not a conclusion. You` + + ` were still writing the rest of it. That was neither modesty nor avoidance — it` + + ` was honesty. You left the silence of the Absolute and walked forward, because` + + ` walking forward was what you did.`, + }, + ], + content: `There was no dramatic approach to the Absolute. No great architecture, no` + + ` fanfare, no threshold that announced itself as the final one. The path simply led` + + ` here, and here was where it ended, and the Absolute One waited in the way that` + + ` fundamental truths wait: patiently, completely, indifferent to whether you were` + + ` ready.\n\n{characterName} had been preparing for this, in some sense, since the Vale.` + + ` Every battle, every choice, every loss and triumph had been a step toward this point.` + + ` Standing at the end of it, that knowledge did not make the confrontation smaller — it` + + ` made it more coherent. The guild understood what they were fighting for. They had not` + + ` forgotten.\n\nThe Absolute One was defeated. Or resolved. Or answered. The word for` + + ` what happened was not quite any of those. What remained was {characterName}, in the` + + ` silence of the absolute, and the realisation that the journey had changed you so` + + ` thoroughly that the person who had begun it would not entirely recognise who you were` + + ` now. The question was: was what you'd become worth what you'd spent to become it? You` + + ` stood in the silence. You knew the answer. You always had.`, + id: "story_ch_18", + title: "Beyond the Last Door", + unlock: { bossId: "the_absolute_one", type: "bossDefeated" }, + }, + { + choices: [ + { + id: "know", + label: "Tell the guild: we know the way", + outcome: `The veterans who had made this choice with you nodded. The newer members` + + ` looked uncertain. You had both in your guild, and that was the point — the` + + ` knowledge passed forward, the lessons given to those who hadn't yet paid for` + + ` them. That was the real economy of prestige.`, + }, + { + id: "work", + label: "Begin immediately, without ceremony", + outcome: `There was a kind of respect in not making a production of it. The work was` + + ` what mattered. The ceremony could wait for a summit that didn't keep moving. You` + + ` set to work, and your guild followed, and that was the whole of the ritual.`, + }, + { + id: "rest", + label: "Take a single day to rest before restarting", + outcome: `One day. You had earned it, and so had they. The guild rested, and healed,` + + ` and ate without rushing, and said things to each other that the urgency of the` + + ` climb hadn't left room for. On the second morning you began again, and you began` + + ` stronger.`, + }, + ], + content: `The scholars who had followed the guild's ascent called it various things:` + + ` renewal, iteration, the loop of power. The word that the guild's oldest member used` + + ` was simpler — beginning again. Which was exactly what it was. {characterName} had` + + ` brought the guild from its first uncertain steps to a height most would consider the` + + ` summit, and then made the choice to dissolve that height and start over — not because` + + ` the achievement was worthless, but because the shape of growth required` + + ` it.\n\nPrestige was not defeat and it was not retreat. It was the choice to let the` + + ` accumulated work settle into something structural — to let every lesson, every` + + ` hard-won understanding, become the new foundation rather than the old ceiling. The` + + ` guild had been rebuilt on knowledge rather than innocence, and knowledge was a far` + + ` sturdier material.\n\nThe first morning after the prestige, {characterName} stood in` + + ` the guild hall with its reduced numbers and its scant resources and felt something` + + ` unexpected: not disappointment, but anticipation. The road was familiar now. You knew` + + ` where the dangers hid and where the opportunities were. The second walk was going to` + + ` be faster, and harder, and better. You knew this with a certainty that only` + + ` experience could manufacture.`, + id: "story_ch_19", + title: "The Cycle Begins", + unlock: { threshold: 1, type: "prestige" }, + }, + { + choices: [ + { + id: "speak", + label: "Speak to the guild about why you keep going", + outcome: `You hadn't planned to say anything, and what you said wasn't polished. But` + + ` it was honest, and your guild heard it that way, and the room got quieter in the` + + ` good way — the way of people deciding to believe in something together.`, + }, + { + id: "listen", + label: "Let the gathering speak for itself", + outcome: `Sometimes leadership was knowing when not to speak. The guild had found its` + + ` own reason to celebrate, its own meaning in the repetition. You listened and were` + + ` grateful.`, + }, + { + id: "store", + label: "Commit the moment to memory, for hard times", + outcome: `There would be difficult nights later. There always were. You stored this one` + + ` carefully — the warmth of it, the sound of laughter, the proof that your people` + + ` were still whole — so that you could return to it when the cold came in.`, + }, + ], + content: `By the fifth time, the road had a texture to it. {characterName} knew its` + + ` rhythms — knew where the ground softened before the climb, knew which obstacles` + + ` yielded to patience and which to force, knew which members of the guild would` + + ` struggle at which stages and what they would need. The fifth prestige was not easier` + + ` than the first, but it was more legible, and legibility was its own form of` + + ` ease.\n\nThere was a danger here, too, that {characterName} had begun to notice: the` + + ` danger of mastery becoming habit. The road could be walked competently in a kind of` + + ` fog, each step correct but automatic. The guild members who had made this journey` + + ` fewer times still found it new. They were still surprised. You had to choose,` + + ` consciously, to let their surprise be contagious — to see what they saw, rather than` + + ` what you had already catalogued.\n\nOn the night before the fifth return, the guild` + + ` held a gathering that no one had organised and no one had asked for — it had simply` + + ` happened, the way meaningful things sometimes do. Stories traded, laughter at shared` + + ` memory, a kind of warmth that had nothing to do with strategy or achievement.` + + ` {characterName} sat in the middle of it and thought: this is what the power has` + + ` always been for.`, + id: "story_ch_20", + title: "A Familiar Road", + unlock: { threshold: 5, type: "prestige" }, + }, + { + choices: [ + { + id: "begin", + label: "Accept the strangeness and begin", + outcome: `The unfamiliarity was not your enemy. It was proof that you were somewhere` + + ` genuinely new. You held that discomfort lightly and took the first step.`, + }, + { + id: "grieve", + label: "Sit with what was released before moving on", + outcome: `Loss and choice were not incompatible. You had chosen to release, and what` + + ` you had released had been real and worth having. Acknowledging that before` + + ` turning forward was not weakness. It was honesty.`, + }, + { + id: "pattern", + label: "Find the shape of the new pattern immediately", + outcome: `Your mind moved the way it always had, already mapping the new terrain. The` + + ` guild watched you and felt steadier for it. Pattern-finding was its own form of` + + ` courage — the refusal to be lost.`, + }, + ], + content: `Transcendence was the end of a particular kind of certainty. Everything` + + ` {characterName} had built — the guild in its current form, the resources accumulated,` + + ` the structures of power carefully constructed through prestige after prestige — was` + + ` released. Not destroyed. Released, the way water is released when a dam is removed,` + + ` allowed to flow where it had always wanted to flow.\n\nWhat remained afterward was` + + ` something harder to name. Not less, though it was certainly different. The echoes of` + + ` what had been done crystallised into something structural, something that would shape` + + ` what came next in ways that the doing itself hadn't. Transcendence was the distance` + + ` between experience and wisdom, and {characterName} now understood why so few managed` + + ` it: it required the genuine willingness to let go of what you had` + + ` made.\n\nStanding in the new beginning, which was stranger than any previous new` + + ` beginning because there was so much more to rebuild, {characterName} felt the shape` + + ` of a larger pattern — one that required more of you than any single achievement. This` + + ` was not the end of the story. This was the moment the story understood its own` + + ` scale.`, + id: "story_ch_21", + title: "The Shedding", + unlock: { threshold: 1, type: "transcendence" }, + }, + { + choices: [ + { + id: "given", + label: "Acknowledge what was given as much as earned", + outcome: `You had not walked this road alone. Every person who had followed you, every` + + ` ally who had helped, every predecessor whose failures had mapped the path — their` + + ` contribution was woven into what you were now. You remembered them, and it` + + ` mattered.`, + }, + { + id: "forward", + label: "Look forward to what this makes possible", + outcome: `The horizon had not disappeared. It had moved — further, broader, stranger.` + + ` What you were now could do things that what you had been could only approach. You` + + ` looked at the new horizon and felt something you had almost forgotten: excitement.`, + }, + { + id: "be", + label: "Simply be what you have become, for now", + outcome: `Not every threshold needed to be rushed past. You were here. You were this.` + + ` You let the weight of that settle before you took the next step. Presence was its` + + ` own kind of power.`, + }, + ], + content: `There was no adequate language for what {characterName} had become. The old` + + ` categories — leader, fighter, guild master — had been accurate once, and were now` + + ` technically still correct, the way a childhood home is technically still the same` + + ` structure as the one you remember, even though the scale of everything has` + + ` shifted.\n\nApotheosis was not the end of growth. It was the moment growth changed` + + ` kind. Everything prior had been accumulation: skill, power, understanding,` + + ` experience. What lay on the other side of this threshold was something that could not` + + ` be accumulated, only inhabited. A relationship with existence that was less about` + + ` acquiring and more about being — about what it meant to occupy a place in the world` + + ` when that place had become genuinely extraordinary.\n\n{characterName} looked at the` + + ` guild — at every person who had walked some or all of this road — and felt something` + + ` that did not have a simple name. Gratitude was part of it. Pride was part of it. The` + + ` weight of responsibility was part of it. Most of all it was the recognition that you` + + ` had arrived somewhere none of you had initially imagined, together, and that together` + + ` was the only word that did the thing justice.`, + id: "story_ch_22", + title: "Becoming", + unlock: { threshold: 1, type: "apotheosis" }, + }, +]; + +export type { + CompletedChapter, + StoryChapter, + StoryChoice, + StoryState, + StoryUnlockCondition, + StoryUnlockType, +}; +export { STORY_CHAPTERS, isStoryChapterUnlocked }; diff --git a/packages/types/src/interfaces/title.ts b/packages/types/src/interfaces/title.ts new file mode 100644 index 0000000..aaf256e --- /dev/null +++ b/packages/types/src/interfaces/title.ts @@ -0,0 +1,41 @@ +/** + * @file Title types for the Elysium game. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +type TitleConditionType = + | "totalClicks" + | "totalGoldEarned" + | "bossesDefeated" + | "questsCompleted" + | "prestigeCount" + | "transcendenceCount" + | "apotheosisCount" + | "adventurerTotal" + | "achievementsUnlocked" + | "guildFounded" + | "playedDays"; + +interface TitleCondition { + type: TitleConditionType; + + /** + * Threshold required to unlock (not used for guildFounded). + */ + amount?: number; +} + +interface Title { + id: string; + name: string; + + /** + * Human-readable description shown as the unlock hint. + */ + description: string; + condition: TitleCondition; +} + +export type { Title, TitleCondition, TitleConditionType }; diff --git a/packages/types/src/interfaces/transcendence.ts b/packages/types/src/interfaces/transcendence.ts new file mode 100644 index 0000000..39e0a0b --- /dev/null +++ b/packages/types/src/interfaces/transcendence.ts @@ -0,0 +1,79 @@ +/** + * @file Transcendence types for the Elysium game. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +type TranscendenceUpgradeCategory = + | "income" + | "combat" + | "prestige_threshold" + | "prestige_runestones" + | "echo_meta"; + +interface TranscendenceUpgrade { + id: string; + name: string; + description: string; + category: TranscendenceUpgradeCategory; + + /** + * Echo cost to purchase. + */ + cost: number; + + /** + * Multiplicative effect of this upgrade. + */ + multiplier: number; +} + +interface TranscendenceData { + + /** + * Number of times the player has transcended. + */ + count: number; + + /** + * Echoes accumulated across all transcendences. + */ + echoes: number; + + /** + * IDs of echo upgrades purchased with echoes. + */ + purchasedUpgradeIds: Array<string>; + + /** + * Pre-computed: multiplier applied to all passive gold income. + */ + echoIncomeMultiplier: number; + + /** + * Pre-computed: multiplier applied to party DPS in boss fights. + */ + echoCombatMultiplier: number; + + /** + * Pre-computed: multiplier applied to the prestige gold threshold (< 1 lowers requirement). + */ + echoPrestigeThresholdMultiplier: number; + + /** + * Pre-computed: multiplier applied to runestones earned per prestige. + */ + echoPrestigeRunestoneMultiplier: number; + + /** + * Pre-computed: multiplier applied to echo yield on future transcendences. + */ + echoMetaMultiplier: number; +} + +export type { + TranscendenceData, + TranscendenceUpgrade, + TranscendenceUpgradeCategory, +}; diff --git a/packages/types/src/interfaces/upgrade.ts b/packages/types/src/interfaces/upgrade.ts new file mode 100644 index 0000000..fbf1d5d --- /dev/null +++ b/packages/types/src/interfaces/upgrade.ts @@ -0,0 +1,37 @@ +/** + * @file Upgrade types for the Elysium game. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +type UpgradeTarget = + | "click" + | "adventurer" + | "global" + | "prestige" + | "boss"; + +interface Upgrade { + id: string; + name: string; + description: string; + target: UpgradeTarget; + + /** + * ID of the adventurer this applies to (if target is "adventurer"). + */ + adventurerId?: string; + + /** + * Multiplier applied to the target's output. + */ + multiplier: number; + costGold: number; + costEssence: number; + costCrystals: number; + purchased: boolean; + unlocked: boolean; +} + +export type { Upgrade, UpgradeTarget }; diff --git a/packages/types/src/interfaces/zone.ts b/packages/types/src/interfaces/zone.ts new file mode 100644 index 0000000..21839fc --- /dev/null +++ b/packages/types/src/interfaces/zone.ts @@ -0,0 +1,28 @@ +/** + * @file Zone types for the Elysium game. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +type ZoneStatus = "locked" | "unlocked"; + +interface Zone { + id: string; + name: string; + description: string; + emoji: string; + status: ZoneStatus; + + /** + * Boss ID whose defeat is required to unlock this zone (null for the starter zone). + */ + unlockBossId: string | null; + + /** + * Quest ID that must be completed to unlock this zone (null for the starter zone). + */ + unlockQuestId: string | null; +} + +export type { Zone, ZoneStatus }; diff --git a/packages/types/test/companions.spec.ts b/packages/types/test/companions.spec.ts index e823868..1c0e257 100644 --- a/packages/types/test/companions.spec.ts +++ b/packages/types/test/companions.spec.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import { computeUnlockedCompanionIds, getActiveCompanionBonus, -} from "../src/interfaces/Companion.js"; +} from "../src/interfaces/companion.js"; const baseParams = { lifetimeBossesDefeated: 0, diff --git a/packages/types/test/equipmentSet.spec.ts b/packages/types/test/equipmentSet.spec.ts index 91759d0..e5926aa 100644 --- a/packages/types/test/equipmentSet.spec.ts +++ b/packages/types/test/equipmentSet.spec.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ import { describe, expect, it } from "vitest"; -import { computeSetBonuses } from "../src/interfaces/EquipmentSet.js"; -import type { EquipmentSet } from "../src/interfaces/EquipmentSet.js"; +import { computeSetBonuses } from "../src/interfaces/equipmentSet.js"; +import type { EquipmentSet } from "../src/interfaces/equipmentSet.js"; const makeSet = (partial: Partial<EquipmentSet> & Pick<EquipmentSet, "pieces">): EquipmentSet => ({ id: "test_set", diff --git a/packages/types/test/profileSettings.spec.ts b/packages/types/test/profileSettings.spec.ts index 1e23ad0..3b4448d 100644 --- a/packages/types/test/profileSettings.spec.ts +++ b/packages/types/test/profileSettings.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_PROFILE_SETTINGS } from "../src/interfaces/ProfileSettings.js"; +import { DEFAULT_PROFILE_SETTINGS } from "../src/interfaces/profileSettings.js"; describe("DEFAULT_PROFILE_SETTINGS", () => { it("has all visibility flags set to true by default", () => { diff --git a/packages/types/test/story.spec.ts b/packages/types/test/story.spec.ts index 94b63d0..1217cb3 100644 --- a/packages/types/test/story.spec.ts +++ b/packages/types/test/story.spec.ts @@ -1,8 +1,8 @@ /* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ import { describe, expect, it } from "vitest"; -import { isStoryChapterUnlocked } from "../src/interfaces/Story.js"; -import type { StoryChapter } from "../src/interfaces/Story.js"; -import type { GameState } from "../src/interfaces/GameState.js"; +import { isStoryChapterUnlocked } from "../src/interfaces/story.js"; +import type { StoryChapter } from "../src/interfaces/story.js"; +import type { GameState } from "../src/interfaces/gameState.js"; const makeMinimalState = (overrides: Partial<GameState> = {}): GameState => ({ diff --git a/packages/types/vitest.config.ts b/packages/types/vitest.config.ts index 2aa59c5..daf8318 100644 --- a/packages/types/vitest.config.ts +++ b/packages/types/vitest.config.ts @@ -5,10 +5,10 @@ export default defineConfig({ coverage: { provider: "v8", include: [ - "src/interfaces/Companion.ts", - "src/interfaces/EquipmentSet.ts", - "src/interfaces/ProfileSettings.ts", - "src/interfaces/Story.ts", + "src/interfaces/companion.ts", + "src/interfaces/equipmentSet.ts", + "src/interfaces/profileSettings.ts", + "src/interfaces/story.ts", ], exclude: [], thresholds: { diff --git a/verify.md b/verify.md new file mode 100644 index 0000000..85ca03c --- /dev/null +++ b/verify.md @@ -0,0 +1,200 @@ +# Manual Verification Checklist + +Things our unit tests cannot verify — these need manual testing in a real browser with a live server. + +--- + +## Auth & Login + +- [ ] Discord OAuth login flow works end-to-end (redirect → callback → JWT → game loads) +- [ ] Logging out and back in restores the correct game state +- [ ] A brand-new player (no existing save) gets a fresh game state with no errors +- [ ] A player with an outdated save (no `schemaVersion`) sees the `OutdatedSchemaModal` on login + - [ ] "Reset Progress" button nukes the save and starts fresh + - [ ] "Proceed with Bugs" dismisses the modal for the session but reappears on next login + - [ ] Cloud save attempts while on an outdated save are rejected (save button should fail gracefully) +- [ ] A player whose save was nuked server-side (v1 release reset) gets a fresh state, not a 404 + +--- + +## Cloud Save + +- [ ] Auto-save triggers every 60 seconds while the game is open +- [ ] Manual save (if applicable) works correctly +- [ ] Saving and reloading in a new tab preserves all progress +- [ ] Save is rejected with a helpful error if the schema version is outdated (HTTP 409) + +--- + +## Offline Progress + +- [ ] Closing the tab and reopening after 30+ minutes shows the offline earnings modal +- [ ] Offline gold is calculated correctly (passive income × elapsed time, capped appropriately) +- [ ] Offline essence is calculated correctly +- [ ] **Resetting progress clears offline earnings** — after a reset, no offline modal should appear + +--- + +## Daily Login Bonus + +- [ ] First login of a new day shows the login bonus modal with correct reward +- [ ] Consecutive days increment the streak counter +- [ ] Missing a day resets the streak to 1 +- [ ] Day 7 reward (crystals) is awarded correctly +- [ ] Week multiplier applies for streaks beyond 7 days +- [ ] Streak is displayed correctly on the Character Sheet + +--- + +## Discord Integration + +- [ ] **Webhook on prestige**: posting to the Discord webhook channel with correct message and player mention +- [ ] **Webhook on transcendence**: same as above +- [ ] **Webhook on apotheosis**: webhook fires AND Discord role is granted to the player +- [ ] Discord role grant requires the bot to have `Manage Roles` permission and the bot role to be above the target role +- [ ] If Discord env vars are missing (dev environment), these silently no-op without crashing + +--- + +## Prestige / Transcendence / Apotheosis + +- [ ] Prestige resets gold and adventurers but preserves story/titles/companions/achievements +- [ ] Prestige increments the prestige count correctly +- [ ] Transcendence resets prestige progress but preserves correct fields +- [ ] Apotheosis resets transcendence progress but preserves correct fields +- [ ] Story chapters unlock at correct milestone points (1st prestige, 5th prestige, 1st transcendence, 1st apotheosis) + +--- + +## Boss Fights + +- [ ] Boss fight UI opens correctly and shows HP bar +- [ ] Win/loss outcomes are applied correctly +- [ ] Boss rewards (gold, equipment) are granted on win +- [ ] Zone unlock happens after final zone boss is defeated **and** final zone quest is completed +- [ ] `bossesDefeated` daily challenge counter increments in the UI after a win +- [ ] Auto-boss toggle fires when a boss is available and the player is actively on the page + +--- + +## Quests + +- [ ] Quest starts and the timer counts down in real time +- [ ] Quest completes and rewards are granted +- [ ] Quest failure (chance-based) resets the quest to available with ⚠️ indicator +- [ ] `questsCompleted` daily challenge counter increments in the UI +- [ ] Auto-quest toggle fires when a quest becomes available +- [ ] Active companion quest-time reduction (Wren, Zuri) visibly shortens the timer + +--- + +## Exploration + +- [ ] Starting an exploration works and shows in-progress state +- [ ] Cannot start a second exploration while one is in progress (all Explore buttons disabled) +- [ ] Exploration completes and rewards/codex entries are granted +- [ ] "Found nothing" outcome is handled gracefully (no crash, no reward) +- [ ] `adventurer_loss` event properly removes adventurers + +--- + +## Companions + +- [ ] Companions unlock when the correct threshold is met (bosses defeated, quests completed, prestige, etc.) +- [ ] Setting an active companion applies the bonus correctly: + - [ ] Passive gold bonus (Lyra, Mira, Pria) — check gold income rate + - [ ] Click gold bonus (Finn) — check click power + - [ ] Boss damage bonus (Aldric, Kael) — check boss fight outcomes + - [ ] Quest time reduction (Wren, Zuri) — check quest timer + - [ ] Essence income bonus (Sera, Vex) — check essence accumulation +- [ ] Changing active companion takes effect immediately + +--- + +## Story System + +- [ ] First zone boss defeat unlocks Chapter 1 and shows a toast notification +- [ ] Story tab shows unread dot badge when new chapters are available +- [ ] Reading a chapter and making a choice saves the selection +- [ ] Choice outcome is displayed after selection +- [ ] Completed choices are visible on the Character Sheet +- [ ] Story progress is preserved through prestige, transcendence, and apotheosis +- [ ] `{characterName}` is substituted correctly with the player's character name + +--- + +## Titles + +- [ ] Titles unlock when the correct milestones are reached +- [ ] Active title is selectable in the Character Sheet edit mode +- [ ] Active title displays on the public `/character/:id` page +- [ ] Titles are preserved through prestige, transcendence, and apotheosis + +--- + +## Leaderboards + +- [ ] Leaderboard page loads and displays real player data +- [ ] All 7 categories (gold, bosses, quests, achievements, prestige, transcendence, apotheosis) work +- [ ] Top 3 rows have medal highlighting +- [ ] Clicking a player's row navigates to their `/character/:id` page +- [ ] `showOnLeaderboards: false` hides the player from all categories +- [ ] Default is opted-in (new players appear on leaderboards) + +--- + +## Character Sheet & Public Profile + +- [ ] Character Sheet tab loads and displays correctly +- [ ] Edit mode saves all fields (name, pronouns, race, class, bio, guild name, guild description, active title) +- [ ] Share button copies the correct `/character/:id` URL and shows ✓ Copied! +- [ ] Public `/character/:id` page renders for another player's ID +- [ ] Equipped items (weapon, armour, trinket) display correctly with rarity colours +- [ ] Profile settings (showOnLeaderboards, etc.) persist correctly + +--- + +## Batch Hire + +- [ ] Batch selector (×1, ×5, ×10, ×25, ×100, ×Max) updates all hire buttons +- [ ] Cost display updates to reflect batch price +- [ ] ×Max hires as many as the player can afford, down to 1 +- [ ] ×Max is disabled (or shows ×0) if the player cannot afford even 1 + +--- + +## Responsive Layout + +- [ ] Game is usable on a 375px wide mobile screen +- [ ] Sidebar collapses to a horizontal strip on mobile +- [ ] Tabs scroll horizontally when there are too many to fit +- [ ] Resource bar wraps cleanly on small screens + +--- + +## Notifications / Toasts + +- [ ] Achievement toast appears when an achievement is unlocked +- [ ] Codex toast appears when a new codex entry is discovered +- [ ] Story toast appears when a new chapter unlocks +- [ ] Login bonus modal appears on first daily login +- [ ] Offline progress modal appears when returning after time away +- [ ] Outdated schema modal appears for players with old saves +- [ ] All toasts auto-dismiss after a few seconds + +--- + +## Crafting + +- [ ] Crafting a recipe deducts the correct materials +- [ ] Crafted item appears in inventory / updates stats +- [ ] Cannot craft without required materials (error shown) + +--- + +## 1Password Secrets (Production Only) + +- [ ] `DISCORD_MILESTONE_WEBHOOK` is set and webhook posts succeed +- [ ] `DISCORD_BOT_TOKEN`, `DISCORD_GUILD_ID`, `DISCORD_APOTHEOSIS_ROLE_ID` are set and role grant succeeds +- [ ] JWT secret is correctly injected +- [ ] MongoDB connection string is correctly injected