feat: add exploration and crafting systems

Adds two new game systems: Exploration (scouts collect materials from
timed area runs) and Crafting (combine materials into permanent
multipliers). Includes 72 exploration areas, 54 materials, 36 recipes,
and 108 new Codex lore entries. Removes unused characterName requirement
from prestige/transcendence/apotheosis reset flows.
This commit is contained in:
2026-03-07 04:14:04 -08:00
committed by Naomi Carrigan
parent 2aa6362ad6
commit 6ddf8e0b43
35 changed files with 4722 additions and 94 deletions
@@ -0,0 +1,172 @@
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 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}
onClick={() => { void handleStart(area.id); }}
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>
);
};