feat: vampire boss panel and thralls panel with combat simulation

Implements VampireBossPanel (zone filtering, HP bar, battle modal with
rewards/casualties) and VampireThrallsPanel (batch buy with geometric
cost scaling). Wires challengeVampireBoss, dismissVampireBattle, and
buyVampireThrall into GameContext with correct sort-key ordering.
This commit is contained in:
2026-04-16 12:17:45 -07:00
committed by Naomi Carrigan
parent 3e34701d32
commit bd88eecda5
4 changed files with 922 additions and 6 deletions
@@ -0,0 +1,504 @@
/**
* @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,
} = 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;
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 };