feat: v0.10.0 — right Jobs & Market pane (contracts accept/dispatch, market buy/sell/refuel with live previews); catalog + economy constants exposed in /api/state; shared applySnapshot for instant action feedback

This commit is contained in:
2026-09-17 19:18:42 +00:00
parent 03653fc50e
commit cf811dcf4d
15 changed files with 894 additions and 14 deletions
+9
View File
@@ -1,5 +1,14 @@
# Changelog
## v0.10.0 — 2026-09-17 19:16
- **Right Jobs & Market pane** (`components/RightPane.tsx`, `data-testid="right-pane"`) replaces the v0.9.0 placeholder in `app/page.tsx` (PLAN.md "Right pane"): open contracts render as rows — origin → destination, reward (€), cargo type × count, deadline relative to the sim clock ("in 14h 30m") — each with an Accept button (`accept-<id>`); accepted contracts form a separate group where grounded ones get dispatch controls (plane picker enabling only planes that are 'available' AND at the contract origin; others disabled with the reason, e.g. "in flight" / "at EDDF") and in-flight ones show an "in flight" badge instead
- **Market section**: buy plane (model picker from the snapshot catalog + price preview), sell plane (grounded-plane picker + payout preview = sellFraction × price), refuel (partial-tank grounded-plane picker + cost preview = (maxFuel − fuel) × fuelPricePerL) — all previews computed client-side from the new snapshot fields, no extra endpoints
- **Instant action feedback**: every action POSTs to its v0.8.0 endpoint and pushes the returned full snapshot into the shared shell state via a new `useGameState.applySnapshot(s)` (polling continues unchanged and simply overwrites it next tick), so the UI updates on click; 4xx `{error}` bodies show as a small inline pane message that auto-clears on the next successful action or poll; all action buttons are disabled while a request is in flight
- **`/api/state` snapshot gains market reference data** — one source of truth stays `lib/game/catalog.ts`: new `catalogEntries()` shapes PLANE_TYPES into `catalog: [{model, label, price, maxFuel, burnPerKm, speedKmh}]`, and the economy constants are exposed as `fuelPricePerL` + `sellFraction`; OpenAPI StateSnapshot/CatalogEntry schemas updated with tests
- **Pure display helpers** in `lib/format.ts`: `formatDuration(ms)` ("2d 3h" / "14h 30m" / "45m", rounded to the minute, clamped at 0) and `formatDeadlineRelative(deadlineMs, nowMs)` ("in …"; "now" under a minute; "overdue" at/past the deadline)
- Tests: unit — new duration/deadline tests in `lib/format.test.ts`; `app/api/state/route.test.ts` asserts the catalog + economy fields match `lib/game/catalog.ts` values exactly; snapshot-key assertions updated in the state and accept route tests (action endpoints return the full snapshot); OpenAPI spec tests cover the new schemas. e2e — new `e2e/right-pane.spec.ts`: open contracts render with names/reward/Accept and a UI Accept click moves a hook-created contract into the accepted group (asserted in UI AND /api/state); the full dispatch loop through the UI on a short EDDF route (accept → plane picker → dispatch → "in flight" indicator in UI + state, arrival at scale 60) followed by market refuel deducting exactly (maxFuel − fuel) × fuelPricePerL with the tank filled; buy/sell buttons render with price/payout previews matching the catalog exposed in /api/state. Live instance restored to scale 1 + unpaused
- Verified: unit suite 161 → 166 green; `tsc --noEmit` clean; e2e 21 → 24 green against the rebuilt compose instance (all pre-existing specs — map, sim, state-restart, actions full-loop, panes — still pass)
## v0.9.2 — 2026-09-17 18:10
- **Power-of-two time scale** (left-pane UI refinement + API range bump): the slider now steps through the discrete ladder **1× → 2× → 4× → 8× → 16× → 32× → 64×** — each of the 7 positions doubles the speed instead of the v0.9.1 continuous log mapping `round(60^t)`. `lib/timeScale.ts` rewritten: `TIME_SCALES = [1,2,4,8,16,32,64]`, `sliderToScale(t)` maps to the nearest ladder step (exact at endpoints 0→1×, 1→64×), `scaleToSlider(s)` places a scale on its nearest position by log₂ distance; off-ladder values (e.g. a legacy 60) snap to the nearest power of two
- **Server clamp raised [1,60] → [1,64]** so the top of the ladder actually works end-to-end: `MAX_TIME_SCALE = 64` in `lib/sim/engine.ts`, error message + comments in `app/api/sim/route.ts`, OpenAPI `SimState.timeScale`/`SimControlsRequest.timeScale` maximums updated (spec lives in code per AGENTS.md)
+10 -1
View File
@@ -39,7 +39,16 @@ describe('POST /api/contracts/:id/accept', () => {
planes: Array<Record<string, unknown>>;
contracts: Array<{ id: string; status: string }>;
};
expect(Object.keys(body).sort()).toEqual(['account', 'contracts', 'planes', 'simState']);
// v0.10.0: the snapshot also carries the static market reference data.
expect(Object.keys(body).sort()).toEqual([
'account',
'catalog',
'contracts',
'fuelPricePerL',
'planes',
'sellFraction',
'simState',
]);
expect(body.contracts.find((c) => c.id === open.id)?.status).toBe('accepted');
// Persisted, not just in the response.
+34 -1
View File
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
import { GET } from './route';
let dir: string;
@@ -26,7 +27,15 @@ describe('GET /api/state', () => {
expect(res.headers.get('content-type')).toContain('application/json');
const body = (await res.json()) as Record<string, unknown>;
expect(Object.keys(body).sort()).toEqual(['account', 'contracts', 'planes', 'simState']);
expect(Object.keys(body).sort()).toEqual([
'account',
'catalog',
'contracts',
'fuelPricePerL',
'planes',
'sellFraction',
'simState',
]);
const account = body.account as { cash: number; reputation: number };
expect(typeof account.cash).toBe('number');
@@ -56,4 +65,28 @@ describe('GET /api/state', () => {
const b = ((await GET()).json()) as Promise<{ account: { cash: number } }>;
expect((await b).account.cash).toBe((await a).account.cash);
});
it('exposes the catalog + economy constants matching lib/game/catalog.ts (v0.10.0)', async () => {
const body = ((await GET()).json()) as Promise<{
catalog: Array<Record<string, unknown>>;
fuelPricePerL: number;
sellFraction: number;
}>;
const snap = await body;
// One entry per PLANE_TYPES model, values copied straight from the catalog.
expect(snap.catalog.map((e) => e.model).sort()).toEqual(Object.keys(PLANE_TYPES).sort());
for (const [model, type] of Object.entries(PLANE_TYPES)) {
const entry = snap.catalog.find((e) => e.model === model)!;
expect(entry.label).toBe(type.name);
expect(entry.price).toBe(type.price);
expect(entry.maxFuel).toBe(type.maxFuel);
expect(entry.burnPerKm).toBe(type.burnPerKm);
expect(entry.speedKmh).toBe(type.speedKmh);
}
// Economy constants — the single source of truth stays lib/game/catalog.ts.
expect(snap.fuelPricePerL).toBe(FUEL_PRICE_PER_L);
expect(snap.sellFraction).toBe(SELL_FRACTION);
});
});
+90
View File
@@ -240,3 +240,93 @@ body {
text-align: right;
font-variant-numeric: tabular-nums;
}
/* v0.10.0 — right Jobs & Market pane (contracts + market) */
.contract-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.contract-row {
background: rgba(30, 41, 59, 0.6);
border-radius: 6px;
padding: 8px;
}
.contract-main {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.contract-route {
font-weight: 600;
color: #f1f5f9;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contract-sub {
display: flex;
justify-content: space-between;
gap: 8px;
margin-top: 4px;
font-size: 11px;
color: #cbd5e1;
font-variant-numeric: tabular-nums;
}
.pane-btn {
flex-shrink: 0;
font-size: 12px;
padding: 3px 10px;
border-radius: 4px;
border: 1px solid rgba(148, 163, 184, 0.4);
background: rgba(30, 41, 59, 0.9);
color: #e2e8f0;
cursor: pointer;
}
.pane-btn:disabled {
opacity: 0.5;
cursor: default;
}
.dispatch-controls select,
.market-row select {
font-size: 12px;
padding: 3px 6px;
border-radius: 4px;
border: 1px solid rgba(148, 163, 184, 0.4);
background: #1e293b;
color: #e2e8f0;
max-width: 100%;
}
.dispatch-controls {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
}
.market-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.market-preview {
font-size: 12px;
color: #f8fafc;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
+5 -5
View File
@@ -2,22 +2,22 @@
import dynamic from 'next/dynamic';
import LeftPane from '@/components/LeftPane';
import RightPane from '@/components/RightPane';
import { useGameState } from '@/lib/useGameState';
const Map = dynamic(() => import('@/components/Map'), { ssr: false });
export default function Home() {
// 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);
// down as props so later panes can share it without extra poll loops. applySnapshot (v0.10.0)
// lets a pane push an action endpoint's full response snapshot in immediately.
const { state, loading, error, refresh, applySnapshot } = 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>
<RightPane state={state} loading={loading} applySnapshot={applySnapshot} />
</div>
);
}
+326
View File
@@ -0,0 +1,326 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import type { ContractState, PlaneState, StateSnapshot } from '@/lib/game/state';
import { PLANE_TYPES } from '@/lib/game/catalog';
import { formatCash, formatDeadlineRelative } from '@/lib/format';
interface RightPaneProps {
state: StateSnapshot | null;
loading: boolean;
// Push the full snapshot returned by an action endpoint into the shared shell state (v0.10.0),
// so the pane updates on click without waiting for the next poll tick.
applySnapshot: (snapshot: StateSnapshot) => void;
}
const planeName = (p: PlaneState): string => PLANE_TYPES[p.model]?.name ?? p.model;
// Why a plane cannot be dispatched on `contract` — null when it can (grounded at the origin).
function dispatchReason(p: PlaneState, contract: ContractState): string | null {
if (p.status !== 'available') return 'in flight';
if (p.locationIcao !== contract.originIcao) return `at ${p.locationIcao}`;
return null;
}
// Dispatch controls for one accepted, not-yet-dispatched contract: a plane picker where only
// grounded planes at the origin are enabled (others disabled with the reason), plus a dispatch
// button bound to the selection. The effective selection is the user's pick while it stays
// eligible, else auto-tracks the first eligible plane so a fresh contract needs one click.
function DispatchControls({
contract,
planes,
busy,
onDispatch,
}: {
contract: ContractState;
planes: PlaneState[];
busy: boolean;
onDispatch: (planeId: string) => void;
}) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const effective =
planes.find((p) => p.id === selectedId && dispatchReason(p, contract) === null) ??
planes.find((p) => dispatchReason(p, contract) === null) ??
null;
return (
<div className="dispatch-controls">
<select
data-testid={`dispatch-plane-${contract.id}`}
aria-label={`Dispatch plane for ${contract.originName} to ${contract.destName}`}
value={effective?.id ?? ''}
onChange={(e) => setSelectedId(e.target.value || null)}
>
{planes.map((p) => {
const reason = dispatchReason(p, contract);
return (
<option key={p.id} value={p.id} disabled={reason !== null}>
{planeName(p)}
{reason ? ` — ${reason}` : ''}
</option>
);
})}
</select>
<button
type="button"
data-testid={`dispatch-${contract.id}`}
className="pane-btn"
disabled={busy || effective === null}
onClick={() => {
if (effective) onDispatch(effective.id);
}}
>
Dispatch
</button>
</div>
);
}
export default function RightPane({ state, loading, applySnapshot }: RightPaneProps) {
const [busy, setBusy] = useState(false);
// Inline error from a failed action — auto-clears on the next successful poll (the shell's
// snapshot prop changes every tick) or the next successful action.
const [actionError, setActionError] = useState<string | null>(null);
useEffect(() => {
if (state !== null) setActionError(null);
}, [state]);
// Market picker selections — same "effective selection" pattern as DispatchControls: the
// user's pick while it stays valid, else a sensible default (first catalog entry / first plane).
const [buyModelId, setBuyModelId] = useState<string | null>(null);
const [sellPlaneId, setSellPlaneId] = useState<string | null>(null);
const [refuelPlaneId, setRefuelPlaneId] = useState<string | null>(null);
// POST an action endpoint. Every success returns the FULL post-action snapshot (v0.8.0
// convention) — pushed into the shared state immediately; a 4xx {error} body becomes the
// inline pane error and the stale UI stays until the next poll. Buttons are disabled while
// a request is in flight, so at most one action runs at a time.
const runAction = useCallback(
async (path: string, body?: unknown) => {
if (busy) return;
setBusy(true);
try {
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
cache: 'no-store',
});
const data = (await res.json()) as StateSnapshot & { error?: string };
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
setActionError(null);
applySnapshot(data);
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
},
[applySnapshot, busy],
);
if (loading && !state) {
return (
<aside data-testid="right-pane" className="pane right-pane">
<h2 className="pane-title">Jobs &amp; Market</h2>
<p data-testid="right-loading" className="pane-muted">Loading…</p>
</aside>
);
}
if (!state) return null;
const clockMs = state.simState.clockMs;
const open = state.contracts.filter((c) => c.status === 'open');
const accepted = state.contracts.filter((c) => c.status === 'accepted');
const catalog = state.catalog;
const buyEntry = catalog.find((e) => e.model === buyModelId) ?? catalog[0] ?? null;
const grounded = state.planes.filter((p) => p.status === 'available');
const sellPlane = state.planes.find((p) => p.id === sellPlaneId && p.status === 'available') ?? grounded[0] ?? null;
const refuelable = grounded.filter((p) => p.fuel < p.maxFuel);
const refuelPlane =
state.planes.find((p) => p.id === refuelPlaneId && p.status === 'available' && p.fuel < p.maxFuel) ??
refuelable[0] ??
null;
// Previews computed client-side from the snapshot's catalog + economy constants (v0.10.0).
const sellEntry = sellPlane ? catalog.find((e) => e.model === sellPlane.model) : undefined;
const sellPayout = sellEntry ? Math.round(sellEntry.price * state.sellFraction * 100) / 100 : null;
const refuelCost = refuelPlane ? (refuelPlane.maxFuel - refuelPlane.fuel) * state.fuelPricePerL : null;
return (
<aside data-testid="right-pane" className="pane right-pane">
<h2 className="pane-title">Jobs &amp; Market</h2>
<section className="pane-section">
<h3 className="pane-heading">Open contracts</h3>
{open.length === 0 ? (
<p className="pane-muted">No open contracts.</p>
) : (
<ul data-testid="open-contracts" className="contract-list">
{open.map((c) => (
<li key={c.id} data-testid={`open-contract-${c.id}`} className="contract-row">
<div className="contract-main">
<span className="contract-route">{c.originName} → {c.destName}</span>
<button
type="button"
data-testid={`accept-${c.id}`}
className="pane-btn"
disabled={busy}
onClick={() => void runAction(`/api/contracts/${c.id}/accept`)}
>
Accept
</button>
</div>
<div className="contract-sub">
<span data-testid={`open-contract-reward-${c.id}`}>{formatCash(c.reward)}</span>
<span>{c.cargoType} × {c.cargoCount}</span>
<span data-testid={`open-contract-deadline-${c.id}`}>
{formatDeadlineRelative(c.deadlineSimTime, clockMs)}
</span>
</div>
</li>
))}
</ul>
)}
</section>
<section className="pane-section">
<h3 className="pane-heading">Accepted</h3>
{accepted.length === 0 ? (
<p className="pane-muted">Nothing accepted yet.</p>
) : (
<ul data-testid="accepted-contracts" className="contract-list">
{accepted.map((c) => (
<li key={c.id} data-testid={`accepted-contract-${c.id}`} className="contract-row">
<div className="contract-main">
<span className="contract-route">{c.originName} → {c.destName}</span>
{c.planeId ? (
<span data-testid={`contract-inflight-${c.id}`} className="plane-status status-flying">
in flight
</span>
) : null}
</div>
<div className="contract-sub">
<span>{formatCash(c.reward)}</span>
<span>{c.cargoType} × {c.cargoCount}</span>
<span>{formatDeadlineRelative(c.deadlineSimTime, clockMs)}</span>
</div>
{!c.planeId && (
<DispatchControls
contract={c}
planes={state.planes}
busy={busy}
onDispatch={(planeId) => void runAction(`/api/planes/${planeId}/dispatch`, { contractId: c.id })}
/>
)}
</li>
))}
</ul>
)}
</section>
<section className="pane-section">
<h3 className="pane-heading">Market</h3>
<div className="market-row">
<select
data-testid="market-buy-model"
aria-label="Plane to buy"
value={buyEntry?.model ?? ''}
onChange={(e) => setBuyModelId(e.target.value || null)}
>
{catalog.map((e) => (
<option key={e.model} value={e.model}>
{e.label}
</option>
))}
</select>
<span data-testid="market-buy-price" className="market-preview">
{buyEntry ? formatCash(buyEntry.price) : ''}
</span>
<button
type="button"
data-testid="market-buy"
className="pane-btn"
disabled={busy || buyEntry === null}
onClick={() => {
if (buyEntry) void runAction('/api/market/buy-plane', { model: buyEntry.model });
}}
>
Buy
</button>
</div>
<div className="market-row">
<select
data-testid="market-sell-plane"
aria-label="Plane to sell"
value={sellPlane?.id ?? ''}
onChange={(e) => setSellPlaneId(e.target.value || null)}
>
{grounded.map((p) => (
<option key={p.id} value={p.id}>
{planeName(p)} — {p.locationIcao}
</option>
))}
</select>
<span data-testid="market-sell-payout" className="market-preview">
{sellPayout !== null ? formatCash(sellPayout) : ''}
</span>
<button
type="button"
data-testid="market-sell"
className="pane-btn"
disabled={busy || sellPlane === null}
onClick={() => {
if (sellPlane) void runAction('/api/market/sell-plane', { planeId: sellPlane.id });
}}
>
Sell
</button>
</div>
<div className="market-row">
{refuelable.length === 0 ? (
<span data-testid="market-refuel-none" className="pane-muted">
All tanks full.
</span>
) : (
<>
<select
data-testid="market-refuel-plane"
aria-label="Plane to refuel"
value={refuelPlane?.id ?? ''}
onChange={(e) => setRefuelPlaneId(e.target.value || null)}
>
{refuelable.map((p) => (
<option key={p.id} value={p.id}>
{planeName(p)} — {Math.round(p.fuel)} / {p.maxFuel} L
</option>
))}
</select>
<span data-testid="market-refuel-cost" className="market-preview">
{refuelCost !== null ? formatCash(refuelCost) : ''}
</span>
</>
)}
<button
type="button"
data-testid="market-refuel"
className="pane-btn"
disabled={busy || refuelPlane === null}
onClick={() => {
if (refuelPlane) void runAction(`/api/planes/${refuelPlane.id}/refuel`);
}}
>
Refuel
</button>
</div>
</section>
{actionError && <p data-testid="right-error" className="pane-error">{actionError}</p>}
</aside>
);
}
+263
View File
@@ -0,0 +1,263 @@
import { test, expect, type Page } from '@playwright/test';
// v0.10.0 — right Jobs & Market pane against the live origin: open contracts render with
// names/reward/deadline and Accept (UI click) moves one into the accepted group; a hook-created
// EDDF contract is accepted + dispatched through the UI picker, plane-starter shows "in flight"
// in the UI AND /api/state; market refuel through the pane deducts exactly
// (maxFuel − fuel) × fuelPricePerL and fills the tank; buy/sell previews match the catalog +
// economy constants exposed in /api/state. Contracts are created via POST /api/test/create-contract
// (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts.
const SCALE = 60;
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
interface PlaneState {
id: string;
model: string;
status: 'available' | 'flying';
fuel: number;
maxFuel: number;
locationIcao: string;
}
interface ContractState {
id: string;
originName: string;
destName: string;
reward: number;
status: 'open' | 'accepted' | 'completed' | 'expired';
planeId: string | null;
}
interface CatalogEntry {
model: string;
label: string;
price: number;
maxFuel: number;
burnPerKm: number;
speedKmh: number;
}
interface StateBody {
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
planes: PlaneState[];
contracts: ContractState[];
catalog: CatalogEntry[];
fuelPricePerL: number;
sellFraction: number;
}
// Mirrors formatCash in lib/format.ts (same Intl settings → identical strings).
const eur = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(v);
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;
}
async function postSim(page: Page, body: unknown) {
return page.request.post('/api/sim', { data: body });
}
// Leave the live instance in a sane state for the other specs (scale 1, running).
test.afterEach(async ({ page }) => {
const res = await postSim(page, { timeScale: 1, paused: false });
expect(res.status()).toBe(200);
});
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
}
// Closest airport to EDDF from /api/airports → shortest possible arrival time at scale 60.
async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number }> {
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
let nearest = fc.features.filter((f) => f.properties.icao !== HUB)[0].properties;
for (const f of fc.features) {
if (f.properties.icao === HUB) continue;
if (haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) < haversineKm(hub.lat, hub.lon, nearest.lat, nearest.lon)) {
nearest = f.properties;
}
}
return nearest;
}
// Test hook (FLIGHT_TEST_HOOKS=1): create an open contract on a known route.
async function createContract(page: Page, fromIcao: string, toIcao: string): Promise<ContractState> {
const created = await page.request.post('/api/test/create-contract', {
data: { originIcao: fromIcao, destIcao: toIcao },
});
expect(created.status()).toBe(201);
return (await created.json()) as ContractState;
}
// Create + accept + dispatch a contract on plane-starter via the API, then wait for arrival.
async function flyViaApi(page: Page, fromIcao: string, toIcao: string): Promise<void> {
const contract = await createContract(page, fromIcao, toIcao);
const acc = await page.request.post(`/api/contracts/${contract.id}/accept`);
expect(acc.status()).toBe(200);
const disp = await page.request.post('/api/planes/plane-starter/dispatch', { data: { contractId: contract.id } });
expect(disp.status()).toBe(200);
await postSim(page, { timeScale: SCALE });
await expect
.poll(async () => {
const body = await getState(page);
const p = body.planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'available' && p.locationIcao === toIcao;
}, { timeout: 90_000, message: `waiting for plane-starter to land at ${toIcao}` })
.toBe(true);
}
test('open contracts render with names, reward and deadline; Accept moves the contract into the accepted group', async ({ page }) => {
test.setTimeout(90_000);
await page.goto('/');
const right = page.getByTestId('right-pane');
await expect(right).toBeVisible({ timeout: 15_000 });
// ≥1 open contract row once the first snapshot lands — names, reward (€) and an Accept button.
const rows = page.locator('li[data-testid^="open-contract-"]');
await expect(rows.first(), 'an open contract row appears').toBeVisible({ timeout: 15_000 });
await expect(rows.first()).toContainText(/→/);
await expect(page.locator('[data-testid^="open-contract-reward-"]').first()).toContainText('€');
await expect(page.locator('[data-testid^="accept-"]').first()).toBeVisible();
// Deterministic target: a hook-created EDDF contract, accepted through the UI.
const nearest = await nearestToHub(page);
const contract = await createContract(page, HUB, nearest.icao);
const row = page.locator(`li[data-testid="open-contract-${contract.id}"]`);
await expect(row).toBeVisible({ timeout: 15_000 }); // the running poll picks it up
await expect(page.getByTestId(`accept-${contract.id}`)).toBeVisible();
await page.getByTestId(`accept-${contract.id}`).click();
// Server truth first…
const body = await getState(page);
expect(body.contracts.find((c) => c.id === contract.id)?.status).toBe('accepted');
// …and the UI: gone from open, present in the accepted group with dispatch controls.
await expect(row).not.toBeVisible();
const accRow = page.getByTestId(`accepted-contract-${contract.id}`);
await expect(accRow).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId(`dispatch-plane-${contract.id}`)).toBeVisible();
});
test('dispatch loop through the UI + market refuel with exact cost', async ({ page }) => {
test.setTimeout(240_000); // two flights at scale 60 plus UI steps — typical run well under three minutes
// Self-heal first: a previous interrupted run may have left plane-starter mid-flight or at
// another airport. Let any flight finish, then fly it home (same pattern as actions.spec.ts).
let body = await getState(page);
const starter0 = body.planes.find((p) => p.id === 'plane-starter');
expect(starter0).toBeDefined();
if (starter0!.status === 'flying') {
await postSim(page, { timeScale: SCALE });
await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', {
timeout: 90_000,
message: 'waiting for an in-flight plane-starter to land',
})
.toBe(true);
body = await getState(page);
}
if (body.planes.find((p) => p.id === 'plane-starter')!.locationIcao !== HUB) {
const from = body.planes.find((p) => p.id === 'plane-starter')!.locationIcao;
await flyViaApi(page, from, HUB);
}
// Hook contract on the shortest EDDF route, accepted through the UI.
const nearest = await nearestToHub(page);
const contract = await createContract(page, HUB, nearest.icao);
await page.goto('/');
const row = page.locator(`li[data-testid="open-contract-${contract.id}"]`);
await expect(row).toBeVisible({ timeout: 15_000 });
await page.getByTestId(`accept-${contract.id}`).click();
// Dispatch controls appear in the accepted group; pick plane-starter and dispatch.
const accRow = page.getByTestId(`accepted-contract-${contract.id}`);
await expect(accRow).toBeVisible({ timeout: 10_000 });
const picker = page.getByTestId(`dispatch-plane-${contract.id}`);
await expect(picker).toBeVisible();
await picker.selectOption('plane-starter'); // grounded at EDDF → enabled option
await page.getByTestId(`dispatch-${contract.id}`).click();
// UI shows the in-flight indicator…
await expect(page.getByTestId(`contract-inflight-${contract.id}`)).toBeVisible({ timeout: 10_000 });
// …and /api/state agrees.
body = await getState(page);
let starter = body.planes.find((p) => p.id === 'plane-starter')!;
expect(starter.status).toBe('flying');
// Fly to arrival at scale 60 (poll the authoritative state).
await postSim(page, { timeScale: SCALE });
await expect
.poll(async () => {
const b = await getState(page);
const p = b.planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'available' && p.locationIcao === nearest.icao;
}, { timeout: 90_000, message: `waiting for arrival at ${nearest.icao} (scale 60)` })
.toBe(true);
// Market refuel through the pane: the flight genuinely burned fuel…
body = await getState(page);
starter = body.planes.find((p) => p.id === 'plane-starter')!;
expect(starter.fuel).toBeLessThan(starter.maxFuel);
const expectedCost = Math.round((starter.maxFuel - starter.fuel) * body.fuelPricePerL * 100) / 100;
const cashBefore = body.account.cash;
// …the refuel picker lists it, the preview matches the exact cost, and the click deducts it.
await expect(page.getByTestId('market-refuel-plane')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('market-refuel-plane').selectOption('plane-starter');
await expect(page.getByTestId('market-refuel-cost')).toContainText(eur(expectedCost));
await page.getByTestId('market-refuel').click();
// Cash decreased by exactly the expected cost and the tank is full (server truth).
await expect
.poll(async () => {
const b = await getState(page);
return Math.abs(b.account.cash - (cashBefore - expectedCost)) < 0.001;
}, { timeout: 15_000, message: 'waiting for the refuel cost to land in /api/state' })
.toBe(true);
body = await getState(page);
starter = body.planes.find((p) => p.id === 'plane-starter')!;
expect(starter.fuel).toBe(starter.maxFuel);
// Fly home so the live instance stays in its onboarding state for the next run.
await flyViaApi(page, nearest.icao, HUB);
});
test('market buy/sell render with price and payout previews from the catalog in /api/state', async ({ page }) => {
test.setTimeout(60_000);
const body = await getState(page); // authoritative catalog + economy constants (v0.10.0)
expect(body.catalog.length).toBeGreaterThan(0);
const entry = body.catalog.find((e) => e.model === 'atr72')!;
expect(entry).toBeDefined();
await page.goto('/');
// Buy: the model picker lists the catalog entry and the price preview is its full price.
const buyModel = page.getByTestId('market-buy-model');
await expect(buyModel).toBeVisible({ timeout: 15_000 });
await expect(buyModel.locator(`option[value="${entry.model}"]`)).toHaveText(entry.label);
await expect(page.getByTestId('market-buy-price')).toContainText(eur(entry.price));
await expect(page.getByTestId('market-buy')).toBeVisible();
// Sell: the grounded-plane picker lists plane-starter and the payout preview is
// sellFraction × price, both from the snapshot's catalog/constants.
const starter = body.planes.find((p) => p.id === 'plane-starter')!;
expect(starter.status).toBe('available'); // test order: it flew home at the end of the previous test
await page.getByTestId('market-sell-plane').selectOption('plane-starter', { timeout: 15_000 });
const payout = Math.round(entry.price * body.sellFraction * 100) / 100;
await expect(page.getByTestId('market-sell-payout')).toContainText(eur(payout));
await expect(page.getByTestId('market-sell')).toBeVisible();
});
+38 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { formatCash, formatFuel, formatSimClock } from './format';
import { formatCash, formatDeadlineRelative, formatDuration, formatFuel, formatSimClock } from './format';
describe('formatCash', () => {
it('formats EUR with thousands separators and two decimals', () => {
@@ -28,3 +28,40 @@ describe('formatSimClock', () => {
expect(formatSimClock(Date.UTC(2026, 8, 17, 14, 5, 9))).toBe('2026-09-17 14:05:09 UTC');
});
});
const MIN = 60_000;
const HOUR = 3_600_000;
const DAY = 86_400_000;
describe('formatDuration', () => {
it('renders days + hours, hours + minutes, or bare minutes (rounded to the minute)', () => {
expect(formatDuration(2 * DAY + 3 * HOUR)).toBe('2d 3h');
expect(formatDuration(14 * HOUR + 30 * MIN)).toBe('14h 30m');
expect(formatDuration(45 * MIN)).toBe('45m');
expect(formatDuration(60 * MIN)).toBe('1h 0m');
// Rounding to the nearest minute: 29m29s stays at 29, 29m31s rounds up to 30.
expect(formatDuration(29 * MIN + 29_000)).toBe('29m');
expect(formatDuration(29 * MIN + 31_000)).toBe('30m');
});
it('clamps zero and negative input to "0m"', () => {
expect(formatDuration(0)).toBe('0m');
expect(formatDuration(-5 * MIN)).toBe('0m');
});
});
describe('formatDeadlineRelative', () => {
const NOW = Date.UTC(2026, 8, 17, 12, 0);
it('renders "in <duration>" for future deadlines relative to the sim clock', () => {
expect(formatDeadlineRelative(NOW + 14 * HOUR + 30 * MIN, NOW)).toBe('in 14h 30m');
expect(formatDeadlineRelative(NOW + 2 * DAY + 3 * HOUR, NOW)).toBe('in 2d 3h');
expect(formatDeadlineRelative(NOW + 45 * MIN, NOW)).toBe('in 45m');
});
it('renders "now" for sub-minute deadlines and "overdue" at or past the deadline', () => {
expect(formatDeadlineRelative(NOW + 30_000, NOW)).toBe('now');
expect(formatDeadlineRelative(NOW, NOW)).toBe('overdue');
expect(formatDeadlineRelative(NOW - HOUR, NOW)).toBe('overdue');
});
});
+20
View File
@@ -18,3 +18,23 @@ export function formatSimClock(clockMs: number): string {
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())}:${pad(d.getUTCSeconds())} UTC`;
}
// Compact game-time duration rounded to the minute: "2d 3h" / "14h 30m" / "45m".
export function formatDuration(ms: number): string {
const totalMin = Math.max(0, Math.round(ms / 60_000));
const d = Math.floor(totalMin / (24 * 60));
const h = Math.floor((totalMin % (24 * 60)) / 60);
const m = totalMin % 60;
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
// Contract deadline relative to the sim clock: "in 14h 30m"; a passed deadline reads
// "overdue" and a sub-minute one "now". Pure — unit-testable, no game logic.
export function formatDeadlineRelative(deadlineMs: number, nowMs: number): string {
const diff = deadlineMs - nowMs;
if (diff <= 0) return 'overdue';
if (diff < 60_000) return 'now'; // sub-minute — "in 1m" starts at a full minute
return `in ${formatDuration(diff)}`;
}
+25
View File
@@ -36,3 +36,28 @@ export const FUEL_PRICE_PER_L = 0.8;
// Fraction of the catalog price paid out when selling a plane (v0.8.0).
export const SELL_FRACTION = 0.5;
// Snapshot shape of one catalog entry in GET /api/state (v0.10.0): `model` is the
// id used by buy/dispatch, `label` the display name — clients compute all market
// previews (buy price, sell payout, refuel cost) from these + the economy constants.
export interface CatalogEntry {
model: string;
label: string;
price: number;
maxFuel: number;
burnPerKm: number;
speedKmh: number;
}
// The catalog as a flat array, one entry per plane type — the single source of
// truth for both the sim (PLANE_TYPES) and the client snapshot.
export function catalogEntries(): CatalogEntry[] {
return Object.values(PLANE_TYPES).map((t) => ({
model: t.id,
label: t.name,
price: t.price,
maxFuel: t.maxFuel,
burnPerKm: t.burnPerKm,
speedKmh: t.speedKmh,
}));
}
+9 -1
View File
@@ -2,7 +2,7 @@ import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { getGameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { PLANE_TYPES } from './catalog';
import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
import { fuelAt, progressAt, positionAt } from '@/lib/sim/movement';
// Read-only game-state snapshot for client polling (GET /api/state).
@@ -43,6 +43,11 @@ export interface StateSnapshot {
simState: { clockMs: number; timeScale: number; paused: boolean };
planes: PlaneState[];
contracts: ContractState[];
// Market reference data (v0.10.0) — static per build, sent with every snapshot so
// the client can compute buy/sell/refuel previews without a separate endpoint.
catalog: CatalogEntry[];
fuelPricePerL: number;
sellFraction: number;
}
export async function getStateSnapshot(): Promise<StateSnapshot> {
@@ -84,6 +89,9 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
return {
account: { cash: accountRow.cash, reputation: accountRow.reputation },
simState: { clockMs: simRow.clockMs, timeScale: simRow.timeScale, paused: simRow.paused },
catalog: catalogEntries(),
fuelPricePerL: FUEL_PRICE_PER_L,
sellFraction: SELL_FRACTION,
planes: planeRows.map((p) => {
const live = livePlane(p);
return {
+29 -1
View File
@@ -62,11 +62,39 @@ describe('OpenAPI spec', () => {
expect(schema.$ref).toBe('#/components/schemas/StateSnapshot');
const snap = openApiSpec.components.schemas.StateSnapshot.properties;
expect(Object.keys(snap).sort()).toEqual(['account', 'contracts', 'planes', 'simState']);
expect(Object.keys(snap).sort()).toEqual([
'account',
'catalog',
'contracts',
'fuelPricePerL',
'planes',
'sellFraction',
'simState',
]);
expect(openApiSpec.components.schemas.StateSnapshot.required.sort()).toEqual([
'account',
'catalog',
'contracts',
'fuelPricePerL',
'planes',
'sellFraction',
'simState',
]);
expect(snap.account.$ref).toBe('#/components/schemas/AccountState');
expect(snap.simState.$ref).toBe('#/components/schemas/SimState');
expect(snap.planes.items.$ref).toBe('#/components/schemas/PlaneState');
expect(snap.contracts.items.$ref).toBe('#/components/schemas/ContractState');
expect(snap.catalog.items.$ref).toBe('#/components/schemas/CatalogEntry');
expect(snap.fuelPricePerL.type).toBe('number');
expect(snap.sellFraction.type).toBe('number');
const catalog = openApiSpec.components.schemas.CatalogEntry.properties;
expect(Object.keys(catalog).sort()).toEqual(['burnPerKm', 'label', 'maxFuel', 'model', 'price', 'speedKmh']);
for (const key of ['burnPerKm', 'maxFuel', 'price', 'speedKmh'] as const) {
expect(catalog[key].type, `CatalogEntry.${key}`).toBe('number');
}
expect(catalog.model.type).toBe('string');
expect(catalog.label.type).toBe('string');
const plane = openApiSpec.components.schemas.PlaneState.properties;
expect(Object.keys(plane).sort()).toEqual([
+21 -2
View File
@@ -37,7 +37,7 @@ export const openApiSpec = {
get: {
summary: 'Game state snapshot',
description:
'Full current game state for client polling — account, sim clock, planes and contracts. Tick-on-read: each call first advances the sim clock by (real time since last read) × timeScale (clamped per tick), so expiry/finalization/spawn are always applied before the snapshot is returned.',
'Full current game state for client polling — account, sim clock, planes and contracts, plus the static market reference data (catalog + economy constants) so clients can compute buy/sell/refuel previews locally. Tick-on-read: each call first advances the sim clock by (real time since last read) × timeScale (clamped per tick), so expiry/finalization/spawn are always applied before the snapshot is returned.',
operationId: 'getState',
responses: {
200: {
@@ -321,8 +321,27 @@ export const openApiSpec = {
type: 'array',
items: { $ref: '#/components/schemas/ContractState' },
},
catalog: {
type: 'array',
description: 'Aircraft catalog (v0.10.0) — static per build, one entry per plane type.',
items: { $ref: '#/components/schemas/CatalogEntry' },
},
fuelPricePerL: { type: 'number', description: 'Jet-A1 price in €/liter; refuel cost = (maxFuel − fuel) × this.' },
sellFraction: { type: 'number', description: 'Fraction of the catalog price paid out when selling a plane.' },
},
required: ['account', 'simState', 'planes', 'contracts'],
required: ['account', 'simState', 'planes', 'contracts', 'catalog', 'fuelPricePerL', 'sellFraction'],
},
CatalogEntry: {
type: 'object',
properties: {
model: { type: 'string', description: 'Catalog model id (e.g. "atr72") — used by buy/dispatch.' },
label: { type: 'string', description: 'Display name (e.g. "ATR 72-600").' },
price: { type: 'number', description: 'Market price in € (buy cost; sell pays out sellFraction × this).' },
maxFuel: { type: 'number', description: 'Fuel capacity, liters.' },
burnPerKm: { type: 'number', description: 'Cruise fuel burn, liters/km.' },
speedKmh: { type: 'number', description: 'Cruise speed, km/h.' },
},
required: ['model', 'label', 'price', 'maxFuel', 'burnPerKm', 'speedKmh'],
},
AccountState: {
type: 'object',
+14 -1
View File
@@ -10,6 +10,11 @@ export interface GameStateResult {
error: string | null;
// Trigger an immediate refetch (used after POST /api/sim so the UI updates without waiting for the next tick).
refresh: () => void;
// Push a snapshot straight into the shared state (v0.10.0): every action endpoint returns
// the FULL post-action snapshot, so panes can render its result on click instead of waiting
// for the next poll tick. The polling loop keeps running and simply overwrites it with the
// server's truth on its next cycle (which also re-clears any transient pane error).
applySnapshot: (snapshot: StateSnapshot) => void;
}
// Single fetch of the full game-state snapshot. Exported separately from the hook so it can be unit-tested with a mocked fetch.
@@ -45,11 +50,19 @@ export function useGameState(intervalMs = 1000): GameStateResult {
});
}, []);
// v0.10.0 — instant action feedback: panes push the full snapshot returned by an
// action endpoint into the shared state here (see GameStateResult.applySnapshot).
const applySnapshot = useCallback((snapshot: StateSnapshot) => {
setState(snapshot);
setError(null);
setLoading(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 };
return { state, loading, error, refresh, applySnapshot };
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "flight-ops",
"version": "0.9.2",
"version": "0.10.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",