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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user