From cfcf763ce3d967be6d77b6bde373d66426873e92 Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 18 Mar 2026 17:09:48 -0700 Subject: [PATCH] fix: use server-computed endsAt for exploration timer to prevent clock drift (#68) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Exploration timers showed more time than the area's stated duration when the server clock was ahead of the client's - The timer was derived from `startedAt = endsAt - durationMs`, then computed as `durationSeconds - (clientNow - startedAt) / 1000` — any server/client clock skew directly inflated the result - Now stores `endsAt` (the server-computed completion timestamp) directly in `ExplorationAreaState` and computes the timer as `(endsAt - Date.now()) / 1000`, which is immune to clock drift - Old saves without `endsAt` fall back gracefully to the previous `startedAt`-based calculation ## Test plan - [ ] Start a new exploration — timer should show exactly the area's stated duration (no more "1h area shows 1h15m") - [ ] Refresh the page mid-exploration — timer should resume from the correct remaining time (using server-anchored `endsAt`) - [ ] Old saves with `startedAt` but no `endsAt` should still display a timer via the fallback path Closes #53 ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: https://git.nhcarrigan.com/nhcarrigan/elysium/pulls/68 Co-authored-by: Hikari Co-committed-by: Hikari --- .../src/components/game/explorationPanel.tsx | 20 ++++++++++++++----- apps/web/src/context/gameContext.tsx | 15 +++++--------- packages/types/src/interfaces/exploration.ts | 6 ++++++ 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/game/explorationPanel.tsx b/apps/web/src/components/game/explorationPanel.tsx index b32fac4..defcafb 100644 --- a/apps/web/src/components/game/explorationPanel.tsx +++ b/apps/web/src/components/game/explorationPanel.tsx @@ -46,11 +46,21 @@ const formatDuration = (seconds: number): string => { /** * Computes the time remaining for an exploration in progress. + * Uses endsAt (server-computed) when available to avoid client/server clock drift. + * Falls back to startedAt + durationSeconds for saves predating the endsAt field. + * @param endsAt - The server-computed completion timestamp, if available. * @param startedAt - The timestamp when exploration started. * @param durationSeconds - The total duration in seconds. * @returns The remaining seconds. */ -const timeRemaining = (startedAt: number, durationSeconds: number): number => { +const timeRemaining = ( + endsAt: number | undefined, + startedAt: number, + durationSeconds: number, +): number => { + if (endsAt !== undefined) { + return Math.max(0, (endsAt - Date.now()) / 1000); + } const elapsed = (Date.now() - startedAt) / 1000; return Math.max(0, durationSeconds - elapsed); }; @@ -217,9 +227,10 @@ const ExplorationPanel = (): JSX.Element => { }); const status = areaState?.status ?? "locked"; const startedAt = areaState?.startedAt ?? 0; + const endsAt = areaState?.endsAt; const isReady = status === "in_progress" - && timeRemaining(startedAt, area.durationSeconds) <= 0; + && timeRemaining(endsAt, startedAt, area.durationSeconds) <= 0; const isPending = pendingAreaId === area.id; function handleStartClick(): void { @@ -276,9 +287,8 @@ const ExplorationPanel = (): JSX.Element => { {status === "in_progress" && !isReady && {"⏳ "} - {formatDuration( - Math.ceil(timeRemaining(startedAt, area.durationSeconds)), - )} + {/* eslint-disable-next-line stylistic/max-len -- long call cannot be shortened */} + {formatDuration(Math.ceil(timeRemaining(endsAt, startedAt, area.durationSeconds)))} {" remaining"} } diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index 1669589..d356a3f 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -52,7 +52,6 @@ import { transcend as transcendApi, } from "../api/client.js"; import { CODEX_ENTRIES } from "../data/codex.js"; -import { EXPLORATION_AREAS } from "../data/explorations.js"; import { RECIPES } from "../data/recipes.js"; import { RESOURCE_CAP, @@ -1671,14 +1670,6 @@ export const GameProvider = ({ const startExploration = useCallback(async(areaId: string) => { try { const response = await startExplorationApi({ areaId }); - const areaData = EXPLORATION_AREAS.find((a) => { - return a.id === areaId; - }); - if (areaData === undefined) { - return; - } - // eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear - const startedAt = response.endsAt - areaData.durationSeconds * 1000; setState((previous) => { if (previous?.exploration === undefined) { return previous; @@ -1689,7 +1680,11 @@ export const GameProvider = ({ ...previous.exploration, areas: previous.exploration.areas.map((a) => { return a.id === areaId - ? { ...a, startedAt: startedAt, status: "in_progress" as const } + ? { + ...a, + endsAt: response.endsAt, + status: "in_progress" as const, + } : a; }), }, diff --git a/packages/types/src/interfaces/exploration.ts b/packages/types/src/interfaces/exploration.ts index 6b35b77..a80122a 100644 --- a/packages/types/src/interfaces/exploration.ts +++ b/packages/types/src/interfaces/exploration.ts @@ -72,6 +72,12 @@ interface ExplorationAreaState { */ startedAt?: number; + /** + * Unix timestamp when the exploration will complete (server-computed, used for + * accurate client-side countdown that is immune to client/server clock drift). + */ + endsAt?: number; + /** * True after the first successful collect — used for codex unlock detection. */