fix: use server-computed endsAt for exploration timer to prevent clock drift #68

Merged
naomi merged 1 commits from fix/exploration-timer-drift into main 2026-03-18 17:09:48 -07:00
3 changed files with 26 additions and 15 deletions
@@ -46,11 +46,21 @@ const formatDuration = (seconds: number): string => {
/** /**
* Computes the time remaining for an exploration in progress. * 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 startedAt - The timestamp when exploration started.
* @param durationSeconds - The total duration in seconds. * @param durationSeconds - The total duration in seconds.
* @returns The remaining 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; const elapsed = (Date.now() - startedAt) / 1000;
return Math.max(0, durationSeconds - elapsed); return Math.max(0, durationSeconds - elapsed);
}; };
@@ -217,9 +227,10 @@ const ExplorationPanel = (): JSX.Element => {
}); });
const status = areaState?.status ?? "locked"; const status = areaState?.status ?? "locked";
const startedAt = areaState?.startedAt ?? 0; const startedAt = areaState?.startedAt ?? 0;
const endsAt = areaState?.endsAt;
const isReady const isReady
= status === "in_progress" = status === "in_progress"
&& timeRemaining(startedAt, area.durationSeconds) <= 0; && timeRemaining(endsAt, startedAt, area.durationSeconds) <= 0;
const isPending = pendingAreaId === area.id; const isPending = pendingAreaId === area.id;
function handleStartClick(): void { function handleStartClick(): void {
@@ -276,9 +287,8 @@ const ExplorationPanel = (): JSX.Element => {
{status === "in_progress" && !isReady {status === "in_progress" && !isReady
&& <span className="quest-badge active"> && <span className="quest-badge active">
{"⏳ "} {"⏳ "}
{formatDuration( {/* eslint-disable-next-line stylistic/max-len -- long call cannot be shortened */}
Math.ceil(timeRemaining(startedAt, area.durationSeconds)), {formatDuration(Math.ceil(timeRemaining(endsAt, startedAt, area.durationSeconds)))}
)}
{" remaining"} {" remaining"}
</span> </span>
} }
+5 -10
View File
@@ -52,7 +52,6 @@ import {
transcend as transcendApi, transcend as transcendApi,
} from "../api/client.js"; } from "../api/client.js";
import { CODEX_ENTRIES } from "../data/codex.js"; import { CODEX_ENTRIES } from "../data/codex.js";
import { EXPLORATION_AREAS } from "../data/explorations.js";
import { RECIPES } from "../data/recipes.js"; import { RECIPES } from "../data/recipes.js";
import { import {
RESOURCE_CAP, RESOURCE_CAP,
@@ -1671,14 +1670,6 @@ export const GameProvider = ({
const startExploration = useCallback(async(areaId: string) => { const startExploration = useCallback(async(areaId: string) => {
try { try {
const response = await startExplorationApi({ areaId }); 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) => { setState((previous) => {
if (previous?.exploration === undefined) { if (previous?.exploration === undefined) {
return previous; return previous;
@@ -1689,7 +1680,11 @@ export const GameProvider = ({
...previous.exploration, ...previous.exploration,
areas: previous.exploration.areas.map((a) => { areas: previous.exploration.areas.map((a) => {
return a.id === areaId return a.id === areaId
? { ...a, startedAt: startedAt, status: "in_progress" as const } ? {
...a,
endsAt: response.endsAt,
status: "in_progress" as const,
}
: a; : a;
}), }),
}, },
@@ -72,6 +72,12 @@ interface ExplorationAreaState {
*/ */
startedAt?: number; 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. * True after the first successful collect — used for codex unlock detection.
*/ */