generated from nhcarrigan/template
feat: goddess expansion chunks 6–9 — UI panels, tick engine, CSS theme, about page
- 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
This commit is contained in:
@@ -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<LoadResponse> => {
|
||||
return await fetchJson<LoadResponse>("/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<GoddessBossChallengeResponse> => {
|
||||
return await fetchJson<GoddessBossChallengeResponse>("/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<ConsecrationResponse> => {
|
||||
return await fetchJson<ConsecrationResponse>("/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<BuyConsecrationUpgradeResponse> => {
|
||||
return await fetchJson<BuyConsecrationUpgradeResponse>(
|
||||
"/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<EnlightenmentResponse> => {
|
||||
return await fetchJson<EnlightenmentResponse>("/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<BuyEnlightenmentUpgradeResponse> => {
|
||||
return await fetchJson<BuyEnlightenmentUpgradeResponse>(
|
||||
"/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<BuyGoddessUpgradeResponse> => {
|
||||
return await fetchJson<BuyGoddessUpgradeResponse>("/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<GoddessCraftResponse> => {
|
||||
return await fetchJson<GoddessCraftResponse>("/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<GoddessExploreStartResponse> => {
|
||||
return await fetchJson<GoddessExploreStartResponse>("/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<GoddessExploreCollectResponse> => {
|
||||
return await fetchJson<GoddessExploreCollectResponse>("/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<GoddessExploreClaimableResponse> => {
|
||||
return await fetchJson<GoddessExploreClaimableResponse>(
|
||||
`/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,
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<ConsecrationUpgradeCategory> = [
|
||||
"prayers",
|
||||
"disciples",
|
||||
"combat",
|
||||
"divinity",
|
||||
"utility",
|
||||
];
|
||||
|
||||
const CONSECRATION_UPGRADE_CATEGORY_LABELS: Record<ConsecrationUpgradeCategory, string> = {
|
||||
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<string | null>(null);
|
||||
const [ buyingId, setBuyingId ] = useState<string | null>(null);
|
||||
const [ activeTab, setActiveTab ] = useState<ConsecrationTab>("consecrate");
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { goddess } = state;
|
||||
|
||||
if (goddess === undefined) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"The Goddess expansion is not yet unlocked."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 (
|
||||
<section className="panel consecration-panel">
|
||||
<h2>{"🕯️ Consecration"}</h2>
|
||||
|
||||
{showConsecrationToast
|
||||
? <div className="prestige-toast consecration-toast">
|
||||
<p>{"✨ Consecration complete!"}</p>
|
||||
<button
|
||||
onClick={dismissConsecrationToast}
|
||||
type="button"
|
||||
>
|
||||
{"Dismiss"}
|
||||
</button>
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
|
||||
<div className="prestige-tabs">
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "consecrate"
|
||||
? "active"
|
||||
: ""}`}
|
||||
onClick={handleConsecrateTabClick}
|
||||
type="button"
|
||||
>
|
||||
{"Consecrate"}
|
||||
</button>
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "shop"
|
||||
? "active"
|
||||
: ""}`}
|
||||
onClick={handleShopTabClick}
|
||||
type="button"
|
||||
>
|
||||
{"✨ Divinity Shop ("}
|
||||
{formatInteger(currentDivinity)}
|
||||
{" divinity)"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "consecrate"
|
||||
&& <>
|
||||
<p className="transcendence-intro">
|
||||
{"Consecration is the goddess prestige layer. It resets your prayers"
|
||||
+ " and goddess progress, but grants "}
|
||||
<strong>{"Divinity"}</strong>
|
||||
{" — a permanent goddess currency used to purchase powerful upgrades."
|
||||
+ " Each consecration also permanently increases your prayers/s multiplier."}
|
||||
</p>
|
||||
|
||||
<div className="transcendence-status">
|
||||
{consecration.count > 0
|
||||
? <p>
|
||||
{"Consecration count: "}
|
||||
<strong>{consecration.count}</strong>
|
||||
</p>
|
||||
: null
|
||||
}
|
||||
<p>
|
||||
{"Current Divinity: "}
|
||||
<strong>{formatInteger(currentDivinity)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
{"Prayers this run: "}
|
||||
<strong>{formatNumber(totalPrayersEarned)}</strong>
|
||||
{" / "}
|
||||
<strong>{formatNumber(threshold)}</strong>
|
||||
</p>
|
||||
<div className="prestige-progress-bar">
|
||||
<div
|
||||
className="prestige-progress-fill"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="prestige-progress-label">
|
||||
{progressPct}
|
||||
{"% of threshold"}
|
||||
</p>
|
||||
{isEligible
|
||||
? <p className="echo-preview">
|
||||
{"Divinity on consecration: "}
|
||||
<strong>
|
||||
{"+"}
|
||||
{formatInteger(divinityPreview)}
|
||||
</strong>
|
||||
{divinityMultiplier > 1
|
||||
? <span className="echo-meta-bonus">
|
||||
{" (×"}
|
||||
{divinityMultiplier.toFixed(2)}
|
||||
{" yield bonus applied)"}
|
||||
</span>
|
||||
: null
|
||||
}
|
||||
</p>
|
||||
: null}
|
||||
<p>
|
||||
{"Next production multiplier: "}
|
||||
<strong>
|
||||
{"×"}
|
||||
{nextMultiplier.toFixed(2)}
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isEligible
|
||||
? null
|
||||
: <div className="transcendence-locked">
|
||||
<p>
|
||||
{"🔒 "}
|
||||
<strong>{"Earn enough prayers"}</strong>
|
||||
{" to unlock consecration."}
|
||||
</p>
|
||||
<p className="transcendence-hint">
|
||||
{"You need "}
|
||||
{formatNumber(threshold)}
|
||||
{" total prayers in the current run. You have "}
|
||||
{formatNumber(totalPrayersEarned)}
|
||||
{"."}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
{isEligible
|
||||
? <div className="prestige-form">
|
||||
<p>
|
||||
{"You are ready to consecrate. This action is "}
|
||||
<strong>{"irreversible"}</strong>
|
||||
{" within this goddess run."}
|
||||
</p>
|
||||
<button
|
||||
className="transcendence-button"
|
||||
disabled={isPending}
|
||||
onClick={handleConsecrateClick}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Consecrating..."
|
||||
: `🕯️ Consecrate (+${formatInteger(divinityPreview)} Divinity)`}
|
||||
</button>
|
||||
{consecrationError === null
|
||||
? null
|
||||
: <p className="error">{consecrationError}</p>}
|
||||
{result === null
|
||||
? null
|
||||
: <p className="success">
|
||||
{"Consecrated! Earned "}
|
||||
<strong>
|
||||
{formatInteger(result.divinityEarned)}
|
||||
{" Divinity"}
|
||||
</strong>
|
||||
{". This is Consecration "}
|
||||
{result.count}
|
||||
{". A new divine cycle begins."}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
: null}
|
||||
</>
|
||||
}
|
||||
|
||||
{activeTab === "shop"
|
||||
&& <div className="echo-shop">
|
||||
<p className="shop-balance">
|
||||
{"Balance: "}
|
||||
<strong>
|
||||
{formatInteger(currentDivinity)}
|
||||
{" Divinity"}
|
||||
</strong>
|
||||
</p>
|
||||
<p className="echo-shop-description">
|
||||
{"Divinity upgrades are "}
|
||||
<strong>{"permanent"}</strong>
|
||||
{" — they survive future consecrations."}
|
||||
</p>
|
||||
|
||||
{upgradesByCategory.map(({ categoryId, label, upgrades }) => {
|
||||
return (
|
||||
<div className="shop-category" key={categoryId}>
|
||||
<h3>{label}</h3>
|
||||
<div className="shop-upgrades">
|
||||
{upgrades.map((upgrade) => {
|
||||
const purchased = consecration.purchasedUpgradeIds.includes(upgrade.id);
|
||||
const canAfford = currentDivinity >= upgrade.divinityCost;
|
||||
const isLoading = buyingId === upgrade.id;
|
||||
|
||||
function handleBuyClick(): void {
|
||||
void handleBuyUpgrade(upgrade.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`shop-upgrade-card echo-upgrade-card ${
|
||||
purchased
|
||||
? "purchased"
|
||||
: ""
|
||||
} ${!canAfford && !purchased
|
||||
? "unaffordable"
|
||||
: ""}`}
|
||||
key={upgrade.id}
|
||||
>
|
||||
<div className="shop-upgrade-info">
|
||||
<h4>{upgrade.name}</h4>
|
||||
<p>{upgrade.description}</p>
|
||||
<p className="upgrade-cost">
|
||||
{purchased
|
||||
? "✅ Purchased"
|
||||
: `✨ ${formatInteger(upgrade.divinityCost)} Divinity`}
|
||||
</p>
|
||||
</div>
|
||||
{purchased
|
||||
? null
|
||||
: <button
|
||||
className="upgrade-buy-button"
|
||||
disabled={!canAfford || isLoading}
|
||||
onClick={handleBuyClick}
|
||||
type="button"
|
||||
>
|
||||
{isLoading
|
||||
? "Buying..."
|
||||
: "Buy"}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { ConsecrationPanel };
|
||||
@@ -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<BatchSize> = [ 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 (
|
||||
<div className={`disciple-card ${disciple.unlocked
|
||||
? ""
|
||||
: "disciple-locked"}`}>
|
||||
<div className="disciple-header">
|
||||
<div className="disciple-title">
|
||||
<h3>{disciple.name}</h3>
|
||||
<span className="disciple-class">{disciple.class}</span>
|
||||
</div>
|
||||
<span className="disciple-count">
|
||||
{"×"}
|
||||
{formatNumber(disciple.count)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="disciple-income">
|
||||
{disciple.prayersPerSecond > 0
|
||||
&& <span className="income-tag">
|
||||
{"🙏 "}
|
||||
{formatNumber(disciple.prayersPerSecond)}
|
||||
{"/s prayers"}
|
||||
</span>
|
||||
}
|
||||
{disciple.divinityPerSecond > 0
|
||||
&& <span className="income-tag">
|
||||
{"✨ "}
|
||||
{formatNumber(disciple.divinityPerSecond)}
|
||||
{"/s divinity"}
|
||||
</span>
|
||||
}
|
||||
<span className="combat-power-tag">
|
||||
{"⚔️ "}
|
||||
{formatNumber(disciple.combatPower)}
|
||||
{" combat power each"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="disciple-cost">
|
||||
<span className="cost-label">
|
||||
{"Next: 🙏 "}
|
||||
{formatNumber(singleCost)}
|
||||
</span>
|
||||
{selectedBatch !== 1
|
||||
&& effectiveBatch > 0
|
||||
&& <span className="cost-label">
|
||||
{selectedBatch === "max"
|
||||
? "Max"
|
||||
: String(selectedBatch)}
|
||||
{" (×"}
|
||||
{String(effectiveBatch)}
|
||||
{"): 🙏 "}
|
||||
{formatNumber(batchCost)}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
{disciple.unlocked
|
||||
? <button
|
||||
className="buy-disciple-button"
|
||||
disabled={!canAffordBatch}
|
||||
onClick={handleBuy}
|
||||
title={
|
||||
canAffordBatch
|
||||
? undefined
|
||||
: `Need 🙏 ${formatNumber(batchCost)} prayers`
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{getBuyButtonLabel()}
|
||||
</button>
|
||||
: <span className="disciple-badge locked">{"🔒 Locked"}</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the disciples panel for purchasing goddess disciples.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const DisciplesPanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
const [ selectedBatch, setSelectedBatch ] = useState<BatchSize>(() => {
|
||||
return parseBatchSize(localStorage.getItem("elysium_disciple_batch"));
|
||||
});
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const goddessState = state.goddess;
|
||||
if (goddessState === undefined) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Goddess expansion not yet unlocked."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const prayers = state.resources.prayers ?? 0;
|
||||
const { disciples } = goddessState;
|
||||
|
||||
function handleBatchSelect(batch: BatchSize): void {
|
||||
setSelectedBatch(batch);
|
||||
localStorage.setItem("elysium_disciple_batch", String(batch));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel disciples-panel">
|
||||
<h2>{"Disciples"}</h2>
|
||||
<div className="disciples-balance">
|
||||
<span>
|
||||
{"🙏 Prayers: "}
|
||||
<strong>{formatNumber(prayers)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div className="batch-selector">
|
||||
{batchOptions.map((batch) => {
|
||||
function handleClick(): void {
|
||||
handleBatchSelect(batch);
|
||||
}
|
||||
return <button
|
||||
className={`batch-button ${selectedBatch === batch
|
||||
? "active"
|
||||
: ""}`}
|
||||
key={String(batch)}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
>
|
||||
{batch === "max"
|
||||
? "Max"
|
||||
: `×${String(batch)}`}
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
<div className="disciples-list">
|
||||
{disciples.map((disciple: GoddessDisciple) => {
|
||||
return <DiscipleCard
|
||||
disciple={disciple}
|
||||
key={disciple.id}
|
||||
prayers={prayers}
|
||||
selectedBatch={selectedBatch}
|
||||
/>;
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { DisciplesPanel };
|
||||
@@ -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<EnlightenmentUpgradeCategory> = [
|
||||
"prayers",
|
||||
"combat",
|
||||
"consecration_threshold",
|
||||
"consecration_divinity",
|
||||
"stardust_meta",
|
||||
];
|
||||
|
||||
const ENLIGHTENMENT_UPGRADE_CATEGORY_LABELS: Record<EnlightenmentUpgradeCategory, string> = {
|
||||
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<string | null>(null);
|
||||
const [ buyingId, setBuyingId ] = useState<string | null>(null);
|
||||
const [ activeTab, setActiveTab ] = useState<EnlightenmentTab>("enlighten");
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { goddess } = state;
|
||||
|
||||
if (goddess === undefined) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"The Goddess expansion is not yet unlocked."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 (
|
||||
<section className="panel enlightenment-panel">
|
||||
<h2>{"🌟 Enlightenment"}</h2>
|
||||
|
||||
{showEnlightenmentToast
|
||||
? <div className="prestige-toast enlightenment-toast">
|
||||
<p>{"🌟 Enlightenment achieved!"}</p>
|
||||
<button
|
||||
onClick={dismissEnlightenmentToast}
|
||||
type="button"
|
||||
>
|
||||
{"Dismiss"}
|
||||
</button>
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
|
||||
<div className="prestige-tabs">
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "enlighten"
|
||||
? "active"
|
||||
: ""}`}
|
||||
onClick={handleEnlightenTabClick}
|
||||
type="button"
|
||||
>
|
||||
{"Enlighten"}
|
||||
</button>
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "shop"
|
||||
? "active"
|
||||
: ""}`}
|
||||
onClick={handleShopTabClick}
|
||||
type="button"
|
||||
>
|
||||
{"🌟 Stardust Shop ("}
|
||||
{formatInteger(currentStardust)}
|
||||
{" stardust)"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "enlighten"
|
||||
&& <>
|
||||
<p className="transcendence-intro">
|
||||
{"Enlightenment is the ultimate goddess reset. It wipes "}
|
||||
<strong>{"everything"}</strong>
|
||||
{" in the goddess realm — prayers, consecrations, disciples, and upgrades"
|
||||
+ " — but grants "}
|
||||
<strong>{"Stardust"}</strong>
|
||||
{", a permanent goddess currency that survives all future resets."
|
||||
+ " Stardust powers upgrades that permanently amplify every goddess run."}
|
||||
</p>
|
||||
<p className="transcendence-intro">
|
||||
<em>
|
||||
{"More consecrations = more Stardust."}
|
||||
{" Optimise your goddess run for maximum yield!"}
|
||||
</em>
|
||||
</p>
|
||||
|
||||
<div className="transcendence-status">
|
||||
{enlightenmentCount > 0
|
||||
? <p>
|
||||
{"Enlightenment count: "}
|
||||
<strong>{enlightenmentCount}</strong>
|
||||
</p>
|
||||
: null
|
||||
}
|
||||
<p>
|
||||
{"Current Stardust: "}
|
||||
<strong>{formatInteger(currentStardust)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
{"Current consecration count: "}
|
||||
<strong>{consecration.count}</strong>
|
||||
</p>
|
||||
{hasDefeatedFinalBoss
|
||||
? <p className="echo-preview">
|
||||
{"Stardust on enlightenment: "}
|
||||
<strong>
|
||||
{"+"}
|
||||
{formatInteger(stardustPreview)}
|
||||
</strong>
|
||||
{metaMultiplier > 1
|
||||
? <span className="echo-meta-bonus">
|
||||
{" (×"}
|
||||
{metaMultiplier.toFixed(2)}
|
||||
{" meta bonus applied)"}
|
||||
</span>
|
||||
: null
|
||||
}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
{hasDefeatedFinalBoss
|
||||
? null
|
||||
: <div className="transcendence-locked">
|
||||
<p>
|
||||
{"🔒 "}
|
||||
<strong>{"Defeat the Divine Heart Sovereign"}</strong>
|
||||
{" to unlock Enlightenment."}
|
||||
</p>
|
||||
<p className="transcendence-hint">
|
||||
{"The Divine Heart Sovereign is the final boss of the Goddess realm."}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
{hasDefeatedFinalBoss
|
||||
? <div className="prestige-form">
|
||||
<p>
|
||||
{"You are ready to achieve Enlightenment. This action is "}
|
||||
<strong>{"irreversible"}</strong>
|
||||
{"."}
|
||||
</p>
|
||||
<button
|
||||
className="transcendence-button"
|
||||
disabled={isPending}
|
||||
onClick={handleEnlightenClick}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Achieving Enlightenment..."
|
||||
: `🌟 Enlighten (+${formatInteger(stardustPreview)} Stardust)`}
|
||||
</button>
|
||||
{enlightenmentError === null
|
||||
? null
|
||||
: <p className="error">{enlightenmentError}</p>}
|
||||
{result === null
|
||||
? null
|
||||
: <p className="success">
|
||||
{"Enlightenment achieved! Earned "}
|
||||
<strong>
|
||||
{formatInteger(result.stardustEarned)}
|
||||
{" Stardust"}
|
||||
</strong>
|
||||
{". This is Enlightenment "}
|
||||
{result.count}
|
||||
{". A new stellar cycle begins."}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
: null}
|
||||
</>
|
||||
}
|
||||
|
||||
{activeTab === "shop"
|
||||
&& <div className="echo-shop">
|
||||
<p className="shop-balance">
|
||||
{"Balance: "}
|
||||
<strong>
|
||||
{formatInteger(currentStardust)}
|
||||
{" Stardust"}
|
||||
</strong>
|
||||
</p>
|
||||
<p className="echo-shop-description">
|
||||
{"Stardust upgrades are "}
|
||||
<strong>{"permanent"}</strong>
|
||||
{" — they survive all future consecrations and enlightenments."}
|
||||
</p>
|
||||
|
||||
{upgradesByCategory.map(({ catId, label, upgrades }) => {
|
||||
return (
|
||||
<div className="shop-category" key={catId}>
|
||||
<h3>{label}</h3>
|
||||
<div className="shop-upgrades">
|
||||
{upgrades.map((upgrade) => {
|
||||
const purchased = enlightenment.purchasedUpgradeIds.includes(upgrade.id);
|
||||
const canAfford = currentStardust >= upgrade.cost;
|
||||
const isLoading = buyingId === upgrade.id;
|
||||
|
||||
function handleBuyClick(): void {
|
||||
void handleBuyUpgrade(upgrade.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`shop-upgrade-card echo-upgrade-card ${
|
||||
purchased
|
||||
? "purchased"
|
||||
: ""
|
||||
} ${!canAfford && !purchased
|
||||
? "unaffordable"
|
||||
: ""}`}
|
||||
key={upgrade.id}
|
||||
>
|
||||
<div className="shop-upgrade-info">
|
||||
<h4>{upgrade.name}</h4>
|
||||
<p>{upgrade.description}</p>
|
||||
<p className="upgrade-cost">
|
||||
{purchased
|
||||
? "✅ Purchased"
|
||||
: `🌟 ${formatInteger(upgrade.cost)} Stardust`}
|
||||
</p>
|
||||
</div>
|
||||
{purchased
|
||||
? null
|
||||
: <button
|
||||
className="upgrade-buy-button"
|
||||
disabled={!canAfford || isLoading}
|
||||
onClick={handleBuyClick}
|
||||
type="button"
|
||||
>
|
||||
{isLoading
|
||||
? "Buying..."
|
||||
: "Buy"}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { EnlightenmentPanel };
|
||||
@@ -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 => {
|
||||
&& <AboutPanel />}
|
||||
{activeMode === "mortal" && activeTab === "debug"
|
||||
&& <DebugPanel />}
|
||||
{activeMode === "goddess" && activeGoddessTab === "goddess-zones"
|
||||
&& <GoddessZonesPanel />}
|
||||
{activeMode === "goddess" && activeGoddessTab === "goddess-bosses"
|
||||
&& <GoddessBossPanel />}
|
||||
{activeMode === "goddess" && activeGoddessTab === "goddess-quests"
|
||||
&& <GoddessQuestsPanel />}
|
||||
{activeMode === "goddess" && activeGoddessTab === "disciples"
|
||||
&& <DisciplesPanel />}
|
||||
{activeMode === "goddess"
|
||||
&& <div className="goddess-placeholder">
|
||||
<p>{"✨ Goddess panels coming soon..."}</p>
|
||||
</div>
|
||||
}
|
||||
&& activeGoddessTab === "goddess-equipment"
|
||||
&& <GoddessEquipmentPanel />}
|
||||
{activeMode === "goddess"
|
||||
&& activeGoddessTab === "goddess-upgrades"
|
||||
&& <GoddessUpgradesPanel />}
|
||||
{activeMode === "goddess" && activeGoddessTab === "consecration"
|
||||
&& <ConsecrationPanel />}
|
||||
{activeMode === "goddess" && activeGoddessTab === "enlightenment"
|
||||
&& <EnlightenmentPanel />}
|
||||
{activeMode === "goddess"
|
||||
&& activeGoddessTab === "goddess-crafting"
|
||||
&& <GoddessCraftingPanel />}
|
||||
{activeMode === "goddess"
|
||||
&& activeGoddessTab === "goddess-exploration"
|
||||
&& <GoddessExplorationPanel />}
|
||||
{activeMode === "goddess"
|
||||
&& activeGoddessTab === "goddess-achievements"
|
||||
&& <GoddessAchievementsPanel />}
|
||||
{activeMode === "vampire"
|
||||
&& <div className="goddess-placeholder">
|
||||
<p>{"🧛 Vampire panels coming soon..."}</p>
|
||||
|
||||
@@ -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 (
|
||||
<div className={`achievement-card ${isUnlocked
|
||||
? "unlocked"
|
||||
: "locked"}`}>
|
||||
<div className="achievement-icon">
|
||||
<span className="achievement-emoji">{achievement.icon}</span>
|
||||
</div>
|
||||
<div className="achievement-info">
|
||||
<h3>{achievement.name}</h3>
|
||||
<p>{achievement.description}</p>
|
||||
<p className="achievement-condition">
|
||||
{conditionDescription(achievement, formatNumber)}
|
||||
</p>
|
||||
{!isUnlocked
|
||||
&& <div className="achievement-progress">
|
||||
<progress
|
||||
max={achievement.condition.amount}
|
||||
value={cappedProgress}
|
||||
/>
|
||||
<span className="achievement-progress-label">
|
||||
{formatNumber(progressValue)}
|
||||
{" / "}
|
||||
{formatNumber(achievement.condition.amount)}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
{achievement.reward !== undefined
|
||||
&& <div className="achievement-reward">
|
||||
{achievement.reward.divinity !== undefined
|
||||
&& <p>
|
||||
{"✨ +"}
|
||||
{achievement.reward.divinity}
|
||||
{" Divinity"}
|
||||
</p>
|
||||
}
|
||||
{achievement.reward.stardust !== undefined
|
||||
&& <p>
|
||||
{"🌟 +"}
|
||||
{achievement.reward.stardust}
|
||||
{" Stardust"}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div className="achievement-status">
|
||||
{isUnlocked
|
||||
? <>
|
||||
<span className="achievement-unlocked-badge">{"✓ Unlocked"}</span>
|
||||
{achievement.unlockedAt !== null
|
||||
&& <span className="achievement-unlocked-at">
|
||||
{new Date(achievement.unlockedAt).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
}
|
||||
</>
|
||||
: <span className="achievement-locked-badge">{"🔒"}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { goddess } = state;
|
||||
|
||||
if (goddess === undefined) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"The Goddess expansion is not yet unlocked."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="panel achievement-panel goddess-achievements-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"🌸 Goddess Achievements"}</h2>
|
||||
<LockToggle
|
||||
lockedCount={locked.length}
|
||||
onToggle={handleToggle}
|
||||
showLocked={showLocked}
|
||||
/>
|
||||
</div>
|
||||
<p className="achievement-progress">
|
||||
{unlocked.length}
|
||||
{" / "}
|
||||
{achievementList.length}
|
||||
{" unlocked"}
|
||||
</p>
|
||||
<div className="achievement-list">
|
||||
{visible.map((achievement) => {
|
||||
return (
|
||||
<GoddessAchievementCard
|
||||
achievement={achievement}
|
||||
formatNumber={formatNumber}
|
||||
key={achievement.id}
|
||||
progressValue={getCurrentProgress(achievement, goddess)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { GoddessAchievementsPanel };
|
||||
@@ -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 (
|
||||
<div className={`boss-card boss-${boss.status}`}>
|
||||
<div className="boss-info">
|
||||
<h3>{boss.name}</h3>
|
||||
<p>{boss.description}</p>
|
||||
{boss.status === "locked" && unlockHint !== undefined
|
||||
? <p className="unlock-hint">{unlockHint}</p>
|
||||
: null}
|
||||
{boss.consecrationRequirement > 0
|
||||
? <p className="consecration-requirement">
|
||||
{"🕊️ Requires Consecration "}
|
||||
{boss.consecrationRequirement}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
{boss.status !== "locked" && boss.status !== "defeated"
|
||||
? <div className="boss-hp">
|
||||
<div className="hp-bar">
|
||||
<div
|
||||
className="hp-fill"
|
||||
style={{ width: `${hpPercent.toFixed(1)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="hp-text">
|
||||
{formatNumber(boss.currentHp)}
|
||||
{" / "}
|
||||
{formatNumber(boss.maxHp)}
|
||||
{" HP"}
|
||||
</span>
|
||||
</div>
|
||||
: null}
|
||||
|
||||
<div className="boss-meta">
|
||||
<span className="boss-dps">
|
||||
{"💢 Boss DPS: "}
|
||||
{formatNumber(boss.damagePerSecond)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="boss-rewards">
|
||||
{boss.prayersReward > 0
|
||||
&& <span>
|
||||
{"🙏 "}
|
||||
{formatNumber(boss.prayersReward)}
|
||||
</span>
|
||||
}
|
||||
{boss.divinityReward > 0
|
||||
&& <span>
|
||||
{"✨ "}
|
||||
{formatInteger(boss.divinityReward)}
|
||||
{" Divinity"}
|
||||
</span>
|
||||
}
|
||||
{boss.stardustReward > 0
|
||||
&& <span>
|
||||
{"⭐ "}
|
||||
{formatInteger(boss.stardustReward)}
|
||||
{" Stardust"}
|
||||
</span>
|
||||
}
|
||||
{boss.equipmentRewards.length > 0
|
||||
&& <span>
|
||||
{"🗡️ "}
|
||||
{boss.equipmentRewards.length}
|
||||
{" Equipment"}
|
||||
</span>
|
||||
}
|
||||
{boss.status !== "defeated"
|
||||
&& boss.bountyDivinity > 0
|
||||
&& boss.bountyDivinityClaimed !== true
|
||||
? <span className="boss-bounty">
|
||||
{"✨ "}
|
||||
{boss.bountyDivinity}
|
||||
{" Divinity (first kill)"}
|
||||
</span>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
{boss.status === "available" || boss.status === "in_progress"
|
||||
? <button
|
||||
className="attack-button"
|
||||
disabled={!canChallenge}
|
||||
onClick={handleChallenge}
|
||||
type="button"
|
||||
>
|
||||
{isChallenging
|
||||
? "⚔️ Battling…"
|
||||
: "⚔️ Challenge"}
|
||||
</button>
|
||||
: null}
|
||||
|
||||
{boss.status === "defeated"
|
||||
? <span className="boss-badge defeated">{"☠️ Defeated"}</span>
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div aria-modal="true" className="battle-modal-overlay" role="dialog">
|
||||
<div className="battle-modal">
|
||||
<h2 className="battle-modal-title">
|
||||
{result.won
|
||||
? "⚔️ Victory!"
|
||||
: "💀 Defeated!"}
|
||||
</h2>
|
||||
|
||||
<div className="battle-stats">
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">{"⚔️ Your Party DPS"}</span>
|
||||
<span className="stat-value">{formatNumber(result.partyDPS)}</span>
|
||||
</div>
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">{"💢 Boss DPS"}</span>
|
||||
<span className="stat-value">{formatNumber(result.bossDPS)}</span>
|
||||
</div>
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">{"❤️ Boss HP Before"}</span>
|
||||
<span className="stat-value">
|
||||
{formatNumber(result.bossHpBefore)}
|
||||
{" / "}
|
||||
{formatNumber(result.bossMaxHp)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">{"❤️ Boss HP After"}</span>
|
||||
<span className="stat-value">{formatNumber(result.bossNewHp)}</span>
|
||||
</div>
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">{"🛡️ Party HP Remaining"}</span>
|
||||
<span className="stat-value">
|
||||
{formatNumber(result.partyHpRemaining)}
|
||||
{" / "}
|
||||
{formatNumber(result.partyMaxHp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.won && result.rewards !== undefined
|
||||
? <div className="battle-rewards">
|
||||
<h3>{"Rewards"}</h3>
|
||||
{result.rewards.prayers > 0
|
||||
? <p>
|
||||
{"🙏 "}
|
||||
{formatNumber(result.rewards.prayers)}
|
||||
{" Prayers"}
|
||||
</p>
|
||||
: null}
|
||||
{result.rewards.divinity > 0
|
||||
? <p>
|
||||
{"✨ "}
|
||||
{formatInteger(result.rewards.divinity)}
|
||||
{" Divinity"}
|
||||
</p>
|
||||
: null}
|
||||
{result.rewards.stardust > 0
|
||||
? <p>
|
||||
{"⭐ "}
|
||||
{formatInteger(result.rewards.stardust)}
|
||||
{" Stardust"}
|
||||
</p>
|
||||
: null}
|
||||
{result.rewards.bountyDivinity > 0
|
||||
? <p className="bounty-reward">
|
||||
{"✨ "}
|
||||
{formatInteger(result.rewards.bountyDivinity)}
|
||||
{" Divinity (first kill bonus!)"}
|
||||
</p>
|
||||
: null}
|
||||
{result.rewards.upgradeIds.length > 0
|
||||
? <p>
|
||||
{"🔓 "}
|
||||
{result.rewards.upgradeIds.length}
|
||||
{" Upgrade(s) unlocked"}
|
||||
</p>
|
||||
: null}
|
||||
{result.rewards.equipmentIds.length > 0
|
||||
? <p>
|
||||
{"🗡️ "}
|
||||
{result.rewards.equipmentIds.length}
|
||||
{" Equipment item(s) gained"}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
: null}
|
||||
|
||||
{result.casualties !== undefined && result.casualties.length > 0
|
||||
? <div className="battle-casualties">
|
||||
<h3>{"Casualties"}</h3>
|
||||
{result.casualties.map((casualty) => {
|
||||
return (
|
||||
<p key={casualty.discipleId}>
|
||||
{casualty.killed}
|
||||
{" "}
|
||||
{casualty.discipleId}
|
||||
{" lost"}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
: null}
|
||||
|
||||
<button
|
||||
className="dismiss-button"
|
||||
onClick={onDismiss}
|
||||
type="button"
|
||||
>
|
||||
{"Dismiss"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<string | null>(
|
||||
null,
|
||||
);
|
||||
const [ activeZoneId, setActiveZoneId ] = useState<string | null>(() => {
|
||||
return sessionStorage.getItem("elysium_goddess_boss_zone");
|
||||
});
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { goddess } = state;
|
||||
|
||||
if (goddess === undefined) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"The Goddess expansion is not yet unlocked."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { bosses, quests, zones } = goddess;
|
||||
|
||||
async function handleChallenge(bossId: string): Promise<void> {
|
||||
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<string, string>();
|
||||
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<string> = [];
|
||||
if (unlockBossId !== null) {
|
||||
const gateBoss = bosses.find((candidate) => {
|
||||
return candidate.id === unlockBossId;
|
||||
});
|
||||
if (gateBoss !== undefined) {
|
||||
parts.push(`⚔️ Defeat: ${gateBoss.name}`);
|
||||
}
|
||||
}
|
||||
if (unlockQuestId !== null) {
|
||||
const gateQuest = quests.find((candidate) => {
|
||||
return candidate.id === unlockQuestId;
|
||||
});
|
||||
if (gateQuest !== undefined) {
|
||||
parts.push(`📜 Complete: ${gateQuest.name}`);
|
||||
}
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
bossUnlockHints.set(boss.id, parts.join(" & "));
|
||||
}
|
||||
} else {
|
||||
const previousBoss = 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 (
|
||||
<section className="panel goddess-boss-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"⚔️ Goddess Bosses"}</h2>
|
||||
</div>
|
||||
|
||||
<div className="zone-selector">
|
||||
<button
|
||||
className={`zone-tab${activeZoneId === null
|
||||
? " zone-tab-active"
|
||||
: ""}`}
|
||||
onClick={handleShowAll}
|
||||
type="button"
|
||||
>
|
||||
{"All Zones"}
|
||||
</button>
|
||||
{zones.map((zone) => {
|
||||
function handleSelect(): void {
|
||||
handleZoneSelect(zone.id);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`zone-tab${zone.id === activeZoneId
|
||||
? " zone-tab-active"
|
||||
: ""}`}
|
||||
key={zone.id}
|
||||
onClick={handleSelect}
|
||||
title={zone.description}
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden="true">{zone.emoji}</span>
|
||||
<span className="zone-name">{zone.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{activeZoneData?.status === "locked"
|
||||
? <div className="exploration-zone-locked-hint">
|
||||
<p>{"🔒 This zone is locked."}</p>
|
||||
{activeZoneData.unlockBossId === null
|
||||
? null
|
||||
: <p>
|
||||
{"⚔️ Defeat: "}
|
||||
{bosses.find((boss) => {
|
||||
return boss.id === activeZoneData.unlockBossId;
|
||||
})?.name ?? activeZoneData.unlockBossId}
|
||||
</p>}
|
||||
{activeZoneData.unlockQuestId === null
|
||||
? null
|
||||
: <p>
|
||||
{"📜 Complete: "}
|
||||
{quests.find((quest) => {
|
||||
return quest.id === activeZoneData.unlockQuestId;
|
||||
})?.name ?? activeZoneData.unlockQuestId}
|
||||
</p>}
|
||||
</div>
|
||||
: null}
|
||||
|
||||
<div className="boss-list">
|
||||
{filteredBosses.map((boss) => {
|
||||
return (
|
||||
<GoddessBossCard
|
||||
boss={boss}
|
||||
formatInteger={formatInteger}
|
||||
formatNumber={formatNumber}
|
||||
isChallenging={challengingBossId === boss.id}
|
||||
key={boss.id}
|
||||
onChallenge={handleChallengeClick}
|
||||
unlockHint={bossUnlockHints.get(boss.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{filteredBosses.length === 0
|
||||
? <p className="empty-zone">{"No bosses to show in this zone."}</p>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
{goddessBattleResult === null
|
||||
? null
|
||||
: <GoddessBattleModal
|
||||
formatInteger={formatInteger}
|
||||
formatNumber={formatNumber}
|
||||
onDismiss={dismissGoddessBattle}
|
||||
result={goddessBattleResult}
|
||||
/>}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { GoddessBossPanel };
|
||||
@@ -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<string, string> = {
|
||||
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<string | null>(null);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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<void> {
|
||||
setPendingRecipeId(recipeId);
|
||||
try {
|
||||
await craftGoddessRecipe(recipeId);
|
||||
} finally {
|
||||
setPendingRecipeId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel crafting-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"⚗️ Sacred Crafting"}</h2>
|
||||
</div>
|
||||
|
||||
<div className="zone-selector">
|
||||
{goddessZones.map((zone) => {
|
||||
const isLocked = zone.status === "locked";
|
||||
|
||||
function handleZoneClick(): void {
|
||||
handleZoneSelect(zone.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`zone-tab ${
|
||||
activeZoneId === zone.id
|
||||
? "zone-tab-active"
|
||||
: ""
|
||||
} ${isLocked
|
||||
? "zone-tab-locked"
|
||||
: ""}`}
|
||||
disabled={isLocked}
|
||||
key={zone.id}
|
||||
onClick={handleZoneClick}
|
||||
title={isLocked
|
||||
? "Zone locked"
|
||||
: zone.name}
|
||||
type="button"
|
||||
>
|
||||
{zone.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="crafting-content">
|
||||
<div className="materials-section">
|
||||
<h3>{"📦 Sacred Materials"}</h3>
|
||||
{zoneMaterials.length === 0
|
||||
? <p className="empty-zone">{"No materials in this zone."}</p>
|
||||
: <div className="materials-list">
|
||||
{zoneMaterials.map((material) => {
|
||||
const qty = getQuantity(material.id);
|
||||
return (
|
||||
<div
|
||||
className={`material-card rarity-${material.rarity} ${
|
||||
qty === 0
|
||||
? "material-empty"
|
||||
: ""
|
||||
}`}
|
||||
key={material.id}
|
||||
>
|
||||
<img
|
||||
alt={material.name}
|
||||
className="card-thumbnail"
|
||||
src={cdnImage("materials", material.id)}
|
||||
/>
|
||||
<div className="material-info">
|
||||
<span className="material-name">{material.name}</span>
|
||||
<span className="material-rarity">{material.rarity}</span>
|
||||
</div>
|
||||
<span className="material-quantity">
|
||||
{formatNumber(qty)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="recipes-section">
|
||||
<h3>{"📜 Sacred Recipes"}</h3>
|
||||
{zoneRecipes.length === 0
|
||||
? <p className="empty-zone">{"No recipes in this zone."}</p>
|
||||
: <div className="recipes-list">
|
||||
{zoneRecipes.map((recipe) => {
|
||||
const crafted = craftedIds.includes(recipe.id);
|
||||
const affordable = canAffordRecipe(recipe.id);
|
||||
const isPending = pendingRecipeId === recipe.id;
|
||||
|
||||
function handleCraftClick(): void {
|
||||
void handleCraft(recipe.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`recipe-card ${
|
||||
crafted
|
||||
? "recipe-crafted"
|
||||
: ""
|
||||
} ${!affordable && !crafted
|
||||
? "recipe-unaffordable"
|
||||
: ""}`}
|
||||
key={recipe.id}
|
||||
>
|
||||
<img
|
||||
alt={recipe.name}
|
||||
className="card-thumbnail"
|
||||
src={cdnImage("recipes", recipe.id)}
|
||||
/>
|
||||
<div className="recipe-info">
|
||||
<h4>{recipe.name}</h4>
|
||||
<p className="recipe-description">{recipe.description}</p>
|
||||
<div className="recipe-bonus">
|
||||
<span className="bonus-label">
|
||||
{bonusLabel[recipe.bonus.type] ?? recipe.bonus.type}
|
||||
</span>
|
||||
<span className="bonus-value">
|
||||
{"×"}
|
||||
{recipe.bonus.value.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="recipe-requirements">
|
||||
{recipe.requiredMaterials.map((request) => {
|
||||
const have = getQuantity(request.materialId);
|
||||
const enough = have >= request.quantity;
|
||||
const matName
|
||||
= GODDESS_MATERIALS.find((mat) => {
|
||||
return mat.id === request.materialId;
|
||||
})?.name ?? request.materialId;
|
||||
return (
|
||||
<span
|
||||
className={`req-tag ${
|
||||
enough
|
||||
? "req-met"
|
||||
: "req-missing"
|
||||
}`}
|
||||
key={request.materialId}
|
||||
>
|
||||
{matName}
|
||||
{": "}
|
||||
{formatNumber(have)}
|
||||
{"/"}
|
||||
{formatNumber(request.quantity)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="recipe-action">
|
||||
{crafted
|
||||
? <span className="quest-badge active">
|
||||
{"✅ Crafted"}
|
||||
</span>
|
||||
: <button
|
||||
className="craft-button"
|
||||
disabled={
|
||||
!affordable || isPending || pendingRecipeId !== null
|
||||
}
|
||||
onClick={handleCraftClick}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Crafting..."
|
||||
: "⚗️ Craft"}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { GoddessCraftingPanel };
|
||||
@@ -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<string, string> = {
|
||||
common: "#9e9e9e",
|
||||
epic: "#9c27b0",
|
||||
legendary: "#ff9800",
|
||||
rare: "#2196f3",
|
||||
};
|
||||
|
||||
const rarityLabel: Record<string, string> = {
|
||||
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<string> = [];
|
||||
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<string> = [];
|
||||
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 (
|
||||
<div className={cardClassName}>
|
||||
<div className="equipment-card-header">
|
||||
<span className="equipment-type-icon">{typeEmoji}</span>
|
||||
<span className="equipment-name">{item.name}</span>
|
||||
<span
|
||||
className="equipment-rarity-badge"
|
||||
style={{ color: rarityColour[item.rarity] }}
|
||||
>
|
||||
{rarityLabel[item.rarity]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="equipment-description">{item.description}</p>
|
||||
<p className="equipment-bonus">{bonusDescription(item)}</p>
|
||||
{item.setId === undefined
|
||||
? null
|
||||
: <p className="equipment-set">{"Set: "}{item.setId}</p>}
|
||||
<div className="equipment-card-actions">
|
||||
{item.owned && item.equipped
|
||||
? <span className="equipment-equipped-badge">{"✅ Equipped"}</span>
|
||||
: null}
|
||||
{item.owned && !item.equipped
|
||||
? <button
|
||||
className="btn-equip"
|
||||
onClick={handleEquip}
|
||||
type="button"
|
||||
>
|
||||
{"Equip"}
|
||||
</button>
|
||||
: null}
|
||||
{!item.owned && item.cost !== undefined
|
||||
? <button
|
||||
className="btn-buy"
|
||||
disabled={!canAfford}
|
||||
onClick={handleBuy}
|
||||
title={canAfford
|
||||
? ""
|
||||
: "Not enough resources"}
|
||||
type="button"
|
||||
>
|
||||
{"Buy — "}
|
||||
{costLabel(item.cost, formatNumber)}
|
||||
</button>
|
||||
: null}
|
||||
{!item.owned && item.cost === undefined
|
||||
? <span className="equipment-drop-hint">{"🎲 Boss Drop Only"}</span>
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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<TabFilter>("all");
|
||||
|
||||
if (state === null) {
|
||||
return <div className="panel"><p>{"Loading..."}</p></div>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="goddess-equipment-panel">
|
||||
<div className="panel-resource-bar">
|
||||
<span className="resource-item">
|
||||
{"🙏 Prayers: "}
|
||||
{formatNumber(prayers)}
|
||||
</span>
|
||||
<span className="resource-item">
|
||||
{"✨ Divinity: "}
|
||||
{formatNumber(divinity)}
|
||||
</span>
|
||||
<span className="resource-item">
|
||||
{"⭐ Stardust: "}
|
||||
{formatNumber(stardust)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="equipment-tabs">
|
||||
{tabs.map((tab) => {
|
||||
function handleTabClick(): void {
|
||||
setActiveTab(tab.id);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`tab-btn${activeTab === tab.id
|
||||
? " active"
|
||||
: ""}`}
|
||||
key={tab.id}
|
||||
onClick={handleTabClick}
|
||||
type="button"
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="equipment-grid">
|
||||
{filteredEquipment.map((item) => {
|
||||
return (
|
||||
<GoddessEquipmentCard
|
||||
divinity={divinity}
|
||||
formatNumber={formatNumber}
|
||||
item={item}
|
||||
key={item.id}
|
||||
prayers={prayers}
|
||||
stardust={stardust}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{filteredEquipment.length === 0
|
||||
? <p className="empty-state">
|
||||
{"No equipment in this category yet."}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<string | null>(null);
|
||||
const [ lastResult, setLastResult ] = useState<CollectResult | null>(null);
|
||||
const [ claimableAreaIds, setClaimableAreaIds ]
|
||||
= useState<ReadonlySet<string>>(new Set());
|
||||
|
||||
const stateReference = useRef(state);
|
||||
stateReference.current = state;
|
||||
|
||||
const claimableReference = useRef(claimableAreaIds);
|
||||
claimableReference.current = claimableAreaIds;
|
||||
|
||||
useEffect(() => {
|
||||
const pollClaimable = async(): Promise<void> => {
|
||||
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 (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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<void> {
|
||||
setPendingAreaId(areaId);
|
||||
try {
|
||||
await startGoddessExploration(areaId);
|
||||
} finally {
|
||||
setPendingAreaId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCollect(areaId: string): Promise<void> {
|
||||
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 (
|
||||
<section className="panel exploration-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"🗺️ Divine Exploration"}</h2>
|
||||
</div>
|
||||
|
||||
{lastResult === null
|
||||
? null
|
||||
: <div className="exploration-result">
|
||||
<button
|
||||
className="exploration-result-close"
|
||||
onClick={handleDismissResult}
|
||||
type="button"
|
||||
>
|
||||
{"✕"}
|
||||
</button>
|
||||
{lastResult.response.foundNothing
|
||||
? <p className="exploration-nothing">
|
||||
{lastResult.response.nothingMessage}
|
||||
</p>
|
||||
: <>
|
||||
{lastResult.response.event === null
|
||||
? null
|
||||
: <p className="exploration-event-text">
|
||||
{lastResult.response.event.text}
|
||||
</p>
|
||||
}
|
||||
<div className="exploration-rewards">
|
||||
{prayersChange !== 0
|
||||
&& <span
|
||||
className={`reward-tag ${prayersChange > 0
|
||||
? ""
|
||||
: "negative"}`}
|
||||
>
|
||||
{"🙏 "}
|
||||
{prayersChange > 0
|
||||
? "+"
|
||||
: ""}
|
||||
{formatNumber(prayersChange)}
|
||||
{" prayers"}
|
||||
</span>
|
||||
}
|
||||
{discipleLostCount > 0
|
||||
&& <span className="reward-tag negative">
|
||||
{"👤 -"}
|
||||
{formatNumber(discipleLostCount)}
|
||||
{" disciples lost"}
|
||||
</span>
|
||||
}
|
||||
{lastResult.response.event?.materialGained !== null
|
||||
&& lastResult.response.event?.materialGained !== undefined
|
||||
? <span className="reward-tag material-tag">
|
||||
{"📦 +"}
|
||||
{lastResult.response.event.materialGained.quantity}{" "}
|
||||
{/* eslint-disable-next-line stylistic/max-len -- long property chain cannot be shortened */}
|
||||
{lastResult.response.event.materialGained.materialId.replaceAll(
|
||||
"_",
|
||||
" ",
|
||||
)}
|
||||
{" (event)"}
|
||||
</span>
|
||||
: null}
|
||||
{lastResult.response.materialsFound.map((foundMaterial) => {
|
||||
return (
|
||||
<span
|
||||
className="reward-tag material-tag"
|
||||
key={foundMaterial.materialId}
|
||||
>
|
||||
{"📦 +"}
|
||||
{foundMaterial.quantity}{" "}
|
||||
{foundMaterial.materialId.replaceAll("_", " ")}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="zone-selector">
|
||||
{goddessZones.map((zone) => {
|
||||
const isLocked = zone.status === "locked";
|
||||
|
||||
function handleZoneClick(): void {
|
||||
handleZoneSelect(zone.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`zone-tab ${
|
||||
activeZoneId === zone.id
|
||||
? "zone-tab-active"
|
||||
: ""
|
||||
} ${isLocked
|
||||
? "zone-tab-locked"
|
||||
: ""}`}
|
||||
disabled={isLocked}
|
||||
key={zone.id}
|
||||
onClick={handleZoneClick}
|
||||
title={isLocked
|
||||
? "Zone locked"
|
||||
: zone.name}
|
||||
type="button"
|
||||
>
|
||||
{zone.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{zoneIsLocked
|
||||
? <div className="exploration-zone-locked-hint">
|
||||
<p>{"🔒 This divine zone is locked."}</p>
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
|
||||
<div className="exploration-list">
|
||||
{zoneAreas.map((area) => {
|
||||
const areaState = explorationState?.areas.find(
|
||||
(explorationArea) => {
|
||||
return explorationArea.id === area.id;
|
||||
},
|
||||
);
|
||||
const status = areaState?.status ?? "locked";
|
||||
const startedAt = areaState?.startedAt ?? 0;
|
||||
const 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 (
|
||||
<div
|
||||
className={`exploration-card exploration-${status}`}
|
||||
key={area.id}
|
||||
>
|
||||
<img
|
||||
alt={area.name}
|
||||
className="card-thumbnail"
|
||||
src={cdnImage("explorations", area.id)}
|
||||
/>
|
||||
<div className="exploration-info">
|
||||
<h3>
|
||||
{area.name}
|
||||
{areaState?.completedOnce === true
|
||||
? <span className="exploration-discovered">{" 📖"}</span>
|
||||
: null}
|
||||
</h3>
|
||||
<p>{area.description}</p>
|
||||
<span className="exploration-duration">
|
||||
{"⏱️ "}
|
||||
{formatDuration(area.durationSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="exploration-action">
|
||||
{status === "locked"
|
||||
&& <span className="quest-badge locked">{"🔒 Locked"}</span>
|
||||
}
|
||||
{status === "available"
|
||||
&& <button
|
||||
className="start-quest-button"
|
||||
disabled={isPending || hasActiveExploration}
|
||||
onClick={handleStartClick}
|
||||
title={
|
||||
hasActiveExploration
|
||||
? "A divine exploration is already in progress"
|
||||
: undefined
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Departing..."
|
||||
: `Explore (${formatDuration(area.durationSeconds)})`}
|
||||
</button>
|
||||
}
|
||||
{status === "in_progress" && !isReady
|
||||
&& <span className="quest-badge active">
|
||||
{"⏳ "}
|
||||
{/* eslint-disable-next-line stylistic/max-len -- long call cannot be shortened */}
|
||||
{formatDuration(Math.ceil(timeRemaining(endsAt, startedAt, area.durationSeconds)))}
|
||||
{" remaining"}
|
||||
</span>
|
||||
}
|
||||
{status === "in_progress" && isReady
|
||||
? <button
|
||||
className="collect-button"
|
||||
disabled={isPending}
|
||||
onClick={handleCollectClick}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Collecting..."
|
||||
: "📦 Collect Results"}
|
||||
</button>
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{zoneAreas.length === 0
|
||||
&& <p className="empty-zone">
|
||||
{"No exploration areas in this zone."}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { GoddessExplorationPanel };
|
||||
@@ -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 (
|
||||
<div className={`quest-card quest-${quest.status}`}>
|
||||
<div className="quest-info">
|
||||
<h3>{quest.name}</h3>
|
||||
<p>{quest.description}</p>
|
||||
<p className="quest-duration">
|
||||
{"⏱ "}
|
||||
{formatDuration(quest.durationSeconds)}
|
||||
</p>
|
||||
<div className="quest-rewards">
|
||||
{quest.rewards.map((reward, rewardIndex) => {
|
||||
return <span
|
||||
className="reward-tag"
|
||||
key={`${reward.type}-${reward.targetId ?? String(reward.amount ?? rewardIndex)}`}
|
||||
>
|
||||
{getRewardLabel(reward, formatNumber)}
|
||||
</span>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="quest-action">
|
||||
{quest.status === "locked" && !zoneIsOpen
|
||||
&& <span className="quest-badge locked">{"🔒 Zone Locked"}</span>
|
||||
}
|
||||
{quest.status === "locked" && zoneIsOpen
|
||||
? <>
|
||||
<span className="quest-badge locked">{"🔒 Locked"}</span>
|
||||
{unlockHint !== undefined
|
||||
&& <p className="unlock-hint">
|
||||
{"📜 Complete: "}
|
||||
{unlockHint}
|
||||
</p>
|
||||
}
|
||||
</>
|
||||
: null
|
||||
}
|
||||
{quest.status === "available"
|
||||
&& <span className="quest-badge available">{"📋 Available"}</span>
|
||||
}
|
||||
{quest.status === "active"
|
||||
&& <span className="quest-badge active">{"⏳ In Progress"}</span>
|
||||
}
|
||||
{quest.status === "completed"
|
||||
&& <span className="quest-badge completed">{"✅ Completed"}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const goddessState = state.goddess;
|
||||
if (goddessState === undefined) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Goddess expansion not yet unlocked."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="panel goddess-quests-panel">
|
||||
<h2>{"Goddess Quests"}</h2>
|
||||
<div className="zone-filter-buttons">
|
||||
{zones.map((zone: GoddessZone) => {
|
||||
function handleClick(): void {
|
||||
handleZoneSelect(zone.id);
|
||||
}
|
||||
return <button
|
||||
className={`zone-filter-button ${zone.id === activeZoneId
|
||||
? "active"
|
||||
: ""} ${zone.status === "locked"
|
||||
? "zone-locked"
|
||||
: ""}`}
|
||||
key={zone.id}
|
||||
onClick={handleClick}
|
||||
title={zone.status === "locked"
|
||||
? "Zone locked"
|
||||
: zone.name}
|
||||
type="button"
|
||||
>
|
||||
{zone.emoji}
|
||||
{" "}
|
||||
{zone.name}
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
{activeZone !== undefined
|
||||
&& <div className="zone-info">
|
||||
<p className="zone-description">{activeZone.description}</p>
|
||||
<p className="zone-progress">
|
||||
{String(completedCount)}
|
||||
{" / "}
|
||||
{String(zoneQuests.length)}
|
||||
{" quests completed"}
|
||||
</p>
|
||||
{activeZone.status === "locked"
|
||||
&& <p className="zone-locked-notice">
|
||||
{"🔒 This zone is locked. Defeat the required goddess boss"}
|
||||
{" to unlock it."}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div className="quest-list">
|
||||
{zoneQuests.length === 0
|
||||
? <p className="empty-state">{"No quests in this zone."}</p>
|
||||
: zoneQuests.map((quest: GoddessQuest) => {
|
||||
return <GoddessQuestCard
|
||||
key={quest.id}
|
||||
quest={quest}
|
||||
unlockHint={getUnlockHint(quest)}
|
||||
zoneIsOpen={zoneIsOpen}
|
||||
/>;
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { GoddessQuestsPanel };
|
||||
@@ -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<string> = [];
|
||||
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<GoddessUpgrade["target"], string> = {
|
||||
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<void> {
|
||||
await buyGoddessUpgrade(upgrade.id);
|
||||
}
|
||||
|
||||
const multiplierPct = Math.round((upgrade.multiplier - 1) * 100);
|
||||
|
||||
if (upgrade.purchased) {
|
||||
return (
|
||||
<div className="goddess-upgrade-card purchased">
|
||||
<div className="upgrade-card-header">
|
||||
<span className="upgrade-name">
|
||||
{"✅ "}
|
||||
{upgrade.name}
|
||||
</span>
|
||||
<span className="upgrade-target-badge">
|
||||
{targetLabel(upgrade.target)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="upgrade-description">{upgrade.description}</p>
|
||||
<p className="upgrade-effect">{`×${String(upgrade.multiplier)} (+${String(multiplierPct)}%)`}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (upgrade.unlocked) {
|
||||
return (
|
||||
<div className={`goddess-upgrade-card available${canAfford
|
||||
? ""
|
||||
: " cannot-afford"}`}>
|
||||
<div className="upgrade-card-header">
|
||||
<span className="upgrade-name">{upgrade.name}</span>
|
||||
<span className="upgrade-target-badge">
|
||||
{targetLabel(upgrade.target)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="upgrade-description">{upgrade.description}</p>
|
||||
<p className="upgrade-effect">{`×${String(upgrade.multiplier)} (+${String(multiplierPct)}%)`}</p>
|
||||
{upgrade.discipleId === undefined
|
||||
? null
|
||||
: <p className="upgrade-disciple">
|
||||
{"🧎 Disciple: "}
|
||||
{upgrade.discipleId}
|
||||
</p>
|
||||
}
|
||||
<button
|
||||
className="btn-buy"
|
||||
disabled={!canAfford}
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- intentional async handler
|
||||
onClick={handleBuy}
|
||||
title={canAfford
|
||||
? ""
|
||||
: "Not enough resources"}
|
||||
type="button"
|
||||
>
|
||||
{"Buy — "}
|
||||
{costLabel(upgrade, formatNumber)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="goddess-upgrade-card locked">
|
||||
<div className="upgrade-card-header">
|
||||
<span className="upgrade-name">{"🔒 ???"}</span>
|
||||
<span className="upgrade-target-badge">
|
||||
{targetLabel(upgrade.target)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="upgrade-description">{"Not yet unlocked."}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 <div className="panel"><p>{"Loading..."}</p></div>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="goddess-upgrades-panel">
|
||||
<div className="panel-resource-bar">
|
||||
<span className="resource-item">
|
||||
{"🙏 Prayers: "}
|
||||
{formatNumber(prayers)}
|
||||
</span>
|
||||
<span className="resource-item">
|
||||
{"✨ Divinity: "}
|
||||
{formatNumber(divinity)}
|
||||
</span>
|
||||
<span className="resource-item">
|
||||
{"⭐ Stardust: "}
|
||||
{formatNumber(stardust)}
|
||||
</span>
|
||||
</div>
|
||||
{available.length > 0
|
||||
? <section className="upgrades-section">
|
||||
<h3 className="section-heading">{"Available Upgrades"}</h3>
|
||||
<div className="upgrades-grid">
|
||||
{available.map((upgrade) => {
|
||||
return (
|
||||
<GoddessUpgradeCard
|
||||
divinity={divinity}
|
||||
formatNumber={formatNumber}
|
||||
key={upgrade.id}
|
||||
prayers={prayers}
|
||||
stardust={stardust}
|
||||
upgrade={upgrade}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
: null}
|
||||
{locked.length > 0
|
||||
? <section className="upgrades-section">
|
||||
<h3 className="section-heading">{"Locked Upgrades"}</h3>
|
||||
<div className="upgrades-grid">
|
||||
{locked.map((upgrade) => {
|
||||
return (
|
||||
<GoddessUpgradeCard
|
||||
divinity={divinity}
|
||||
formatNumber={formatNumber}
|
||||
key={upgrade.id}
|
||||
prayers={prayers}
|
||||
stardust={stardust}
|
||||
upgrade={upgrade}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
: null}
|
||||
{purchased.length > 0
|
||||
? <section className="upgrades-section">
|
||||
<h3 className="section-heading">{"Purchased Upgrades"}</h3>
|
||||
<div className="upgrades-grid">
|
||||
{purchased.map((upgrade) => {
|
||||
return (
|
||||
<GoddessUpgradeCard
|
||||
divinity={divinity}
|
||||
formatNumber={formatNumber}
|
||||
key={upgrade.id}
|
||||
prayers={prayers}
|
||||
stardust={stardust}
|
||||
upgrade={upgrade}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
: null}
|
||||
{upgrades.length === 0
|
||||
? <p className="empty-state">{"No goddess upgrades available yet."}</p>
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<div className={`zone-card${isLocked
|
||||
? " locked"
|
||||
: ""}`}>
|
||||
<div className="zone-card-header">
|
||||
<span aria-hidden="true" className="zone-emoji">{zone.emoji}</span>
|
||||
<h3 className="zone-name">{zone.name}</h3>
|
||||
{isLocked
|
||||
? <span aria-label="Locked" className="zone-lock-icon">{"🔒"}</span>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
<p className="zone-description">{zone.description}</p>
|
||||
|
||||
{isLocked
|
||||
&& (unlockBossName !== undefined || unlockQuestName !== undefined)
|
||||
? <div className="zone-unlock-requirements">
|
||||
<p className="zone-unlock-label">{"Unlock requirements:"}</p>
|
||||
{unlockBossName === undefined
|
||||
? null
|
||||
: <p className="zone-unlock-item">
|
||||
{"⚔️ Defeat: "}
|
||||
{unlockBossName}
|
||||
</p>
|
||||
}
|
||||
{unlockQuestName === undefined
|
||||
? null
|
||||
: <p className="zone-unlock-item">
|
||||
{"📜 Complete: "}
|
||||
{unlockQuestName}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
: null}
|
||||
|
||||
{isLocked
|
||||
? null
|
||||
: <span className="zone-badge unlocked">{"✨ Unlocked"}</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { goddess } = state;
|
||||
|
||||
if (goddess === undefined) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"The Goddess expansion is not yet unlocked."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { bosses: goddessBosses, quests: goddessQuests, zones } = goddess;
|
||||
|
||||
const defeatedBossIds = new Set(
|
||||
goddessBosses.
|
||||
filter((boss) => {
|
||||
return boss.status === "defeated";
|
||||
}).
|
||||
map((boss) => {
|
||||
return boss.id;
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="panel goddess-zones-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"🌟 Goddess Zones"}</h2>
|
||||
</div>
|
||||
|
||||
<div className="zone-grid">
|
||||
{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 (
|
||||
<GoddessZoneCard
|
||||
isLocked={isLocked}
|
||||
key={zone.id}
|
||||
unlockBossName={unlockBoss?.name}
|
||||
unlockQuestName={unlockQuest?.name}
|
||||
zone={zone}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { GoddessZonesPanel };
|
||||
@@ -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<void>;
|
||||
|
||||
/**
|
||||
* 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<ConsecrationResponse>;
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
|
||||
/**
|
||||
* Perform an enlightenment — goddess transcendence, earning stardust.
|
||||
*/
|
||||
enlighten: ()=> Promise<EnlightenmentResponse>;
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
|
||||
/**
|
||||
* Purchase a goddess upgrade using prayers/divinity/stardust.
|
||||
*/
|
||||
buyGoddessUpgrade: (upgradeId: string)=> Promise<void>;
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
|
||||
/**
|
||||
* Start a goddess exploration in the given area.
|
||||
*/
|
||||
startGoddessExploration: (areaId: string)=> Promise<void>;
|
||||
|
||||
/**
|
||||
* Collect results of a completed goddess exploration.
|
||||
*/
|
||||
collectGoddessExploration: (
|
||||
areaId: string,
|
||||
)=> Promise<GoddessExploreCollectResponse>;
|
||||
}
|
||||
|
||||
export interface BattleResult {
|
||||
@@ -713,6 +818,10 @@ export const GameProvider = ({
|
||||
} | null>(null);
|
||||
const [ autoBossError, setAutoBossError ] = useState<string | null>(null);
|
||||
const [ bossError, setBossError ] = useState<string | null>(null);
|
||||
const [ goddessBattleResult, setGoddessBattleResult ]
|
||||
= useState<GoddessBossChallengeResponse | null>(null);
|
||||
const [ showConsecrationToast, setShowConsecrationToast ] = useState(false);
|
||||
const [ showEnlightenmentToast, setShowEnlightenmentToast ] = useState(false);
|
||||
const syncErrorTimerReference = useRef<ReturnType<typeof setTimeout> | 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<GoddessExploreCollectResponse> => {
|
||||
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,
|
||||
|
||||
@@ -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<CraftingRecipe> = [
|
||||
// ── 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",
|
||||
},
|
||||
];
|
||||
@@ -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<GoddessExplorationAreaSummary> = [
|
||||
// ── 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",
|
||||
},
|
||||
];
|
||||
@@ -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<Material> = [
|
||||
// ── 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",
|
||||
},
|
||||
];
|
||||
@@ -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<Achievement> => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<GoddessAchievement> => {
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user