From 53eaebd7d851349060f0aede666459578e424aa7 Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 18 Mar 2026 16:46:49 -0700 Subject: [PATCH] fix: use server-computed endsAt for exploration timer to prevent clock drift Exploration timers were showing more time than the area's stated duration when the server clock was ahead of the client clock. The timer now uses the server-provided endsAt timestamp directly instead of deriving startedAt from it, making countdowns immune to client/server clock skew. Old saves without endsAt fall back to the previous startedAt-based calculation. Closes #53 --- .../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. */