From b85126c34515fa1164868683334df46ec663701e Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 24 Mar 2026 12:00:11 -0700 Subject: [PATCH] feat: poll server for exploration claimability before showing collect button Resolves #127 --- apps/api/src/routes/explore.ts | 60 ++++++++++++++++ apps/web/src/api/client.ts | 15 ++++ .../src/components/game/explorationPanel.tsx | 71 ++++++++++++++++++- packages/types/src/index.ts | 1 + packages/types/src/interfaces/api.ts | 5 ++ 5 files changed, 149 insertions(+), 3 deletions(-) diff --git a/apps/api/src/routes/explore.ts b/apps/api/src/routes/explore.ts index 8120f59..a7b80f1 100644 --- a/apps/api/src/routes/explore.ts +++ b/apps/api/src/routes/explore.ts @@ -7,6 +7,7 @@ /* eslint-disable max-lines-per-function -- Route handlers require many steps */ /* eslint-disable max-statements -- Route handlers require many statements */ /* eslint-disable complexity -- Route handlers have inherent complexity */ +/* eslint-disable max-lines -- Route file requires multiple handlers */ import { Hono } from "hono"; import { defaultExplorations } from "../data/explorations.js"; import { initialExploration } from "../data/initialState.js"; @@ -15,6 +16,7 @@ import { authMiddleware } from "../middleware/auth.js"; import { logger } from "../services/logger.js"; import type { HonoEnvironment } from "../types/hono.js"; import type { + ExploreClaimableResponse, ExploreCollectEventResult, ExploreCollectRequest, ExploreCollectResponse, @@ -49,6 +51,64 @@ const pickNothingMessage = (): string => { return nothingMessages[index] ?? nothingMessages[0] ?? ""; }; +exploreRouter.get("/claimable", async(context) => { + try { + const discordId = context.get("discordId"); + const areaId = context.req.query("areaId"); + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime query validation + if (!areaId) { + return context.json({ error: "areaId is required" }, 400); + } + + const explorationArea = defaultExplorations.find((a) => { + return a.id === areaId; + }); + if (!explorationArea) { + return context.json({ error: "Unknown exploration area" }, 404); + } + + const record = await prisma.gameState.findUnique({ where: { discordId } }); + if (!record) { + return context.json({ error: "No save found" }, 404); + } + + const rawState: unknown = record.state; + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ + const state = rawState as GameState; + + if (!state.exploration) { + const response: ExploreClaimableResponse = { claimable: false }; + return context.json(response); + } + + const area = state.exploration.areas.find((a) => { + return a.id === areaId; + }); + + if (!area || area.status !== "in_progress") { + const response: ExploreClaimableResponse = { claimable: false }; + return context.json(response); + } + + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next -- @preserve */ + const startedAt = area.startedAt ?? 0; + const durationMs = explorationArea.durationSeconds * 1000; + const expiresAt = startedAt + durationMs; + const claimable = Date.now() >= expiresAt; + const response: ExploreClaimableResponse = { claimable }; + return context.json(response); + } catch (error) { + void logger.error( + "explore_claimable", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } +}); + exploreRouter.post("/start", async(context) => { try { const discordId = context.get("discordId"); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index c5b28ff..b4bc783 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -17,6 +17,7 @@ import type { BuyPrestigeUpgradeResponse, CraftRecipeRequest, CraftRecipeResponse, + ExploreClaimableResponse, ExploreCollectRequest, ExploreCollectResponse, ExploreStartRequest, @@ -244,6 +245,19 @@ const collectExploration = async( }); }; +/** + * Checks whether a given exploration area is ready to claim on the server. + * @param areaId - The area ID to check. + * @returns Whether the exploration is claimable. + */ +const checkExplorationClaimable = async( + areaId: string, +): Promise => { + return await fetchJson( + `/explore/claimable?areaId=${encodeURIComponent(areaId)}`, + ); +}; + /** * Crafts a recipe on the server. * @param body - The craft recipe request payload. @@ -316,6 +330,7 @@ export { buyEchoUpgrade, buyPrestigeUpgrade, challengeBoss, + checkExplorationClaimable, collectExploration, craftRecipe, debugHardReset, diff --git a/apps/web/src/components/game/explorationPanel.tsx b/apps/web/src/components/game/explorationPanel.tsx index 2ba693e..228fa14 100644 --- a/apps/web/src/components/game/explorationPanel.tsx +++ b/apps/web/src/components/game/explorationPanel.tsx @@ -7,12 +7,17 @@ /* 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"; +/* 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 { ExploreCollectResponse } from "@elysium/types"; +import type { + ExploreClaimableResponse, + ExploreCollectResponse, +} from "@elysium/types"; /** * Formats a duration in seconds to a human-readable string. @@ -83,6 +88,61 @@ const ExplorationPanel = (): JSX.Element => { }); const [ pendingAreaId, setPendingAreaId ] = useState(null); const [ lastResult, setLastResult ] = useState(null); + const [ claimableAreaIds, setClaimableAreaIds ] + = useState>(new Set()); + + const stateReference = useRef(state); + stateReference.current = state; + + const claimableReference = useRef(claimableAreaIds); + claimableReference.current = claimableAreaIds; + + useEffect(() => { + const pollClaimable = async(): Promise => { + 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 ( @@ -134,6 +194,11 @@ const ExplorationPanel = (): JSX.Element => { 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); } @@ -269,7 +334,7 @@ const ExplorationPanel = (): JSX.Element => { const endsAt = areaState?.endsAt; const isReady = status === "in_progress" - && timeRemaining(endsAt, startedAt, area.durationSeconds) <= 0; + && claimableAreaIds.has(area.id); const isPending = pendingAreaId === area.id; function handleStartClick(): void { diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 3687c06..64464a1 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -55,6 +55,7 @@ export type { BuyPrestigeUpgradeResponse, CraftRecipeRequest, CraftRecipeResponse, + ExploreClaimableResponse, ExploreCollectEventResult, ExploreCollectRequest, ExploreCollectResponse, diff --git a/packages/types/src/interfaces/api.ts b/packages/types/src/interfaces/api.ts index 70fd596..ffd2125 100644 --- a/packages/types/src/interfaces/api.ts +++ b/packages/types/src/interfaces/api.ts @@ -384,6 +384,10 @@ interface ExploreCollectResponse { event: ExploreCollectEventResult | null; } +interface ExploreClaimableResponse { + claimable: boolean; +} + interface CraftRecipeRequest { recipeId: string; } @@ -528,6 +532,7 @@ export type { BuyPrestigeUpgradeResponse, CraftRecipeRequest, CraftRecipeResponse, + ExploreClaimableResponse, ExploreCollectEventResult, ExploreCollectRequest, ExploreCollectResponse,