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
+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 };