Files
Flight-Ops/lib/useGameState.ts
2026-09-19 10:35:36 +00:00

80 lines
3.2 KiB
TypeScript

'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<StateSnapshot> {
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<string, unknown>).__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<StateSnapshot | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 };
}