Files
elysium/apps/web/src/components/game/explorationPanel.tsx
T
hikari 6e573bea14
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m5s
CI / Lint, Build & Test (push) Successful in 1m9s
chore: more feedback fixes (#129)
## Summary

- Fix `NaN` displayed in Sync New Content / Force Unlock notifications by guarding against undefined counts
- Poll server for exploration claimability before showing Collect button to prevent client/server desync
- Return authoritative materials list from craft API to prevent client desync causing false affordability
- Add test coverage for `sync-new-content` and `explore/claimable` endpoints

Closes #125
Closes #127
Closes #128

## Test plan

- [ ] Trigger a sync with new content and verify the notification shows a real count instead of `NaN`
- [ ] Start an exploration, wait for it to complete, and verify the Collect button only appears after the server confirms claimable
- [ ] Attempt to craft a recipe and verify the material counts in the UI update to match the server's authoritative values

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #129
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-24 13:20:37 -07:00

426 lines
14 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 */
/* eslint-disable max-statements -- Component function requires many state declarations and handlers */
import { type JSX, useEffect, useRef, useState } from "react";
import { checkExplorationClaimable } from "../../api/client.js";
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 {
ExploreClaimableResponse,
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);
const [ claimableAreaIds, setClaimableAreaIds ]
= useState<ReadonlySet<string>>(new Set());
const stateReference = useRef(state);
stateReference.current = state;
const claimableReference = useRef(claimableAreaIds);
claimableReference.current = claimableAreaIds;
useEffect(() => {
const pollClaimable = async(): Promise<void> => {
const currentState = stateReference.current;
if (currentState === null) {
return;
}
const inProgressArea = currentState.exploration?.areas.find((a) => {
return a.status === "in_progress";
});
if (inProgressArea === undefined) {
return;
}
if (claimableReference.current.has(inProgressArea.id)) {
return;
}
const areaData = EXPLORATION_AREAS.find((a) => {
return a.id === inProgressArea.id;
});
if (areaData === undefined) {
return;
}
const remaining = timeRemaining(
inProgressArea.endsAt,
inProgressArea.startedAt ?? 0,
areaData.durationSeconds,
);
if (remaining > 0) {
return;
}
const result: ExploreClaimableResponse
= await checkExplorationClaimable(inProgressArea.id);
if (result.claimable) {
setClaimableAreaIds((previous) => {
return new Set([ ...previous, inProgressArea.id ]);
});
}
};
const intervalId = setInterval(() => {
void pollClaimable();
}, 1000);
return (): void => {
clearInterval(intervalId);
};
}, []);
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 });
setClaimableAreaIds((previous) => {
const next = new Set(previous);
next.delete(areaId);
return next;
});
} 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"
&& claimableAreaIds.has(area.id);
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 };