Files
Flight-Ops/CHANGELOG.md
T
kamilkrzywda ae1cb93a4f
CI / build + unit + e2e (pull_request) Successful in 6m32s
test: harden full-core-loop against Load/Fly precondition races
2026-09-19 13:36:27 +00:00

85 KiB
Raw Permalink Blame History

Changelog

v0.16.0 — 2026-09-19 12:18

  • Weight-proportional fuel burn (ISS-005, part A). Fuel no longer burns at a constant L/km — the instantaneous rate is proportional to total weight, r(F) = k·(We + ρF) L/km with We the empty weight and ρ = 0.8 kg/L (Jet-A1), so a heavier plane burns more per km and a tank gets lighter as it empties. The constant k is calibrated so a FULL tank burns exactly the catalog's old burnPerKm, keeping full-tank departure behavior identical to before; divergence only grows in flight as fuel drops off. Closed forms of dF/dx = −k(We + ρF) live in lib/sim/movement.ts (single source of truth):
    • fuelAtWeighted(startFuel, burnPerKm, emptyWeightKg, maxFuel, distanceKm, progress) — fuel remaining after flying progress of the leg (replaces the old constant-rate fuelAt, which is removed)
    • requiredFuelFor(burnPerKm, emptyWeightKg, maxFuel, distanceKm) — liters needed to cover the distance arriving empty, independent of starting load; a leg is flyable iff current fuel ≥ this and fits at all iff it ≤ tank capacity
    • burnRateConstant(...) + exported FUEL_DENSITY_KG_PER_L (0.8)
  • Catalog gains emptyWeightKg (lib/game/catalog.ts, ATR 72-600 OEW ≈ 9300 kg), exposed in the snapshot's catalog[] so clients can mirror the model; OpenAPI CatalogEntry follows
  • All three burn call sites moved to the weighted model: landing fuel on arrival (lib/sim/engine.ts), live in-flight fuel in the state snapshot (lib/game/state.ts), and fly validation (lib/game/actions.ts — a leg now needs requiredFuelFor, not burnPerKm × km)
  • Refuel "just enough for the loaded package" (ISS-005, part B). POST /api/planes/{planeId}/refuel takes an OPTIONAL JSON body { mode?: 'full' | 'forFlight', packageId?: string }: no body or mode:'full' is exactly today's fill-to-max behavior; mode:'forFlight' adds just enough fuel to fly that loaded package's destination leg — liters = requiredFuelFor(haversine(plane → pkg.destIcao)) − current fuel, 409 if the package isn't loaded on this plane / is expired, or the leg doesn't fit even a full tank; bad bodies (unknown field/mode, forFlight without packageId) are 400. OpenAPI gains RefuelRequest + the 400
  • UI (components/RightPane.tsx): each on-board cargo row gets a "Refuel" button (refuel-for-{pkg.id} → {mode:'forFlight', packageId}, disabled while flying or busy, mirroring fly-to/abandon); the market fill-up button is renamed "Refuel to full" (testid market-refuel and behavior unchanged)
  • Tests: unit — new weighted-burn suite in lib/sim/movement.test.ts (full-tank calibration ≈ old constant, closed form vs numeric integration of r(F), F_req monotonic + empty-arrival identity, lighter-start burns less, clamping); refuelPlane forFlight cases in lib/game/actions.test.ts (exact liters/cost, no-op when enough, 409 not-loaded/expired/doesn't-fit via the ~7670 km UNNT↔LPPD pair, 404 unknown package, 400 missing packageId); refuel route body-validation tests; lib/openapi.test.ts updated (optional RefuelRequest, errorCodes ['400','404','409'], CatalogEntry.emptyWeightKg). Adjusted where the model legitimately shifts numbers: landing-fuel assertions in lib/sim/engine.test.ts and e2e/actions.spec.ts now use the weighted closed form; the e2e "return leg unfuelable" scenario is reworked — under weight-proportional burn a full tank covers the EDDF↔LEZL round trip (the plane lightens as it flies), so the 409 insufficient-fuel assertion moves to the SECOND outbound after landing back with a partial tank
  • e2e (right-pane.spec.ts): new test — fly the longest hub leg on a full tank, load a package back to the hub at the far end, click the row "Refuel" and assert via the endpoint's own response snapshot that cash drops by exactly L×price and fuel rises by exactly L (L from the mirrored closed form); also asserts the market button label is "Refuel to full". e2e/api.spec.ts path list unchanged
  • Verified: tsc --noEmit clean; unit suite green locally; e2e not run locally by design — CI verifies against an isolated stack
  • Fix (CI run #40): forFlight refuel leaves an exactly-feasible tank, and the new e2e's premise corrected. The right-pane e2e picked the dataset's absolute farthest airport from EDDF — UNNT at ~4803 km, whose leg needs ~6955 L > the 6300 L tank (max range from empty is ~4436 km) — so the full-tank fly rightly 409'd; farthestToHub becomes farthestFlyableToHub, picking the farthest airport whose leg still fits a full tank (UWUU, ~3195 km). Server hardening in the same area: forFlight now sets fuel to EXACTLY requiredFuelFor(...) instead of fuel + liters — that sum is not guaranteed ≥ R in IEEE754 (f + fl(R − f) can round a hair below), and flyPlane's strict comparison would then 409 on an otherwise-feasible leg; cost math unchanged (still the added liters × price). New unit regression test: forFlight on an empty-ish tank → immediate flyPlane to the package destination succeeds
  • E2e flake hardening (runs #36/#41): full-core-loop Load/Fly precondition races — test mechanics only, no app code changes. Two independent races in e2e/actions.spec.ts: (1) the Fly click fires its POST asynchronously (runAction is fire-and-forget from the test's view), so the immediate getState could race ahead of it and still see status 'available' — now an expect.poll waits until the server shows 'flying' with the expected destIcao before proceeding; (2) flyHomeViaApi made its repositioning decision from a snapshot taken BEFORE waiting for a leftover in-flight leg to land — while flying, locationIcao stays at the departure airport, so a plane that landed away from home was still believed to be home and never repositioned (run #41 retry: starter stranded at EDFH) — state is now re-read after landing; plus an explicit server-side precondition poll (starter grounded at the package origin) before clicking Load

v0.15.1 — 2026-09-19 11:04

  • Right pane widened, "On board" wrapping fixed (ISS-004). The 3-pane shell grid (app/globals.css .app-shell) moves from 300px / 1fr / 320px to 280px / 1fr / 360px — the map column absorbs the delta (MapLibre sizes itself from its container, no JS change). Three wrap offenders in the right pane's "On board" blocks are fixed:
    • In-flight leg header (components/RightPane.tsx, .contract-route span): the class already had white-space: nowrap; overflow: hidden; text-overflow: ellipsis, but as a flex item with default min-width: auto it could not shrink below its full text width, so long routes (e.g. "Frankfurt → Madrid–Barajas") overflowed instead of ellipsizing — .contract-route now has min-width: 0
    • Cargo-row buttons ("Fly to {dest}" / "Abandon −€X" in .contract-main): labels wrapped mid-text at the old width — .pane-btn now has white-space: nowrap
    • Cargo sub-line route span had no white-space rule and wrapped mid-route — it now carries the existing contract-route class (nowrap + ellipsis)
  • Left pane sanity-checked at its new 280px: fleet-row location span (.plane-loc) already had nowrap+ellipsis but lacked min-width: 0, so a long in-flight leg would have overflowed instead of truncating — same one-line guard added. Fuel/loadout spans are short fixed strings and need no guard
  • Verified: tsc --noEmit clean; unit suite 238/238 green locally (no lib changes); e2e not run locally by design — CI verifies against an isolated stack
  • Unrelated test-stability fix included on this branch to unblock the pipeline (e2e/map-interactions.spec.ts, no app changes): the mid-flight marker click now targets the plane's RENDERED position — it polls up to ~5 s for the rendered feature via window.__flightMap.queryRenderedFeatures on layers planes-flying/planes-grounded and projects that feature's coordinates, instead of fetching a fresh /api/state. With the sim paused the server position is static, but the map renders from its own ~1 s poll — right after the freeze confirmation the last rendered frame can still be up to one poll interval stale (tens of px at scale 1000 on EDDF→LEMD), so a click projected from a fresh fetch misses the small circle layer: the render-lag race that flaked CI run #37. Pause + freeze confirmation, popup assertions and resume are unchanged

v0.15.0 — 2026-09-19 09:35

  • Hover visualization from the side panels (ISS-002). Hovering a fleet row in the left pane, an "On board" block (including its cargo rows) or an open-package row in the right pane now highlights the matching object on the map. The shared HoverTarget type (components/hoverTypes.ts: {kind:'plane', id} | {kind:'package', originIcao, destIcao}) is owned by the shell as a single useState and prop-drilled down — panes report via an onHover callback, <Map> consumes it. No React contexts introduced; the established prop-drilling pattern is kept
  • Three top-most highlight layers (components/Map.tsx, added inside idempotent ensureLayers() so they survive every setStyle): planes-highlight (circle over both plane layers, filter ['==',['get','id'],<planeId>], r=10, #38bdf8 fill + white stroke), routes-highlight (line over routes-legs, filter on the leg's planeId, width 6, #ef4444) and airports-highlight (circle ring over airports-circles, filter matching the hovered package's two ICAOs via ['any', …], r=8 stroke-only). All start filtered to match nothing — an empty-string sentinel no feature can have. Consumption follows the existing ref + effect pattern: a [hovered] effect calls map.setFilter(...) on the three layers (guarded by getLayer), and ensureLayers re-applies the current filter at its end so a hover outliving a style switch stays highlighted
  • e2e — new e2e/hover.spec.ts (3 tests, same SCALE/self-heal/window.__flightMap conventions as the other specs): fleet-row hover → planes-highlight renders exactly plane-starter, clears on mouseleave; open-package hover → airports-highlight shows both endpoint ICAOs (hook-created EDDF→LEMD package), clears on mouseleave; on-board block of a flying plane → routes-highlight shows the leg's planeId (sim paused mid-flight so the ~10 s leg can't land under the assertions, then resumed + fly-home cleanup)
  • Verified: tsc --noEmit clean; unit suite 238/238 green locally (no lib changes); e2e not run locally by design — CI verifies against an isolated stack
  • E2e hover spec mechanics fixed after CI run #32 (e2e/hover.spec.ts, no app changes): the fleet-row wait now polls up to 30 s with a diagnostic that distinguishes "server has no plane-starter" from "DOM didn't render" (the browser's /api/state poll can lag node-side requests right after page load, leaving the pane in its loading state); the open-package test fits the map to both endpoint airports before asserting — queryRenderedFeatures only returns features inside the current viewport and the default Frankfurt-centered view leaves LEMD off-screen
  • Decisive hover test-1 diagnostics (run #33 transient): the fleet-row poll now reports on every failed attempt what the NODE side sees (/api/state plane ids + cash via page.request) vs what the BROWSER last polled — new dev hook window.__flightLastSnapshot set in lib/useGameState.ts after each successful fetch (planeIds/cash/timeScale/timestamp, client-only) — plus the fleet section's HTML (fleet-list outerHTML or left-pane text), so a future failure can distinguish server divergence from a render bug
  • CI removes the stale e2e DB volume at run start (.gitea/workflows/ci.yml, "Prepare e2e network"): docker volume rm flight-ci_flight-db 2>/dev/null || true — an interrupted run skips the end-of-run cleanup and leaks its used game state into later runs (run #33's instance showed spent cash from a pre-#26 seed), while tests assume seeded state
  • Hover e2e test 1 used the wrong fleet-row testid (e2e/hover.spec.ts): it waited for data-testid="plane-starter", but rows are rendered as plane-${p.id} and the server id already carries the plane- prefix — so the real testid is plane-plane-starter and the "missing row" failures in runs #32–#34 were deterministic selector misses, not transient state (run #34's diagnostics proved it: the fleet-list HTML contained <li data-testid="plane-plane-starter">). The selector is now derived from /api/state (starter plane id, or the single plane) and used for both the visibility poll and the hover

v0.14.0 — 2026-09-19 08:45

  • Time-scale slider reworked to a 10-step ladder up to 1000× (ISS-006). The old power-of-two ladder 1/2/4/8/16/32/64 is replaced by the discrete values 1, 2, 5, 10, 25, 50, 100, 250, 500, 1000 (each step roughly doubles the speed). The server clamp range widens from [1, 64] to [1, 1000] (MAX_TIME_SCALE in lib/sim/engine.ts, enforced by clampTimeScale; out-of-range values are still clamped, never rejected), and the OpenAPI spec's timeScale bounds + description follow. The slider mapping helpers in lib/timeScale.ts now place off-ladder scales at the nearest ladder step by log distance (the old log2 formula assumed a pure power-of-two ladder); round-trip, monotonicity and endpoint-exactness are preserved
  • E2e suite runs at the fastest time. Every spec's fast-forward scale moves from 60× to the new top of 1000× (sim, actions, right-pane, map-interactions), so flights/expiries resolve in seconds instead of minutes. The map mid-flight test (map-interactions.spec.ts) needed a longer leg to keep its click assertions inside the flight window at 1000×: destination moves EDDV (Hannover, ~2 s real) → LEMD (Madrid–Barajas, ~10 s real), with a full-tank refuel before departure so the ~4970 L round trip always fits the 6300 L tank. The slider e2e (panes.spec.ts) now asserts End→1000× / Home→1×, and the clamp tests post 2000 → expect 1000 (500 is a valid ladder value, so it passes through unclamped)
  • Tests: unit — lib/timeScale.test.ts covers the 10-position ladder + log-distance nearest-step mapping; new clampTimeScale boundary suite in lib/sim/engine.test.ts ([1, 1000], full ladder pass-through); app/api/sim/route.test.ts and lib/openapi.test.ts updated to the new bounds. Verified: unit suite 238/238 green locally (was 234 before this change)
  • e2e map-interactions.spec.ts: the plane-popup test now pauses the sim around the marker click — at 1000× the rendered marker lags a fresh /api/state fetch by up to one map poll (~1 s), tens of px of drift on the EDDF→LEMD leg, so page.mouse.click missed the small circle layer (CI run #29); the sim is paused until the popup assertions pass, then resumed

v0.13.1 — 2026-09-19 08:09

  • CI e2e step now uses Playwright's list reporter so every test prints as it runs in the job log (progress visibility); failures still get full error context

v0.12.3 — 2026-09-19 07:36

  • e2e: removed the unreliable curved-route assertion from map-interactions.spec.ts. The "route line has >2 coordinates in the routes source" check (added with v0.12.2) failed in CI and against the live instance even though the arc renders correctly — MapLibre's querySourceFeatures returns tile-simplified geometry at low zoom, so coordinate counts from it are not a reliable signal for what was pushed into the source. The curved arc remains fully covered by unit tests (lib/mapData.test.ts: 25 sampled coordinates, exact endpoints, curvature on long routes); the spec keeps all other steps (grounded plane draws no line, marker moves across polls, plane popup with status + route)

v0.13.0 — 2026-09-18 15:31

  • CI + pull-request workflow (process/infra): new .gitea/workflows/ci.yml runs build → unit tests → e2e on every PR to master and on push to master, in one job with a docker:dind service. E2E isolation: the suite targets a fresh compose stack inside dind (docker-compose.ci.yml — mirrors docker-compose.yml minus Traefik/dev_net; app served directly on port 3000 of the dind container; game state in a named volume so it is fresh per run and survives state.spec.ts's mid-run restart) — CI never deploys to or touches production. The dind endpoint is resolved at job start (tries tcp://localhost:2375, then tcp://docker:2375 — Gitea services are sibling containers reachable by service id on the job network) and exported as DOCKER_HOST + E2E_BASE_URL; build context is tarred by the compose client in the job, so nothing needs to be visible from inside dind
  • Playwright config made CI-aware, backward-compatible (playwright.config.ts): new optional env vars — PW_REMOTE_BROWSER=0 (local chromium instead of the shared remote server at 192.168.1.4:3000), E2E_DEPLOY_CMD (runs as webServer + readiness wait; unset = current local behavior with no auto-deploy); E2E_BASE_URL already existed. e2e/state.spec.ts reads E2E_COMPOSE_ARGS for its mid-run docker compose restart flight (default empty → unchanged locally)
  • @playwright/test pinned as devDependency (1.63.0, matching the shared remote browser server image and the global install) so CI is self-contained; still excluded from the runtime image via .dockerignore (comment updated), prod-deps stage unaffected
  • AGENTS.md: "Change process" + "E2E tests" rewritten for the PR workflow — feature branch off master → commits with CHANGELOG entry + version bump → PR to master → CI (build→unit→e2e) green → merge → tag on master → manual production deploy via docker compose up -d --build; no direct pushes to master, CI never deploys
  • CI build reliability + caching (after first dind runs): the e2e image is now built with --progress=plain before up -d so BuildKit step output lands in job logs instead of being hidden by auto progress; Dockerfile bun installs use a BuildKit cache mount (/root/.bun/install/cache) that persists on the runner's shared daemon, and CI restores/saves node_modules + the bun cache via actions/cache around dependency install (both steps continue-on-error — safe while the runner's cache server is being fixed)
  • CI flaky-network handling settled on retry wrappers (transient EAI_AGAIN failures seen in runs #10/#11): bun install, dependency install and the e2e image build each retry up to 3× with 5s between attempts before failing — run #12 with this setup got furthest (build + unit + browsers green). A short-lived "DNS diagnostics" step was tried but removed: its daemon-side probe pulled an image not present on the runner's docker daemon and stalled runs #13/#14 at Setup Node
  • E2e image build needs no outbound network from dind build containers (DNSResolveFailed in RUN bun install across runs #10/#12/#15): node_modules is now installed in the CI job / dev host and copied into the build context (.dockerignore no longer excludes it; Dockerfile COPYs it instead of running any install inside the image); base images switched alpine → node:22-slim (glibc) for native-module ABI compatibility with the job-installed tree (@next/swc-*, sharp are prebuilt per platform — CI job and prod host are x86_64 glibc linux); a "Pull e2e base image" step fails fast if the daemon can't fetch node:22-slim
  • CI e2e deploy command stays alive (E2E_DEPLOY_CMD now ends with docker compose … logs -f): Playwright's webServer requires a long-running process — up -d returns immediately and triggered "Process from config.webServer exited early" (run #16); logs -f also streams app logs into CI output for debugging
  • CI e2e step bypasses proxies for the in-network app host (NO_PROXY/no_proxy=app,localhost,127.0.0.1,.kkhost.pl) and prints proxy/DNS/curl diagnostics before the suite — run #18 showed Chromium getting ERR_SSL_PROTOCOL_ERROR on http://app:3000 while node-side HTTP worked (9 API-only tests passed), pointing at a TLS-terminating proxy env var that Chromium honors but Node's HTTP client ignores
  • CI e2e targets the app at a static in-network IP (172.39.0.10, pinned subnet 172.39.0.0/24 created by "Prepare e2e network"; stale networks with other subnets are recreated) instead of the DNS alias — Chromium got ERR_SSL_PROTOCOL_ERROR via the hostname while node-side HTTP worked, so DNS is removed from the equation; post-deploy connectivity diagnostics added as a CI-gated globalSetup (e2e/ci-diagnostics.ts, no-op locally) comparing node resolution/HTTP against Chromium navigation on both hostname and static IP
  • CI caches Playwright browser binaries across runs ($HOME/.cache/ms-playwright via actions/cache): the flaky runner uplink makes the ~187 MiB Chrome-for-Testing download unreliable (runs #20/#21 died mid-download at "Install browsers"); also removed the no-op "Save deps cache" step — on this runner caches marked by a restore step are uploaded automatically at job end, so an explicit save never did anything
  • CI browser install runs in a bounded 6-attempt loop — cdn.playwright.dev is flaky from the runner and Playwright's internal retry window (~40–90s) was often insufficient (runs #21–#23), while longer windows get through (runs #12/#18); already-installed browsers are skipped so progress accumulates across attempts
  • Verified: unit suite 234 green locally; e2e not run locally by design (would deploy to production) — first real pass happens in the PR's CI run
  • CI e2e now runs in self-hosted map-assets mode — the workflow downloads the OpenFreeMap asset pack (npm run assets:download → styles/fonts/sprites into git-ignored ./public, bounded 6-attempt retry like the other flaky-network steps) and caches it via actions/cache keyed on the download script (job-end upload seeds future runs; a partial pack without manifest is harmless — app falls back to remote); docker-compose.ci.yml mounts ./public:/app/public mirroring docker-compose.yml, so map tests that abort all *.openfreemap.org traffic get self-hosted styles/fonts/sprites like local dev (fixes the deterministic map.spec.ts wheel-zoom + style-switcher failures)

v0.12.2 — 2026-09-18 14:06

  • Bugfix (ISS-003): route leg lines were drawn as straight two-point chords instead of following the curved great-circle path planes actually fly. Root cause in lib/mapData.ts: routesGeoJson built each flying plane's leg LineString from only its origin and destination airport coordinates, so on long routes the drawn line cut across the chord (up to ~40 km off the true arc for a ~1400 km leg) while the plane marker moved along the curved path. Fix: new pure greatCircleArc(from, to) samples the same slerp used by the sim (positionAt in lib/sim/movement.ts) at t = i/24 for i = 0..24 — 25 coordinates (exact endpoints + 23 intermediate points, ROUTE_ARC_SEGMENTS = 24) — so the drawn line matches the curved flight path; routesGeoJson now uses it instead of the two-point chord
  • Tests: unit — lib/mapData.test.ts updated (route feature is a sampled arc with exact endpoints) + new greatCircleArc suite (endpoint exactness, long-route midpoint deviates >5 km from the straight chord proving curvature, short route stays <5 km off). e2e — map-interactions.spec.ts now asserts the flying plane's route line has >2 coordinates in the routes source (curved arc, not a two-point chord)
  • Verified: unit suite 231 → 234 green; fix verified working by the user on the live compose instance (e2e re-run skipped per session scope — the map-interactions spec change is covered for the next full e2e pass)

v0.12.1 — 2026-09-18 11:13

  • Bugfix (ISS-001): time-scale slider pointer jumped back to the old value, then forward again, on every speed change. Root cause in components/LeftPane.tsx: commitNow cleared the optimistic pendingScale before the server confirmed it — so for one poll cycle the displayed scale fell back to the stale snapshot's value (pointer snapped back), then jumped forward when the confirming snapshot landed. Fix: keep the chosen value on screen until a rendered snapshot actually CONFIRMS it (serverTimeScale === pendingScale) and no commit is still in flight; drop it immediately only if the POST genuinely fails, so the display falls back to server truth. The confirmation rule is extracted as a pure exported pendingScaleConfirmed(pending, server) for unit testing (same pattern as RightPane's picker rules); the busy guard prevents an old snapshot from coincidentally "confirming" a quick drag back to the previous scale before its own commit fires
  • Hardened the sim-control commit machinery while fixing it (components/LeftPane.tsx): the in-flight gate moved from React state to a synchronous ref (closures can no longer see a stale busy and re-queue instead of send, which could silently drop a commit), the single queue slot is now flushed by a loop inside the in-flight call (last intent always wins, multiple queued commits all sent), and applySimControls reports 'sent' | 'queued' | 'failed' so an optimistic value is only dropped on genuine failure — never when a commit merely got queued behind another
  • Tests: unit — new components/left-pane.test.ts (no pending → false; stale-scale window stays false; confirms exactly when server carries the pending value). e2e — new panes.spec.ts test commits 64× via keyboard and samples the displayed value ~30× across the whole commit→confirm window, asserting it never snaps back to a stale scale (the old bug), then verifies the server confirms
  • Verified: unit suite 228 → 231 green; tsc --noEmit clean; e2e — full suite green on the initial v0.12.1 deploy against the live compose build (incl. the new no-snap-back test); after the commit-machinery restructure, the time-control specs (panes + sim) verified green in 3 consecutive rounds. Full-suite re-run deferred: the live instance is under active human play and the destructive self-heal specs must not touch it

v0.12.0 — 2026-09-18 09:17

  • Gameplay pivot (user-driven): contracts → packages — the job model is rebuilt end-to-end around cargo packages and manual flight, replacing the old accept→dispatch flow in one coherent increment. A package is a cargo unit origin→dest with a reward € and a game-time deadline; planes carry up to capacity packages (ATR 72-600 = 6 slots, new capacity field on catalog entries exposed via /api/state). The loop: Load an open package onto a grounded plane at its origin → Fly the plane to any known airport (the destination of a loaded package, or pure repositioning) → on arrival every in-deadline package bound for that airport is delivered (cash += reward + rep per package); packages destined elsewhere stay loaded for a later leg
  • New player actions (lib/game/actions.ts, all under the sim lock via engine.runAction() — tick first, then validate+mutate): loadPackage (open → loaded; plane must be GROUNDED at the origin with a free slot — expired packages still occupy theirs), flyPlane (grounded plane → any dataset ICAO; 400 unknown airport, 409 flying / insufficient fuel where burnPerKm × great-circle km > fuel), abandonPackage (loaded or already-expired package while its plane is GROUNDED — 409 if in flight). Removed: acceptContract, dispatchPlane
  • Abandon penalty (lib/game/penalty.ts, pure + shared by server and client preview): fraction of the payout scaling with time pressure — exactly 15% at creation, linear to 60% at the deadline; an already-expired package abandons for free; cash floors at €0. abandonPenaltyPct / elapsedFractionFor / abandonPenalty(pkg, clockMs)
  • Sim engine (lib/sim/engine.ts): expirePackages flips both OPEN and LOADED packages past their deadline to 'expired' (open ones leave the market so spawn tops it back up; loaded ones keep their slot); deliverPackages (was finalizeArrivals) grounds a flying plane at its dest_icao, then for every bound package destined there — in-deadline 'loaded' → delivered + cash/rep, 'expired' → unbind only (no reward); spawn rules (spawnOpenPackages / spawnCoveragePackages) adapted to packages via the renamed shared builder
  • Schema (lib/db/schema.ts + DDL in lib/db/client.ts): new packages table (status open|loaded|delivered|expired|abandoned, planeId nullable); planes gain nullable dest_icao and lose contract_id; seed builder renamed generateContract → generatePackage (same reward/deadline formulas), MARKET_TARGET_OPEN_PACKAGES = 10
  • One-time migration for live DBs (lib/db/client.ts, detected by the presence of the old contracts table so it runs exactly once per file): PRESERVES account + sim_state rows and every plane row; resets all planes to GROUNDED at their locationIcao with departure_sim_time/dest_icao cleared (an in-flight plane from the old state simply lands back where it took off); wipes old contract data (DROP TABLE contracts), drops the orphaned contract_id column, creates the empty packages table — the engine's spawn rules reseed the market on the next tick. Wallet, fleet and sim clock survive; old contracts do not.
  • API (OpenAPI updated in code): NEW POST /api/packages/:id/load {planeId}, POST /api/planes/:planeId/fly {destIcao}, POST /api/packages/:id/abandon — all tick-first, full state snapshot on 200, {error} on 4xx (404 unknown id; 409 wrong status / not at origin / no free slot / in flight / insufficient fuel; 400 bad body or unknown ICAO). REMOVED POST /api/contracts/:id/accept and POST /api/planes/:planeId/dispatch. Test hooks renamed: /api/test/create-package (origin/dest/deadlineOffsetMs) and /api/test/expire-package ({packageId?}), same FLIGHT_TEST_HOOKS=1 gate
  • Snapshot (lib/game/state.ts): contracts → packages field; planes gain destIcao + destName (null when grounded); catalog entries carry capacity. /api/state shape otherwise unchanged
  • UI — right pane (components/RightPane.tsx): "Packages" section lists open packages with a Load button that acts directly on the single eligible plane or shows a small picker when several qualify (pure rule extracted as loadEligiblePlanes / slotsUsedFor, unit-tested); new "On board" section per plane — loaded/expired packages listed with a fly-to-destination button (the destination's name; enabled only while grounded) and an Abandon button showing the exact penalty preview computed client-side from the same formula; flying planes show their current leg "originName → destName". Left pane fleet rows gain a loadout "n/6 pkgs"
  • UI — map (components/Map.tsx + lib/mapData.ts): route lines are now each FLYING plane's current leg (origin→dest_icao, one amber line per flying plane keyed by planeId) — the old pending/inflight contract-based layers are gone; grounded planes draw nothing. Plane popup route text comes from the plane's own dest fields
  • Tests: unit 192 → 228 green — new lib/game/penalty.test.ts (15% start / 60% deadline / monotonic / clamped / expired→0 / cent rounding), rewritten actions.test.ts (load validation matrix incl. slot capacity + expired-slot occupancy; fly unknown-ICAO/flying/no-fuel; abandon penalty at exact clock points, cash floor, in-flight 409), engine.test.ts (expire open+loaded, terminal never flips; arrival delivery mixed loaded/expired/other-dest with exact cash+rep deltas and idempotency; spawn top-up + coverage on packages), seed.test.ts / client.test.ts (fresh seed of packages; pre-v0.12 migration wipes contracts + grounds planes + preserves account/clock, one-shot on reopen), route tests for load/fly/abandon, openapi/mapData/right-pane updated to the package model. e2e 27 → 29 — rewritten actions.spec.ts (full loop via UI: hook package at EDDF → Load click → fly-to-destination click → exact reward+rep on arrival; abandon deducts exactly the formula penalty; expired packages deliver/abandon with no reward and no penalty; load/fly validation 404/409/400 incl. an unfuelable return leg), right-pane.spec.ts (open package rows + Load enabled/disabled by origin, on-board fly+abandon with penalty preview, flying-leg display, market previews + exact refuel cost), map-interactions.spec.ts (no line while grounded → leg line appears keyed by planeId after fly, marker moves across polls, popup shows the leg); sim/state/api/panes updated to packages (api now asserts ten endpoints; panes asserts the "n/6 pkgs" loadout)
  • Verified: tsc --noEmit clean; unit suite 228 green; e2e 29 green against the rebuilt compose instance with the live DB migrated in place (wallet € + sim clock preserved, old contracts wiped); scale restored to 1×/unpaused after every spec

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
  • Audit of all RightPane selects: market buy-model / sell-plane / refuel-plane pickers are single pickers each with their own independent useState — no shared state found, left unchanged (their first-entry fallbacks are intentional preview defaults)
  • Tests: unit — new components/right-pane.test.ts for the extracted rule (placeholder default = NO auto-selection of first eligible plane; user pick honored while eligible; drops to placeholder when a poll makes it ineligible via flight or relocation; unknown id → placeholder). e2e — new regression test in e2e/right-pane.spec.ts: two hook-created EDDF contracts accepted through the UI, both dispatch pickers assert placeholder default + disabled Dispatch buttons, selecting plane-starter in contract A's picker asserts contract B's picker STILL shows the placeholder (the reported bug), then dispatching A asserts 'flying' in /api/state + in-flight indicator while B remains accepted with its independent placeholder picker; live instance restored to scale 1 + unpaused
  • Verified: unit suite 166 → 171 green; tsc --noEmit clean; e2e 24 → 25 green against the rebuilt compose instance

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)
  • Slider step is now 1/6 (7 positions, one doubling per keyboard-arrow step); debounce/release-flush/in-flight-queue commit behavior from v0.9.1 unchanged; "N×" label and pause toggle unchanged
  • Tests: unit — lib/timeScale.test.ts rewritten for the ladder (endpoint exactness 0→1/1→64, all 7 positions map to [1,2,4,8,16,32,64], monotonic non-decreasing over a 2000-step grid, only-ladder-values output, clamping of out-of-range/non-finite input, round-trip for every ladder value, off-ladder snapping 3→4 and legacy 60→64); app/api/sim/route.test.ts clamp matrix updated (500 → 64, new 64 pass-through case, both-fields call at 64); lib/openapi.test.ts maximum assertion 60 → 64; e2e — panes.spec.ts slider test now drives End → "64×" + /api/state timeScale=64 and Home → "1×"+timeScale=1, sim.spec.ts clamp-high expectation 60 → 64 (scale-60 tick-math test unchanged — 60 still passes through unclamped); live instance restored to scale 1 + unpaused in-test and in afterEach as before
  • Verified: unit suite green; tsc --noEmit clean; e2e re-run against the rebuilt compose instance

v0.9.1 — 2026-09-17 16:03

  • Log-scale time slider (left-pane UI refinement): the 1x–60x <select> in components/LeftPane.tsx is replaced by a single <input type="range" data-testid="time-scale-slider"> — position t ∈ [0,1] maps to scale = round(60^t) (t=0→1×, mid≈8×, t=1→60×) so equal slider distances are equal multiplicative steps; 60 discrete positions (step = 1/59) keep keyboard arrows usable (~7% per step). The current value is shown beside the slider as "N×" (data-testid="time-scale-value"); pause/resume toggle unchanged
  • Debounced commit: dragging updates the displayed value optimistically; POST /api/sim {timeScale} fires after a 250 ms debounce and flushes immediately on release (native change event); a commit landing while another POST is in flight is held in a single slot and flushed right after, so rapid slider moves never drop the last intent — server's [1,60] clamp kept as backstop, no new dependencies
  • Pure mapping helpers in new lib/timeScale.ts: sliderToScale(t) / scaleToSlider(scale), both clamped; exact at endpoints and round-tripping every integer 1..60
  • Game clock shows seconds: formatSimClock (in lib/format.ts) now renders the sim clock as YYYY-MM-DD HH:MM:SS UTC — same deterministic UTC wall-clock, seconds added
  • No backend changes: POST /api/sim signature and clamp unchanged; no OpenAPI change
  • Tests: unit — new lib/timeScale.test.ts (endpoint exactness 0→1/1→60, mid-slider ≈8×, monotonic non-decreasing over a 2000-step grid, full 1..60 coverage, clamping of out-of-range/non-finite input, round-trip for all integers 1..60) and lib/format.test.ts clock expectations updated to include seconds; e2e — e2e/panes.spec.ts time-controls test re-driven through the slider (keyboard End → max asserts label "60×" + /api/state timeScale=60, Home → min asserts "1×" + timeScale=1) and now also asserts the clock text matches HH:MM:SS; live instance restored to scale 1 + unpaused in-test and in afterEach as before
  • Verified: unit suite 150 → 159 green; tsc --noEmit clean; e2e re-run against the rebuilt compose instance

v0.9.0 — 2026-09-17 14:24

  • Single-screen 3-pane shell (PLAN.md "Layout" increment): app/page.tsx is now a CSS grid (grid-template-columns: 300px minmax(0, 1fr) 320px) — left Assets pane, center = the existing <Map/> untouched (its .map-wrap fills the middle column, header included), right placeholder data-testid="right-pane" with a subtle "Jobs & Market" heading so the next increment has a home
  • Single polling source: new hook lib/useGameState.ts polls GET /api/state every 1000 ms (configurable arg) and returns {state, loading, error, refresh} — overlap guard skips a tick while the previous request is still in flight, interval cleaned up on unmount. Called ONCE in app/page.tsx; the snapshot flows down as props so later panes share one poll loop instead of running their own
  • Left Assets pane (components/LeftPane.tsx, data-testid="left-pane"): wallet cash (formatted EUR), reputation, and a fleet list — per plane: catalog display name (ATR 72-600), status badge, fuel bar + x / y L, location (grounded → airport name; flying → "origin → destination" from its bound contract). Time controls: pause/resume toggle + 1x–60x scale select wired to POST /api/sim; after each action the pane calls the hook's refresh() so it refetches immediately instead of waiting for the next poll tick. Sim clock shown as a UTC wall-clock (data-testid="sim-clock")
  • Pure display formatters in new lib/format.ts — formatCash (EUR, 2 dp), formatFuel ("x / y L"), formatSimClock (epoch ms → deterministic UTC string)
  • No backend changes: all endpoints pre-existed (GET /api/state, POST /api/sim); no OpenAPI change; Map.tsx and its header untouched per the increment constraint
  • Tests: unit — new lib/format.test.ts (currency/fuel/clock formatting incl. fractional cash + rounding) and lib/useGameState.test.ts (fetchState success shape + exact fetch args, non-2xx error with status, network failure via mocked global fetch); e2e — new e2e/panes.spec.ts: left pane renders wallet (cash > 0)/reputation/starter ATR 72-600 at Frankfurt + sim clock, time controls pause the sim and set scale 60 through the UI (asserted against authoritative /api/state AND in-pane button/select state; restored to scale 1 + unpaused in-test and again in afterEach, same pattern as sim.spec.ts), right pane placeholder present
  • Known interaction with v0.4.x map e2e: the three-pane layout narrows the map column to ~660 px at the default 1280×720 viewport, which puts UKBB Boryspil (~475 px east of the [10,52] center at zoom 4) outside the initial view — e2e/map.spec.ts marker-click tests re-pointed from UKBB to LFRS Nantes Atlantique, now the most isolated large_airport marker inside the new initial viewport (nearest neighbour LFRB ~78 px away), same pattern as the v0.2.7/v0.2.8 dataset re-points
  • Pre-existing e2e race fixed (found while landing this increment, reproduced 5/5 on a clean v0.8.0 worktree deploy — NOT caused by the layout): MapLibre's diff-based setStyle removes our custom airports source/layer before ensureAirportsLayer re-adds it on the next styledata event (~20–40 ms window), and the style-switcher test's post-switch wait (queryRenderedFeatures > 0) can be satisfied by stale pre-switch worker data, so its single marker click could land in that gap. The test now retries the click until the popup opens (marker is clickable again within milliseconds; the toHaveCount(1) assertion still guards against duplicate listeners)
  • Verified: unit suite 143 → 150 green; tsc --noEmit clean; e2e 18 → 21 green against the rebuilt compose instance (marker-click, zoom clamp and style switch all pass in the narrower middle column)

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/dispatch body {contractId} — validates in order: contract exists & status 'accepted' (409 otherwise), plane exists & 'available', plane.locationIcao === contract.originIcao (409 "not at origin"), fuel sufficiency burnPerKm × haversineKm(origin,dest) ≤ plane.fuel (409 "insufficient fuel"); unknown ids → 404. On success sets the plane 'flying' with departureSimTime = current clockMs and 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-plane body {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-plane body {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 gains price: 200_000 on atr72 (with €20k starting cash and ~€1–3k hop rewards, a second ATR takes sustained play — the intended MVP money sink), plus constants FUEL_PRICE_PER_L = 0.8 €/L and SELL_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 via generateContract with 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
  • generateContract gains optional originIcao/destIcao to 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-contract body {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-contract under the same FLIGHT_TEST_HOOKS=1 gate — 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.ts endpoint 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.ts gains spawn-coverage tests (uncovered grounded airport ⇒ forced-origin contract, already-covered no-op, multi-airport dedupe, flying planes ignored, full tickOnce pipeline) and lib/game/seed.test.ts the forced-endpoint matrix; new colocated route tests for all five endpoints (HTTP status codes + snapshot response convention against temp DBs); e2e — new e2e/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 --noEmit clean; 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 calls tickNow() first (GET /api/state, POST /api/sim — and any future action endpoint); the tick advances clockMs by (real ms since last tick) × timeScale. New sim_state.last_real_tick_ms column 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 but lastRealTickMs keeps 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 nullable planes.departure_sim_time column carries the flight context (route comes from the bound contract, speed/burn from the catalog by model); while flying, fuel is 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 ≤ clock gets its contract marked completed, the exact reward added to account.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 open contract with deadlineSimTime ≤ 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 from buildSeed (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 in lib/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 guarded ALTER TABLE ADD COLUMN step (PRAGMA table_info check) adding planes.departure_sim_time and sim_state.last_real_tick_ms to pre-v0.7.0 files, then fixes up the ALTER-default last_real_tick_ms = 0 to "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 when FLIGHT_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) and lib/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.ts gains generateContract coverage (formula, window, determinism, deadlineOffsetMs, buildSeed ≡ builder output) and the simState assertion now includes lastRealTickMs; new app/api/sim/route.test.ts (200 shape, clamp to [1,60], pause freeze across ticks, 400 matrix); lib/db/client.test.ts gains a legacy-DB migration test; e2e — new e2e/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.ts restart-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-restart clockMs ≥ before bounded by MAX_TICK_ELAPSED_MS × timeScale. e2e/api.spec.ts endpoint list updated to four
  • Verified: unit suite 47 → 86 green; tsc --noEmit clean; 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 in lib/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-row account (cash, reputation) and sim_state (clockMs, timeScale 1–60, paused)
  • First-run seed (lib/game/seed.ts) — pure buildSeed({airports, rng, nowMs}), fully deterministic with an injected RNG (mulberry32 PRNG exported for tests): starter plane ATR 72-600 (in-code catalog constant in lib/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 reward round(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.db on 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, so persist() 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_PATH env 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/state documented 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 relational db.query.* queries and returns raw snake_case rows, so all reads use plain select()
  • 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) and app/api/state/route.test.ts (200 + exact top-level shape against a temp DB); openapi tests updated for the third endpoint; e2e — new e2e/state.spec.ts: snapshot shape from the live origin, plus restart-persistence test that records cash/clockMs/ids, runs docker compose restart flight, waits for readiness and asserts everything unchanged (PLAN.md "sim clock persisted across sessions"); playwright.config gains workers: 1 so the mid-run container restart can't race the map tests
  • Verified: unit suite 47/47; tsc --noEmit clean; e2e 11/11 green against the rebuilt compose instance (incl. both new state tests); live curl /api/state → Frankfurt starter plane + 11 contracts, /api/airports still serves 242 features after the mount change; persistence across a full docker compose up -d --build verified 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.ts exports an object documenting exactly the two public endpoints — GET /api/airports (GeoJSON FeatureCollection, with component schemas for feature collection/feature/point geometry/properties) and GET /api/version; info.version is 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 from lib/openapi.ts and 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) and lib/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 — new e2e/api.spec.ts: fetches /api/version from the live origin and asserts 200 + version matching package.json read at test time; fetches /api/openapi and 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), .gitignore now excludes exactly the generated paths (public/styles/, public/fonts/, public/sprites/, public/map-assets-manifest.json) — not all of public/; .dockerignore gets the same four entries; the v0.4.0 runtime-stage COPY --from=build /app/public ./public line is gone from the Dockerfile
  • The pack now reaches the container via a docker-compose volume mount: ./public:/app/public on the flight service — 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.mjs now writes public/map-assets-manifest.json as 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.ts gains pure styleUrlFor(id, localAssetsAvailable) → /styles/{id}.json or https://tiles.openfreemap.org/styles/{id}, plus async localAssetsAvailable() — fetches /map-assets-manifest.json once (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 before new 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 via styleUrlFor; all v0.4.0 behavior intact (fiord default, persistence key, idempotent ensureAirportsLayer on every styledata, single /api/airports fetch, window.__flightMap)
  • Tests: unit — new styleUrlFor cases (local path / exact remote OFM URL) and localAssetsAvailable cases (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 --noEmit clean; e2e suite green against the rebuilt compose instance with assets served from the bind mount (curl: /styles/fiord.json, a font .pbf, /sprites/ofm.json all 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.mjs fetches the 5 OFM styles with a browser UA, rewrites each via rewriteStyle, and extracts the font/sprite tarballs into public/ — 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: STYLES registry (5 ids + labels), DEFAULT_STYLE_ID = 'fiord', and pure rewriteStyle() — glyphs https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf → /fonts/{fontstack}/{range}.pbf, sprite https://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.json by default; new top-bar style switcher (<select data-testid="style-switch">, subtle styling in app/globals.css) listing all 5 styles, persisted to localStorage key flight-ops-style; SSR-safe — state initializes to DEFAULT_STYLE_ID and 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/airports is fetched at most once per page life (promise cached in a ref; count set exactly once), and an idempotent ensureAirportsLayer() re-adds the source+layer if missing on EVERY styledata event (initial load AND after each setStyle, 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 duplicate
  • Dockerfile: runtime stage now copies public/ from the build stage (COPY --from=build /app/public ./public) so next start serves the self-hosted assets; .dockerignore already 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 --noEmit clean; 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> in components/Map.tsx), sourced dynamically from package.json via a JSON import — future version bumps show up automatically with no code change; subtle 11px muted style added to app/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 shows v{version} with the expected value read from package.json at test time, so it stays in sync automatically
  • Verified: unit suite 10/10; tsc --noEmit clean; 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.ts pitch curve + the map move handler): the map is now always flat/top-down with standard pan/zoom only; deleted lib/camera.ts and its unit tests
  • Removed terrain: no more DEM raster source / map.setTerrain, and the ?terrain=0 / NEXT_PUBLIC_TERRAIN toggles 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 --noEmit clean; 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/airports and the circle layer pick it up with no code changes
  • Verified: unit suite 8/8; tsc --noEmit clean; e2e suite 3/3 green against the rebuilt compose instance (/api/airports serves 242 features)

v0.2.8 — 2026-09-16 23:53

  • data/airports_eu.csv: reduced the dataset to only large_airport rows (dropped all 731 medium_airport + 54 seaplane_base) — the map now shows just the 260 biggest EU airports; /api/airports and the circle layer pick it up with no code changes
  • e2e/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 --noEmit clean; e2e suite 3/3 green against the rebuilt compose instance (/api/airports serves 260 features)

v0.2.7 — 2026-09-16 23:39

  • data/airports_eu.csv: removed all 6400 small_airport rows from the OurAirports EU export — the map now shows only the 1045 bigger airports (260 large + 731 medium + 54 seaplane_base); /api/airports and the circle layer pick it up with no code changes
  • e2e/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/airports serves 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.json replaced by bun.lock; no BuildKit type=cache mounts — 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 it
  • Dockerfile: new prod-deps stage (bun install --production) so the runtime image carries prod-only node_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-run next 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 local next dev on :3000; deploy current code first with docker compose up -d --build; browsers run through the shared remote Playwright server at 192.168.1.4:3000, and plain npx playwright test handles deploy + readiness + remote connect
  • playwright.config.ts: dropped the E2E_LOCAL=1 local-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, with webServer running docker compose up -d --build; E2E_REMOTE=1 and explicit PW_TEST_CONNECT_WS_ENDPOINT/E2E_BASE_URL overrides 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) — baseURL defaults to that URL and webServer runs docker compose up -d --build (300 s timeout) instead of npm run dev, because next dev does not render for non-loopback clients: Turbopack's HMR WebSocket fails from the remote browsers, so the page loads blank
  • E2E_LOCAL=1 keeps the local iteration path (browsers on this box, webServer auto-starts npm run dev, baseURL localhost:3000) as a fallback when the proxy or compose stack is down; E2E_REMOTE=1 and explicit PW_TEST_CONNECT_WS_ENDPOINT/E2E_BASE_URL overrides 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 exports PW_TEST_CONNECT_WS_ENDPOINT pointing at that box; in this mode baseURL defaults to this box's LAN IP (http://192.168.1.26:3000) because pages load from the remote browser's perspective, while the local webServer still auto-starts npm run dev
  • E2E_LOCAL=1 restores fully-local runs (browsers on this box, localhost:3000 baseURL) as a fallback when the remote server is down; E2E_REMOTE=1 keeps its previous behavior for runners on other machines — skips webServer entirely and pairs with E2E_BASE_URL; an explicit PW_TEST_CONNECT_WS_ENDPOINT always 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 — with E2E_REMOTE=1 the local webServer block is skipped entirely and baseURL defaults to http://flight.dev.kkhost.pl; E2E_BASE_URL overrides baseURL in both modes; with no env vars set behavior is unchanged (local webServer auto-starts npm run dev)

v0.2.1 — 2026-09-16 19:49

  • Clamp map zoom to the useful gameplay range: minZoom: 3 / maxZoom: 14 on the MapLibre options in components/Map.tsx (exported as MIN_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 via expect.poll on the map instance that getZoom() 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) running next start on port 3000; no output: 'standalone' — the runtime stage keeps full node_modules because /api/mlworker/[file] reads maplibre-gl dist files from disk at runtime (and /api/airports reads data/*.csv)
  • .dockerignore: node_modules, .next, data/*.db, test-results, *.tsbuildinfo, plus .git/.env/.codenomad and the e2e harness (@playwright/test is 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 service flight, no published ports, joins external network dev_net with Traefik labels routing Host(flight.dev.kkhost.pl) → port 3000 (plain HTTP entrypoint web; 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=0 or NEXT_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 in lib/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 (webServer auto-starts npm run dev; page load + airport count + marker click popup)