generated from nhcarrigan/template
feat: poll server for exploration claimability before showing collect button
Resolves #127
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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<ExploreClaimableResponse> => {
|
||||
return await fetchJson<ExploreClaimableResponse>(
|
||||
`/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,
|
||||
|
||||
@@ -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<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 (
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user