/** * @file Quest panel component for managing and completing quests. * @copyright nhcarrigan * @license Naomi's Public License * @author Naomi Carrigan */ /* eslint-disable react/no-multi-comp -- QuestCard sub-component is tightly coupled */ /* eslint-disable max-lines-per-function -- Complex component with many render paths */ /* eslint-disable complexity -- Many conditional render paths */ /* eslint-disable max-statements -- Many local variables needed for quest state */ import { useState, type JSX } from "react"; import { useGame } from "../../context/gameContext.js"; import { LockToggle } from "../ui/lockToggle.js"; import { ZoneSelector } from "./zoneSelector.js"; import type { Quest } from "@elysium/types"; /** * Formats a duration in seconds to a human-readable string. * @param seconds - The total number of seconds to format. * @returns The formatted duration string. */ const formatDuration = (seconds: number): string => { const secondsPerHour = 3600; const secondsPerMinute = 60; if (seconds >= secondsPerHour) { const hours = Math.floor(seconds / secondsPerHour); const remainderSeconds = seconds % secondsPerHour; const minutes = Math.floor(remainderSeconds / secondsPerMinute); return `${String(hours)}h ${String(minutes)}m`; } if (seconds >= secondsPerMinute) { const minutes = Math.floor(seconds / secondsPerMinute); const secs = seconds % secondsPerMinute; return `${String(minutes)}m ${String(secs)}s`; } return `${String(seconds)}s`; }; /** * Computes the time remaining for an active quest. * @param quest - The quest to check. * @returns The remaining seconds. */ const questTimeRemaining = (quest: Quest): number => { if (quest.status !== "active" || quest.startedAt === undefined) { return 0; } const elapsed = (Date.now() - quest.startedAt) / 1000; return Math.max(0, quest.durationSeconds - elapsed); }; interface QuestCardProperties { readonly quest: Quest; readonly partyCombatPower: number; readonly unlockHint: string | undefined; readonly zoneHint: string | undefined; } /** * Renders a single quest card. * @param props - The quest card properties. * @param props.quest - The quest to display. * @param props.partyCombatPower - The current party's combat power. * @param props.unlockHint - Optional hint for how to unlock this quest. * @param props.zoneHint - Optional hint for which zone to unlock. * @returns The JSX element. */ const QuestCard = ({ quest, partyCombatPower, unlockHint, zoneHint, }: QuestCardProperties): JSX.Element => { const { startQuest, formatNumber } = useGame(); const cpRequired = quest.combatPowerRequired ?? 0; const meetsCP = partyCombatPower >= cpRequired; function handleStartQuest(): void { startQuest(quest.id); } return (

{quest.name}

{quest.description}

{cpRequired > 0 &&

{"βš”οΈ Requires "} {formatNumber(cpRequired)} {" Combat Power"} {quest.status === "available" && (meetsCP ? " βœ“" : ` (you have ${formatNumber(partyCombatPower)})`)}

}
{quest.rewards.map((reward) => { return ( {reward.type === "gold" && `πŸͺ™ ${formatNumber(reward.amount ?? 0)}`} {reward.type === "essence" && `✨ ${formatNumber(reward.amount ?? 0)}`} {reward.type === "crystals" && `πŸ’Ž ${formatNumber(reward.amount ?? 0)}`} {reward.type === "upgrade" && "πŸ”“ Upgrade"} {reward.type === "adventurer" && "πŸ‘₯ New Adventurer"} ); })}
{quest.status === "locked" && <> {"πŸ”’ Locked"} {zoneHint === undefined ? null :

{"πŸ—ΊοΈ Unlock zone: "} {zoneHint}

} {zoneHint === undefined && unlockHint !== undefined ?

{"πŸ“œ Complete: "} {unlockHint}

: null} } {quest.status === "available" && quest.lastFailedAt !== undefined &&

{"⚠️ Last attempt failed"}

} {quest.status === "available" && } {quest.status === "active" && {"⏳ "} {formatDuration(Math.ceil(questTimeRemaining(quest)))} {" remaining"} } {quest.status === "completed" && {"βœ… Complete"} }
); }; /** * Renders the quest panel with zone selection and quest list. * @returns The JSX element. */ const QuestPanel = (): JSX.Element => { const { state, toggleAutoQuest } = useGame(); const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale"); const [ showLocked, setShowLocked ] = useState(true); if (state === null) { return (

{"Loading..."}

); } const { adventurers, autoQuest, quests, zones } = state; // eslint-disable-next-line unicorn/no-array-reduce -- Need the total! const partyCombatPower = adventurers.reduce((total, adventurer) => { return total + adventurer.combatPower * adventurer.count; }, 0); const zoneQuests = quests.filter(({ zoneId }) => { return zoneId === activeZoneId; }); const lockedCount = zoneQuests.filter(({ status }) => { return status === "locked"; }).length; const visibleQuests = showLocked ? zoneQuests : zoneQuests.filter(({ status }) => { return status !== "locked"; }); const questNameById = new Map( quests.map(({ id, name }) => { return [ id, name ]; }), ); const zoneById = new Map( zones.map((zone) => { return [ zone.id, zone ]; }), ); const questUnlockHints = new Map(); const questZoneHints = new Map(); for (const { id: questId, status, zoneId, prerequisiteIds } of quests) { if (status !== "locked") { continue; } const zone = zoneById.get(zoneId); if (zone?.status === "locked") { questZoneHints.set(questId, zone.name); } else if (prerequisiteIds.length > 0) { const [ prereqId ] = prerequisiteIds; if (prereqId !== undefined) { const prereqName = questNameById.get(prereqId); if (prereqName !== undefined) { questUnlockHints.set(questId, prereqName); } } } } function handleToggle(): void { setShowLocked((current) => { return !current; }); } function handleAutoQuest(): void { toggleAutoQuest(); } const autoQuestOn = autoQuest === true; return (

{"Quests"}

{visibleQuests.map((quest) => { return ( ); })} {visibleQuests.length === 0 &&

{"No quests to show in this zone."}

}
); }; export { QuestPanel };