Files
elysium/apps/web/src/components/game/equipmentPanel.tsx
T
hikari 81ae1f18e1
CI / Lint, Build & Test (push) Successful in 1m4s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m34s
chore: clarify equipment combat bonus applies to boss fights only (#83)
## Summary

Resolves player confusion about equipment combat bonuses not affecting quest combat power. The behaviour is by design — combat multipliers only apply to boss DPS — but this was never communicated anywhere.

- **Equipment bonus labels** now read `+X% Boss Combat` instead of `+X% Combat` (both individual items and set bonuses)
- **About panel** — both equipment entries updated to explicitly state that combat bonuses only affect boss fights, and that quest combat power is determined solely by adventurers

No game logic changed.

Closes #81

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #83
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-19 19:02:24 -07:00

376 lines
11 KiB
TypeScript

/**
* @file Equipment panel component for managing owned and available equipment.
* @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 -- Complex component with many conditional render paths */
/* eslint-disable max-lines -- Equipment panel with set bonus display and sort logic */
import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js";
import { EQUIPMENT_SETS } from "../../data/equipmentSets.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js";
import type { Equipment, EquipmentType } from "@elysium/types";
const rarityLabel: Record<string, string> = {
common: "Common",
epic: "Epic",
legendary: "Legendary",
rare: "Rare",
};
/**
* Computes a human-readable bonus description for a piece of equipment.
* @param item - The equipment item.
* @returns The formatted bonus description.
*/
const bonusDescription = (item: Equipment): string => {
const parts: Array<string> = [];
if (item.bonus.combatMultiplier !== undefined) {
const pct = Math.round((item.bonus.combatMultiplier - 1) * 100);
parts.push(`+${String(pct)}% Boss Combat`);
}
if (item.bonus.goldMultiplier !== undefined) {
const pct = Math.round((item.bonus.goldMultiplier - 1) * 100);
parts.push(`+${String(pct)}% Gold/s`);
}
if (item.bonus.clickMultiplier !== undefined) {
const pct = Math.round((item.bonus.clickMultiplier - 1) * 100);
parts.push(`+${String(pct)}% Click`);
}
return parts.join(", ");
};
/**
* Formats an equipment cost as a readable string.
* @param cost - The cost object with gold, essence, and crystals.
* @param cost.gold - The gold component of the cost.
* @param cost.essence - The essence component of the cost.
* @param cost.crystals - The crystals component of the cost.
* @returns The formatted cost string.
*/
const costLabel = (cost: {
gold: number;
essence: number;
crystals: number;
}): string => {
const parts: Array<string> = [];
if (cost.gold > 0) {
parts.push(`🪙 ${cost.gold.toLocaleString()}`);
}
if (cost.essence > 0) {
parts.push(`${cost.essence.toLocaleString()}`);
}
if (cost.crystals > 0) {
parts.push(`💎 ${cost.crystals.toLocaleString()}`);
}
return parts.join(" ");
};
interface EquipmentCardProperties {
readonly item: Equipment;
readonly gold: number;
readonly essence: number;
readonly crystals: number;
readonly dropBossName: string | undefined;
readonly setName: string | undefined;
}
/**
* Renders a single equipment card.
* @param props - The equipment card properties.
* @param props.item - The equipment item data.
* @param props.gold - The current gold amount.
* @param props.essence - The current essence amount.
* @param props.crystals - The current crystals amount.
* @param props.dropBossName - The name of the boss that drops this item.
* @param props.setName - The name of the set this item belongs to.
* @returns The JSX element.
*/
const EquipmentCard = ({
item,
gold,
essence,
crystals,
dropBossName,
setName,
}: EquipmentCardProperties): JSX.Element => {
const { equipItem, buyEquipment } = useGame();
const canAfford
= item.cost !== undefined
&& gold >= item.cost.gold
&& essence >= item.cost.essence
&& crystals >= item.cost.crystals;
function handleBuy(): void {
buyEquipment(item.id);
}
function handleEquip(): void {
equipItem(item.id);
}
const ownedClass = item.owned
? ""
: "not-owned";
const equippedClass = item.equipped
? "equipped"
: "";
return (
<div
className={`equipment-card rarity-${item.rarity} ${equippedClass} ${ownedClass}`}
>
<img
alt={item.name}
className="card-thumbnail"
src={cdnImage("equipment", item.id)}
/>
<div className="equipment-info">
<div className="equipment-name-row">
<h3>{item.name}</h3>
<span className={`rarity-badge rarity-${item.rarity}`}>
{rarityLabel[item.rarity]}
</span>
</div>
<p className="equipment-description">{item.description}</p>
<p className="equipment-bonus">{bonusDescription(item)}</p>
{setName === undefined
? null
: <span className="equipment-set-badge">
{"🔗 "}
{setName}
</span>
}
{item.owned || item.cost === undefined
? null
: <p className="equipment-cost">{costLabel(item.cost)}</p>
}
</div>
<div className="equipment-action">
{!item.owned && item.cost === undefined
&& <span className="equipment-locked">
{dropBossName === undefined
? "🔒 Boss drop"
: `⚔️ Drop: ${dropBossName}`}
</span>
}
{item.owned || item.cost === undefined
? null
: <button
className="equip-button"
disabled={!canAfford}
onClick={handleBuy}
type="button"
>
{canAfford
? "Purchase"
: "Can't afford"}
</button>
}
{item.owned && item.equipped
? <span className="equipment-equipped-badge">{"✓ Equipped"}</span>
: null}
{item.owned && !item.equipped
? <button
className="equip-button"
onClick={handleEquip}
type="button"
>
{"Equip"}
</button>
: null}
</div>
</div>
);
};
/**
* Computes a combined power score for sorting — sum of all bonus multipliers.
* Using the sum (rather than a single stat) keeps hybrid items in sensible order.
* @param item - The equipment piece whose bonus multipliers are summed.
* @returns The combined bonus value.
*/
const equipmentPower = (item: Equipment): number => {
return (
(item.bonus.combatMultiplier ?? 1)
+ (item.bonus.goldMultiplier ?? 1)
+ (item.bonus.clickMultiplier ?? 1)
);
};
const slotOrder: Array<EquipmentType> = [ "weapon", "armour", "trinket" ];
const slotLabel: Record<EquipmentType, string> = {
armour: "🛡️ Armour",
trinket: "💍 Trinkets",
weapon: "⚔️ Weapons",
};
/**
* Renders the equipment panel with all owned and available equipment.
* @returns The JSX element.
*/
const EquipmentPanel = (): JSX.Element => {
const { state } = useGame();
const [ showLocked, setShowLocked ] = useState(true);
if (state === null) {
return (
<section className="panel">
<p>{"Loading..."}</p>
</section>
);
}
const { bosses, equipment, resources } = state;
const unownedCount = equipment.filter((item) => {
return !item.owned;
}).length;
const equipmentDropSources = new Map<string, string>();
for (const { equipmentRewards, name: bossName } of bosses) {
for (const equipmentId of equipmentRewards) {
equipmentDropSources.set(equipmentId, bossName);
}
}
const setNameById = new Map<string, string>(
EQUIPMENT_SETS.map((equipSet) => {
return [ equipSet.id, equipSet.name ];
}),
);
const equippedItemIds = new Set(
equipment.
filter((item) => {
return item.equipped;
}).
map((item) => {
return item.id;
}),
);
const activeSets = EQUIPMENT_SETS.map((set) => {
const count = set.pieces.filter((id) => {
return equippedItemIds.has(id);
}).length;
return { count, set };
}).filter(({ count }) => {
return count >= 2;
});
function setBonusDescription(
equipSet: (typeof EQUIPMENT_SETS)[number],
count: number,
): string {
const parts: Array<string> = [];
for (const threshold of [ 2, 3 ] as const) {
if (count >= threshold) {
const bonus = equipSet.bonuses[threshold];
if (bonus.goldMultiplier !== undefined) {
const pct = Math.round((bonus.goldMultiplier - 1) * 100);
parts.push(`+${String(pct)}% Gold/s (${String(threshold)}pc)`);
}
if (bonus.combatMultiplier !== undefined) {
const pct = Math.round((bonus.combatMultiplier - 1) * 100);
parts.push(`+${String(pct)}% Boss Combat (${String(threshold)}pc)`);
}
if (bonus.clickMultiplier !== undefined) {
const pct = Math.round((bonus.clickMultiplier - 1) * 100);
parts.push(`+${String(pct)}% Click (${String(threshold)}pc)`);
}
}
}
return parts.join(", ");
}
function handleToggle(): void {
setShowLocked((current) => {
return !current;
});
}
return (
<section className="panel equipment-panel">
<div className="panel-header">
<h2>{"Equipment"}</h2>
<LockToggle
lockedCount={unownedCount}
onToggle={handleToggle}
showLocked={showLocked}
/>
</div>
<p className="equipment-intro">
{"Equipment drops from bosses and grants passive bonuses."
+ " Only one item per slot can be equipped at a time."
+ " Equip matching set pieces for bonus effects!"}
</p>
{activeSets.length > 0
&& <div className="active-sets">
<h3 className="active-sets-heading">{"✨ Active Set Bonuses"}</h3>
{activeSets.map(({ set, count }) => {
return (
<div className="active-set-row" key={set.id}>
<span className="active-set-name">
{set.name}
{" ("}
{count}
{"/"}
{set.pieces.length}
{")"}
</span>
<span className="active-set-bonus">
{setBonusDescription(set, count)}
</span>
</div>
);
})}
</div>
}
{slotOrder.map((slotType) => {
const items = equipment.filter((item) => {
return item.type === slotType && (showLocked || item.owned);
}).sort((a, b) => {
return equipmentPower(a) - equipmentPower(b);
});
return (
<div className="equipment-slot-section" key={slotType}>
<h3 className="slot-heading">{slotLabel[slotType]}</h3>
<div className="equipment-list">
{items.map((item) => {
return (
<EquipmentCard
crystals={resources.crystals}
dropBossName={equipmentDropSources.get(item.id)}
essence={resources.essence}
gold={resources.gold}
item={item}
key={item.id}
setName={
item.setId === undefined
? undefined
: setNameById.get(item.setId)
}
/>
);
})}
{items.length === 0
&& <p className="empty-zone">
{"No items to show in this slot."}
</p>
}
</div>
</div>
);
})}
</section>
);
};
export { EquipmentPanel };