feat: v0.11.0 — map interactions per PLAN.md: expanded airport popup (approx local time + deterministic generated weather), animated plane markers from /api/state polls with status popups, active contract route lines (pending dashed vs in-flight solid); pure libs lib/weather.ts, lib/localTime.ts, lib/mapData.ts
This commit is contained in:
@@ -1,5 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## v0.11.0 — 2026-09-18 07:25
|
||||
- **Map interactions per PLAN.md "Middle — Map"** (`components/Map.tsx` now receives the shell's polled `/api/state` snapshot from `app/page.tsx`, same single poll loop as the panes): (a) airport popup expanded — name + ICAO kept, plus **approximate local time** and a **generated weather** line; (b) **animated plane markers** — every plane rendered on the map at its polled lat/lon (server computes flying positions analytically each tick), grounded planes as small sky-blue circles at their airport, flying planes bigger + green so they stand out; movement between 1 s polls is acceptable for this increment (no client-side interpolation); (c) **plane status popup** on click — catalog model label ("ATR 72-600"), status, fuel "x / y L", and while flying the contract route "originName → destName"; (d) **active contract routes highlighted** — one origin→dest line per accepted contract: dashed muted slate when pending (accepted, not yet dispatched), solid bright amber once in-flight
|
||||
- **Pure deterministic weather generator** `lib/weather.ts`: `weatherFor(icao: string, dayKey?: number)` → `{condition: 'Clear'|'Cloudy'|'Rain'|'Fog', windDirDeg, windKt, visibilityM}` — seeded by FNV-1a hash of the ICAO through mulberry32 (same PRNG as game seeding), so the same airport always shows the same weather across polls/sessions; optional `dayKey` varies it per sim-day. Display-only per PLAN.md — no gameplay effects
|
||||
- **Approximate local time** `lib/localTime.ts`: zone band N = round(lon/15) with the Etc/GMT±N POSIX sign flip (UTC+1 is "Etc/GMT-1"); formatted via `Intl.DateTimeFormat` as a UTC-format of a shifted timestamp so it works on runtimes whose reduced ICU lacks the positive-offset Etc zones. Labelled "(approx)" in the popup — no DST, no political boundaries
|
||||
- **Pure GeoJSON builders** `lib/mapData.ts`: `planesGeoJson(state)` (one Point per plane with popup properties; catalog label resolved from the snapshot) and `routesGeoJson(state, airportsByIcao)` (accepted contracts only; kind 'inflight' iff its planeId's status is 'flying'; endpoints missing from the dataset are skipped, never guessed) — kept out of the component so the classification logic is unit-testable
|
||||
- **New layers coexist with the ensure-on-styledata pattern**: `ensureAirportsLayer` became `ensureLayers` and now idempotently re-establishes all six sources/layers (airports circles; routes-pending + routes-inflight line layers over one 'routes' source, added first so lines render under planes; planes-grounded + planes-flying circle layers over one 'planes' source) on every styledata event — `map.setStyle()` still wipes everything not in the new style and this re-adds it all. Latest snapshot + airport coordinate index live in refs (`stateRef`/`airportsIndexRef`) so both the mount effect's styledata wiring and a `[state]` effect can push data: every poll calls `setData` on the two dynamic sources (no-op until map/sources exist; routes wait for the airports fetch), and `ensureLayers` re-pushes after each re-establish. Layer-scoped click listeners are off+on'd with the same references as before, so no duplicate popups
|
||||
- **No runways in the popup**: the OurAirports export (`data/airports_eu.csv`) has no runways column (closest usable extras: `elevation_ft`, `municipality`, `iata_code` — none shown this increment), so per "don't fabricate" the field is omitted
|
||||
- No API changes: `/api/state` shape untouched, OpenAPI spec unchanged; test hooks (`/api/test/create-contract`) reused as-is
|
||||
- Tests: unit — new `lib/weather.test.ts` (determinism same ICAO → identical weather; 10 ICACOs mostly distinct; all values in sane ranges incl. per-condition visibility bands; dayKey determinism + effect), `lib/localTime.test.ts` (Etc/GMT±N sign flip, band rounding/clamping, HH:MM formatting, midnight wrap both directions) and `lib/mapData.test.ts` (plane features at polled coords with catalog label/fuel/routeText incl. missing-contract fallback; route classification open/completed/expired → nothing, accepted-undispatched → pending, accepted+flying → inflight, grounded-plane defensive case, missing endpoint skipped). e2e — new `e2e/map-interactions.spec.ts`: (1) airport popup keeps name+ICAO and gains local-time (HH:MM) + weather (condition + kt) fields; (2) hook-created EDDF→EDDV contract accepted → pending route line feature appears in the 'routes' source, dispatch at scale 60 → flying plane feature appears in 'planes', position changes across two /api/state polls, route line flips to inflight for that contract id, clicking the flying plane (fresh fetch+project in one evaluate) opens a popup with model label + "flying" + fuel + "Frankfurt Main Airport → Hannover Airport"; flight completed and starter flown home so the live instance stays in its onboarding state; scale restored to 1×/unpaused in `afterEach`
|
||||
- Verified: unit suite 171 → 192 green; `tsc --noEmit` clean; e2e 25 → 27 green against the rebuilt compose instance (all pre-existing specs — map, sim, state-restart, actions full-loop, panes, right-pane, api — still pass)
|
||||
|
||||
## v0.10.1 — 2026-09-18 06:36
|
||||
- **Bugfix (reported): dispatch picker selections appeared shared across accepted contracts** — selecting a plane in one contract's dispatch picker made every other accepted contract's picker show that same plane until the page was refreshed. Root cause confirmed by code audit of `components/RightPane.tsx`: NOT a single shared state variable and NOT missing list keys (each row already had its own `useState` inside `DispatchControls` and stable `key={contract.id}`) — it was the "effective selection" fallback, which auto-tracked the **first eligible plane** in every row whenever no explicit pick existed: with one grounded plane all pickers displayed that same plane by default, so any selection looked like it propagated to the other rows; additionally there was no placeholder `<option value="">`, so once a pick became ineligible (e.g. after dispatching the only eligible plane from another contract) `value=''` matched no option and the browser kept showing the stale previously-selected option until a DOM refresh
|
||||
- **Fix**: each accepted-contract row keeps its own local selection in the per-row `DispatchControls` component, now defaulting to an explicit **"Select a plane…" placeholder** (`<option value="">`) instead of preselecting the first eligible plane — rows are visibly independent and the Dispatch button stays disabled until a valid plane is chosen (prevents accidental dispatch on the wrong contract); eligibility unchanged (only planes 'available' AND at `contract.originIcao` enabled, others disabled with reason) and a poll that makes the current pick ineligible drops it back to the placeholder; stable `key={contract.id}` kept on all open + accepted rows. Pure selection rule extracted as exported `dispatchSelection(planes, contract, selectedId)` for unit testing
|
||||
|
||||
+2
-1
@@ -16,7 +16,8 @@ export default function Home() {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<LeftPane state={state} loading={loading} error={error} onRefresh={refresh} />
|
||||
<Map />
|
||||
{/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines */}
|
||||
<Map state={state} />
|
||||
<RightPane state={state} loading={loading} applySnapshot={applySnapshot} />
|
||||
</div>
|
||||
);
|
||||
|
||||
+156
-12
@@ -5,6 +5,11 @@ import * as maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import pkg from '../package.json';
|
||||
import { DEFAULT_STYLE_ID, STYLES, STYLE_STORAGE_KEY, isStyleId, localAssetsAvailable, styleUrlFor } from '../lib/mapStyles';
|
||||
import type { GeoJsonFeatureCollection } from '../lib/airports';
|
||||
import type { StateSnapshot } from '../lib/game/state';
|
||||
import { planesGeoJson, routesGeoJson, type AirportCoord, type PlaneFeatureProps } from '../lib/mapData';
|
||||
import { weatherFor } from '../lib/weather';
|
||||
import { approxLocalTime } from '../lib/localTime';
|
||||
|
||||
const EUROPE_CENTER: [number, number] = [10, 52];
|
||||
const INITIAL_ZOOM = 4;
|
||||
@@ -15,20 +20,60 @@ export const MAX_ZOOM = 11;
|
||||
|
||||
const AIRPORTS_SOURCE_ID = 'airports';
|
||||
const AIRPORTS_LAYER_ID = 'airports-circles';
|
||||
// Dynamic game-state layers (v0.11.0): planes as circles, active contract routes as lines.
|
||||
const PLANES_SOURCE_ID = 'planes';
|
||||
const PLANES_GROUNDED_LAYER_ID = 'planes-grounded';
|
||||
const PLANES_FLYING_LAYER_ID = 'planes-flying';
|
||||
const ROUTES_SOURCE_ID = 'routes';
|
||||
const ROUTES_PENDING_LAYER_ID = 'routes-pending';
|
||||
const ROUTES_INFLIGHT_LAYER_ID = 'routes-inflight';
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
export default function Map() {
|
||||
interface MapProps {
|
||||
// The shell's polled /api/state snapshot (v0.11.0) — drives the plane markers and
|
||||
// route lines. Null until the first successful poll.
|
||||
state?: StateSnapshot | null;
|
||||
}
|
||||
|
||||
// Named FlightMap (not Map): a component called `Map` would shadow the global
|
||||
// Map constructor inside its own body, breaking `new Map(...)` for the airport index.
|
||||
export default function FlightMap({ state }: MapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||
// /api/airports is fetched at most once per page life: every styledata event
|
||||
// (initial load AND after each setStyle) reuses this promise.
|
||||
const airportsPromiseRef = useRef<Promise<{ features: unknown[] }> | null>(null);
|
||||
const airportsPromiseRef = useRef<Promise<GeoJsonFeatureCollection> | null>(null);
|
||||
// Latest snapshot + airport coordinate index, so the mount effect's styledata wiring
|
||||
// and the state-change effect can both push data without prop-drilling into closures.
|
||||
const stateRef = useRef<StateSnapshot | null>(null);
|
||||
const airportsIndexRef = useRef<Map<string, AirportCoord> | null>(null);
|
||||
const [airportCount, setAirportCount] = useState<number | null>(null);
|
||||
const [styleId, setStyleId] = useState<string>(DEFAULT_STYLE_ID);
|
||||
|
||||
// Push the latest planes/routes GeoJSON into whatever sources currently exist.
|
||||
// No-op until the map (and for routes: the airport index) is ready — the mount
|
||||
// effect calls this again after every styledata re-establish and airports load.
|
||||
const applyDynamicRef = useRef<() => void>(() => {});
|
||||
applyDynamicRef.current = () => {
|
||||
const map = mapRef.current;
|
||||
const st = stateRef.current;
|
||||
if (!map || !st) return;
|
||||
const planesSource = map.getSource(PLANES_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
|
||||
if (planesSource) void planesSource.setData(planesGeoJson(st)); // v6 API (setGeoData was removed)
|
||||
const routesSource = map.getSource(ROUTES_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
|
||||
if (routesSource && airportsIndexRef.current) {
|
||||
void routesSource.setData(routesGeoJson(st, airportsIndexRef.current));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
stateRef.current = state ?? null;
|
||||
applyDynamicRef.current();
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
@@ -65,11 +110,37 @@ export default function Map() {
|
||||
const onAirportClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
const f = e.features?.[0];
|
||||
if (!f) return;
|
||||
const p = f.properties as { name: string; icao: string };
|
||||
const p = f.properties as { name: string; icao: string; lat: number; lon: number };
|
||||
// Display-only extras (v0.11.0): approximate local time from longitude and a
|
||||
// deterministic generated weather — no gameplay effects per PLAN.md.
|
||||
const w = weatherFor(p.icao);
|
||||
const localTime = approxLocalTime(p.lon, Date.now());
|
||||
new maplibregl.Popup({ offset: 10 })
|
||||
.setLngLat(f.geometry.coordinates as [number, number])
|
||||
.setHTML(
|
||||
`<div data-testid="airport-popup"><strong>${escapeHtml(p.name)}</strong><br/>${escapeHtml(p.icao)}</div>`,
|
||||
`<div data-testid="airport-popup">` +
|
||||
`<strong>${escapeHtml(p.name)}</strong><br/>${escapeHtml(p.icao)}<br/>` +
|
||||
`<span data-testid="airport-local-time">${localTime} local (approx)</span><br/>` +
|
||||
`<span data-testid="airport-weather">${w.condition}, wind ${w.windDirDeg}\u00B0 / ${w.windKt} kt, vis ${w.visibilityM} m</span>` +
|
||||
`</div>`,
|
||||
)
|
||||
.addTo(map);
|
||||
};
|
||||
|
||||
const onPlaneClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
const f = e.features?.[0];
|
||||
if (!f) return;
|
||||
const p = f.properties as PlaneFeatureProps;
|
||||
const routeLine = p.routeText ? `<br/><span data-testid="plane-route">${escapeHtml(p.routeText)}</span>` : '';
|
||||
new maplibregl.Popup({ offset: 12 })
|
||||
.setLngLat(f.geometry.coordinates as [number, number])
|
||||
.setHTML(
|
||||
`<div data-testid="plane-popup">` +
|
||||
`<strong>${escapeHtml(p.modelLabel)}</strong><br/>` +
|
||||
`<span data-testid="plane-status">${p.status}</span><br/>` +
|
||||
`fuel <span data-testid="plane-fuel">${p.fuelText}</span>` +
|
||||
routeLine +
|
||||
`</div>`,
|
||||
)
|
||||
.addTo(map);
|
||||
};
|
||||
@@ -79,10 +150,13 @@ export default function Map() {
|
||||
airportsPromiseRef.current = fetch('/api/airports')
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`GET /api/airports → HTTP ${res.status}`);
|
||||
return res.json() as Promise<{ features: unknown[] }>;
|
||||
return res.json() as Promise<GeoJsonFeatureCollection>;
|
||||
})
|
||||
.then((data) => {
|
||||
setAirportCount(data.features.length); // exactly once per page life
|
||||
airportsIndexRef.current = new Map(
|
||||
data.features.map((f) => [f.properties.icao, { lat: f.properties.lat, lon: f.properties.lon }]),
|
||||
);
|
||||
return data;
|
||||
});
|
||||
}
|
||||
@@ -90,10 +164,10 @@ export default function Map() {
|
||||
};
|
||||
|
||||
// Idempotent, so it can run on EVERY styledata event: map.setStyle() removes all
|
||||
// sources/layers not in the new style, which wipes our GeoJSON layer after each
|
||||
// switch — this re-establishes it (and the initial load goes through here too).
|
||||
const ensureAirportsLayer = async () => {
|
||||
let data: { features: unknown[] };
|
||||
// sources/layers not in the new style, which wipes our GeoJSON layers after each
|
||||
// switch — this re-establishes them (and the initial load goes through here too).
|
||||
const ensureLayers = async () => {
|
||||
let data: GeoJsonFeatureCollection;
|
||||
try {
|
||||
data = await loadAirports();
|
||||
} catch (err) {
|
||||
@@ -116,14 +190,84 @@ export default function Map() {
|
||||
},
|
||||
});
|
||||
}
|
||||
// Routes first so the lines render under the plane circles. Empty collections
|
||||
// until the shell's first snapshot lands — applyDynamicRef fills them in.
|
||||
if (!map.getSource(ROUTES_SOURCE_ID)) {
|
||||
map.addSource(ROUTES_SOURCE_ID, {
|
||||
type: 'geojson',
|
||||
data: routesGeoJson(stateRef.current, airportsIndexRef.current ?? new Map()),
|
||||
});
|
||||
}
|
||||
if (!map.getLayer(ROUTES_PENDING_LAYER_ID)) {
|
||||
map.addLayer({
|
||||
id: ROUTES_PENDING_LAYER_ID,
|
||||
type: 'line',
|
||||
source: ROUTES_SOURCE_ID,
|
||||
filter: ['==', ['get', 'kind'], 'pending'],
|
||||
paint: {
|
||||
'line-color': '#94a3b8', // muted slate — accepted but not yet dispatched
|
||||
'line-width': 2,
|
||||
'line-dasharray': [2, 2],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!map.getLayer(ROUTES_INFLIGHT_LAYER_ID)) {
|
||||
map.addLayer({
|
||||
id: ROUTES_INFLIGHT_LAYER_ID,
|
||||
type: 'line',
|
||||
source: ROUTES_SOURCE_ID,
|
||||
filter: ['==', ['get', 'kind'], 'inflight'],
|
||||
paint: {
|
||||
'line-color': '#f59e0b', // bright amber — actively flying the leg
|
||||
'line-width': 3,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!map.getSource(PLANES_SOURCE_ID)) {
|
||||
map.addSource(PLANES_SOURCE_ID, { type: 'geojson', data: planesGeoJson(stateRef.current) });
|
||||
}
|
||||
if (!map.getLayer(PLANES_GROUNDED_LAYER_ID)) {
|
||||
map.addLayer({
|
||||
id: PLANES_GROUNDED_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLANES_SOURCE_ID,
|
||||
filter: ['==', ['get', 'flying'], false],
|
||||
paint: {
|
||||
'circle-radius': 3,
|
||||
'circle-color': '#38bdf8', // sky blue at the home airport
|
||||
'circle-stroke-width': 1,
|
||||
'circle-stroke-color': '#ffffff',
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!map.getLayer(PLANES_FLYING_LAYER_ID)) {
|
||||
map.addLayer({
|
||||
id: PLANES_FLYING_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLANES_SOURCE_ID,
|
||||
filter: ['==', ['get', 'flying'], true],
|
||||
paint: {
|
||||
'circle-radius': 6, // bigger + distinct so a moving plane stands out
|
||||
'circle-color': '#22c55e',
|
||||
'circle-stroke-width': 1.5,
|
||||
'circle-stroke-color': '#ffffff',
|
||||
},
|
||||
});
|
||||
}
|
||||
// Layer-scoped listeners survive setStyle (they filter by layer id at event
|
||||
// time), so off+on with the same reference re-wires without duplicating.
|
||||
map.off('click', AIRPORTS_LAYER_ID, onAirportClick);
|
||||
map.on('click', AIRPORTS_LAYER_ID, onAirportClick);
|
||||
map.off('click', PLANES_GROUNDED_LAYER_ID, onPlaneClick);
|
||||
map.on('click', PLANES_GROUNDED_LAYER_ID, onPlaneClick);
|
||||
map.off('click', PLANES_FLYING_LAYER_ID, onPlaneClick);
|
||||
map.on('click', PLANES_FLYING_LAYER_ID, onPlaneClick);
|
||||
// Sources were (re)created above — push the latest game state into them.
|
||||
applyDynamicRef.current();
|
||||
};
|
||||
|
||||
if (map.isStyleLoaded()) void ensureAirportsLayer();
|
||||
else map.on('styledata', () => void ensureAirportsLayer());
|
||||
if (map.isStyleLoaded()) void ensureLayers();
|
||||
else map.on('styledata', () => void ensureLayers());
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -135,7 +279,7 @@ export default function Map() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onStyleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const onStyleChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
||||
const id = e.target.value;
|
||||
if (!isStyleId(id)) return;
|
||||
setStyleId(id);
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
// v0.11.0 — map interactions against the live origin (PLAN.md "Middle — Map"):
|
||||
// expanded airport popup (name + ICAO kept, plus approximate local time and a
|
||||
// deterministic generated weather), plane markers whose positions come from the
|
||||
// /api/state polls, active contract route lines (pending dashed vs in-flight
|
||||
// solid), and the plane status popup. Contracts are created on a known EDDF→EDDV
|
||||
// route via POST /api/test/create-contract (FLIGHT_TEST_HOOKS=1).
|
||||
|
||||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||
// Hannover — ~282 km from EDDF → ~33 min game time ≈ 33 s at scale 60, a comfortable
|
||||
// window for the click assertions below (the nearest airport would land too fast).
|
||||
const DEST = 'EDDV';
|
||||
const SCALE = 60;
|
||||
|
||||
interface PlaneState {
|
||||
id: string;
|
||||
model: string;
|
||||
status: 'available' | 'flying';
|
||||
fuel: number;
|
||||
maxFuel: number;
|
||||
locationIcao: string;
|
||||
contractId: string | null;
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
interface ContractState {
|
||||
id: string;
|
||||
originIcao: string;
|
||||
originName: string;
|
||||
destIcao: string;
|
||||
destName: string;
|
||||
reward: number;
|
||||
status: 'open' | 'accepted' | 'completed' | 'expired';
|
||||
planeId: string | null;
|
||||
}
|
||||
|
||||
interface StateBody {
|
||||
account: { cash: number; reputation: number };
|
||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||
planes: PlaneState[];
|
||||
contracts: ContractState[];
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// 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)));
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// 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, deadlineOffsetMs: 24 * 3_600_000 },
|
||||
});
|
||||
expect(created.status()).toBe(201);
|
||||
return (await created.json()) as ContractState;
|
||||
}
|
||||
|
||||
// Create + accept + dispatch a contract from → to on plane-starter, then poll at
|
||||
// scale 60 until the plane lands at `to`. Same pattern as e2e/actions.spec.ts.
|
||||
async function flyContract(page: Page, fromIcao: string, toIcao: string): Promise<ContractState> {
|
||||
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: 120_000, message: `waiting for plane-starter to land at ${toIcao}` })
|
||||
.toBe(true);
|
||||
return contract;
|
||||
}
|
||||
|
||||
// All features of a GeoJSON source (viewport-independent), [] until the map/source exists.
|
||||
function sourceFeatures(page: Page, sourceId: string) {
|
||||
return page.evaluate((srcId: string) => {
|
||||
const m = (window as any).__flightMap;
|
||||
if (!m || !m.getSource(srcId)) return [];
|
||||
try {
|
||||
return m.querySourceFeatures(srcId);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, sourceId);
|
||||
}
|
||||
|
||||
async function waitForAirportCount(page: Page): Promise<void> {
|
||||
const countEl = page.getByTestId('airport-count');
|
||||
await expect.poll(async () => {
|
||||
const text = (await countEl.textContent()) ?? '';
|
||||
const m = text.match(/(\d+)/);
|
||||
return m ? parseInt(m[1], 10) : -1;
|
||||
}).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
test('airport popup shows name, ICAO, local time and generated weather', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForAirportCount(page);
|
||||
|
||||
// Wait until the map instance exists and airport circles are actually rendered.
|
||||
await expect.poll(async () =>
|
||||
page.evaluate(
|
||||
() => (window as any).__flightMap?.queryRenderedFeatures(undefined, { layers: ['airports-circles'] })?.length ?? 0,
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
// LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts.
|
||||
const pt = await page.evaluate(() => {
|
||||
const map = (window as any).__flightMap;
|
||||
const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique
|
||||
const rect = map.getContainer().getBoundingClientRect();
|
||||
return { x: rect.left + p.x, y: rect.top + p.y };
|
||||
});
|
||||
await page.mouse.click(pt.x, pt.y);
|
||||
|
||||
const popup = page.getByTestId('airport-popup');
|
||||
await expect(popup).toBeVisible();
|
||||
// Pre-v0.11.0 fields still present (regression guard for the existing map specs).
|
||||
await expect(popup).toContainText('Nantes Atlantique Airport');
|
||||
await expect(popup).toContainText('LFRS');
|
||||
|
||||
// New: approximate local time (HH:MM) and generated weather (condition + wind + visibility).
|
||||
const localTime = page.getByTestId('airport-local-time');
|
||||
await expect(localTime).toBeVisible();
|
||||
expect((await localTime.textContent()) ?? '').toMatch(/\d{2}:\d{2}/);
|
||||
|
||||
const weather = page.getByTestId('airport-weather');
|
||||
await expect(weather).toBeVisible();
|
||||
const wText = (await weather.textContent()) ?? '';
|
||||
expect(wText).toMatch(/(Clear|Cloudy|Rain|Fog)/);
|
||||
expect(wText).toMatch(/\d+ kt/); // wind speed in knots
|
||||
});
|
||||
|
||||
test('plane markers move while flying, the active route line is highlighted, and the plane popup shows status + route', async ({ page }) => {
|
||||
test.setTimeout(300_000); // generous cap — two short flights at scale 60 plus map waits
|
||||
|
||||
await page.goto('/');
|
||||
await waitForAirportCount(page);
|
||||
|
||||
// Self-heal first: a previous interrupted run may have left the starter plane
|
||||
// mid-flight or at another airport. Let any flight finish, then fly it home so
|
||||
// the test starts from the canonical onboarding state (plane grounded at EDDF).
|
||||
const foundStarter = (await getState(page)).planes.find((p) => p.id === 'plane-starter');
|
||||
expect(foundStarter).toBeDefined();
|
||||
if (foundStarter!.status === 'flying') {
|
||||
await postSim(page, { timeScale: SCALE });
|
||||
await expect
|
||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', {
|
||||
timeout: 120_000,
|
||||
message: 'waiting for an in-flight plane-starter to land',
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
const starterLoc = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao;
|
||||
if (starterLoc !== HUB) {
|
||||
await flyContract(page, starterLoc, HUB);
|
||||
}
|
||||
|
||||
// 1) Accept the hook-created contract — before dispatch it must draw a PENDING route line.
|
||||
const contract = await createContract(page, HUB, DEST);
|
||||
const acc = await page.request.post(`/api/contracts/${contract.id}/accept`);
|
||||
expect(acc.status()).toBe(200);
|
||||
|
||||
// The map updates from the shell's /api/state polls — poll until the pending line lands.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const feats = (await sourceFeatures(page, 'routes')) as Array<{ properties: Record<string, unknown> }>;
|
||||
return feats.some((f) => f.properties.contractId === contract.id && f.properties.kind === 'pending');
|
||||
}, { timeout: 15_000, message: 'waiting for the pending route line in the routes source' })
|
||||
.toBe(true);
|
||||
|
||||
// 2) Dispatch plane-starter and speed up — the marker must appear as a flying feature.
|
||||
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 feats = (await sourceFeatures(page, 'planes')) as Array<{ properties: Record<string, unknown> }>;
|
||||
return feats.some((f) => f.properties.id === 'plane-starter' && f.properties.flying === true);
|
||||
}, { timeout: 15_000, message: 'waiting for the flying plane feature in the planes source' })
|
||||
.toBe(true);
|
||||
|
||||
// 3) Position CHANGES across two /api/state polls while flying (server computes
|
||||
// lat/lon analytically each tick; the map just renders the polled values).
|
||||
const p1 = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!;
|
||||
expect(p1.status).toBe('flying');
|
||||
await sleep(1500);
|
||||
const p2 = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!;
|
||||
expect(haversineKm(p1.lat, p1.lon, p2.lat, p2.lon)).toBeGreaterThan(0);
|
||||
|
||||
// 4) The route line for this contract is now IN-FLIGHT (solid layer), not pending.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const feats = (await sourceFeatures(page, 'routes')) as Array<{ properties: Record<string, unknown> }>;
|
||||
return feats.some((f) => f.properties.contractId === contract.id && f.properties.kind === 'inflight');
|
||||
}, { timeout: 15_000, message: 'waiting for the in-flight route line' })
|
||||
.toBe(true);
|
||||
|
||||
// 5) Click the flying plane → status popup with model, status, fuel and the route.
|
||||
// Fetch + project in one evaluate so the click lands on a fresh position (the
|
||||
// marker drifts only ~1 px/s of screen space at scale 60).
|
||||
const pt = await page.evaluate(async () => {
|
||||
const m = (window as any).__flightMap;
|
||||
if (!m) return null;
|
||||
const res = await fetch('/api/state', { cache: 'no-store' });
|
||||
const body = (await res.json()) as StateBody;
|
||||
const p = body.planes.find((x) => x.id === 'plane-starter');
|
||||
if (!p || p.status !== 'flying') return null;
|
||||
const projected = m.project([p.lon, p.lat]);
|
||||
const rect = m.getContainer().getBoundingClientRect();
|
||||
return { x: rect.left + projected.x, y: rect.top + projected.y };
|
||||
});
|
||||
expect(pt).not.toBeNull(); // the flight (~33 s at scale 60) is still in progress here
|
||||
|
||||
await page.mouse.click(pt!.x, pt!.y);
|
||||
const popup = page.getByTestId('plane-popup');
|
||||
await expect(popup).toBeVisible({ timeout: 5_000 });
|
||||
await expect(popup).toContainText('ATR 72-600'); // catalog label via the snapshot
|
||||
await expect(page.getByTestId('plane-status')).toHaveText('flying');
|
||||
await expect(page.getByTestId('plane-fuel')).toHaveText(/\d+ \/ \d+ L/);
|
||||
|
||||
const live = (await getState(page)).contracts.find((c) => c.id === contract.id)!;
|
||||
await expect(page.getByTestId('plane-route')).toContainText(`${live.originName} → ${live.destName}`);
|
||||
|
||||
// 6) Let the flight finish, then fly home so the live instance stays in its
|
||||
// onboarding state (starter grounded at EDDF) for the other specs.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||
return !!p && p.status === 'available' && p.locationIcao === DEST;
|
||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` })
|
||||
.toBe(true);
|
||||
|
||||
await flyContract(page, DEST, HUB);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { approxLocalTime, approxZoneForLon } from './localTime';
|
||||
|
||||
describe('approxZoneForLon', () => {
|
||||
it('maps longitude to the Etc/GMT±N zone with the POSIX sign flip', () => {
|
||||
// UTC+1 (lon +15) is "Etc/GMT-1"; UTC−2 (lon −30) is "Etc/GMT2".
|
||||
expect(approxZoneForLon(15)).toBe('Etc/GMT-1');
|
||||
expect(approxZoneForLon(-30)).toBe('Etc/GMT2');
|
||||
expect(approxZoneForLon(0)).toBe('Etc/GMT');
|
||||
});
|
||||
|
||||
it('rounds to the nearest 15° band and clamps at ±12', () => {
|
||||
expect(approxZoneForLon(10)).toBe('Etc/GMT-1'); // Frankfurt ≈ UTC+1
|
||||
expect(approxZoneForLon(-6.2)).toBe('Etc/GMT'); // Lisbon: round(-0.41) = 0 → plain Etc/GMT (UTC)
|
||||
expect(approxZoneForLon(179)).toBe('Etc/GMT-12');
|
||||
expect(approxZoneForLon(-179)).toBe('Etc/GMT12');
|
||||
});
|
||||
});
|
||||
|
||||
describe('approxLocalTime', () => {
|
||||
const noonUtc = Date.UTC(2026, 8, 17, 12, 0); // 2026-09-17 12:00 UTC
|
||||
|
||||
it('formats HH:MM in the approximate zone (deterministic)', () => {
|
||||
expect(approxLocalTime(15, noonUtc)).toBe('13:00'); // east of Greenwich → UTC+1
|
||||
expect(approxLocalTime(-30, noonUtc)).toBe('10:00'); // west of Greenwich → UTC−2
|
||||
expect(approxLocalTime(0, noonUtc)).toBe('12:00'); // UTC
|
||||
});
|
||||
|
||||
it('wraps across midnight', () => {
|
||||
const lateUtc = Date.UTC(2026, 8, 17, 23, 30);
|
||||
expect(approxLocalTime(45, lateUtc)).toBe('02:30'); // UTC+3 → next day
|
||||
const earlyUtc = Date.UTC(2026, 8, 17, 1, 30);
|
||||
expect(approxLocalTime(-45, earlyUtc)).toBe('22:30'); // UTC−3 → previous day
|
||||
});
|
||||
|
||||
it('always yields a two-digit HH:MM string', () => {
|
||||
for (const lon of [-170, -6.2, 8.56, 45, 179]) {
|
||||
expect(approxLocalTime(lon, noonUtc)).toMatch(/^\d{2}:\d{2}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
// Approximate local wall-clock from longitude (v0.11.0) — display-only airport
|
||||
// popup field. The zone is the fixed-offset band N = round(lon/15), no DST and no
|
||||
// political boundaries — good enough for a game map, labelled approximate in the UI.
|
||||
|
||||
// Etc/GMT±N name for the band (POSIX sign flip: UTC+1 is "Etc/GMT-1"). Exported as
|
||||
// the documented pure mapping; approxLocalTime below does NOT pass it to Intl,
|
||||
// because some runtimes ship a reduced ICU that lacks the positive-offset zones.
|
||||
export function approxZoneForLon(lonDeg: number): string {
|
||||
const n = Math.max(-12, Math.min(12, Math.round(lonDeg / 15)));
|
||||
return `Etc/GMT${n === 0 ? '' : -n}`;
|
||||
}
|
||||
|
||||
// "HH:MM" in the approximate zone at epoch ms. Implemented as a UTC-format of a
|
||||
// shifted timestamp (UTC + N hours) so it works on every ICU build, including
|
||||
// small-icu runtimes missing Etc/GMT+N zones.
|
||||
export function approxLocalTime(lonDeg: number, atMs: number): string {
|
||||
const n = Math.max(-12, Math.min(12, Math.round(lonDeg / 15)));
|
||||
const shifted = new Date(atMs + n * 3_600_000);
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: 'UTC',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).format(shifted);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { StateSnapshot } from './game/state';
|
||||
import { planesGeoJson, routesGeoJson, type AirportCoord } from './mapData';
|
||||
|
||||
const AIRPORTS = new Map<string, AirportCoord>([
|
||||
['EDDF', { lat: 50.03, lon: 8.56 }],
|
||||
['EDDV', { lat: 52.46, lon: 9.68 }],
|
||||
['EHAM', { lat: 52.31, lon: 4.76 }],
|
||||
]);
|
||||
|
||||
function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot {
|
||||
return {
|
||||
account: { cash: 0, reputation: 0 },
|
||||
simState: { clockMs: 0, timeScale: 1, paused: false },
|
||||
planes: [],
|
||||
contracts: [],
|
||||
catalog: [{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, speedKmh: 510 }],
|
||||
fuelPricePerL: 0.8,
|
||||
sellFraction: 0.5,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('planesGeoJson', () => {
|
||||
it('returns an empty collection for a null snapshot', () => {
|
||||
expect(planesGeoJson(null)).toEqual({ type: 'FeatureCollection', features: [] });
|
||||
});
|
||||
|
||||
it('emits one Point per plane at its polled lat/lon with popup properties', () => {
|
||||
const fc = planesGeoJson(
|
||||
snapshot({
|
||||
planes: [
|
||||
{ id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', contractId: null, departureSimTime: null, lat: 50.03, lon: 8.56 },
|
||||
{ id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', contractId: 'c1', departureSimTime: 1000, lat: 51.2, lon: 9.1 },
|
||||
],
|
||||
contracts: [
|
||||
{ id: 'c1', originIcao: 'EDDF', originName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', reward: 100, cargoType: 'mail', cargoCount: 5, deadlineSimTime: 9e8, status: 'accepted', planeId: 'plane-2', createdSimTime: 0 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(fc.features).toHaveLength(2);
|
||||
const grounded = fc.features[0];
|
||||
expect(grounded.geometry.coordinates).toEqual([8.56, 50.03]); // [lon, lat]
|
||||
expect(grounded.properties.flying).toBe(false);
|
||||
expect(grounded.properties.modelLabel).toBe('ATR 72-600'); // catalog label, not raw model id
|
||||
expect(grounded.properties.fuelText).toBe('6300 / 6300 L');
|
||||
expect(grounded.properties.routeText).toBe('');
|
||||
|
||||
const flying = fc.features[1];
|
||||
expect(flying.geometry.coordinates).toEqual([9.1, 51.2]); // live polled position
|
||||
expect(flying.properties.flying).toBe(true);
|
||||
expect(flying.properties.status).toBe('flying');
|
||||
expect(flying.properties.fuelText).toBe('4000 / 6300 L');
|
||||
expect(flying.properties.routeText).toBe('Frankfurt Main Airport → Hannover Airport');
|
||||
});
|
||||
|
||||
it('falls back to the raw model id when the catalog has no entry', () => {
|
||||
const fc = planesGeoJson(
|
||||
snapshot({
|
||||
catalog: [],
|
||||
planes: [
|
||||
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', contractId: null, departureSimTime: null, lat: 0, lon: 0 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(fc.features[0].properties.modelLabel).toBe('mystery');
|
||||
});
|
||||
|
||||
it('leaves routeText empty for a flying plane whose contract is missing from the snapshot', () => {
|
||||
const fc = planesGeoJson(
|
||||
snapshot({
|
||||
planes: [
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', contractId: 'gone', departureSimTime: 0, lat: 0, lon: 0 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(fc.features[0].properties.routeText).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('routesGeoJson', () => {
|
||||
it('returns an empty collection for a null snapshot', () => {
|
||||
expect(routesGeoJson(null, AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
|
||||
});
|
||||
|
||||
it('draws nothing for open/completed/expired contracts', () => {
|
||||
const mk = (status: string) => ({ id: `c-${status}`, originIcao: 'EDDF', originName: 'F', destIcao: 'EDDV', destName: 'H', reward: 1, cargoType: 'mail', cargoCount: 1, deadlineSimTime: 9e8, status, planeId: null, createdSimTime: 0 });
|
||||
const fc = routesGeoJson(
|
||||
snapshot({ contracts: [mk('open'), mk('completed'), mk('expired')] as StateSnapshot['contracts'] }),
|
||||
AIRPORTS,
|
||||
);
|
||||
expect(fc.features).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accepted-but-undispatched contract → pending line origin→dest', () => {
|
||||
const fc = routesGeoJson(
|
||||
snapshot({
|
||||
contracts: [
|
||||
{ id: 'c1', originIcao: 'EDDF', originName: 'F', destIcao: 'EHAM', destName: 'A', reward: 1, cargoType: 'mail', cargoCount: 1, deadlineSimTime: 9e8, status: 'accepted', planeId: null, createdSimTime: 0 },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
);
|
||||
expect(fc.features).toHaveLength(1);
|
||||
expect(fc.features[0].properties).toEqual({ contractId: 'c1', kind: 'pending' });
|
||||
expect(fc.features[0].geometry.coordinates).toEqual([
|
||||
[8.56, 50.03], // EDDF [lon, lat]
|
||||
[4.76, 52.31], // EHAM
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepted + plane flying → inflight line', () => {
|
||||
const fc = routesGeoJson(
|
||||
snapshot({
|
||||
planes: [
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', contractId: 'c1', departureSimTime: 0, lat: 51, lon: 9 },
|
||||
],
|
||||
contracts: [
|
||||
{ id: 'c1', originIcao: 'EDDF', originName: 'F', destIcao: 'EDDV', destName: 'H', reward: 1, cargoType: 'mail', cargoCount: 1, deadlineSimTime: 9e8, status: 'accepted', planeId: 'p1', createdSimTime: 0 },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
);
|
||||
expect(fc.features).toHaveLength(1);
|
||||
expect(fc.features[0].properties.kind).toBe('inflight');
|
||||
});
|
||||
|
||||
it('accepted + planeId set but plane grounded → still pending (defensive)', () => {
|
||||
const fc = routesGeoJson(
|
||||
snapshot({
|
||||
planes: [
|
||||
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', contractId: null, departureSimTime: null, lat: 50.03, lon: 8.56 },
|
||||
],
|
||||
contracts: [
|
||||
{ id: 'c1', originIcao: 'EDDF', originName: 'F', destIcao: 'EDDV', destName: 'H', reward: 1, cargoType: 'mail', cargoCount: 1, deadlineSimTime: 9e8, status: 'accepted', planeId: 'p1', createdSimTime: 0 },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
);
|
||||
expect(fc.features[0].properties.kind).toBe('pending');
|
||||
});
|
||||
|
||||
it('skips a contract whose endpoint is missing from the airport dataset', () => {
|
||||
const fc = routesGeoJson(
|
||||
snapshot({
|
||||
contracts: [
|
||||
{ id: 'c1', originIcao: 'ZZZZ', originName: '?', destIcao: 'EDDV', destName: 'H', reward: 1, cargoType: 'mail', cargoCount: 1, deadlineSimTime: 9e8, status: 'accepted', planeId: null, createdSimTime: 0 },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
);
|
||||
expect(fc.features).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import type { StateSnapshot } from './game/state';
|
||||
import { formatFuel } from './format';
|
||||
|
||||
// Pure GeoJSON builders for the map's dynamic layers (v0.11.0): one Point feature
|
||||
// per plane, one LineString per accepted/in-flight contract. Kept out of
|
||||
// components/Map.tsx so the classification logic is unit-testable without a DOM.
|
||||
|
||||
export interface PlaneFeatureProps {
|
||||
id: string;
|
||||
modelLabel: string;
|
||||
status: 'available' | 'flying';
|
||||
fuelText: string; // "1234 / 6300 L"
|
||||
routeText: string; // "Origin → Destination" while flying, '' when grounded
|
||||
flying: boolean;
|
||||
}
|
||||
|
||||
export interface RouteFeatureProps {
|
||||
contractId: string;
|
||||
kind: 'pending' | 'inflight';
|
||||
}
|
||||
|
||||
export interface MapPointFeature {
|
||||
type: 'Feature';
|
||||
geometry: { type: 'Point'; coordinates: [number, number] };
|
||||
properties: PlaneFeatureProps;
|
||||
}
|
||||
|
||||
export interface MapLineFeature {
|
||||
type: 'Feature';
|
||||
geometry: { type: 'LineString'; coordinates: [number, number][] };
|
||||
properties: RouteFeatureProps;
|
||||
}
|
||||
|
||||
export interface FeatureCollection<T> {
|
||||
type: 'FeatureCollection';
|
||||
features: T[];
|
||||
}
|
||||
|
||||
const EMPTY_POINTS: FeatureCollection<MapPointFeature> = { type: 'FeatureCollection', features: [] };
|
||||
const EMPTY_LINES: FeatureCollection<MapLineFeature> = { type: 'FeatureCollection', features: [] };
|
||||
|
||||
export function planesGeoJson(state: StateSnapshot | null): FeatureCollection<MapPointFeature> {
|
||||
if (!state) return EMPTY_POINTS;
|
||||
const contractById = new Map(state.contracts.map((c) => [c.id, c]));
|
||||
// Catalog display name ("ATR 72-600") for the popup — raw model id as fallback.
|
||||
const labelByModel = new Map(state.catalog.map((e) => [e.model, e.label]));
|
||||
const features: MapPointFeature[] = state.planes.map((p) => {
|
||||
const flying = p.status === 'flying';
|
||||
const contract = p.contractId ? contractById.get(p.contractId) : undefined;
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [p.lon, p.lat] },
|
||||
properties: {
|
||||
id: p.id,
|
||||
modelLabel: labelByModel.get(p.model) ?? p.model,
|
||||
status: p.status,
|
||||
fuelText: formatFuel(p.fuel, p.maxFuel),
|
||||
routeText: flying && contract ? `${contract.originName} → ${contract.destName}` : '',
|
||||
flying,
|
||||
},
|
||||
};
|
||||
});
|
||||
return { type: 'FeatureCollection', features };
|
||||
}
|
||||
|
||||
// Airport coordinate lookup for route endpoints (from the /api/airports GeoJSON).
|
||||
export interface AirportCoord {
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
// One LineString per contract that is accepted but not yet dispatched ('pending')
|
||||
// or already flying ('inflight' — planeId set and that plane's status is 'flying').
|
||||
// Open/completed/expired contracts draw nothing.
|
||||
export function routesGeoJson(
|
||||
state: StateSnapshot | null,
|
||||
airportsByIcao: ReadonlyMap<string, AirportCoord>,
|
||||
): FeatureCollection<MapLineFeature> {
|
||||
if (!state) return EMPTY_LINES;
|
||||
const planeById = new Map(state.planes.map((p) => [p.id, p]));
|
||||
const features: MapLineFeature[] = [];
|
||||
for (const c of state.contracts) {
|
||||
if (c.status !== 'accepted') continue;
|
||||
const from = airportsByIcao.get(c.originIcao);
|
||||
const to = airportsByIcao.get(c.destIcao);
|
||||
if (!from || !to) continue; // endpoint missing from the dataset — skip, don't guess
|
||||
const plane = c.planeId ? planeById.get(c.planeId) : undefined;
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[from.lon, from.lat],
|
||||
[to.lon, to.lat],
|
||||
],
|
||||
},
|
||||
properties: { contractId: c.id, kind: plane?.status === 'flying' ? 'inflight' : 'pending' },
|
||||
});
|
||||
}
|
||||
return { type: 'FeatureCollection', features };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { weatherFor, type AirportWeather } from './weather';
|
||||
|
||||
const CONDITIONS = ['Clear', 'Cloudy', 'Rain', 'Fog'];
|
||||
|
||||
describe('weatherFor', () => {
|
||||
it('is deterministic: same ICAO → identical weather on every call', () => {
|
||||
const a = weatherFor('EDDF');
|
||||
for (let i = 0; i < 5; i++) expect(weatherFor('EDDF')).toEqual(a);
|
||||
});
|
||||
|
||||
it('different ICACOs mostly produce different weather', () => {
|
||||
const icaos = ['EDDF', 'EHAM', 'LFPG', 'LEMD', 'LIRF', 'EKCH', 'LSGG', 'BIAR', 'LFRS', 'EDDV'];
|
||||
const seen = new Set(icaos.map((i) => JSON.stringify(weatherFor(i))));
|
||||
// 10 airports, 4 conditions × many wind/visibility values — collisions are vanishingly unlikely.
|
||||
expect(seen.size).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
it('keeps every value in a sane range', () => {
|
||||
for (const icao of ['EDDF', 'EHAM', 'LFPG', 'LEMD', 'LIRF', 'EKCH', 'LSGG', 'BIAR']) {
|
||||
const w = weatherFor(icao);
|
||||
expect(CONDITIONS).toContain(w.condition);
|
||||
expect(w.windDirDeg).toBeGreaterThanOrEqual(0);
|
||||
expect(w.windDirDeg).toBeLessThan(360);
|
||||
expect(Number.isInteger(w.windDirDeg)).toBe(true);
|
||||
expect(w.windKt).toBeGreaterThanOrEqual(2);
|
||||
expect(w.windKt).toBeLessThanOrEqual(30);
|
||||
expect(w.visibilityM).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('fog and rain reduce visibility relative to clear skies', () => {
|
||||
// Find one airport per condition by scanning a fixed set (deterministic data).
|
||||
const byCondition = new Map<string, AirportWeather>();
|
||||
for (let i = 0; i < 200 && byCondition.size < CONDITIONS.length; i++) {
|
||||
const w = weatherFor(`TEST${i}`);
|
||||
if (!byCondition.has(w.condition)) byCondition.set(w.condition, w);
|
||||
}
|
||||
expect(byCondition.size).toBe(CONDITIONS.length);
|
||||
const fog = byCondition.get('Fog')!;
|
||||
const rain = byCondition.get('Rain')!;
|
||||
const clear = byCondition.get('Clear')!;
|
||||
expect(fog.visibilityM).toBeLessThan(2500); // 0.5–2 km band
|
||||
expect(rain.visibilityM).toBeGreaterThanOrEqual(4000);
|
||||
expect(rain.visibilityM).toBeLessThanOrEqual(10_000);
|
||||
expect(clear.visibilityM).toBeGreaterThanOrEqual(8000);
|
||||
});
|
||||
|
||||
it('dayKey: same day is deterministic, and some airports do change across days', () => {
|
||||
const icaos = ['EDDF', 'EHAM', 'LFPG', 'LEMD', 'LIRF'];
|
||||
for (const icao of icaos) {
|
||||
expect(weatherFor(icao, 3)).toEqual(weatherFor(icao, 3));
|
||||
}
|
||||
let changed = 0;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const a = weatherFor(`TEST${i}`, 1);
|
||||
const b = weatherFor(`TEST${i}`, 2);
|
||||
if (JSON.stringify(a) !== JSON.stringify(b)) changed++;
|
||||
}
|
||||
expect(changed).toBeGreaterThan(0); // the dayKey actually feeds the seed
|
||||
});
|
||||
|
||||
it('no dayKey is stable and independent of any later dayKey argument', () => {
|
||||
const w = weatherFor('EDDF');
|
||||
expect(weatherFor('EDDF')).toEqual(w);
|
||||
// A defined dayKey may differ from the default — that is allowed, not asserted.
|
||||
expect(CONDITIONS).toContain(weatherFor('EDDF', 0).condition);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { mulberry32 } from './game/seed';
|
||||
|
||||
// Pure deterministic per-airport weather (v0.11.0) — display-only per PLAN.md, no
|
||||
// gameplay effects. The same ICAO always yields the same weather (stable across
|
||||
// polls and sessions); an optional dayKey varies it by sim-day when wanted.
|
||||
|
||||
export type WeatherCondition = 'Clear' | 'Cloudy' | 'Rain' | 'Fog';
|
||||
|
||||
export interface AirportWeather {
|
||||
condition: WeatherCondition;
|
||||
// Meteorological convention: direction the wind blows FROM, degrees true [0, 360).
|
||||
windDirDeg: number;
|
||||
windKt: number;
|
||||
visibilityM: number;
|
||||
}
|
||||
|
||||
// FNV-1a 32-bit — cheap, stable string hash (no Math.random, no Date anywhere).
|
||||
function fnv1a(s: string): number {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
export function weatherFor(icao: string, dayKey?: number): AirportWeather {
|
||||
const seed = (fnv1a(icao) ^ (dayKey === undefined ? 0 : Math.imul(dayKey | 0, 0x9e3779b9))) >>> 0;
|
||||
const rng = mulberry32(seed);
|
||||
|
||||
const c = rng();
|
||||
let condition: WeatherCondition;
|
||||
if (c < 0.45) condition = 'Clear';
|
||||
else if (c < 0.75) condition = 'Cloudy';
|
||||
else if (c < 0.92) condition = 'Rain';
|
||||
else condition = 'Fog';
|
||||
|
||||
const windDirDeg = Math.floor(rng() * 360);
|
||||
const windKt = Math.round(2 + rng() * 28); // 2–30 kt
|
||||
|
||||
let visibilityM: number;
|
||||
if (condition === 'Fog') visibilityM = 500 + Math.floor(rng() * 1500); // 0.5–2 km
|
||||
else if (condition === 'Rain') visibilityM = 4000 + Math.floor(rng() * 6000); // 4–10 km
|
||||
else visibilityM = 8000 + Math.floor(rng() * 32000); // 8–40 km
|
||||
|
||||
return { condition, windDirDeg, windKt, visibilityM };
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flight-ops",
|
||||
"version": "0.10.1",
|
||||
"version": "0.11.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user