generated from nhcarrigan/template
0dc572c6fa
The previous key `${reward.type}-${amount ?? ""}` collapsed to
"adventurer-" for every adventurer-unlock reward (which carries no
amount), producing duplicate-key React warnings on every render tick.
Because console.error is forwarded to the backend telemetry service,
this caused continuous email alerts.
The key now uses targetId (present on adventurer and upgrade rewards)
first, falls back to amount (present on gold/essence/crystal rewards),
and uses the map index only as a last resort.
320 lines
9.6 KiB
TypeScript
320 lines
9.6 KiB
TypeScript
/**
|
|
* @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 { 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 (
|
|
<div className={`quest-card quest-${quest.status}`}>
|
|
<img
|
|
alt={quest.name}
|
|
className="card-thumbnail"
|
|
src={cdnImage("quests", quest.id)}
|
|
/>
|
|
<div className="quest-info">
|
|
<h3>{quest.name}</h3>
|
|
<p>{quest.description}</p>
|
|
{cpRequired > 0
|
|
&& <p
|
|
className={`quest-cp-requirement ${
|
|
meetsCP
|
|
? "cp-met"
|
|
: "cp-unmet"
|
|
}`}
|
|
>
|
|
{"⚔️ Requires "}
|
|
{formatNumber(cpRequired)}
|
|
{" Combat Power"}
|
|
{quest.status === "available"
|
|
&& (meetsCP
|
|
? " ✓"
|
|
: ` (you have ${formatNumber(partyCombatPower)})`)}
|
|
</p>
|
|
}
|
|
<div className="quest-rewards">
|
|
{quest.rewards.map((reward, rewardIndex) => {
|
|
return (
|
|
<span className="reward-tag" key={`${reward.type}-${reward.targetId ?? String(reward.amount ?? rewardIndex)}`}>
|
|
{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"}
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
<div className="quest-action">
|
|
{quest.status === "locked"
|
|
&& <>
|
|
<span className="quest-badge locked">{"🔒 Locked"}</span>
|
|
{zoneHint === undefined
|
|
? null
|
|
: <p className="unlock-hint">
|
|
{"🗺️ Unlock zone: "}
|
|
{zoneHint}
|
|
</p>
|
|
}
|
|
{zoneHint === undefined && unlockHint !== undefined
|
|
? <p className="unlock-hint">
|
|
{"📜 Complete: "}
|
|
{unlockHint}
|
|
</p>
|
|
: null}
|
|
</>
|
|
}
|
|
{quest.status === "available" && quest.lastFailedAt !== undefined
|
|
&& <p className="quest-failed-hint">{"⚠️ Last attempt failed"}</p>
|
|
}
|
|
{quest.status === "available"
|
|
&& <button
|
|
className="start-quest-button"
|
|
disabled={!meetsCP}
|
|
onClick={handleStartQuest}
|
|
title={
|
|
meetsCP
|
|
? undefined
|
|
: `Need ${formatNumber(cpRequired)} combat power`
|
|
}
|
|
type="button"
|
|
>
|
|
{"Send Party ("}
|
|
{formatDuration(quest.durationSeconds)}
|
|
{")"}
|
|
</button>
|
|
}
|
|
{quest.status === "active"
|
|
&& <span className="quest-badge active">
|
|
{"⏳ "}
|
|
{formatDuration(Math.ceil(questTimeRemaining(quest)))}
|
|
{" remaining"}
|
|
</span>
|
|
}
|
|
{quest.status === "completed"
|
|
&& <span className="quest-badge completed">{"✅ Complete"}</span>
|
|
}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 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(() => {
|
|
return sessionStorage.getItem("elysium_quest_zone") ?? "verdant_vale";
|
|
});
|
|
const [ showLocked, setShowLocked ] = useState(true);
|
|
|
|
if (state === null) {
|
|
return (
|
|
<section className="panel">
|
|
<p>{"Loading..."}</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const { adventurers, autoQuest, quests, zones } = state;
|
|
let partyCombatPower = 0;
|
|
for (const adventurer of adventurers) {
|
|
const contribution = adventurer.combatPower * adventurer.count;
|
|
partyCombatPower = partyCombatPower + contribution;
|
|
}
|
|
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<string, string>();
|
|
const questZoneHints = new Map<string, string>();
|
|
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 handleZoneSelect(zoneId: string): void {
|
|
setActiveZoneId(zoneId);
|
|
sessionStorage.setItem("elysium_quest_zone", zoneId);
|
|
}
|
|
|
|
function handleToggle(): void {
|
|
setShowLocked((current) => {
|
|
return !current;
|
|
});
|
|
}
|
|
|
|
function handleAutoQuest(): void {
|
|
toggleAutoQuest();
|
|
}
|
|
|
|
const autoQuestOn = autoQuest === true;
|
|
|
|
return (
|
|
<section className="panel quest-panel">
|
|
<div className="panel-header">
|
|
<h2>{"Quests"}</h2>
|
|
<div className="panel-header-controls">
|
|
<button
|
|
className={`auto-toggle-btn ${
|
|
autoQuestOn
|
|
? "auto-toggle-on"
|
|
: "auto-toggle-off"
|
|
}`}
|
|
onClick={handleAutoQuest}
|
|
title="Automatically send the party on the highest available quest"
|
|
type="button"
|
|
>
|
|
{"🤖 Auto: "}
|
|
{autoQuestOn
|
|
? "ON"
|
|
: "OFF"}
|
|
</button>
|
|
<LockToggle
|
|
lockedCount={lockedCount}
|
|
onToggle={handleToggle}
|
|
showLocked={showLocked}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<ZoneSelector
|
|
activeZoneId={activeZoneId}
|
|
onSelectZone={handleZoneSelect}
|
|
zones={zones}
|
|
/>
|
|
|
|
<div className="quest-list">
|
|
{visibleQuests.map((quest) => {
|
|
return (
|
|
<QuestCard
|
|
key={quest.id}
|
|
partyCombatPower={partyCombatPower}
|
|
quest={quest}
|
|
unlockHint={questUnlockHints.get(quest.id)}
|
|
zoneHint={questZoneHints.get(quest.id)}
|
|
/>
|
|
);
|
|
})}
|
|
{visibleQuests.length === 0
|
|
&& <p className="empty-zone">{"No quests to show in this zone."}</p>
|
|
}
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export { QuestPanel };
|