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
+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 };
}