309 lines
14 KiB
TypeScript
309 lines
14 KiB
TypeScript
'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 { useSimClock } from '@/lib/useSimClock';
|
||
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;
|
||
// ISS-008 (v0.17.0): report a failed sim-control POST to the shell as an error toast.
|
||
onError?: (message: string) => 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, onError }: 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<SimControlsOutcome> => {
|
||
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);
|
||
// ISS-008: surface the failure as an error toast too — previously console-only.
|
||
onError?.(`Failed to update time controls (${err instanceof Error ? err.message : String(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, onError],
|
||
);
|
||
|
||
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<number | null>(null);
|
||
const pendingRef = useRef<number | null>(null);
|
||
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | 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<HTMLInputElement>) => {
|
||
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<HTMLInputElement>(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);
|
||
},
|
||
[],
|
||
);
|
||
|
||
// ISS-008: browser-notification opt-in. Starts null (SSR-safe — the real permission is read
|
||
// in an effect after hydration, so server and client render identically) and follows the
|
||
// result of Notification.requestPermission(). 'unsupported' = no Notification API at all.
|
||
const [notifPermission, setNotifPermission] = useState<string | null>(null);
|
||
useEffect(() => {
|
||
if ('Notification' in window) setNotifPermission(Notification.permission);
|
||
else setNotifPermission('unsupported');
|
||
}, []);
|
||
|
||
// null (pre-effect) and 'default' both read as the opt-in prompt.
|
||
const notifLabel: string =
|
||
notifPermission === 'granted'
|
||
? 'Notifications enabled'
|
||
: notifPermission === 'denied'
|
||
? 'Notifications blocked'
|
||
: notifPermission === 'unsupported'
|
||
? 'Notifications unavailable'
|
||
: 'Enable notifications';
|
||
|
||
const displayedScale = pendingScale ?? timeScale;
|
||
|
||
// ISS-007: the clock ticks client-side between polls, re-syncing to server truth on every
|
||
// snapshot. Fed with EFFECTIVE controls so a scale drag / pause click takes effect immediately,
|
||
// not when the confirming snapshot lands. Only the sim-clock paragraph uses this — deadline and
|
||
// penalty previews stay snapshot-based (deterministic against the server clock).
|
||
const simClockMs = useSimClock(state, { timeScale: displayedScale, paused });
|
||
|
||
return (
|
||
<aside data-testid="left-pane" className="pane left-pane">
|
||
<h2 className="pane-title">Assets</h2>
|
||
{loading && !state ? (
|
||
<p data-testid="left-loading" className="pane-muted">Loading…</p>
|
||
) : state ? (
|
||
<>
|
||
<section className="pane-section">
|
||
<div className="stat-row">
|
||
<span>Cash</span>
|
||
<strong data-testid="wallet-cash">{formatCash(state.account.cash)}</strong>
|
||
</div>
|
||
<div className="stat-row">
|
||
<span>Reputation</span>
|
||
<strong data-testid="reputation">{state.account.reputation}</strong>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="pane-section">
|
||
<h3 className="pane-heading">Fleet</h3>
|
||
{state.planes.length === 0 ? (
|
||
<p className="pane-muted">No planes.</p>
|
||
) : (
|
||
<ul data-testid="fleet-list" className="fleet-list">
|
||
{state.planes.map((p) => (
|
||
// ISS-002: hovering a fleet row highlights the plane on the map.
|
||
<li
|
||
key={p.id}
|
||
data-testid={`plane-${p.id}`}
|
||
className="plane-row"
|
||
onMouseEnter={() => onHover({ kind: 'plane', id: p.id })}
|
||
onMouseLeave={() => onHover(null)}
|
||
>
|
||
<div className="plane-main">
|
||
<span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span>
|
||
<span className={`plane-status status-${p.status}`}>{p.status}</span>
|
||
</div>
|
||
<div className="fuel-bar" aria-hidden="true">
|
||
<div style={{ width: `${Math.max(0, Math.min(100, (p.fuel / p.maxFuel) * 100))}%` }} />
|
||
</div>
|
||
<div className="plane-sub">
|
||
<span data-testid={`plane-fuel-${p.id}`}>{formatFuel(p.fuel, p.maxFuel)}</span>
|
||
<span data-testid={`plane-loadout-${p.id}`}>{loadoutFor(state, p)}</span>
|
||
<span className="plane-loc">{planeLocation(p)}</span>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
|
||
<section className="pane-section">
|
||
<h3 className="pane-heading">Time</h3>
|
||
<p data-testid="sim-clock" className="sim-clock">
|
||
{simClockMs !== null && formatSimClock(simClockMs)}
|
||
{paused ? ' (paused)' : ''}
|
||
</p>
|
||
<div className="time-controls">
|
||
<button
|
||
type="button"
|
||
data-testid="pause-toggle"
|
||
disabled={busy}
|
||
onClick={() => void applySimControls({ paused: !paused })}
|
||
>
|
||
{paused ? 'Resume' : 'Pause'}
|
||
</button>
|
||
<input
|
||
ref={sliderRef}
|
||
type="range"
|
||
data-testid="time-scale-slider"
|
||
aria-label="Time scale"
|
||
min={0}
|
||
max={1}
|
||
step={SCALE_SLIDER_STEP}
|
||
value={scaleToSlider(displayedScale)}
|
||
onChange={onSliderInput}
|
||
/>
|
||
<span data-testid="time-scale-value">{displayedScale}×</span>
|
||
</div>
|
||
{/* ISS-008: explicit opt-in for browser notifications (fired when the window is hidden). */}
|
||
<button
|
||
type="button"
|
||
data-testid="enable-notifications"
|
||
className="notif-toggle"
|
||
disabled={notifPermission === 'unsupported'}
|
||
title={notifPermission === 'unsupported' ? 'This browser does not support notifications' : undefined}
|
||
onClick={() => {
|
||
// A rejection (rare — e.g. some headless setups) reads as "blocked".
|
||
if ('Notification' in window) {
|
||
void Notification.requestPermission()
|
||
.then(setNotifPermission)
|
||
.catch(() => setNotifPermission('denied'));
|
||
}
|
||
}}
|
||
>
|
||
{notifLabel}
|
||
</button>
|
||
</section>
|
||
|
||
{error && (
|
||
<p data-testid="left-error" className="pane-error">
|
||
{error}
|
||
</p>
|
||
)}
|
||
</>
|
||
) : null}
|
||
</aside>
|
||
);
|
||
}
|