129 lines
5.0 KiB
TypeScript
129 lines
5.0 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,
|
|
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<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(() => {
|
|
let pollId: ReturnType<typeof setInterval> | null = null;
|
|
let silenceTimer: ReturnType<typeof setTimeout> | 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 {
|
|
source = new EventSource(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); // connect check served its purpose
|
|
};
|
|
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 };
|
|
}
|