'use client'; import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react'; import type { PlaneState, StateSnapshot } from '@/lib/game/state'; import { PLANE_TYPES } from '@/lib/game/catalog'; import { formatCash, formatFuel, formatSimClock } from '@/lib/format'; import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale'; import type { HoverTarget } from './hoverTypes'; interface LeftPaneProps { state: StateSnapshot | null; loading: boolean; error: string | null; // The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately. onRefresh: () => void; // ISS-002 (v0.15.0): report fleet-row hovers to the shell so the map can highlight the plane. onHover: (target: HoverTarget | null) => void; } // Discrete ladder slider (v0.9.2, values reworked in v0.14.0): position t ∈ [0,1] → nearest // step of the ladder 1×/2×/5×/10×/25×/50×/100×/250×/500×/1000× — each of the 10 positions // roughly doubles the speed. const SCALE_SLIDER_STEP = 1 / (TIME_SCALES.length - 1); // one ladder step per keyboard-arrow step const SCALE_COMMIT_DEBOUNCE_MS = 250; // Flying planes show their current leg from the snapshot's dest fields (v0.12.0). function planeLocation(p: PlaneState): string { if (p.status === 'flying' && p.destName) return `${p.locationName} → ${p.destName}`; return p.locationName; } // Cargo loadout "n/capacity pkgs" — expired packages still occupy their slot. function loadoutFor(state: StateSnapshot, p: PlaneState): string { const used = state.packages.filter( (pkg) => pkg.planeId === p.id && (pkg.status === 'loaded' || pkg.status === 'expired'), ).length; const capacity = state.catalog.find((e) => e.model === p.model)?.capacity ?? PLANE_TYPES[p.model]?.capacity ?? 0; return `${used}/${capacity} pkgs`; } // ISS-001 — an optimistic slider value is cleared exactly when a rendered snapshot CONFIRMS it // (server scale === pending), never earlier: clearing at commit time would snap the pointer back // to the stale scale for one poll cycle, then jump forward again on the confirming snapshot. export function pendingScaleConfirmed(pendingScale: number | null, serverTimeScale: number): boolean { return pendingScale !== null && serverTimeScale === pendingScale; } export default function LeftPane({ state, loading, error, onRefresh, onHover }: LeftPaneProps) { const [busy, setBusy] = useState(false); // POST /api/sim, then refetch the full snapshot immediately so the pane reflects the new // controls without waiting for the next poll tick. On failure keep the stale UI — the // running poll retries on its own and surfaces the error state. A commit that lands while // another POST is in flight is held in a single slot (last intent wins) and flushed by the // in-flight call, so every user intent reaches the server (the [1,1000] clamp stays as backstop). // The gate is a REF, not React state: closures never see a stale "busy" value, which used to // let a flush re-queue instead of send and silently drop a commit. const queuedRef = useRef<{ timeScale?: number; paused?: boolean } | null>(null); const busyRef = useRef(false); type SimControlsOutcome = 'sent' | 'queued' | 'failed'; // 'sent': this body reached the server (a confirming snapshot follows via onRefresh). // 'queued': an in-flight call holds it — its flush will send it. // 'failed': the POST for this body never succeeded; callers drop optimistic values only then. const applySimControls = useCallback( async (body: { timeScale?: number; paused?: boolean }): Promise => { if (busyRef.current) { queuedRef.current = body; // last intent wins — the in-flight call's flush sends it return 'queued'; } busyRef.current = true; setBusy(true); let sentOwn = false; try { let current: { timeScale?: number; paused?: boolean } | null = body; while (current !== null) { const res = await fetch('/api/sim', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(current), cache: 'no-store', }); if (!res.ok) throw new Error(`POST /api/sim → HTTP ${res.status}`); onRefresh(); if (current === body) sentOwn = true; // object identity — queued bodies are fresh literals current = queuedRef.current; // pick up anything that arrived while we were in flight queuedRef.current = null; } } catch (err) { console.error('failed to apply sim controls', err); queuedRef.current = null; // don't replay a stale intent after a failed round-trip } finally { busyRef.current = false; setBusy(false); } return sentOwn ? 'sent' : 'failed'; }, [onRefresh], ); const paused = state?.simState.paused ?? false; const timeScale = state?.simState.timeScale ?? 1; // Slider commit (v0.9.1): the displayed value follows the drag optimistically via // pendingScale; POST /api/sim fires after a 250 ms debounce, or immediately when the // user releases the slider (browsers fire a native 'change' on release). The server's // [1,1000] clamp stays as the backstop. const [pendingScale, setPendingScaleState] = useState(null); const pendingRef = useRef(null); const commitTimerRef = useRef | null>(null); const setPending = useCallback((s: number | null) => { pendingRef.current = s; setPendingScaleState(s); }, []); const commitNow = useCallback(() => { if (commitTimerRef.current !== null) { clearTimeout(commitTimerRef.current); commitTimerRef.current = null; } const s = pendingRef.current; if (s === null) return; // ISS-001: keep the chosen value on screen until a snapshot CONFIRMS it — clearing at // commit time would snap the slider back to the stale scale for one poll cycle, then jump // forward again when the confirming snapshot lands. 'queued' also keeps it (the in-flight // call flushes it); only a genuine failure drops it so the display falls back to truth. void applySimControls({ timeScale: s }).then((outcome) => { if (outcome === 'failed') setPending(null); }); }, [applySimControls, setPending]); // Confirmation: once a rendered snapshot carries the pending scale AND no commit is in // flight, the optimistic value has been superseded by server truth — clear it (display is // unchanged either way). The busy guard matters: while a commit POST is still out, an OLD // snapshot that happens to match the pending value is coincidence, not confirmation (e.g. a // quick drag back to the previous scale must not be swallowed before its own commit fires). useEffect(() => { if (!busy && state && pendingScaleConfirmed(pendingScale, state.simState.timeScale)) setPending(null); }, [state, pendingScale, busy, setPending]); const onSliderInput = useCallback( (e: ChangeEvent) => { setPending(sliderToScale(Number(e.target.value))); if (commitTimerRef.current !== null) clearTimeout(commitTimerRef.current); commitTimerRef.current = setTimeout(commitNow, SCALE_COMMIT_DEBOUNCE_MS); }, [commitNow, setPending], ); const sliderRef = useRef(null); useEffect(() => { const el = sliderRef.current; if (!el) return; el.addEventListener('change', commitNow); // flush on pointer-up / keyboard release return () => el.removeEventListener('change', commitNow); }, [commitNow]); // Drop any pending debounce timer on unmount. useEffect( () => () => { if (commitTimerRef.current !== null) clearTimeout(commitTimerRef.current); }, [], ); const displayedScale = pendingScale ?? timeScale; return ( ); }