/** * @file Quest panel component for managing and completing quests. * @copyright nhcarrigan * @license Naomi's Public License * @author Naomi Carrigan */ /* eslint-disable max-lines -- QuestPanel with sub-component and helper functions */ /* 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 { computePartyCombatPower, zoneFailureChance, } from "../../engine/tick.js"; import { cdnImage } from "../../utils/cdn.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.description}
{cpRequired > 0 &&{"βοΈ Requires "} {formatNumber(cpRequired)} {" Combat Power"} {quest.status === "available" && (meetsCP ? " β" : ` (you have ${formatNumber(partyCombatPower)})`)}
}{"πΊοΈ Unlock zone: "} {zoneHint}
} {zoneHint === undefined && unlockHint !== undefined ?{"π Complete: "} {unlockHint}
: null} > } {quest.status === "available" &&{"π² "} {String(Math.round((zoneFailureChance[quest.zoneId] ?? 0) * 100))} {"% failure chance"}
} {quest.status === "available" && quest.lastFailedAt !== undefined &&{"β οΈ Last attempt failed β no rewards were granted."}
} {quest.status === "available" && } {quest.status === "active" && {"β³ "} {formatDuration(Math.ceil(questTimeRemaining(quest)))} {" remaining"} } {quest.status === "completed" && {"β Complete"} }{"Loading..."}
{"π This zone is locked. Unlock quests by:"}
{unlockBoss === undefined ? null :{"βοΈ Defeat: "} {unlockBoss.name}
} {unlockQuest === undefined ? null :{"π Complete: "} {unlockQuest.name}
}{"β οΈ If a quest fails, it resets with no rewards β you must retry."}
{"No quests to show in this zone."}
}