fix: use server-computed endsAt for exploration timer to prevent clock drift (#68)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Lint, Build & Test (push) Successful in 1m8s

## 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: #68
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #68.
This commit is contained in:
2026-03-18 17:09:48 -07:00
committed by Naomi Carrigan
parent aede55a13d
commit cfcf763ce3
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.
*/ */