feat: v0.16.1 — ISS-007 smooth client-side sim clock with per-snapshot re-sync
CI / build + unit + e2e (pull_request) Failing after 6m42s

This commit is contained in:
2026-09-19 14:00:47 +00:00
parent 05d956f71b
commit 7d2f8f07c8
6 changed files with 169 additions and 2 deletions
+68
View File
@@ -0,0 +1,68 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import type { StateSnapshot } from './game/state';
// 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
// (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.
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;
}
// 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
// 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;
}
// ~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 {
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.
useEffect(() => {
if (!snapshot) return;
anchorRef.current = {
clockMs: snapshot.simState.clockMs,
at: Date.now(),
timeScale: controls.timeScale,
paused: controls.paused,
};
setDisplayMs(advanceSimClock(anchorRef.current, Date.now()));
}, [snapshot, controls.timeScale, controls.paused]);
useEffect(() => {
const id = setInterval(() => {
const anchor = anchorRef.current;
if (anchor) setDisplayMs(advanceSimClock(anchor, Date.now()));
}, DISPLAY_TICK_MS);
return () => clearInterval(id);
}, []);
// First paint after a snapshot arrives happens before the re-sync effect runs — show the
// snapshot value directly so the clock never blanks for a frame.
return displayMs ?? (snapshot ? snapshot.simState.clockMs : null);
}