generated from nhcarrigan/template
fix: use server-computed endsAt for exploration timer to prevent clock drift (#68)
## 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:
@@ -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
|
||||
&& <span className="quest-badge active">
|
||||
{"⏳ "}
|
||||
{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"}
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user