feat: v0.9.0 — single-screen 3-pane shell + left Assets pane (wallet/reputation/fleet/time controls) from a single /api/state poll; right pane placeholder

This commit is contained in:
2026-09-17 15:43:29 +00:00
parent aa70eb5286
commit 9502cd1975
11 changed files with 589 additions and 22 deletions
+137
View File
@@ -0,0 +1,137 @@
'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>
);
}