Files
elysium/apps/web/src/components/game/bossPanel.tsx
T
hikari 1195b657a0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m10s
CI / Lint, Build & Test (push) Successful in 1m13s
feat: another balance and bug fix pass (#238)
Working through open issues — fixes, balance changes, and features.

## Closed

- Closes #161
- Closes #181
- Closes #191
- Closes #199
- Closes #201
- Closes #202
- Closes #203
- Closes #204
- Closes #205
- Closes #206
- Closes #208
- Closes #211
- Closes #212
- Closes #213
- Closes #214
- Closes #216
- Closes #219
- Closes #220
- Closes #221
- Closes #222
- Closes #224
- Closes #225
- Closes #226
- Closes #228
- Closes #229
- Closes #230
- Closes #231
- Closes #232
- Closes #233
- Closes #234
- Closes #235
- Closes #236

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #238
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-04-06 18:17:00 -07:00

421 lines
12 KiB
TypeScript

/**
* @file Boss panel component for viewing and challenging zone bosses.
* @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 -- Boss card requires many conditional render paths */
/* eslint-disable max-statements -- Boss panel requires many variable declarations */
/* eslint-disable max-lines -- Boss panel with sub-component and helper function */
import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js";
import { computePartyCombatPower } from "../../engine/tick.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js";
import { ZoneSelector } from "./zoneSelector.js";
import type { Boss } from "@elysium/types";
interface BossCardProperties {
readonly boss: Boss;
readonly prestigeCount: number;
readonly onChallenge: (bossId: string)=> void;
readonly isChallenging: boolean;
readonly unlockHint: string | undefined;
readonly formatInteger: (n: number)=> string;
readonly formatNumber: (n: number)=> string;
}
/**
* Renders a single boss card.
* @param props - The boss card properties.
* @param props.boss - The boss data.
* @param props.prestigeCount - The current prestige count for lock checking.
* @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.formatInteger - The integer formatting utility function.
* @param props.formatNumber - The number formatting utility function.
* @returns The JSX element.
*/
const BossCard = ({
boss,
prestigeCount,
onChallenge,
isChallenging,
unlockHint,
formatInteger,
formatNumber,
}: BossCardProperties): JSX.Element => {
const scaled = boss.currentHp * 100;
const hpPercent = scaled / boss.maxHp;
const isPrestigeLocked = boss.prestigeRequirement > prestigeCount;
const canChallenge
= (boss.status === "available" || boss.status === "in_progress")
&& !isChallenging;
function handleChallenge(): void {
onChallenge(boss.id);
}
return (
<div className={`boss-card boss-${boss.status}`}>
<img
alt={boss.name}
className="card-thumbnail"
src={cdnImage("bosses", boss.id)}
/>
<div className="boss-info">
<h3>{boss.name}</h3>
<p>{boss.description}</p>
{isPrestigeLocked && boss.status === "locked"
? <p className="prestige-lock">
{"🔒 Requires Prestige "}
{boss.prestigeRequirement}
</p>
: null}
{!isPrestigeLocked
&& boss.status === "locked"
&& unlockHint !== undefined
? <p className="unlock-hint">{unlockHint}</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>
}
<div className="boss-meta">
<span className="boss-dps">
{"💢 Boss DPS: "}
{formatNumber(boss.damagePerSecond)}
</span>
</div>
<div className="boss-rewards">
<span>
{"🪙 "}
{formatNumber(boss.goldReward)}
</span>
{boss.essenceReward > 0
&& <span>
{"✨ "}
{formatNumber(boss.essenceReward)}
</span>
}
{boss.crystalReward > 0
&& <span>
{"💎 "}
{formatInteger(boss.crystalReward)}
</span>
}
{boss.equipmentRewards.length > 0
&& <span>
{"🗡️ "}
{boss.equipmentRewards.length}
{" Equipment"}
</span>
}
{boss.status !== "defeated"
&& boss.bountyRunestones > 0
&& boss.bountyRunestonesClaimed !== true
&& <span className="boss-bounty">
{"🔮 "}
{boss.bountyRunestones}
{" (first kill)"}
</span>
}
</div>
{(boss.status === "available" || boss.status === "in_progress")
&& <button
className="attack-button"
disabled={!canChallenge}
onClick={handleChallenge}
type="button"
>
{isChallenging
? "⚔️ Battling…"
: "⚔️ Challenge"}
</button>
}
{boss.status === "defeated"
&& <span className="boss-badge defeated">{"☠️ Defeated"}</span>
}
</div>
);
};
/**
* Renders the boss panel with zone selection and boss list.
* @returns The JSX element.
*/
const BossPanel = (): JSX.Element => {
const {
state,
challengeBoss,
formatInteger,
formatNumber,
toggleAutoBoss,
autoBossLastResult,
autoBossError,
bossError,
} = useGame();
const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
null,
);
const [ activeZoneId, setActiveZoneId ] = useState(() => {
return sessionStorage.getItem("elysium_boss_zone") ?? "verdant_vale";
});
const [ showLocked, setShowLocked ] = useState(true);
if (state === null) {
return (
<section className="panel">
<p>{"Loading..."}</p>
</section>
);
}
async function handleChallenge(bossId: string): Promise<void> {
setChallengingBossId(bossId);
try {
await challengeBoss(bossId);
} finally {
setChallengingBossId(null);
}
}
function handleChallengeClick(bossId: string): void {
void handleChallenge(bossId);
}
const {
adventurers,
autoBoss,
bosses,
prestige: playerPrestige,
quests,
zones,
} = state;
const activeZone = zones.find((zone) => {
return zone.id === activeZoneId;
});
const zoneIsLocked = activeZone?.status === "locked";
const unlockBoss = activeZone?.unlockBossId === null
|| activeZone?.unlockBossId === undefined
? undefined
: bosses.find((boss) => {
return boss.id === activeZone.unlockBossId;
});
const unlockQuest = activeZone?.unlockQuestId === null
|| activeZone?.unlockQuestId === undefined
? undefined
: quests.find((quest) => {
return quest.id === activeZone.unlockQuestId;
});
const zoneBosses = bosses.filter((boss) => {
return boss.zoneId === activeZoneId;
});
const lockedCount = zoneBosses.filter((boss) => {
return boss.status === "locked";
}).length;
const visibleBosses = showLocked
? zoneBosses
: zoneBosses.filter((boss) => {
return boss.status !== "locked";
});
const bossUnlockHints = new Map<string, string>();
for (const zone of zones) {
const { id: zoneId, unlockBossId, unlockQuestId } = zone;
const allZoneBosses = bosses.filter((boss) => {
return boss.zoneId === zoneId;
});
for (let index = 0; index < allZoneBosses.length; index = index + 1) {
const boss = allZoneBosses[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 = allZoneBosses[index - 1];
if (previousBoss !== undefined) {
bossUnlockHints.set(boss.id, `⚔️ Defeat: ${previousBoss.name} first`);
}
}
}
}
function handleZoneSelect(zoneId: string): void {
setActiveZoneId(zoneId);
sessionStorage.setItem("elysium_boss_zone", zoneId);
}
function handleToggle(): void {
setShowLocked((current) => {
return !current;
});
}
const autoBossOn = autoBoss === true;
const partyDps = computePartyCombatPower(state);
let partyHp = 0;
for (const { level, count } of adventurers) {
// eslint-disable-next-line stylistic/no-mixed-operators -- level * 50 * count is clear
partyHp = partyHp + level * 50 * count;
}
const { count: prestigeCount } = playerPrestige;
return (
<section className="panel boss-panel">
<div className="panel-header">
<h2>{"Boss Encounters"}</h2>
<div className="panel-header-controls">
<button
className={`auto-toggle-btn ${
autoBossOn
? "auto-toggle-on"
: "auto-toggle-off"
}`}
onClick={toggleAutoBoss}
title="Automatically challenge the highest available boss"
type="button"
>
{"🤖 Auto: "}
{autoBossOn
? "ON"
: "OFF"}
</button>
<LockToggle
lockedCount={lockedCount}
onToggle={handleToggle}
showLocked={showLocked}
/>
</div>
</div>
{bossError === null
? null
: <p className="auto-boss-error">
{"⚠️ "}
{bossError}
</p>
}
{autoBossError === null
? null
: <p className="auto-boss-error">
{"⚠️ Auto-boss stopped: "}
{autoBossError}
</p>
}
{autoBossLastResult !== null && autoBossError === null
? <p className="auto-boss-status">
{"🤖 Last fight: "}
{autoBossLastResult.bossName}
{autoBossLastResult.won
? " — ✅ Won"
: " — ❌ Lost"}
</p>
: null}
<ZoneSelector
activeZoneId={activeZoneId}
onSelectZone={handleZoneSelect}
zones={zones}
/>
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
? <div className="exploration-zone-locked-hint">
<p>{"🔒 This zone is locked. Unlock bosses by:"}</p>
{unlockBoss === undefined
? null
: <p>
{"⚔️ Defeat: "}
{unlockBoss.name}
</p>
}
{unlockQuest === undefined
? null
: <p>
{"📜 Complete: "}
{unlockQuest.name}
</p>
}
</div>
: null
}
<div className="party-combat-stats">
<div className="combat-stat">
<span className="stat-label">{"⚔️ Party DPS"}</span>
<span className="stat-value">{formatNumber(partyDps)}</span>
</div>
<div className="combat-stat">
<span className="stat-label">{"❤️ Party HP"}</span>
<span className="stat-value">{formatNumber(partyHp)}</span>
</div>
</div>
<div className="boss-list">
{visibleBosses.map((boss) => {
const { id: bossId } = boss;
return (
<BossCard
boss={boss}
formatInteger={formatInteger}
formatNumber={formatNumber}
isChallenging={challengingBossId === bossId}
key={bossId}
onChallenge={handleChallengeClick}
prestigeCount={prestigeCount}
unlockHint={bossUnlockHints.get(bossId)}
/>
);
})}
{visibleBosses.length === 0
&& <p className="empty-zone">{"No bosses to show in this zone."}</p>
}
</div>
</section>
);
};
export { BossPanel };