'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(null); const [displayMs, setDisplayMs] = useState(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); }