'use client'; import { useEffect, useRef, useState } from 'react'; import type { StateSnapshot } from './game/state'; import { IDLE_WORLD_TIME_SCALE, 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 × 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. ISS-117 (v0.52.0): the scale is the // fixed WORLD_TIME_SCALE; ISS-128 (v0.56.0): while nothing is happening it slows to // IDLE_WORLD_TIME_SCALE (real time) instead of freezing — mirroring the server's idle slowdown, // so polls re-anchor without a visible jump. export interface SimClockAnchor { // Server truth at anchor time — the snapshot's simState.clockMs. clockMs: number; // Date.now() when that snapshot was received. at: number; // Whether the world clock is advancing (any plane flying or in repair). advancing: boolean; } // Pure core of the extrapolation — mirrors tickOnce: while advancing, game time advances by // real-time × WORLD_TIME_SCALE; while idle it slows to real-time × IDLE_WORLD_TIME_SCALE (ISS-128). // nowMs before `at` clamps to the anchor value instead of going backwards. Unit-testable in node. export function advanceSimClock(anchor: SimClockAnchor, nowMs: number): number { const scale = anchor.advancing ? WORLD_TIME_SCALE : IDLE_WORLD_TIME_SCALE; return anchor.clockMs + Math.max(0, nowMs - anchor.at) * scale; } // ~10 fps display refresh — smooth for a seconds-precision wall clock without busy re-renders. const DISPLAY_TICK_MS = 100; // Returns the display clockMs to render, or null until the first snapshot arrives. export function useSimClock(snapshot: StateSnapshot | null): number | null { const anchorRef = useRef(null); const [displayMs, setDisplayMs] = useState(null); // 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; // Mirror of the server's shouldAdvanceTime (lib/sim/engine.ts): full rate only while a plane // is flying or in repair, idle real-time otherwise. One line on purpose — keep it in sync. 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]); 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); }