Files
elysium/apps/web/src/components/game/ExplorationPanel.tsx
T
hikari 58e7000954 feat: limit players to one active exploration at a time
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.
2026-03-07 12:06:21 -08:00

177 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};