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:
2026-04-13 18:38:27 -07:00
committed by Naomi Carrigan
parent 96d6759661
commit 91c9f52daf
21 changed files with 7093 additions and 108 deletions
+169
View File
@@ -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,
};
+133
View File
@@ -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 };
+37 -4
View File
@@ -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 };
+586 -5
View File
@@ -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,
+439
View File
@@ -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",
},
];
+409
View File
@@ -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",
},
];
+333
View File
@@ -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,
+595
View File
@@ -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;
}
-99
View File
@@ -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