34 KiB
34 KiB
Changelog
v0.8.0 — 2026-09-17 13:54
- Player-actions API (PLAN.md "Core loop" increment, server-side only — NO frontend/UI changes): the core loop now works end-to-end through five new endpoints in
lib/game/actions.ts+app/api/. Every action endpoint follows one convention: tick the sim clock FIRST (so validation/mutation see current game time), validate, mutate, persist atomically under the same promise lock as ticks (engine.runAction()— no request can interleave between tick and mutation), then return the FULL state snapshot (same shape as GET /api/state) with 200 so clients never need a follow-up poll; errors are proper 4xx{error}bodies - Accept:
POST /api/contracts/:id/accept— open → accepted only. 404 unknown id, 409 if the contract is not 'open' (accepted/completed/expired). Single-player, so no ownership tracking beyond the status flip; dispatch binds the plane - Dispatch:
POST /api/planes/:planeId/dispatchbody{contractId}— validates in order: contract exists & status 'accepted' (409 otherwise), plane exists & 'available',plane.locationIcao === contract.originIcao(409 "not at origin"), fuel sufficiencyburnPerKm × haversineKm(origin,dest) ≤ plane.fuel(409 "insufficient fuel"); unknown ids → 404. On success sets the plane 'flying' withdepartureSimTime = current clockMsand lat/lon pinned to the origin — the v0.7.0 finalize path then flies it analytically and pays out on arrival, unchanged - Market:
POST /api/market/buy-planebody{model}(400 unknown catalog model; 409 insufficient funds) deducts the full price and delivers a full-tank 'available' plane at home base EDDF;POST /api/market/sell-planebody{planeId}(409 if flying, 409 for the last remaining plane — the company always keeps ≥1 aircraft) pays out 50% of catalog price and deletes the row. Catalog gainsprice: 200_000on atr72 (with €20k starting cash and ~€1–3k hop rewards, a second ATR takes sustained play — the intended MVP money sink), plus constantsFUEL_PRICE_PER_L = 0.8€/L andSELL_FRACTION = 0.5 - Refuel:
POST /api/planes/:planeId/refuel(no body) — grounded planes only (409 if flying); cost(maxFuel − fuel) × FUEL_PRICE_PER_L, cash-checked (409), fills to max; a full tank is a free no-op - Spawn coverage rule (
engine.spawnCoverageContracts, in-tick after top-up + finalization): in addition to topping the market up to 10 random open contracts, every airport that currently has a grounded 'available' plane must have ≥1 OPEN contract originating there — one generated per uncovered airport viagenerateContractwith origin forced. Guarantees ongoing work wherever the fleet sits (including onboarding at EDDF), and because it runs after finalization a plane that just landed gets work spawned at its new location in the same tick; flying planes don't count generateContractgains optionaloriginIcao/destIcaoto force endpoints (used by the coverage rule + test hook); forced endpoints consume no RNG draws, so existing callers are byte-identical (a random origin landing on a forced destination is shifted, mirroring the unforced collision handling)- Snapshot gains
planes[].departureSimTime(null when grounded; sim-clock ms set by dispatch while flying) so clients can compute progress/ETA from the clock — documented in OpenAPI PlaneState - Test hook generalized: new
POST /api/test/create-contractbody{originIcao?, destIcao?, deadlineOffsetMs?}creates an open contract on a known route (400 unknown ICAO/identical endpoints; default 6–72h window), sibling of/api/test/expire-contractunder the sameFLIGHT_TEST_HOOKS=1gate — lets e2e exercise the full loop on a short, deterministic route - OpenAPI: all five action endpoints documented with request schemas (DispatchRequest/BuyPlaneRequest/SellPlaneRequest), path parameters, per-endpoint 4xx error shapes and the StateSnapshot success shape;
lib/openapi.test.ts+e2e/api.spec.tsendpoint lists updated four → nine - Tests: unit — new
lib/game/actions.test.ts(full validation matrix: accept non-open/unknown; dispatch wrong-status ×3, unknown ids, flying plane, not-at-origin, insufficient fuel, success state incl. frozen departure fuel; buy unknown-model/poor/success with home-base delivery; sell flying/last-plane/unknown/success payout+delete; refuel cost math/full-tank no-op/flying/poor/unknown),lib/sim/engine.test.tsgains spawn-coverage tests (uncovered grounded airport ⇒ forced-origin contract, already-covered no-op, multi-airport dedupe, flying planes ignored, full tickOnce pipeline) andlib/game/seed.test.tsthe forced-endpoint matrix; new colocated route tests for all five endpoints (HTTP status codes + snapshot response convention against temp DBs); e2e — newe2e/actions.spec.ts: THE full loop on the live origin (self-healing starter plane, hook-created EDDF→closest-airport contract, accept → dispatch with departure set and position moving across polls at scale 60 → arrival paying the EXACT reward + rep gain with analytic remaining fuel → refuel cost deduction → fly home to restore onboarding state) plus the dispatch-without-accept 409 / unknown-id 404 path - Verified: unit suite 86 → 143 green;
tsc --noEmitclean; e2e 16 → 18 green against the rebuilt compose instance (full loop measured at scale 60); live curl of dispatch + arrival shows the exact cash/rep delta
v0.7.0 — 2026-09-17 12:27
- Server-authoritative sim engine (PLAN.md "Simulation engine" increment 2) in new
lib/sim/— no frontend/UI changes; dispatch of planes lands next increment, but all the clock/market/finalization machinery it builds on is live and tested now - Tick-on-read game clock (
lib/sim/engine.ts, NO background timer): every read/action endpoint callstickNow()first (GET /api/state, POST /api/sim — and any future action endpoint); the tick advancesclockMsby(real ms since last tick) × timeScale. Newsim_state.last_real_tick_mscolumn stores the real-time anchor (seed sets it to the seed moment). Clamp:MAX_TICK_ELAPSED_MS = 2000— a single tick counts at most 2 s of real absence, chosen above a normal ~1 s poll interval (active play advances ~real time × scale) but small enough that one stale read after an absence moves game time by ≤ 2 s × scale (≤ 2 min at max scale 60), so the sim only meaningfully progresses while actively polled. While paused the clock is frozen butlastRealTickMskeeps tracking real time, so unpausing resumes with no catch-up jump. Ticks are serialized through a module-level promise lock (concurrent Next.js requests can't double-advance) and each tick persists via the existing sql.js atomic export - Analytic movement (
lib/sim/movement.ts, pure functions of the sim clock — no incremental stepping):travelDurationMs(distanceKm, speedKmh),arrivalTimeFor(departureSimTime, distanceKm, speedKmh),progressAt(...)(clamped 0–1),positionAt(fromLat, fromLon, toLat, toLon, t)(great-circle slerp — exact endpoints, stays on the arc),fuelAt(startFuel, burnPerKm, distanceKm, progress)(start − burn·distanceSoFar, clamped at 0; range exhaustion not modeled yet). New nullableplanes.departure_sim_timecolumn carries the flight context (route comes from the bound contract, speed/burn from the catalog by model); while flying,fuelis frozen at its departure value and GET /api/state reports live lat/lon/fuel computed from the clock - Arrival finalization (in-tick): a flying plane whose
arrivalTime ≤ clockgets its contract marked completed, the exact reward added toaccount.cash, reputation +=1 + 0.0005 × km(rounded to 2 dp — ~1.15 rep for a 300 km hop, ~2.5 for a 3000 km leg; slow growth from the starting 10), and the plane grounded at the destination with remaining fuel +departureSimTime = null. Idempotent by construction: finalization leaves the 'flying' state, so an award can never be paid twice (unit-tested) - Contract expiry (in-tick): any
opencontract withdeadlineSimTime ≤ clockMs→ status'expired'(new enum value in schema/DDL/snapshot/OpenAPI); accepted/in-flight contracts never expire — they complete. Expired contracts leave the available set the client sees - Market spawn (in-tick, after expiry): if open contracts <
MARKET_TARGET_OPEN_CONTRACTS = 10, new ones are generated to top it up using the SAME builder as the first-run seed —generateContract({id, airports, rng, nowMs, deadlineOffsetMs?})extracted frombuildSeed(same RNG call order, reward formula, cargo flavor, 6–72h game-time deadlines; spawn uses crypto-random UUID ids and the sim clock as "now"). Injectable RNG for deterministic tests - Time controls endpoint: new
POST /api/sim— body{ timeScale?: number, paused?: boolean }; unknown fields / non-number timeScale / non-boolean paused → 400; timeScale clamped to [1, 60]; ticks the clock first so the returned simState already reflects current game time. Documented inlib/openapi.ts(new SimControlsRequest + Error component schemas; ContractState status enum gains 'expired'; /api/state description notes tick-on-read) - DB migration for existing installs:
openGameDb()now runs a guardedALTER TABLE ADD COLUMNstep (PRAGMA table_info check) addingplanes.departure_sim_timeandsim_state.last_real_tick_msto pre-v0.7.0 files, then fixes up the ALTER-defaultlast_real_tick_ms = 0to "now" so old DBs resume cleanly instead of taking one clamped catch-up jump - e2e test hook (test-only affordance): new
POST /api/test/expire-contract, enabled ONLY whenFLIGHT_TEST_HOOKS=1(set in docker-compose.yml for the e2e instance; 404 everywhere else) — with{contractId}it moves that open contract's deadline ~10 game-seconds out, without a body it creates a fresh short-deadline open contract; lets the suite observe expiry at scale 60 instead of waiting hours - Tests: unit — new
lib/sim/movement.test.ts(duration/arrival math, progress boundaries incl. just-before-arrival, slerp endpoints/on-arc/equator-exact/same-point, fuel burn + zero clamp) andlib/sim/engine.test.ts(fake-timer clock advance × timeScale, pause freeze + clean resume, 1-hour-gap clamp to exactly one tick, same-ms no-op; expiry flips only open past-deadline contracts incl. the inclusive edge; finalize awards cash+rep exactly once with right remaining fuel and does nothing pre-arrival or without route info; spawn tops up to target via the shared builder — deterministic for an injected RNG);lib/game/seed.test.tsgains generateContract coverage (formula, window, determinism, deadlineOffsetMs, buildSeed ≡ builder output) and the simState assertion now includes lastRealTickMs; newapp/api/sim/route.test.ts(200 shape, clamp to [1,60], pause freeze across ticks, 400 matrix);lib/db/client.test.tsgains a legacy-DB migration test; e2e — newe2e/sim.spec.ts: scale-60 clock advance bounded per tick by the clamp, exact freeze while paused, expiry of a hook-shortened deadline + market staying ≥ target (both mutate and create hook modes), POST /api/sim validation/clamping - Known interaction with v0.6.0 e2e:
e2e/state.spec.tsrestart-persistence test previously asserted the sim clock was byte-identical across a container restart — impossible under tick-on-read by design (every GET advances ≤ one clamped tick). The assertion now checks persistence properly: account/planes/contracts still byte-identical, and post-restartclockMs ≥ beforebounded byMAX_TICK_ELAPSED_MS × timeScale.e2e/api.spec.tsendpoint list updated to four - Verified: unit suite 47 → 86 green;
tsc --noEmitclean; e2e 11 → 16 green against the rebuilt compose instance (incl. restart-persistence); live curl of POST /api/sim + GET /api/state shows the clock advancing at scale 60 then frozen on pause
v0.6.0 — 2026-09-17 11:49
- Data foundation (PLAN.md "Simulation engine" increment 1): Drizzle ORM + SQLite via sql.js (WASM) — chosen over better-sqlite3 because it needs zero native compilation, so the existing oven/bun-alpine install stages and node:22-alpine runtime work unchanged; deps
drizzle-orm@0.45,sql.js@1.14(+ dev-only@types/sql.js) - Schema (
lib/db/schema.ts, DDL mirrored inlib/db/client.ts):planes(id, model, status available|flying, fuel/maxFuel, locationIcao, contractId?, lat/lon),contracts(id, origin/dest ICAO, reward, cargoType/cargoCount, deadlineSimTime, status open|accepted|completed, planeId?, createdSimTime), single-rowaccount(cash, reputation) andsim_state(clockMs, timeScale 1–60, paused) - First-run seed (
lib/game/seed.ts) — purebuildSeed({airports, rng, nowMs}), fully deterministic with an injected RNG (mulberry32 PRNG exported for tests): starter plane ATR 72-600 (in-code catalog constant inlib/game/catalog.ts: maxFuel 6300 L, burnPerKm 1.75 L/km, speedKmh 510 — structured so more types can be added) grounded full-tank at Frankfurt (EDDF; EDDM is Munich), 8–12 open contracts between random distinct CSV airports with rewardround(500 + 2.5 · haversineKm)(documented simple formula; real revenue/cost modeling is a later increment), cargo flavor (Passengers ≤68 / pallets ≤50), deadline = now + 6–72h game time; account starts at cash 20 000 / reputation 10; simState clockMs = real now, timeScale 1, paused false - Persistence: DB file
db/flight.dbon the host (gitignored) volume-mounted in docker-compose as./db:/app/db— a dedicated dir, NOT over/app/data(which would mask airports_eu.csv); sql.js keeps the DB in memory, sopersist()exports the bytes and writes them atomically (tmp file + rename) after every mutation; seeding happens lazily on first access when the account table is empty;FLIGHT_DB_PATHenv override lets tests point at temp files - New read-only
GET /api/state(app/api/state/route.ts): full snapshot{account, simState, planes[], contracts[]}for client polling — plane/contract shapes include airport names (locationName / originName / destName) resolved from the CSV; no tick loop yet, state is static after seed - OpenAPI:
/api/statedocumented with component schemas StateSnapshot/AccountState/SimState/PlaneState/ContractState (lib/openapi.ts) next.config.ts:serverExternalPackages: ['sql.js']— the WASM blob resolves relative to sql.js's dist dir, so it must not be bundled into a .next chunk; known drizzle-orm 0.45.x quirk worked around in code (commented): the sql-js driver drops the result mapper for relationaldb.query.*queries and returns raw snake_case rows, so all reads use plainselect()- Tests: unit — new
lib/geo.test.ts(haversine),lib/game/seed.test.ts(determinism with fixed RNG, 8–12 count, distinct endpoints, reward formula vs haversine, deadline window, hub + fallback, account/simState values),lib/db/client.test.ts(first-run seed + on-disk file, no re-seed on reopen, persist() round-trip) andapp/api/state/route.test.ts(200 + exact top-level shape against a temp DB); openapi tests updated for the third endpoint; e2e — newe2e/state.spec.ts: snapshot shape from the live origin, plus restart-persistence test that records cash/clockMs/ids, runsdocker compose restart flight, waits for readiness and asserts everything unchanged (PLAN.md "sim clock persisted across sessions"); playwright.config gainsworkers: 1so the mid-run container restart can't race the map tests - Verified: unit suite 47/47;
tsc --noEmitclean; e2e 11/11 green against the rebuilt compose instance (incl. both new state tests); live curl/api/state→ Frankfurt starter plane + 11 contracts,/api/airportsstill serves 242 features after the mount change; persistence across a fulldocker compose up -d --buildverified manually (clockMs/cash/contract count identical before/after)
v0.5.0 — 2026-09-17 10:15
- New
GET /api/version(app/api/version/route.ts): returns{ "version": "<semver>" }, version read from package.json via a server-side import (same single-source-of-truth pattern as the header tag) - OpenAPI 3.1 spec in code:
lib/openapi.tsexports an object documenting exactly the two public endpoints —GET /api/airports(GeoJSON FeatureCollection, with component schemas for feature collection/feature/point geometry/properties) andGET /api/version;info.versionis imported from package.json so it stays in sync automatically;servers: [{ url: "" }]means "current origin" - New
GET /api/openapi(app/api/openapi/route.ts) serves that spec as JSON — machine-readable API docs for AI agents and external tooling (no interactive docs page, no new dependencies) - AGENTS.md gains a short "## API" section pointing agents at the live base URL and the spec at
https://flight.dev.kkhost.pl/api/openapi, noting the spec is generated fromlib/openapi.tsand must be updated whenever endpoints change; this commit also includes the pending local note in AGENTS.md's "## Other" section (use pty_* tools for longer terminal tasks) - Tests: unit — new
app/api/version/route.test.ts(200 + JSON version equals package.json's, exact body shape) andlib/openapi.test.ts(both paths present, info.version in sync with package.json, empty server url, airports schema refs the FeatureCollection/Feature/Properties component schemas with name/icao/lat/lon); e2e — newe2e/api.spec.ts: fetches/api/versionfrom the live origin and asserts 200 + version matching package.json read at test time; fetches/api/openapiand checks content-type, title, info.version and both paths
v0.4.1 — 2026-09-17 08:50
- Map-asset pack (~100 MB, 780 files) removed from git and from the Docker image:
git rm -r --cached public/{styles,fonts,sprites}(files kept on disk),.gitignorenow excludes exactly the generated paths (public/styles/,public/fonts/,public/sprites/,public/map-assets-manifest.json) — not all ofpublic/;.dockerignoregets the same four entries; the v0.4.0 runtime-stageCOPY --from=build /app/public ./publicline is gone from the Dockerfile - The pack now reaches the container via a docker-compose volume mount:
./public:/app/publicon theflightservice — host dir present with assets → served locally; absent/empty → 404s → runtime fallback (below). Establishes the pattern for the future multi-GB tile file (v0.5.0) that must never go into git scripts/download-map-assets.mjsnow writespublic/map-assets-manifest.jsonas its LAST step ({name, generatedAt ISO, styles: [ids]}) so the manifest's presence proves the whole pack is in place; new npm script"assets:download": "node scripts/download-map-assets.mjs"- Runtime fallback for fresh clones without the pack:
lib/mapStyles.tsgains purestyleUrlFor(id, localAssetsAvailable)→/styles/{id}.jsonorhttps://tiles.openfreemap.org/styles/{id}, plus asynclocalAssetsAvailable()— fetches/map-assets-manifest.jsononce (module-level cached promise; any non-200/network error → false); the single manifest probe is designed so a future self-hosted tile pack plugs into the same availability mechanism without rework components/Map.tsx: map creation in the mount effect is deferred until the probe resolves (one cheap local request beforenew Map), and the persisted-style restore was folded into that same post-mount flow — localStorage is still read only after mount (SSR-safe), applied at creation time so there's no fiord→saved flash; style switcher changes reuse the cached probe viastyleUrlFor; all v0.4.0 behavior intact (fiord default, persistence key, idempotentensureAirportsLayeron everystyledata, single/api/airportsfetch,window.__flightMap)- Tests: unit — new
styleUrlForcases (local path / exact remote OFM URL) andlocalAssetsAvailablecases (false on non-200, true + cached with exactly one fetch across awaits via module reset, false on network error); e2e — new test "falls back to remote OFM styles when the local asset pack is absent" routes/map-assets-manifest.json→ 404, lets*.openfreemap.org/styles/**through while aborting all other OFM traffic, and asserts the map initializes from the REMOTE style (glyphs still point at openfreemap.org), the airport count renders, and a UKBB marker click opens its popup — proving fresh-clone-without-assets usability end-to-end - Verified: unit suite 21/21;
tsc --noEmitclean; e2e suite green against the rebuilt compose instance with assets served from the bind mount (curl:/styles/fiord.json, a font.pbf,/sprites/ofm.jsonall 200); note the asset blobs remain in git history — no history rewrite was performed
v0.4.0 — 2026-09-17 08:22
- Self-hosted all OpenFreeMap map-styling assets (tiles stay remote): new
scripts/download-map-assets.mjsfetches the 5 OFM styles with a browser UA, rewrites each viarewriteStyle, and extracts the font/sprite tarballs intopublic/— committed so the image is self-contained:public/styles/{liberty,bright,positron,dark,fiord}.json(0.15 MB),public/fonts/Noto Sans {Regular,Italic,Bold}/*.pbf(768 files, 99.76 MB — the font payload),public/sprites/ofm{.json,.png,@2x.json,@2x.png}(0.21 MB); total public/ = 100.13 MB - New shared module
lib/mapStyles.ts:STYLESregistry (5 ids + labels),DEFAULT_STYLE_ID = 'fiord', and purerewriteStyle()— glyphshttps://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf→/fonts/{fontstack}/{range}.pbf, spritehttps://tiles.openfreemap.org/sprites/ofm_f384/ofm→/sprites/ofm; the vector tile url (/planet) and ne2_shaded raster tiles (/natural_earth/ne2sr/...) are left remote; every source lacking an attribution gets"© OpenStreetMap contributors © OpenFreeMap"(OFM styles ship none; ODbL requires visible OSM credit) components/Map.tsx: map now loads the local/styles/fiord.jsonby default; new top-bar style switcher (<select data-testid="style-switch">, subtle styling inapp/globals.css) listing all 5 styles, persisted to localStorage keyflight-ops-style; SSR-safe — state initializes toDEFAULT_STYLE_IDand the saved choice is read/applied only in a post-mount effect (no localStorage reads during render/hydration)components/Map.tsx: airports layer now survives style switches —/api/airportsis fetched at most once per page life (promise cached in a ref; count set exactly once), and an idempotentensureAirportsLayer()re-adds the source+layer if missing on EVERYstyledataevent (initial load AND after eachsetStyle, which wipes sources/layers not in the new style); the click→popup handler is re-wired with off+on using a stable reference since MapLibre layer-scoped listeners survive setStyle and would otherwise duplicateDockerfile: runtime stage now copiespublic/from the build stage (COPY --from=build /app/public ./public) sonext startserves the self-hosted assets;.dockerignorealready lets public/ through- Tests: new unit suite
lib/mapStyles.test.ts(registry shape, fiord default, rewriteStyle against an inline OFM fixture — local glyphs/sprite, remote tiles preserved, attribution added/preserved, input not mutated);e2e/map.spec.ts: openfreemap mock simplified to abort ALL requests from that host (only tiles remain there), new test asserting the switcher has 5 options with Fiord default, switching changes the applied style's layer set AND the airports layer re-renders after the switch + exactly one popup on marker click (guards the setStyle-wipes-layers and duplicate-listener regressions), plus a reload-persistence test for the localStorage restore path - Verified: unit suite 16/16;
tsc --noEmitclean; e2e suite 6/6 green against the rebuilt compose instance (all styles/fonts/sprites served locally, fiord default, switch + persistence verified in a real browser)
v0.3.1 — 2026-09-17 06:35
- Header now shows the app version between the "Flight Ops" title and the airport count (
<small>v{version}</small>incomponents/Map.tsx), sourced dynamically frompackage.jsonvia a JSON import — future version bumps show up automatically with no code change; subtle 11px muted style added toapp/globals.css - Tests: new unit suite
components/version.test.ts(valid semver in package.json + guard that the header sources the version from package.json rather than hardcoding it);e2e/map.spec.ts: new test asserting the header showsv{version}with the expected value read frompackage.jsonat test time, so it stays in sync automatically - Verified: unit suite 10/10;
tsc --noEmitclean; e2e suite 4/4 green against the rebuilt compose instance
v0.3.0 — 2026-09-17 00:32
- Removed the zoom-driven camera tilt (
lib/camera.tspitch curve + the mapmovehandler): the map is now always flat/top-down with standard pan/zoom only; deletedlib/camera.tsand its unit tests - Removed terrain: no more DEM raster source /
map.setTerrain, and the?terrain=0/NEXT_PUBLIC_TERRAINtoggles are gone — pure 2D view, no 3D elements - MAX_ZOOM 14 → 11: at European latitudes (~52°N) z11 ≈ 47 m/px, so the viewport spans ~90–135 km across a typical desktop width (≈100 km on screen); street-level zoom had no gameplay value
e2e/map.spec.ts: APP_URL is now/(terrain flag removed), MAX_ZOOM mirror updated to 11; PLAN.md map section updated to flat-2D-only- Verified: unit suite 8/8;
tsc --noEmitclean; e2e suite 3/3 green against the rebuilt compose instance (flat view, wheel zoom clamped to [3, 11])
v0.2.9 — 2026-09-17 00:21
data/airports_eu.csv: multi-airport metro areas reduced to one dot each on the map — Paris→CDG, London→LHR, Moscow→SVO, Milan→MXP, Istanbul→IST, Warsaw→WAW, Brussels→BRU (Rhine-Ruhr keeps both DUS and CGN as distinct cities); dropped 18 satellite airports incl. Orly, Gatwick, Luton, Stansted, Domodedovo, Vnukovo, Zhukovsky, Linate, Caravaggio, Atatürk, Modlin, Charleroi, Weeze, Prestwick, Treviso, Ciampino, Băneasa — 260 → 242 rows;/api/airportsand the circle layer pick it up with no code changes- Verified: unit suite 8/8;
tsc --noEmitclean; e2e suite 3/3 green against the rebuilt compose instance (/api/airportsserves 242 features)
v0.2.8 — 2026-09-16 23:53
data/airports_eu.csv: reduced the dataset to onlylarge_airportrows (dropped all 731 medium_airport + 54 seaplane_base) — the map now shows just the 260 biggest EU airports;/api/airportsand the circle layer pick it up with no code changese2e/map.spec.ts: marker-click test repointed from ESSV Visby Airport (a medium_airport, now removed) to UKBB Boryspil International Airport — with only large_airport rows left it is the most isolated marker inside the initial zoom-4 viewport: its nearest surviving airport is Chişinău (LUKK), ~126 px away at the initial zoom (Kyiv Zhuliany, geographically closer, was a medium and got cut)- Verified: unit suite 12/12;
tsc --noEmitclean; e2e suite 3/3 green against the rebuilt compose instance (/api/airportsserves 260 features)
v0.2.7 — 2026-09-16 23:39
data/airports_eu.csv: removed all 6400small_airportrows from the OurAirports EU export — the map now shows only the 1045 bigger airports (260 large + 731 medium + 54 seaplane_base);/api/airportsand the circle layer pick it up with no code changese2e/map.spec.ts: marker-click test repointed from EKRN Bornholm Airport (a small_airport, now removed) to ESSV Visby Airport on Gotland — its nearest remaining airport is Stockholm Skavsta across the Baltic Sea (~59 px away at the initial zoom), so its marker cannot be overlapped by a neighbouring circle- Verified: unit suite 12/12; e2e suite 3/3 green against the rebuilt compose instance (
/api/airportsserves 1045 features)
v0.2.6 — 2026-09-16 22:41
Dockerfile: install stages switched from npm to bun (oven/bun:1.4.2-alpine,bun install --frozen-lockfile);package-lock.jsonreplaced bybun.lock; no BuildKittype=cachemounts — they don't persist across builds on this daemon, and bun install is fast enough (~3 s cold) that cross-build caching isn't worth itDockerfile: newprod-depsstage (bun install --production) so the runtime image carries prod-onlynode_modules— devDeps (typescript, vitest, @types) are no longer shipped; build and runtime stages otherwise unchanged (still node:22-alpine, full.next, no standalone output).dockerignore: excludes docs/deployment files (*.md,Dockerfile*,docker-compose*) from the build context so changelog-only commits don't invalidate the COPY layer and re-runnext build- Verified: full cold build 77 s → 58 s; e2e suite 3/3 green against the rebuilt compose instance
v0.2.5 — 2026-09-16 21:22
AGENTS.md: new "E2E tests (mandatory)" section codifies the e2e workflow — the compose instance behind Traefik (https://flight.dev.kkhost.pl) is always the app under test, never a localnext devon :3000; deploy current code first withdocker compose up -d --build; browsers run through the shared remote Playwright server at192.168.1.4:3000, and plainnpx playwright testhandles deploy + readiness + remote connectplaywright.config.ts: dropped theE2E_LOCAL=1local-dev path accordingly — no more local :3000 dev instance for e2e; the default (and only) mode is browsers on the shared remote Playwright server against the prod build behind Traefik, withwebServerrunningdocker compose up -d --build;E2E_REMOTE=1and explicitPW_TEST_CONNECT_WS_ENDPOINT/E2E_BASE_URLoverrides are unchanged
v0.2.4 — 2026-09-16 21:06
playwright.config.ts: default e2e mode now targets the prod build behind Traefik (https://flight.dev.kkhost.pl) —baseURLdefaults to that URL andwebServerrunsdocker compose up -d --build(300 s timeout) instead ofnpm run dev, becausenext devdoes not render for non-loopback clients: Turbopack's HMR WebSocket fails from the remote browsers, so the page loads blankE2E_LOCAL=1keeps the local iteration path (browsers on this box,webServerauto-startsnpm run dev, baseURLlocalhost:3000) as a fallback when the proxy or compose stack is down;E2E_REMOTE=1and explicitPW_TEST_CONNECT_WS_ENDPOINT/E2E_BASE_URLoverrides are unchanged
v0.2.3 — 2026-09-16 20:30
playwright.config.ts: e2e now launches browsers on the shared remote Playwright server (Docker,ws://192.168.1.4:3000/) by default — when no env vars are set it exportsPW_TEST_CONNECT_WS_ENDPOINTpointing at that box; in this modebaseURLdefaults to this box's LAN IP (http://192.168.1.26:3000) because pages load from the remote browser's perspective, while the localwebServerstill auto-startsnpm run devE2E_LOCAL=1restores fully-local runs (browsers on this box,localhost:3000baseURL) as a fallback when the remote server is down;E2E_REMOTE=1keeps its previous behavior for runners on other machines — skipswebServerentirely and pairs withE2E_BASE_URL; an explicitPW_TEST_CONNECT_WS_ENDPOINTalways wins over the default
v0.2.2 — 2026-09-16 20:05
playwright.config.ts: e2e suite can now run on a different machine than the app — withE2E_REMOTE=1the localwebServerblock is skipped entirely andbaseURLdefaults tohttp://flight.dev.kkhost.pl;E2E_BASE_URLoverridesbaseURLin both modes; with no env vars set behavior is unchanged (localwebServerauto-startsnpm run dev)
v0.2.1 — 2026-09-16 19:49
- Clamp map zoom to the useful gameplay range:
minZoom: 3/maxZoom: 14on the MapLibre options incomponents/Map.tsx(exported asMIN_ZOOM/MAX_ZOOM) — wheel travel from the Europe overview is now ~10 levels instead of ~16, and street-level zoom past the z12 pitch transition no longer exists - e2e: new test that wheels well past both limits via
page.mouse.wheel(basemap tile fetches blocked for speed/determinism; clamping is pure camera behavior) and asserts viaexpect.pollon the map instance thatgetZoom()settles at 14 / 3, i.e. clamped within [3, 14]
v0.2.0 — 2026-09-16 18:32
- Docker deployment: multi-stage
Dockerfile(node:22-alpine; deps → build → runtime) runningnext starton port 3000; nooutput: 'standalone'— the runtime stage keeps fullnode_modulesbecause/api/mlworker/[file]reads maplibre-gl dist files from disk at runtime (and/api/airportsreadsdata/*.csv) .dockerignore: node_modules, .next, data/*.db, test-results, *.tsbuildinfo, plus .git/.env/.codenomad and the e2e harness (@playwright/testis a host-global install, not a project dependency — Next's build-time type check would fail on it inside the image)docker-compose.yml: single serviceflight, no published ports, joins external networkdev_netwith Traefik labels routingHost(flight.dev.kkhost.pl)→ port 3000 (plain HTTP entrypointweb; TLS terminated upstream)- Verified: container builds and runs; router registered in dev_traefik rawdata;
curl -H "Host: flight.dev.kkhost.pl" http://localhost/→ 200,/api/airports→ 200 GeoJSON (7445 features), mlworker .mjs files served from node_modules inside the container; unit suite 12/12 and e2e suite 2/2 still green
v0.1.0 — 2026-09-16 18:24
- Scaffolded Next.js (App Router) + TypeScript project; dev server on port 3000 (
npm run dev) - Full-viewport MapLibre GL map with OpenFreeMap "liberty" vector basemap (attribution included via style)
- Zoom-driven camera controller: top-down at zoom ≤ 6, smoothstep tilt to 45° pitch by zoom ≥ 12; pitch is a pure function of zoom so it never fights pan/zoom gestures
- Lite 3D terrain from AWS terrarium raster-DEM tiles (
map.setTerrain); toggleable off via?terrain=0orNEXT_PUBLIC_TERRAIN=0(off in e2e for deterministic headless rendering) - EU airport seed data: OurAirports CSV (official mirror at davidmegginson.github.io/ourairports-data — the legacy ourairports.com/data URL now 404s) filtered to continent=EU with types small/medium/large_airport + seaplane_base → committed as
data/airports_eu.csv(7445 airports; heliports, closed and balloon sites excluded — rationale documented inlib/airports.ts) GET /api/airports: server-side CSV parse with in-memory cache, returns GeoJSON FeatureCollection (properties: name, icao, lat, lon)- Client renders airports as a GeoJSON circle layer (no DOM markers); click → popup with name + ICAO; header shows loaded airport count (
data-testid="airport-count") - MapLibre worker served from node_modules via
/api/mlworker/*route — bundlers break the URL MapLibre derives for its worker, which silently hangs GeoJSON source updates - Tests: vitest unit suite (CSV parse/filter, GeoJSON build, pitch curve, API handler) + self-contained Playwright e2e suite (
webServerauto-startsnpm run dev; page load + airport count + marker click popup)