Files
elysium/apps/web/src/components/game/questPanel.tsx
T
hikari 0057cfeaaa
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m2s
CI / Lint, Build & Test (push) Successful in 1m8s
feat: communicate quest failure mechanics in the UI (#82)
## Summary

Addresses recurring community confusion about quests failing — multiple players asked whether it was a bug or intended behaviour with no in-game explanation.

- **Exports `zoneFailureChance`** from `tick.ts` so the quest panel can read it
- **Quest cards** now show a `🎲 X% failure chance` note on all available quests, with a brief explanation that a failure resets the quest with no rewards
- **"Last attempt failed" hint** now reads `"⚠️ Last attempt failed — no rewards were granted."` so players understand the consequence immediately
- **About panel** updated to document the failure mechanic, including the 10%–40% range across zones

Closes #80

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #82
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-19 17:57:17 -07:00

331 lines
10 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 { 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 (
<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"
&& <p className="quest-failure-chance">
{"🎲 "}
{String(Math.round((zoneFailureChance[quest.zoneId] ?? 0) * 100))}
{"% failure chance — if failed, the quest resets"}
{" and must be retried."}
</p>
}
{quest.status === "available" && quest.lastFailedAt !== undefined
&& <p className="quest-failed-hint">
{"⚠️ Last attempt failed — no rewards were granted."}
</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 };