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:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 & Market</h2>
|
||||
</aside>
|
||||
<RightPane state={state} loading={loading} applySnapshot={applySnapshot} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 & 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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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' },
|
||||
},
|
||||
required: ['account', 'simState', 'planes', 'contracts'],
|
||||
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', '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
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flight-ops",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user