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