CI / build + unit + e2e (pull_request) Failing after 5m28s
Part A: r(F) = k(We + rho*F) L/km, k calibrated so a full tank burns exactly
the old constant; closed forms in lib/sim/movement.ts (fuelAtWeighted,
requiredFuelFor, burnRateConstant, FUEL_DENSITY_KG_PER_L). Catalog gains
emptyWeightKg (ATR72 OEW 9300 kg), exposed in the snapshot. All three burn
call sites (engine landing, state live fuel, fly validation) moved to the
weighted model; old constant-rate fuelAt removed.
Part B: POST /api/planes/{planeId}/refuel takes an optional body
{mode:'full'|'forFlight', packageId} — no body keeps today's fill-to-full
behavior exactly (fuel lands bit-exact on maxFuel); forFlight adds just
enough for the loaded package's destination leg (409 not-loaded/expired/
doesn't-fit, 400 bad body). UI: per-cargo-row 'Refuel' button
(refuel-for-{pkg.id}), market button renamed 'Refuel to full'.
OpenAPI: RefuelRequest schema + optional requestBody + 400 on refuel, fly's
409 description updated, CatalogEntry.emptyWeightKg. Tests: new weighted-burn
suite, forFlight action/route cases (incl. ~7670 km UNNT<->LPPD doesn't-fit),
openapi guards; landing-fuel assertions in engine.test.ts + e2e/actions.spec.ts
use the closed form; the e2e 'return leg unfuelable' scenario reworked — a full
tank now covers the EDDF<->LEZL round trip, so the 409 moves to the second
outbound. New e2e: row Refuel deducts exactly L*price / raises fuel by exactly
L via the endpoint's own response snapshot.
274 lines
84 KiB
Markdown
274 lines
84 KiB
Markdown
# 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
|
||
|
||
## 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)
|