Fixed world clock: no player time controls (ISS-117)
CI / build + unit + e2e (pull_request) Failing after 12m10s
CI / build + unit + e2e (pull_request) Failing after 12m10s
The world always runs at WORLD_TIME_SCALE = 24 (lib/worldClock.ts). A server
background tick (instrumentation.ts -> startBackgroundTick, 1 s interval)
advances the sim with no open browser; idle-pause freezes the clock exactly
while no plane is flying or in repair. Fresh worlds start year 2000
(STARTING_CLOCK_MS in seed.ts).
- Removed: POST /api/sim + OpenAPI entry, applyTimeControls/clampTimeScale,
lib/timeScale.ts, LeftPane pause button + scale slider machinery,
ISS-057 slow-on-landing/resume in flyPlane.
- Snapshot simState is now { clockMs } only; client extrapolates at the fixed
rate between polls and freezes while idle (useSimClock).
- New test-only hook POST /api/test/set-timescale behind FLIGHT_TEST_HOOKS=1
(sibling of set-health); all e2e setScale helpers call it, afterEach restores
the world rate. E2E pause tests replaced by idle-pause assertions; sim.spec.ts
activates the world via set-health + /repair for advance/expiry tests.
This commit is contained in:
+10
-218
@@ -1,12 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { GeoJsonFeatureCollection } from '@/lib/airports';
|
||||
import type { PlaneState, StateSnapshot } from '@/lib/game/state';
|
||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||||
import { abandonPenalty } from '@/lib/game/penalty';
|
||||
import { formatCash, formatDeadlineRelative, formatFuel, formatKm, formatSimClock, formatSpecialLabel } from '@/lib/format';
|
||||
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
|
||||
import { useGameAction } from '@/lib/useGameAction';
|
||||
import { useSimClock } from '@/lib/useSimClock';
|
||||
import { cargoWeightFor } from './RightPane';
|
||||
@@ -16,8 +15,6 @@ interface LeftPaneProps {
|
||||
state: StateSnapshot | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
// The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately.
|
||||
onRefresh: () => void;
|
||||
// ISS-021 (v0.20.0): push the full snapshot returned by an action endpoint into the shared shell
|
||||
// state — needed now that the per-plane cargo rows' fly/abandon actions live in this pane.
|
||||
applySnapshot: (snapshot: StateSnapshot) => void;
|
||||
@@ -27,12 +24,6 @@ interface LeftPaneProps {
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
// Discrete ladder slider (v0.9.2, values reworked in v0.14.0): position t ∈ [0,1] → nearest
|
||||
// step of the ladder 1×/2×/5×/10×/25×/50×/100×/250×/500×/1000× — each of the 10 positions
|
||||
// roughly doubles the speed.
|
||||
const SCALE_SLIDER_STEP = 1 / (TIME_SCALES.length - 1); // one ladder step per keyboard-arrow step
|
||||
const SCALE_COMMIT_DEBOUNCE_MS = 250;
|
||||
|
||||
// Flying planes show their current leg from the snapshot's dest fields (v0.12.0).
|
||||
function planeLocation(p: PlaneState): string {
|
||||
if (p.status === 'flying' && p.destName) return `${p.locationName} → ${p.destName}`;
|
||||
@@ -47,51 +38,7 @@ function loadoutFor(state: StateSnapshot, p: PlaneState): string {
|
||||
return `${(used / 1000).toFixed(1)}t / ${(maxPayloadKg / 1000).toFixed(1)}t`;
|
||||
}
|
||||
|
||||
// ISS-001 — an optimistic slider value is cleared exactly when a rendered snapshot CONFIRMS it
|
||||
// (server scale === pending), never earlier: clearing at commit time would snap the pointer back
|
||||
// to the stale scale for one poll cycle, then jump forward again on the confirming snapshot.
|
||||
export function pendingScaleConfirmed(pendingScale: number | null, serverTimeScale: number): boolean {
|
||||
return pendingScale !== null && serverTimeScale === pendingScale;
|
||||
}
|
||||
|
||||
// ISS-008 fix (v0.17.0, CI runs #44/#47): bounded retry for POST /api/sim — a commit dropped by
|
||||
// the runner's flaky uplink used to leave the UI optimistic while the server kept the old scale.
|
||||
// Up to maxAttempts tries with delayMs between them; stops early once superseded() flips (a newer
|
||||
// intent arrived and the caller's loop sends it instead). post is idempotent set-semantics, so
|
||||
// re-sending a body whose response was lost cannot double-apply. Pure core — unit-tested in node.
|
||||
export const SIM_POST_MAX_ATTEMPTS = 3;
|
||||
export const SIM_POST_RETRY_DELAY_MS = 500;
|
||||
|
||||
export type SimPostRetryResult =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: 'exhausted'; error: unknown }
|
||||
| { ok: false; reason: 'superseded' };
|
||||
|
||||
export async function retrySimPost(
|
||||
post: () => Promise<void>,
|
||||
superseded: () => boolean,
|
||||
sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
|
||||
maxAttempts: number = SIM_POST_MAX_ATTEMPTS,
|
||||
delayMs: number = SIM_POST_RETRY_DELAY_MS,
|
||||
): Promise<SimPostRetryResult> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
if (superseded()) return { ok: false, reason: 'superseded' }; // a newer intent takes over — don't even send this body
|
||||
try {
|
||||
await post();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (superseded()) return { ok: false, reason: 'superseded' }; // a newer intent arrived during the attempt — don't wait out the backoff
|
||||
if (attempt < maxAttempts) await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
return { ok: false, reason: 'exhausted', error: lastError };
|
||||
}
|
||||
|
||||
export default function LeftPane({ state, loading, error, onRefresh, applySnapshot, onHover, onError }: LeftPaneProps) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
export default function LeftPane({ state, loading, error, applySnapshot, onHover, onError }: LeftPaneProps) {
|
||||
// ISS-021 (v0.20.0): shared action runner for the per-plane cargo/queue actions (moved here from RightPane).
|
||||
const { runAction, busy: actionsBusy, actionError } = useGameAction(state, applySnapshot, onError);
|
||||
|
||||
@@ -123,73 +70,6 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
||||
void airportsPromiseRef.current.then(setAirportOptions).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// POST /api/sim (retried up to SIM_POST_MAX_ATTEMPTS× on failure — CI runs #44/#47), then
|
||||
// refetch the full snapshot immediately so the pane reflects the new controls without waiting
|
||||
// for the next poll tick. After the final attempt fails keep the stale UI and surface an error
|
||||
// toast (the running poll retries on its own). A commit that lands while another POST is in
|
||||
// flight — including mid-retry — is held in a single slot (last intent wins) and supersedes
|
||||
// the in-flight body, so every user intent reaches the server (the [1,1000] clamp stays as
|
||||
// backstop). The gate is a REF, not React state: closures never see a stale "busy" value,
|
||||
// which used to let a flush re-queue instead of send and silently drop a commit.
|
||||
const queuedRef = useRef<{ timeScale?: number; paused?: boolean } | null>(null);
|
||||
const busyRef = useRef(false);
|
||||
|
||||
type SimControlsOutcome = 'sent' | 'queued' | 'failed';
|
||||
|
||||
// 'sent': this body reached the server (a confirming snapshot follows via onRefresh).
|
||||
// 'queued': an in-flight call holds it — its flush will send it.
|
||||
// 'failed': the POST for this body never succeeded; callers drop optimistic values only then.
|
||||
const applySimControls = useCallback(
|
||||
async (body: { timeScale?: number; paused?: boolean }): Promise<SimControlsOutcome> => {
|
||||
if (busyRef.current) {
|
||||
queuedRef.current = body; // last intent wins — the in-flight call's flush sends it
|
||||
return 'queued';
|
||||
}
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
let sentOwn = false;
|
||||
try {
|
||||
let current: { timeScale?: number; paused?: boolean } | null = body;
|
||||
while (current !== null) {
|
||||
const target = current; // stable across this retry round
|
||||
const result = await retrySimPost(
|
||||
async () => {
|
||||
const res = await fetch('/api/sim', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(target),
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) throw new Error(`POST /api/sim → HTTP ${res.status}`);
|
||||
},
|
||||
() => queuedRef.current !== null, // a newer intent supersedes the in-flight body mid-retry
|
||||
);
|
||||
if (result.ok) {
|
||||
onRefresh();
|
||||
if (target === body) sentOwn = true; // object identity — queued bodies are fresh literals
|
||||
} else if (result.reason === 'exhausted') {
|
||||
throw result.error; // only after the final attempt fails → error toast below
|
||||
}
|
||||
current = queuedRef.current; // pick up anything that arrived while we were in flight
|
||||
queuedRef.current = null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('failed to apply sim controls', err);
|
||||
// ISS-008: surface the failure as an error toast too — previously console-only.
|
||||
onError?.(`Failed to update time controls (${err instanceof Error ? err.message : String(err)})`);
|
||||
queuedRef.current = null; // don't replay a stale intent after a failed round-trip
|
||||
} finally {
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
return sentOwn ? 'sent' : 'failed';
|
||||
},
|
||||
[onRefresh, onError],
|
||||
);
|
||||
|
||||
const paused = state?.simState.paused ?? false;
|
||||
const timeScale = state?.simState.timeScale ?? 1;
|
||||
|
||||
// Snapshot clock — feeds the deadline/penalty previews in the per-plane cargo rows.
|
||||
const clockMs = state?.simState.clockMs ?? 0;
|
||||
|
||||
@@ -208,69 +88,6 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
||||
relocatePlane?.status === 'flying' && relocateDest !== null &&
|
||||
(relocatePlane.destIcao === relocateDest.icao || relocatePlane.queue.includes(relocateDest.icao));
|
||||
|
||||
// Slider commit (v0.9.1): the displayed value follows the drag optimistically via
|
||||
// pendingScale; POST /api/sim fires after a 250 ms debounce, or immediately when the
|
||||
// user releases the slider (browsers fire a native 'change' on release). The server's
|
||||
// [1,1000] clamp stays as the backstop.
|
||||
const [pendingScale, setPendingScaleState] = useState<number | null>(null);
|
||||
const pendingRef = useRef<number | null>(null);
|
||||
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const setPending = useCallback((s: number | null) => {
|
||||
pendingRef.current = s;
|
||||
setPendingScaleState(s);
|
||||
}, []);
|
||||
|
||||
const commitNow = useCallback(() => {
|
||||
if (commitTimerRef.current !== null) {
|
||||
clearTimeout(commitTimerRef.current);
|
||||
commitTimerRef.current = null;
|
||||
}
|
||||
const s = pendingRef.current;
|
||||
if (s === null) return;
|
||||
// ISS-001: keep the chosen value on screen until a snapshot CONFIRMS it — clearing at
|
||||
// commit time would snap the slider back to the stale scale for one poll cycle, then jump
|
||||
// forward again when the confirming snapshot lands. 'queued' also keeps it (the in-flight
|
||||
// call flushes it); only a genuine failure drops it so the display falls back to truth.
|
||||
void applySimControls({ timeScale: s }).then((outcome) => {
|
||||
if (outcome === 'failed') setPending(null);
|
||||
});
|
||||
}, [applySimControls, setPending]);
|
||||
|
||||
// Confirmation: once a rendered snapshot carries the pending scale AND no commit is in
|
||||
// flight, the optimistic value has been superseded by server truth — clear it (display is
|
||||
// unchanged either way). The busy guard matters: while a commit POST is still out, an OLD
|
||||
// snapshot that happens to match the pending value is coincidence, not confirmation (e.g. a
|
||||
// quick drag back to the previous scale must not be swallowed before its own commit fires).
|
||||
useEffect(() => {
|
||||
if (!busy && state && pendingScaleConfirmed(pendingScale, state.simState.timeScale)) setPending(null);
|
||||
}, [state, pendingScale, busy, setPending]);
|
||||
|
||||
const onSliderInput = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
setPending(sliderToScale(Number(e.target.value)));
|
||||
if (commitTimerRef.current !== null) clearTimeout(commitTimerRef.current);
|
||||
commitTimerRef.current = setTimeout(commitNow, SCALE_COMMIT_DEBOUNCE_MS);
|
||||
},
|
||||
[commitNow, setPending],
|
||||
);
|
||||
|
||||
const sliderRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
const el = sliderRef.current;
|
||||
if (!el) return;
|
||||
el.addEventListener('change', commitNow); // flush on pointer-up / keyboard release
|
||||
return () => el.removeEventListener('change', commitNow);
|
||||
}, [commitNow]);
|
||||
|
||||
// Drop any pending debounce timer on unmount.
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (commitTimerRef.current !== null) clearTimeout(commitTimerRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// ISS-008: browser-notification opt-in. Starts null (SSR-safe — the real permission is read
|
||||
// in an effect after hydration, so server and client render identically) and follows the
|
||||
// result of Notification.requestPermission(). 'unsupported' = no Notification API at all.
|
||||
@@ -290,13 +107,11 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
||||
? 'Notifications unavailable'
|
||||
: 'Enable notifications';
|
||||
|
||||
const displayedScale = pendingScale ?? timeScale;
|
||||
|
||||
// ISS-007: the clock ticks client-side between polls, re-syncing to server truth on every
|
||||
// snapshot. Fed with EFFECTIVE controls so a scale drag / pause click takes effect immediately,
|
||||
// not when the confirming snapshot lands. Only the sim-clock paragraph uses this — deadline and
|
||||
// penalty previews stay snapshot-based (deterministic against the server clock).
|
||||
const simClockMs = useSimClock(state, { timeScale: displayedScale, paused });
|
||||
// ISS-007: the clock ticks client-side between polls at the fixed world rate, re-syncing to
|
||||
// server truth on every snapshot (and freezing while nothing is happening). Only the sim-clock
|
||||
// paragraph uses this — deadline and penalty previews stay snapshot-based (deterministic against
|
||||
// the server clock).
|
||||
const simClockMs = useSimClock(state);
|
||||
|
||||
return (
|
||||
<aside data-testid="left-pane" className="pane left-pane">
|
||||
@@ -563,32 +378,9 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
||||
|
||||
<section className="pane-section">
|
||||
<h3 className="pane-heading">Time</h3>
|
||||
<p data-testid="sim-clock" className="sim-clock">
|
||||
{simClockMs !== null && formatSimClock(simClockMs)}
|
||||
{paused ? ' (paused)' : ''}
|
||||
</p>
|
||||
<div className="time-controls">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pause-toggle"
|
||||
disabled={busy}
|
||||
onClick={() => void applySimControls({ paused: !paused })}
|
||||
>
|
||||
{paused ? 'Resume' : 'Pause'}
|
||||
</button>
|
||||
<input
|
||||
ref={sliderRef}
|
||||
type="range"
|
||||
data-testid="time-scale-slider"
|
||||
aria-label="Time scale"
|
||||
min={0}
|
||||
max={1}
|
||||
step={SCALE_SLIDER_STEP}
|
||||
value={scaleToSlider(displayedScale)}
|
||||
onChange={onSliderInput}
|
||||
/>
|
||||
<span data-testid="time-scale-value">{displayedScale}×</span>
|
||||
</div>
|
||||
{/* ISS-117 (v0.52.0): no player-facing time controls — the world runs at a fixed rate;
|
||||
only the clock display remains here. */}
|
||||
<p data-testid="sim-clock" className="sim-clock">{simClockMs !== null && formatSimClock(simClockMs)}</p>
|
||||
{/* ISS-008: explicit opt-in for browser notifications (fired when the window is hidden). */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user