138 lines
5.1 KiB
TypeScript
138 lines
5.1 KiB
TypeScript
'use client';
|
||
|
||
import { useState, type ChangeEvent } from 'react';
|
||
import type { ContractState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||
import { formatCash, formatFuel, formatSimClock } from '@/lib/format';
|
||
|
||
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;
|
||
}
|
||
|
||
const SCALE_OPTIONS = Array.from({ length: 60 }, (_, i) => i + 1); // 1x–60x, matching POST /api/sim's clamp range
|
||
|
||
function planeLocation(p: PlaneState, contracts: ContractState[]): string {
|
||
if (p.status === 'flying' && p.contractId) {
|
||
const c = contracts.find((c) => c.id === p.contractId);
|
||
if (c) return `${p.locationName} → ${c.destName}`;
|
||
}
|
||
return p.locationName;
|
||
}
|
||
|
||
export default function LeftPane({ state, loading, error, onRefresh }: 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.
|
||
const applySimControls = async (body: { timeScale?: number; paused?: boolean }) => {
|
||
if (busy) return;
|
||
setBusy(true);
|
||
try {
|
||
const res = await fetch('/api/sim', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
cache: 'no-store',
|
||
});
|
||
if (!res.ok) throw new Error(`POST /api/sim → HTTP ${res.status}`);
|
||
onRefresh();
|
||
} catch (err) {
|
||
console.error('failed to apply sim controls', err);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const paused = state?.simState.paused ?? false;
|
||
const timeScale = state?.simState.timeScale ?? 1;
|
||
|
||
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) => (
|
||
<li key={p.id} data-testid={`plane-${p.id}`} className="plane-row">
|
||
<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 className="plane-loc">{planeLocation(p, state.contracts)}</span>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
|
||
<section className="pane-section">
|
||
<h3 className="pane-heading">Time</h3>
|
||
<p data-testid="sim-clock" className="sim-clock">
|
||
{formatSimClock(state.simState.clockMs)}
|
||
{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>
|
||
<select
|
||
data-testid="time-scale-select"
|
||
aria-label="Time scale"
|
||
value={String(timeScale)}
|
||
disabled={busy}
|
||
onChange={(e: ChangeEvent<HTMLSelectElement>) => void applySimControls({ timeScale: Number(e.target.value) })}
|
||
>
|
||
{SCALE_OPTIONS.map((n) => (
|
||
<option key={n} value={n}>
|
||
{n}x
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</section>
|
||
|
||
{error && (
|
||
<p data-testid="left-error" className="pane-error">
|
||
{error}
|
||
</p>
|
||
)}
|
||
</>
|
||
) : null}
|
||
</aside>
|
||
);
|
||
}
|