generated from nhcarrigan/template
fix: use server-computed endsAt for exploration timer to prevent clock drift #68
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user