generated from nhcarrigan/template
397169e3dc
Add an expansion preview system so the Goddess and Vampire panels render their full content (zones, bosses, thralls, achievements, etc.) even when the expansion has not been unlocked, with all interactive elements visually disabled. - API /load now returns expansionPreview alongside game state, populated from initialGoddessState() and initialVampireState() — never part of the saved blob - LoadResponse type updated with expansionPreview field - gameContext exposes goddessPreview and vampirePreview, stored in separate state vars that never touch stateReference so saves are never polluted - gameLayout applies expansion-preview CSS class when viewing a locked expansion with preview data available, and shows the coming-soon banner - All 22 expansion panels updated to use state.vampire ?? vampirePreview and state.goddess ?? goddessPreview for display - CSS disables all buttons/inputs/selects inside .expansion-preview - apotheosis service patched to never auto-initialise goddess state — expansion remains locked until explicitly released
505 lines
15 KiB
TypeScript
505 lines
15 KiB
TypeScript
/**
|
|
* @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,
|
|
goddessPreview,
|
|
} = 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.goddess ?? goddessPreview;
|
|
|
|
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 };
|