generated from nhcarrigan/template
58e7000954
Server-side: check if any area is already in_progress before allowing a new exploration to start. Client-side: derive hasActiveExploration from the full areas list and disable all Explore buttons (with a tooltip) whilst another area is underway. Closes #9.
177 lines
6.9 KiB
TypeScript
177 lines
6.9 KiB
TypeScript
import type { ExploreCollectResponse } from "@elysium/types";
|
||
import { useState } from "react";
|
||
import { useGame } from "../../context/GameContext.js";
|
||
import { EXPLORATION_AREAS } from "../../data/explorations.js";
|
||
import { ZoneSelector } from "./ZoneSelector.js";
|
||
|
||
const formatDuration = (seconds: number): string => {
|
||
if (seconds >= 86400) {
|
||
const days = Math.floor(seconds / 86400);
|
||
const hours = Math.floor((seconds % 86400) / 3600);
|
||
return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
|
||
}
|
||
if (seconds >= 3600) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
||
if (seconds >= 60) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||
return `${seconds}s`;
|
||
};
|
||
|
||
const timeRemaining = (startedAt: number, durationSeconds: number): number => {
|
||
const elapsed = (Date.now() - startedAt) / 1000;
|
||
return Math.max(0, durationSeconds - elapsed);
|
||
};
|
||
|
||
interface CollectResult {
|
||
areaId: string;
|
||
response: ExploreCollectResponse;
|
||
}
|
||
|
||
export const ExplorationPanel = (): React.JSX.Element => {
|
||
const { state, startExploration, collectExploration, formatNumber } = useGame();
|
||
const [activeZoneId, setActiveZoneId] = useState("verdant_vale");
|
||
const [pendingAreaId, setPendingAreaId] = useState<string | null>(null);
|
||
const [lastResult, setLastResult] = useState<CollectResult | null>(null);
|
||
|
||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||
|
||
const zones = state.zones ?? [];
|
||
const explorationState = state.exploration;
|
||
|
||
const zoneAreas = EXPLORATION_AREAS.filter((a) => a.zoneId === activeZoneId);
|
||
|
||
const hasActiveExploration =
|
||
explorationState?.areas.some((a) => a.status === "in_progress") ?? false;
|
||
|
||
const handleStart = async (areaId: string): Promise<void> => {
|
||
setPendingAreaId(areaId);
|
||
try {
|
||
await startExploration(areaId);
|
||
} finally {
|
||
setPendingAreaId(null);
|
||
}
|
||
};
|
||
|
||
const handleCollect = async (areaId: string): Promise<void> => {
|
||
setPendingAreaId(areaId);
|
||
try {
|
||
const result = await collectExploration(areaId);
|
||
setLastResult({ areaId, response: result });
|
||
} finally {
|
||
setPendingAreaId(null);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<section className="panel exploration-panel">
|
||
<div className="panel-header">
|
||
<h2>🗺️ Exploration</h2>
|
||
</div>
|
||
|
||
{lastResult && (
|
||
<div className="exploration-result">
|
||
<button
|
||
className="exploration-result-close"
|
||
onClick={() => { setLastResult(null); }}
|
||
type="button"
|
||
>
|
||
✕
|
||
</button>
|
||
{lastResult.response.foundNothing ? (
|
||
<p className="exploration-nothing">{lastResult.response.nothingMessage}</p>
|
||
) : (
|
||
<>
|
||
{lastResult.response.event && (
|
||
<p className="exploration-event-text">{lastResult.response.event.text}</p>
|
||
)}
|
||
<div className="exploration-rewards">
|
||
{(lastResult.response.event?.goldChange ?? 0) !== 0 && (
|
||
<span className={`reward-tag ${(lastResult.response.event?.goldChange ?? 0) > 0 ? "" : "negative"}`}>
|
||
🪙 {(lastResult.response.event?.goldChange ?? 0) > 0 ? "+" : ""}{formatNumber(lastResult.response.event?.goldChange ?? 0)} gold
|
||
</span>
|
||
)}
|
||
{(lastResult.response.event?.essenceChange ?? 0) > 0 && (
|
||
<span className="reward-tag">
|
||
✨ +{formatNumber(lastResult.response.event?.essenceChange ?? 0)} essence
|
||
</span>
|
||
)}
|
||
{lastResult.response.event?.materialGained && (
|
||
<span className="reward-tag material-tag">
|
||
📦 +{lastResult.response.event.materialGained.quantity} {lastResult.response.event.materialGained.materialId.replace(/_/g, " ")} (event)
|
||
</span>
|
||
)}
|
||
{lastResult.response.materialsFound.map((m) => (
|
||
<span key={m.materialId} className="reward-tag material-tag">
|
||
📦 +{m.quantity} {m.materialId.replace(/_/g, " ")}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<ZoneSelector
|
||
activeZoneId={activeZoneId}
|
||
zones={zones}
|
||
onSelectZone={(id) => { setActiveZoneId(id); setLastResult(null); }}
|
||
/>
|
||
|
||
<div className="exploration-list">
|
||
{zoneAreas.map((area) => {
|
||
const areaState = explorationState?.areas.find((a) => a.id === area.id);
|
||
const status = areaState?.status ?? "locked";
|
||
const startedAt = areaState?.startedAt ?? 0;
|
||
const isReady = status === "in_progress" && timeRemaining(startedAt, area.durationSeconds) <= 0;
|
||
const isPending = pendingAreaId === area.id;
|
||
|
||
return (
|
||
<div key={area.id} className={`exploration-card exploration-${status}`}>
|
||
<div className="exploration-info">
|
||
<h3>
|
||
{area.name}
|
||
{areaState?.completedOnce && <span className="exploration-discovered"> 📖</span>}
|
||
</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={() => { void handleStart(area.id); }}
|
||
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">
|
||
⏳ {formatDuration(Math.ceil(timeRemaining(startedAt, area.durationSeconds)))} remaining
|
||
</span>
|
||
)}
|
||
{(status === "in_progress" && isReady) && (
|
||
<button
|
||
className="collect-button"
|
||
disabled={isPending}
|
||
onClick={() => { void handleCollect(area.id); }}
|
||
type="button"
|
||
>
|
||
{isPending ? "Collecting..." : "📦 Collect Results"}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
{zoneAreas.length === 0 && (
|
||
<p className="empty-zone">No exploration areas in this zone.</p>
|
||
)}
|
||
</div>
|
||
</section>
|
||
);
|
||
};
|