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
506 lines
15 KiB
TypeScript
506 lines
15 KiB
TypeScript
/**
|
|
* @file Vampire Boss panel — challenge vampire 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 {
|
|
VampireBoss,
|
|
VampireBossChallengeResponse,
|
|
VampireZone,
|
|
} from "@elysium/types";
|
|
|
|
interface VampireBossCardProperties {
|
|
readonly boss: VampireBoss;
|
|
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 vampire 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 VampireBossCard = ({
|
|
boss,
|
|
onChallenge,
|
|
isChallenging,
|
|
unlockHint,
|
|
formatNumber,
|
|
formatInteger,
|
|
}: VampireBossCardProperties): 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.siringRequirement > 0
|
|
? <p className="consecration-requirement">
|
|
{"🩸 Requires Siring "}
|
|
{boss.siringRequirement}
|
|
</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.bloodReward > 0
|
|
&& <span>
|
|
{"🩸 "}
|
|
{formatNumber(boss.bloodReward)}
|
|
{" Blood"}
|
|
</span>
|
|
}
|
|
{boss.ichorReward > 0
|
|
&& <span>
|
|
{"💧 "}
|
|
{formatInteger(boss.ichorReward)}
|
|
{" Ichor"}
|
|
</span>
|
|
}
|
|
{boss.soulShardsReward > 0
|
|
&& <span>
|
|
{"💠 "}
|
|
{formatInteger(boss.soulShardsReward)}
|
|
{" Soul Shards"}
|
|
</span>
|
|
}
|
|
{boss.equipmentRewards.length > 0
|
|
&& <span>
|
|
{"🦇 "}
|
|
{boss.equipmentRewards.length}
|
|
{" Equipment"}
|
|
</span>
|
|
}
|
|
{boss.status !== "defeated"
|
|
&& boss.bountyIchor > 0
|
|
&& boss.bountyIchorClaimed !== true
|
|
? <span className="boss-bounty">
|
|
{"💧 "}
|
|
{boss.bountyIchor}
|
|
{" Ichor (first kill)"}
|
|
</span>
|
|
: null}
|
|
</div>
|
|
|
|
{boss.status === "available" || boss.status === "in_progress"
|
|
? <button
|
|
className="attack-button"
|
|
disabled={!canChallenge}
|
|
onClick={handleChallenge}
|
|
type="button"
|
|
>
|
|
{isChallenging
|
|
? "🩸 Hunting…"
|
|
: "🩸 Challenge"}
|
|
</button>
|
|
: null}
|
|
|
|
{boss.status === "defeated"
|
|
? <span className="boss-badge defeated">{"☠️ Defeated"}</span>
|
|
: null}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface VampireBattleModalProperties {
|
|
readonly result: VampireBossChallengeResponse;
|
|
readonly onDismiss: ()=> void;
|
|
readonly formatNumber: (n: number)=> string;
|
|
readonly formatInteger: (n: number)=> string;
|
|
}
|
|
|
|
/**
|
|
* Renders the vampire 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 VampireBattleModal = ({
|
|
result,
|
|
onDismiss,
|
|
formatNumber,
|
|
formatInteger,
|
|
}: VampireBattleModalProperties): 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 Thrall 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">{"🛡️ Thrall 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.blood > 0
|
|
? <p>
|
|
{"🩸 "}
|
|
{formatNumber(result.rewards.blood)}
|
|
{" Blood"}
|
|
</p>
|
|
: null}
|
|
{result.rewards.ichor > 0
|
|
? <p>
|
|
{"💧 "}
|
|
{formatInteger(result.rewards.ichor)}
|
|
{" Ichor"}
|
|
</p>
|
|
: null}
|
|
{result.rewards.soulShards > 0
|
|
? <p>
|
|
{"💠 "}
|
|
{formatInteger(result.rewards.soulShards)}
|
|
{" Soul Shards"}
|
|
</p>
|
|
: null}
|
|
{result.rewards.bountyIchor > 0
|
|
? <p className="bounty-reward">
|
|
{"💧 "}
|
|
{formatInteger(result.rewards.bountyIchor)}
|
|
{" Ichor (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.thrallId}>
|
|
{casualty.killed}
|
|
{" "}
|
|
{casualty.thrallId}
|
|
{" lost"}
|
|
</p>
|
|
);
|
|
})}
|
|
</div>
|
|
: null}
|
|
|
|
<button
|
|
className="dismiss-button"
|
|
onClick={onDismiss}
|
|
type="button"
|
|
>
|
|
{"Dismiss"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Renders the Vampire Boss panel with zone filtering and battle result modal.
|
|
* @returns The JSX element.
|
|
*/
|
|
const VampireBossPanel = (): JSX.Element => {
|
|
const {
|
|
state,
|
|
challengeVampireBoss,
|
|
vampireBattleResult,
|
|
dismissVampireBattle,
|
|
formatNumber,
|
|
formatInteger,
|
|
vampirePreview,
|
|
} = useGame();
|
|
|
|
const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
|
|
null,
|
|
);
|
|
const [ activeZoneId, setActiveZoneId ] = useState<string | null>(() => {
|
|
return sessionStorage.getItem("elysium_vampire_boss_zone");
|
|
});
|
|
|
|
if (state === null) {
|
|
return (
|
|
<section className="panel">
|
|
<p>{"Loading..."}</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const vampire = state.vampire ?? vampirePreview;
|
|
|
|
if (vampire === undefined) {
|
|
return (
|
|
<section className="panel">
|
|
<p>{"The Vampire expansion is not yet unlocked."}</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const { bosses, quests, zones } = vampire;
|
|
|
|
async function handleChallenge(bossId: string): Promise<void> {
|
|
setChallengingBossId(bossId);
|
|
try {
|
|
await challengeVampireBoss(bossId);
|
|
} finally {
|
|
setChallengingBossId(null);
|
|
}
|
|
}
|
|
|
|
function handleChallengeClick(bossId: string): void {
|
|
void handleChallenge(bossId);
|
|
}
|
|
|
|
function handleZoneSelect(zoneId: string): void {
|
|
setActiveZoneId(zoneId);
|
|
sessionStorage.setItem("elysium_vampire_boss_zone", zoneId);
|
|
}
|
|
|
|
function handleShowAll(): void {
|
|
setActiveZoneId(null);
|
|
sessionStorage.removeItem("elysium_vampire_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: VampireZone | undefined = activeZoneId === null
|
|
? undefined
|
|
: zones.find((zone) => {
|
|
return zone.id === activeZoneId;
|
|
});
|
|
|
|
return (
|
|
<section className="panel vampire-boss-panel">
|
|
<div className="panel-header">
|
|
<h2>{"🩸 Vampire 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 (
|
|
<VampireBossCard
|
|
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>
|
|
|
|
{vampireBattleResult === null
|
|
? null
|
|
: <VampireBattleModal
|
|
formatInteger={formatInteger}
|
|
formatNumber={formatNumber}
|
|
onDismiss={dismissVampireBattle}
|
|
result={vampireBattleResult}
|
|
/>}
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export { VampireBossPanel };
|