generated from nhcarrigan/template
14de87d765
## Summary - Locked exploration zones now show a `🔒 This zone is locked. Unlock exploration by:` hint above the area list, with the specific `⚔️ Defeat: {boss}` and `📜 Complete: {quest}` required - Updated the About panel's Exploration how-to-play entry to document the zone unlock rule explicitly - No new data required — unlock conditions are read directly from `zone.unlockBossId` and `zone.unlockQuestId` already in state ## Test plan - [ ] Verify locked exploration zones display the correct boss and quest unlock hints - [ ] Verify already-unlocked zones show no hint - [ ] Verify starter zone (no unlock conditions) shows no hint - [ ] Verify the About panel Exploration entry reflects the updated description - [ ] Confirm lint, build, and tests all pass Closes #59 Reviewed-on: #74 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
361 lines
12 KiB
TypeScript
361 lines
12 KiB
TypeScript
/**
|
|
* @file Exploration panel component for exploring areas and collecting materials.
|
|
* @copyright nhcarrigan
|
|
* @license Naomi's Public License
|
|
* @author Naomi Carrigan
|
|
*/
|
|
/* 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 -- Exploration panel requires many render paths and result display */
|
|
import { type JSX, useState } from "react";
|
|
import { useGame } from "../../context/gameContext.js";
|
|
import { EXPLORATION_AREAS } from "../../data/explorations.js";
|
|
import { cdnImage } from "../../utils/cdn.js";
|
|
import { ZoneSelector } from "./zoneSelector.js";
|
|
import type { ExploreCollectResponse } 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 secondsPerDay = 86_400;
|
|
const secondsPerHour = 3600;
|
|
const secondsPerMinute = 60;
|
|
if (seconds >= secondsPerDay) {
|
|
const days = Math.floor(seconds / secondsPerDay);
|
|
const remainingAfterDays = seconds % secondsPerDay;
|
|
const hours = Math.floor(remainingAfterDays / secondsPerHour);
|
|
return hours > 0
|
|
? `${String(days)}d ${String(hours)}h`
|
|
: `${String(days)}d`;
|
|
}
|
|
if (seconds >= secondsPerHour) {
|
|
const hours = Math.floor(seconds / secondsPerHour);
|
|
const remainingAfterHours = seconds % secondsPerHour;
|
|
const minutes = Math.floor(remainingAfterHours / 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 exploration in progress.
|
|
* Uses endsAt (server-computed) when available to avoid client/server clock drift.
|
|
* Falls back to startedAt + durationSeconds for saves predating the endsAt field.
|
|
* @param endsAt - The server-computed completion timestamp, if available.
|
|
* @param startedAt - The timestamp when exploration started.
|
|
* @param durationSeconds - The total duration in seconds.
|
|
* @returns The remaining seconds.
|
|
*/
|
|
const timeRemaining = (
|
|
endsAt: number | undefined,
|
|
startedAt: number,
|
|
durationSeconds: number,
|
|
): number => {
|
|
if (endsAt !== undefined) {
|
|
return Math.max(0, (endsAt - Date.now()) / 1000);
|
|
}
|
|
const elapsed = (Date.now() - startedAt) / 1000;
|
|
return Math.max(0, durationSeconds - elapsed);
|
|
};
|
|
|
|
interface CollectResult {
|
|
areaId: string;
|
|
response: ExploreCollectResponse;
|
|
}
|
|
|
|
/**
|
|
* Renders the exploration panel for managing area explorations.
|
|
* @returns The JSX element.
|
|
*/
|
|
const ExplorationPanel = (): JSX.Element => {
|
|
const { state, startExploration, collectExploration, formatNumber }
|
|
= useGame();
|
|
const [ activeZoneId, setActiveZoneId ] = useState(() => {
|
|
return sessionStorage.getItem("elysium_explore_zone") ?? "verdant_vale";
|
|
});
|
|
const [ pendingAreaId, setPendingAreaId ] = useState<string | null>(null);
|
|
const [ lastResult, setLastResult ] = useState<CollectResult | null>(null);
|
|
|
|
if (state === null) {
|
|
return (
|
|
<section className="panel">
|
|
<p>{"Loading..."}</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const { zones, exploration: explorationState, bosses, quests } = state;
|
|
|
|
const activeZone = zones.find((zone) => {
|
|
return zone.id === activeZoneId;
|
|
});
|
|
const zoneIsLocked = activeZone?.status === "locked";
|
|
const unlockBoss = activeZone?.unlockBossId === null
|
|
|| activeZone?.unlockBossId === undefined
|
|
? undefined
|
|
: bosses.find((boss) => {
|
|
return boss.id === activeZone.unlockBossId;
|
|
});
|
|
const unlockQuest = activeZone?.unlockQuestId === null
|
|
|| activeZone?.unlockQuestId === undefined
|
|
? undefined
|
|
: quests.find((quest) => {
|
|
return quest.id === activeZone.unlockQuestId;
|
|
});
|
|
|
|
const zoneAreas = EXPLORATION_AREAS.filter((area) => {
|
|
return area.zoneId === activeZoneId;
|
|
});
|
|
|
|
const hasActiveExploration
|
|
= explorationState?.areas.some((area) => {
|
|
return area.status === "in_progress";
|
|
}) ?? false;
|
|
|
|
async function handleStart(areaId: string): Promise<void> {
|
|
setPendingAreaId(areaId);
|
|
try {
|
|
await startExploration(areaId);
|
|
} finally {
|
|
setPendingAreaId(null);
|
|
}
|
|
}
|
|
|
|
async function handleCollect(areaId: string): Promise<void> {
|
|
setPendingAreaId(areaId);
|
|
try {
|
|
const result = await collectExploration(areaId);
|
|
setLastResult({ areaId: areaId, response: result });
|
|
} finally {
|
|
setPendingAreaId(null);
|
|
}
|
|
}
|
|
|
|
function handleDismissResult(): void {
|
|
setLastResult(null);
|
|
}
|
|
|
|
function handleZoneSelect(id: string): void {
|
|
setActiveZoneId(id);
|
|
setLastResult(null);
|
|
sessionStorage.setItem("elysium_explore_zone", id);
|
|
}
|
|
|
|
const goldChange = lastResult?.response.event?.goldChange ?? 0;
|
|
const essenceChange = lastResult?.response.event?.essenceChange ?? 0;
|
|
|
|
return (
|
|
<section className="panel exploration-panel">
|
|
<div className="panel-header">
|
|
<h2>{"🗺️ Exploration"}</h2>
|
|
</div>
|
|
|
|
{lastResult === null
|
|
? null
|
|
: <div className="exploration-result">
|
|
<button
|
|
className="exploration-result-close"
|
|
onClick={handleDismissResult}
|
|
type="button"
|
|
>
|
|
{"✕"}
|
|
</button>
|
|
{lastResult.response.foundNothing
|
|
? <p className="exploration-nothing">
|
|
{lastResult.response.nothingMessage}
|
|
</p>
|
|
: <>
|
|
{lastResult.response.event === null
|
|
? null
|
|
: <p className="exploration-event-text">
|
|
{lastResult.response.event.text}
|
|
</p>
|
|
}
|
|
<div className="exploration-rewards">
|
|
{goldChange !== 0
|
|
&& <span
|
|
className={`reward-tag ${goldChange > 0
|
|
? ""
|
|
: "negative"}`}
|
|
>
|
|
{"🪙 "}
|
|
{goldChange > 0
|
|
? "+"
|
|
: ""}
|
|
{formatNumber(goldChange)}
|
|
{" gold"}
|
|
</span>
|
|
}
|
|
{essenceChange > 0
|
|
&& <span className="reward-tag">
|
|
{"✨ +"}
|
|
{formatNumber(essenceChange)}
|
|
{" essence"}
|
|
</span>
|
|
}
|
|
{lastResult.response.event?.materialGained !== null
|
|
&& lastResult.response.event?.materialGained !== undefined
|
|
? <span className="reward-tag material-tag">
|
|
{"📦 +"}
|
|
{lastResult.response.event.materialGained.quantity}{" "}
|
|
{/* eslint-disable-next-line stylistic/max-len -- long property chain cannot be shortened */}
|
|
{lastResult.response.event.materialGained.materialId.replaceAll(
|
|
"_",
|
|
" ",
|
|
)}
|
|
{" (event)"}
|
|
</span>
|
|
: null}
|
|
{lastResult.response.materialsFound.map((foundMaterial) => {
|
|
return (
|
|
<span
|
|
className="reward-tag material-tag"
|
|
key={foundMaterial.materialId}
|
|
>
|
|
{"📦 +"}
|
|
{foundMaterial.quantity}{" "}
|
|
{foundMaterial.materialId.replaceAll("_", " ")}
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
<ZoneSelector
|
|
activeZoneId={activeZoneId}
|
|
onSelectZone={handleZoneSelect}
|
|
zones={zones}
|
|
/>
|
|
|
|
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
|
|
? <div className="exploration-zone-locked-hint">
|
|
<p>{"🔒 This zone is locked. Unlock exploration by:"}</p>
|
|
{unlockBoss === undefined
|
|
? null
|
|
: <p>
|
|
{"⚔️ Defeat: "}
|
|
{unlockBoss.name}
|
|
</p>
|
|
}
|
|
{unlockQuest === undefined
|
|
? null
|
|
: <p>
|
|
{"📜 Complete: "}
|
|
{unlockQuest.name}
|
|
</p>
|
|
}
|
|
</div>
|
|
: null
|
|
}
|
|
|
|
<div className="exploration-list">
|
|
{zoneAreas.map((area) => {
|
|
const areaState = explorationState?.areas.find((explorationArea) => {
|
|
return explorationArea.id === area.id;
|
|
});
|
|
const status = areaState?.status ?? "locked";
|
|
const startedAt = areaState?.startedAt ?? 0;
|
|
const endsAt = areaState?.endsAt;
|
|
const isReady
|
|
= status === "in_progress"
|
|
&& timeRemaining(endsAt, startedAt, area.durationSeconds) <= 0;
|
|
const isPending = pendingAreaId === area.id;
|
|
|
|
function handleStartClick(): void {
|
|
void handleStart(area.id);
|
|
}
|
|
function handleCollectClick(): void {
|
|
void handleCollect(area.id);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={`exploration-card exploration-${status}`}
|
|
key={area.id}
|
|
>
|
|
<img
|
|
alt={area.name}
|
|
className="card-thumbnail"
|
|
src={cdnImage("explorations", area.id)}
|
|
/>
|
|
<div className="exploration-info">
|
|
<h3>
|
|
{area.name}
|
|
{areaState?.completedOnce === true
|
|
? <span className="exploration-discovered">{" 📖"}</span>
|
|
: null}
|
|
</h3>
|
|
<p>{area.description}</p>
|
|
<span className="exploration-duration">
|
|
{"⏱️ "}
|
|
{formatDuration(area.durationSeconds)}
|
|
</span>
|
|
</div>
|
|
<div className="exploration-action">
|
|
{status === "locked"
|
|
&& <span className="quest-badge locked">{"🔒 Locked"}</span>
|
|
}
|
|
{status === "available"
|
|
&& <button
|
|
className="start-quest-button"
|
|
disabled={isPending || hasActiveExploration}
|
|
onClick={handleStartClick}
|
|
title={
|
|
hasActiveExploration
|
|
? "An exploration is already in progress"
|
|
: undefined
|
|
}
|
|
type="button"
|
|
>
|
|
{isPending
|
|
? "Departing..."
|
|
: `Explore (${formatDuration(area.durationSeconds)})`}
|
|
</button>
|
|
}
|
|
{status === "in_progress" && !isReady
|
|
&& <span className="quest-badge active">
|
|
{"⏳ "}
|
|
{/* eslint-disable-next-line stylistic/max-len -- long call cannot be shortened */}
|
|
{formatDuration(Math.ceil(timeRemaining(endsAt, startedAt, area.durationSeconds)))}
|
|
{" remaining"}
|
|
</span>
|
|
}
|
|
{status === "in_progress" && isReady
|
|
? <button
|
|
className="collect-button"
|
|
disabled={isPending}
|
|
onClick={handleCollectClick}
|
|
type="button"
|
|
>
|
|
{isPending
|
|
? "Collecting..."
|
|
: "📦 Collect Results"}
|
|
</button>
|
|
: null}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{zoneAreas.length === 0
|
|
&& <p className="empty-zone">
|
|
{"No exploration areas in this zone."}
|
|
</p>
|
|
}
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export { ExplorationPanel };
|