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-lines-per-function -- Route handlers require many steps */
|
||||||
/* eslint-disable max-statements -- Route handlers require many statements */
|
/* eslint-disable max-statements -- Route handlers require many statements */
|
||||||
/* eslint-disable complexity -- Route handlers have inherent complexity */
|
/* eslint-disable complexity -- Route handlers have inherent complexity */
|
||||||
|
/* eslint-disable max-lines -- Route file requires multiple handlers */
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { defaultExplorations } from "../data/explorations.js";
|
import { defaultExplorations } from "../data/explorations.js";
|
||||||
import { initialExploration } from "../data/initialState.js";
|
import { initialExploration } from "../data/initialState.js";
|
||||||
@@ -15,6 +16,7 @@ import { authMiddleware } from "../middleware/auth.js";
|
|||||||
import { logger } from "../services/logger.js";
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
import type {
|
import type {
|
||||||
|
ExploreClaimableResponse,
|
||||||
ExploreCollectEventResult,
|
ExploreCollectEventResult,
|
||||||
ExploreCollectRequest,
|
ExploreCollectRequest,
|
||||||
ExploreCollectResponse,
|
ExploreCollectResponse,
|
||||||
@@ -49,6 +51,64 @@ const pickNothingMessage = (): string => {
|
|||||||
return nothingMessages[index] ?? nothingMessages[0] ?? "";
|
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) => {
|
exploreRouter.post("/start", async(context) => {
|
||||||
try {
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
BuyPrestigeUpgradeResponse,
|
BuyPrestigeUpgradeResponse,
|
||||||
CraftRecipeRequest,
|
CraftRecipeRequest,
|
||||||
CraftRecipeResponse,
|
CraftRecipeResponse,
|
||||||
|
ExploreClaimableResponse,
|
||||||
ExploreCollectRequest,
|
ExploreCollectRequest,
|
||||||
ExploreCollectResponse,
|
ExploreCollectResponse,
|
||||||
ExploreStartRequest,
|
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.
|
* Crafts a recipe on the server.
|
||||||
* @param body - The craft recipe request payload.
|
* @param body - The craft recipe request payload.
|
||||||
@@ -316,6 +330,7 @@ export {
|
|||||||
buyEchoUpgrade,
|
buyEchoUpgrade,
|
||||||
buyPrestigeUpgrade,
|
buyPrestigeUpgrade,
|
||||||
challengeBoss,
|
challengeBoss,
|
||||||
|
checkExplorationClaimable,
|
||||||
collectExploration,
|
collectExploration,
|
||||||
craftRecipe,
|
craftRecipe,
|
||||||
debugHardReset,
|
debugHardReset,
|
||||||
|
|||||||
@@ -7,12 +7,17 @@
|
|||||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||||
/* eslint-disable complexity -- Complex component with many conditional 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 */
|
/* 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 { useGame } from "../../context/gameContext.js";
|
||||||
import { EXPLORATION_AREAS } from "../../data/explorations.js";
|
import { EXPLORATION_AREAS } from "../../data/explorations.js";
|
||||||
import { cdnImage } from "../../utils/cdn.js";
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { ZoneSelector } from "./zoneSelector.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.
|
* 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 [ pendingAreaId, setPendingAreaId ] = useState<string | null>(null);
|
||||||
const [ lastResult, setLastResult ] = useState<CollectResult | 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) {
|
if (state === null) {
|
||||||
return (
|
return (
|
||||||
@@ -134,6 +194,11 @@ const ExplorationPanel = (): JSX.Element => {
|
|||||||
try {
|
try {
|
||||||
const result = await collectExploration(areaId);
|
const result = await collectExploration(areaId);
|
||||||
setLastResult({ areaId: areaId, response: result });
|
setLastResult({ areaId: areaId, response: result });
|
||||||
|
setClaimableAreaIds((previous) => {
|
||||||
|
const next = new Set(previous);
|
||||||
|
next.delete(areaId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setPendingAreaId(null);
|
setPendingAreaId(null);
|
||||||
}
|
}
|
||||||
@@ -269,7 +334,7 @@ const ExplorationPanel = (): JSX.Element => {
|
|||||||
const endsAt = areaState?.endsAt;
|
const endsAt = areaState?.endsAt;
|
||||||
const isReady
|
const isReady
|
||||||
= status === "in_progress"
|
= status === "in_progress"
|
||||||
&& timeRemaining(endsAt, startedAt, area.durationSeconds) <= 0;
|
&& claimableAreaIds.has(area.id);
|
||||||
const isPending = pendingAreaId === area.id;
|
const isPending = pendingAreaId === area.id;
|
||||||
|
|
||||||
function handleStartClick(): void {
|
function handleStartClick(): void {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export type {
|
|||||||
BuyPrestigeUpgradeResponse,
|
BuyPrestigeUpgradeResponse,
|
||||||
CraftRecipeRequest,
|
CraftRecipeRequest,
|
||||||
CraftRecipeResponse,
|
CraftRecipeResponse,
|
||||||
|
ExploreClaimableResponse,
|
||||||
ExploreCollectEventResult,
|
ExploreCollectEventResult,
|
||||||
ExploreCollectRequest,
|
ExploreCollectRequest,
|
||||||
ExploreCollectResponse,
|
ExploreCollectResponse,
|
||||||
|
|||||||
@@ -384,6 +384,10 @@ interface ExploreCollectResponse {
|
|||||||
event: ExploreCollectEventResult | null;
|
event: ExploreCollectEventResult | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ExploreClaimableResponse {
|
||||||
|
claimable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface CraftRecipeRequest {
|
interface CraftRecipeRequest {
|
||||||
recipeId: string;
|
recipeId: string;
|
||||||
}
|
}
|
||||||
@@ -528,6 +532,7 @@ export type {
|
|||||||
BuyPrestigeUpgradeResponse,
|
BuyPrestigeUpgradeResponse,
|
||||||
CraftRecipeRequest,
|
CraftRecipeRequest,
|
||||||
CraftRecipeResponse,
|
CraftRecipeResponse,
|
||||||
|
ExploreClaimableResponse,
|
||||||
ExploreCollectEventResult,
|
ExploreCollectEventResult,
|
||||||
ExploreCollectRequest,
|
ExploreCollectRequest,
|
||||||
ExploreCollectResponse,
|
ExploreCollectResponse,
|
||||||
|
|||||||
Reference in New Issue
Block a user