'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; import type { StateSnapshot } from './game/state'; 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 { const res = await fetch('/api/state', { cache: 'no-store' }); 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, timeScale: snapshot.simState.timeScale, at: Date.now(), }; } return snapshot; } // The shell's single polling source (v0.9.0): one GET /api/state loop for the whole page, // so every pane renders from the same snapshot instead of running its own poll loops. 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(() => { refresh(); // fetch immediately on mount, don't wait for the first interval tick const id = setInterval(refresh, intervalMs); return () => clearInterval(id); }, [intervalMs, refresh]); return { state, loading, error, refresh, applySnapshot }; }