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
+11
View File
@@ -1,5 +1,16 @@
# Changelog # Changelog
## v0.9.0 — 2026-09-17 14:24
- **Single-screen 3-pane shell** (PLAN.md "Layout" increment): `app/page.tsx` is now a CSS grid (`grid-template-columns: 300px minmax(0, 1fr) 320px`) — left Assets pane, center = the existing `<Map/>` **untouched** (its `.map-wrap` fills the middle column, header included), right placeholder `data-testid="right-pane"` with a subtle "Jobs & Market" heading so the next increment has a home
- **Single polling source**: new hook `lib/useGameState.ts` polls `GET /api/state` every 1000 ms (configurable arg) and returns `{state, loading, error, refresh}` — overlap guard skips a tick while the previous request is still in flight, interval cleaned up on unmount. Called ONCE in `app/page.tsx`; the snapshot flows down as props so later panes share one poll loop instead of running their own
- **Left Assets pane** (`components/LeftPane.tsx`, `data-testid="left-pane"`): wallet cash (formatted EUR), reputation, and a fleet list — per plane: catalog display name (ATR 72-600), status badge, fuel bar + `x / y L`, location (grounded → airport name; flying → "origin → destination" from its bound contract). Time controls: pause/resume toggle + 1x–60x scale select wired to `POST /api/sim`; after each action the pane calls the hook's `refresh()` so it refetches immediately instead of waiting for the next poll tick. Sim clock shown as a UTC wall-clock (`data-testid="sim-clock"`)
- Pure display formatters in new `lib/format.ts` — `formatCash` (EUR, 2 dp), `formatFuel` ("x / y L"), `formatSimClock` (epoch ms → deterministic UTC string)
- No backend changes: all endpoints pre-existed (`GET /api/state`, `POST /api/sim`); no OpenAPI change; Map.tsx and its header untouched per the increment constraint
- Tests: unit — new `lib/format.test.ts` (currency/fuel/clock formatting incl. fractional cash + rounding) and `lib/useGameState.test.ts` (`fetchState` success shape + exact fetch args, non-2xx error with status, network failure via mocked global fetch); e2e — new `e2e/panes.spec.ts`: left pane renders wallet (cash > 0)/reputation/starter ATR 72-600 at Frankfurt + sim clock, time controls pause the sim and set scale 60 through the UI (asserted against authoritative `/api/state` AND in-pane button/select state; restored to scale 1 + unpaused in-test and again in `afterEach`, same pattern as sim.spec.ts), right pane placeholder present
- **Known interaction with v0.4.x map e2e**: the three-pane layout narrows the map column to ~660 px at the default 1280×720 viewport, which puts UKBB Boryspil (~475 px east of the [10,52] center at zoom 4) outside the initial view — `e2e/map.spec.ts` marker-click tests re-pointed from UKBB to **LFRS Nantes Atlantique**, now the most isolated large_airport marker inside the new initial viewport (nearest neighbour LFRB ~78 px away), same pattern as the v0.2.7/v0.2.8 dataset re-points
- **Pre-existing e2e race fixed** (found while landing this increment, reproduced 5/5 on a clean v0.8.0 worktree deploy — NOT caused by the layout): MapLibre's diff-based `setStyle` removes our custom airports source/layer before `ensureAirportsLayer` re-adds it on the next `styledata` event (~20–40 ms window), and the style-switcher test's post-switch wait (`queryRenderedFeatures > 0`) can be satisfied by stale pre-switch worker data, so its single marker click could land in that gap. The test now retries the click until the popup opens (marker is clickable again within milliseconds; the `toHaveCount(1)` assertion still guards against duplicate listeners)
- Verified: unit suite 143 → 150 green; `tsc --noEmit` clean; e2e 18 → 21 green against the rebuilt compose instance (marker-click, zoom clamp and style switch all pass in the narrower middle column)
## v0.8.0 — 2026-09-17 13:54 ## v0.8.0 — 2026-09-17 13:54
- **Player-actions API** (PLAN.md "Core loop" increment, server-side only — NO frontend/UI changes): the core loop now works end-to-end through five new endpoints in `lib/game/actions.ts` + `app/api/`. Every action endpoint follows one convention: tick the sim clock FIRST (so validation/mutation see current game time), validate, mutate, persist atomically under the same promise lock as ticks (`engine.runAction()` — no request can interleave between tick and mutation), then return the **FULL state snapshot** (same shape as GET /api/state) with 200 so clients never need a follow-up poll; errors are proper 4xx `{error}` bodies - **Player-actions API** (PLAN.md "Core loop" increment, server-side only — NO frontend/UI changes): the core loop now works end-to-end through five new endpoints in `lib/game/actions.ts` + `app/api/`. Every action endpoint follows one convention: tick the sim clock FIRST (so validation/mutation see current game time), validate, mutate, persist atomically under the same promise lock as ticks (`engine.runAction()` — no request can interleave between tick and mutation), then return the **FULL state snapshot** (same shape as GET /api/state) with 200 so clients never need a follow-up poll; errors are proper 4xx `{error}` bodies
- **Accept**: `POST /api/contracts/:id/accept` — open → accepted only. 404 unknown id, 409 if the contract is not 'open' (accepted/completed/expired). Single-player, so no ownership tracking beyond the status flip; dispatch binds the plane - **Accept**: `POST /api/contracts/:id/accept` — open → accepted only. 404 unknown id, 409 if the contract is not 'open' (accepted/completed/expired). Single-player, so no ownership tracking beyond the status flip; dispatch binds the plane
+168
View File
@@ -67,3 +67,171 @@ body {
font-size: 12px; font-size: 12px;
color: #cbd5e1; color: #cbd5e1;
} }
/* v0.9.0 — single-screen 3-pane shell: left Assets / center map / right Jobs & Market */
.app-shell {
display: grid;
grid-template-columns: 300px minmax(0, 1fr) 320px;
height: 100%;
}
.pane {
background: #0f172a;
color: #e2e8f0;
padding: 14px;
font-size: 13px;
overflow-y: auto;
}
.left-pane {
border-right: 1px solid rgba(148, 163, 184, 0.25);
}
.right-pane {
border-left: 1px solid rgba(148, 163, 184, 0.25);
}
.pane-title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
color: #f8fafc;
}
.pane-heading {
margin: 0 0 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
}
.pane-section {
margin-bottom: 16px;
}
.pane-muted {
color: #94a3b8;
font-size: 12px;
}
.pane-error {
color: #fca5a5;
font-size: 12px;
}
.stat-row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 2px 0;
}
.stat-row span {
color: #94a3b8;
font-size: 12px;
}
.stat-row strong {
font-size: 13px;
color: #f8fafc;
}
.fleet-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.plane-row {
background: rgba(30, 41, 59, 0.6);
border-radius: 6px;
padding: 8px;
}
.plane-main {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
}
.plane-model {
font-weight: 600;
color: #f1f5f9;
}
.plane-status {
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
background: rgba(148, 163, 184, 0.2);
color: #cbd5e1;
}
.status-flying {
background: rgba(59, 130, 246, 0.25);
color: #bfdbfe;
}
.fuel-bar {
height: 4px;
border-radius: 2px;
background: rgba(148, 163, 184, 0.25);
margin: 6px 0 4px;
overflow: hidden;
}
.fuel-bar > div {
height: 100%;
border-radius: 2px;
background: #34d399;
}
.plane-sub {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 11px;
color: #cbd5e1;
}
.plane-loc {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sim-clock {
margin: 0 0 8px;
font-size: 12px;
color: #cbd5e1;
font-variant-numeric: tabular-nums;
}
.time-controls {
display: flex;
gap: 8px;
}
.time-controls button,
.time-controls select {
font-size: 12px;
padding: 4px 10px;
border-radius: 4px;
border: 1px solid rgba(148, 163, 184, 0.4);
background: rgba(30, 41, 59, 0.9);
color: #e2e8f0;
}
.time-controls button {
cursor: pointer;
}
.time-controls select {
flex: 1;
}
+15 -1
View File
@@ -1,9 +1,23 @@
'use client'; 'use client';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import LeftPane from '@/components/LeftPane';
import { useGameState } from '@/lib/useGameState';
const Map = dynamic(() => import('@/components/Map'), { ssr: false }); const Map = dynamic(() => import('@/components/Map'), { ssr: false });
export default function Home() { export default function Home() {
return <Map />; // Single polling source for the whole shell (v0.9.0): one /api/state loop, snapshot passed
// down as props so later panes can share it without extra poll loops.
const { state, loading, error, refresh } = useGameState(1000);
return (
<div className="app-shell">
<LeftPane state={state} loading={loading} error={error} onRefresh={refresh} />
<Map />
<aside data-testid="right-pane" className="pane right-pane">
<h2 className="pane-title">Jobs &amp; Market</h2>
</aside>
</div>
);
} }
+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>
);
}
+27 -20
View File
@@ -51,15 +51,15 @@ test('clicking an airport marker opens a popup with name and ICAO', async ({ pag
{ timeout: 15_000 }, { timeout: 15_000 },
).toBeGreaterThan(0); ).toBeGreaterThan(0);
// Project UKBB (Boryspil) to screen space via the dev hook, then click it. // Project LFRS (Nantes Atlantique) to screen space via the dev hook, then click it.
// Boryspil is used because with only large_airport rows remaining it is the // Nantes is used because the v0.9.0 three-pane layout narrows the map column to ~660 px at
// most isolated marker inside the initial zoom-4 viewport: its nearest // the default 1280×720 viewport, which puts UKBB Boryspil (~475 px east of the [10,52] center)
// surviving airport is Chişinău (LUKK), ~126 px away at the initial zoom // outside the initial view; LFRS is now the most isolated large_airport marker inside it — its
// (Kyiv Zhuliany, geographically closer, was a medium_airport and got cut), // nearest neighbour is LFRB Nantes–Montoir ~78 px away at the initial zoom, so its marker cannot
// so its marker cannot be overlapped by a neighbouring circle. // be overlapped by a neighbouring circle.
const pt = await page.evaluate(() => { const pt = await page.evaluate(() => {
const map = (window as any).__flightMap; const map = (window as any).__flightMap;
const p = map.project([30.894699, 50.345001]); const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique — see the marker-click test above for why
const rect = map.getContainer().getBoundingClientRect(); const rect = map.getContainer().getBoundingClientRect();
return { x: rect.left + p.x, y: rect.top + p.y }; return { x: rect.left + p.x, y: rect.top + p.y };
}); });
@@ -67,8 +67,8 @@ test('clicking an airport marker opens a popup with name and ICAO', async ({ pag
const popup = page.getByTestId('airport-popup'); const popup = page.getByTestId('airport-popup');
await expect(popup).toBeVisible(); await expect(popup).toBeVisible();
await expect(popup).toContainText('Boryspil International Airport'); await expect(popup).toContainText('Nantes Atlantique Airport');
await expect(popup).toContainText('UKBB'); await expect(popup).toContainText('LFRS');
}); });
test('wheel zoom is clamped between min and max zoom', async ({ page }) => { test('wheel zoom is clamped between min and max zoom', async ({ page }) => {
@@ -185,14 +185,21 @@ test('style switcher defaults to Fiord, switches styles, and keeps the airports
await expect.poll(renderedCircles, { timeout: 15_000 }).toBeGreaterThan(0); await expect.poll(renderedCircles, { timeout: 15_000 }).toBeGreaterThan(0);
// Clicking a marker after the switch opens exactly one popup (the click handler is // Clicking a marker after the switch opens exactly one popup (the click handler is
// re-wired idempotently — no duplicate listeners). // re-wired idempotently — no duplicate listeners). MapLibre's diff-based setStyle removes our
const pt = await page.evaluate(() => { // custom source/layer before ensureAirportsLayer re-adds it on the next styledata event, so
const map = (window as any).__flightMap; // retry the click until the popup opens instead of asserting on a single shot (the marker is
const p = map.project([30.894699, 50.345001]); // UKBB Boryspil // clickable again within milliseconds; toHaveCount(1) below still catches duplicate listeners).
const rect = map.getContainer().getBoundingClientRect(); await expect.poll(async () => {
return { x: rect.left + p.x, y: rect.top + p.y }; const pt = await page.evaluate(() => {
}); const map = (window as any).__flightMap;
await page.mouse.click(pt.x, pt.y); const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique — see the marker-click test above for why
const rect = map.getContainer().getBoundingClientRect();
return { x: rect.left + p.x, y: rect.top + p.y };
});
await page.mouse.click(pt.x, pt.y);
return (await page.getByTestId('airport-popup').count()) > 0;
}, { timeout: 15_000 }).toBe(true);
const popups = page.getByTestId('airport-popup'); const popups = page.getByTestId('airport-popup');
await expect(popups.first()).toBeVisible(); await expect(popups.first()).toBeVisible();
await expect(popups).toHaveCount(1); await expect(popups).toHaveCount(1);
@@ -248,7 +255,7 @@ test('falls back to remote OFM styles when the local asset pack is absent', asyn
const pt = await page.evaluate(() => { const pt = await page.evaluate(() => {
const map = (window as any).__flightMap; const map = (window as any).__flightMap;
const p = map.project([30.894699, 50.345001]); // UKBB Boryspil const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique — see the marker-click test above for why
const rect = map.getContainer().getBoundingClientRect(); const rect = map.getContainer().getBoundingClientRect();
return { x: rect.left + p.x, y: rect.top + p.y }; return { x: rect.left + p.x, y: rect.top + p.y };
}); });
@@ -256,6 +263,6 @@ test('falls back to remote OFM styles when the local asset pack is absent', asyn
const popup = page.getByTestId('airport-popup'); const popup = page.getByTestId('airport-popup');
await expect(popup).toBeVisible(); await expect(popup).toBeVisible();
await expect(popup).toContainText('Boryspil International Airport'); await expect(popup).toContainText('Nantes Atlantique Airport');
await expect(popup).toContainText('UKBB'); await expect(popup).toContainText('LFRS');
}); });
+87
View File
@@ -0,0 +1,87 @@
import { test, expect, type Page } from '@playwright/test';
// v0.9.0 — single-screen 3-pane shell + left Assets pane against the live origin.
// The panes render from the shell's single /api/state poll (lib/useGameState.ts); time
// controls POST to /api/sim and refetch immediately, so UI assertions settle within a tick.
interface SimState {
clockMs: number;
timeScale: number;
paused: boolean;
}
interface StateBody {
account: { cash: number; reputation: number };
simState: SimState;
planes: Array<Record<string, unknown>>;
contracts: Array<Record<string, unknown>>;
}
async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state');
expect(res.status()).toBe(200);
return (await res.json()) as StateBody;
}
// Leave the live instance in a sane state for the other specs (scale 1, running) — same
// pattern as e2e/sim.spec.ts.
test.afterEach(async ({ page }) => {
const res = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } });
expect(res.status()).toBe(200);
});
test('left pane renders wallet, reputation and the starter fleet at Frankfurt', async ({ page }) => {
await page.goto('/');
await expect(page.getByTestId('left-pane')).toBeVisible();
// Wallet: cash > 0 (formatted EUR) — poll until the first snapshot lands.
const cashEl = page.getByTestId('wallet-cash');
await expect(cashEl).toBeVisible({ timeout: 15_000 });
const cashText = (await cashEl.textContent()) ?? '';
expect(Number(cashText.replace(/[^0-9.]/g, ''))).toBeGreaterThan(0);
// Reputation present and numeric.
await expect(page.getByTestId('reputation')).toBeVisible();
expect(Number((await page.getByTestId('reputation').textContent()) ?? 'NaN')).toBeGreaterThanOrEqual(0);
// Fleet: the starter ATR 72-600 at Frankfurt (EDDF).
const row = page.getByTestId('fleet-list').locator('.plane-row').first();
await expect(row, 'starter plane row appears once the first snapshot lands').toBeVisible({ timeout: 15_000 });
await expect(row).toContainText('ATR 72-600');
await expect(row).toContainText('Frankfurt');
// Sim clock renders (UTC wall-clock of the game time).
await expect(page.getByTestId('sim-clock')).toContainText(/UTC/);
});
test('time controls: pause freezes the sim and the scale selector sets timeScale=60', async ({ page }) => {
await page.goto('/');
const pauseBtn = page.getByTestId('pause-toggle');
await expect(pauseBtn).toBeVisible({ timeout: 15_000 });
// Pause via the UI button — assert against the authoritative /api/state…
await pauseBtn.click();
await expect.poll(async () => (await getState(page)).simState.paused, { timeout: 10_000 }).toBe(true);
// …and in the pane itself (the refetch after POST flips the button to Resume).
await expect(pauseBtn).toHaveText('Resume');
// Scale via the UI select.
const scaleSelect = page.getByTestId('time-scale-select');
await scaleSelect.selectOption('60');
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(60);
await expect(scaleSelect).toHaveValue('60');
// Restore the live instance to scale 1 + unpaused (afterEach enforces this too).
const restore = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } });
expect(restore.status()).toBe(200);
const after = await getState(page);
expect(after.simState.timeScale).toBe(1);
expect(after.simState.paused).toBe(false);
});
test('right pane placeholder exists with the Jobs & Market heading', async ({ page }) => {
await page.goto('/');
const right = page.getByTestId('right-pane');
await expect(right).toBeVisible();
await expect(right).toContainText('Jobs & Market');
});
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { formatCash, formatFuel, formatSimClock } from './format';
describe('formatCash', () => {
it('formats EUR with thousands separators and two decimals', () => {
expect(formatCash(20_000)).toBe('€20,000.00');
expect(formatCash(1_234_567.891)).toBe('€1,234,567.89');
});
it('handles zero and fractional values (refuel costs are fractional)', () => {
expect(formatCash(0)).toBe('€0.00');
expect(formatCash(30.4)).toBe('€30.40');
});
});
describe('formatFuel', () => {
it('shows current / max liters with the current value rounded', () => {
expect(formatFuel(6300, 6300)).toBe('6300 / 6300 L');
expect(formatFuel(1234.6, 6300)).toBe('1235 / 6300 L');
expect(formatFuel(0, 6300)).toBe('0 / 6300 L');
});
});
describe('formatSimClock', () => {
it('formats epoch ms as a UTC wall-clock string (deterministic across timezones)', () => {
expect(formatSimClock(Date.UTC(2026, 8, 17, 14, 5))).toBe('2026-09-17 14:05 UTC');
expect(formatSimClock(Date.UTC(2026, 0, 1, 0, 0))).toBe('2026-01-01 00:00 UTC');
});
});
+20
View File
@@ -0,0 +1,20 @@
// Pure display formatters for the client panes (v0.9.0) — no game logic, unit-testable in node.
const CURRENCY = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' });
export function formatCash(value: number): string {
return CURRENCY.format(value);
}
// "6300 / 6300 L" — current value rounded (live fuel is fractional mid-flight).
export function formatFuel(fuel: number, maxFuel: number): string {
return `${Math.round(fuel)} / ${maxFuel} L`;
}
// The sim clock is epoch ms (seeded from real time) — shown as a UTC wall-clock so the
// display is deterministic regardless of the viewer's timezone.
export function formatSimClock(clockMs: number): string {
const d = new Date(clockMs);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} UTC`;
}
+39
View File
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { fetchState } from './useGameState';
// The hook's fetch logic (lib/useGameState.ts) against a mocked global fetch — the polling
// loop itself is plain setInterval and covered by e2e (panes.spec.ts renders from it).
const SNAPSHOT = {
account: { cash: 20_000, reputation: 10 },
simState: { clockMs: 1_750_000_000_000, timeScale: 1, paused: false },
planes: [],
contracts: [],
};
afterEach(() => {
vi.unstubAllGlobals();
});
describe('fetchState', () => {
it('GETs /api/state with no-store and returns the parsed snapshot on success', async () => {
const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => SNAPSHOT }));
vi.stubGlobal('fetch', fetchMock);
await expect(fetchState()).resolves.toEqual(SNAPSHOT);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith('/api/state', { cache: 'no-store' });
});
it('throws with the HTTP status on a non-2xx response', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500, json: async () => ({}) })));
await expect(fetchState()).rejects.toThrow('GET /api/state → HTTP 500');
});
it('throws on a network failure (no response at all)', async () => {
vi.stubGlobal('fetch', vi.fn(async () => {
throw new Error('fetch failed');
}));
await expect(fetchState()).rejects.toThrow('fetch failed');
});
});
+55
View File
@@ -0,0 +1,55 @@
'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;
}
// 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}`);
return (await res.json()) as StateSnapshot;
}
// The shell's single polling source (v0.9.0): one GET /api/state loop for the whole page,
// so every pane renders from the same snapshot instead of running its own poll loops.
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;
});
}, []);
useEffect(() => {
refresh(); // fetch immediately on mount, don't wait for the first interval tick
const id = setInterval(refresh, intervalMs);
return () => clearInterval(id);
}, [intervalMs, refresh]);
return { state, loading, error, refresh };
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "flight-ops", "name": "flight-ops",
"version": "0.8.0", "version": "0.9.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000", "dev": "next dev -p 3000",