'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; import type { StateSnapshot } from './game/state'; import { sessionHeaders, storedSession } from './authClient'; export interface GameStateResult { state: StateSnapshot | null; // True until the first successful fetch (stale-while-error afterwards). loading: boolean; error: string | null; // Trigger an immediate refetch (used after POST /api/sim so the UI updates without waiting for the next tick). refresh: () => void; // Push a snapshot straight into the shared state (v0.10.0): every action endpoint returns // the FULL post-action snapshot, so panes can render its result on click instead of waiting // for the next poll tick. The polling loop keeps running and simply overwrites it with the // server's truth on its next cycle (which also re-clears any transient pane error). applySnapshot: (snapshot: StateSnapshot) => void; } // Single fetch of the full game-state snapshot. Exported separately from the hook so it can be unit-tested with a mocked fetch. export async function fetchState(): Promise { // v1.1.0 (ISS-107): /api/state is session-guarded — the stored id rides along as X-Flight-Session. const res = await fetch('/api/state', { cache: 'no-store', headers: sessionHeaders() }); if (!res.ok) throw new Error(`GET /api/state → HTTP ${res.status}`); const snapshot = (await res.json()) as StateSnapshot; // Dev/e2e hook (v0.15.0): the last successfully polled snapshot, so tests can compare what // the BROWSER received against a fresh node-side fetch. Client-only, harmless in prod. if (typeof window !== 'undefined') { (window as unknown as Record).__flightLastSnapshot = { planeIds: snapshot.planes.map((p) => p.id), cash: snapshot.account.cash, clockMs: snapshot.simState.clockMs, at: Date.now(), }; } return snapshot; } // The shell's single state source (v0.9.0): one snapshot feed for the whole page, so every pane // renders from the same snapshot instead of running its own poll loops. ISS-129 (v0.57.0): primary // transport is SSE — /api/events pushes a full snapshot after every sim tick; polling stays as an // automatic fallback (no event within 5 s of connect, or on stream error) and stops again when fresh // events flow. EventSource reconnects natively, so there is no custom reconnect logic here. const SSE_URL = '/api/events'; const SSE_SILENCE_MS = 5_000; export function useGameState(intervalMs = 1000): GameStateResult { const [state, setState] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Overlap guard: if the previous request is still in flight (slow server), skip this tick. const inFlightRef = useRef(false); const refresh = useCallback(() => { if (inFlightRef.current) return; inFlightRef.current = true; fetchState() .then((snapshot) => { setState(snapshot); setError(null); setLoading(false); }) .catch((err: unknown) => { setError(err instanceof Error ? err.message : String(err)); }) .finally(() => { inFlightRef.current = false; }); }, []); // v0.10.0 — instant action feedback: panes push the full snapshot returned by an // action endpoint into the shared state here (see GameStateResult.applySnapshot). const applySnapshot = useCallback((snapshot: StateSnapshot) => { setState(snapshot); setError(null); setLoading(false); }, []); useEffect(() => { let pollId: ReturnType | null = null; let silenceTimer: ReturnType | null = null; let closed = false; const stopPolling = () => { if (pollId !== null) clearInterval(pollId); pollId = null; }; // Fallback loop: immediate catch-up fetch, then the old interval. const startPolling = () => { if (closed || pollId !== null) return; refresh(); pollId = setInterval(refresh, intervalMs); }; let source: EventSource | null = null; try { // v1.1.0 (ISS-107): EventSource cannot set custom headers, so the session id rides along as a // query param — /api/events accepts ?session= exactly for this (requireAuthSse). const sid = storedSession(); source = new EventSource(sid ? `${SSE_URL}?session=${encodeURIComponent(sid)}` : SSE_URL); } catch { source = null; // no EventSource support — pure polling below } if (source !== null) { source.onmessage = (event) => { try { applySnapshot(JSON.parse(event.data)); } catch { // malformed event — the next one overwrites it anyway } stopPolling(); // fresh push flow → polling no longer needed if (silenceTimer !== null) clearTimeout(silenceTimer); silenceTimer = setTimeout(startPolling, SSE_SILENCE_MS); // re-arm on EVERY message: a stream that goes silent after the first event (half-open connection — no onerror fires) must fall back to polling again, or state freezes until reload }; source.onerror = () => startPolling(); // stream trouble → poll until pushes resume silenceTimer = setTimeout(startPolling, SSE_SILENCE_MS); // connected but silent for 5 s → poll } else { startPolling(); } refresh(); // mount: same immediate fetch as before (fast first paint + __flightLastSnapshot) return () => { closed = true; source?.close(); stopPolling(); if (silenceTimer !== null) clearTimeout(silenceTimer); }; }, [intervalMs, refresh, applySnapshot]); return { state, loading, error, refresh, applySnapshot }; }