From 91c9f52daf46c6df276a4c49d1d2f560a58f1494 Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 13 Apr 2026 18:38:27 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20goddess=20expansion=20chunks=206?= =?UTF-8?q?=E2=80=939=20=E2=80=94=20UI=20panels,=20tick=20engine,=20CSS=20?= =?UTF-8?q?theme,=20about=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 11 goddess panels (zones, bosses, quests, disciples, equipment, upgrades, consecration, enlightenment, crafting, exploration, achievements) - Wire all panels into gameLayout via mode/tab routing - Add goddess passive income, disciple tick, quest timers, zone/quest unlock logic, and achievement checking to the tick engine - Add goddess CSS variables, .goddess-mode overrides, 300ms fade transition, and full panel stylesheet coverage - Add 13 Goddess expansion entries to the How to Play guide - Add web-side data files for crafting recipes, exploration areas, materials --- apps/web/src/api/client.ts | 169 +++++ apps/web/src/components/game/aboutPanel.tsx | 133 ++++ .../src/components/game/consecrationPanel.tsx | 640 ++++++++++++++++++ .../src/components/game/disciplesPanel.tsx | 274 ++++++++ .../components/game/enlightenmentPanel.tsx | 520 ++++++++++++++ apps/web/src/components/game/gameLayout.tsx | 41 +- .../game/goddessAchievementsPanel.tsx | 251 +++++++ .../src/components/game/goddessBossPanel.tsx | 503 ++++++++++++++ .../components/game/goddessCraftingPanel.tsx | 263 +++++++ .../components/game/goddessEquipmentPanel.tsx | 276 ++++++++ .../game/goddessExplorationPanel.tsx | 437 ++++++++++++ .../components/game/goddessQuestsPanel.tsx | 265 ++++++++ .../components/game/goddessUpgradesPanel.tsx | 267 ++++++++ .../src/components/game/goddessZonesPanel.tsx | 154 +++++ apps/web/src/context/gameContext.tsx | 591 +++++++++++++++- apps/web/src/data/goddessCraftingRecipes.ts | 439 ++++++++++++ apps/web/src/data/goddessExplorationAreas.ts | 542 +++++++++++++++ apps/web/src/data/goddessMaterials.ts | 409 +++++++++++ apps/web/src/engine/tick.ts | 333 +++++++++ apps/web/src/styles.css | 595 ++++++++++++++++ goddess-todo.md | 99 --- 21 files changed, 7093 insertions(+), 108 deletions(-) create mode 100644 apps/web/src/components/game/consecrationPanel.tsx create mode 100644 apps/web/src/components/game/disciplesPanel.tsx create mode 100644 apps/web/src/components/game/enlightenmentPanel.tsx create mode 100644 apps/web/src/components/game/goddessAchievementsPanel.tsx create mode 100644 apps/web/src/components/game/goddessBossPanel.tsx create mode 100644 apps/web/src/components/game/goddessCraftingPanel.tsx create mode 100644 apps/web/src/components/game/goddessEquipmentPanel.tsx create mode 100644 apps/web/src/components/game/goddessExplorationPanel.tsx create mode 100644 apps/web/src/components/game/goddessQuestsPanel.tsx create mode 100644 apps/web/src/components/game/goddessUpgradesPanel.tsx create mode 100644 apps/web/src/components/game/goddessZonesPanel.tsx create mode 100644 apps/web/src/data/goddessCraftingRecipes.ts create mode 100644 apps/web/src/data/goddessExplorationAreas.ts create mode 100644 apps/web/src/data/goddessMaterials.ts delete mode 100644 goddess-todo.md diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 151aa85..28e3981 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -4,6 +4,7 @@ * @license Naomi's Public License * @author Naomi Carrigan */ +/* eslint-disable max-lines -- API client grows with each new endpoint group */ import type { AboutResponse, ApotheosisRequest, @@ -11,18 +12,37 @@ import type { AuthResponse, BossChallengeRequest, BossChallengeResponse, + BuyConsecrationUpgradeRequest, + BuyConsecrationUpgradeResponse, BuyEchoUpgradeRequest, BuyEchoUpgradeResponse, + BuyEnlightenmentUpgradeRequest, + BuyEnlightenmentUpgradeResponse, + BuyGoddessUpgradeRequest, + BuyGoddessUpgradeResponse, BuyPrestigeUpgradeRequest, BuyPrestigeUpgradeResponse, + ConsecrationRequest, + ConsecrationResponse, CraftRecipeRequest, CraftRecipeResponse, + EnlightenmentRequest, + EnlightenmentResponse, ExploreClaimableResponse, ExploreCollectRequest, ExploreCollectResponse, ExploreStartRequest, ExploreStartResponse, ForceUnlocksResponse, + GoddessBossChallengeRequest, + GoddessBossChallengeResponse, + GoddessCraftRequest, + GoddessCraftResponse, + GoddessExploreClaimableResponse, + GoddessExploreCollectRequest, + GoddessExploreCollectResponse, + GoddessExploreStartRequest, + GoddessExploreStartResponse, LoadResponse, PrestigeRequest, PrestigeResponse, @@ -328,6 +348,145 @@ const debugHardReset = async(): Promise => { return await fetchJson("/debug/hard-reset", { method: "POST" }); }; +/** + * Challenges a goddess boss. + * @param body - The goddess boss challenge request payload. + * @returns The goddess boss challenge response data. + */ +const challengeGoddessBoss = async( + body: GoddessBossChallengeRequest, +): Promise => { + return await fetchJson("/goddess/boss", { + body: JSON.stringify(body), + method: "POST", + }); +}; + +/** + * Triggers a consecration reset on the server. + * @param body - The consecration request payload. + * @returns The consecration response data. + */ +const consecrate = async( + body: ConsecrationRequest, +): Promise => { + return await fetchJson("/consecration", { + body: JSON.stringify(body), + method: "POST", + }); +}; + +/** + * Purchases a consecration upgrade on the server. + * @param body - The buy consecration upgrade request payload. + * @returns The buy consecration upgrade response data. + */ +const buyConsecrationUpgrade = async( + body: BuyConsecrationUpgradeRequest, +): Promise => { + return await fetchJson( + "/consecration/buy-upgrade", + { body: JSON.stringify(body), method: "POST" }, + ); +}; + +/** + * Triggers an enlightenment reset on the server. + * @param body - The enlightenment request payload. + * @returns The enlightenment response data. + */ +const enlighten = async( + body: EnlightenmentRequest, +): Promise => { + return await fetchJson("/enlightenment", { + body: JSON.stringify(body), + method: "POST", + }); +}; + +/** + * Purchases an enlightenment upgrade on the server. + * @param body - The buy enlightenment upgrade request payload. + * @returns The buy enlightenment upgrade response data. + */ +const buyEnlightenmentUpgrade = async( + body: BuyEnlightenmentUpgradeRequest, +): Promise => { + return await fetchJson( + "/enlightenment/buy-upgrade", + { body: JSON.stringify(body), method: "POST" }, + ); +}; + +/** + * Purchases a goddess upgrade on the server. + * @param body - The buy goddess upgrade request payload. + * @returns The buy goddess upgrade response data. + */ +const buyGoddessUpgrade = async( + body: BuyGoddessUpgradeRequest, +): Promise => { + return await fetchJson("/goddess/upgrade", { + body: JSON.stringify(body), + method: "POST", + }); +}; + +/** + * Crafts a goddess recipe on the server. + * @param body - The goddess craft request payload. + * @returns The goddess craft response data. + */ +const craftGoddessRecipe = async( + body: GoddessCraftRequest, +): Promise => { + return await fetchJson("/goddess/craft", { + body: JSON.stringify(body), + method: "POST", + }); +}; + +/** + * Starts a goddess exploration in a given area. + * @param body - The goddess exploration start request payload. + * @returns The goddess exploration start response data. + */ +const startGoddessExploration = async( + body: GoddessExploreStartRequest, +): Promise => { + return await fetchJson("/goddess/explore", { + body: JSON.stringify(body), + method: "POST", + }); +}; + +/** + * Collects the rewards from a completed goddess exploration. + * @param body - The goddess exploration collect request payload. + * @returns The goddess exploration collect response data. + */ +const collectGoddessExploration = async( + body: GoddessExploreCollectRequest, +): Promise => { + return await fetchJson("/goddess/explore", { + body: JSON.stringify(body), + method: "PUT", + }); +}; + +/** + * Checks whether a given goddess exploration area is ready to claim on the server. + * @param areaId - The area ID to check. + * @returns Whether the goddess exploration is claimable. + */ +const checkGoddessExplorationClaimable = async( + areaId: string, +): Promise => { + return await fetchJson( + `/goddess/explore/claimable?areaId=${encodeURIComponent(areaId)}`, + ); +}; + /** * Fetches a public player profile by Discord ID. * @param discordId - The Discord ID of the player to look up. @@ -356,13 +515,22 @@ const updateProfile = async( export { ValidationError, achieveApotheosis, + buyConsecrationUpgrade, buyEchoUpgrade, + buyEnlightenmentUpgrade, + buyGoddessUpgrade, buyPrestigeUpgrade, challengeBoss, + challengeGoddessBoss, checkExplorationClaimable, + checkGoddessExplorationClaimable, collectExploration, + collectGoddessExploration, + consecrate, + craftGoddessRecipe, craftRecipe, debugHardReset, + enlighten, forceUnlocks, syncNewContent, getAbout, @@ -374,6 +542,7 @@ export { resetProgress, saveGame, startExploration, + startGoddessExploration, transcend, updateProfile, }; diff --git a/apps/web/src/components/game/aboutPanel.tsx b/apps/web/src/components/game/aboutPanel.tsx index e51b40a..a6b8a9f 100644 --- a/apps/web/src/components/game/aboutPanel.tsx +++ b/apps/web/src/components/game/aboutPanel.tsx @@ -254,6 +254,139 @@ const howToPlay = [ + " entries and lifetime profile statistics are always preserved.", title: "✨ Apotheosis", }, + { + body: + "Your first Apotheosis unlocks the Goddess Realm — an entirely new" + + " layer of the game that runs alongside your mortal progress. Switch" + + " between Mortal and Goddess modes using the mode bar at the top of" + + " the screen. All Goddess tabs are always visible, but their content" + + " is locked until Apotheosis. Your mortal progress is fully preserved" + + " when switching modes — they advance in parallel.", + title: "✨ Goddess Mode", + }, + { + body: + "The Goddess Realm uses three currencies: Prayers (earned passively" + + " from Disciples each tick), Divinity (earned from Disciples and" + + " multiplied by Consecration bonuses), and Stardust (awarded by" + + " Goddess Quests, Enlightenment resets, and Achievement unlocks)." + + " All three are always visible in the resource bar — greyed out" + + " before Apotheosis, fully active after.", + title: "🙏 Divine Currencies", + }, + { + body: + "The Goddess Realm has 18 zones, each containing 4 bosses and 5" + + " quests. The first zone is always available after Apotheosis." + + " Subsequent" + + " zones unlock when you defeat the required Goddess Boss AND complete" + + " the required Goddess Quest from the preceding zone — the same" + + " pattern as the mortal game, but with divine stakes.", + title: "🌟 Goddess Zones", + }, + { + body: + "Challenge Goddess Bosses to earn Prayers, sacred equipment drops, and" + + " unlock new Goddess Zones. Each boss has a Consecration requirement" + + " — you must have consecrated a minimum number of times before you" + + " can attempt it. Bosses that have been defeated stay defeated;" + + " the zone simply marks them as cleared.", + title: "⚔️ Goddess Boss Fights", + }, + { + body: + "Goddess Quests run on a timer just like mortal quests, but they always" + + " succeed — there is no failure chance in the divine realm. Rewards" + + " include Prayers, Divinity, Stardust, and unlocks for Goddess" + + " Upgrades, Disciples, and equipment. Quests within a zone are" + + " unlocked in order via prerequisites; a quest becomes available once" + + " all its required quests are completed.", + title: "📜 Goddess Quests", + }, + { + body: + "Disciples are the Goddess Realm's equivalent of adventurers. Hire" + + " them with Prayers and Divinity to generate passive Prayers and" + + " Divinity income every tick. Disciples come in six classes — Oracle," + + " Seraph, Invoker, Templar, Herald, and Warden — each with unique" + + " income rates and combat power. Buy in batches of 1, 10, or Max." + + " Disciple-specific Upgrades multiply the income of individual" + + " classes, and Global Upgrades stack on top.", + title: "🧎 Disciples", + }, + { + body: + "The Goddess Realm has three equipment types: Relics 📿, Vestments 👘," + + " and Sigils 🔯. Each type occupies its own slot — only one of each" + + " can be equipped at a time. Equipment is purchased with Prayers," + + " Divinity, and Stardust, or obtained exclusively as Boss Drop rewards" + + " (marked 🎲 Boss Drop Only). Pieces come in Common, Rare, Epic, and" + + " Legendary rarities and provide bonuses to Prayers/s, Disciple" + + " Combat, or Divinity from Consecration.", + title: "🔯 Goddess Equipment & Sets", + }, + { + body: + "Goddess Upgrades are purchased with Prayers, Divinity, and Stardust" + + " and fall into five categories: Prayers (boosts all Disciple prayer" + + " income globally), Disciple (boosts a specific Disciple class)," + + " Global (multiplies all Goddess income), Consecration (amplifies" + + " Consecration bonuses), and Boss (increases Goddess Boss damage)." + + " Upgrades stack multiplicatively and are permanent within a" + + " Consecration cycle.", + title: "🔧 Goddess Upgrades", + }, + { + body: + "Consecration is the Goddess Realm's prestige layer. When you" + + " Consecrate, your Prayers and Divinity are reset but you receive a" + + " permanent production multiplier that stacks with every Consecration." + + " Spend Divinity in the Consecration Shop on lasting upgrades that" + + " amplify Prayer income, Divinity income, and Disciple power. Each" + + " Consecration also raises your Consecration count, which is required" + + " to challenge higher-tier Goddess Bosses.", + title: "🙏 Consecration", + }, + { + body: + "Enlightenment is the Goddess Realm's transcendence layer, available" + + " after sufficient Consecrations. Enlightening performs a deeper" + + " reset — clearing Prayers, Divinity, and Consecration progress — in" + + " exchange for Stardust multipliers that persist forever. Spend" + + " Stardust in the Enlightenment Shop on meta-upgrades that amplify" + + " Prayer income, Disciple power, and future Stardust yields.", + title: "🌌 Enlightenment", + }, + { + body: + "Sacred Materials are gathered from Goddess Explorations (three unique" + + " materials per zone). Use them in the Goddess Crafting panel to" + + " craft recipes that grant permanent multipliers to Prayers/s, Divinity" + + " from Consecration, and Disciple Combat Power. Each recipe can only" + + " be crafted once; multipliers from all crafted recipes stack together" + + " and persist through Consecration and Enlightenment resets.", + title: "⚗️ Goddess Crafting", + }, + { + body: + "Send divine scouts to explore areas within each Goddess Zone. Each" + + " area runs on a timer and rewards Prayers, Divinity, Stardust, and" + + " Sacred Materials when collected. Goddess Explorations never fail." + + " Exploration zones unlock alongside their corresponding Goddess Zone" + + " — four areas are available per zone. Collecting from an area at" + + " least once marks it as discovered.", + title: "🗺️ Goddess Exploration", + }, + { + body: + "Goddess Achievements track milestones across the Goddess Realm:" + + " total Prayers earned, Goddess Bosses defeated, Goddess Quests" + + " completed, Disciples hired, Consecration count, and Goddess" + + " Equipment owned. Unlocking an achievement instantly awards bonus" + + " Divinity and Stardust. Achievements are checked automatically each" + + " tick and are permanent once unlocked.", + title: "🏆 Goddess Achievements", + }, { body: "The Story tab contains 22 chapters that unlock as you progress. The" diff --git a/apps/web/src/components/game/consecrationPanel.tsx b/apps/web/src/components/game/consecrationPanel.tsx new file mode 100644 index 0000000..c7215b7 --- /dev/null +++ b/apps/web/src/components/game/consecrationPanel.tsx @@ -0,0 +1,640 @@ +/** + * @file Consecration panel component for goddess prestige and divinity upgrade shop. + * @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 consecration and shop tabs */ +/* eslint-disable max-statements -- Consecration panel manages many local state variables */ +/* eslint-disable stylistic/max-len -- Data content with long description strings */ +/* eslint-disable @typescript-eslint/naming-convention -- SCREAMING_SNAKE_CASE is conventional for module-level data constants */ +import { useState, type JSX } from "react"; +import { useGame } from "../../context/gameContext.js"; +import type { ConsecrationUpgradeCategory } from "@elysium/types"; + +const baseConsecrationThreshold = 50_000; + +/** + * Calculates the prayers threshold required for the next consecration. + * Mirrors the server formula: BASE * (count + 1)^2 * thresholdMultiplier. + * @param consecrationCount - The number of consecrations completed so far. + * @param thresholdMultiplier - Optional stardust-upgrade multiplier applied to the threshold. + * @returns The prayers amount required to consecrate. + */ +const calculateConsecrationThreshold = ( + consecrationCount: number, + thresholdMultiplier = 1, +): number => { + return ( + baseConsecrationThreshold + * Math.pow(consecrationCount + 1, 2) + * thresholdMultiplier + ); +}; + +const divinityYieldDivisor = 1000; + +/** + * Calculates the projected divinity yield from a consecration. + * Mirrors the server formula: MAX(1, FLOOR(SQRT(totalPrayersEarned / divisor) * divinityMultiplier)). + * @param totalPrayersEarned - Total prayers earned in the current run. + * @param divinityMultiplier - Multiplier from stardust upgrades applied to divinity yield. + * @returns The projected divinity earned. + */ +const calculateDivinityYield = ( + totalPrayersEarned: number, + divinityMultiplier: number, +): number => { + return Math.max( + 1, + Math.floor( + Math.sqrt(totalPrayersEarned / divinityYieldDivisor) * divinityMultiplier, + ), + ); +}; + +/** + * Computes the consecration production multiplier from the count. + * Each consecration adds 25% to the production multiplier. + * @param count - The number of consecrations completed. + * @returns The computed production multiplier. + */ +const computeConsecrationProductionMultiplier = (count: number): number => { + // eslint-disable-next-line stylistic/no-extra-parens -- Required by no-mixed-operators rule + return 1 + (count * 0.25); +}; + +const CONSECRATION_UPGRADES: Array<{ + id: string; + name: string; + description: string; + category: ConsecrationUpgradeCategory; + divinityCost: number; + multiplier: number; +}> = [ + { + category: "prayers", + description: "The first drop of divinity awakens your disciples' devotion. All prayers/s ×1.25.", + divinityCost: 5, + id: "divine_prayers_1", + multiplier: 1.25, + name: "Divinity Blessing I", + }, + { + category: "prayers", + description: "Deeper divine resonance amplifies every prayer across the order. All prayers/s ×1.5.", + divinityCost: 15, + id: "divine_prayers_2", + multiplier: 1.5, + name: "Divinity Blessing II", + }, + { + category: "prayers", + description: "The full weight of accumulated consecration doubles prayer output entirely. All prayers/s ×2.", + divinityCost: 40, + id: "divine_prayers_3", + multiplier: 2, + name: "Divinity Blessing III", + }, + { + category: "prayers", + description: "The goddess's own blessing multiplies prayers fivefold through all of creation. All prayers/s ×5.", + divinityCost: 120, + id: "divine_prayers_4", + multiplier: 5, + name: "Divinity Blessing IV", + }, + { + category: "prayers", + description: "An unbroken chain of consecrations has tuned your disciples to a perfect divine frequency. All prayers/s ×10.", + divinityCost: 350, + id: "divine_prayers_5", + multiplier: 10, + name: "Divinity Blessing V", + }, + { + category: "prayers", + description: "The consecration memory floods every prayer with exponential fervour. All prayers/s ×25.", + divinityCost: 900, + id: "divine_prayers_6", + multiplier: 25, + name: "Divinity Blessing VI", + }, + { + category: "prayers", + description: "Every act of consecration resonates through eternity, amplifying prayers a hundredfold. All prayers/s ×100.", + divinityCost: 2500, + id: "divine_prayers_7", + multiplier: 100, + name: "Divinity Blessing VII", + }, + { + category: "disciples", + description: "Divinity breathes life into every disciple in your order. Disciple output ×1.25.", + divinityCost: 8, + id: "divine_disciples_1", + multiplier: 1.25, + name: "Sacred Ordination I", + }, + { + category: "disciples", + description: "Deeper divine resonance heightens disciple fervour. Disciple output ×1.5.", + divinityCost: 25, + id: "divine_disciples_2", + multiplier: 1.5, + name: "Sacred Ordination II", + }, + { + category: "disciples", + description: "Consecration anoints each disciple with divine purpose, doubling their output.", + divinityCost: 70, + id: "divine_disciples_3", + multiplier: 2, + name: "Sacred Ordination III", + }, + { + category: "disciples", + description: "The goddess's light magnifies every disciple fivefold across the entire order.", + divinityCost: 200, + id: "divine_disciples_4", + multiplier: 5, + name: "Sacred Ordination IV", + }, + { + category: "disciples", + description: "Generations of consecration have transcended the order itself. Disciple output ×10.", + divinityCost: 600, + id: "divine_disciples_5", + multiplier: 10, + name: "Sacred Ordination V", + }, + { + category: "combat", + description: "The goddess breathes divine fury into your disciples on the battlefield. Combat DPS ×1.25.", + divinityCost: 10, + id: "divine_combat_1", + multiplier: 1.25, + name: "Blessed Blade I", + }, + { + category: "combat", + description: "Divine power surges through your warriors in battle. Combat DPS ×1.5.", + divinityCost: 30, + id: "divine_combat_2", + multiplier: 1.5, + name: "Blessed Blade II", + }, + { + category: "combat", + description: "Consecration has forged your disciples into divine instruments of war. Combat DPS ×2.", + divinityCost: 80, + id: "divine_combat_3", + multiplier: 2, + name: "Blessed Blade III", + }, + { + category: "combat", + description: "Your disciples strike with the force of accumulated divinity. Combat DPS ×5.", + divinityCost: 250, + id: "divine_combat_4", + multiplier: 5, + name: "Blessed Blade IV", + }, + { + category: "combat", + description: "The goddess herself fights through your disciples on the sacred field. Combat DPS ×10.", + divinityCost: 750, + id: "divine_combat_5", + multiplier: 10, + name: "Blessed Blade V", + }, + { + category: "divinity", + description: "Divine attunement sharpens the flow of divinity from each consecration. Divinity yield ×1.25.", + divinityCost: 20, + id: "divine_divinity_1", + multiplier: 1.25, + name: "Divine Resonance I", + }, + { + category: "divinity", + description: "Sacred wisdom accumulated through consecrations amplifies the divine yield. Divinity yield ×1.5.", + divinityCost: 60, + id: "divine_divinity_2", + multiplier: 1.5, + name: "Divine Resonance II", + }, + { + category: "divinity", + description: "Each consecration leaves a deeper impression on the divine fabric. Divinity yield ×2.", + divinityCost: 175, + id: "divine_divinity_3", + multiplier: 2, + name: "Divine Resonance III", + }, + { + category: "divinity", + description: "The goddess opens a direct channel of divinity to reward your devotion. Divinity yield ×3.", + divinityCost: 500, + id: "divine_divinity_4", + multiplier: 3, + name: "Divine Resonance IV", + }, + { + category: "divinity", + description: "Perfect harmony between consecrator and goddess multiplies divinity fivefold. Divinity yield ×5.", + divinityCost: 1500, + id: "divine_divinity_5", + multiplier: 5, + name: "Divine Resonance V", + }, + { + category: "utility", + description: "Consecration memory compresses the prayers required for the next divine reset by 10%.", + divinityCost: 50, + id: "divine_utility_1", + multiplier: 0.9, + name: "Sacred Shortcut I", + }, + { + category: "utility", + description: "The goddess guides your path, shortening the consecration threshold by 20%.", + divinityCost: 150, + id: "divine_utility_2", + multiplier: 0.8, + name: "Sacred Shortcut II", + }, + { + category: "utility", + description: "Centuries of consecration have worn a path through the divine — threshold reduced by 30%.", + divinityCost: 400, + id: "divine_utility_3", + multiplier: 0.7, + name: "Sacred Shortcut III", + }, +]; + +const categoryOrder: Array = [ + "prayers", + "disciples", + "combat", + "divinity", + "utility", +]; + +const CONSECRATION_UPGRADE_CATEGORY_LABELS: Record = { + combat: "⚔️ Combat Multipliers", + disciples: "🙏 Disciple Multipliers", + divinity: "✨ Divinity Yield", + prayers: "📿 Prayer Multipliers", + utility: "🎯 Quality of Life", +}; + +type ConsecrationTab = "consecrate" | "shop"; + +/** + * Renders the consecration panel with ascension and divinity shop tabs. + * @returns The JSX element. + */ +const ConsecrationPanel = (): JSX.Element => { + const { + state, + reloadSilent, + formatInteger, + formatNumber, + consecrate, + buyConsecrationUpgrade, + showConsecrationToast, + dismissConsecrationToast, + } = useGame(); + + const [ isPending, setIsPending ] = useState(false); + const [ result, setResult ] = useState<{ + divinityEarned: number; + count: number; + } | null>(null); + const [ consecrationError, setConsecrationError ] = useState(null); + const [ buyingId, setBuyingId ] = useState(null); + const [ activeTab, setActiveTab ] = useState("consecrate"); + + if (state === null) { + return ( +
+

{"Loading..."}

+
+ ); + } + + const { goddess } = state; + + if (goddess === undefined) { + return ( +
+

{"The Goddess expansion is not yet unlocked."}

+
+ ); + } + + const { consecration, enlightenment, totalPrayersEarned } = goddess; + + const thresholdMultiplier = enlightenment.stardustConsecrationThresholdMultiplier; + const threshold = calculateConsecrationThreshold(consecration.count, thresholdMultiplier); + const isEligible = totalPrayersEarned >= threshold; + + const divinityMultiplier = enlightenment.stardustConsecrationDivinityMultiplier; + const divinityPreview = calculateDivinityYield(totalPrayersEarned, divinityMultiplier); + + const nextMultiplier = computeConsecrationProductionMultiplier(consecration.count + 1); + const progressRatio = Math.min(totalPrayersEarned / threshold, 1); + const progressPct = (progressRatio * 100).toFixed(1); + + const currentDivinity = consecration.divinity; + + async function handleConsecrate(): Promise { + setIsPending(true); + setConsecrationError(null); + try { + const data = await consecrate(); + setResult({ + count: data.newConsecrationCount, + divinityEarned: data.divinityEarned, + }); + await reloadSilent(); + } catch (error_: unknown) { + setConsecrationError( + error_ instanceof Error + ? error_.message + : "Consecration failed", + ); + } finally { + setIsPending(false); + } + } + + async function handleBuyUpgrade(upgradeId: string): Promise { + setBuyingId(upgradeId); + try { + await buyConsecrationUpgrade(upgradeId); + } finally { + setBuyingId(null); + } + } + + const upgradesByCategory = categoryOrder.map((categoryId) => { + const label = CONSECRATION_UPGRADE_CATEGORY_LABELS[categoryId]; + const upgrades = CONSECRATION_UPGRADES.filter((upgrade) => { + return upgrade.category === categoryId; + }); + return { categoryId, label, upgrades }; + }); + + function handleConsecrateClick(): void { + void handleConsecrate(); + } + + function handleConsecrateTabClick(): void { + setActiveTab("consecrate"); + } + + function handleShopTabClick(): void { + setActiveTab("shop"); + } + + return ( +
+

{"🕯️ Consecration"}

+ + {showConsecrationToast + ?
+

{"✨ Consecration complete!"}

+ +
+ : null + } + +
+ + +
+ + {activeTab === "consecrate" + && <> +

+ {"Consecration is the goddess prestige layer. It resets your prayers" + + " and goddess progress, but grants "} + {"Divinity"} + {" — a permanent goddess currency used to purchase powerful upgrades." + + " Each consecration also permanently increases your prayers/s multiplier."} +

+ +
+ {consecration.count > 0 + ?

+ {"Consecration count: "} + {consecration.count} +

+ : null + } +

+ {"Current Divinity: "} + {formatInteger(currentDivinity)} +

+

+ {"Prayers this run: "} + {formatNumber(totalPrayersEarned)} + {" / "} + {formatNumber(threshold)} +

+
+
+
+

+ {progressPct} + {"% of threshold"} +

+ {isEligible + ?

+ {"Divinity on consecration: "} + + {"+"} + {formatInteger(divinityPreview)} + + {divinityMultiplier > 1 + ? + {" (×"} + {divinityMultiplier.toFixed(2)} + {" yield bonus applied)"} + + : null + } +

+ : null} +

+ {"Next production multiplier: "} + + {"×"} + {nextMultiplier.toFixed(2)} + +

+
+ + {isEligible + ? null + :
+

+ {"🔒 "} + {"Earn enough prayers"} + {" to unlock consecration."} +

+

+ {"You need "} + {formatNumber(threshold)} + {" total prayers in the current run. You have "} + {formatNumber(totalPrayersEarned)} + {"."} +

+
+ } + + {isEligible + ?
+

+ {"You are ready to consecrate. This action is "} + {"irreversible"} + {" within this goddess run."} +

+ + {consecrationError === null + ? null + :

{consecrationError}

} + {result === null + ? null + :

+ {"Consecrated! Earned "} + + {formatInteger(result.divinityEarned)} + {" Divinity"} + + {". This is Consecration "} + {result.count} + {". A new divine cycle begins."} +

+ } +
+ : null} + + } + + {activeTab === "shop" + &&
+

+ {"Balance: "} + + {formatInteger(currentDivinity)} + {" Divinity"} + +

+

+ {"Divinity upgrades are "} + {"permanent"} + {" — they survive future consecrations."} +

+ + {upgradesByCategory.map(({ categoryId, label, upgrades }) => { + return ( +
+

{label}

+
+ {upgrades.map((upgrade) => { + const purchased = consecration.purchasedUpgradeIds.includes(upgrade.id); + const canAfford = currentDivinity >= upgrade.divinityCost; + const isLoading = buyingId === upgrade.id; + + function handleBuyClick(): void { + void handleBuyUpgrade(upgrade.id); + } + + return ( +
+
+

{upgrade.name}

+

{upgrade.description}

+

+ {purchased + ? "✅ Purchased" + : `✨ ${formatInteger(upgrade.divinityCost)} Divinity`} +

+
+ {purchased + ? null + : + } +
+ ); + })} +
+
+ ); + })} +
+ } +
+ ); +}; + +export { ConsecrationPanel }; diff --git a/apps/web/src/components/game/disciplesPanel.tsx b/apps/web/src/components/game/disciplesPanel.tsx new file mode 100644 index 0000000..393e5cc --- /dev/null +++ b/apps/web/src/components/game/disciplesPanel.tsx @@ -0,0 +1,274 @@ +/** + * @file Disciples panel component for purchasing goddess disciples. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +/* eslint-disable max-lines-per-function -- Complex component with many render paths */ +/* eslint-disable react/no-multi-comp -- DiscipleCard sub-component is tightly coupled */ +/* eslint-disable complexity -- DiscipleCard has inherent branching for batch/afford logic */ +import { type JSX, useState } from "react"; +import { useGame } from "../../context/gameContext.js"; +import type { GoddessDisciple } from "@elysium/types"; + +type BatchSize = 1 | 10 | "max"; +const batchOptions: Array = [ 1, 10, "max" ]; + +const growthRate = 1.15; + +/** + * Computes the total prayers cost to buy a batch of disciples. + * @param disciple - The disciple tier to purchase. + * @param quantity - The number to buy. + * @returns The total prayers cost. + */ +const computeBatchCost = ( + disciple: GoddessDisciple, + quantity: number, +): number => { + let total = 0; + for (let index = 0; index < quantity; index = index + 1) { + const exponent = disciple.count + index; + const cost = disciple.baseCost * Math.pow(growthRate, exponent); + total = total + cost; + } + return total; +}; + +/** + * Computes the maximum number of disciples affordable with available prayers. + * @param disciple - The disciple tier. + * @param prayers - The available prayer balance. + * @returns The maximum affordable quantity. + */ +const computeMaxAffordable = ( + disciple: GoddessDisciple, + prayers: number, +): number => { + let total = 0; + let quantity = 0; + for (let index = 0; index < 100_000; index = index + 1) { + const exponent = disciple.count + index; + const cost = disciple.baseCost * Math.pow(growthRate, exponent); + if (total + cost > prayers) { + break; + } + total = total + cost; + quantity = quantity + 1; + } + return quantity; +}; + +/** + * Parses a localStorage string back into a valid BatchSize, defaulting to 1. + * @param stored - The raw string from localStorage (or null if absent). + * @returns A valid BatchSize value. + */ +const parseBatchSize = (stored: string | null): BatchSize => { + if (stored === "max") { + return "max"; + } + if (stored === "10") { + return 10; + } + return 1; +}; + +interface DiscipleCardProperties { + readonly disciple: GoddessDisciple; + readonly prayers: number; + readonly selectedBatch: BatchSize; +} + +/** + * Renders a single disciple purchase card. + * @param props - The component properties. + * @param props.disciple - The disciple tier to display. + * @param props.prayers - The player's current prayer balance. + * @param props.selectedBatch - The active batch size selection. + * @returns The JSX element. + */ +const DiscipleCard = ({ + disciple, + prayers, + selectedBatch, +}: DiscipleCardProperties): JSX.Element => { + const { buyGoddessDisciple, formatNumber } = useGame(); + + const maxAffordable = computeMaxAffordable(disciple, prayers); + const effectiveBatch = selectedBatch === "max" + ? maxAffordable + : selectedBatch; + const batchCost = computeBatchCost(disciple, effectiveBatch); + const canAffordBatch = prayers >= batchCost && effectiveBatch > 0; + + const singleCost = computeBatchCost(disciple, 1); + + function handleBuy(): void { + if (effectiveBatch > 0) { + buyGoddessDisciple(disciple.id, effectiveBatch); + } + } + + function getBuyButtonLabel(): string { + if (selectedBatch === "max") { + if (maxAffordable === 0) { + return "Can't Afford"; + } + return `Buy Max (×${String(maxAffordable)})`; + } + return `Buy ×${String(effectiveBatch)}`; + } + + return ( +
+
+
+

{disciple.name}

+ {disciple.class} +
+ + {"×"} + {formatNumber(disciple.count)} + +
+
+ {disciple.prayersPerSecond > 0 + && + {"🙏 "} + {formatNumber(disciple.prayersPerSecond)} + {"/s prayers"} + + } + {disciple.divinityPerSecond > 0 + && + {"✨ "} + {formatNumber(disciple.divinityPerSecond)} + {"/s divinity"} + + } + + {"⚔️ "} + {formatNumber(disciple.combatPower)} + {" combat power each"} + +
+
+ + {"Next: 🙏 "} + {formatNumber(singleCost)} + + {selectedBatch !== 1 + && effectiveBatch > 0 + && + {selectedBatch === "max" + ? "Max" + : String(selectedBatch)} + {" (×"} + {String(effectiveBatch)} + {"): 🙏 "} + {formatNumber(batchCost)} + + } +
+ {disciple.unlocked + ? + : {"🔒 Locked"} + } +
+ ); +}; + +/** + * Renders the disciples panel for purchasing goddess disciples. + * @returns The JSX element. + */ +const DisciplesPanel = (): JSX.Element => { + const { state, formatNumber } = useGame(); + const [ selectedBatch, setSelectedBatch ] = useState(() => { + return parseBatchSize(localStorage.getItem("elysium_disciple_batch")); + }); + + if (state === null) { + return ( +
+

{"Loading..."}

+
+ ); + } + + const goddessState = state.goddess; + if (goddessState === undefined) { + return ( +
+

{"Goddess expansion not yet unlocked."}

+
+ ); + } + + const prayers = state.resources.prayers ?? 0; + const { disciples } = goddessState; + + function handleBatchSelect(batch: BatchSize): void { + setSelectedBatch(batch); + localStorage.setItem("elysium_disciple_batch", String(batch)); + } + + return ( +
+

{"Disciples"}

+
+ + {"🙏 Prayers: "} + {formatNumber(prayers)} + +
+
+ {batchOptions.map((batch) => { + function handleClick(): void { + handleBatchSelect(batch); + } + return ; + })} +
+
+ {disciples.map((disciple: GoddessDisciple) => { + return ; + })} +
+
+ ); +}; + +export { DisciplesPanel }; diff --git a/apps/web/src/components/game/enlightenmentPanel.tsx b/apps/web/src/components/game/enlightenmentPanel.tsx new file mode 100644 index 0000000..6821be1 --- /dev/null +++ b/apps/web/src/components/game/enlightenmentPanel.tsx @@ -0,0 +1,520 @@ +/** + * @file Enlightenment panel component for goddess transcendence and stardust upgrade shop. + * @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 enlightenment and shop tabs */ +/* eslint-disable max-statements -- Enlightenment panel manages many local state variables */ +/* eslint-disable stylistic/max-len -- Data content with long description strings */ +/* eslint-disable @typescript-eslint/naming-convention -- SCREAMING_SNAKE_CASE is conventional for module-level data constants */ +import { useState, type JSX } from "react"; +import { useGame } from "../../context/gameContext.js"; +import type { EnlightenmentUpgradeCategory } from "@elysium/types"; + +const finalGoddessBossId = "divine_heart_sovereign"; + +/** + * Calculates the projected stardust yield from an Enlightenment. + * Mirrors the server formula: MAX(1, FLOOR(SQRT(consecrationCount) * metaMultiplier)). + * @param consecrationCount - The number of consecrations completed before this Enlightenment. + * @param metaMultiplier - Multiplier from prior enlightenment upgrades applied to stardust yield. + * @returns The projected stardust earned. + */ +const calculateStardustYield = ( + consecrationCount: number, + metaMultiplier: number, +): number => { + return Math.max(1, Math.floor(Math.sqrt(consecrationCount) * metaMultiplier)); +}; + +const ENLIGHTENMENT_UPGRADES: Array<{ + id: string; + name: string; + description: string; + category: EnlightenmentUpgradeCategory; + cost: number; + multiplier: number; +}> = [ + { + category: "prayers", + cost: 2, + description: "The memory of past consecrations echoes through your order, amplifying prayer income by 25%.", + id: "stardust_prayers_1", + multiplier: 1.25, + name: "Celestial Echo I", + }, + { + category: "prayers", + cost: 4, + description: "Transcendent experience resonates through every disciple in the order, boosting prayers by 50%.", + id: "stardust_prayers_2", + multiplier: 1.5, + name: "Celestial Echo II", + }, + { + category: "prayers", + cost: 8, + description: "The harmony of enlightened cycles surges through your order, doubling all prayer income.", + id: "stardust_prayers_3", + multiplier: 2, + name: "Celestial Echo III", + }, + { + category: "prayers", + cost: 16, + description: "Divine overflow from enlightenment floods the order, tripling all prayer income.", + id: "stardust_prayers_4", + multiplier: 3, + name: "Celestial Echo IV", + }, + { + category: "prayers", + cost: 32, + description: "The infinite chorus of every consecration you have completed multiplies prayer income fivefold.", + id: "stardust_prayers_5", + multiplier: 5, + name: "Celestial Echo V", + }, + { + category: "combat", + cost: 2, + description: "Memories of every divine battle harden your disciples, increasing combat power by 25%.", + id: "stardust_combat_1", + multiplier: 1.25, + name: "Battle Memory I", + }, + { + category: "combat", + cost: 5, + description: "Veterans of enlightenment carry the strength of all past battles, boosting combat by 50%.", + id: "stardust_combat_2", + multiplier: 1.5, + name: "Battle Memory II", + }, + { + category: "combat", + cost: 12, + description: "Your disciples fight with the accumulated fury of countless consecrated cycles. Combat ×2.", + id: "stardust_combat_3", + multiplier: 2, + name: "Battle Memory III", + }, + { + category: "consecration_threshold", + cost: 3, + description: "Enlightened wisdom shortens the path to each consecration — threshold reduced by 10%.", + id: "stardust_threshold_1", + multiplier: 0.9, + name: "Accelerated Devotion I", + }, + { + category: "consecration_threshold", + cost: 7, + description: "The goddess herself smooths your path — consecration threshold reduced by 20%.", + id: "stardust_threshold_2", + multiplier: 0.8, + name: "Accelerated Devotion II", + }, + { + category: "consecration_threshold", + cost: 15, + description: "Generations of enlightenment compress the threshold by 30%.", + id: "stardust_threshold_3", + multiplier: 0.7, + name: "Accelerated Devotion III", + }, + { + category: "consecration_divinity", + cost: 3, + description: "Enlightened insight amplifies the divinity granted by each consecration by 25%.", + id: "stardust_divinity_1", + multiplier: 1.25, + name: "Divinity Amplifier I", + }, + { + category: "consecration_divinity", + cost: 8, + description: "The goddess pours greater divinity through each sacred reset — divinity yield ×1.5.", + id: "stardust_divinity_2", + multiplier: 1.5, + name: "Divinity Amplifier II", + }, + { + category: "consecration_divinity", + cost: 18, + description: "Enlightenment has attuned you to the divine source itself — divinity yield ×2.", + id: "stardust_divinity_3", + multiplier: 2, + name: "Divinity Amplifier III", + }, + { + category: "stardust_meta", + cost: 5, + description: "Each enlightenment resonates deeper, amplifying future stardust yields by 25%.", + id: "stardust_meta_1", + multiplier: 1.25, + name: "Stellar Resonance I", + }, + { + category: "stardust_meta", + cost: 12, + description: "The spiral of enlightenment compounds — future stardust yields ×1.5.", + id: "stardust_meta_2", + multiplier: 1.5, + name: "Stellar Resonance II", + }, + { + category: "stardust_meta", + cost: 25, + description: "You have mastered the infinite stellar cycle — future stardust yields ×2.", + id: "stardust_meta_3", + multiplier: 2, + name: "Stellar Resonance III", + }, +]; + +const categoryOrder: Array = [ + "prayers", + "combat", + "consecration_threshold", + "consecration_divinity", + "stardust_meta", +]; + +const ENLIGHTENMENT_UPGRADE_CATEGORY_LABELS: Record = { + combat: "⚔️ Combat Multipliers", + consecration_divinity: "✨ Consecration Quality of Life — Divinity Yield", + consecration_threshold: "🎯 Consecration Quality of Life — Threshold", + prayers: "📿 Prayer Multipliers", + stardust_meta: "🌟 Stardust Meta Upgrades", +}; + +type EnlightenmentTab = "enlighten" | "shop"; + +/** + * Renders the enlightenment panel with transcendence and stardust shop tabs. + * @returns The JSX element. + */ +const EnlightenmentPanel = (): JSX.Element => { + const { + state, + reloadSilent, + formatInteger, + enlighten, + buyEnlightenmentUpgrade, + showEnlightenmentToast, + dismissEnlightenmentToast, + } = useGame(); + + const [ isPending, setIsPending ] = useState(false); + const [ result, setResult ] = useState<{ + stardustEarned: number; + count: number; + } | null>(null); + const [ enlightenmentError, setEnlightenmentError ] = useState(null); + const [ buyingId, setBuyingId ] = useState(null); + const [ activeTab, setActiveTab ] = useState("enlighten"); + + if (state === null) { + return ( +
+

{"Loading..."}

+
+ ); + } + + const { goddess } = state; + + if (goddess === undefined) { + return ( +
+

{"The Goddess expansion is not yet unlocked."}

+
+ ); + } + + const { consecration, enlightenment, bosses } = goddess; + + const hasDefeatedFinalBoss = bosses.some((boss) => { + return boss.id === finalGoddessBossId && boss.status === "defeated"; + }); + + const metaMultiplier = enlightenment.stardustMetaMultiplier; + const stardustPreview = calculateStardustYield(consecration.count, metaMultiplier); + const currentStardust = enlightenment.stardust; + const enlightenmentCount = enlightenment.count; + + async function handleEnlighten(): Promise { + setIsPending(true); + setEnlightenmentError(null); + try { + const data = await enlighten(); + setResult({ + count: data.newEnlightenmentCount, + stardustEarned: data.stardustEarned, + }); + await reloadSilent(); + } catch (error_: unknown) { + setEnlightenmentError( + error_ instanceof Error + ? error_.message + : "Enlightenment failed", + ); + } finally { + setIsPending(false); + } + } + + async function handleBuyUpgrade(upgradeId: string): Promise { + setBuyingId(upgradeId); + try { + await buyEnlightenmentUpgrade(upgradeId); + } finally { + setBuyingId(null); + } + } + + const upgradesByCategory = categoryOrder.map((catId) => { + const label = ENLIGHTENMENT_UPGRADE_CATEGORY_LABELS[catId]; + const upgrades = ENLIGHTENMENT_UPGRADES.filter((upgrade) => { + return upgrade.category === catId; + }); + return { catId, label, upgrades }; + }); + + function handleEnlightenClick(): void { + void handleEnlighten(); + } + + function handleEnlightenTabClick(): void { + setActiveTab("enlighten"); + } + + function handleShopTabClick(): void { + setActiveTab("shop"); + } + + return ( +
+

{"🌟 Enlightenment"}

+ + {showEnlightenmentToast + ?
+

{"🌟 Enlightenment achieved!"}

+ +
+ : null + } + +
+ + +
+ + {activeTab === "enlighten" + && <> +

+ {"Enlightenment is the ultimate goddess reset. It wipes "} + {"everything"} + {" in the goddess realm — prayers, consecrations, disciples, and upgrades" + + " — but grants "} + {"Stardust"} + {", a permanent goddess currency that survives all future resets." + + " Stardust powers upgrades that permanently amplify every goddess run."} +

+

+ + {"More consecrations = more Stardust."} + {" Optimise your goddess run for maximum yield!"} + +

+ +
+ {enlightenmentCount > 0 + ?

+ {"Enlightenment count: "} + {enlightenmentCount} +

+ : null + } +

+ {"Current Stardust: "} + {formatInteger(currentStardust)} +

+

+ {"Current consecration count: "} + {consecration.count} +

+ {hasDefeatedFinalBoss + ?

+ {"Stardust on enlightenment: "} + + {"+"} + {formatInteger(stardustPreview)} + + {metaMultiplier > 1 + ? + {" (×"} + {metaMultiplier.toFixed(2)} + {" meta bonus applied)"} + + : null + } +

+ : null} +
+ + {hasDefeatedFinalBoss + ? null + :
+

+ {"🔒 "} + {"Defeat the Divine Heart Sovereign"} + {" to unlock Enlightenment."} +

+

+ {"The Divine Heart Sovereign is the final boss of the Goddess realm."} +

+
+ } + + {hasDefeatedFinalBoss + ?
+

+ {"You are ready to achieve Enlightenment. This action is "} + {"irreversible"} + {"."} +

+ + {enlightenmentError === null + ? null + :

{enlightenmentError}

} + {result === null + ? null + :

+ {"Enlightenment achieved! Earned "} + + {formatInteger(result.stardustEarned)} + {" Stardust"} + + {". This is Enlightenment "} + {result.count} + {". A new stellar cycle begins."} +

+ } +
+ : null} + + } + + {activeTab === "shop" + &&
+

+ {"Balance: "} + + {formatInteger(currentStardust)} + {" Stardust"} + +

+

+ {"Stardust upgrades are "} + {"permanent"} + {" — they survive all future consecrations and enlightenments."} +

+ + {upgradesByCategory.map(({ catId, label, upgrades }) => { + return ( +
+

{label}

+
+ {upgrades.map((upgrade) => { + const purchased = enlightenment.purchasedUpgradeIds.includes(upgrade.id); + const canAfford = currentStardust >= upgrade.cost; + const isLoading = buyingId === upgrade.id; + + function handleBuyClick(): void { + void handleBuyUpgrade(upgrade.id); + } + + return ( +
+
+

{upgrade.name}

+

{upgrade.description}

+

+ {purchased + ? "✅ Purchased" + : `🌟 ${formatInteger(upgrade.cost)} Stardust`} +

+
+ {purchased + ? null + : + } +
+ ); + })} +
+
+ ); + })} +
+ } +
+ ); +}; + +export { EnlightenmentPanel }; diff --git a/apps/web/src/components/game/gameLayout.tsx b/apps/web/src/components/game/gameLayout.tsx index ca6dff6..5590bae 100644 --- a/apps/web/src/components/game/gameLayout.tsx +++ b/apps/web/src/components/game/gameLayout.tsx @@ -22,12 +22,23 @@ import { ClickArea } from "./clickArea.js"; import { CodexPanel } from "./codexPanel.js"; import { CodexToast } from "./codexToast.js"; import { CompanionPanel } from "./companionPanel.js"; +import { ConsecrationPanel } from "./consecrationPanel.js"; import { CraftingPanel } from "./craftingPanel.js"; import { DailyChallengePanel } from "./dailyChallengePanel.js"; import { DebugPanel } from "./debugPanel.js"; +import { DisciplesPanel } from "./disciplesPanel.js"; import { EditProfileModal } from "./editProfileModal.js"; +import { EnlightenmentPanel } from "./enlightenmentPanel.js"; import { EquipmentPanel } from "./equipmentPanel.js"; import { ExplorationPanel } from "./explorationPanel.js"; +import { GoddessAchievementsPanel } from "./goddessAchievementsPanel.js"; +import { GoddessBossPanel } from "./goddessBossPanel.js"; +import { GoddessCraftingPanel } from "./goddessCraftingPanel.js"; +import { GoddessEquipmentPanel } from "./goddessEquipmentPanel.js"; +import { GoddessExplorationPanel } from "./goddessExplorationPanel.js"; +import { GoddessQuestsPanel } from "./goddessQuestsPanel.js"; +import { GoddessUpgradesPanel } from "./goddessUpgradesPanel.js"; +import { GoddessZonesPanel } from "./goddessZonesPanel.js"; import { JoinCommunityModal } from "./joinCommunityModal.js"; import { LoginBonusModal } from "./loginBonusModal.js"; import { MilestoneToast } from "./milestoneToast.js"; @@ -381,11 +392,33 @@ const GameLayout = (): JSX.Element => { && } {activeMode === "mortal" && activeTab === "debug" && } + {activeMode === "goddess" && activeGoddessTab === "goddess-zones" + && } + {activeMode === "goddess" && activeGoddessTab === "goddess-bosses" + && } + {activeMode === "goddess" && activeGoddessTab === "goddess-quests" + && } + {activeMode === "goddess" && activeGoddessTab === "disciples" + && } {activeMode === "goddess" - &&
-

{"✨ Goddess panels coming soon..."}

-
- } + && activeGoddessTab === "goddess-equipment" + && } + {activeMode === "goddess" + && activeGoddessTab === "goddess-upgrades" + && } + {activeMode === "goddess" && activeGoddessTab === "consecration" + && } + {activeMode === "goddess" && activeGoddessTab === "enlightenment" + && } + {activeMode === "goddess" + && activeGoddessTab === "goddess-crafting" + && } + {activeMode === "goddess" + && activeGoddessTab === "goddess-exploration" + && } + {activeMode === "goddess" + && activeGoddessTab === "goddess-achievements" + && } {activeMode === "vampire" &&

{"🧛 Vampire panels coming soon..."}

diff --git a/apps/web/src/components/game/goddessAchievementsPanel.tsx b/apps/web/src/components/game/goddessAchievementsPanel.tsx new file mode 100644 index 0000000..9362433 --- /dev/null +++ b/apps/web/src/components/game/goddessAchievementsPanel.tsx @@ -0,0 +1,251 @@ +/** + * @file Goddess achievements panel component displaying all goddess expansion 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 */ +/* eslint-disable max-lines-per-function -- Achievement panel renders many achievement states */ +import { type JSX, useState } from "react"; +import { useGame } from "../../context/gameContext.js"; +import { LockToggle } from "../ui/lockToggle.js"; +import type { GoddessAchievement, GoddessState } 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 a goddess achievement. + * @param achievement - The goddess achievement to describe. + * @param formatNumber - The number formatting utility function. + * @returns A string describing the achievement condition. + */ +const conditionDescription = ( + achievement: GoddessAchievement, + formatNumber: (n: number)=> string, +): string => { + const { condition } = achievement; + switch (condition.type) { + case "totalPrayersEarned": + return `Earn ${formatNumber(condition.amount)} total prayers`; + case "goddessBossesDefeated": + return `Defeat ${String(condition.amount)} goddess ${pluralise(condition.amount, "boss")}`; + case "goddessQuestsCompleted": + return `Complete ${String(condition.amount)} goddess ${pluralise(condition.amount, "quest")}`; + case "discipleTotal": + return `Recruit ${formatNumber(condition.amount)} total ${pluralise(condition.amount, "disciple")}`; + case "consecrationCount": + return `Consecrate ${String(condition.amount)} ${pluralise(condition.amount, "time")}`; + case "goddessEquipmentOwned": + return `Own ${String(condition.amount)} goddess equipment ${pluralise(condition.amount, "item")}`; + default: + return "Unknown condition"; + } +}; + +/** + * Returns the player's current progress value toward a goddess achievement's unlock condition. + * @param achievement - The achievement to evaluate progress for. + * @param goddess - The current goddess state. + * @returns The current numeric progress toward the achievement condition. + */ +const getCurrentProgress = ( + achievement: GoddessAchievement, + goddess: GoddessState, +): number => { + const { condition } = achievement; + switch (condition.type) { + case "totalPrayersEarned": + return goddess.lifetimePrayersEarned; + case "goddessBossesDefeated": + return goddess.lifetimeBossesDefeated; + case "goddessQuestsCompleted": + return goddess.lifetimeQuestsCompleted; + case "discipleTotal": + return goddess.disciples.reduce((sum, disciple) => { + return sum + disciple.count; + }, 0); + case "consecrationCount": + return goddess.consecration.count; + case "goddessEquipmentOwned": + return goddess.equipment.filter((item) => { + return item.owned; + }).length; + default: + return 0; + } +}; + +interface GoddessAchievementCardProperties { + readonly achievement: GoddessAchievement; + readonly formatNumber: (n: number)=> string; + readonly progressValue: number; +} + +/** + * Renders a single goddess achievement card. + * @param props - The achievement card properties. + * @param props.achievement - The achievement to display. + * @param props.formatNumber - The number formatting utility function. + * @param props.progressValue - The player's current progress toward the unlock condition. + * @returns The JSX element. + */ +const GoddessAchievementCard = ({ + achievement, + formatNumber, + progressValue, +}: GoddessAchievementCardProperties): JSX.Element => { + const isUnlocked = achievement.unlockedAt !== null; + const cappedProgress = Math.min(progressValue, achievement.condition.amount); + + return ( +
+
+ {achievement.icon} +
+
+

{achievement.name}

+

{achievement.description}

+

+ {conditionDescription(achievement, formatNumber)} +

+ {!isUnlocked + &&
+ + + {formatNumber(progressValue)} + {" / "} + {formatNumber(achievement.condition.amount)} + +
+ } + {achievement.reward !== undefined + &&
+ {achievement.reward.divinity !== undefined + &&

+ {"✨ +"} + {achievement.reward.divinity} + {" Divinity"} +

+ } + {achievement.reward.stardust !== undefined + &&

+ {"🌟 +"} + {achievement.reward.stardust} + {" Stardust"} +

+ } +
+ } +
+
+ {isUnlocked + ? <> + {"✓ Unlocked"} + {achievement.unlockedAt !== null + && + {new Date(achievement.unlockedAt).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + })} + + } + + : {"🔒"} + } +
+
+ ); +}; + +/** + * Renders the goddess achievements panel with all goddess expansion achievements. + * @returns The JSX element. + */ +const GoddessAchievementsPanel = (): JSX.Element => { + const { state, formatNumber } = useGame(); + const [ showLocked, setShowLocked ] = useState(true); + + if (state === null) { + return ( +
+

{"Loading..."}

+
+ ); + } + + const { goddess } = state; + + if (goddess === undefined) { + return ( +
+

{"The Goddess expansion is not yet unlocked."}

+
+ ); + } + + const achievementList = goddess.achievements; + const unlocked = achievementList.filter((achievement) => { + return achievement.unlockedAt !== null; + }); + const locked = achievementList.filter((achievement) => { + return achievement.unlockedAt === null; + }); + const visible = showLocked + ? achievementList + : unlocked; + + function handleToggle(): void { + setShowLocked((current) => { + return !current; + }); + } + + return ( +
+
+

{"🌸 Goddess Achievements"}

+ +
+

+ {unlocked.length} + {" / "} + {achievementList.length} + {" unlocked"} +

+
+ {visible.map((achievement) => { + return ( + + ); + })} +
+
+ ); +}; + +export { GoddessAchievementsPanel }; diff --git a/apps/web/src/components/game/goddessBossPanel.tsx b/apps/web/src/components/game/goddessBossPanel.tsx new file mode 100644 index 0000000..7c5fd16 --- /dev/null +++ b/apps/web/src/components/game/goddessBossPanel.tsx @@ -0,0 +1,503 @@ +/** + * @file Goddess Boss panel — challenge divine realm bosses. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines -- Panel with sub-component, modal, and zone filter */ +/* 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 -- Panel requires many variable declarations */ +/* eslint-disable react/no-multi-comp -- Sub-components are tightly coupled to this panel */ +import { type JSX, useState } from "react"; +import { useGame } from "../../context/gameContext.js"; +import type { + GoddessBoss, + GoddessBossChallengeResponse, + GoddessZone, +} from "@elysium/types"; + +interface GoddessBossCardProperties { + readonly boss: GoddessBoss; + readonly onChallenge: (bossId: string)=> void; + readonly isChallenging: boolean; + readonly unlockHint: string | undefined; + readonly formatNumber: (n: number)=> string; + readonly formatInteger: (n: number)=> string; +} + +/** + * Renders a single goddess boss card. + * @param props - The boss card properties. + * @param props.boss - The boss data. + * @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. + * @param props.formatInteger - The integer formatting utility function. + * @returns The JSX element. + */ +const GoddessBossCard = ({ + boss, + onChallenge, + isChallenging, + unlockHint, + formatNumber, + formatInteger, +}: GoddessBossCardProperties): JSX.Element => { + const canChallenge + = (boss.status === "available" || boss.status === "in_progress") + && !isChallenging; + + const hpRatio = boss.currentHp / boss.maxHp; + const hpPercent = hpRatio * 100; + + function handleChallenge(): void { + onChallenge(boss.id); + } + + return ( +
+
+

{boss.name}

+

{boss.description}

+ {boss.status === "locked" && unlockHint !== undefined + ?

{unlockHint}

+ : null} + {boss.consecrationRequirement > 0 + ?

+ {"🕊️ Requires Consecration "} + {boss.consecrationRequirement} +

+ : null} +
+ + {boss.status !== "locked" && boss.status !== "defeated" + ?
+
+
+
+ + {formatNumber(boss.currentHp)} + {" / "} + {formatNumber(boss.maxHp)} + {" HP"} + +
+ : null} + +
+ + {"💢 Boss DPS: "} + {formatNumber(boss.damagePerSecond)} + +
+ +
+ {boss.prayersReward > 0 + && + {"🙏 "} + {formatNumber(boss.prayersReward)} + + } + {boss.divinityReward > 0 + && + {"✨ "} + {formatInteger(boss.divinityReward)} + {" Divinity"} + + } + {boss.stardustReward > 0 + && + {"⭐ "} + {formatInteger(boss.stardustReward)} + {" Stardust"} + + } + {boss.equipmentRewards.length > 0 + && + {"🗡️ "} + {boss.equipmentRewards.length} + {" Equipment"} + + } + {boss.status !== "defeated" + && boss.bountyDivinity > 0 + && boss.bountyDivinityClaimed !== true + ? + {"✨ "} + {boss.bountyDivinity} + {" Divinity (first kill)"} + + : null} +
+ + {boss.status === "available" || boss.status === "in_progress" + ? + : null} + + {boss.status === "defeated" + ? {"☠️ Defeated"} + : null} +
+ ); +}; + +interface GoddessBattleModalProperties { + readonly result: GoddessBossChallengeResponse; + readonly onDismiss: ()=> void; + readonly formatNumber: (n: number)=> string; + readonly formatInteger: (n: number)=> string; +} + +/** + * Renders the goddess battle result modal overlay. + * @param props - The modal properties. + * @param props.result - The battle result data. + * @param props.onDismiss - Callback to dismiss the modal. + * @param props.formatNumber - The number formatting utility function. + * @param props.formatInteger - The integer formatting utility function. + * @returns The JSX element. + */ +const GoddessBattleModal = ({ + result, + onDismiss, + formatNumber, + formatInteger, +}: GoddessBattleModalProperties): JSX.Element => { + return ( +
+
+

+ {result.won + ? "⚔️ Victory!" + : "💀 Defeated!"} +

+ +
+
+ {"⚔️ Your Party DPS"} + {formatNumber(result.partyDPS)} +
+
+ {"💢 Boss DPS"} + {formatNumber(result.bossDPS)} +
+
+ {"❤️ Boss HP Before"} + + {formatNumber(result.bossHpBefore)} + {" / "} + {formatNumber(result.bossMaxHp)} + +
+
+ {"❤️ Boss HP After"} + {formatNumber(result.bossNewHp)} +
+
+ {"🛡️ Party HP Remaining"} + + {formatNumber(result.partyHpRemaining)} + {" / "} + {formatNumber(result.partyMaxHp)} + +
+
+ + {result.won && result.rewards !== undefined + ?
+

{"Rewards"}

+ {result.rewards.prayers > 0 + ?

+ {"🙏 "} + {formatNumber(result.rewards.prayers)} + {" Prayers"} +

+ : null} + {result.rewards.divinity > 0 + ?

+ {"✨ "} + {formatInteger(result.rewards.divinity)} + {" Divinity"} +

+ : null} + {result.rewards.stardust > 0 + ?

+ {"⭐ "} + {formatInteger(result.rewards.stardust)} + {" Stardust"} +

+ : null} + {result.rewards.bountyDivinity > 0 + ?

+ {"✨ "} + {formatInteger(result.rewards.bountyDivinity)} + {" Divinity (first kill bonus!)"} +

+ : null} + {result.rewards.upgradeIds.length > 0 + ?

+ {"🔓 "} + {result.rewards.upgradeIds.length} + {" Upgrade(s) unlocked"} +

+ : null} + {result.rewards.equipmentIds.length > 0 + ?

+ {"🗡️ "} + {result.rewards.equipmentIds.length} + {" Equipment item(s) gained"} +

+ : null} +
+ : null} + + {result.casualties !== undefined && result.casualties.length > 0 + ?
+

{"Casualties"}

+ {result.casualties.map((casualty) => { + return ( +

+ {casualty.killed} + {" "} + {casualty.discipleId} + {" lost"} +

+ ); + })} +
+ : null} + + +
+
+ ); +}; + +/** + * Renders the Goddess Boss panel with zone filtering and battle result modal. + * @returns The JSX element. + */ +const GoddessBossPanel = (): JSX.Element => { + const { + state, + challengeGoddessBoss, + goddessBattleResult, + dismissGoddessBattle, + formatNumber, + formatInteger, + } = useGame(); + + const [ challengingBossId, setChallengingBossId ] = useState( + null, + ); + const [ activeZoneId, setActiveZoneId ] = useState(() => { + return sessionStorage.getItem("elysium_goddess_boss_zone"); + }); + + if (state === null) { + return ( +
+

{"Loading..."}

+
+ ); + } + + const { goddess } = state; + + if (goddess === undefined) { + return ( +
+

{"The Goddess expansion is not yet unlocked."}

+
+ ); + } + + const { bosses, quests, zones } = goddess; + + async function handleChallenge(bossId: string): Promise { + setChallengingBossId(bossId); + try { + await challengeGoddessBoss(bossId); + } finally { + setChallengingBossId(null); + } + } + + function handleChallengeClick(bossId: string): void { + void handleChallenge(bossId); + } + + function handleZoneSelect(zoneId: string): void { + setActiveZoneId(zoneId); + sessionStorage.setItem("elysium_goddess_boss_zone", zoneId); + } + + function handleShowAll(): void { + setActiveZoneId(null); + sessionStorage.removeItem("elysium_goddess_boss_zone"); + } + + const filteredBosses = activeZoneId === null + ? bosses + : bosses.filter((boss) => { + return boss.zoneId === activeZoneId; + }); + + const bossUnlockHints = new Map(); + for (const zone of zones) { + const { id: zoneId, unlockBossId, unlockQuestId } = zone; + const zoneBosses = bosses.filter((boss) => { + return boss.zoneId === zoneId; + }); + for (let index = 0; index < zoneBosses.length; index = index + 1) { + const boss = zoneBosses[index]; + if (boss === undefined || boss.status !== "locked") { + continue; + } + if (index === 0) { + const parts: Array = []; + 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 = zoneBosses[index - 1]; + if (previousBoss !== undefined) { + bossUnlockHints.set(boss.id, `⚔️ Defeat: ${previousBoss.name} first`); + } + } + } + } + + const activeZoneData: GoddessZone | undefined = activeZoneId === null + ? undefined + : zones.find((zone) => { + return zone.id === activeZoneId; + }); + + return ( +
+
+

{"⚔️ Goddess Bosses"}

+
+ +
+ + {zones.map((zone) => { + function handleSelect(): void { + handleZoneSelect(zone.id); + } + return ( + + ); + })} +
+ + {activeZoneData?.status === "locked" + ?
+

{"🔒 This zone is locked."}

+ {activeZoneData.unlockBossId === null + ? null + :

+ {"⚔️ Defeat: "} + {bosses.find((boss) => { + return boss.id === activeZoneData.unlockBossId; + })?.name ?? activeZoneData.unlockBossId} +

} + {activeZoneData.unlockQuestId === null + ? null + :

+ {"📜 Complete: "} + {quests.find((quest) => { + return quest.id === activeZoneData.unlockQuestId; + })?.name ?? activeZoneData.unlockQuestId} +

} +
+ : null} + +
+ {filteredBosses.map((boss) => { + return ( + + ); + })} + {filteredBosses.length === 0 + ?

{"No bosses to show in this zone."}

+ : null} +
+ + {goddessBattleResult === null + ? null + : } +
+ ); +}; + +export { GoddessBossPanel }; diff --git a/apps/web/src/components/game/goddessCraftingPanel.tsx b/apps/web/src/components/game/goddessCraftingPanel.tsx new file mode 100644 index 0000000..b7cb9a1 --- /dev/null +++ b/apps/web/src/components/game/goddessCraftingPanel.tsx @@ -0,0 +1,263 @@ +/** + * @file Goddess crafting panel component for crafting recipes from sacred 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 { GODDESS_RECIPES } from "../../data/goddessCraftingRecipes.js"; +import { GODDESS_MATERIALS } from "../../data/goddessMaterials.js"; +import { cdnImage } from "../../utils/cdn.js"; + +const bonusLabel: Record = { + click_power: "👆 Click Power", + combat_power: "⚔️ Combat Power", + essence_income: "✨ Essence Income", + gold_income: "🪙 Gold Income", +}; + +/** + * Renders the goddess crafting panel for crafting recipes from sacred materials. + * @returns The JSX element. + */ +const GoddessCraftingPanel = (): JSX.Element => { + const { state, craftGoddessRecipe, formatNumber } = useGame(); + const [ activeZoneId, setActiveZoneId ] = useState(() => { + return ( + sessionStorage.getItem("elysium_goddess_craft_zone") + ?? "goddess_celestial_garden" + ); + }); + const [ pendingRecipeId, setPendingRecipeId ] = useState(null); + + if (state === null) { + return ( +
+

{"Loading..."}

+
+ ); + } + + const { goddess } = state; + const playerMaterials = goddess?.exploration.materials ?? []; + const craftedIds = goddess?.exploration.craftedRecipeIds ?? []; + + const goddessZones = goddess?.zones ?? []; + const zoneRecipes = GODDESS_RECIPES.filter((recipe) => { + return recipe.zoneId === activeZoneId; + }); + const zoneMaterials = GODDESS_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 = GODDESS_RECIPES.find((candidateRecipe) => { + return candidateRecipe.id === recipeId; + }); + if (recipe === undefined) { + return false; + } + return recipe.requiredMaterials.every((request) => { + return getQuantity(request.materialId) >= request.quantity; + }); + } + + function handleZoneSelect(zoneId: string): void { + setActiveZoneId(zoneId); + sessionStorage.setItem("elysium_goddess_craft_zone", zoneId); + } + + async function handleCraft(recipeId: string): Promise { + setPendingRecipeId(recipeId); + try { + await craftGoddessRecipe(recipeId); + } finally { + setPendingRecipeId(null); + } + } + + return ( +
+
+

{"⚗️ Sacred Crafting"}

+
+ +
+ {goddessZones.map((zone) => { + const isLocked = zone.status === "locked"; + + function handleZoneClick(): void { + handleZoneSelect(zone.id); + } + + return ( + + ); + })} +
+ +
+
+

{"📦 Sacred Materials"}

+ {zoneMaterials.length === 0 + ?

{"No materials in this zone."}

+ :
+ {zoneMaterials.map((material) => { + const qty = getQuantity(material.id); + return ( +
+ {material.name} +
+ {material.name} + {material.rarity} +
+ + {formatNumber(qty)} + +
+ ); + })} +
+ } +
+ +
+

{"📜 Sacred Recipes"}

+ {zoneRecipes.length === 0 + ?

{"No recipes in this zone."}

+ :
+ {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 ( +
+ {recipe.name} +
+

{recipe.name}

+

{recipe.description}

+
+ + {bonusLabel[recipe.bonus.type] ?? recipe.bonus.type} + + + {"×"} + {recipe.bonus.value.toFixed(2)} + +
+
+ {recipe.requiredMaterials.map((request) => { + const have = getQuantity(request.materialId); + const enough = have >= request.quantity; + const matName + = GODDESS_MATERIALS.find((mat) => { + return mat.id === request.materialId; + })?.name ?? request.materialId; + return ( + + {matName} + {": "} + {formatNumber(have)} + {"/"} + {formatNumber(request.quantity)} + + ); + })} +
+
+
+ {crafted + ? + {"✅ Crafted"} + + : + } +
+
+ ); + })} +
+ } +
+
+
+ ); +}; + +export { GoddessCraftingPanel }; diff --git a/apps/web/src/components/game/goddessEquipmentPanel.tsx b/apps/web/src/components/game/goddessEquipmentPanel.tsx new file mode 100644 index 0000000..0e915fe --- /dev/null +++ b/apps/web/src/components/game/goddessEquipmentPanel.tsx @@ -0,0 +1,276 @@ +/** + * @file Goddess equipment panel for managing goddess relics, vestments, and sigils. + * @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 -- GoddessEquipmentCard has many conditional render paths */ + +import { type JSX, useState } from "react"; +import { useGame } from "../../context/gameContext.js"; +import type { GoddessEquipment, GoddessEquipmentType } from "@elysium/types"; + +const rarityColour: Record = { + common: "#9e9e9e", + epic: "#9c27b0", + legendary: "#ff9800", + rare: "#2196f3", +}; + +const rarityLabel: Record = { + common: "Common", + epic: "Epic", + legendary: "Legendary", + rare: "Rare", +}; + +/** + * Computes a human-readable bonus description for a goddess equipment item. + * @param item - The goddess equipment item. + * @returns The formatted bonus description string. + */ +const bonusDescription = (item: GoddessEquipment): string => { + const parts: Array = []; + if (item.bonus.prayersMultiplier !== undefined) { + const pct = Math.round((item.bonus.prayersMultiplier - 1) * 100); + parts.push(`+${String(pct)}% Prayers/s`); + } + if (item.bonus.combatMultiplier !== undefined) { + const pct = Math.round((item.bonus.combatMultiplier - 1) * 100); + parts.push(`+${String(pct)}% Disciple Combat`); + } + if (item.bonus.divinityMultiplier !== undefined) { + const pct = Math.round((item.bonus.divinityMultiplier - 1) * 100); + parts.push(`+${String(pct)}% Divinity/Consecration`); + } + return parts.join(", "); +}; + +/** + * Formats a goddess equipment cost as a readable string. + * @param cost - The cost object with prayers, divinity, and stardust. + * @param cost.prayers - The prayers component of the cost. + * @param cost.divinity - The divinity component of the cost. + * @param cost.stardust - The stardust component of the cost. + * @param formatNumber - The number formatting utility function. + * @returns The formatted cost string. + */ +const costLabel = ( + cost: { prayers: number; divinity: number; stardust: number }, + formatNumber: (n: number)=> string, +): string => { + const parts: Array = []; + if (cost.prayers > 0) { + parts.push(`🙏 ${formatNumber(cost.prayers)}`); + } + if (cost.divinity > 0) { + parts.push(`✨ ${formatNumber(cost.divinity)}`); + } + if (cost.stardust > 0) { + parts.push(`⭐ ${formatNumber(cost.stardust)}`); + } + return parts.join(" "); +}; + +interface GoddessEquipmentCardProperties { + readonly item: GoddessEquipment; + readonly prayers: number; + readonly divinity: number; + readonly stardust: number; + readonly formatNumber: (n: number)=> string; +} + +/** + * Renders a single goddess equipment card with buy/equip actions. + * @param props - The card properties. + * @param props.item - The goddess equipment data to display. + * @param props.prayers - The player's current prayers balance. + * @param props.divinity - The player's current divinity balance. + * @param props.stardust - The player's current stardust balance. + * @param props.formatNumber - The number formatting utility function. + * @returns The JSX element. + */ +const GoddessEquipmentCard = ({ + item, + prayers, + divinity, + stardust, + formatNumber, +}: GoddessEquipmentCardProperties): JSX.Element => { + const { buyGoddessEquipment, equipGoddessItem } = useGame(); + + const canAfford = item.cost !== undefined + && prayers >= item.cost.prayers + && divinity >= item.cost.divinity + && stardust >= item.cost.stardust; + + function handleBuy(): void { + buyGoddessEquipment(item.id); + } + + function handleEquip(): void { + equipGoddessItem(item.id); + } + + let typeEmoji = "🔯"; + if (item.type === "relic") { + typeEmoji = "📿"; + } else if (item.type === "vestment") { + typeEmoji = "👘"; + } + + const equippedClass = item.equipped + ? " equipped" + : ""; + const ownedClass = item.owned && !item.equipped + ? " owned" + : ""; + const lockedClass = item.owned + ? "" + : " locked"; + const cardClassName = `goddess-equipment-card rarity-${item.rarity}${equippedClass}${ownedClass}${lockedClass}`; + + return ( +
+
+ {typeEmoji} + {item.name} + + {rarityLabel[item.rarity]} + +
+

{item.description}

+

{bonusDescription(item)}

+ {item.setId === undefined + ? null + :

{"Set: "}{item.setId}

} +
+ {item.owned && item.equipped + ? {"✅ Equipped"} + : null} + {item.owned && !item.equipped + ? + : null} + {!item.owned && item.cost !== undefined + ? + : null} + {!item.owned && item.cost === undefined + ? {"🎲 Boss Drop Only"} + : null} +
+
+ ); +}; + +type TabFilter = "all" | GoddessEquipmentType; + +/** + * Renders the goddess equipment panel, displaying all relics, vestments, and sigils. + * @returns The JSX element. + */ +export const GoddessEquipmentPanel = (): JSX.Element => { + const { state, formatNumber } = useGame(); + const [ activeTab, setActiveTab ] = useState("all"); + + if (state === null) { + return

{"Loading..."}

; + } + + const prayers = state.resources.prayers ?? 0; + const divinity = state.resources.divinity ?? 0; + const stardust = state.resources.stardust ?? 0; + + const equipment = state.goddess?.equipment ?? []; + + const filteredEquipment = activeTab === "all" + ? equipment + : equipment.filter((item) => { + return item.type === activeTab; + }); + + const tabs: Array<{ id: TabFilter; label: string }> = [ + { id: "all", label: "All" }, + { id: "relic", label: "📿 Relics" }, + { id: "vestment", label: "👘 Vestments" }, + { id: "sigil", label: "🔯 Sigils" }, + ]; + + return ( +
+
+ + {"🙏 Prayers: "} + {formatNumber(prayers)} + + + {"✨ Divinity: "} + {formatNumber(divinity)} + + + {"⭐ Stardust: "} + {formatNumber(stardust)} + +
+
+ {tabs.map((tab) => { + function handleTabClick(): void { + setActiveTab(tab.id); + } + return ( + + ); + })} +
+
+ {filteredEquipment.map((item) => { + return ( + + ); + })} + {filteredEquipment.length === 0 + ?

+ {"No equipment in this category yet."} +

+ : null} +
+
+ ); +}; diff --git a/apps/web/src/components/game/goddessExplorationPanel.tsx b/apps/web/src/components/game/goddessExplorationPanel.tsx new file mode 100644 index 0000000..451313c --- /dev/null +++ b/apps/web/src/components/game/goddessExplorationPanel.tsx @@ -0,0 +1,437 @@ +/** + * @file Goddess exploration panel component for exploring divine areas and collecting sacred 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 */ +/* eslint-disable max-lines -- Exploration panel requires many render paths and result display */ +/* eslint-disable max-statements -- Component function requires many state declarations and handlers */ +import { type JSX, useEffect, useRef, useState } from "react"; +import { checkGoddessExplorationClaimable } from "../../api/client.js"; +import { useGame } from "../../context/gameContext.js"; +// eslint-disable-next-line stylistic/max-len -- import path cannot be shortened +import { GODDESS_EXPLORATION_AREAS } from "../../data/goddessExplorationAreas.js"; +import { cdnImage } from "../../utils/cdn.js"; +import type { + GoddessExploreClaimableResponse, + GoddessExploreCollectResponse, +} 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. + * Uses endsAt (server-computed) when available to avoid client/server clock drift. + * @param endsAt - The server-computed completion timestamp, if available. + * @param startedAt - The timestamp when exploration started. + * @param durationSeconds - The total duration in seconds. + * @returns The remaining seconds. + */ +const timeRemaining = ( + endsAt: number | undefined, + startedAt: number, + durationSeconds: number, +): number => { + if (endsAt !== undefined) { + return Math.max(0, (endsAt - Date.now()) / 1000); + } + const elapsed = (Date.now() - startedAt) / 1000; + return Math.max(0, durationSeconds - elapsed); +}; + +interface CollectResult { + areaId: string; + response: GoddessExploreCollectResponse; +} + +/** + * Renders the goddess exploration panel for managing divine area explorations. + * @returns The JSX element. + */ +const GoddessExplorationPanel = (): JSX.Element => { + const { + state, + startGoddessExploration, + collectGoddessExploration, + formatNumber, + } = useGame(); + const [ activeZoneId, setActiveZoneId ] = useState(() => { + return ( + sessionStorage.getItem("elysium_goddess_explore_zone") + ?? "goddess_celestial_garden" + ); + }); + const [ pendingAreaId, setPendingAreaId ] = useState(null); + const [ lastResult, setLastResult ] = useState(null); + const [ claimableAreaIds, setClaimableAreaIds ] + = useState>(new Set()); + + const stateReference = useRef(state); + stateReference.current = state; + + const claimableReference = useRef(claimableAreaIds); + claimableReference.current = claimableAreaIds; + + useEffect(() => { + const pollClaimable = async(): Promise => { + const currentState = stateReference.current; + if (currentState === null) { + return; + } + const inProgressArea = currentState.goddess?.exploration.areas.find( + (a) => { + return a.status === "in_progress"; + }, + ); + if (inProgressArea === undefined) { + return; + } + if (claimableReference.current.has(inProgressArea.id)) { + return; + } + const areaData = GODDESS_EXPLORATION_AREAS.find((a) => { + return a.id === inProgressArea.id; + }); + if (areaData === undefined) { + return; + } + const remaining = timeRemaining( + inProgressArea.endsAt, + inProgressArea.startedAt ?? 0, + areaData.durationSeconds, + ); + if (remaining > 0) { + return; + } + const result: GoddessExploreClaimableResponse + = await checkGoddessExplorationClaimable(inProgressArea.id); + if (result.claimable) { + setClaimableAreaIds((previous) => { + return new Set([ ...previous, inProgressArea.id ]); + }); + } + }; + + const intervalId = setInterval(() => { + void pollClaimable(); + }, 1000); + + return (): void => { + clearInterval(intervalId); + }; + }, []); + + if (state === null) { + return ( +
+

{"Loading..."}

+
+ ); + } + + const { goddess } = state; + const explorationState = goddess?.exploration; + const goddessZones = goddess?.zones ?? []; + + const activeZone = goddessZones.find((zone) => { + return zone.id === activeZoneId; + }); + const zoneIsLocked = activeZone?.status === "locked"; + + const zoneAreas = GODDESS_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 { + setPendingAreaId(areaId); + try { + await startGoddessExploration(areaId); + } finally { + setPendingAreaId(null); + } + } + + async function handleCollect(areaId: string): Promise { + setPendingAreaId(areaId); + try { + const result = await collectGoddessExploration(areaId); + setLastResult({ areaId: areaId, response: result }); + setClaimableAreaIds((previous) => { + const next = new Set(previous); + next.delete(areaId); + return next; + }); + } finally { + setPendingAreaId(null); + } + } + + function handleDismissResult(): void { + setLastResult(null); + } + + function handleZoneSelect(id: string): void { + setActiveZoneId(id); + setLastResult(null); + sessionStorage.setItem("elysium_goddess_explore_zone", id); + } + + const prayersChange = lastResult?.response.event?.prayersChange ?? 0; + const discipleLostCount + = lastResult?.response.event?.discipleLostCount ?? 0; + + return ( +
+
+

{"🗺️ Divine Exploration"}

+
+ + {lastResult === null + ? null + :
+ + {lastResult.response.foundNothing + ?

+ {lastResult.response.nothingMessage} +

+ : <> + {lastResult.response.event === null + ? null + :

+ {lastResult.response.event.text} +

+ } +
+ {prayersChange !== 0 + && 0 + ? "" + : "negative"}`} + > + {"🙏 "} + {prayersChange > 0 + ? "+" + : ""} + {formatNumber(prayersChange)} + {" prayers"} + + } + {discipleLostCount > 0 + && + {"👤 -"} + {formatNumber(discipleLostCount)} + {" disciples lost"} + + } + {lastResult.response.event?.materialGained !== null + && lastResult.response.event?.materialGained !== undefined + ? + {"📦 +"} + {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)"} + + : null} + {lastResult.response.materialsFound.map((foundMaterial) => { + return ( + + {"📦 +"} + {foundMaterial.quantity}{" "} + {foundMaterial.materialId.replaceAll("_", " ")} + + ); + })} +
+ + } +
+ } + +
+ {goddessZones.map((zone) => { + const isLocked = zone.status === "locked"; + + function handleZoneClick(): void { + handleZoneSelect(zone.id); + } + + return ( + + ); + })} +
+ + {zoneIsLocked + ?
+

{"🔒 This divine zone is locked."}

+
+ : null + } + +
+ {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 endsAt = areaState?.endsAt; + const isReady + = status === "in_progress" + && claimableAreaIds.has(area.id); + const isPending = pendingAreaId === area.id; + + function handleStartClick(): void { + void handleStart(area.id); + } + function handleCollectClick(): void { + void handleCollect(area.id); + } + + return ( +
+ {area.name} +
+

+ {area.name} + {areaState?.completedOnce === true + ? {" 📖"} + : null} +

+

{area.description}

+ + {"⏱️ "} + {formatDuration(area.durationSeconds)} + +
+
+ {status === "locked" + && {"🔒 Locked"} + } + {status === "available" + && + } + {status === "in_progress" && !isReady + && + {"⏳ "} + {/* eslint-disable-next-line stylistic/max-len -- long call cannot be shortened */} + {formatDuration(Math.ceil(timeRemaining(endsAt, startedAt, area.durationSeconds)))} + {" remaining"} + + } + {status === "in_progress" && isReady + ? + : null} +
+
+ ); + })} + {zoneAreas.length === 0 + &&

+ {"No exploration areas in this zone."} +

+ } +
+
+ ); +}; + +export { GoddessExplorationPanel }; diff --git a/apps/web/src/components/game/goddessQuestsPanel.tsx b/apps/web/src/components/game/goddessQuestsPanel.tsx new file mode 100644 index 0000000..2dc233f --- /dev/null +++ b/apps/web/src/components/game/goddessQuestsPanel.tsx @@ -0,0 +1,265 @@ +/** + * @file Read-only panel displaying goddess quests grouped by zone. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +/* eslint-disable max-lines-per-function -- Complex component with many render paths */ +/* eslint-disable react/no-multi-comp -- QuestCard sub-component is tightly coupled */ +import { useState, type JSX } from "react"; +import { useGame } from "../../context/gameContext.js"; +import type { + GoddessQuest, + GoddessQuestReward, + GoddessZone, +} 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`; +}; + +/** + * Returns a human-readable label string for a goddess quest reward. + * @param reward - The reward to describe. + * @param formatNumber - The number formatter function. + * @returns The label string, or an empty string for unknown types. + */ +const getRewardLabel = ( + reward: GoddessQuestReward, + formatNumber: (value: number)=> string, +): string => { + if (reward.type === "prayers") { + return `🙏 ${formatNumber(reward.amount ?? 0)} Prayers`; + } + if (reward.type === "divinity") { + return `✨ ${formatNumber(reward.amount ?? 0)} Divinity`; + } + if (reward.type === "stardust") { + return `⭐ ${formatNumber(reward.amount ?? 0)} Stardust`; + } + if (reward.type === "upgrade") { + return "🔓 Upgrade Unlocked"; + } + if (reward.type === "disciple") { + return "👤 New Disciple Tier"; + } + return "🛡️ Equipment Unlocked"; +}; + +interface GoddessQuestCardProperties { + readonly quest: GoddessQuest; + readonly unlockHint: string | undefined; + readonly zoneIsOpen: boolean; +} + +/** + * Renders a single goddess quest card (read-only). + * @param props - The component properties. + * @param props.quest - The goddess quest to display. + * @param props.unlockHint - The name of the prerequisite quest, if locked. + * @param props.zoneIsOpen - Whether the quest's zone is currently unlocked. + * @returns The JSX element. + */ +const GoddessQuestCard = ({ + quest, + unlockHint, + zoneIsOpen, +}: GoddessQuestCardProperties): JSX.Element => { + const { formatNumber } = useGame(); + + return ( +
+
+

{quest.name}

+

{quest.description}

+

+ {"⏱ "} + {formatDuration(quest.durationSeconds)} +

+
+ {quest.rewards.map((reward, rewardIndex) => { + return + {getRewardLabel(reward, formatNumber)} + ; + })} +
+
+
+ {quest.status === "locked" && !zoneIsOpen + && {"🔒 Zone Locked"} + } + {quest.status === "locked" && zoneIsOpen + ? <> + {"🔒 Locked"} + {unlockHint !== undefined + &&

+ {"📜 Complete: "} + {unlockHint} +

+ } + + : null + } + {quest.status === "available" + && {"📋 Available"} + } + {quest.status === "active" + && {"⏳ In Progress"} + } + {quest.status === "completed" + && {"✅ Completed"} + } +
+
+ ); +}; + +/** + * Renders the goddess quests panel with zone selection and quest list. + * @returns The JSX element. + */ +const GoddessQuestsPanel = (): JSX.Element => { + const { state } = useGame(); + const [ activeZoneId, setActiveZoneId ] = useState(() => { + return sessionStorage.getItem("elysium_goddess_quest_zone") + ?? "goddess_celestial_garden"; + }); + + if (state === null) { + return ( +
+

{"Loading..."}

+
+ ); + } + + const goddessState = state.goddess; + if (goddessState === undefined) { + return ( +
+

{"Goddess expansion not yet unlocked."}

+
+ ); + } + + const { zones, quests } = goddessState; + + const activeZone = zones.find((zone: GoddessZone) => { + return zone.id === activeZoneId; + }); + const zoneIsOpen = activeZone?.status === "unlocked"; + + const zoneQuests = quests.filter((quest: GoddessQuest) => { + return quest.zoneId === activeZoneId; + }); + + const questNameById = new Map( + quests.map((quest: GoddessQuest) => { + return [ quest.id, quest.name ]; + }), + ); + + const getUnlockHint = (quest: GoddessQuest): string | undefined => { + if (quest.status !== "locked" || quest.prerequisiteIds.length === 0) { + return undefined; + } + const [ prereqId ] = quest.prerequisiteIds; + if (prereqId === undefined) { + return undefined; + } + return questNameById.get(prereqId); + }; + + function handleZoneSelect(zoneId: string): void { + setActiveZoneId(zoneId); + sessionStorage.setItem("elysium_goddess_quest_zone", zoneId); + } + + const completedCount = zoneQuests.filter((quest: GoddessQuest) => { + return quest.status === "completed"; + }).length; + + return ( +
+

{"Goddess Quests"}

+
+ {zones.map((zone: GoddessZone) => { + function handleClick(): void { + handleZoneSelect(zone.id); + } + return ; + })} +
+ {activeZone !== undefined + &&
+

{activeZone.description}

+

+ {String(completedCount)} + {" / "} + {String(zoneQuests.length)} + {" quests completed"} +

+ {activeZone.status === "locked" + &&

+ {"🔒 This zone is locked. Defeat the required goddess boss"} + {" to unlock it."} +

+ } +
+ } +
+ {zoneQuests.length === 0 + ?

{"No quests in this zone."}

+ : zoneQuests.map((quest: GoddessQuest) => { + return ; + }) + } +
+
+ ); +}; + +export { GoddessQuestsPanel }; diff --git a/apps/web/src/components/game/goddessUpgradesPanel.tsx b/apps/web/src/components/game/goddessUpgradesPanel.tsx new file mode 100644 index 0000000..949516b --- /dev/null +++ b/apps/web/src/components/game/goddessUpgradesPanel.tsx @@ -0,0 +1,267 @@ +/** + * @file Goddess upgrades panel for purchasing goddess-realm 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 -- Upgrade card has inherently high branching across categories */ + +import { useGame } from "../../context/gameContext.js"; +import type { GoddessUpgrade } from "@elysium/types"; +import type { JSX } from "react"; + +/** + * Formats a goddess upgrade cost as a readable string. + * @param upgrade - The goddess upgrade. + * @param formatNumber - The number formatting utility function. + * @returns The formatted cost string. + */ +const costLabel = ( + upgrade: GoddessUpgrade, + formatNumber: (n: number)=> string, +): string => { + const parts: Array = []; + if (upgrade.costPrayers > 0) { + parts.push(`🙏 ${formatNumber(upgrade.costPrayers)}`); + } + if (upgrade.costDivinity > 0) { + parts.push(`✨ ${formatNumber(upgrade.costDivinity)}`); + } + if (upgrade.costStardust > 0) { + parts.push(`⭐ ${formatNumber(upgrade.costStardust)}`); + } + return parts.length > 0 + ? parts.join(" ") + : "Free"; +}; + +/** + * Returns a human-readable label for a goddess upgrade target. + * @param target - The upgrade target string. + * @returns The display label. + */ +const targetLabel = (target: GoddessUpgrade["target"]): string => { + const labels: Record = { + boss: "Boss", + consecration: "Consecration", + disciple: "Disciple", + global: "Global", + prayers: "Prayers", + }; + return labels[target]; +}; + +interface GoddessUpgradeCardProperties { + readonly upgrade: GoddessUpgrade; + readonly prayers: number; + readonly divinity: number; + readonly stardust: number; + readonly formatNumber: (n: number)=> string; +} + +/** + * Renders a single goddess upgrade card. + * @param props - The card properties. + * @param props.upgrade - The goddess upgrade data. + * @param props.prayers - The player's current prayers balance. + * @param props.divinity - The player's current divinity balance. + * @param props.stardust - The player's current stardust balance. + * @param props.formatNumber - The number formatting utility function. + * @returns The JSX element. + */ +const GoddessUpgradeCard = ({ + upgrade, + prayers, + divinity, + stardust, + formatNumber, +}: GoddessUpgradeCardProperties): JSX.Element => { + const { buyGoddessUpgrade } = useGame(); + + const canAfford + = prayers >= upgrade.costPrayers + && divinity >= upgrade.costDivinity + && stardust >= upgrade.costStardust; + + async function handleBuy(): Promise { + await buyGoddessUpgrade(upgrade.id); + } + + const multiplierPct = Math.round((upgrade.multiplier - 1) * 100); + + if (upgrade.purchased) { + return ( +
+
+ + {"✅ "} + {upgrade.name} + + + {targetLabel(upgrade.target)} + +
+

{upgrade.description}

+

{`×${String(upgrade.multiplier)} (+${String(multiplierPct)}%)`}

+
+ ); + } + + if (upgrade.unlocked) { + return ( +
+
+ {upgrade.name} + + {targetLabel(upgrade.target)} + +
+

{upgrade.description}

+

{`×${String(upgrade.multiplier)} (+${String(multiplierPct)}%)`}

+ {upgrade.discipleId === undefined + ? null + :

+ {"🧎 Disciple: "} + {upgrade.discipleId} +

+ } + +
+ ); + } + + return ( +
+
+ {"🔒 ???"} + + {targetLabel(upgrade.target)} + +
+

{"Not yet unlocked."}

+
+ ); +}; + +/** + * Renders the goddess upgrades panel, displaying all available and purchased upgrades. + * @returns The JSX element. + */ +export const GoddessUpgradesPanel = (): JSX.Element => { + const { state, formatNumber } = useGame(); + + if (state === null) { + return

{"Loading..."}

; + } + + const prayers = state.resources.prayers ?? 0; + const divinity = state.resources.divinity ?? 0; + const stardust = state.resources.stardust ?? 0; + + const upgrades = state.goddess?.upgrades ?? []; + + 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 && !upgrade.purchased; + }); + + return ( +
+
+ + {"🙏 Prayers: "} + {formatNumber(prayers)} + + + {"✨ Divinity: "} + {formatNumber(divinity)} + + + {"⭐ Stardust: "} + {formatNumber(stardust)} + +
+ {available.length > 0 + ?
+

{"Available Upgrades"}

+
+ {available.map((upgrade) => { + return ( + + ); + })} +
+
+ : null} + {locked.length > 0 + ?
+

{"Locked Upgrades"}

+
+ {locked.map((upgrade) => { + return ( + + ); + })} +
+
+ : null} + {purchased.length > 0 + ?
+

{"Purchased Upgrades"}

+
+ {purchased.map((upgrade) => { + return ( + + ); + })} +
+
+ : null} + {upgrades.length === 0 + ?

{"No goddess upgrades available yet."}

+ : null} +
+ ); +}; diff --git a/apps/web/src/components/game/goddessZonesPanel.tsx b/apps/web/src/components/game/goddessZonesPanel.tsx new file mode 100644 index 0000000..9c98de1 --- /dev/null +++ b/apps/web/src/components/game/goddessZonesPanel.tsx @@ -0,0 +1,154 @@ +/** + * @file Goddess Zones panel — read-only view of all divine realms. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Complex panel with zone grid rendering */ +/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */ + +import { useGame } from "../../context/gameContext.js"; +import type { GoddessZone } from "@elysium/types"; +import type { JSX } from "react"; + +interface ZoneCardProperties { + readonly zone: GoddessZone; + readonly isLocked: boolean; + readonly unlockBossName: string | undefined; + readonly unlockQuestName: string | undefined; +} + +/** + * Renders a single goddess zone card. + * @param props - The zone card properties. + * @param props.zone - The zone data. + * @param props.isLocked - Whether this zone is currently locked. + * @param props.unlockBossName - Name of the boss required to unlock, if any. + * @param props.unlockQuestName - Name of the quest required to unlock, if any. + * @returns The JSX element. + */ +const GoddessZoneCard = ({ + zone, + isLocked, + unlockBossName, + unlockQuestName, +}: ZoneCardProperties): JSX.Element => { + return ( +
+
+ +

{zone.name}

+ {isLocked + ? {"🔒"} + : null} +
+ +

{zone.description}

+ + {isLocked + && (unlockBossName !== undefined || unlockQuestName !== undefined) + ?
+

{"Unlock requirements:"}

+ {unlockBossName === undefined + ? null + :

+ {"⚔️ Defeat: "} + {unlockBossName} +

+ } + {unlockQuestName === undefined + ? null + :

+ {"📜 Complete: "} + {unlockQuestName} +

+ } +
+ : null} + + {isLocked + ? null + : {"✨ Unlocked"} + } +
+ ); +}; + +/** + * Renders the Goddess Zones panel showing all 18 divine realms. + * @returns The JSX element. + */ +const GoddessZonesPanel = (): JSX.Element => { + const { state } = useGame(); + + if (state === null) { + return ( +
+

{"Loading..."}

+
+ ); + } + + const { goddess } = state; + + if (goddess === undefined) { + return ( +
+

{"The Goddess expansion is not yet unlocked."}

+
+ ); + } + + const { bosses: goddessBosses, quests: goddessQuests, zones } = goddess; + + const defeatedBossIds = new Set( + goddessBosses. + filter((boss) => { + return boss.status === "defeated"; + }). + map((boss) => { + return boss.id; + }), + ); + + return ( +
+
+

{"🌟 Goddess Zones"}

+
+ +
+ {zones.map((zone) => { + const isLocked = zone.unlockBossId !== null + && !defeatedBossIds.has(zone.unlockBossId); + + const unlockBoss = zone.unlockBossId === null + ? undefined + : goddessBosses.find((boss) => { + return boss.id === zone.unlockBossId; + }); + + const unlockQuest = zone.unlockQuestId === null + ? undefined + : goddessQuests.find((quest) => { + return quest.id === zone.unlockQuestId; + }); + + return ( + + ); + })} +
+
+ ); +}; + +export { GoddessZonesPanel }; diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index 7da5708..656a1ea 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -16,8 +16,12 @@ import { type Achievement, type ApotheosisResponse, type BossChallengeResponse, + type ConsecrationResponse, + type EnlightenmentResponse, type ExploreCollectResponse, type GameState, + type GoddessBossChallengeResponse, + type GoddessExploreCollectResponse, type LoginBonusResult, type NumberFormat, type Quest, @@ -38,12 +42,20 @@ import { } from "react"; import { achieveApotheosis as achieveApotheosisApi, + buyConsecrationUpgrade as buyConsecrationUpgradeApi, buyEchoUpgrade as buyEchoUpgradeApi, + buyEnlightenmentUpgrade as buyEnlightenmentUpgradeApi, + buyGoddessUpgrade as buyGoddessUpgradeApi, buyPrestigeUpgrade as buyPrestigeUpgradeApi, challengeBoss as challengeBossApi, + challengeGoddessBoss as challengeGoddessBossApi, collectExploration as collectExplorationApi, + collectGoddessExploration as collectGoddessExplorationApi, + consecrate as consecrateApi, + craftGoddessRecipe as craftGoddessRecipeApi, craftRecipe as craftRecipeApi, debugHardReset as debugHardResetApi, + enlighten as enlightenApi, forceUnlocks as forceUnlocksApi, syncNewContent as syncNewContentApi, loadGame, @@ -51,6 +63,7 @@ import { resetProgress as resetProgressApi, saveGame, startExploration as startExplorationApi, + startGoddessExploration as startGoddessExplorationApi, transcend as transcendApi, } from "../api/client.js"; import { CODEX_ENTRIES } from "../data/codex.js"; @@ -663,6 +676,98 @@ interface GameContextValue { * error). Cleared automatically when a new challenge is initiated. */ bossError: string | null; + + /** + * Challenge a goddess boss — runs full server-side divine combat simulation. + */ + challengeGoddessBoss: (bossId: string)=> Promise; + + /** + * Goddess battle result to display (null when no battle pending). + */ + goddessBattleResult: GoddessBossChallengeResponse | null; + + /** + * Dismiss the goddess battle result modal. + */ + dismissGoddessBattle: ()=> void; + + /** + * Perform a consecration — goddess prestige, earning divinity. + */ + consecrate: ()=> Promise; + + /** + * Whether the consecration milestone toast is currently showing. + */ + showConsecrationToast: boolean; + + /** + * Dismiss the consecration milestone toast. + */ + dismissConsecrationToast: ()=> void; + + /** + * Purchase a consecration upgrade from the divinity shop. + */ + buyConsecrationUpgrade: (upgradeId: string)=> Promise; + + /** + * Perform an enlightenment — goddess transcendence, earning stardust. + */ + enlighten: ()=> Promise; + + /** + * Whether the enlightenment milestone toast is currently showing. + */ + showEnlightenmentToast: boolean; + + /** + * Dismiss the enlightenment milestone toast. + */ + dismissEnlightenmentToast: ()=> void; + + /** + * Purchase an enlightenment upgrade from the stardust shop. + */ + buyEnlightenmentUpgrade: (upgradeId: string)=> Promise; + + /** + * Purchase a goddess upgrade using prayers/divinity/stardust. + */ + buyGoddessUpgrade: (upgradeId: string)=> Promise; + + /** + * Buy one or more disciples (client-side state mutation). + */ + buyGoddessDisciple: (discipleId: string, quantity: number)=> void; + + /** + * Purchase a goddess equipment item (client-side state mutation). + */ + buyGoddessEquipment: (equipmentId: string)=> void; + + /** + * Equip a owned goddess equipment item (auto-unequips same slot). + */ + equipGoddessItem: (equipmentId: string)=> void; + + /** + * Craft a goddess recipe using sacred materials. + */ + craftGoddessRecipe: (recipeId: string)=> Promise; + + /** + * Start a goddess exploration in the given area. + */ + startGoddessExploration: (areaId: string)=> Promise; + + /** + * Collect results of a completed goddess exploration. + */ + collectGoddessExploration: ( + areaId: string, + )=> Promise; } export interface BattleResult { @@ -713,6 +818,10 @@ export const GameProvider = ({ } | null>(null); const [ autoBossError, setAutoBossError ] = useState(null); const [ bossError, setBossError ] = useState(null); + const [ goddessBattleResult, setGoddessBattleResult ] + = useState(null); + const [ showConsecrationToast, setShowConsecrationToast ] = useState(false); + const [ showEnlightenmentToast, setShowEnlightenmentToast ] = useState(false); const syncErrorTimerReference = useRef | null>( null, ); @@ -1935,6 +2044,442 @@ export const GameProvider = ({ } }, []); + const challengeGoddessBoss = useCallback(async(bossId: string) => { + setGoddessBattleResult(null); + try { + const result = await challengeGoddessBossApi({ bossId }); + if (result.signature !== undefined) { + signatureReference.current = result.signature; + localStorage.setItem("elysium_save_signature", result.signature); + } + setState((previous) => { + if (previous?.goddess === undefined) { + return previous; + } + return { + ...previous, + goddess: { + ...previous.goddess, + bosses: previous.goddess.bosses.map((b) => { + return b.id === bossId + ? { ...b, currentHp: result.bossNewHp, status: result.won + ? "defeated" as const + : "available" as const } + : b; + }), + resources: { + ...previous.resources, + prayers: (previous.resources.prayers ?? 0) + + (result.rewards?.prayers ?? 0), + }, + }, + }; + }); + setGoddessBattleResult(result); + } catch (error_: unknown) { + logError("challenge_goddess_boss", error_); + } + }, []); + + const dismissGoddessBattle = useCallback(() => { + setGoddessBattleResult(null); + }, []); + + const consecrate = useCallback(async() => { + try { + const result = await consecrateApi({}); + setShowConsecrationToast(true); + await reload(); + return result; + } catch (error_: unknown) { + logError("consecrate", error_); + throw error_; + } + }, [ reload ]); + + const dismissConsecrationToast = useCallback(() => { + setShowConsecrationToast(false); + }, []); + + const buyConsecrationUpgrade = useCallback(async(upgradeId: string) => { + try { + const result = await buyConsecrationUpgradeApi({ upgradeId }); + setState((previous) => { + if (previous?.goddess === undefined) { + return previous; + } + return { + ...previous, + goddess: { + ...previous.goddess, + consecration: { + ...previous.goddess.consecration, + divinity: result.divinityRemaining, + divinityCombatMultiplier: result.divinityCombatMultiplier, + divinityDisciplesMultiplier: result.divinityDisciplesMultiplier, + divinityPrayersMultiplier: result.divinityPrayersMultiplier, + purchasedUpgradeIds: result.purchasedUpgradeIds, + }, + }, + }; + }); + signatureReference.current = null; + localStorage.removeItem("elysium_save_signature"); + } catch (error_: unknown) { + logError("buy_consecration_upgrade", error_); + } + }, []); + + const enlighten = useCallback(async() => { + try { + const result = await enlightenApi({}); + setShowEnlightenmentToast(true); + await reload(); + return result; + } catch (error_: unknown) { + logError("enlighten", error_); + throw error_; + } + }, [ reload ]); + + const dismissEnlightenmentToast = useCallback(() => { + setShowEnlightenmentToast(false); + }, []); + + const buyEnlightenmentUpgrade = useCallback(async(upgradeId: string) => { + try { + const result = await buyEnlightenmentUpgradeApi({ upgradeId }); + setState((previous) => { + if (previous?.goddess === undefined) { + return previous; + } + return { + ...previous, + goddess: { + ...previous.goddess, + enlightenment: { + ...previous.goddess.enlightenment, + purchasedUpgradeIds: + result.purchasedUpgradeIds, + stardust: + result.stardustRemaining, + stardustCombatMultiplier: + result.stardustCombatMultiplier, + stardustConsecrationDivinityMultiplier: + result.stardustConsecrationDivinityMultiplier, + stardustConsecrationThresholdMultiplier: + result.stardustConsecrationThresholdMultiplier, + stardustMetaMultiplier: + result.stardustMetaMultiplier, + stardustPrayersMultiplier: + result.stardustPrayersMultiplier, + }, + }, + }; + }); + signatureReference.current = null; + localStorage.removeItem("elysium_save_signature"); + } catch (error_: unknown) { + logError("buy_enlightenment_upgrade", error_); + } + }, []); + + const buyGoddessUpgrade = useCallback(async(upgradeId: string) => { + try { + const result = await buyGoddessUpgradeApi({ upgradeId }); + setState((previous) => { + if (previous?.goddess === undefined) { + return previous; + } + return { + ...previous, + goddess: { + ...previous.goddess, + upgrades: previous.goddess.upgrades.map((u) => { + return u.id === upgradeId + ? { ...u, purchased: true } + : u; + }), + }, + resources: { + ...previous.resources, + divinity: result.divinityRemaining, + prayers: result.prayersRemaining, + stardust: result.stardustRemaining, + }, + }; + }); + signatureReference.current = null; + localStorage.removeItem("elysium_save_signature"); + } catch (error_: unknown) { + logError("buy_goddess_upgrade", error_); + } + }, []); + + const buyGoddessDisciple = useCallback( + (discipleId: string, quantity: number) => { + setState((previous) => { + if (previous?.goddess === undefined) { + return previous; + } + const disciple = previous.goddess.disciples.find((d) => { + return d.id === discipleId; + }); + if (disciple === undefined) { + return previous; + } + const geometric = disciple.baseCost * (1 - Math.pow(1.15, quantity)); + const normalised = geometric / (1 - 1.15); + const totalCost = normalised * Math.pow(1.15, disciple.count); + const currentPrayers = previous.resources.prayers ?? 0; + if (currentPrayers < totalCost) { + return previous; + } + return { + ...previous, + goddess: { + ...previous.goddess, + disciples: previous.goddess.disciples.map((d) => { + return d.id === discipleId + ? { ...d, count: d.count + quantity } + : d; + }), + }, + resources: { + ...previous.resources, + prayers: currentPrayers - totalCost, + }, + }; + }); + }, + [], + ); + + const buyGoddessEquipment = useCallback((equipmentId: string) => { + setState((previous) => { + if (previous?.goddess === undefined) { + return previous; + } + const item = previous.goddess.equipment.find((equip) => { + return equip.id === equipmentId; + }); + if (item?.owned === true) { + return previous; + } + const prayers = previous.resources.prayers ?? 0; + const divinity = previous.resources.divinity ?? 0; + if ( + prayers < (item?.cost?.prayers ?? 0) + || divinity < (item?.cost?.divinity ?? 0) + ) { + return previous; + } + const slotAlreadyEquipped = previous.goddess.equipment.find((equip) => { + return equip.equipped && equip.type === item?.type; + }); + return { + ...previous, + goddess: { + ...previous.goddess, + equipment: previous.goddess.equipment.map((equip) => { + if (equip.id === equipmentId) { + return { + ...equip, + equipped: slotAlreadyEquipped === undefined, + owned: true, + }; + } + if (equip.id === slotAlreadyEquipped?.id) { + return { ...equip, equipped: false }; + } + return equip; + }), + }, + resources: { + ...previous.resources, + divinity: divinity - (item?.cost?.divinity ?? 0), + prayers: prayers - (item?.cost?.prayers ?? 0), + }, + }; + }); + }, []); + + const equipGoddessItem = useCallback((equipmentId: string) => { + setState((previous) => { + if (previous?.goddess === undefined) { + return previous; + } + const item = previous.goddess.equipment.find((equip) => { + return equip.id === equipmentId; + }); + if (item?.owned !== true) { + return previous; + } + const slotAlreadyEquipped = previous.goddess.equipment.find((equip) => { + return ( + equip.equipped && equip.type === item.type && equip.id !== equipmentId + ); + }); + return { + ...previous, + goddess: { + ...previous.goddess, + equipment: previous.goddess.equipment.map((equip) => { + if (equip.id === equipmentId) { + return { ...equip, equipped: true }; + } + if (equip.id === slotAlreadyEquipped?.id) { + return { ...equip, equipped: false }; + } + return equip; + }), + }, + }; + }); + }, []); + + const craftGoddessRecipe = useCallback(async(recipeId: string) => { + try { + const result = await craftGoddessRecipeApi({ recipeId }); + signatureReference.current = null; + localStorage.removeItem("elysium_save_signature"); + setState((previous) => { + if (previous?.goddess === undefined) { + return previous; + } + let materials = [ ...previous.goddess.exploration.materials ]; + for (const cost of result.materials) { + materials = materials.map((m) => { + return m.materialId === cost.materialId + ? { ...m, quantity: m.quantity - cost.quantity } + : m; + }); + } + return { + ...previous, + goddess: { + ...previous.goddess, + exploration: { + ...previous.goddess.exploration, + craftedCombatMultiplier: result.craftedCombatMultiplier, + craftedDivinityMultiplier: result.craftedDivinityMultiplier, + craftedPrayersMultiplier: result.craftedPrayersMultiplier, + craftedRecipeIds: [ + ...previous.goddess.exploration.craftedRecipeIds, + result.recipeId, + ], + materials: materials, + }, + }, + }; + }); + } catch (error_: unknown) { + logError("craft_goddess_recipe", error_); + } + }, []); + + const startGoddessExploration = useCallback(async(areaId: string) => { + const response = await startGoddessExplorationApi({ areaId }); + setState((previous) => { + if (previous?.goddess === undefined) { + return previous; + } + return { + ...previous, + goddess: { + ...previous.goddess, + exploration: { + ...previous.goddess.exploration, + areas: previous.goddess.exploration.areas.map((a) => { + return a.id === areaId + ? { + ...a, + endsAt: response.endsAt, + status: "in_progress" as const, + } + : a; + }), + }, + }, + }; + }); + }, []); + + const collectGoddessExploration = useCallback( + async(areaId: string): Promise => { + isSyncingReference.current = true; + const result = await collectGoddessExplorationApi({ areaId }); + signatureReference.current = null; + localStorage.removeItem("elysium_save_signature"); + lastSaveReference.current = Date.now(); + isSyncingReference.current = false; + setState((previous) => { + if (previous?.goddess === undefined) { + return previous; + } + let materials = [ ...previous.goddess.exploration.materials ]; + for (const drop of result.materialsFound) { + const existing = materials.find((m) => { + return m.materialId === drop.materialId; + }); + if (existing === undefined) { + materials = [ + ...materials, + { materialId: drop.materialId, quantity: drop.quantity }, + ]; + } else { + materials = materials.map((m) => { + return m.materialId === drop.materialId + ? { ...m, quantity: m.quantity + drop.quantity } + : m; + }); + } + } + const materialGained = result.event?.materialGained; + if (materialGained !== null && materialGained !== undefined) { + const { materialId, quantity } = materialGained; + const existing = materials.find((m) => { + return m.materialId === materialId; + }); + if (existing === undefined) { + materials = [ ...materials, { materialId, quantity } ]; + } else { + materials = materials.map((m) => { + return m.materialId === materialId + ? { ...m, quantity: m.quantity + quantity } + : m; + }); + } + } + return { + ...previous, + goddess: { + ...previous.goddess, + exploration: { + ...previous.goddess.exploration, + areas: previous.goddess.exploration.areas.map((a) => { + return a.id === areaId + ? { ...a, completedOnce: true, status: "available" as const } + : a; + }), + materials: materials, + }, + }, + resources: { + ...previous.resources, + prayers: Math.max( + 0, + (previous.resources.prayers ?? 0) + + (result.event?.prayersChange ?? 0), + ), + }, + }; + }); + return result; + }, + [], + ); + const startExploration = useCallback(async(areaId: string) => { const response = await startExplorationApi({ areaId }); setState((previous) => { @@ -2492,14 +3037,23 @@ export const GameProvider = ({ battleResult, bossError, buyAdventurer, + buyConsecrationUpgrade, buyEchoUpgrade, + buyEnlightenmentUpgrade, buyEquipment, + buyGoddessDisciple, + buyGoddessEquipment, + buyGoddessUpgrade, buyPrestigeUpgrade, buyUpgrade, challengeBoss, + challengeGoddessBoss, collectExploration, + collectGoddessExploration, completeChapter, completedQuestToasts, + consecrate, + craftGoddessRecipe, craftRecipe, currentSchemaVersion, debugHardReset, @@ -2508,7 +3062,10 @@ export const GameProvider = ({ dismissBattle, dismissCodexEntry, dismissCompletedQuest, + dismissConsecrationToast, + dismissEnlightenmentToast, dismissFailedQuest, + dismissGoddessBattle, dismissLoginBonus, dismissOfflineGold, dismissPrestigeToast, @@ -2516,6 +3073,8 @@ export const GameProvider = ({ dismissTranscendenceToast, enableNotifications, enableSounds, + enlighten, + equipGoddessItem, equipItem, error, failedQuestToasts, @@ -2524,6 +3083,7 @@ export const GameProvider = ({ forceUnlocks, formatInteger, formatNumber, + goddessBattleResult, handleClick, inGuild, isLoading, @@ -2544,9 +3104,12 @@ export const GameProvider = ({ setEnableSounds, setNumberFormat, showApotheosisToast, + showConsecrationToast, + showEnlightenmentToast, showPrestigeToast, showTranscendenceToast, startExploration, + startGoddessExploration, startQuest, state, syncError, @@ -2568,18 +3131,24 @@ export const GameProvider = ({ autoBossLastResult, battleResult, bossError, - completedQuestToasts, - failedQuestToasts, - formatInteger, - formatNumber, buyAdventurer, + buyConsecrationUpgrade, buyEchoUpgrade, + buyEnlightenmentUpgrade, buyEquipment, + buyGoddessDisciple, + buyGoddessEquipment, + buyGoddessUpgrade, buyPrestigeUpgrade, buyUpgrade, challengeBoss, + challengeGoddessBoss, collectExploration, + collectGoddessExploration, completeChapter, + completedQuestToasts, + consecrate, + craftGoddessRecipe, craftRecipe, currentSchemaVersion, debugHardReset, @@ -2588,7 +3157,10 @@ export const GameProvider = ({ dismissBattle, dismissCodexEntry, dismissCompletedQuest, + dismissConsecrationToast, + dismissEnlightenmentToast, dismissFailedQuest, + dismissGoddessBattle, dismissLoginBonus, dismissOfflineGold, dismissPrestigeToast, @@ -2596,13 +3168,19 @@ export const GameProvider = ({ dismissTranscendenceToast, enableNotifications, enableSounds, + enlighten, + equipGoddessItem, equipItem, error, + failedQuestToasts, flushBossLoreToasts, forceSync, - inGuild, forceUnlocks, + formatInteger, + formatNumber, + goddessBattleResult, handleClick, + inGuild, isLoading, isSyncing, lastSavedAt, @@ -2620,9 +3198,12 @@ export const GameProvider = ({ setEnableSounds, setNumberFormat, showApotheosisToast, + showConsecrationToast, + showEnlightenmentToast, showPrestigeToast, showTranscendenceToast, startExploration, + startGoddessExploration, startQuest, state, syncError, diff --git a/apps/web/src/data/goddessCraftingRecipes.ts b/apps/web/src/data/goddessCraftingRecipes.ts new file mode 100644 index 0000000..d233027 --- /dev/null +++ b/apps/web/src/data/goddessCraftingRecipes.ts @@ -0,0 +1,439 @@ +/** + * @file Goddess 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 GODDESS_RECIPES: Array = [ + // ── Celestial Garden ───────────────────────────────────────────────────── + { + bonus: { type: "gold_income", value: 1.1 }, + description: "An amplifier woven from divine petals and crystallised prayer. It does not make prayers louder — it makes them more true.", + id: "prayer_amplifier", + name: "Prayer Amplifier", + requiredMaterials: [ + { materialId: "divine_petal", quantity: 3 }, + { materialId: "prayer_crystal", quantity: 2 }, + ], + zoneId: "goddess_celestial_garden", + }, + { + bonus: { type: "combat_power", value: 1.1 }, + description: "Celestial dust ground into prayer crystals creates a focus that sharpens disciples' divine combat instincts.", + id: "celestial_focus", + name: "Celestial Focus", + requiredMaterials: [ + { materialId: "celestial_dust", quantity: 2 }, + { materialId: "prayer_crystal", quantity: 2 }, + ], + zoneId: "goddess_celestial_garden", + }, + // ── Crystal Sanctum ────────────────────────────────────────────────────── + { + bonus: { type: "gold_income", value: 1.15 }, + description: "Holy ink dissolved in sanctum water, then set with shard dust. Disciples who drink it can recite any prayer they have heard exactly once.", + id: "oracle_potion", + name: "Oracle Potion", + requiredMaterials: [ + { materialId: "holy_ink", quantity: 2 }, + { materialId: "sanctum_shard", quantity: 3 }, + ], + zoneId: "goddess_crystal_sanctum", + }, + { + bonus: { type: "essence_income", value: 1.2 }, + description: "A lens ground from an oracle fragment and set in holy ink. Through it, the divine flow of the universe becomes briefly legible.", + id: "lens_of_truth", + name: "Lens of Truth", + requiredMaterials: [ + { materialId: "oracle_lens_fragment", quantity: 1 }, + { materialId: "holy_ink", quantity: 2 }, + ], + zoneId: "goddess_crystal_sanctum", + }, + // ── Astral Cathedral ───────────────────────────────────────────────────── + { + bonus: { type: "combat_power", value: 1.2 }, + description: "A balm prepared from seraph feathers and choir essence. Applied before battle, it gives disciples the brief sensation of having wings.", + id: "seraph_balm", + name: "Seraph Balm", + requiredMaterials: [ + { materialId: "seraph_feather", quantity: 3 }, + { materialId: "choir_essence", quantity: 2 }, + ], + zoneId: "goddess_astral_cathedral", + }, + { + bonus: { type: "gold_income", value: 1.2 }, + description: "Astral glass dissolved in choir essence creates a vial of concentrated resonance. One drop per disciple per morning.", + id: "astral_resonance_vial", + name: "Astral Resonance Vial", + requiredMaterials: [ + { materialId: "astral_glass", quantity: 2 }, + { materialId: "choir_essence", quantity: 2 }, + ], + zoneId: "goddess_astral_cathedral", + }, + // ── Empyrean Citadel ───────────────────────────────────────────────────── + { + bonus: { type: "gold_income", value: 1.25 }, + description: "Empyrean ore refined with divine alloy dust and pressed into a blessing-coin. The citadel uses these to sanctify each new weapon forged.", + id: "empyrean_blessing", + name: "Empyrean Blessing", + requiredMaterials: [ + { materialId: "empyrean_ore", quantity: 3 }, + { materialId: "divine_alloy", quantity: 2 }, + ], + zoneId: "goddess_empyrean_citadel", + }, + { + bonus: { type: "combat_power", value: 1.25 }, + description: "A tincture prepared from a champion's medal and divine alloy filings. Dosed by champions before major engagements.", + id: "champions_tincture", + name: "Champion's Tincture", + requiredMaterials: [ + { materialId: "celestial_medal", quantity: 1 }, + { materialId: "divine_alloy", quantity: 2 }, + ], + zoneId: "goddess_empyrean_citadel", + }, + // ── Primordial Springs ─────────────────────────────────────────────────── + { + bonus: { type: "gold_income", value: 1.3 }, + description: "Creation water distilled with primordial essence — the closest thing to bottled genesis. Handle as if the universe is watching.", + id: "springs_elixir", + name: "Springs Elixir", + requiredMaterials: [ + { materialId: "creation_water", quantity: 3 }, + { materialId: "primordial_essence", quantity: 2 }, + ], + zoneId: "goddess_primordial_springs", + }, + { + bonus: { type: "essence_income", value: 1.35 }, + description: "A genesis crystal dissolved in creation water — a brew of pure origination. Disciples who drink it briefly remember what it felt like to not yet exist.", + id: "genesis_brew", + name: "Genesis Brew", + requiredMaterials: [ + { materialId: "genesis_crystal", quantity: 1 }, + { materialId: "creation_water", quantity: 2 }, + ], + zoneId: "goddess_primordial_springs", + }, + // ── Eternal Firmament ──────────────────────────────────────────────────── + { + bonus: { type: "combat_power", value: 1.35 }, + description: "A ward inscribed on firmament stone using divine light shards as the writing medium. Disciples who carry it are protected by permanence itself.", + id: "firmament_ward", + name: "Firmament Ward", + requiredMaterials: [ + { materialId: "firmament_stone", quantity: 2 }, + { materialId: "divine_light_shard", quantity: 2 }, + ], + zoneId: "goddess_eternal_firmament", + }, + { + bonus: { type: "essence_income", value: 1.4 }, + description: "An eternity fragment set in divine light — a lantern that never dims because it is powered by time itself. It illuminates things that do not normally have light.", + id: "eternity_lantern", + name: "Eternity Lantern", + requiredMaterials: [ + { materialId: "eternity_fragment", quantity: 1 }, + { materialId: "divine_light_shard", quantity: 3 }, + ], + zoneId: "goddess_eternal_firmament", + }, + // ── Sacred Grove ───────────────────────────────────────────────────────── + { + bonus: { type: "gold_income", value: 1.4 }, + description: "Grove resin mixed with luminous leaf extract and set around sacred heartwood creates a talisman that grows warmer in the presence of sincere prayer.", + id: "grove_talisman", + name: "Grove Talisman", + requiredMaterials: [ + { materialId: "grove_resin", quantity: 3 }, + { materialId: "luminous_leaf", quantity: 2 }, + { materialId: "sacred_heartwood", quantity: 1 }, + ], + zoneId: "goddess_sacred_grove", + }, + { + bonus: { type: "combat_power", value: 1.4 }, + description: "Sacred heartwood carved into a pendant and sealed with grove resin. Disciples who wear it feel the grove's centuries of reverence as personal strength.", + id: "heartwood_pendant", + name: "Heartwood Pendant", + requiredMaterials: [ + { materialId: "sacred_heartwood", quantity: 2 }, + { materialId: "grove_resin", quantity: 2 }, + ], + zoneId: "goddess_sacred_grove", + }, + // ── Luminous Expanse ───────────────────────────────────────────────────── + { + bonus: { type: "essence_income", value: 1.45 }, + description: "Captured radiance compressed around a light core — a beacon that broadcasts the goddess's presence to disciples too far away to feel it unaided.", + id: "radiance_beacon", + name: "Radiance Beacon", + requiredMaterials: [ + { materialId: "captured_radiance", quantity: 3 }, + { materialId: "light_core", quantity: 1 }, + ], + zoneId: "goddess_luminous_expanse", + }, + { + bonus: { type: "gold_income", value: 1.45 }, + description: "A pool of radiance encased in glass and suspended on radiance pool solution — a focusing lens that amplifies divine light into something measurable.", + id: "luminous_prism", + name: "Luminous Prism", + requiredMaterials: [ + { materialId: "radiance_pool", quantity: 2 }, + { materialId: "captured_radiance", quantity: 2 }, + { materialId: "light_core", quantity: 1 }, + ], + zoneId: "goddess_luminous_expanse", + }, + // ── Heavenly Forge ─────────────────────────────────────────────────────── + { + bonus: { type: "combat_power", value: 1.5 }, + description: "Forge scale layered over divine slag and inlaid with a forge gem — a gauntlet that channels the forge's sacred heat into every blow struck.", + id: "forge_gauntlet", + name: "Forge Gauntlet", + requiredMaterials: [ + { materialId: "forge_scale", quantity: 3 }, + { materialId: "divine_slag", quantity: 2 }, + { materialId: "forge_gem", quantity: 1 }, + ], + zoneId: "goddess_heavenly_forge", + }, + { + bonus: { type: "essence_income", value: 1.5 }, + description: "A forge gem set in refined divine slag — a crucible that converts ambient prayer energy directly into essence. Runs continuously once lit.", + id: "divine_crucible", + name: "Divine Crucible", + requiredMaterials: [ + { materialId: "forge_gem", quantity: 2 }, + { materialId: "divine_slag", quantity: 3 }, + ], + zoneId: "goddess_heavenly_forge", + }, + // ── Oracle Sanctum ─────────────────────────────────────────────────────── + { + bonus: { type: "gold_income", value: 1.55 }, + description: "Vision residue suspended in prophecy crystal solution — an elixir that grants disciples fleeting precognitive awareness of lucrative opportunities.", + id: "oracle_elixir", + name: "Oracle Elixir", + requiredMaterials: [ + { materialId: "vision_residue", quantity: 3 }, + { materialId: "prophecy_crystal", quantity: 2 }, + ], + zoneId: "goddess_oracle_sanctum", + }, + { + bonus: { type: "combat_power", value: 1.55 }, + description: "A fate shard mounted on a prophecy crystal matrix — when a disciple holds it before battle, they briefly see the outcome and can choose their approach accordingly.", + id: "fate_compass", + name: "Fate Compass", + requiredMaterials: [ + { materialId: "fate_shard", quantity: 1 }, + { materialId: "prophecy_crystal", quantity: 3 }, + { materialId: "vision_residue", quantity: 2 }, + ], + zoneId: "goddess_oracle_sanctum", + }, + // ── Seraph's Nest ──────────────────────────────────────────────────────── + { + bonus: { type: "essence_income", value: 1.6 }, + description: "Seraph down woven into a mantle and sealed with seraph primary barbs — wearing it is indistinguishable from being held by something enormous and gentle.", + id: "seraph_mantle", + name: "Seraph Mantle", + requiredMaterials: [ + { materialId: "seraph_down", quantity: 4 }, + { materialId: "seraph_primary", quantity: 2 }, + ], + zoneId: "goddess_seraphs_nest", + }, + { + bonus: { type: "combat_power", value: 1.6 }, + description: "An ascended quill carved into a focus rod — it channels divine will with zero resistance, as though the goddess herself were guiding every motion.", + id: "ascended_focus", + name: "Ascended Focus", + requiredMaterials: [ + { materialId: "ascended_quill", quantity: 1 }, + { materialId: "seraph_primary", quantity: 2 }, + { materialId: "seraph_down", quantity: 2 }, + ], + zoneId: "goddess_seraphs_nest", + }, + // ── Divine Archive ─────────────────────────────────────────────────────── + { + bonus: { type: "gold_income", value: 1.65 }, + description: "Celestial vellum stamped with archive seals and pressed into a portable codex — disciples who carry it can access divine knowledge in the field.", + id: "field_codex", + name: "Field Codex", + requiredMaterials: [ + { materialId: "celestial_vellum", quantity: 4 }, + { materialId: "archive_seal", quantity: 2 }, + ], + zoneId: "goddess_divine_archive", + }, + { + bonus: { type: "essence_income", value: 1.65 }, + description: "A living codex page bound with archive seals — it updates itself with new divine knowledge continuously, and disciples gain essence simply by proximity.", + id: "living_tome", + name: "Living Tome", + requiredMaterials: [ + { materialId: "living_codex_page", quantity: 2 }, + { materialId: "archive_seal", quantity: 2 }, + { materialId: "celestial_vellum", quantity: 2 }, + ], + zoneId: "goddess_divine_archive", + }, + // ── Consecrated Depths ─────────────────────────────────────────────────── + { + bonus: { type: "combat_power", value: 1.7 }, + description: "Consecrated stone carved into armour plates and blessed with depth blessing — the armour remembers every prayer spoken over it and adds their weight to the wearer.", + id: "depth_armour", + name: "Depth Armour", + requiredMaterials: [ + { materialId: "consecrated_stone", quantity: 3 }, + { materialId: "depth_blessing", quantity: 2 }, + { materialId: "abyssal_gem", quantity: 1 }, + ], + zoneId: "goddess_consecrated_depths", + }, + { + bonus: { type: "essence_income", value: 1.7 }, + description: "An abyssal gem set in depth blessing solution — a vessel that draws essence from the deepest consecrated places and stores it for release when needed.", + id: "abyssal_vessel", + name: "Abyssal Vessel", + requiredMaterials: [ + { materialId: "abyssal_gem", quantity: 2 }, + { materialId: "depth_blessing", quantity: 3 }, + ], + zoneId: "goddess_consecrated_depths", + }, + // ── Astral Confluence ──────────────────────────────────────────────────── + { + bonus: { type: "gold_income", value: 1.75 }, + description: "Confluence shards woven into a prism using astral harmonics — it refracts divine energy across multiple streams simultaneously, multiplying its effective output.", + id: "confluence_prism", + name: "Confluence Prism", + requiredMaterials: [ + { materialId: "confluence_shard", quantity: 3 }, + { materialId: "astral_harmonic", quantity: 2 }, + ], + zoneId: "goddess_astral_confluence", + }, + { + bonus: { type: "combat_power", value: 1.75 }, + description: "A convergence node bound in astral harmonic resonance — a weapon core that channels the power of seven converging astral streams into a single devastating point.", + id: "convergence_core", + name: "Convergence Core", + requiredMaterials: [ + { materialId: "convergence_node", quantity: 1 }, + { materialId: "astral_harmonic", quantity: 3 }, + { materialId: "confluence_shard", quantity: 2 }, + ], + zoneId: "goddess_astral_confluence", + }, + // ── Celestial Throne ───────────────────────────────────────────────────── + { + bonus: { type: "gold_income", value: 1.8 }, + description: "Throne gold leaf pressed over a sovereignty gem — a signet whose mark carries the full weight of divine authority and cannot be questioned.", + id: "sovereignty_signet", + name: "Sovereignty Signet", + requiredMaterials: [ + { materialId: "throne_gold_leaf", quantity: 3 }, + { materialId: "sovereignty_gem", quantity: 1 }, + ], + zoneId: "goddess_celestial_throne", + }, + { + bonus: { type: "combat_power", value: 1.8 }, + description: "A crown fragment set in sovereignty gem and trimmed with throne gold — the fragment remembers every ruling ever passed from the throne and channels that authority.", + id: "crown_relic", + name: "Crown Relic", + requiredMaterials: [ + { materialId: "crown_fragment", quantity: 1 }, + { materialId: "sovereignty_gem", quantity: 2 }, + { materialId: "throne_gold_leaf", quantity: 2 }, + ], + zoneId: "goddess_celestial_throne", + }, + // ── Infinite Choir ─────────────────────────────────────────────────────── + { + bonus: { type: "essence_income", value: 1.85 }, + description: "Choir notes compressed with divine resonance into a single instrument — playing it fills nearby disciples with the choir's infinite devotion and multiplies their essence output.", + id: "resonance_instrument", + name: "Resonance Instrument", + requiredMaterials: [ + { materialId: "choir_note", quantity: 4 }, + { materialId: "divine_resonance", quantity: 2 }, + ], + zoneId: "goddess_infinite_choir", + }, + { + bonus: { type: "combat_power", value: 1.85 }, + description: "The sacred chord crystallised and mounted on a divine resonance matrix — its vibration disrupts the coherence of any force that opposes the goddess's will.", + id: "sacred_chord_matrix", + name: "Sacred Chord Matrix", + requiredMaterials: [ + { materialId: "sacred_chord", quantity: 1 }, + { materialId: "divine_resonance", quantity: 2 }, + { materialId: "choir_note", quantity: 2 }, + ], + zoneId: "goddess_infinite_choir", + }, + // ── The Veil ───────────────────────────────────────────────────────────── + { + bonus: { type: "essence_income", value: 1.9 }, + description: "Veil thread woven with liminal essence — a cloak that allows the wearer to exist partially outside reality, drawing essence from both sides of the divide.", + id: "liminal_cloak", + name: "Liminal Cloak", + requiredMaterials: [ + { materialId: "veil_thread", quantity: 3 }, + { materialId: "liminal_essence", quantity: 2 }, + ], + zoneId: "goddess_veil", + }, + { + bonus: { type: "combat_power", value: 1.9 }, + description: "A beyond fragment encased in liminal essence and bound with veil thread — a weapon that strikes from a direction reality does not expect and cannot easily defend against.", + id: "veil_piercer", + name: "Veil Piercer", + requiredMaterials: [ + { materialId: "beyond_fragment", quantity: 1 }, + { materialId: "liminal_essence", quantity: 3 }, + { materialId: "veil_thread", quantity: 2 }, + ], + zoneId: "goddess_veil", + }, + // ── Divine Heart ───────────────────────────────────────────────────────── + { + bonus: { type: "gold_income", value: 2 }, + description: "Heart pulses suspended in divine love crystal matrix — an amplifier that broadcasts the goddess's love as a measurable economic force. Disciples work harder when they feel it.", + id: "heart_amplifier", + name: "Heart Amplifier", + requiredMaterials: [ + { materialId: "heart_pulse", quantity: 4 }, + { materialId: "divine_love_crystal", quantity: 2 }, + ], + zoneId: "goddess_divine_heart", + }, + { + bonus: { type: "combat_power", value: 2 }, + description: "Heart ichor distilled with divine love crystal into an essence that transforms willingness into unstoppable force. The goddess's love, weaponised. She approves.", + id: "divine_heart_essence", + name: "Divine Heart Essence", + requiredMaterials: [ + { materialId: "heart_ichor", quantity: 1 }, + { materialId: "divine_love_crystal", quantity: 2 }, + { materialId: "heart_pulse", quantity: 2 }, + ], + zoneId: "goddess_divine_heart", + }, +]; diff --git a/apps/web/src/data/goddessExplorationAreas.ts b/apps/web/src/data/goddessExplorationAreas.ts new file mode 100644 index 0000000..242565d --- /dev/null +++ b/apps/web/src/data/goddessExplorationAreas.ts @@ -0,0 +1,542 @@ +/** + * @file Goddess 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 GoddessExplorationAreaSummary { + id: string; + name: string; + description: string; + zoneId: string; + durationSeconds: number; +} + +export const GODDESS_EXPLORATION_AREAS: Array = [ + // ── Celestial Garden ───────────────────────────────────────────────────── + { + description: "A sun-dappled clearing where divine flowers grow in patterns that mirror the constellation of the goddess's birth.", + durationSeconds: 30, + id: "garden_glade", + name: "The Garden Glade", + zoneId: "goddess_celestial_garden", + }, + { + description: "A vast meadow at the garden's edge where the divine light is diffused into something even novice disciples can bathe in safely.", + durationSeconds: 60, + id: "celestial_meadow", + name: "The Celestial Meadow", + zoneId: "goddess_celestial_garden", + }, + { + description: "A garden path lined with divine blooms whose petals chime softly in a wind that comes from no discernible direction.", + durationSeconds: 90, + id: "chiming_path", + name: "The Chiming Path", + zoneId: "goddess_celestial_garden", + }, + { + description: "The garden's sacred heart, where the oldest divine tree grows — its roots reaching down into the foundation of the goddess's realm.", + durationSeconds: 120, + id: "sacred_tree", + name: "The Sacred Tree", + zoneId: "goddess_celestial_garden", + }, + // ── Crystal Sanctum ────────────────────────────────────────────────────── + { + description: "Corridors of living crystal where divine knowledge has been stored in crystalline form — some of it loud enough to hear if you stand still.", + durationSeconds: 180, + id: "crystal_halls", + name: "The Crystal Halls", + zoneId: "goddess_crystal_sanctum", + }, + { + description: "A reading room where crystallised scripture lines the walls from floor to ceiling. The texts update themselves as new prayers are offered.", + durationSeconds: 240, + id: "scripture_room", + name: "The Scripture Room", + zoneId: "goddess_crystal_sanctum", + }, + { + description: "The sanctum's innermost chamber, where the oracle constructs reside in silent contemplation. Visitors are permitted but not encouraged.", + durationSeconds: 300, + id: "oracle_chamber", + name: "The Oracle's Chamber", + zoneId: "goddess_crystal_sanctum", + }, + { + description: "The sanctum's bell tower, whose crystal bells ring only when a mortal somewhere achieves genuine enlightenment.", + durationSeconds: 420, + id: "bell_tower", + name: "The Bell Tower", + zoneId: "goddess_crystal_sanctum", + }, + // ── Astral Cathedral ───────────────────────────────────────────────────── + { + description: "The high rafters of the cathedral where seraphs rest between duties. The light here has weight and warmth.", + durationSeconds: 360, + id: "seraph_roost", + name: "The Seraph Roost", + zoneId: "goddess_astral_cathedral", + }, + { + description: "The cathedral's crypt, where past seraphs have left offerings — and where the offerings have taken on a life of their own.", + durationSeconds: 480, + id: "cathedral_crypt", + name: "The Cathedral Crypt", + zoneId: "goddess_astral_cathedral", + }, + { + description: "The cathedral's central nave, where the choir sings continuously and the resonance physically alters the space around it.", + durationSeconds: 540, + id: "astral_nave", + name: "The Astral Nave", + zoneId: "goddess_astral_cathedral", + }, + { + description: "The soaring spire above the cathedral — the highest point of any constructed divine structure, touching the boundary between the astral and the divine.", + durationSeconds: 720, + id: "cathedral_spire", + name: "The Cathedral Spire", + zoneId: "goddess_astral_cathedral", + }, + // ── Empyrean Citadel ───────────────────────────────────────────────────── + { + description: "The citadel's weapon-forging district, where divine alloy is hammered into arms for the celestial host. The sound never stops.", + durationSeconds: 600, + id: "forge_district", + name: "The Forge District", + zoneId: "goddess_empyrean_citadel", + }, + { + description: "The outer ramparts of the citadel, where sentinels have stood watch for millennia. The view from here encompasses all of creation.", + durationSeconds: 720, + id: "outer_ramparts", + name: "The Outer Ramparts", + zoneId: "goddess_empyrean_citadel", + }, + { + description: "The great hall where the champions of the citadel are recognised and their deeds recorded on walls that will outlast the universe.", + durationSeconds: 900, + id: "champions_hall", + name: "The Champions Hall", + zoneId: "goddess_empyrean_citadel", + }, + { + description: "The citadel's war council chamber, where every major celestial engagement has been planned. The maps on the walls show conflicts that have not yet happened.", + durationSeconds: 1200, + id: "war_council", + name: "The War Council", + zoneId: "goddess_empyrean_citadel", + }, + // ── Primordial Springs ─────────────────────────────────────────────────── + { + description: "The main basin of the primordial springs — shallow enough to approach, deep enough to understand that you are standing in the source of everything.", + durationSeconds: 900, + id: "springs_basin", + name: "The Springs Basin", + zoneId: "goddess_primordial_springs", + }, + { + description: "A cascade where creation water falls from an impossible height before it reaches the basin — catching the spray is considered a blessing.", + durationSeconds: 1200, + id: "creation_cascade", + name: "The Creation Cascade", + zoneId: "goddess_primordial_springs", + }, + { + description: "The deepest accessible point of the springs — where the creation energy runs so thick it has begun to crystallise.", + durationSeconds: 1800, + id: "genesis_pools", + name: "The Genesis Pools", + zoneId: "goddess_primordial_springs", + }, + { + description: "An island in the springs' centre where nothing has been created yet — pure potential, unformed, waiting. Disciples report feeling deeply uncomfortable and deeply at peace simultaneously.", + durationSeconds: 2400, + id: "unformed_isle", + name: "The Unformed Isle", + zoneId: "goddess_primordial_springs", + }, + // ── Eternal Firmament ──────────────────────────────────────────────────── + { + description: "An observatory built into the firmament's outer edge — the highest point from which the universe can be observed without leaving it.", + durationSeconds: 1800, + id: "eternal_observatory", + name: "The Eternal Observatory", + zoneId: "goddess_eternal_firmament", + }, + { + description: "The firmament's boundary wall — where the divine realm ends and the void begins. Looking over the edge is permitted, but not recommended.", + durationSeconds: 2700, + id: "boundary_wall", + name: "The Boundary Wall", + zoneId: "goddess_eternal_firmament", + }, + { + description: "The firmament's great archive — where every divine event, every prayer, every moment of faith is recorded in materials that cannot be destroyed.", + durationSeconds: 3600, + id: "divine_archive", + name: "The Divine Archive", + zoneId: "goddess_eternal_firmament", + }, + { + description: "A lighthouse built at the firmament's highest point to guide returning divine beings home. Its light has not gone out in recorded history.", + durationSeconds: 4800, + id: "eternal_lighthouse", + name: "The Eternal Lighthouse", + zoneId: "goddess_eternal_firmament", + }, + // ── Sacred Grove ───────────────────────────────────────────────────────── + { + description: "The grove's outermost ring, where ancient trees have grown together into a living canopy that screens out all light save the divine.", + durationSeconds: 3600, + id: "canopy_ring", + name: "The Canopy Ring", + zoneId: "goddess_sacred_grove", + }, + { + description: "A clearing deep in the grove where divine light falls in columns and the ground glows softly with accumulated radiance.", + durationSeconds: 5400, + id: "radiant_clearing", + name: "The Radiant Clearing", + zoneId: "goddess_sacred_grove", + }, + { + description: "The base of the grove's elder tree — a tree so old its roots have grown into the foundations of divinity itself.", + durationSeconds: 7200, + id: "elder_roots", + name: "The Elder Roots", + zoneId: "goddess_sacred_grove", + }, + { + description: "The grove's summit — where the eldest trees have grown so tall their crowns pierce the veil and exist in two realms simultaneously.", + durationSeconds: 10_800, + id: "grove_summit", + name: "The Grove Summit", + zoneId: "goddess_sacred_grove", + }, + // ── Luminous Expanse ───────────────────────────────────────────────────── + { + description: "The shore of the luminous expanse — where solid ground meets the sea of light and the boundary shimmers with captured radiance.", + durationSeconds: 5400, + id: "luminous_shore", + name: "The Luminous Shore", + zoneId: "goddess_luminous_expanse", + }, + { + description: "A deep pool in the expanse where radiance has collected over eons into a liquid form. Wading in it is an act of faith.", + durationSeconds: 7200, + id: "deep_pool", + name: "The Deep Pool", + zoneId: "goddess_luminous_expanse", + }, + { + description: "The absolute centre of the luminous expanse — where all the light originates. The core pulses rhythmically, like breathing.", + durationSeconds: 10_800, + id: "radiance_core", + name: "The Radiance Core", + zoneId: "goddess_luminous_expanse", + }, + { + description: "Columns of pure light rising from the expanse's floor to its ceiling — each one a prayer that became so strong it solidified.", + durationSeconds: 14_400, + id: "prayer_columns", + name: "The Prayer Columns", + zoneId: "goddess_luminous_expanse", + }, + // ── Heavenly Forge ─────────────────────────────────────────────────────── + { + description: "The antechamber of the heavenly forge — where materials are prepared and disciples learn what they are about to witness.", + durationSeconds: 7200, + id: "forge_antechamber", + name: "The Forge Antechamber", + zoneId: "goddess_heavenly_forge", + }, + { + description: "The forge's central burning chamber — divine fire so hot it refines the soul of whatever is placed within it.", + durationSeconds: 10_800, + id: "burning_chamber", + name: "The Burning Chamber", + zoneId: "goddess_heavenly_forge", + }, + { + description: "The cooling vault where newly forged divine items rest — the temperature here is merely scorching, and the rejected gems settle here.", + durationSeconds: 14_400, + id: "cooling_vault", + name: "The Cooling Vault", + zoneId: "goddess_heavenly_forge", + }, + { + description: "The forge master's sanctum — a private workshop where the most powerful divine weapons have been conceived. The blueprints on the walls are weapons that have never needed to be made.", + durationSeconds: 18_000, + id: "forge_master_sanctum", + name: "The Forge Master's Sanctum", + zoneId: "goddess_heavenly_forge", + }, + // ── Oracle Sanctum ─────────────────────────────────────────────────────── + { + description: "The oracle sanctum's entrance hall — lined with prophecies that have already come true, displayed as a reminder of what is possible.", + durationSeconds: 10_800, + id: "prophecy_hall", + name: "The Prophecy Hall", + zoneId: "goddess_oracle_sanctum", + }, + { + description: "The sanctum's vision pool — where oracles submerge themselves to receive prophecy. The water carries the echo of every vision ever seen here.", + durationSeconds: 14_400, + id: "vision_pool", + name: "The Vision Pool", + zoneId: "goddess_oracle_sanctum", + }, + { + description: "The sanctum's sealed vault where unfulfilled prophecies are kept — each one waiting for its moment. The vault hums with potential.", + durationSeconds: 18_000, + id: "prophecy_vault", + name: "The Prophecy Vault", + zoneId: "goddess_oracle_sanctum", + }, + { + description: "The highest oracle tower — where the most powerful seers work, receiving prophecy directly from the goddess without the pool as an intermediary.", + durationSeconds: 25_200, + id: "oracle_tower", + name: "The Oracle Tower", + zoneId: "goddess_oracle_sanctum", + }, + // ── Seraph's Nest ──────────────────────────────────────────────────────── + { + description: "The outer reaches of the seraph's nest — where young seraphs practice flight and lose feathers at a prodigious rate.", + durationSeconds: 14_400, + id: "nesting_grounds", + name: "The Nesting Grounds", + zoneId: "goddess_seraphs_nest", + }, + { + description: "The nest's flight path — a long corridor of open sky where seraphs travel at full speed. Standing on the observation platform is exhilarating.", + durationSeconds: 18_000, + id: "flight_path", + name: "The Flight Path", + zoneId: "goddess_seraphs_nest", + }, + { + description: "The inner nest where the eldest seraphs rest — so old they have transcended their original purpose and exist now as pure radiant being.", + durationSeconds: 25_200, + id: "elder_nest", + name: "The Elder Nest", + zoneId: "goddess_seraphs_nest", + }, + { + description: "The topmost pinnacle of the seraph's nest — where newly ascended seraphs make their first flight into the infinite divine sky.", + durationSeconds: 32_400, + id: "ascension_pinnacle", + name: "The Ascension Pinnacle", + zoneId: "goddess_seraphs_nest", + }, + // ── Divine Archive ─────────────────────────────────────────────────────── + { + description: "The archive's reading room — open to approved disciples, lined with texts that contain everything except what you happen to be looking for.", + durationSeconds: 18_000, + id: "reading_room", + name: "The Reading Room", + zoneId: "goddess_divine_archive", + }, + { + description: "The archive's filing chambers — where seals are applied to documents and every record is formally entered into the divine record.", + durationSeconds: 21_600, + id: "filing_chambers", + name: "The Filing Chambers", + zoneId: "goddess_divine_archive", + }, + { + description: "The restricted stacks — where texts too dangerous, too sacred, or too contradictory to be read by casual visitors are stored.", + durationSeconds: 28_800, + id: "restricted_stacks", + name: "The Restricted Stacks", + zoneId: "goddess_divine_archive", + }, + { + description: "The archive's inner sanctum — where the most fundamental records of existence are kept. The room is aware of visitors and takes notes.", + durationSeconds: 36_000, + id: "archive_inner_sanctum", + name: "The Archive Inner Sanctum", + zoneId: "goddess_divine_archive", + }, + // ── Consecrated Depths ─────────────────────────────────────────────────── + { + description: "The upper consecrated chambers — where generations of devotion have saturated the stone so thoroughly it generates warmth without fire.", + durationSeconds: 21_600, + id: "upper_chambers", + name: "The Upper Chambers", + zoneId: "goddess_consecrated_depths", + }, + { + description: "The sacred springs of the consecrated depths — underground water that has absorbed so much blessing it produces light in complete darkness.", + durationSeconds: 28_800, + id: "sacred_springs", + name: "The Sacred Springs", + zoneId: "goddess_consecrated_depths", + }, + { + description: "The abyssal chamber — the deepest point of the consecrated depths, where gem formations grow in complete darkness fed by divine groundwater.", + durationSeconds: 36_000, + id: "abyssal_chamber", + name: "The Abyssal Chamber", + zoneId: "goddess_consecrated_depths", + }, + { + description: "The heart of the consecrated depths — where the first consecration rite was performed, and where all subsequent rites draw their power from.", + durationSeconds: 46_800, + id: "consecration_heart", + name: "The Consecration Heart", + zoneId: "goddess_consecrated_depths", + }, + // ── Astral Confluence ──────────────────────────────────────────────────── + { + description: "The confluence's outer streams — where astral currents from different zones first meet and begin their complex negotiation of coexistence.", + durationSeconds: 28_800, + id: "outer_streams", + name: "The Outer Streams", + zoneId: "goddess_astral_confluence", + }, + { + description: "The harmonic bridge — a structure built where two major astral streams run parallel, bridging them for easier transit.", + durationSeconds: 36_000, + id: "harmonic_bridge", + name: "The Harmonic Bridge", + zoneId: "goddess_astral_confluence", + }, + { + description: "The confluence point — where seven astral streams meet simultaneously and the combined energy forms something that has no name in any divine language.", + durationSeconds: 50_400, + id: "confluence_point", + name: "The Confluence Point", + zoneId: "goddess_astral_confluence", + }, + { + description: "The still eye at the confluence's centre — where paradoxically the seven streams produce complete stillness. Nothing moves here. Everything is possible.", + durationSeconds: 64_800, + id: "still_eye", + name: "The Still Eye", + zoneId: "goddess_astral_confluence", + }, + // ── Celestial Throne ───────────────────────────────────────────────────── + { + description: "The throne room's antechamber — where petitioners wait to be heard and the air is thick with suppressed hope.", + durationSeconds: 36_000, + id: "throne_antechamber", + name: "The Throne Antechamber", + zoneId: "goddess_celestial_throne", + }, + { + description: "The throne room's gallery — where divine decisions are witnessed and the significance of each ruling is carved into the gallery walls in gold.", + durationSeconds: 50_400, + id: "throne_gallery", + name: "The Throne Gallery", + zoneId: "goddess_celestial_throne", + }, + { + description: "The throne room itself — the seat of divine authority, where the goddess makes her will known. Visitors are rare and never forget it.", + durationSeconds: 72_000, + id: "throne_room", + name: "The Throne Room", + zoneId: "goddess_celestial_throne", + }, + { + description: "The private garden behind the celestial throne — where the goddess retreats between audiences, and where all decisions that will be made have already been decided.", + durationSeconds: 90_000, + id: "private_garden", + name: "The Private Garden", + zoneId: "goddess_celestial_throne", + }, + // ── Infinite Choir ─────────────────────────────────────────────────────── + { + description: "The outer choir stalls — where the newest members of the infinite choir learn the oldest songs. Every mistake is a new prayer.", + durationSeconds: 50_400, + id: "outer_stalls", + name: "The Outer Stalls", + zoneId: "goddess_infinite_choir", + }, + { + description: "The resonance chamber — where the choir's harmonics are amplified and directed. The walls physically vibrate with accumulated song.", + durationSeconds: 64_800, + id: "resonance_chamber", + name: "The Resonance Chamber", + zoneId: "goddess_infinite_choir", + }, + { + description: "The sacred chord vault — where the fundamental harmonics of existence are preserved in crystalline form and protected from degradation.", + durationSeconds: 79_200, + id: "chord_vault", + name: "The Chord Vault", + zoneId: "goddess_infinite_choir", + }, + { + description: "The conductor's podium at the infinite choir's centre — from here, every voice in the infinite song can be heard and directed. The conductor has never stopped.", + durationSeconds: 97_200, + id: "conductors_podium", + name: "The Conductor's Podium", + zoneId: "goddess_infinite_choir", + }, + // ── The Veil ───────────────────────────────────────────────────────────── + { + description: "The veil's outer face — where the divine realm ends in a shimmering boundary that is beautiful from this side and, reportedly, equally beautiful from the other.", + durationSeconds: 64_800, + id: "veil_outer_face", + name: "The Veil's Outer Face", + zoneId: "goddess_veil", + }, + { + description: "The liminal space within the veil itself — where nothing is fully either side, and everything exists in a state of permanent becoming.", + durationSeconds: 86_400, + id: "liminal_space", + name: "The Liminal Space", + zoneId: "goddess_veil", + }, + { + description: "The veil's inner face — where it can be seen from the divine side, and where the fragments of what lies beyond press closest.", + durationSeconds: 108_000, + id: "veil_inner_face", + name: "The Veil's Inner Face", + zoneId: "goddess_veil", + }, + { + description: "The tear in the veil — a wound in the boundary that has been carefully managed for longer than recorded history. Looking through it is the closest to truth any mortal has come.", + durationSeconds: 129_600, + id: "veil_tear", + name: "The Veil Tear", + zoneId: "goddess_veil", + }, + // ── Divine Heart ───────────────────────────────────────────────────────── + { + description: "The outer chamber of the divine heart — where the pulse is felt as a physical pressure and the air tastes of warmth and purpose.", + durationSeconds: 86_400, + id: "heart_outer_chamber", + name: "The Heart's Outer Chamber", + zoneId: "goddess_divine_heart", + }, + { + description: "The love crystal garden — where the divine heart's love has crystallised over aeons into formations of impossible beauty.", + durationSeconds: 108_000, + id: "love_garden", + name: "The Love Crystal Garden", + zoneId: "goddess_divine_heart", + }, + { + description: "The inner sanctum of the divine heart — where the beating is so powerful it can be felt in the bones, and the warmth is the warmth of being loved without condition.", + durationSeconds: 151_200, + id: "heart_inner_sanctum", + name: "The Heart's Inner Sanctum", + zoneId: "goddess_divine_heart", + }, + { + description: "The divine heart itself — the source of all divinity, the origin of the goddess, the first thing that ever was and the last thing that will ever be.", + durationSeconds: 172_800, + id: "divine_heart_itself", + name: "The Divine Heart", + zoneId: "goddess_divine_heart", + }, +]; diff --git a/apps/web/src/data/goddessMaterials.ts b/apps/web/src/data/goddessMaterials.ts new file mode 100644 index 0000000..92d34df --- /dev/null +++ b/apps/web/src/data/goddessMaterials.ts @@ -0,0 +1,409 @@ +/** + * @file Goddess 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 GODDESS_MATERIALS: Array = [ + // ── Celestial Garden ───────────────────────────────────────────────────── + { + description: "Petals from flowers that have never known anything but divine light. They crumble if touched by anything unworthy.", + id: "divine_petal", + name: "Divine Petal", + rarity: "common", + zoneId: "goddess_celestial_garden", + }, + { + description: "Prayer energy that has crystallised over centuries of devotion. Each one holds a fragment of someone's deepest hope.", + id: "prayer_crystal", + name: "Prayer Crystal", + rarity: "common", + zoneId: "goddess_celestial_garden", + }, + { + description: "Dust that falls from the celestial dome above — each mote a fragment of a star that finished its purpose and dissolved.", + id: "celestial_dust", + name: "Celestial Dust", + rarity: "uncommon", + zoneId: "goddess_celestial_garden", + }, + // ── Crystal Sanctum ────────────────────────────────────────────────────── + { + description: "Shards broken from the sanctum walls during divine resonance events. They hum faintly with stored knowledge.", + id: "sanctum_shard", + name: "Sanctum Shard", + rarity: "common", + zoneId: "goddess_crystal_sanctum", + }, + { + description: "Ink distilled from divine light and used to inscribe the sanctum's most sacred texts. It cannot write falsehoods.", + id: "holy_ink", + name: "Holy Ink", + rarity: "uncommon", + zoneId: "goddess_crystal_sanctum", + }, + { + description: "A fragment of an oracle's primary lens, shattered during a vision of catastrophic clarity. The vision was worth it.", + id: "oracle_lens_fragment", + name: "Oracle Lens Fragment", + rarity: "rare", + zoneId: "goddess_crystal_sanctum", + }, + // ── Astral Cathedral ───────────────────────────────────────────────────── + { + description: "A feather shed by a seraph during their first ascension. They shed exactly one. This is the rarest thing most people will ever hold.", + id: "seraph_feather", + name: "Seraph Feather", + rarity: "uncommon", + zoneId: "goddess_astral_cathedral", + }, + { + description: "The concentrated resonance of the celestial choir — bottled by scholars who noticed that it had physical properties.", + id: "choir_essence", + name: "Choir Essence", + rarity: "uncommon", + zoneId: "goddess_astral_cathedral", + }, + { + description: "Material formed where the cathedral's astral structure meets the void. Transparent, harder than diamond, warmer than sunlight.", + id: "astral_glass", + name: "Astral Glass", + rarity: "rare", + zoneId: "goddess_astral_cathedral", + }, + // ── Empyrean Citadel ───────────────────────────────────────────────────── + { + description: "Ore mined from the citadel's deepest foundations — dense with divine potential but raw and unrefined.", + id: "empyrean_ore", + name: "Empyrean Ore", + rarity: "uncommon", + zoneId: "goddess_empyrean_citadel", + }, + { + description: "Empyrean ore refined in the citadel's divine furnaces. The process requires both technical mastery and genuine faith.", + id: "divine_alloy", + name: "Divine Alloy", + rarity: "rare", + zoneId: "goddess_empyrean_citadel", + }, + { + description: "A medal awarded only to champions of the citadel's trials. Fewer than a hundred exist. Each one has a name engraved on the back.", + id: "celestial_medal", + name: "Celestial Medal", + rarity: "rare", + zoneId: "goddess_empyrean_citadel", + }, + // ── Primordial Springs ─────────────────────────────────────────────────── + { + description: "Water drawn directly from the springs of creation. It tastes of nothing. It heals everything. Handle carefully.", + id: "creation_water", + name: "Creation Water", + rarity: "uncommon", + zoneId: "goddess_primordial_springs", + }, + { + description: "The raw essence of creation — the stuff from which everything is made before it decides what to become.", + id: "primordial_essence", + name: "Primordial Essence", + rarity: "rare", + zoneId: "goddess_primordial_springs", + }, + { + description: "A crystal formed spontaneously when creation energy reaches critical density. Each one is unique and has never existed before.", + id: "genesis_crystal", + name: "Genesis Crystal", + rarity: "rare", + zoneId: "goddess_primordial_springs", + }, + // ── Eternal Firmament ──────────────────────────────────────────────────── + { + description: "Stone from the eternal firmament itself — impossibly dense, impossibly enduring. It does not weather. It does not age.", + id: "firmament_stone", + name: "Firmament Stone", + rarity: "rare", + zoneId: "goddess_eternal_firmament", + }, + { + description: "A shard of divine light that has solidified — the kind of light that exists before it is observed, before it is named.", + id: "divine_light_shard", + name: "Divine Light Shard", + rarity: "rare", + zoneId: "goddess_eternal_firmament", + }, + { + description: "A fragment broken from eternity itself during a moment of divine turbulence. It is still vibrating. It will never stop.", + id: "eternity_fragment", + name: "Eternity Fragment", + rarity: "rare", + zoneId: "goddess_eternal_firmament", + }, + // ── Sacred Grove ───────────────────────────────────────────────────────── + { + description: "Resin weeping from the sacred grove's eldest trees — each drop takes decades to form and carries the memory of every prayer offered beneath its branches.", + id: "grove_resin", + name: "Grove Resin", + rarity: "common", + zoneId: "goddess_sacred_grove", + }, + { + description: "A leaf that has absorbed so much divine light it has become semi-translucent, like stained glass grown naturally from a living tree.", + id: "luminous_leaf", + name: "Luminous Leaf", + rarity: "uncommon", + zoneId: "goddess_sacred_grove", + }, + { + description: "Bark shed from the grove's most ancient tree — said to be the first thing the goddess ever touched. No axe can cut it. No fire can burn it.", + id: "sacred_heartwood", + name: "Sacred Heartwood", + rarity: "rare", + zoneId: "goddess_sacred_grove", + }, + // ── Luminous Expanse ───────────────────────────────────────────────────── + { + description: "The ambient radiance of the luminous expanse, captured in small crystalline vessels before it dissipates. Warm to the touch always.", + id: "captured_radiance", + name: "Captured Radiance", + rarity: "common", + zoneId: "goddess_luminous_expanse", + }, + { + description: "Where radiance pools deep enough, it begins to behave like water. This is a vial of that impossible substance.", + id: "radiance_pool", + name: "Radiance Pool", + rarity: "uncommon", + zoneId: "goddess_luminous_expanse", + }, + { + description: "A perfect sphere of compressed luminous energy — formed only at the expanse's absolute centre, where the light meets itself coming back.", + id: "light_core", + name: "Light Core", + rarity: "rare", + zoneId: "goddess_luminous_expanse", + }, + // ── Heavenly Forge ─────────────────────────────────────────────────────── + { + description: "Scale from a celestial creature shed near the forge — tempered by proximity to divine fire into something harder than most metals.", + id: "forge_scale", + name: "Forge Scale", + rarity: "uncommon", + zoneId: "goddess_heavenly_forge", + }, + { + description: "The slag produced when divine alloy is refined to its purest form. Useless for most things. Priceless for the right ones.", + id: "divine_slag", + name: "Divine Slag", + rarity: "uncommon", + zoneId: "goddess_heavenly_forge", + }, + { + description: "A gem formed in the forge's hottest chamber — absorbs heat and releases it as blessing energy over years. Handle with tongs.", + id: "forge_gem", + name: "Forge Gem", + rarity: "rare", + zoneId: "goddess_heavenly_forge", + }, + // ── Oracle Sanctum ─────────────────────────────────────────────────────── + { + description: "The residue left behind when an oracle's vision ends — collected from the floor of the viewing chamber before it evaporates.", + id: "vision_residue", + name: "Vision Residue", + rarity: "common", + zoneId: "goddess_oracle_sanctum", + }, + { + description: "Crystals that form in the minds of oracles during particularly intense visions and are expelled as small shards afterward.", + id: "prophecy_crystal", + name: "Prophecy Crystal", + rarity: "uncommon", + zoneId: "goddess_oracle_sanctum", + }, + { + description: "A shard of pure foresight — carved from the moment between a prophecy being spoken and it being understood. Extremely dangerous to hold for long.", + id: "fate_shard", + name: "Fate Shard", + rarity: "rare", + zoneId: "goddess_oracle_sanctum", + }, + // ── Seraph's Nest ──────────────────────────────────────────────────────── + { + description: "Down from the innermost layer of a seraph's plumage — softer than anything natural, warm as sunlight, impossible to soil.", + id: "seraph_down", + name: "Seraph Down", + rarity: "common", + zoneId: "goddess_seraphs_nest", + }, + { + description: "A primary feather from a seraph's wing — longer than a person is tall, capable of carrying aloft far more than its size suggests.", + id: "seraph_primary", + name: "Seraph Primary", + rarity: "uncommon", + zoneId: "goddess_seraphs_nest", + }, + { + description: "The hollow quill of a fully ascended seraph — said to channel divine will as faithfully as any sacred instrument ever made.", + id: "ascended_quill", + name: "Ascended Quill", + rarity: "rare", + zoneId: "goddess_seraphs_nest", + }, + // ── Divine Archive ─────────────────────────────────────────────────────── + { + description: "Vellum produced from materials that do not exist in the mortal world — can hold text that cannot be written on ordinary parchment.", + id: "celestial_vellum", + name: "Celestial Vellum", + rarity: "common", + zoneId: "goddess_divine_archive", + }, + { + description: "A stamp used to seal the archive's most important documents — its mark cannot be forged and cannot be removed.", + id: "archive_seal", + name: "Archive Seal", + rarity: "uncommon", + zoneId: "goddess_divine_archive", + }, + { + description: "A codex page that has absorbed so much divine knowledge it has become semi-sentient. It resists being filed incorrectly.", + id: "living_codex_page", + name: "Living Codex Page", + rarity: "rare", + zoneId: "goddess_divine_archive", + }, + // ── Consecrated Depths ─────────────────────────────────────────────────── + { + description: "Stone from the deepest consecrated chambers — blessed so thoroughly by generations of ritual that it radiates faint warmth in complete darkness.", + id: "consecrated_stone", + name: "Consecrated Stone", + rarity: "uncommon", + zoneId: "goddess_consecrated_depths", + }, + { + description: "Water from the depths' sacred underground springs — it has been blessed so many times that blessing it again produces light.", + id: "depth_blessing", + name: "Depth Blessing", + rarity: "uncommon", + zoneId: "goddess_consecrated_depths", + }, + { + description: "A gem found only at the absolute lowest point of the consecrated depths — formed from minerals and divine energy in equal parts.", + id: "abyssal_gem", + name: "Abyssal Gem", + rarity: "rare", + zoneId: "goddess_consecrated_depths", + }, + // ── Astral Confluence ──────────────────────────────────────────────────── + { + description: "A shard of ley-material harvested where two astral streams cross — vibrates at two frequencies simultaneously and cannot decide which to settle on.", + id: "confluence_shard", + name: "Confluence Shard", + rarity: "uncommon", + zoneId: "goddess_astral_confluence", + }, + { + description: "The harmonic tone produced when multiple astral streams converge — bottled by scholars with sensitive enough ears to find it before it propagated away.", + id: "astral_harmonic", + name: "Astral Harmonic", + rarity: "uncommon", + zoneId: "goddess_astral_confluence", + }, + { + description: "A knot of astral energy so dense it has become material — formed only at confluence points of seven or more streams. Profoundly stable.", + id: "convergence_node", + name: "Convergence Node", + rarity: "rare", + zoneId: "goddess_astral_confluence", + }, + // ── Celestial Throne ───────────────────────────────────────────────────── + { + description: "Gold leaf beaten so thin it is translucent — used to gild the throne's ceremonial surfaces and shed during every royal audience.", + id: "throne_gold_leaf", + name: "Throne Gold Leaf", + rarity: "uncommon", + zoneId: "goddess_celestial_throne", + }, + { + description: "A gem that fell from the throne's armrest during a momentous divine decision. It carries the weight of that decision.", + id: "sovereignty_gem", + name: "Sovereignty Gem", + rarity: "rare", + zoneId: "goddess_celestial_throne", + }, + { + description: "A fragment of the divine crown — shed when the goddess channels her most absolute authority. Still crackles with that authority.", + id: "crown_fragment", + name: "Crown Fragment", + rarity: "rare", + zoneId: "goddess_celestial_throne", + }, + // ── Infinite Choir ─────────────────────────────────────────────────────── + { + description: "A note from the infinite choir crystallised mid-air — visible proof that sound, given enough devotion, can become matter.", + id: "choir_note", + name: "Choir Note", + rarity: "uncommon", + zoneId: "goddess_infinite_choir", + }, + { + description: "The resonant frequency of the infinite choir, captured in a tuning fork made of condensed praise. Struck, it harmonises everything nearby.", + id: "divine_resonance", + name: "Divine Resonance", + rarity: "rare", + zoneId: "goddess_infinite_choir", + }, + { + description: "The chord that underlies all sacred music — crystallised in a moment of perfect harmony that has not occurred before or since.", + id: "sacred_chord", + name: "Sacred Chord", + rarity: "rare", + zoneId: "goddess_infinite_choir", + }, + // ── The Veil ───────────────────────────────────────────────────────────── + { + description: "A thread of the veil itself — taken from where it has worn thinnest. Still partially transparent. Still partially something else.", + id: "veil_thread", + name: "Veil Thread", + rarity: "uncommon", + zoneId: "goddess_veil", + }, + { + description: "The liminal substance that exists only at the veil's boundary — neither fully divine nor fully void, but something genuinely new.", + id: "liminal_essence", + name: "Liminal Essence", + rarity: "rare", + zoneId: "goddess_veil", + }, + { + description: "A fragment of what lies beyond the veil — contained only by the veil-thread it's wrapped in. Looking at it directly is inadvisable.", + id: "beyond_fragment", + name: "Beyond Fragment", + rarity: "rare", + zoneId: "goddess_veil", + }, + // ── Divine Heart ───────────────────────────────────────────────────────── + { + description: "A pulse of the divine heart made tangible — each one a single beat, still warm, still rhythmic, still alive with purpose.", + id: "heart_pulse", + name: "Heart Pulse", + rarity: "uncommon", + zoneId: "goddess_divine_heart", + }, + { + description: "The pure love of the divine heart, distilled into crystalline form — the most powerful healing agent in existence and the most dangerous to waste.", + id: "divine_love_crystal", + name: "Divine Love Crystal", + rarity: "rare", + zoneId: "goddess_divine_heart", + }, + { + description: "A droplet of ichor from the divine heart itself — the essence of divinity in its most concentrated form. Handle with absolute reverence.", + id: "heart_ichor", + name: "Heart Ichor", + rarity: "rare", + zoneId: "goddess_divine_heart", + }, +]; diff --git a/apps/web/src/engine/tick.ts b/apps/web/src/engine/tick.ts index 6596555..c08d540 100644 --- a/apps/web/src/engine/tick.ts +++ b/apps/web/src/engine/tick.ts @@ -16,6 +16,8 @@ import { type Achievement, type Equipment, type GameState, + type GoddessAchievement, + type GoddessState, computeSetBonuses, getActiveCompanionBonus, } from "@elysium/types"; @@ -83,6 +85,62 @@ const checkAchievements = (state: GameState): Array => { }); }; +/** + * Checks all goddess achievements against a snapshot of the goddess state + * and returns an updated achievements array, marking newly-met conditions + * with the current timestamp. + * @param goddess - The current (or projected) goddess state. + * @param now - Current Unix timestamp in milliseconds. + * @returns Updated goddess achievements array with newly unlocked ones timestamped. + */ +const checkGoddessAchievements = ( + goddess: GoddessState, + now: number, +): Array => { + return goddess.achievements.map((achievement) => { + if (achievement.unlockedAt !== null) { + return achievement; + } + + const { condition } = achievement; + let met = false; + + switch (condition.type) { + case "totalPrayersEarned": + met = goddess.lifetimePrayersEarned >= condition.amount; + break; + case "goddessBossesDefeated": + met = goddess.lifetimeBossesDefeated >= condition.amount; + break; + case "goddessQuestsCompleted": + met = goddess.lifetimeQuestsCompleted >= condition.amount; + break; + case "discipleTotal": + met + = goddess.disciples.reduce((sum, disciple) => { + return sum + disciple.count; + }, 0) >= condition.amount; + break; + case "consecrationCount": + met = goddess.consecration.count >= condition.amount; + break; + case "goddessEquipmentOwned": + met + = goddess.equipment.filter((item) => { + return item.owned; + }).length >= condition.amount; + break; + default: + // eslint-disable-next-line capitalized-comments -- v8 coverage ignore directive + /* v8 ignore next -- @preserve */ break; + } + + return met + ? { ...achievement, unlockedAt: now } + : achievement; + }); +}; + /** * Exponential base for the prestige combat multiplier: Math.pow(base, prestigeCount). * Replaces the former linear formula (1 + count * 0.1) to enable late-game zone progression. @@ -770,6 +828,269 @@ export const applyTick = ( : upgrade; }); + // --- Goddess tick --- + let prayersGained = 0; + let divinityGained = 0; + let stardustFromQuests = 0; + // eslint-disable-next-line no-undef-init -- required by @typescript-eslint/init-declarations + let updatedGoddess: GoddessState | undefined = undefined; + + if ( + state.apotheosis !== undefined + && state.apotheosis.count > 0 + && state.goddess !== undefined + ) { + const { goddess } = state; + + // Collect all multipliers for prayers/divinity income + const consecMult = goddess.consecration.productionMultiplier; + const divinityPrayersMult + = goddess.consecration.divinityPrayersMultiplier ?? 1; + const divinityDisciplesMult + = goddess.consecration.divinityDisciplesMultiplier ?? 1; + const stardustPrayersMult = goddess.enlightenment.stardustPrayersMultiplier; + const craftedPrayersMult = goddess.exploration.craftedPrayersMultiplier; + const craftedDivinityMult = goddess.exploration.craftedDivinityMultiplier; + + let globalPrayersMult = 1; + for (const upgrade of goddess.upgrades) { + if (upgrade.purchased && upgrade.target === "prayers") { + globalPrayersMult = globalPrayersMult * upgrade.multiplier; + } + } + + for (const disciple of goddess.disciples) { + if (disciple.count === 0) { + continue; + } + let discipleUpgradeMult = 1; + for (const upgrade of goddess.upgrades) { + if ( + upgrade.purchased + && upgrade.target === "disciple" + && upgrade.discipleId === disciple.id + ) { + discipleUpgradeMult = discipleUpgradeMult * upgrade.multiplier; + } + } + const prayersTick + = disciple.prayersPerSecond + * disciple.count + * discipleUpgradeMult + * globalPrayersMult + * consecMult + * divinityPrayersMult + * divinityDisciplesMult + * stardustPrayersMult + * craftedPrayersMult + * deltaSeconds; + prayersGained = prayersGained + prayersTick; + const divinityTick + = disciple.divinityPerSecond + * disciple.count + * discipleUpgradeMult + * consecMult + * divinityDisciplesMult + * craftedDivinityMult + * deltaSeconds; + divinityGained = divinityGained + divinityTick; + } + + // Process goddess quest timers + let goddessQuestPrayersGained = 0; + let goddessQuestDivinityGained = 0; + let updatedGoddessDisciples = goddess.disciples; + let updatedGoddessEquipment = goddess.equipment; + let updatedGoddessUpgrades = goddess.upgrades; + let goddessQuestsThisTick = 0; + + const updatedGoddessQuests = goddess.quests.map((quest) => { + const questDurationMs = quest.durationSeconds * 1000; + const questExpiry + = quest.startedAt === undefined + ? Infinity + : quest.startedAt + questDurationMs; + if (quest.status !== "active" || now < questExpiry) { + return quest; + } + + goddessQuestsThisTick = goddessQuestsThisTick + 1; + for (const reward of quest.rewards) { + if (reward.type === "prayers" && reward.amount !== undefined) { + goddessQuestPrayersGained + = goddessQuestPrayersGained + reward.amount; + } else if ( + reward.type === "divinity" + && reward.amount !== undefined + ) { + goddessQuestDivinityGained + = goddessQuestDivinityGained + reward.amount; + } else if ( + reward.type === "stardust" + && reward.amount !== undefined + ) { + stardustFromQuests = stardustFromQuests + reward.amount; + } else if ( + reward.type === "upgrade" + && reward.targetId !== undefined + ) { + const { targetId } = reward; + updatedGoddessUpgrades = updatedGoddessUpgrades.map((upgrade) => { + return upgrade.id === targetId + ? { ...upgrade, unlocked: true } + : upgrade; + }); + } else if ( + reward.type === "disciple" + && reward.targetId !== undefined + ) { + const { targetId } = reward; + updatedGoddessDisciples = updatedGoddessDisciples.map((disciple) => { + return disciple.id === targetId + ? { ...disciple, unlocked: true } + : disciple; + }); + } else if ( + reward.type === "equipment" + && reward.targetId !== undefined + ) { + const rewardTargetId = reward.targetId; + const currentEquipment = updatedGoddessEquipment; + updatedGoddessEquipment = currentEquipment.map((item) => { + if (item.id !== rewardTargetId) { + return item; + } + const slotEmpty = !currentEquipment.some((other) => { + return other.type === item.type && other.equipped; + }); + return { + ...item, + equipped: slotEmpty || item.equipped, + owned: true, + }; + }); + } + } + return { ...quest, status: "completed" as const }; + }); + + // Unlock goddess quests whose prerequisites are now all completed + const completedGoddessIds = new Set( + updatedGoddessQuests. + filter((quest) => { + return quest.status === "completed"; + }). + map((quest) => { + return quest.id; + }), + ); + const defeatedBossIds = new Set( + goddess.bosses. + filter((boss) => { + return boss.status === "defeated"; + }). + map((boss) => { + return boss.id; + }), + ); + // Unlock goddess zones whose boss + quest requirements are now met + const updatedGoddessZones = goddess.zones.map((zone) => { + if (zone.status === "unlocked") { + return zone; + } + const bossOk + = zone.unlockBossId === null + || defeatedBossIds.has(zone.unlockBossId); + const questOk + = zone.unlockQuestId === null + || completedGoddessIds.has(zone.unlockQuestId); + if (bossOk && questOk) { + return { ...zone, status: "unlocked" as const }; + } + return zone; + }); + + const fullyUpdatedGoddessZones = updatedGoddessZones; + const allUnlockedGoddessZoneIds = new Set( + fullyUpdatedGoddessZones. + filter((zone) => { + return zone.status === "unlocked"; + }). + map((zone) => { + return zone.id; + }), + ); + + const fullyUpdatedGoddessQuests = updatedGoddessQuests.map((quest) => { + if (quest.status !== "locked") { + return quest; + } + if (!allUnlockedGoddessZoneIds.has(quest.zoneId)) { + return quest; + } + if ( + quest.prerequisiteIds.every((id) => { + return completedGoddessIds.has(id); + }) + ) { + return { ...quest, status: "available" as const }; + } + return quest; + }); + + // Compute updated lifetime counters + const totalPrayersThisTick + = prayersGained + goddessQuestPrayersGained; + const updatedTotalPrayersEarned + = goddess.totalPrayersEarned + totalPrayersThisTick; + const updatedLifetimePrayersEarned + = goddess.lifetimePrayersEarned + totalPrayersThisTick; + const updatedLifetimeQuestsCompleted + = goddess.lifetimeQuestsCompleted + goddessQuestsThisTick; + + // Build snapshot for achievement check + const goddessSnapshot: GoddessState = { + ...goddess, + disciples: updatedGoddessDisciples, + equipment: updatedGoddessEquipment, + lifetimeBossesDefeated: goddess.lifetimeBossesDefeated, + lifetimePrayersEarned: updatedLifetimePrayersEarned, + lifetimeQuestsCompleted: updatedLifetimeQuestsCompleted, + quests: fullyUpdatedGoddessQuests, + upgrades: updatedGoddessUpgrades, + zones: fullyUpdatedGoddessZones, + }; + const updatedGoddessAchievements + = checkGoddessAchievements(goddessSnapshot, now); + let divinityFromAchievements = 0; + let stardustFromAchievements = 0; + for (const [ index, achievement ] of updatedGoddessAchievements.entries()) { + if ( + goddess.achievements[index]?.unlockedAt === null + && achievement.unlockedAt !== null + ) { + divinityFromAchievements + = divinityFromAchievements + (achievement.reward?.divinity ?? 0); + stardustFromAchievements + = stardustFromAchievements + (achievement.reward?.stardust ?? 0); + } + } + + updatedGoddess = { + ...goddessSnapshot, + achievements: updatedGoddessAchievements, + lastTickAt: now, + totalPrayersEarned: updatedTotalPrayersEarned, + }; + + // Include quest divinity + achievement divinity in this tick's total + divinityGained + = divinityGained + + goddessQuestDivinityGained + + divinityFromAchievements; + stardustFromQuests = stardustFromQuests + stardustFromAchievements; + } + const goldValue = capResource(state.resources.gold + goldGained + questGold); const essenceValue = capResource( state.resources.essence + essenceGained + questEssence, @@ -784,8 +1105,17 @@ export const applyTick = ( crystals: capResource( state.resources.crystals + questCrystals + challengeCrystals, ), + divinity: capResource( + (state.resources.divinity ?? 0) + divinityGained, + ), essence: essenceValue, gold: goldValue, + prayers: capResource( + (state.resources.prayers ?? 0) + prayersGained, + ), + stardust: capResource( + (state.resources.stardust ?? 0) + stardustFromQuests, + ), }, ...updatedDailyChallenges === undefined ? {} @@ -807,6 +1137,9 @@ export const applyTick = ( }), }, }, + ...updatedGoddess === undefined + ? {} + : { goddess: updatedGoddess }, adventurers: updatedAdventurers, bosses: updatedBosses, equipment: updatedEquipmentReference, diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 1c0e9a6..2a686a5 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -4787,3 +4787,598 @@ body, justify-content: center; min-height: 200px; } + +/* ===================== GODDESS ZONES PANEL ===================== */ +.goddess-zones-panel { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.zone-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); +} + +.zone-card { + background: var(--colour-surface); + border: 1px solid var(--colour-border); + border-radius: var(--radius-lg); + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1rem; +} + +.zone-card.locked { + opacity: 0.5; +} + +.zone-card-header { + align-items: center; + display: flex; + gap: 0.75rem; +} + +.zone-emoji { + font-size: 2rem; +} + +.zone-name { + flex: 1; + font-size: 1.1rem; + font-weight: 600; +} + +.zone-lock-icon { + color: var(--colour-text-muted); + font-size: 1.25rem; +} + +.zone-description { + color: var(--colour-text-muted); + font-size: 0.85rem; + line-height: 1.5; +} + +.zone-unlock-requirements { + background: var(--colour-surface-2); + border-radius: var(--radius); + padding: 0.5rem 0.75rem; +} + +.zone-unlock-label { + color: var(--colour-text-muted); + font-size: 0.75rem; + font-weight: 600; + margin-bottom: 0.25rem; + text-transform: uppercase; +} + +.zone-unlock-item { + color: var(--colour-text-muted); + font-size: 0.8rem; +} + +.zone-badge { + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + padding: 0.15rem 0.6rem; +} + +.zone-badge.unlocked { + background: rgba(16, 185, 129, 0.15); + color: var(--colour-success); +} + +/* ===================== GODDESS BOSS PANEL (additions) ===================== */ +.goddess-boss-panel { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.boss-card.boss-available { + border-color: var(--colour-accent); +} + +.boss-card.boss-locked { + opacity: 0.45; +} + +.consecration-requirement { + color: var(--colour-warning); + font-size: 0.8rem; + margin-top: 0.25rem; +} + +.exploration-zone-locked-hint { + color: var(--colour-text-muted); + font-size: 0.85rem; + padding: 0.5rem 0; +} + +/* ===================== GODDESS QUEST PANEL (additions) ===================== */ +.goddess-quests-panel { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.zone-filter-buttons { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.zone-filter-button { + background: transparent; + border: 1px solid var(--colour-border); + border-radius: var(--radius); + color: var(--colour-text-muted); + cursor: pointer; + font-size: 0.85rem; + padding: 0.3rem 0.75rem; + transition: all 0.15s; +} + +.zone-filter-button:hover:not(:disabled) { + background: var(--colour-surface-2); + color: var(--colour-text); +} + +.zone-filter-button.active { + background: var(--colour-accent); + border-color: var(--colour-accent-light); + color: #fff; +} + +.zone-filter-button.zone-locked { + cursor: not-allowed; + opacity: 0.45; +} + +.zone-info { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.zone-progress { + color: var(--colour-text-muted); + font-size: 0.85rem; +} + +.zone-locked-notice { + background: var(--colour-surface-2); + border-radius: var(--radius); + color: var(--colour-text-muted); + font-size: 0.85rem; + padding: 0.75rem 1rem; +} + +.quest-card.quest-locked { + opacity: 0.45; +} + +.quest-card.quest-available { + border-color: var(--colour-accent); +} + +.quest-action { + margin-top: auto; +} + +/* ===================== DISCIPLES PANEL ===================== */ +.disciples-panel { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.disciples-balance { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +.disciples-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.disciple-card { + background: var(--colour-surface); + border: 1px solid var(--colour-border); + border-radius: var(--radius-lg); + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1rem; +} + +.disciple-card.disciple-locked { + opacity: 0.45; +} + +.disciple-header { + align-items: center; + display: flex; + gap: 0.75rem; +} + +.disciple-title { + flex: 1; + font-size: 1rem; + font-weight: 600; +} + +.disciple-class { + background: rgba(124, 58, 237, 0.2); + border-radius: 999px; + color: var(--colour-accent-light); + font-size: 0.75rem; + font-weight: 600; + padding: 0.15rem 0.6rem; +} + +.disciple-count { + color: var(--colour-gold); + font-size: 0.9rem; + font-weight: 600; +} + +.disciple-income { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.income-tag { + background: rgba(16, 185, 129, 0.1); + border-radius: 999px; + color: var(--colour-success); + font-size: 0.75rem; + padding: 0.15rem 0.6rem; +} + +.combat-power-tag { + background: rgba(239, 68, 68, 0.1); + border-radius: 999px; + color: var(--colour-error); + font-size: 0.75rem; + padding: 0.15rem 0.6rem; +} + +.disciple-cost { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.cost-label { + color: var(--colour-text-muted); + font-size: 0.85rem; +} + +.buy-disciple-button { + font-size: 0.85rem; + padding: 0.35rem 0.9rem; +} + +.disciple-badge { + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + padding: 0.15rem 0.6rem; +} + +.disciple-badge.locked { + background: var(--colour-surface-2); + color: var(--colour-text-muted); +} + +/* ===================== GODDESS EQUIPMENT PANEL ===================== */ +.goddess-equipment-panel { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.equipment-tabs { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.tab-btn { + background: transparent; + border: 1px solid var(--colour-border); + border-radius: var(--radius); + color: var(--colour-text-muted); + cursor: pointer; + font-size: 0.85rem; + padding: 0.3rem 0.75rem; + transition: all 0.15s; +} + +.tab-btn:hover { + background: var(--colour-surface-2); + color: var(--colour-text); +} + +.tab-btn.active { + background: var(--colour-accent); + border-color: var(--colour-accent-light); + color: #fff; +} + +.equipment-grid { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); +} + +.goddess-equipment-card { + background: var(--colour-surface); + border: 2px solid var(--colour-border); + border-radius: var(--radius-lg); + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.9rem; +} + +.goddess-equipment-card.equipped { + border-color: var(--colour-gold); + box-shadow: 0 0 10px rgba(245, 200, 66, 0.2); +} + +.goddess-equipment-card.owned { + border-color: var(--colour-accent); +} + +.goddess-equipment-card.locked { + opacity: 0.55; +} + +.goddess-equipment-card.rarity-common { + border-color: #9e9e9e; +} + +.goddess-equipment-card.rarity-rare { + border-color: #2196f3; +} + +.goddess-equipment-card.rarity-epic { + border-color: #9c27b0; +} + +.goddess-equipment-card.rarity-legendary { + border-color: #ff9800; + box-shadow: 0 0 8px rgba(255, 152, 0, 0.2); +} + +.equipment-card-header { + align-items: center; + display: flex; + gap: 0.5rem; +} + +.equipment-type-icon { + font-size: 1.25rem; +} + +.equipment-name { + flex: 1; + font-size: 0.95rem; + font-weight: 600; +} + +.equipment-rarity-badge { + font-size: 0.75rem; + font-weight: 600; +} + +.equipment-description { + color: var(--colour-text-muted); + font-size: 0.8rem; + line-height: 1.4; +} + +.equipment-bonus { + color: var(--colour-success); + font-size: 0.8rem; + font-weight: 500; +} + +.equipment-set { + color: var(--colour-gold); + font-size: 0.75rem; +} + +.equipment-card-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.25rem; +} + +.equipment-equipped-badge { + background: rgba(16, 185, 129, 0.15); + border-radius: 999px; + color: var(--colour-success); + font-size: 0.8rem; + font-weight: 600; + padding: 0.2rem 0.7rem; +} + +.equipment-drop-hint { + color: var(--colour-text-muted); + font-size: 0.8rem; +} + +.btn-equip, +.btn-buy { + background: var(--colour-accent); + border: none; + border-radius: var(--radius); + color: #fff; + cursor: pointer; + font-size: 0.82rem; + font-weight: 600; + padding: 0.3rem 0.8rem; + transition: background 0.15s; +} + +.btn-equip:hover, +.btn-buy:hover:not(:disabled) { + background: var(--colour-accent-light); +} + +.btn-buy:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +/* ===================== GODDESS UPGRADES PANEL ===================== */ +.goddess-upgrades-panel { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.panel-resource-bar { + background: var(--colour-surface-2); + border-radius: var(--radius); + display: flex; + flex-wrap: wrap; + gap: 1rem; + padding: 0.6rem 1rem; +} + +.panel-resource-bar .resource-item { + color: var(--colour-text); + font-size: 0.9rem; +} + +.upgrades-section { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.section-heading { + color: var(--colour-text-muted); + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.upgrades-grid { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); +} + +.goddess-upgrade-card { + background: var(--colour-surface); + border: 1px solid var(--colour-border); + border-radius: var(--radius-lg); + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.9rem; +} + +.goddess-upgrade-card.purchased { + border-color: var(--colour-success); + opacity: 0.75; +} + +.goddess-upgrade-card.available { + border-color: var(--colour-accent); +} + +.goddess-upgrade-card.locked { + opacity: 0.45; +} + +.goddess-upgrade-card.cannot-afford { + border-color: var(--colour-border); + opacity: 0.8; +} + +.upgrade-card-header { + align-items: center; + display: flex; + gap: 0.5rem; +} + +.upgrade-name { + flex: 1; + font-size: 0.95rem; + font-weight: 600; +} + +.upgrade-target-badge { + background: var(--colour-surface-2); + border-radius: 999px; + color: var(--colour-text-muted); + font-size: 0.7rem; + font-weight: 600; + padding: 0.15rem 0.55rem; + text-transform: uppercase; +} + +.upgrade-description { + color: var(--colour-text-muted); + font-size: 0.82rem; + line-height: 1.4; +} + +.upgrade-effect { + color: var(--colour-success); + font-size: 0.82rem; + font-weight: 500; +} + +.upgrade-disciple { + color: var(--colour-accent-light); + font-size: 0.78rem; +} + +/* ===================== GODDESS CONSECRATION / ENLIGHTENMENT ===================== */ +.consecration-panel, +.enlightenment-panel { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.consecration-toast, +.enlightenment-toast { + background: var(--colour-surface-2); + border-left: 4px solid var(--colour-accent); + border-radius: var(--radius); + color: var(--colour-text); + padding: 0.75rem 1rem; +} + +/* ===================== GODDESS ACHIEVEMENTS PANEL ===================== */ +.goddess-achievements-panel, +.achievement-panel { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.achievement-progress-label { + color: var(--colour-text-muted); + font-size: 0.85rem; +} diff --git a/goddess-todo.md b/goddess-todo.md deleted file mode 100644 index 6737275..0000000 --- a/goddess-todo.md +++ /dev/null @@ -1,99 +0,0 @@ -# Goddess Expansion — Implementation Tracker - -Branch: `feat/goddess` - -## Chunk 1 — Types ✅ COMPLETE -- [x] Add `GoddessZone`, `GoddessBoss`, `GoddessQuest` interfaces -- [x] Add `GoddessDisciple` (Disciples) interface -- [x] Add `GoddessEquipment`, `GoddessUpgrade` interfaces -- [x] Add `GoddessExplorationState` interface -- [x] Add `ConsecrationData` + `ConsecrationUpgrade` (Prestige) interfaces -- [x] Add `EnlightenmentData` + `EnlightenmentUpgrade` (Transcendence) interfaces -- [x] Add `GoddessAchievement` interface -- [x] Add goddess currency fields (prayers, divinity, stardust) to `Resource` -- [x] Add top-level `GoddessState` container + add `goddess?` to `GameState` -- [x] Export all new types from `packages/types` -- Lint ✅ · Build ✅ · Tests ✅ (100% coverage) - -## Chunk 2 — Data ✅ COMPLETE -- [x] `goddessZones.ts` — 18 goddess zones -- [x] `goddessBosses.ts` — 72 bosses (4 per zone) -- [x] `goddessQuests.ts` — 90 quests (5 per zone) -- [x] `goddessDisciples.ts` — 32 disciple tiers (oracle/seraph/invoker/templar/herald/warden classes) -- [x] `goddessEquipment.ts` — 53 equipment pieces (18 relics, 18 vestments, 17 sigils) -- [x] `goddessEquipmentSets.ts` — 9 equipment sets (with GoddessEquipmentSet type) -- [x] `goddessUpgrades.ts` — 57 upgrades (prayers/global/combat/consecration/disciple/boss) -- [x] `goddessConsecrationUpgrades.ts` — 25 consecration upgrades -- [x] `goddessEnlightenmentUpgrades.ts` — 15 enlightenment upgrades -- [x] `goddessMaterials.ts` — 54 sacred materials (3 per zone) -- [x] `goddessCrafting.ts` — 36 crafting recipes (2 per zone) -- [x] `goddessExplorations.ts` — 72 exploration areas (4 per zone) -- [x] `goddessAchievements.ts` — 40 achievements -- [x] `GoddessEquipmentSet` + `computeGoddessSetBonuses` added to `packages/types` -- NOTE: All data files excluded from coverage until Chunk 4 routes import them -- Lint ✅ · Build ✅ · Tests ✅ (100% coverage) - -## Chunk 3 — Sync / Sanitize ✅ COMPLETE -- [x] Update `validateAndSanitize` to inject goddess state defaults for existing saves -- [x] Update force-sync (`syncNewContent`) to inject missing goddess fields -- [x] Add apotheosis unlock flag handling -- Lint ✅ · Build ✅ · Tests ✅ (100% coverage) - -## Chunk 4 — API Routes ✅ COMPLETE -- [x] Goddess boss fight route (`goddessBoss.ts`) -- [x] Consecration (goddess prestige) route (`consecration.ts`) -- [x] Enlightenment (goddess transcendence) route (`enlightenment.ts`) -- [x] Goddess upgrade purchase route (`goddessUpgrade.ts`) -- [x] Goddess crafting route (`goddessCraft.ts`) -- [x] Goddess exploration route (`goddessExplore.ts`) -- [x] Services: `consecration.ts`, `enlightenment.ts` -- [x] Tests for all 6 routes (100% coverage) -- Lint ✅ · Build ✅ · Tests ✅ (100% coverage) - -## Chunk 5 — UI: Resource Bar + Mode/Tab Nav ✅ COMPLETE -- [x] Add goddess currencies (Prayers, Divinity, Stardust) to resource bar dropdown — locked icon pre-apotheosis, live values post-apotheosis -- [x] Add **Mode bar** (Row 1) — `⚔️ Mortal | ✨ Goddess | 🧛 Vampire` — always visible, locked modes show 🔒 and are disabled -- [x] Add **Tab bar** (Row 2) — swaps entirely based on selected mode - - Mortal: all existing tabs (unchanged) - - Goddess: Zones · Bosses · Quests · Disciples · Equipment · Upgrades · Consecration · Enlightenment · Crafting · Exploration · Achievements - - Vampire: placeholder (future) -- [x] Mode persists across page reloads via localStorage -- [x] `body.goddess-mode` CSS class toggled via `useEffect` when Goddess mode active -- [x] 300ms CSS fade transition on major layout elements -- [x] Goddess theme: dark navy bg, divine blue accent, gold/white accents -- Lint ✅ · Build ✅ · Tests ✅ (100% coverage) - -## Chunk 6 — UI: Goddess Panels -- [ ] `GoddessZonesPanel` — zones with lock states -- [ ] `GoddessBossPanel` — boss fights -- [ ] `GoddessQuestsPanel` — quests -- [ ] `DisciplesPanel` — goddess adventurers -- [ ] `GoddessEquipmentPanel` — equipment -- [ ] `GoddessUpgradesPanel` — upgrades -- [ ] `ConsecrationPanel` — goddess prestige -- [ ] `EnlightenmentPanel` — goddess transcendence -- [ ] `GoddessCraftingPanel` — crafting -- [ ] `GoddessExplorationPanel` — exploration -- [ ] `GoddessAchievementsPanel` — achievements - -## Chunk 7 — Tick Engine -- [ ] Goddess passive income (prayers, divinity accumulation) -- [ ] Disciple passive income logic -- [ ] Lock state checks (no goddess income pre-apotheosis) -- [ ] Goddess quest timer logic - -## Chunk 8 — CSS Theme -- [ ] Define goddess CSS variables (soft blue primary, gold/white accents) -- [ ] Apply `.goddess-mode` overrides to all themed elements -- [ ] Verify logo stays unchanged during theme shift -- [ ] Test fade transition smoothness - -## Chunk 9 — About Page -- [ ] Update `HOW_TO_PLAY` array in `aboutPanel.tsx` with Goddess expansion documentation - -## Notes -- Apotheosis = the unlock gate for all goddess content (replaces original "Transcendence 20" concept — verify exact trigger) -- Goddess currencies always visible in resource bar, greyed pre-apotheosis -- All goddess tabs always visible in second row, content locked internally pre-apotheosis -- Vampire Mode will follow same pattern as third tab row (future work, not this PR) -- Sync new content must inject goddess defaults for all existing saves