'use client'; 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 × 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; #135 (v0.62.0): while nothing is happening it stays FROZEN at the anchor // value — mirroring the server's freeze, so polls re-anchor without any 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 fully grounded it stays frozen at the anchor value (#135). // 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.advancing) return anchor.clockMs; // frozen while everything is grounded (#135) 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; // 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, frozen otherwise (#135). 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); }