/** * @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 = { 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 = []; 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 = []; 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 (
{item.name}

{item.name}

{rarityLabel[item.rarity]}

{item.description}

{bonusDescription(item)}

{setName === undefined ? null : {"🔗 "} {setName} } {item.owned || item.cost === undefined ? null :

{costLabel(item.cost)}

}
{!item.owned && item.cost === undefined && {dropBossName === undefined ? "🔒 Boss drop" : `⚔️ Drop: ${dropBossName}`} } {item.owned || item.cost === undefined ? null : } {item.owned && item.equipped ? {"✓ Equipped"} : null} {item.owned && !item.equipped ? : null}
); }; /** * 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 = [ "weapon", "armour", "trinket" ]; const slotLabel: Record = { 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 (

{"Loading..."}

); } const { bosses, equipment, resources } = state; const unownedCount = equipment.filter((item) => { return !item.owned; }).length; const equipmentDropSources = new Map(); for (const { equipmentRewards, name: bossName } of bosses) { for (const equipmentId of equipmentRewards) { equipmentDropSources.set(equipmentId, bossName); } } const setNameById = new Map( 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 = []; 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 (

{"Equipment"}

{"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!"}

{activeSets.length > 0 &&

{"✨ Active Set Bonuses"}

{activeSets.map(({ set, count }) => { return (
{set.name} {" ("} {count} {"/"} {set.pieces.length} {")"} {setBonusDescription(set, count)}
); })}
} {slotOrder.map((slotType) => { const items = equipment.filter((item) => { return item.type === slotType && (showLocked || item.owned); }).sort((a, b) => { return equipmentPower(a) - equipmentPower(b); }); return (

{slotLabel[slotType]}

{items.map((item) => { return ( ); })} {items.length === 0 &&

{"No items to show in this slot."}

}
); })}
); }; export { EquipmentPanel };