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:
+19
-25
@@ -2,57 +2,51 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { StateSnapshot } from './game/state';
|
||||
import { WORLD_TIME_SCALE } from './worldClock';
|
||||
|
||||
// ISS-007 (v0.16.1): the sim clock ticks smoothly on the CLIENT between polls instead of
|
||||
// jumping once per snapshot. The server's authoritative rate is exactly real-time × timeScale
|
||||
// jumping once per snapshot. The server's authoritative rate is exactly real-time × scale
|
||||
// (tickOnce in lib/sim/engine.ts: advance = min(elapsed, MAX_TICK_ELAPSED_MS) × timeScale), so
|
||||
// client-side extrapolation mirrors the server's own model. Every new snapshot re-anchors to
|
||||
// server truth, bounding any drift by network latency only.
|
||||
// server truth, bounding any drift by network latency only. ISS-117 (v0.52.0): the scale is the
|
||||
// fixed WORLD_TIME_SCALE and the clock freezes while nothing is happening — mirroring the
|
||||
// server's idle-pause instead of a player-controlled pause.
|
||||
|
||||
export interface SimClockAnchor {
|
||||
// Server truth at anchor time — the snapshot's simState.clockMs.
|
||||
clockMs: number;
|
||||
// Date.now() when that snapshot was received.
|
||||
at: number;
|
||||
timeScale: number;
|
||||
paused: boolean;
|
||||
// Whether the world clock is advancing (any plane flying or in repair).
|
||||
advancing: boolean;
|
||||
}
|
||||
|
||||
// Pure core of the extrapolation — mirrors tickOnce: while running, game time advances by
|
||||
// real-time × timeScale; while paused it is frozen (advance 0). nowMs before `at` clamps to
|
||||
// Pure core of the extrapolation — mirrors tickOnce: while advancing, game time advances by
|
||||
// real-time × WORLD_TIME_SCALE; while idle it is frozen (advance 0). nowMs before `at` clamps to
|
||||
// the anchor value instead of going backwards. Unit-testable in node.
|
||||
export function advanceSimClock(anchor: SimClockAnchor, nowMs: number): number {
|
||||
if (anchor.paused) return anchor.clockMs;
|
||||
return anchor.clockMs + Math.max(0, nowMs - anchor.at) * anchor.timeScale;
|
||||
if (!anchor.advancing) return anchor.clockMs;
|
||||
return anchor.clockMs + Math.max(0, nowMs - anchor.at) * WORLD_TIME_SCALE;
|
||||
}
|
||||
|
||||
// ~10 fps display refresh — smooth for a seconds-precision wall clock without busy re-renders.
|
||||
const DISPLAY_TICK_MS = 100;
|
||||
|
||||
export interface SimClockControls {
|
||||
// EFFECTIVE controls (optimistic pending scale / current pause), not the snapshot's:
|
||||
// UI changes take effect on click/drag without waiting for a confirming snapshot.
|
||||
timeScale: number;
|
||||
paused: boolean;
|
||||
}
|
||||
|
||||
// Returns the display clockMs to render, or null until the first snapshot arrives.
|
||||
export function useSimClock(snapshot: StateSnapshot | null, controls: SimClockControls): number | null {
|
||||
export function useSimClock(snapshot: StateSnapshot | null): number | null {
|
||||
const anchorRef = useRef<SimClockAnchor | null>(null);
|
||||
const [displayMs, setDisplayMs] = useState<number | null>(null);
|
||||
|
||||
// Re-sync on every snapshot AND effective-control change: snap to server truth. The snapshot
|
||||
// object is fresh per poll (useGameState), so this runs once per poll cycle as well.
|
||||
// Re-sync on every snapshot: snap to server truth. The snapshot object is fresh per poll
|
||||
// (useGameState), so this runs once per poll cycle as well.
|
||||
useEffect(() => {
|
||||
if (!snapshot) return;
|
||||
anchorRef.current = {
|
||||
clockMs: snapshot.simState.clockMs,
|
||||
at: Date.now(),
|
||||
timeScale: controls.timeScale,
|
||||
paused: controls.paused,
|
||||
};
|
||||
// Mirror of the server's shouldAdvanceTime (lib/sim/engine.ts): frozen while no plane is
|
||||
// flying or in repair. One line on purpose — keep it in sync with that function.
|
||||
const advancing = snapshot.planes.some((p) => p.status === 'flying' || p.status === 'repairing');
|
||||
anchorRef.current = { clockMs: snapshot.simState.clockMs, at: Date.now(), advancing };
|
||||
setDisplayMs(advanceSimClock(anchorRef.current, Date.now()));
|
||||
}, [snapshot, controls.timeScale, controls.paused]);
|
||||
}, [snapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
|
||||
Reference in New Issue
Block a user