From cf811dcf4db49afa659effdddfd0237f49f44537 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Thu, 17 Sep 2026 19:15:54 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20v0.10.0=20=E2=80=94=20right=20Jobs=20&?= =?UTF-8?q?=20Market=20pane=20(contracts=20accept/dispatch,=20market=20buy?= =?UTF-8?q?/sell/refuel=20with=20live=20previews);=20catalog=20+=20economy?= =?UTF-8?q?=20constants=20exposed=20in=20/api/state;=20shared=20applySnaps?= =?UTF-8?q?hot=20for=20instant=20action=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 9 + app/api/contracts/[id]/accept/route.test.ts | 11 +- app/api/state/route.test.ts | 35 ++- app/globals.css | 90 ++++++ app/page.tsx | 10 +- components/RightPane.tsx | 326 ++++++++++++++++++++ e2e/right-pane.spec.ts | 263 ++++++++++++++++ lib/format.test.ts | 39 ++- lib/format.ts | 20 ++ lib/game/catalog.ts | 25 ++ lib/game/state.ts | 10 +- lib/openapi.test.ts | 30 +- lib/openapi.ts | 23 +- lib/useGameState.ts | 15 +- package.json | 2 +- 15 files changed, 894 insertions(+), 14 deletions(-) create mode 100644 components/RightPane.tsx create mode 100644 e2e/right-pane.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a1f5dfa..ad01f71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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-`); 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) diff --git a/app/api/contracts/[id]/accept/route.test.ts b/app/api/contracts/[id]/accept/route.test.ts index 32f894a..98096c7 100644 --- a/app/api/contracts/[id]/accept/route.test.ts +++ b/app/api/contracts/[id]/accept/route.test.ts @@ -39,7 +39,16 @@ describe('POST /api/contracts/:id/accept', () => { planes: Array>; 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. diff --git a/app/api/state/route.test.ts b/app/api/state/route.test.ts index d5e9ea1..08326c5 100644 --- a/app/api/state/route.test.ts +++ b/app/api/state/route.test.ts @@ -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; - 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>; + 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); + }); }); diff --git a/app/globals.css b/app/globals.css index 98bf0fc..ec04bcd 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; +} diff --git a/app/page.tsx b/app/page.tsx index ffe534e..e6f9d0d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 (
- +
); } diff --git a/components/RightPane.tsx b/components/RightPane.tsx new file mode 100644 index 0000000..dbf4a4e --- /dev/null +++ b/components/RightPane.tsx @@ -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(null); + const effective = + planes.find((p) => p.id === selectedId && dispatchReason(p, contract) === null) ?? + planes.find((p) => dispatchReason(p, contract) === null) ?? + null; + + return ( +
+ + +
+ ); +} + +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(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(null); + const [sellPlaneId, setSellPlaneId] = useState(null); + const [refuelPlaneId, setRefuelPlaneId] = useState(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 ( + + ); + } + 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 ( + + ); +} diff --git a/e2e/right-pane.spec.ts b/e2e/right-pane.spec.ts new file mode 100644 index 0000000..87b827e --- /dev/null +++ b/e2e/right-pane.spec.ts @@ -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 { + 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 { + 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 { + 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(); +}); diff --git a/lib/format.test.ts b/lib/format.test.ts index f512936..a6c4e35 100644 --- a/lib/format.test.ts +++ b/lib/format.test.ts @@ -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 " 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'); + }); +}); diff --git a/lib/format.ts b/lib/format.ts index 37f78f0..d0e4821 100644 --- a/lib/format.ts +++ b/lib/format.ts @@ -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)}`; +} diff --git a/lib/game/catalog.ts b/lib/game/catalog.ts index 40a02fa..4289493 100644 --- a/lib/game/catalog.ts +++ b/lib/game/catalog.ts @@ -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, + })); +} diff --git a/lib/game/state.ts b/lib/game/state.ts index 60fc9ce..eb3abce 100644 --- a/lib/game/state.ts +++ b/lib/game/state.ts @@ -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 { @@ -84,6 +89,9 @@ export async function getStateSnapshot(): Promise { 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 { diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index a44b124..3640d4d 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -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([ diff --git a/lib/openapi.ts b/lib/openapi.ts index 3c4f8c2..68c5588 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -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', diff --git a/lib/useGameState.ts b/lib/useGameState.ts index 9814a33..664a2dc 100644 --- a/lib/useGameState.ts +++ b/lib/useGameState.ts @@ -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 }; } diff --git a/package.json b/package.json index dcba020..6ce8c86 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.9.2", + "version": "0.10.0", "private": true, "scripts": { "dev": "next dev -p 3000",