Files
Flight-Ops/CHANGELOG.md
kamilkrzywda b2034ebc52
CI / build + unit + e2e (pull_request) Successful in 14m25s
v1.0.0: players table + migration from singleton account (ISS-105)
2026-09-24 14:26:34 +00:00

233 KiB
Raw Permalink Blame History

Changelog

v1.0.0 — 2026-09-24 14:20

  • Multiplayer step 1/12 (ISS-105): players table replaces the singleton account. New players table (id TEXT PK, provider, username, cash REAL, reputation REAL) in the raw DDL (lib/db/client.ts) and a matching Drizzle entry (lib/db/schema.ts; AccountRow → PlayerRow). One-time migration: on every open(), if the legacy account table still exists (sqlite_master check, same guard style as the v0.12.0 contracts migration) AND players is empty, its single row is copied into a seeded local player — id local, provider local, username kamilkrzywda (so a later step can join players↔users on username) with cash/reputation carried over — and account is dropped. Idempotent: fresh DBs never have account, already-migrated files no longer do either, so it runs exactly once per legacy file. Fresh worlds: the DDL no longer creates account; first-run seeding (empty-check flipped from COUNT(*) FROM account to players) inserts the local player row directly with the same starting cash/reputation as before.
  • No behavior change — temporary single-player helper. Every pre-multiplayer read/write of the singleton economy now goes through ONE small module, lib/db/localPlayer.ts: getLocalPlayer(inst) (resolves the players row where id = 'local', throws if missing) and updateLocalPlayerEconomy(inst, {cash?, reputation?}). Call sites converted: snapshot (lib/game/state.ts), all five actions (flyPlane refuel, abandon penalty, buy/sell plane, repair — lib/game/actions.ts), the engine's delivery awards + chain top-up + spawn reputation read (lib/sim/engine.ts), and the three test hooks (add-cash / set-cash / create-package). The /api/state snapshot keeps its exact account: {cash, reputation} field name and values, so OpenAPI is untouched — 17 paths, same schemas.
  • Tests. Unit: fresh DB now asserts the seeded local player row (local/local/kamilkrzywda, starting cash/reputation) AND that no account table exists; new dedicated migration test builds a minimal legacy file containing only an account table and verifies open() copies its row into the local player, drops account, and is a no-op on second open. All existing suites (actions, engine, client, buy/sell/abandon routes) now read/write through the helper; the admin rows CRUD test's PK-less-table example moves from account to sim_state (the only remaining PK-less table), and the admin tables-list expectation swaps account → players. No new e2e spec — behavior is byte-identical and CI's existing suite covers the UI paths; a fresh-stack boot exercises the new seed path.

v0.73.0 — 2026-09-24 12:24

  • New-package toasts removed. The client-side snapshot diff in lib/deriveEvents.ts no longer emits "New package available" info toasts when a contract appears (the user was spammed by them); the first-snapshot guard and every other event type — delivered, expired, landed, bought, sold — are unchanged.

v0.72.0 — 2026-09-24 11:44

  • Admin login fixed. The seeded admin's scrypt digest in lib/db/client.ts was computed with different parameters than the verifier (verifyPassword() passes the salt as a plain string to scryptSync(pw, saltHex, 32)), so real logins failed with 401 "invalid credentials" since v0.71.0. The seed now carries a digest produced by exactly that call (the plaintext still exists nowhere in code, tests or comments). Because the seed is INSERT … WHERE NOT EXISTS, pre-existing databases keep their old row — so every open() also runs an idempotent rotation (UPDATE users SET password_hash = ? WHERE id = 'admin-kamilkrzywda' AND password_hash != ?): a no-op once healed, it repairs legacy rows at boot and is durable on the next persist. Unit tests cover the fresh seed with the new digest plus the rotate-on-open path (legacy file heals to the current digest; second open is a no-op).

v0.71.0 — 2026-09-24 09:18

  • Admin panel. A new "Admin" button on the right of the top bar opens a reusable modal (components/Modal.tsx — fixed overlay at z-index 60, above the z-50 toast stack; centered role="dialog" panel; closes on Escape and backdrop click) hosting components/AdminPanel.tsx. No new URL routes: everything lives behind that one button + modal.
  • Users + sessions tables. New users (id PK, unique username, scrypt salt$hex password_hash, admin flag, created_at) and sessions (UUID id, user_id, 7-day expires_at) tables in the raw DDL (lib/db/client.ts) and typed Drizzle entries (lib/db/schema.ts). An idempotent seeded admin is inserted on every open() — user id admin-kamilkrzywda, username kamilkrzywda, admin=1, hash-only (the plaintext exists nowhere in code, tests or comments) — guarded by WHERE NOT EXISTS so pre-existing production DBs gain exactly one row and never a duplicate. The users table is also the foundation for multiplayer auth (#105/#107).
  • Auth API. POST /api/login {username,password} verifies with node:crypto scryptSync + timingSafeEqual; an unknown username still runs one full scrypt against a fixed dummy digest so timing stays uniform. Success mints a 7-day session (lazy cleanup of expired rows) and returns {sessionId, user:{username,admin}}. POST /api/logout deletes the session named by the new X-Flight-Session header (idempotent); GET /api/me resolves it to the current user or 401. The client stores the sessionId in localStorage (flight.session) and sends the header on every authenticated request.
  • Generic whole-database CRUD behind the admin flag. lib/auth.ts's requireAdmin guard (no/expired session → 401, non-admin → 403) runs before any input is touched on: GET /api/admin/tables (user tables, sqlite_% excluded) and GET|PUT|POST|DELETE /api/admin/tables/{name}/rows — rows carry their rowid as __rowid so the PK-less tables (account, sim_state) are editable like any other; PUT updates only changed cells, POST inserts, DELETE removes by rowid. SQL safety: table names quoted only after sqlite_master validation, column names validated against PRAGMA table_info, values ALWAYS bound as parameters — no user input is ever concatenated into SQL.
  • UI. The Admin modal shows a login form (wrong credentials show an error line), a "not an admin" notice for authenticated non-admins, or the grid: table list → per-table editable rows (each cell an input; per-row Save/Delete, "+ Add row", Refresh) with logout in the header area. Deliberately plain — it's a dev tool surface, not a product feature.
  • Test hook. POST /api/test/admin-session (FLIGHT_TEST_HOOKS=1 gate like its siblings) mints a session for the seeded admin server-side and returns {sessionId} — so the e2e suite can exercise the authenticated panel WITHOUT the real admin password ever appearing in test code.
  • OpenAPI. Five new paths documented (/api/login, /api/logout, /api/me, /api/admin/tables, /api/admin/tables/{name}/rows with all four verbs) plus their request/response schemas; endpoint count twelve → seventeen everywhere it is asserted (unit + e2e).
  • Tests. Unit: the idempotent admin seed (fresh DB, reopen, and a legacy pre-v0.71.0 file), login success/wrong-password/unknown-username/malformed bodies with an in-test scrypt hash for a self-created user (the real admin's plaintext is never referenced), logout idempotency, /api/me with missing/unknown/expired/live sessions, the guard ladder (401 no header, 401 expired, 403 non-admin) and the full CRUD validation matrix (rows incl. __rowid for account, PUT-verified-by-refetch, POST+DELETE round-trip, unknown table 404, unknown column/bad limit/missing rowid 400). E2E (e2e/admin.spec.ts, CI-only): open modal → login form → wrong creds error → hook-minted session via localStorage → table list incl. packages/users → edit a cell + Save verified by an independent re-fetch → add + delete a users row → logout back to the login form.

v0.70.0 — 2026-09-23 22:07

  • Default map view is now the whole world. The initial camera in components/Map.tsx moves from the Europe overview [10,52] z4 to null island [0,0] (renamed constant WORLD_CENTER) at zoom 2 — at z2 the world is 2048 px wide and the Africa+Europe bounding box (lat −35..+71, lon −18..+52) spans ~800 px vertically in Web Mercator, so both continents are fully visible on a typical desktop map pane. MIN_ZOOM is lowered 3 → 0: when fully zoomed out the whole earth (512 px world) fits any viewport; MAX_ZOOM = 11 and the flat-2D view are unchanged.
  • E2E. The new default view shows both Africa + Europe with overlapping markers — LFRS's nearest neighbour (LFRB Brest, 254 km) is only ~13–19 px away at z2, so every marker-click spec now frames its target first: e2e/map.spec.ts gains a shared frameLfrs helper (jumpTo z5 + wait until the marker renders), used by all three of its click tests; e2e/map-interactions.spec.ts and e2e/relocation.spec.ts frame LFRS at z5 before their popup clicks. The plane-marker test in e2e/map-interactions.spec.ts fits EDDF+LEMD into the viewport first — queryRenderedFeatures is viewport-scoped and EDDF (50°N) sits right at the edge of the new default view. e2e/hover.spec.ts: the antimeridian test's setMinZoom(0) workaround is deleted — minZoom is natively 0 now, so fitToAirports can reach the z≈1 that EDDF→NFTF needs without lifting any floor. New assertion in e2e/map.spec.ts pins the initial camera to [0,0] z2; the wheel-clamp spec's MIN_ZOOM mirror is updated 3 → 0 (its zoom-out side now clamps at 0).

v0.69.0 — 2026-09-23 21:54

  • Job ignore removed end-to-end. The "Ignore" button on open jobs (v0.29.0, ISS-047 part B) is gone with everything behind it: the POST /api/packages/{id}/ignore route, the ignorePackage action, and 'ignored' from the package status enum (schema, snapshot type, OpenAPI — now twelve endpoints). Packages can only be loaded, delivered, expired, or abandoned.
  • Legacy 'ignored' rows self-heal. Production databases already contain rows with status='ignored'; nothing ever cleaned them up (expireOpenPackages only selected 'open'). The sweep now selects IN ('open','ignored') and flips any row past createdRealMs + JOB_LIFETIME_MS to 'expired', so every legacy row heals on the first tick after its lifetime lapses (in practice: the first tick after deploy) and no new ones can ever be created. Reads before that first tick are safe: Drizzle does no runtime validation, nothing renders a non-open/loaded/expired package, and GET /api/state ticks first anyway.
  • Tests. Unit: the ignorePackage suite is deleted; terminal-status fixtures drop their redundant 'ignored' rows (delivered/abandoned already cover "terminal never flips"); the frozen-top-up drain now flips to 'expired' mirroring expireOpenPackages; the taken-slot cooldown ladder keeps two stamped slots, both 'loaded'. E2E: the Ignore UI test is deleted; sim.spec.ts's soft-lock regression drains via the existing /api/test/expire-package hook (loading can't drain a pool — the starter's payload fits only the lightest band) and expiry.spec.ts cleans up its created packages through the same hook. OpenAPI endpoint count thirteen → twelve everywhere it is asserted.

v0.68.0 — 2026-09-23 20:32

  • Special remote airports re-curated per #144. The old remote pass kept EVERY non-large fixed-wing row with no large airport within 1000 km, so regions without big cities (central Australia, Canada edges) got filled with dozens of tiny strips. scripts/build-world-airports.mjs now greedily thins the same candidate pool to one special per ~1000 km region: candidates are sorted by priority (scheduled service first, then type rank medium < small < seaplane, then numeric id) and a candidate is accepted only if it is at least REMOTE_RADIUS_KM from EVERY already-accepted special — no special airport within 1000 km of another. This subsumes the old 40 km remote clustering (deleted). Regenerated data/airports_world.csv: 1483 → 1161 rows (1119 large unchanged, specials 364 → 42; min pairwise special distance 1001.6 km).
  • Tests. New committed-dataset invariant in lib/airports.test.ts (same style as the existing remote-rule test): every pair of non-large rows is at least REMOTE_RADIUS_KM apart (pairwise haversine, O(n²) — n≈42). The existing "no large airport within 1000 km" invariant stays and still holds. No runtime code changes in lib/airports.ts.

v0.67.0 — 2026-09-23 18:03

  • Prune runner dind leftovers after e2e. The CI runner's full-dind daemon is shared with production and other workflows, so between runs it accumulates what the existing cleanup steps can't reach: dangling images/layers from the per-run flight-ops-ci rebuilds, BuildKit cache (persists by design), and job containers leaked by interrupted runs. A new non-fatal "Prune runner dind leftovers" step (if: always(), placed after the existing "Cleanup e2e stack + network") runs docker system prune -af || echo "::warning::dind prune failed — see logs" so a prune failure can never mask or break the run's real result. Deliberately NO --volumes: named volumes on the shared daemon may hold other workflows' persistent data — this project's own flight-ci_flight-db volume is already dropped by the existing cleanup steps. No app/TS changes; CI validates the workflow itself.

v0.66.0 — 2026-09-23 17:06

  • E2E results in the Gitea job summary (#141). The run page's "Job summaries" panel showed vitest results (vitest's built-in github-actions reporter auto-appends $GITHUB_STEP_SUMMARY when GITHUB_ACTIONS=true) but nothing about the Playwright suite, so a failed e2e run required digging into raw logs to see which specs broke. The CI E2E step now also runs Playwright's built-in json reporter (--reporter=list,json, output pinned to git-ignored test-results/e2e.json via PLAYWRIGHT_JSON_OUTPUT_FILE — no new dependencies, no config/TS changes), and a new non-fatal "E2E job summary" step (if: always()) parses that report with an inline node script and appends spec-level passed/failed/skipped counts plus the file + title of every failed spec to $GITHUB_STEP_SUMMARY — mirroring exactly how vitest gets there. The append is guarded (|| true, missing/malformed report = silent no-op) so it can never fail or mask the e2e step's real exit code; test behavior, timeouts, workers and retries are untouched.

v0.65.0 — 2026-09-23 12:58

  • Per-airport job pools with real-time refresh. The market was a global bag of jobs (top-up to MARKET_TARGET_OPEN_PACKAGES = 10 + coverage spawn at uncovered grounded airports), so the board showed work you could never take and hid nothing. Now open jobs are generated PER AIRPORT where a plane can actually pick them up — the pool is the set of grounded planes' locationIcao, flying planes' destIcao and queued-leg ICAOs — queued legs count even while their plane is in the shop (the work will be waiting at its next stop); a fleet fully repairing with empty queues has no pool, and orphaned pools simply expire out. Each pool airport holds exactly POOL_TARGET_PER_AIRPORT = 10 open jobs in a fixed weight composition (POOL_BANDS: 2×[500,4000) + 6×[4000,18000) + 1×[18000,48000] + 1×(48000,80000] kg), so the board always mixes jobs you can take now with heavier ones that are what you buy better planes for. spawnPoolPackages in lib/sim/engine.ts replaces both old spawners and runs on every tick (including frozen ones) like v0.64.0's top-up; seeded and spawned packages come from the same new generatePoolPackage helper in lib/game/seed.ts, so they are indistinguishable — buildSeed now seeds a full pool at the starter's home hub, and PACKAGE_COUNT_MIN/MAX + MARKET_TARGET_OPEN_PACKAGES are retired.
  • Open jobs live on the wall clock. New created_real_ms INTEGER NOT NULL DEFAULT 0 column (guarded ALTER backfilled with Date.now() for existing rows): an open job expires JOB_LIFETIME_MS = 1 h after creation regardless of time scale or fleet state — checked every tick OUTSIDE the advance gate, so a paused world still refreshes its board. Loaded jobs keep their sim-clock deadline logic unchanged (delivery check, expiry floor, abandon penalty all untouched). Taken slots (loaded OR ignored) stamp slot_free_at_real_ms and hold their pool slot for a refill cooldown — REFILL_DELAY_MS = 15 min, or SMALL_REFILL_DELAY_MS = 1 min for the <4 t band — so the board visibly refreshes instead of instantly refilling; expired/delivered/abandoned never block. ponytail: one shared per-airport cooldown (the max stamp) rather than a per-band table, add when per-band refill timing matters.
  • Visibility + UI. /api/state and SSE now filter open packages to pool origins (poolAirports in lib/game/state.ts; all non-open statuses pass through unchanged), so the right pane only ever shows jobs you can take — no disabled Load buttons for foreign-origin work. PackageState gains expiresRealMs (open → createdRealMs + JOB_LIFETIME_MS, else null; OpenAPI updated) and the open-job row renders a wall-clock countdown via the existing pure formatDeadlineRelative formatter (no new timer — the ~1 s poll re-renders anyway); loaded jobs keep their sim-deadline display.
  • Test hooks. /api/test/expire-package rewritten for the wall clock: {packageId} ages an OPEN job's createdRealMs past its lifetime (HOOK_AGE_MS = JOB_LIFETIME_MS + 60_000) and runs a tick, so it comes back already expired (200; unknown/non-open ids 404); no body creates one already-aged hub job (201). CI env: docker-compose.ci.yml sets FLIGHT_JOB_REFILL_MS=5000 + FLIGHT_JOB_SMALL_REFILL_MS=2000 under the existing FLIGHT_TEST_HOOKS=1 gate — the lifetime stays 1 h everywhere on purpose, so state.spec.ts's mid-suite container restart can't race a whole board expiring.
  • Tests. Unit: lib/sim/engine.test.ts rewritten around the new functions — expireLoadedPackages (sim clock) and expireOpenPackages (v0.65.0 wall clock) suites (both with inclusive-boundary edges), the frozen-top-up test now asserts exactly 10 open jobs all at EDDF, and a six-test spawnPoolPackages suite pins band composition, determinism under an injected RNG + wall clock, pool membership for flying/queued airports (departure airport excluded while its plane is airborne), the taken-slot cooldown ladder, and the full tickOnce pipeline. lib/game/seed.test.ts: buildSeed now asserts the exact hub-pool composition (10 jobs, band counts, weights in-band, createdRealMs = seed moment) plus a "board always offers jobs beyond the starter" pin; the replay test mirrors generatePoolPackage; five full-object equality tests pin createdRealMs to avoid millisecond-boundary flakes. E2E: e2e/sim.spec.ts's expiry tests are wall-clock now (hook ages + ticks, then polls the pool back to target — no scale or clock activation); e2e/right-pane.spec.ts replaces the LEMD Load-disabled test with an absence assertion (a non-pool-origin open job never reaches /api/state); e2e/hover.spec.ts's antimeridian pair moves NZAA→NFTF to EDDF→NFTF (same ±180 eastward crossing, sampled maxJump 359.98) because the hook must create jobs at a pool airport; e2e/expiry.spec.ts pins the new hook contract (aged job expired by response time, unknown id 404, already-expired 404).

v0.64.0 — 2026-09-23 10:06

  • Market keeps topping up while the fleet is grounded (#135 follow-up). Root cause of a soft-lock introduced by v0.62.0's frozen clock: in tickOnce (lib/sim/engine.ts) every package-CREATION path (market top-up + coverage spawn) sat behind the same advanceMs > 0 gate as expiry/delivery/repairs, while removal paths did not — ignorePackage runs on any request regardless of advance. A fully grounded fleet therefore froze the clock AND stopped all spawning, so repeated ignores could drain the market below MARKET_TARGET_OPEN_PACKAGES = 10 (down to zero) and no code path ever created new jobs again: a player with a grounded fleet and an empty market was stuck forever. Fix: only the two spawn calls moved OUT of the gate — spawnOpenPackages + spawnCoveragePackages now run on EVERY tick, including frozen ones, passing the current (possibly frozen) clockMs; expirePackages, deliverPackages and finishRepairs stay inside it. Both spawns were already idempotent no-ops when satisfied (count-gated at target / set-gated by uncovered grounded airports), so advancing ticks behave exactly as before and the clock stays EXACTLY frozen while grounded — only new packages appear, created at game time. No schema change, no migration, no other engine behavior touched.
  • Tests. Unit: lib/sim/engine.test.ts — "a frozen tick still tops up the market so a grounded fleet never starves (#135 follow-up)": fully grounded fleet (advance=false), open market drained to zero via direct DB writes, one tick → asserts BOTH that sim.clockMs is unchanged (freeze preserved) AND that the open count is back at ≥ MARKET_TARGET_OPEN_PACKAGES with ≥1 open package originating at the grounded starter's airport (EDDF coverage). E2E: e2e/sim.spec.ts — "a grounded fleet never starves: ignoring every open package cannot empty the market (#135 follow-up)": self-heals to a fully grounded fleet, drains by calling POST /api/packages/[id]/ignore for every open package in repeated passes (pre-fix this reached zero and stayed there; post-fix each request's tick re-tops-up before its removal, so it can't), then expect.polls that the open count from /api/state is above 0 within ~5 s of real time while asserting simState.clockMs stays EXACTLY equal to its pre-drain value and no plane took off — pinning both starvation-fixed and clock-still-frozen.

v0.63.0 — 2026-09-23 08:43

  • Overlay colors retuned for the Fiord default (ISS-118). The first half of this issue landed in v0.4.0 (DEFAULT_STYLE_ID = 'fiord' + top-bar switcher, e2e already pins toHaveValue('fiord')); this completes the second half — auditing every color drawn on the map against Fiord's actual palette. Key finding: OpenFreeMap's Fiord is a DARK cool-toned basemap (land #45516E slate, water #38435C navy — verified byte-identical between the self-hosted public/styles/fiord.json and tiles.openfreemap.org/styles/fiord), not a light one. WCAG contrast audit of all nine overlay colors against both base surfaces: seven already read fine (≥ 3.3:1 on land AND water) and are untouched — amber flying leg #f59e0b, gold dotted queue line #d4a017 (ISS-098 picked it for light+dark), sky-blue grounded plane + hover halo #38bdf8, green flying plane #22c55e, yellow hover ring/trace #facc15, green target ring #4ade80. Two were weak and got brightened one step within the same hue family: airport dot fill rose-600 #e11d48 (1.68:1 on slate land — the weakest element on the map) → rose-500 #f43f5e (2.15:1), and the hovered-route highlight red-500 #ef4444 (2.10:1, below the 3:1 graphical-object bar for an emphasis layer) → red-400 #f87171 (2.86:1 on land / 3.57:1 on water). White strokes and all other paint values unchanged; no new constants — both colors are single-use inline paint values, matching the existing style of this file.
  • Tests. No test changes needed: components/map.test.ts pins only #d4a017 (untouched), e2e specs query rendered features by id/layer rather than color, and lib/mapStyles.test.ts already pins DEFAULT_STYLE_ID = 'fiord'.

v0.62.0 — 2026-09-23 07:36

  • Fixed 24x world clock (#135). Finalized the closed issue #117 spec: the world clock runs at the fixed WORLD_TIME_SCALE = 24 while any plane is flying or in repair, and stays FROZEN when the whole fleet is grounded — replacing v0.56.0's idle slowdown (IDLE_WORLD_TIME_SCALE = WORLD_TIME_SCALE / 24, ISS-128). Production rate is now exactly 24x regardless of legacy stored values: tickOnce in lib/sim/engine.ts computes advanceMs = advance ? min(elapsed, MAX_TICK_ELAPSED_MS) × scale : 0 where scale = FLIGHT_TEST_HOOKS === '1' ? sim.timeScale : WORLD_TIME_SCALE — pre-v0.52.0 saves hold timeScale: 1000 (the old UI ladder), which is why the live instance still ran at ×1000; only deployments with the env var set honor the stored override written by POST /api/test/set-timescale (range [1, 100_000], kept for tests). A frozen tick skips expiry/delivery/repair/spawn entirely and only updates lastRealTickMs, so resuming activity never fast-forwards the frozen span. The client mirror (advanceSimClock in lib/useSimClock.ts) holds its anchor value while grounded, so polls re-anchor without any jump. Removed: IDLE_WORLD_TIME_SCALE from lib/worldClock.ts, and — separate finding — FLIGHT_TEST_HOOKS: "1" from the production docker-compose.yml: it enabled the /api/test/* hooks in prod AND (pre-gate) made the live instance honor its stale stored 1000; e2e runs only in CI against docker-compose.ci.yml, which sets the var itself. Unchanged on purpose: the ~1 s background tick (started at boot), the test hook endpoint, and the inert legacy columns paused/lastSelectedTimeScale (nothing reads them; no migration — per #117 saves reset when multiplayer ships). The OpenAPI description of /api/state ("while no plane is flying or in repair the clock stays frozen") now matches the code.
  • Tests. Unit: lib/sim/engine.test.ts — both rate branches pinned: with FLIGHT_TEST_HOOKS=1 stubbed (the file's default, like CI) stored overrides are honored; a new production test unsets the var and proves a legacy stored timeScale: 1000 still advances at exactly ×24. Grounded-fleet tests pin a FROZEN clock (no idle creep, resume-without-catch-up after takeoff, raised timeScale ignored while grounded); describe renamed to "grounded fleet freezes the world clock (#135)" and pins WORLD_TIME_SCALE = 24. lib/useSimClock.test.ts — the grounded case asserts staying exactly at the anchor value. E2E: e2e/sim.spec.ts's idle test becomes an exact freeze (delta === 0 after ~2.5 s of idleness); e2e/state.spec.ts's restart test tightens from bounded delta to EXACT clock equality across docker compose restart; e2e/right-pane.spec.ts verifies the frozen clock, then asserts the abandon-penalty preview matches the server exactly (zero drift under a frozen clock — replaces the DRIFT_MS slope tolerance); e2e/events.spec.ts relaxes strict clock advance between SSE frames to non-decreasing (a grounded fleet pushes identical-clockMs frames; advance itself is pinned in sim.spec.ts). The activateWorld/deactivateWorld wrappers are unchanged — a repairing plane still means full scale.

v0.61.0 — 2026-09-23 07:12

  • Special remote airports (ISS-097). The world dataset was curated to large_airport only, so genuinely empty regions — Antarctica, the high Arctic, Alaska, northern Canada and far islands — had no markers at all. New pure function isRemote(airports, icao) in lib/airports.ts (reusing the existing haversineKm from lib/geo.ts): an airport is remote when no other member of the given list lies within REMOTE_RADIUS_KM = 1000. The curation in scripts/build-world-airports.mjs now runs over ALL fixed-wing rows: every large_airport (unchanged clustering) plus every non-large row that is remote against the kept set; same-station twins (two strips <40 km apart at one base) collapse via the same greedy 40 km clustering. data/airports_world.csv grows from 1119 → 1483 rows (+364 remote airports), including Rothera (EGAR), Halley (EGAH) and Showa skiway (AT25) in Antarctica; Grise Fiord (CYGZ), Alert (CYLT), Ny-Ålesund (ENAS — Svalbard's only marker within 1000 km of a large airport) and Wrangel Island (UHMZ) in the Arctic; Bethel (PABR) and other Alaska strips; Canadian-Arctic smalls; and far islands such as Easter (SCIP), Canton (PCIS), Henderson/Pago Pago (PMDY) and Ascension (FHAW). The only change to a pre-existing row is an upstream home_link refresh on CYLW. New airports become contract origins/destinations and map markers unchanged — the most isolated airport in the new set (Easter Island) has its nearest neighbour 2599 km away, well under the 9000 km job cap, so every origin stays flyable; filterWorldAirports already accepts small/medium/seaplane types, so no runtime change was needed.
  • Tests. Unit +4 in lib/airports.test.ts: a synthetic radius boundary (equator pair at ~945 km vs ~1052 km), real coordinates (Frankfurt↔Amsterdam is not remote; McMurdo vs Christchurch is), an unknown ICAO returning false, and the committed-dataset curation invariant — every non-large row has no large airport within 1000 km and at least one non-large row exists.

v0.59.0 — 2026-09-23 05:48

  • SSE frames no longer carry package history (ISS-133). Measured on the live instance first: each /api/events frame was compact JSON at ~597 KB, pushed once per real second regardless of time scale (the background tick is a fixed 1 s interval; ×1000 just advances the clock further per frame) — and 99.8 % of it was packages: 1,521 rows because terminal packages are never deleted from the DB (expired 564, delivered 528, ignored 386, abandoned 31), while only 12 were open. No component ever reads a package with a terminal status — the market list renders open, and on-board cargo is loaded/expired (LeftPane, cargoWeightFor) — so the history was pure re-sent weight. New sseFrame(snapshot) in lib/game/state.ts projects a snapshot down to the client-rendered statuses (SSE_PACKAGE_STATUSES = open|loaded|expired); both SSE paths use it — the broadcast in lib/sim/engine.ts and the initial frame in app/api/events/route.ts, so a connect never costs one full-history frame. Live payload drops ~597 KB → ~10 KB per frame (≈ 60×), bounded forever by fleet/market size instead of growing with play time. /api/state and action responses keep the FULL package set unchanged, so every e2e oracle that asserts delivered/abandoned transitions via state lookups still works; the polling fallback stays a strict superset of what SSE pushes. Deliberately NOT done: hash-skip broadcasts (the snapshot's clockMs advances on every tick even while idle — advanceMs = min(elapsed, 2000 ms) × scale ≥ 1 s — so no two frames are ever equal), throttling (already exactly 1 Hz), and a diff/patch engine.
  • Tests. Unit +3 in new lib/game/state.test.ts: the projection keeps exactly open/loaded/expired (order preserved, terminal rows dropped), passes account/simState/planes through untouched without mutating its input, and pins the status set itself. E2E: e2e/events.spec.ts now records every package status seen in any frame plus the largest frame — asserts no terminal status ever appears and frames stay under 200 KB (a full-history regression at ~597 KB fails loudly).

v0.58.0 — 2026-09-22 21:27

  • Plane markers no longer teleport across the antimeridian (ISS-124). The follow-up to ISS-120's known limitation: a plane mid-crossing still used the snapshot's wrapped lat/lon (positionAt wraps every longitude into [−180, 180]), so its marker jumped +179.x → −179.x between polls — a visible 360° teleport on the map while its route line stayed continuous. planesGeoJson in lib/mapData.ts now takes the airport index (airportsByIcao, same parameter shape as routesGeoJson) and re-expresses each FLYING plane's longitude against its departure airport — the same anchor greatCircleArc keeps for its first sample — via a new module-private unwrapLon(lon, prev) (the ±360 shift until |Δlon| ≤ 180 that unwrapLons's loop body already used; unwrapLons now calls it). The unwrap applies only when BOTH leg endpoints exist in the index, mirroring routesGeoJson's guard exactly: a marker is unwrapped if and only if its line is drawn. Grounded planes, repairing planes and flying planes with a missing endpoint keep their raw wrapped position (unchanged behavior); non-crossing legs are byte-identical (no |Δlon| > 180 → the shift never fires). components/Map.tsx passes airportsIndexRef.current ?? new Map() at both call sites (applyDynamic and the source bootstrap in ensureLayers) — planes draw immediately at their wrapped position until the airport index loads, then unwrap.
  • Tests. Unit +6 in lib/mapData.test.ts (new "antimeridian marker unwrap" block on the real NZAA/NFTF pair): eastward crossing unwraps a mid-leg −179.5 to +180.5 against the origin's +174.8, westward (reversed pair) unwraps below −180, and — the key invariant — every sample of greatCircleArc(NZAA→NFTF) fed back as a WRAPPED snapshot position comes out exactly on that arc's own sample, so marker and line agree frame to frame; grounded planes keep their raw coordinate, a flying plane with a missing endpoint stays wrapped (no line drawn either), and non-crossing legs are untouched. E2E +1 in e2e/map-interactions.spec.ts: the starter is relocated to Auckland via a fewest-legs chain over /api/airports (each hop ≤ 3400 km — within the ATR72's guaranteed range even in worst-case headwind; the tank is topped for free with the set-fuel hook before every dispatch so auto-refuel costs nothing), then a hook-created NZAA → NFTF package is loaded and flown at scale 1000 while the marker's longitude is sampled from the planes source every ~500 ms (real time) until the server reports landing — no two consecutive samples may differ by ≥ 180° (the teleport), some sample must sit on the east world copy (lon > 180, proving the crossing was actually traversed unwrapped rather than skipped), and the sampled span must exceed 4°. The plane chains back home afterwards so the other specs start from the canonical state.
  • E2E fix (CI run 176). The new antimeridian test failed deterministically in CI — not a marker bug, but a query trap: MapLibre's querySourceFeatures only returns features in LOADED tiles, and GeoJSON sources load per viewport tile; the default camera sits over Europe while NZAA/NFTF are in the Pacific, so the plane feature never appeared in queries and the "flying" poll timed out (the same pattern passes for EDDF→LEMD because that leg is inside the default view). The test now fits the camera over both endpoints first via fitToAirports (copied from e2e/hover.spec.ts, including its ISS-120 unwrap so a ±180-straddling pair bounds the Pacific, not the Atlantic) before polling and sampling. Pollution guard: relocation + crossing now run in a try/finally whose cleanup ALWAYS runs — it waits for any in-flight leg to land (scale 1000), then chains the plane back to EDDF from wherever it is, so a failed poll can no longer leave the starter FLYING and 409-cascade into the eleven later specs' fly-home helpers (the run-176 cascade). The dispatch POST's status is now checked explicitly with a clear message instead of a bare expect(...).toBe(200). Unit +1 in lib/mapData.test.ts pins the exact feature properties the e2e starterMarker matches on (properties.id === 'plane-starter', properties.flying === true).
  • E2E fix (CI run 177). The antimeridian test still failed — again not a marker bug: by dispatch time the test's page had been alive ~3 min through the relocation chain, and under dind load the browser's state feed can go stale (the SSE fallback in lib/useGameState.ts only re-arms polling on stream error or a one-shot 5 s silence check at connect), leaving the planes source frozen on an old FLYING frame from a relocation hop — which fooled the "wait for flying" poll and made every sampled longitude a stale pre-crossing value (continuous, all ≤ 180, never > 180). The test now reloads the page after relocation (server state persists; on the fresh page only the crossing leg can ever produce a flying frame), and the sampling loop gains a freshness gate: a sample is recorded only if its marker position is within ~700 km of where the SERVER snapshot says the plane is (~5 s of flight at ×1000 — haversine is ±360°-periodic in Δlon, so wrapped server lon vs unwrapped marker lon compare directly), so a frozen frame can no longer pass continuity vacuously; starterMarker now also returns latitude for that check. Pre-existing flake fixed in the same run: e2e/queue.spec.ts's chain-refuel lower bound loosened from 1.4 × required2 to 1.3 × required2 — under dind load the post-departure snapshot can land ~0.1 × required2 into leg 2's burn (observed miss: 0.07 %); a wrong top-up level still lands far below the bound, and the exact level remains pinned by the upper bound plus the rep-discounted cash assertion.
  • Root cause of the stale-feed freeze fixed in lib/useGameState.ts (CI run 178). The reload + freshness gate above were insurance; run 178 proved the feed can freeze even on a FRESH page — and the reason is now fixed at the source, not worked around. The SSE silence check was one-shot: setTimeout(startPolling, 5 s) armed once at connect, cleared by the FIRST event and never re-armed. After that, only a stream ERROR could restart polling — but a half-open connection that simply stops delivering data (observed under dind load; /api/state on separate connections kept answering fine throughout) fires no onerror, so state froze until reload. The timer is now re-armed on EVERY message: any 5 s gap in the event flow — whenever it happens, not just before the first event — starts the polling fallback (immediate catch-up fetch + interval), which stops again when fresh events resume. Healthy streams pay one set/clearTimeout per event; stalled ones degrade to the pre-v0.57.0 poll loop instead of freezing. The e2e keeps both insurance layers: the reload still guarantees a clean SSE connect at dispatch, and the freshness gate still fails loudly if any future stall outlasts it (a frozen source can no longer pass continuity vacuously).
  • Tests. Unit +1 in lib/mapData.test.ts ("NZAA→NFTF crossing leg — full pipeline on stored dataset coords"): runs the exact production pipeline on the STORED CSV coordinates (loadAirportsGeoJson → positionAt slerp sampled at 51 steps → planesGeoJson) and pins what a live browser source must show — marker lons monotonically increasing from +174.8 to +184.9 (the unwrapped NFTF), every consecutive pair < 180° apart, max > 180 (eastward crossing onto the world copy). This is the ground truth the e2e's lon > 180 assertion encodes: the dataset crosses EASTWARD (NZAA +174.786 → NFTF −175.149, Δ ≈ +10°), so a correct pipeline MUST produce lons above +180 — and any e2e failure of that assertion is now provably a stale browser source, not the math.

v0.57.0 — 2026-09-22 19:19

  • Push updated state to clients over SSE (ISS-129). Clients no longer rely on pure polling for live updates: new GET /api/events streams full game-state snapshots as Server-Sent Events — the current snapshot immediately on connect, then one after every sim tick (~1 s), so the UI updates push-driven even while idle. New module lib/sse.ts is a tiny in-process fan-out registry (subscribe(fn) → unsubscribe, broadcast(text) with per-subscriber try/catch) — the three process-wide singletons (this subscriber set, the sql.js DB promise (getGameDb), and the tick lock (withLock)) live on globalThis: Next.js can load a module more than once per process (instrumentation.ts and route handlers get separate module instances), so plain module-level state would give each layer its own registry/DB/lock, and background-tick pushes would never reach route-handler subscribers. The broadcast hook lives in tickNow() in lib/sim/engine.ts: after each locked tick on the singleton path it serializes via the SAME getStateSnapshot() that GET /api/state returns (no duplicated serialization) and pushes to all subscribers; failures are logged, never fatal. Action mutations reach other clients within one background tick (~1 s); the acting client still gets its immediate post-action snapshot from the POST response as before. The SSE route (app/api/events/route.ts) sends Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive and X-Accel-Buffering: no (prod sits behind Traefik), plus a : ping heartbeat comment every 15 s so idle proxies don't drop the connection; snapshot-first-then-subscribe ordering guarantees events are strictly ordered by sim clock, and stream cancel unsubscribes + clears the heartbeat; broadcast pushes are wrapped in data: <json>\n\n SSE frames at the route (matching the initial snapshot). Client (lib/useGameState.ts): one EventSource('/api/events') per page — onmessage parses the full snapshot into the shared state (same shape as today); polling stays as an automatic fallback: if no event arrives within 5 s of connect, or on a stream error, the old poll loop starts (immediate catch-up fetch + interval), and it stops again when fresh events flow. EventSource's native reconnect is relied on — no custom reconnect framework. /api/state behavior is unchanged (still ticks-then-snapshots; polling remains fully functional).
  • API. OpenAPI spec gains GET /api/events (200 → text/event-stream) in lib/openapi.ts, so the machine-readable spec at /api/openapi stays accurate.
  • Tests. Unit: new lib/sse.test.ts — subscribe/broadcast/unsubscribe semantics, a throwing subscriber doesn't break others, idempotent unsubscribe; new app/api/events/route.test.ts (throwaway-DB pattern from the state route test) — SSE headers + first chunk parses as a snapshot with simState.clockMs. E2E: new e2e/events.spec.ts (+1) — node-side fetch of /api/events against the app under test asserts content-type text/event-stream, the first event parses as a valid snapshot (simState.clockMs), and within ~5 s at least one MORE event arrives with an advanced clock (background tick pushes while idle since v0.56.0 — no scale hook); stream closed in finally. The exact-endpoint-list assertions in lib/openapi.test.ts and e2e/api.spec.ts move twelve → thirteen endpoints (/api/events added).

v0.56.0 — 2026-09-22 18:28

  • Idle fleet slows the world to real time instead of freezing it (ISS-128). A fully grounded fleet no longer freezes the sim clock — tickOnce in lib/sim/engine.ts now advances by min(elapsed, MAX_TICK_ELAPSED_MS) × IDLE_WORLD_TIME_SCALE when nothing is flying or in repair, where new constant IDLE_WORLD_TIME_SCALE = WORLD_TIME_SCALE / 24 (= exactly 1, i.e. real time) lives next to WORLD_TIME_SCALE in lib/worldClock.ts. Packages keep spawning/expiring and the market stays fresh while idle; an active fleet keeps its full scale including any FLIGHT_TEST_HOOKS override (grounded + hook scale 1000 → advances at ×1, not ×1000), and lastRealTickMs still always tracks real time so resuming activity never fast-forwards the idle duration. The client mirror (advanceSimClock in lib/useSimClock.ts) extrapolates at the same idle rate instead of freezing, so polls re-anchor without a visible jump.
  • Tests. Unit: lib/sim/engine.test.ts — the grounded-fleet tests now pin real-time advance clamped to one idle-rate tick (no freeze, no fast-forward), resume-without-catch-up after takeoff, and that a raised timeScale is ignored while grounded; new describe "idle slowdown" pins IDLE_WORLD_TIME_SCALE = WORLD_TIME_SCALE / 24 = 1. lib/useSimClock.test.ts — the frozen-while-idle test now asserts real-time advance at the idle rate. E2E: e2e/sim.spec.ts's freeze test becomes a bounded real-time delta (advances > 0, ≥ half the wall span clamped to one tick, ≤ wall span + one clamp, clearly below ×24); e2e/state.spec.ts's restart test replaces byte-identical clock equality with derived bounds — never backwards (not re-seeded) and ≤ MAX_TICK_ELAPSED_MS of clamped downtime + the live wall span; e2e/right-pane.spec.ts's penalty preview no longer races a frozen clock — it verifies the idle rate, then allows the formula's slope over one UI poll interval (~1 s at ×1) instead of an exact string match. The activateWorld/repair wrappers in actions/panes/sim specs are unchanged (a repairing plane still means full scale); only their "freezes" comments were updated.

v0.55.0 — 2026-09-22 15:38

  • Favicon (ISS-122). The app shipped with no favicon at all (app/layout.tsx set only title/description; no app/icon.svg, no public/favicon.ico). New hand-written single-file app/icon.svg — a bold white top-down airplane silhouette (fuselage + swept wings + tail, one straight-line path) on a pastel blue (#93c5fd) rounded square, 64×64 viewBox. Next App Router's file convention auto-serves it at /icon.svg and injects <link rel="icon"> into the head; no layout/metadata changes needed.
  • Tests. E2E +1 in e2e/panes.spec.ts: page load asserts exactly one head link[rel~="icon"] pointing at /icon.svg, and a direct GET /icon.svg returns 200 with content-type image/svg+xml. No unit test — static asset.
  • E2E hardening (CI run #167). Two e2e-only fixes: the favicon assertion now tolerates the content-hash query string Next App Router production builds append to the auto-injected icon <link href> (/icon.svg?icon.<hash>.svg — matched with /^\/icon\.svg/, canonical GET /icon.svg check unchanged), and e2e/hover.spec.ts's local flyHomeViaApi self-heal is replaced by the established e2e/panes.spec.ts pattern (set-health repair-gate reset + re-read state AFTER an in-flight leg lands) — the old version checked locationIcao against the stale pre-landing snapshot, so a retry after a mid-flight interruption left plane-starter grounded at the destination and its package load 409'd ("plane not at the origin").
  • E2E hardening (CI run #168). Retry-cascade fix in e2e/actions.spec.ts: its local flyHomeViaApi self-heal now also tops plane-starter's tank back to full via the free POST /api/test/set-fuel hook after repositioning home. A transient /api/state 500 (issue #127) killed attempt 1 mid-flight; the retry's self-heal then flew the starter home with a low tank, so the test's own dispatch auto-refueled AFTER its cash baseline was captured and the exact-cash "no reward for expired cargo" assertion failed by €1190.56 — a full tank makes every subsequent dispatch top-up cost exactly €0.
  • E2E hardening (ISS-127). e2e getState hardened against transient /api/state 500s — internal retry ×3/500 ms keeps poll callbacks total.

v0.54.0 — 2026-09-22 15:20

  • Long priority jobs are now always finishable (ISS-119). generatePackage in lib/game/seed.ts drew a flat 6–72 h expiry window (priority ×0.5 → 3–36 h) independent of leg distance, so some jobs' deadlines were shorter than the flight itself and could never be completed (legs go up to MAX_JOB_LEG_KM = 9000 km; the fastest catalog plane is ~920 km/h ≈ 10 h for a max leg). The drawn window is now clamped UP after special scaling: deadline ≥ now + travelDurationMs(legKm, FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS (flat 1 h), reusing the existing travelDurationMs from lib/sim/movement.ts. New constant FASTEST_CATALOG_SPEED_KMH in lib/game/catalog.ts — data-driven max of PLANE_TYPES speeds, so a job must be finishable with the best available aircraft (NOT the starter's 510 km/h, which would wreck long-job economics); adding a faster plane tightens the floor automatically. EXPIRY_BUFFER_MS lives next to the deadline tunables in seed.ts. The window is a single uniform draw, so clamping up covers both requirements: short legs (drawn × scale already above the floor) are byte-identical to before, and when the raw draw falls below the floor the deadline lands exactly on it — which stays ≤ the scaled ceiling for any pair of airports on Earth (max leg ~20000 km → floor ≈ 22.8 h < the 36 h priority ceiling), so no separate ceiling raise is needed. Forced deadlineOffsetMs skips the floor, same as it skips special scaling (the e2e expiry hook keeps working).
  • Tests. Unit +5 in lib/game/seed.test.ts (new "expiry floor" block): pins the constants (fastest = max of catalog speeds and > starter speed; buffer exactly 1 h); a seeded sweep over 200 unforced packages on the real dataset asserting deadline ≥ now + travel time at max speed + buffer for every one (specials roll naturally, ~15% priority); the farthest within-cap pair in the real dataset (~9000 km) forced with both priority and null across 50 seeds each — invariant holds everywhere AND at least one package per special lands exactly on the floor (deterministic for fixed seeds, proving the clamp engages rather than holding vacuously); short legs (EDDF→EHAM ≈ 370 km → floor ≈ 1.4 h < the priority minimum window) pinned byte-identical to a replay of the pre-v0.54.0 draw order (no economy regression); forced deadline offsets still skip the floor. E2E +1 in e2e/expiry.spec.ts: the EXISTING POST /api/test/create-package hook forces EDDB→KSMF (~9000 km, at MAX_JOB_LEG_KM) with special: 'priority'; 10 packages are created and every one asserted to expire ≥ travel time at max speed + buffer after creation (and ≤ the 36 h priority ceiling), then all ignored for cleanup — no new server hooks.

v0.53.0 — 2026-09-22 14:47

  • Antimeridian routes no longer chord across the map (ISS-120). Routes whose great circle crosses ±180° (e.g. Auckland → Fua'amotu, Tonga) rendered as a line spanning the whole map: greatCircleArc sampled positionAt, which wraps every sample's longitude into [−180, 180], so consecutive samples jumped +179.x → −179.x and MapLibre — which draws LineStrings as-is with no ±180 splitting — connected them across the globe. New module-private unwrapLons in lib/mapData.ts re-expresses each arc's sample sequence in continuous longitudes (first sample keeps its raw lon; every later one is shifted by ±360 relative to the previous UNWRAPPED value until |Δlon| ≤ 180), applied inside greatCircleArc so all four line builders (routesGeoJson, queuedRoutesGeoJson, hoverRouteGeoJson, hoverTraceGeoJson) are fixed at once. One line stays one LineString — no splitting; out-of-range values render on MapLibre's adjacent world copies (renderWorldCopies is default-on). queuedRoutesGeoJson gets a second global pass after concatenation: each leg's arc unwraps relative to its OWN start airport, so at a joint the sequence can jump ±360 (leg 1 ends at dest+360k, leg 2 restarts at the raw dest) and the re-unwrap keeps multi-leg chains continuous. Non-crossing routes are byte-identical (no |Δlon| > 180 anywhere → the pass is a no-op).
  • Tests. Unit +4 in lib/mapData.test.ts (new "antimeridian crossing" block, real dataset pairs NZAA/NFTF/NZCH): eastward and westward crossings stay continuous (every consecutive |Δlon| < 180), endpoints exact mod 360 with the first sample still the raw origin, sampled chord length ≈ haversine within 1%, a non-crossing route's lons stay in [−180, 180], and a grounded plane's two-leg queue chain crossing ±180 stays continuous across the joint (the regression queuedRoutesGeoJson's global pass guards). E2E +1 in e2e/hover.spec.ts: hovering an antimeridian-crossing package (hook-created NZAA → NFTF) draws one continuous trace — no rendered segment may contain a ±180 longitude jump; expectHoverRoute's endpoint check now compares longitudes on the circle (mod 360, identical for any pair within ±180 so existing routes are unaffected) and fitToAirports unwraps crossing pairs so their bounds follow the short arc across the date line instead of spanning the wrong side of the globe.
  • Known limitation (follow-up). Plane MARKERS still use the snapshot's wrapped lat/lon (lib/game/state.ts → positionAt), so a plane mid-crossing teleports 360° on its marker at the instant it passes ±180 while its route line stays continuous. Fixing needs airport coordinates inside planesGeoJson (signature change + Map.tsx call site) — deliberately out of scope for this fix.

v0.52.0 — 2026-09-22 10:45

  • Fixed world clock, no player time controls (ISS-117). The pause button and the 1×–1000× scale slider are gone from the left pane (components/LeftPane.tsx) — the world now always runs at a fixed WORLD_TIME_SCALE = 24 (new client-safe lib/worldClock.ts, re-exported by the engine). The server advances time in a background tick: new instrumentation.ts starts startBackgroundTick() (1 s interval, singleton-guarded) under NEXT_RUNTIME === 'nodejs', so the sim moves even with no open browser. Idle-pause: while no plane is flying or in repair the clock is frozen exactly — pure shouldAdvanceTime(planes) in lib/sim/engine.ts gates every tick (no catch-up jump on wake; lastRealTickMs still updates). Fresh worlds start at year 2000 (STARTING_CLOCK_MS = Date.UTC(2000, 0, 1) in lib/game/seed.ts; existing saves untouched — no migration). The client clock (lib/useSimClock.ts) extrapolates between polls at the fixed rate and freezes while idle, mirroring the server.
  • API. POST /api/sim deleted (route + OpenAPI entry; spec now lists twelve endpoints) and its controls removed from the engine (applyTimeControls, clampTimeScale, MIN/MAX_TIME_SCALE). The /api/state snapshot's simState is now { clockMs } only — no timeScale/paused. New test-only hook POST /api/test/set-timescale behind the existing FLIGHT_TEST_HOOKS=1 gate (sibling of /api/test/set-health, same 404-when-disabled convention): body { timeScale: finite number in [1, 100_000] } else 400; ticks first under the sim lock and returns { clockMs, timeScale }. The e2e suite's per-spec setScale helpers now call it (and verify from the response); every spec restores the world rate in afterEach.
  • Tests. Unit: engine tests re-anchored to STARTING_CLOCK_MS with a makeWorldActive helper (fresh DBs seed a grounded starter, so advancing-world tests put one in flight first) plus new idle-pause coverage (shouldAdvanceTime synthetic arrays; grounded fleet freezes the clock exactly; repairing keeps it advancing); actions/seed/db/openapi tests re-anchored to the year-2000 origin and fixed world rate; lib/timeScale.ts, its test, components/left-pane.test.ts and the old /api/sim route + test deleted. E2E: all specs rewritten off POST /api/sim — pause tests replaced by idle-pause assertions (e.g. right-pane.spec.ts verifies a double-read 1.5 s apart that a grounded fleet freezes the clock exactly, which also makes its penalty-preview math deterministic); sim.spec.ts activates the world via set-health + /repair for its advance/expiry tests and pins the new hook's validation; panes.spec.ts drops the slider-commit test (controls gone) and samples the smooth client clock at the fixed 24× rate, asserting the pause/slider testids are absent; state.spec.ts restart test self-heals to a fully grounded fleet first and now asserts the post-restart clock is byte-identical (frozen across the restart); two-planes.spec.ts loses its ISS-057 slow-on-landing assertions — the auto-chain observation leg now flies at world rate so polls can see it; api.spec.ts endpoint list drops /api/sim (thirteen → twelve).

v0.51.0 — 2026-09-22 08:21

  • Max range display is now guaranteed flyable in all weather (ISS-099). The spec card's "Max range" (components/RightPane.tsx) showed maxRangeKm — the weighted-model closed form with NO wind — while dispatch's fuel guard (flyPlane, and the engine's auto-chain) uses wind-adjusted burn (up to +20% headwind, v0.36.0), so a leg inside the displayed range could 409 "insufficient fuel" when the departure airport had a headwind. New MAX_WIND_BURN_FACTOR (= 1.2 — pinned in tests to be exactly windBurnFactor's maximum) and guaranteedRangeKm(burnPerKm, emptyWeightKg, maxFuel) = maxRangeKm under worst-case headwind burn (lib/sim/movement.ts). The UI now shows the guaranteed range: any leg within it is flyable in all weather. ATR 72-600: 4436 → 3696 km; Boeing 777: 13045 → 10871 km — matching the maxRangeKm ÷ 1.2 convention seed.ts already uses for its job-generation cap
  • Tests. Unit +15: new guaranteedRangeKm block in lib/sim/movement.test.ts (3 tests — MAX_WIND_BURN_FACTOR pinned to windBurnFactor(30, 90, 90), ATR ≈ 3697 km ±5%, zero-fuel/invalid-weight guards) and a per-type consistency loop in lib/game/catalog.test.ts (12 tests — for ALL six catalog types: requiredFuelFor(guaranteed range) ≤ maxFuel under worst-case headwind, tightness at 1.05× > maxFuel, and the no-wind closed form still an exact inverse). E2E: e2e/right-pane.spec.ts market-tab test now imports and asserts guaranteedRangeKm directly (local mirror deleted)

v0.50.0 — 2026-09-22 08:00

  • Queued/planned route line is now amber/gold instead of gray (ISS-098). The dotted queue line in components/Map.tsx (a flying plane's not-yet-started legs, ISS-043) was drawn in muted slate #64748b. It is now an amber/gold #d4a017, readable on both light and dark tiles. The color was extracted from the inline literal to a new exported module-scope constant QUEUE_ROUTE_LINE_COLOR so it is testable; the dashed/dotted style (line-dasharray [0.1, 1.5]) and width (3) are unchanged
  • Tests. Unit +1: new components/map.test.ts pins QUEUE_ROUTE_LINE_COLOR === '#d4a017'. No new e2e spec — the existing queued-route rendering paths (e2e/queue.spec.ts, e2e/relocation.spec.ts) already exercise this layer, and a pure color swap needs no behavioral coverage

v0.49.0 — 2026-09-22 07:25

  • Airport popup Relocate now works for flying/repairing planes (ISS-096). The Relocate row in the map's airport popup (components/Map.tsx) previously listed only grounded planes and showed "No grounded planes to relocate." when none was available. It now lists ALL planes from the latest snapshot, and the click handler picks the endpoint by the SELECTED plane's current status: available → POST /api/planes/{id}/fly (immediate relocation, unchanged), flying/repairing → POST /api/planes/{id}/queue (the destination is appended to the plane's flight queue and chains on landing). Success text is "Relocation started." for fly and "Added to queue." for queue; the no-planes fallback guard is reworded to "No planes to relocate." No API changes — both endpoints existed since v0.25.0/v0.46.0
  • Tests. E2E +1 (e2e/relocation.spec.ts): dispatches the starter plane on a leg via the API so it is flying, slows the sim to 1x so the leg cannot land mid-test, opens the airport popup for a different airport, selects the flying plane from the (now full) list and clicks "Relocate here", then asserts via state that the plane's queue contains the clicked airport's ICAO while it is still flying its original destination

v0.48.0 — 2026-09-22 06:55

  • Leg distance shown on contracts (ISS-095). PackageState gains distanceKm (lib/game/state.ts) — the great-circle origin→dest distance, computed server-side in getStateSnapshot() from the airport GeoJSON already loaded for live-plane math (same haversineKm as flight movement; 0 if a coord is missing). Shown in two places via new formatKm() (lib/format.ts, integer km, no space before "km"): the on-board contract line (EGCC → GMME 554km €23,826.00 …) and the job-list row (€37,408.00 215km Perishables 0.4t …). OpenAPI spec updated in lockstep; nothing stored
  • Tests. Unit: app/api/state/route.test.ts asserts every package's distanceKm equals the haversine of its airports' coordinates (computed with the same lib/geo helper); lib/openapi.test.ts pinned key list gains distanceKm; new formatKm test in lib/format.test.ts. E2E: right-pane.spec.ts +2 assertions (job-row distance span matches /^\d+km$/, generic and hook-created package), actions.spec.ts +1 (on-board line)

v0.47.0 — 2026-09-21 23:01

  • Job payoff retuned so weight dominates (ISS-090). REWARD_PER_KG in lib/game/seed.ts rises from €0.5/kg to €2.0/kg — the linear form is unchanged, only the constant moves. Before, the weight term was small vs base+distance, so heavier jobs barely paid more (a 1000 km leg: €3,250 @500 kg → €5,000 @4,000 kg — per-kg rate fell from €4.59 to €1.01) and there was no sense in taking heavy jobs; after the retune the same leg pays €4,000 / €11,000, so weight clearly drives payoff. Safe for viability: fuel burn (lib/sim/movement.ts) is weight-independent, so this only raises income
  • Tests. seed.test.ts: stale test name "… 0.5 * weightKg …" made generic; new literal pins in the rewardFor formula test — rewardFor(1000, 4000) === 11_000 and rewardFor(1000, 500) === 4_000 — so future retunes are deliberate. All other reward assertions recompute through the exported constants and pass unchanged

v0.46.0 — 2026-09-21 22:32

  • Queue consumption on dispatch (ISS-088). flyPlane (lib/game/actions.ts) now consumes queue[0] when the requested destination IS the first queued leg, so a landing never chains a zero-distance self-hop (the "phantom hop" — a→a completing instantly before reaching the real next leg). Flying to a non-first queued leg stays an explicit override and leaves the queue untouched; the persisted queue is written back in the same update
  • Engine auto-chain hardening. deliverPackages (lib/sim/engine.ts) gains two guards: (1) defensive skip — if queue[0] equals the arrival airport (only reachable from legacy/corrupt rows now that dispatch consumes), it drops the leg without flying; (2) full-tank backstop — if the next queued leg does not fit even a full tank, the plane grounds as 'available' with the queue kept intact instead of chaining an unflyable leg (mirrors flyPlane's 409 without throwing mid-tick)
  • Selling a queued plane is rejected. sellPlane (lib/game/actions.ts) returns 409 the plane has queued legs — cancel queued legs first for a grounded plane with a non-empty queue, instead of silently discarding the queued legs
  • Test hook: /api/test/set-cash. New route under app/api/test/ (sibling of set-fuel/add-cash) setting account cash to an ABSOLUTE value { amount } — same FLIGHT_TEST_HOOKS=1 gate, strict JSON validation, post-tick state snapshot. Deliberately NOT in the OpenAPI spec: test hooks are out-of-spec by convention and lib/openapi.test.ts pins exactly 13 public paths
  • Tests. Unit +5 (373 total): flyPlane consumes queue[0] on dispatch / skip-ahead keeps the queue intact; sellPlane 409s a grounded plane with queued legs leaving the row untouched; engine full-tank backstop grounds with the queue intact (FGSL ~4267 km from LEMD — beyond ATR range); engine defensive self-leg drop. E2E: e2e/queue.spec.ts +3 — three queued legs fly in FIFO order across landings (dispatch response snapshot proves queue[0] consumed), a mid-flight drained tank is topped up before the chained leg at exactly the rep-discounted cost (expectations computed with the same lib helpers as the engine, via /api/test/set-fuel drain + new set-cash hook), cancelling the only queued leg mid-flight leaves a plain landing with no auto-chain; e2e/state.spec.ts +1 — a staged two-leg queue survives docker compose restart flight byte-identical; e2e/two-planes.spec.ts reworked — the 240 s poll that absorbed the phantom hop is replaced by explicit assertions: the fly response snapshot shows only [b] queued, and every state observed while waiting for the chain must never show location a with dest a

v0.45.0 — 2026-09-21 21:12

  • Broken planes cannot fly (ISS-089). A plane at health ≤ 0 is now grounded until repaired instead of flying on fumes: flyPlane (lib/game/actions.ts) rejects it with 409 the plane is broken (health 0) — repair it first BEFORE any auto-refuel math, so fuel and cash stay untouched; the engine's auto-chain (deliverPackages, lib/sim/engine.ts) skips chaining when a landing's wear breaks the plane — it stays grounded at the arrival airport with its queue kept intact for a later dispatch (same shape as an unaffordable top-up)
  • Repair is gradual. repairPlane no longer jumps health to 100 — it grounds the plane in 'repairing' with the gate at clock + REPAIR_DURATION_MS and leaves health where it was. The engine's finishRepairs pass (lib/sim/engine.ts) now heals along a linear curve each tick: health = min(100, max(startHealth, round(f × 100))) with f the fraction of the repair duration elapsed — monotonic (never drops below the start value), and the flip to 'available' at exactly 100 happens when the gate passes. The left-pane health bar doubles as the repair progress indicator; no UI change
  • REMOVED the broken-plane ×1.5 burn mechanic. BROKEN_BURN_MULTIPLIER + conditionBurnMultiplier() are deleted from lib/game/catalog.ts and every burn site reverts to plain wind-adjusted consumption: flyPlane dispatch requirement, engine landed fuel + auto-chain next-leg check (lib/sim/engine.ts), the live in-flight gauge (livePlane, lib/game/state.ts). The mechanic was unreachable anyway — broken planes can no longer depart
  • Docs. OpenAPI reworded to match: fly's 409 lists the broken case, queue/repair descriptions + the PlaneState.health field note the gradual heal and the cannot-fly rule; route comment in app/api/planes/[planeId]/repair/route.ts updated. No endpoints added or removed — count pins unchanged
  • Tests. Unit: new flyPlane broken-guard test (409 + fuel/cash untouched) and a worn-but-not-broken control; the ×1.5 broken-burn tests deleted (lib/game/catalog.test.ts, lib/game/actions.test.ts, lib/sim/engine.test.ts) in favor of an engine chain-stop test (a plane that lands broken stays grounded with its queue intact despite enough fuel) and a finishRepairs curve test (mid-repair climb to round(f × 100), floor at the start value, exact 100 + 'available' at the gate); repairPlane unit pins updated — health is NOT restored by the action itself. E2E: e2e/maintenance.spec.ts reworked per the issue — force broken → pill shows AND dispatch 409s with the tank untouched (the ×1.5 burn flight leg is gone) → click Repair → "in repair" pill with health still ~0 → shorten the gate via the hook → back to service at exactly 100; e2e/right-pane.spec.ts drops the dead condition factor from its auto-refuel math

v0.44.0 — 2026-09-21 17:10

  • World airport dataset (ISS-079). data/airports_eu.csv (242 EU rows) is replaced by data/airports_world.csv — the full OurAirports world dump curated to one marker per metro area: keep large_airport rows only, then greedy 40 km haversine clustering with one airport kept per cluster ("biggest" proxy: scheduled service → IATA code → Wikipedia page → file order; no pax data exists in the CSV). Result: 1174 large airports worldwide → 1119 kept, 55 dropped into 52 multi-airport clusters (e.g. Newark ← JFK + LaGuardia, Dallas ← DFW, Warsaw Chopin merged with Modlin). New committed generator scripts/build-world-airports.mjs re-derives the CSV from the source mirror (aborts on download failure; asserts EDDF/UNNT/LPPD presence, unique ICACOs, sane row count)
  • Loader. lib/airports.ts reads the world file and drops the continent check (type filter kept as a guard); new load-time guard throws on duplicate ICAO — three consumers key features by ICAO with silent-overwrite semantics, so duplicates would corrupt state silently
  • Generated jobs are distance-capped. New MAX_JOB_LEG_KM = 9000 in lib/game/seed.ts: every spawned leg is flyable by at least one catalog plane even in worst-case headwind (b777 full-tank range ~13045 km nominal, ~10871 km with the +20% wind factor — 9000 leaves margin). generatePackage rejection-samples unforced origin/dest pairs until they fit (bounded at 25 attempts, then a uniform within-cap fallback); forced endpoints consume no extra draws and stay uncapped (the UNNT→LPPD 409 backstop relies on that), so under-cap RNG streams are byte-identical to before. The engine's coverage spawn caps its random dest through the shared pickDestWithinCap helper — one draw in the same stream position as before
  • UI/spec/docs. Map header label → "N world airports" (e2e regex updated), OpenAPI /api/airports summary/description reworded to the curated world dataset, PLAN.md dataset mentions updated; initial camera stays EU-tuned (out of scope)
  • E2E marker isolation re-verified. LFRS (Nantes) keeps ≥39 px clearance from its nearest markers (LFRB Brest / LFBD Bordeaux) at the initial zoom [10,52] z4 with the world dataset — no test changes needed beyond the label regex

v0.43.0 — 2026-09-21 15:57

  • Wear rate reduced 10× per ISS-085 — WEAR_PER_KM in lib/game/catalog.ts drops from 0.005 to 0.0005, so plane maintenance status degrades 1/10th as fast (~13 mid-range hops → ~130 hops to break a full-health plane)

v0.42.0 — 2026-09-21 15:02

  • Auto-refuel on dispatch (ISS-082). Manual refueling is gone — planes top up automatically at dispatch, so a grounded plane can always start any leg that fits a full tank when cash allows. flyPlane (lib/game/actions.ts) tops the tank to min(maxFuel, max(fuel, 1.5 × required)) before departure — the same target/price terms as the removed forFlight mode: liters × FUEL_PRICE_PER_L minus the rep-scaled discount (v0.38.0), deducted from cash; unaffordable → 409 insufficient funds with nothing mutated, and a leg that doesn't fit even a full tank still 409s insufficient fuel (the backstop). The engine's auto-chain (deliverPackages, lib/sim/engine.ts) does the same at landing: when the next queued leg exceeds the landed fuel it tops up to the identical target when cash allows and chains anyway — only an unaffordable top-up grounds the plane with the queue kept intact
  • REMOVED POST /api/planes/:planeId/refuel (route + route tests), the refuelPlane() action + RefuelOptions (lib/game/actions.ts), the left-pane "Refuel to full" section and per-leg Refuel button with their dead state/derived values (components/LeftPane.tsx), and the OpenAPI /api/planes/{planeId}/refuel path + RefuelRequest schema — endpoint count 14 → 13, stale refuel mentions reworded in info/state/queue/repair descriptions, and fly's description + 409 now document the auto-refuel
  • Test hook. New POST /api/test/set-fuel (FLIGHT_TEST_HOOKS=1 only): { planeId, fuel } forces a plane's fuel — finite number in [0, maxFuel] (400 outside / unknown field), 404 unknown plane; sibling of set-health
  • Tests. Unit: refuelPlane suite deleted; new flyPlane auto-refuel tests (exact top-up target + rep-discounted cost at rep 30, free no-op on a full tank, cash 0 → 409 insufficient funds with fuel unchanged, UNNT→LPPD backstop still 409s); the broken-burn test reworked to pin that ×1.5 burn raises the top-up target; engine.test.ts's chain test flipped — a short tank now chains via an affordable top-up (post-top-up fuel + exact cost from the real helpers) plus a new cash-0 variant grounding with the queue intact. E2E: every /refuel setup/retry site (~25 across 9 specs) replaced by the set-fuel hook, heal helpers drop their refuel-and-retry guard (dispatch auto-refuels), right-pane's per-leg Refuel test and two-planes' "refuel section lists both planes" deleted in favor of one new end-to-end dispatch auto-top-up test (set-fuel drops fuel to 500 L → fly → exact cash delta from snapshot + real helpers, landed fuel = weighted burn from the topped start value), panes.spec.ts now asserts Refuel-UI absence in both panes, api.spec.ts pins thirteen endpoints

v0.41.0 — 2026-09-21 13:26

  • Queue UI fixes (ISS-077). Three queue-related map/UI fixes, no server logic changed:
    • Would-409 controls are disabled. The on-board "Fly to …" button (components/LeftPane.tsx) is additionally disabled while the plane is flying and its destination is already active or queued (title "Already in flight queue"); the Relocate section disables its GO button for the same reason AND renders the selected plane's active/queued airports as native disabled <option>s. Grounded planes are untouched — their clicks go to /fly, which has no such conflict
    • Hover trace starts at the landing airport. Hovering an on-board package while its carrier is FLYING now traces from the carrier's LANDING airport (destIcao), not its live mid-flight position (components/Map.tsx) — the hover previews what happens AFTER this leg lands; the solid active-leg + dotted queue layers already show where it is. Grounded / no-active-leg carriers keep today's behavior exactly (live position = the plane's airport). hoverTraceGeoJson returns an empty collection when start == dest, so a package destined for the active leg's destination draws nothing there — the solid layer covers it
    • Grounded queues draw dotted lines. queuedRoutesGeoJson (lib/mapData.ts) now also draws GROUNDED ('available') planes with a non-empty queue, chaining from their current airport (locationIcao → queue[0] → …); flying planes keep the [destIcao, ...queue] chain; 'repairing' planes draw nothing. Same source/layer — no new MapLibre layer
  • Tests. Unit: lib/mapData.test.ts pins the grounded chain (exact endpoints), the unknown-first-point skip, the repairing skip and the start==dest empty trace; all flying-case pins stay green. E2E: e2e/hover.spec.ts's mid-flight test is reworked — a package destined for a QUEUED stop beyond the active leg, hover traces from the landing airport to that stop with no segment starting at the departure airport (deterministic: paused right after dispatch); new e2e/queue.spec.ts test pins the grounded two-leg chain HUB → DEST → QUEUE in the queue-routes layer

v0.40.0 — 2026-09-21 13:06

  • Top-bar version link (ISS-080). The header's version tag (components/Map.tsx) is now an anchor to the repo's CHANGELOG.md on Gitea (app-version testid, opens in a new tab); one small CSS rule keeps it subtle against the dark header. E2E: e2e/map.spec.ts pins the exact href
  • On-board ICAO tags (ISS-081). The on-board package row in the fleet list (components/LeftPane.tsx) shows 4-char ICAO tags instead of full airport names — both the route span (new onboard-route-{id} testid) and the "Fly to …" button label + title. Other name displays (in-flight leg, plane location, map popup, open-package rows, toasts) are unchanged. E2E: one new assertion in e2e/actions.spec.ts pins the ICAO route on a loaded package

v0.39.0 — 2026-09-21 12:02

  • Maintenance (ISS-069). Planes wear out and can be repaired: distance-based health, a broken state that burns more fuel, and a repair action that grounds the plane for 3 sim days. New balance constants in lib/game/catalog.ts (all ponytail-tunable): WEAR_PER_KM = 0.005, BROKEN_BURN_MULTIPLIER = 1.5, REPAIR_COST = 1_500 (flat), REPAIR_DURATION_MS = 3 × 86_400_000, plus the pure helper conditionBurnMultiplier(health) — exactly 1 above zero health, ×1.5 at/below it
  • Schema. Two new planes columns in all four places: Drizzle schema (lib/db/schema.ts), raw DDL + guarded ALTER TABLE … ADD COLUMN migrations in migrateColumns() (pre-existing rows backfill to full health / NULL gate, mirroring the weight_kg/special precedent), and the seed insert path — starter plane in buildSeed (lib/game/seed.ts) and every buyPlane delivery start at health: 100. New plane status 'repairing' added to the three pinned unions (schema enum, PlaneState, OpenAPI) plus mapData.ts's feature props
  • Wear. At landing only — deliverPackages (lib/sim/engine.ts) drops health by WEAR_PER_KM × distanceKm per landed leg (clamped at 0), in both the grounded and auto-chain write branches. A broken plane is one with health ≤ 0: it still flies, but burns ×1.5 more fuel — applied as a third factor alongside wind at ALL FIVE burn sites that hold the plane row: flyPlane dispatch feasibility + refuelPlane forFlight requirement (lib/game/actions.ts), engine landed-fuel + auto-chain next-leg check (lib/sim/engine.ts), and the live in-flight gauge in livePlane() (lib/game/state.ts). The engine's landed fuel uses the PRE-landing health (frozen at departure, like fuel) so it stays bit-consistent with the UI gauge; the chained leg's feasibility uses the post-wear value
  • Repair. New action repairPlane (lib/game/actions.ts) + POST /api/planes/{planeId}/repair (no body): 404 unknown plane, 409 not grounded ("only grounded planes can be repaired"), free no-op at full health (mirrors refuel), else charges the flat REPAIR_COST, restores health to 100 and grounds the plane in 'repairing' with a gate at clockMs + REPAIR_DURATION_MS. The engine's new finishRepairs pass (same shape as expirePackages) runs inside every advancing tick — even when nothing landed — flipping gated planes back to 'available' and clearing the gate. While repairing, the existing status !== 'available' guards in load/fly/refuel/sell/abandon block everything automatically
  • Snapshot & OpenAPI. PlaneState gains health: number + repairUntilSimTime: number | null; the repair endpoint is documented (200 → snapshot, 404/409). Pins updated in lockstep: lib/openapi.test.ts (fourteen endpoints, nine action-endpoint loop with errorCodes ['404','409'], PlaneState key list) and e2e/api.spec.ts
  • UI. The fleet rows (components/LeftPane.tsx) show an amber health bar (new .health-bar, modeled on .fuel-bar) + a {n}% readout next to the fuel text; grounded planes get a red "broken" pill at zero health and an amber "in repair" pill while the gate runs (.status-broken / .status-repairing). A Repair button appears for any grounded plane below full health — no cost preview, flat cost is server-side only
  • Test hook. POST /api/test/set-health (FLIGHT_TEST_HOOKS=1 only): { planeId, health?, repairUntilMs? } forces a plane's health (finite in [0,100]) and/or sets its repair gate to clock + repairUntilMs — lets e2e force the broken state and shorten an active 3-day gate without flying thousands of km
  • Tests. New unit tests: wear accumulation per landed leg + clamping at 0 across multiple legs; broken-state ×1.5 landed fuel vs a healthy control on the identical leg; conditionBurnMultiplier pins; flyPlane feasibility using the adjusted requirement (same tank flyable at full health, 409 when broken); repairPlane (exact cost/status/gate/health, free no-op, 409s for flying/repairing/insufficient funds, load/fly/refuel/sell all blocked while repairing) and finishRepairs (gate boundary + flip-back through a full tickOnce with nothing landed). E2E: new e2e/maintenance.spec.ts — the visible flow per the issue (force 42 → UI shows it; force 0 → broken pill + short full-tank leg lands with exactly the ×1.5-adjusted fuel from the real lib helpers; click Repair → exact cash drop + "in repair" pill; shorten the gate via the hook → back to service at health 100), restoring a clean starter on exit
  • E2E drift mitigation. Wear accumulates across the whole CI run (one shared DB, workers:1) — the self-heal helpers in e2e/right-pane.spec.ts (flyHomeViaApi), e2e/two-planes.spec.ts (resetFleet) and e2e/actions.spec.ts (flyHomeViaApi) now restore full health + clear any active repair gate first, so the farthest-flyable pickers and the FAR round-trip feasibility pin never see a worn or broken starter

v0.38.0 — 2026-09-21 10:47

  • Reputation economy (ISS-072). Reputation is no longer a dead stat — it drives the economy through three simple tunable formulas, all built on one new pure helper repRate(reputation, perPoint, cap) in lib/game/catalog.ts (linear above the starting value of 10, capped; exactly 0 at/below start so default-rep behavior is byte-identical):
    • Cheaper plane purchases — buyPlane (lib/game/actions.ts) charges price × (1 − repRate(rep, PLANE_DISCOUNT_PER_REP = 0.01, MAX_PLANE_DISCOUNT = 0.4)), rounded to 2dp; the effective cost drives both the funds check and the deduction (up to 40% off at rep ≥ 50)
    • Cheaper refuel — refuelPlane multiplies ONLY the price term: liters × FUEL_PRICE_PER_L × (1 − repRate(rep, FUEL_DISCOUNT_PER_REP = 0.005, MAX_FUEL_DISCOUNT = 0.2)); burn/target math (wind factor, ×1.5 forFlight margin) untouched
    • Better jobs at higher reputation — generatePackage (lib/game/seed.ts) takes an optional reputation input (defaults to the starting value) and multiplies the final reward by 1 + repRate(rep, JOB_BONUS_PER_REP = 0.01, MAX_JOB_BONUS = 1.0) at the same spot as the special multiplier (base × jobBonus × special, single round at the end; up to ×2 at rep ≥ 110). Consumes NO extra RNG draw
  • Spawn sites carry live reputation. spawnOpenPackages + spawnCoveragePackages (lib/sim/engine.ts) read the account row once per spawn batch and thread its reputation into generatePackage; the test hook POST /api/test/create-package does the same (hook-created packages get current job quality, never a forced value). buildSeed keeps defaulting — no rep at seed time
  • UI shows the effect. The market buy row (components/RightPane.tsx) previews the EFFECTIVE price computed client-side from the snapshot's reputation + the imported constants, with a sky-blue "−N%" pill (market-buy-discount, reusing .special-badge) when the discount is non-zero; tech-spec cards keep showing full list prices. The left-pane Refuel section (components/LeftPane.tsx) previews the discounted fill-up cost with its own refuel-discount pill. No new snapshot fields — rep was already in the state, so the OpenAPI schema is unchanged
  • OpenAPI (descriptions only). buy-plane + refuel endpoint descriptions mention the rep-scaled discounts; CatalogEntry.price, StateSnapshot.fuelPricePerL and AccountState.reputation gain notes that the discount applies at purchase/refuel time. No properties or endpoints added/removed
  • Tests. New unit tests: repRate (zero at/below start, linear mid-range exact values, cap clamping), buyPlane at rep 30 (exact discounted cash delta + funds boundary between discounted and full price), refuelPlane at fixed rep in both 'full' and 'forFlight' modes (discount on the price term only), generatePackage job bonus (×1.5 at rep 60, default-rep byte-identity, composition with the special multiplier, no extra RNG draw). All existing unit tests pass unmodified — at the starting reputation every rate is exactly 0. E2E: the full-price pins in right-pane.spec.ts, two-planes.spec.ts and actions.spec.ts now compute expectations from the LIVE snapshot's reputation via the imported helpers (rep accumulates across specs), plus a new assertion that the buy-row discount pill appears iff live rep > starting with text round(rate × 100)

v0.37.0 — 2026-09-21 09:29

  • Special jobs: data-driven special-status registry for packages (ISS-073). New lib/game/specials.ts holds a flat SPECIALS record (special status → { rewardMultiplier, deadlineScale, repBonus }) in the same house style as PLANE_TYPES, plus SPECIAL_SPAWN_CHANCE = 0.15 and a pure pickSpecial(rng, registry?) helper (one chance draw, then a uniform pick among the registry's keys; the registry is a param defaulting to the real one). Adding or removing a special is an edit of ONE registry entry — no scattered branches. First status: priority (×1.5 reward, ×0.5 deadline window, +0.5 rep at delivery; all balance-tunable)
  • Spawn & modifiers. generatePackage (lib/game/seed.ts) appends one RNG draw at the END of its fixed order to roll a special (~15% of packages); a forced special input (including explicit null) skips the draw, mirroring the other overrides. The reward multiplier applies where the reward is computed; the deadline scale applies only to the drawn 6–72h window — a forced deadlineOffsetMs skips scaling
  • Rep bonus at delivery. deliverPackages (lib/sim/engine.ts) adds the registry's flat rep bonus on top of the distance-based gain for special packages, keeping the existing double-rounding convention (gain rounded to cents, total re-rounded)
  • State shape & DB. special: string | null added in all four places — SeedPackage, Drizzle schema (lib/db/schema.ts), raw DDL and a guarded nullable ALTER TABLE … ADD COLUMN special TEXT migration in migrateColumns() (pre-existing rows stay NULL = none, mirroring the weight_kg precedent), and PackageState + its row mapping
  • UI badge. Open-package rows (components/RightPane.tsx) and on-board rows (components/LeftPane.tsx) show a sky-blue "Priority" pill next to the route when the package has a special status (new .special-badge class, package-special-{id} testid); label via new formatSpecialLabel in lib/format.ts
  • Test hooks & spec. POST /api/test/create-package accepts special (string or null; omitted → forced null so existing e2e reward/rep assertions stay deterministic — documented in the route header), and expire-package passes special: null explicitly. OpenAPI PackageState gains optional special: string | null
  • Tests. New unit tests: registry application (reward ×multiplier, deadline ×scale, rep bonus at delivery), pickSpecial distribution with a fixed RNG, forced-value draw skip, and removing an entry cleanly disabling the status; existing reward/deadline assertions scoped for special packages. E2E: new right-pane test forces special: 'priority' via the hook and asserts the row shows the "Priority" badge
  • E2E self-heal hardening. The flyHomeViaApi helper in e2e/right-pane.spec.ts now abandons any leftover loaded/expired package bound to plane-starter once it's back at EDDF (the "open packages render" test leaves a 4 t package on board, which made later hook-package loads hit the payload check), and both that helper and e2e/slow-on-landing.spec.ts's equivalent now refuel-and-retry the repositioning flight when it fails on insufficient fuel (modeled on e2e/two-planes.spec.ts)

v0.36.0 — 2026-09-21 08:45

  • Refuel-for-flight now buys 150% of the required fuel for the leg (ISS-071). refuelPlane mode 'forFlight' (lib/game/actions.ts) tops the tank up to min(maxFuel, max(current fuel, 1.5 × required)) instead of exactly the requirement — a margin so burn variance doesn't strand planes at the far end. The "does not fit a full tank" 409 guard now compares against the weather-adjusted requirement (see below). No UI change: the per-leg Refuel button has no amount preview, and the left-pane "Refuel to full" row is unaffected
  • Wind affects per-leg fuel burn ±20% based on departure weather vs route heading (ISS-070). The deterministic per-airport weather (lib/weather.ts, FNV-1a seeded by ICAO — no dayKey, stable across polls/sessions) now feeds the fuel math: a new windBurnFactor(windKt, windDirDeg, bearingDeg) in lib/sim/movement.ts returns 1 + headwindKt/150 (exactly [0.8, 1.2] over the 2–30 kt weather range), and a new initialBearingDeg(lat1, lon1, lat2, lon2) in lib/geo.ts gives the great-circle heading of each leg. Every fuel call site applies the departure airport's factor to burnPerKm: flyPlane's feasibility check, refuelPlane forFlight, the engine's landed-fuel + auto-chain checks (lib/sim/engine.ts), and the live in-flight fuel display — computed server-side in getStateSnapshot()/livePlane() (lib/game/state.ts) from airport coordinates, so no schema change. Fully deterministic: one factor per leg, a pure function of (ICAO pair, catalog)
  • Tests & spec. Unit tests added for both helpers and the ×1.5 expectation (computed in-test via the same helpers — no stale literals); e2e specs import the real helpers from lib/ instead of mirroring the wind math (e2e/right-pane.spec.ts, e2e/two-planes.spec.ts, e2e/actions.spec.ts), and their "farthest flyable" airport pickers now require BOTH directions to fit a full tank under wind. OpenAPI: description-only updates (refuel endpoint + burnPerKm note)

v0.35.0 — 2026-09-21 02:26

  • Job list shows cargo tonnage instead of count (ISS-066). The open-package rows in the right pane's Jobs tab (components/RightPane.tsx) now show cargo as type + tonnage ("Mail 10.0t") via a new formatCargo in lib/format.ts — (kg / 1000).toFixed(1) + 't', identical to the loadout display — instead of "type × count"; unit tests added to lib/format.test.ts; e2e assertion added to e2e/right-pane.spec.ts (the hook-created package is forced to 10 t and its row must contain "10.0t"). No server or OpenAPI changes — weightKg was already in the snapshot

v0.34.0 — 2026-09-21 01:26

  • Duplicate queue legs rejected with 409 (ISS-065). queueLeg (lib/game/actions.ts) now refuses to append a destination that is already in the plane's flight queue ("{icao} is already in the flight queue"), and — while the plane is flying — re-queuing the active leg's destination ("already flying to {icao}"). Previously the same route could be queued any number of times, producing pointless repeated/zero-length hops when landing auto-chains queue[0]. No UI or OpenAPI changes (the endpoint already documents error responses)

v0.33.0 — 2026-09-21 00:45

  • Merged On board into the Fleet list (ISS-054). The left pane's separate "On board" section is deleted; each Fleet row (components/LeftPane.tsx) is now the single per-plane block — status/fuel/loadout plus, when present, that plane's loaded/expired cargo rows (fly-to / refuel-for-flight / abandon + penalty preview) and its queue legs. The header shows "in flight" + the current leg for flying planes and the location ICAO when grounded; the empty "Queue" heading is no longer rendered (the sub-section renders only with ≥1 queued leg). All per-item testids unchanged (onboard-package-*, fly-to-*, refuel-for-*, abandon-*, abandon-penalty-*, plane-leg-*, queue-leg-*, queue-cancel-*); the removed one is onboard-{plane.id} (use plane-{id}). CSS: .onboard-block .contract-row* re-targeted to .plane-row .contract-row*; no server or OpenAPI changes
  • E2E updates. The four onboard-{plane.id} references now target the fleet rows (e2e/hover.spec.ts, e2e/right-pane.spec.ts, e2e/two-planes.spec.ts); e2e/panes.spec.ts no longer asserts an "On board" heading — it asserts the heading is absent from both panes

v0.32.0 — 2026-09-20 23:47

  • Queue via fly-to and Relocate; dedicated queue-add input removed (ISS-053). The flight queue no longer has its own entry point in the On board block (components/LeftPane.tsx): the per-cargo "Fly to …" button is now enabled for ANY plane state, and posts to /api/planes/{id}/queue instead of /fly when the plane is flying (grounded → /fly, unchanged); Relocate's plane list widened from grounded-only to ALL planes (relocatable = state.planes; Refuel keeps its own grounded filter), and relocate-go posts to /queue when the SELECTED plane is flying, else /fly. The queue-add select + "Add to queue" button (and the shared queueAddDestIcao state) are deleted; leg rows + cancel buttons unchanged. No server or OpenAPI changes — both endpoints already existed
  • E2E updates. e2e/queue.spec.ts test 1 now adds its queued leg via the Relocate section (selects plane-starter, which is flying at that point → /queue) instead of the removed queue-add control; e2e/two-planes.spec.ts "per-plane queues are independent" adds the starter's second leg via API POST /api/planes/plane-starter/queue (the starter is grounded there, so fly-to would start rather than queue). All queue-leg-* / queue-cancel-* assertions unchanged

v0.31.0 — 2026-09-20 21:23

  • Two-plane Load is one-click again (ISS-055). The reported "can't load cargo with two planes at the same airport" was UX, not a server bug: LoadControls (components/RightPane.tsx) rendered a plane picker for ≥2 eligible planes whose Load button stayed DISABLED until a pick existed, while the single-plane path is one-click. The multi-plane path now PRESELECTS the first eligible plane (effective = … ?? eligible[0] ?? null; the select already bound to effective?.id), so Load is enabled and one-click whenever ≥1 plane qualifies — the dropdown still allows picking a different plane
  • LeftPane queue testids scoped by plane id (ISS-055). With two on-board blocks in the DOM the unscoped ids duplicated: queue-leg-{i} → queue-leg-{plane.id}-{i}, queue-cancel-{i} → queue-cancel-{plane.id}-{i}, plus the add control queue-add-dest/queue-add → queue-add-dest-{plane.id}/queue-add-{plane.id} (components/LeftPane.tsx). The shared pane-level destination pick is unchanged. e2e/queue.spec.ts locators updated to the scoped form (single-plane fleet, id plane-starter)
  • New test hook: POST /api/test/add-cash (ISS-055). Grants cash so e2e can afford a second plane (ATR €200k vs the €20k starting balance) — body { amount?: number } (positive finite, default €1M), gated on FLIGHT_TEST_HOOKS=1 exactly like its siblings (app/api/test/create-package, /expire-package; same 404/400 error shape), returns the full state snapshot. Test-only: outside the OpenAPI spec, so the documented path list stays at thirteen
  • New serial e2e spec e2e/two-planes.spec.ts (ISS-055). Six tests covering every two-plane gameplay flow — each buys its own second ATR via the UI (after an add-cash grant) and discovers it in /api/state as the plane that isn't plane-starter: load (picker lists both planes, preselect makes Load one-click without touching the select, explicit pick routes to the other plane, both on-board blocks render with cargo simultaneously), fly (parallel dispatch to different destinations; ISS-057 semantics — last landing leaves paused === false && timeScale === 1, dispatching a plane while another is grounded stays at 1×, dispatching the LAST grounded plane resumes lastSelectedTimeScale = 1000 in the /fly response), refuel (left-pane section lists both planes; refueling one deducts exactly its fill-up cost and tops only that tank), relocate (moves one plane, starter untouched), queue (per-plane scoped controls add/cancel independently; landing auto-chains the next queued leg at 1000×), sell (payout = round(price × SELL_FRACTION) added to cash, fleet back to 1, selling the last remaining plane 409s via API). Robust reset: beforeEach heals + sells every non-starter plane (queue cleared first so landings can't auto-chain mid-heal, leftover cargo abandoned so the sell guard never 409s) and repositions the starter home — every test starts AND ends at fleet = 1
  • Verified: tsc --noEmit clean; unit suite green locally (327 passed in 32 files — unchanged, no new unit tests: the sibling test hooks have none, so add-cash follows suit); npx playwright test --list = 52 tests in 14 files (was 46/13 — six new two-plane tests); e2e not run locally by design — CI verifies against an isolated stack

v0.30.0 — 2026-09-20 19:47

  • Landing slows the sim to 1× instead of pausing; dispatch restores the last selected speed (ISS-057). Replaces the v0.24.0 pause-on-landing behavior with a slow-down: in tickOnce (lib/sim/engine.ts), when ≥1 plane landed this tick and ANY plane is grounded ('available') afterwards, the sim drops to MIN_TIME_SCALE (1×) — a just-landed plane counts, so any real landing slows; a chained plane stays 'flying' via deliverPackages' auto-chain branch, so a pure-chain tick with nothing grounded does NOT slow. Pause becomes manual-only: ticks never set it anymore
  • Resume on dispatch. flyPlane (lib/game/actions.ts) still clears a manually-paused sim on dispatch (kept), and now ALSO restores the last manually selected speed when this dispatch took the LAST grounded plane airborne — fresh-select 'available' planes after the update, and if zero remain set timeScale = lastSelectedTimeScale. With another plane still grounded the sim stays at 1×
  • New column last_selected_time_scale on sim_state (lib/db/schema.ts, DDL + guarded ALTER TABLE … DEFAULT 1 migration in lib/db/client.ts; pre-existing rows backfill to their OWN current time_scale, so a legacy DB resumes at the speed it was running). Written by applyTimeControls whenever a POST /api/sim body carries timeScale (clamped value); a {paused}-only post never touches it — last manual intent wins as the resume target with no extra flag. Seed sets it to 1 (lib/game/seed.ts)
  • No snapshot/OpenAPI/route/client changes: the slider and speed label re-read timeScale from each poll automatically, so the slow-down and the resume are visible in the UI without any client work
  • Tests: unit — lib/sim/engine.test.ts pause-on-landing describe rewritten for the slow/resume semantics (last landing at 1000× → paused=false + timeScale=1 + lastSelectedTimeScale preserved; landing with another plane still flying → slows, literal issue reading; already at 1× stays; chained-plane tick does NOT slow; manually-paused sim untouched by ticks), lib/game/actions.test.ts dispatch test rewritten (sole grounded plane → resumes to lastSelectedTimeScale=250 + unpauses; second grounded plane → stays 1×), new app/api/sim/route.test.ts case (POST {timeScale} records the row's lastSelectedTimeScale, POST {paused}-only leaves it untouched — read via getGameDb()), lib/game/seed.test.ts exact-equality gains the field, lib/db/client.test.ts legacy-migration fixture bumps time_scale to 50 and asserts the backfill. E2E — e2e/pause-on-landing.spec.ts renamed to e2e/slow-on-landing.spec.ts and rewritten: after landing assert paused === false && timeScale === 1; the /fly post-action snapshot asserts timeScale === 1000 via resume (setScale posts 1000, so lastSelected=1000). Other specs' setScale helpers post {timeScale, paused:false} and keep working unchanged
  • Verified: tsc --noEmit clean; unit suite green locally (327, was 325 — +2 new cases; engine describe rewritten in place); npx playwright test --list = 46 tests in 13 files (unchanged — the spec rename keeps the count); e2e not run locally by design — CI verifies against an isolated stack

v0.29.1 — 2026-09-20 18:22

  • Removed the flaky 'time controls' e2e test from e2e/panes.spec.ts (keyboard-driven slider assertions timed out randomly in CI); pause semantics remain covered by e2e/sim.spec.ts and e2e/pause-on-landing.spec.ts.

v0.29.0 — 2026-09-20 14:27

  • Ignore a job (ISS-047, part B). A player can now dismiss an open job so it leaves the market permanently; new random jobs spawn in its place via the existing top-up logic (spawnOpenPackages counts 'open', so the market refills to 10 on the next tick). New terminal package status 'ignored' added to the status union/enum in lib/db/schema.ts, lib/game/state.ts, lib/openapi.ts (PackageState.status) and the e2e-local interface copy in e2e/right-pane.spec.ts. No DB migration — the column is plain TEXT with no CHECK constraint. New action ignorePackage (lib/game/actions.ts) next to abandon: 404 unknown id, 409 unless open ("only open packages can be ignored"), otherwise flips status to 'ignored' — no body, no penalty, nothing else changes
  • New route. POST /api/packages/{id}/ignore (app/api/packages/[id]/ignore/route.ts) — no body, mirrors the abandon route exactly (same performAction wrapper, full state snapshot on success). The 13th public endpoint: OpenAPI spec gains the path + 'ignored' in PackageState.status; lib/openapi.test.ts and e2e/api.spec.ts path lists updated twelve → thirteen
  • UI. Each open-package row in the Jobs tab gains a small "Ignore" button beside the Load controls (components/RightPane.tsx, testid ignore-{pkg.id}, disabled while the pane action is busy, calls runAction with no body). Ignored rows vanish from the list automatically — the section filters status==='open'; no toast (deriveEvents untouched)
  • Verified-safe consumers unchanged: expirePackages selects only open|loaded ('ignored' never expires), deliverPackages handles loaded/expired, RightPane filters 'open'
  • Tests: unit — new ignorePackage block in lib/game/actions.test.ts (open → ignored with cash/planeId untouched; 409 for each of loaded/delivered/expired/abandoned/already-ignored; 404 unknown id), lib/sim/engine.test.ts terminal-statuses test gains an 'ignored' row, lib/openapi.test.ts status enum list gains 'ignored'. E2E — new "Ignore removes an open job from the list permanently" test in e2e/right-pane.spec.ts: hook-created package renders with an enabled Ignore button, UI click → /api/state shows 'ignored', row gone from [data-testid="open-packages"], second API ignore 409s
  • Verified: tsc --noEmit clean; unit suite green locally (325, was 318); npx playwright test --list = 47 tests in 13 files (was 46/13 — the new ignore test); e2e not run locally by design — CI verifies against an isolated stack

v0.28.0 — 2026-09-20 13:55

  • Right pane reworked into tabs (ISS-047, part A). The right pane now has a tab bar under the "Jobs & Market" heading with two tabs — tab-jobs ("Jobs", default-active) and tab-planes-market ("Planes market") (components/RightPane.tsx, new .pane-tabs/.tab-btn CSS in app/globals.css). The Jobs tab is the existing Packages section moved verbatim (content, testids and hover behavior unchanged); the Planes market tab holds the existing buy/sell rows (testids unchanged) plus a tech-spec panel per catalog type — one card (plane-type-{model}) with price, sell value (price × SELL_FRACTION), max payload in tonnes, cruise speed, fuel burn, max fuel and max range, ordered by price ascending. The inactive tab unmounts; the cargoWeightFor/loadEligiblePlanes exports are untouched
  • Five new plane types — progression ladder above the ATR starter (ISS-047). CRJ-900 (€450k), Embraer E175 (€900k), Airbus A320-200 (€1.8M), Airbus A330-200 (€4.5M) and Boeing 777-300ER (€9M) added to PLANE_TYPES in lib/game/catalog.ts with the exact balance values from the issue (bigger tanks, faster airframes, more payload). Purely additive — everything downstream (buy validation, movement math, load validation, snapshot catalog) is data-driven via PLANE_TYPES[model], so no endpoint or schema change; the OpenAPI spec and its e2e path list stay at twelve
  • Derived max range (ISS-047). New pure maxRangeKm(burnPerKm, emptyWeightKg, maxFuel) in lib/sim/movement.ts — closed form of the weighted burn model solving requiredFuelFor(d) = maxFuel: ln(1 + ρF/We)·(We + ρF)/(ρ·burnPerKm) (reuses FUEL_DENSITY_KG_PER_L, guards We > 0, zero fuel → 0). ATR 72-600 full tank ≈ 4436 km. Display-only — the sim still does not model range exhaustion
  • Sell-plane cargo guard (ISS-047). sellPlane (lib/game/actions.ts) now 409s with "cannot sell a plane carrying cargo" when the grounded plane has any loaded/expired packages bound to it — previously selling such a plane orphaned its cargo (packages stuck 'loaded' with a dangling planeId that could never deliver). Same query pattern as loadPackage; the last-plane and flying-plane guards are unchanged
  • Tests: unit — new lib/sim/movement.test.ts block for maxRangeKm (ATR ≈ 4439 km ±5%, monotonic in tank size, inverse in burn rate, zero fuel → 0, We ≤ 0 throws), new lib/game/catalog.test.ts sanity file (positive specs + unique ids across all six types, atr72 is STARTER_MODEL), lib/game/actions.test.ts sellPlane gains a loaded-cargo 409 case (row left untouched). E2E — e2e/right-pane.spec.ts: the market test clicks tab-planes-market first and a new "Planes market tab lists all plane types with tech specs" test asserts six catalog entries via /api/state, the ATR card's name + range against an in-test mirror of maxRangeKm, and the Boeing 777 card with its €9M price; e2e/toasts.spec.ts clicks the tab before its market-buy flow; e2e/panes.spec.ts asserts both tab buttons render with Jobs default-active. No new endpoints — lib/openapi.test.ts untouched
  • Verified: tsc --noEmit clean; unit suite green locally (318, was 311); npx playwright test --list = 46 tests in 13 files (was 45/13 — the new spec-panel test); e2e not run locally by design — CI verifies against an isolated stack

v0.27.1 — 2026-09-20 13:16

  • Right pane widened from 280px to 360px to match the left pane; toast stack offset adjusted to stay flush with the map's right edge (ISS-046).

v0.27.0 — 2026-09-20 12:27

  • Packages now have real weight (ISS-045). New weight_kg REAL NOT NULL DEFAULT 2000 column on packages (lib/db/schema.ts, DDL + guarded ALTER TABLE migration in lib/db/client.ts; pre-existing rows backfill to the mid-range default). generatePackage draws it AFTER cargoCount: UNIT_WEIGHT_KG[cargoType] × cargoCount × (0.7 + 0.6·rng()), rounded to integer kg — one extra RNG draw per package, so the documented fixed draw order is now origin, dest, cargoType, cargoCount, weight jitter, [deadline window]. New tunable table UNIT_WEIGHT_KG in lib/game/seed.ts (Passengers 50 / Mail 60 / Perishables 70 / Electronics 40 / Auto parts 72 kg per passenger/pallet), chosen so every cargo type at MAX count × max jitter (1.3×) still fits the ATR alone and typical packages land ~400–2500 kg
  • Plane capacity is now max payload kg, not 6 slots (ISS-045). capacity REMOVED from PlaneType/CatalogEntry/snapshot catalog; replaced by maxPayloadKg — ATR 72-600 = 4800 kg (lib/game/catalog.ts). Server load validation (loadPackage, lib/game/actions.ts) now sums weightKg over the plane's loaded+expired packages and 409s with no payload room: the ATR 72-600 carries X/Y kg when the new package doesn't fit; expired cargo still occupies its payload. Client mirrors it: slotsUsedFor → cargoWeightFor(state, planeId) (summed kg) in components/RightPane.tsx, and loadEligiblePlanes compares used + package weight ≤ catalog maxPayloadKg so the Load button state always matches what the server accepts
  • Payout now includes a per-kg part (ISS-045). New rewardFor(km, weightKg) = round(BASE_REWARD + REWARD_PER_KM·km + REWARD_PER_KG·weightKg) with REWARD_PER_KG = 0.5 (lib/game/seed.ts) — replaces the distance-only rewardForDistanceKm, which is deleted (no remaining callers). Typical hops stay in the ~€1–3k band
  • Loadout display shows tonnage (ISS-045). Left-pane fleet rows now render X.Xt / Y.Yt (used/max payload, one decimal) instead of n/6 pkgs; the on-board block filter switches to weight-based. No new endpoints — OpenAPI only gains PackageState.weightKg and swaps CatalogEntry.capacity → maxPayloadKg, so the e2e path list stays at twelve
  • Test hook gains a forced weight (ISS-045). generatePackage accepts an optional weightKg that skips the jitter RNG draw (same pattern as the v0.8.0 forced endpoints), and POST /api/test/create-package passes it through — so e2e flows that load several packages onto one plane stay under its payload deterministically instead of flaking on two heavy random draws (~13% would otherwise exceed 4800 kg). The hook is test-only (FLIGHT_TEST_HOOKS=1) and not part of the documented API, so the OpenAPI path list stays at twelve
  • Tests: unit — lib/game/seed.test.ts (reward expectations → new formula incl. per-kg part; new balance-constraint test asserting every seeded package is a positive integer kg ≤ starter maxPayloadKg; forced-weight block), lib/game/actions.test.ts (loadPackage fill loop now inserts weights summing past maxPayloadKg → /no payload room/; expired-still-occupies reworked weight-based), components/right-pane.test.ts (cargoWeightFor sums + partial-payload eligibility case; CATALOG fixture drops capacity, gains maxPayloadKg), lib/openapi.test.ts (exact key lists for PackageState + CatalogEntry). Type-compat only: catalog fixtures in deriveEvents/mapData tests and the e2e interfaces. E2E — e2e/right-pane.spec.ts asserts maxPayloadKg === 4800; e2e/panes.spec.ts loadout regex → /^\d+\.\dt \/ \d+\.\dt$/; snapshot key lists in app/api/state/route.test.ts + e2e/state.spec.ts gain weightKg; the two multi-load tests in e2e/actions.spec.ts pass forced weights. Fuel math untouched — the refuel-forFlight exact-liter mirrors stay as-is
  • Verified: tsc --noEmit clean; unit suite green locally (311, was 308); npx playwright test --list = 45 tests in 13 files (unchanged); e2e not run locally by design — CI verifies against an isolated stack

v0.26.0 — 2026-09-20 11:28

  • Flight queue UI + map rendering (ISS-043, part 2). Builds on the part-1 backend (PlaneState.queue, POST /api/planes/{id}/queue + /queue/cancel, auto-chain on landing) — no new endpoints, so the OpenAPI spec and its e2e path list are untouched
  • Dotted queue line on the map (ISS-043). New pure builder queuedRoutesGeoJson(planes, airportsByIcao) in lib/mapData.ts: one LineString per FLYING plane with a non-empty queue, chaining dest → queue[0] → … — ONLY the not-yet-started legs (the active leg stays in the solid routes-legs layer). Reuses greatCircleArc, same feature/properties shape as routesGeoJson ({planeId}); arcs concatenated with the shared joint point dropped once; a plane is skipped, never guessed, when its dest or any queued ICAO is missing from the airport index. New GeoJSON source + line layer in components/Map.tsx — id queue-routes, muted slate #64748b, width 3, dotted via 'line-dasharray': [0.1, 1.5] (distinct from the solid amber active leg and the [2,2] yellow hover dash), added after routes-legs but before the hover layers so a hovered trace still draws on top; pushed each poll in applyDynamicRef alongside the solid legs
  • Queue sub-section in the left pane's On board block (ISS-043). Each on-board plane block now carries a "Queue" sub-section after its cargo rows: one row per queued leg (queue-leg-{i}) showing destination name + ICAO with a Cancel button (queue-cancel-{i} → POST …/queue/cancel {index:i}), plus an add control — airport <select> (queue-add-dest) + "Add to queue" button (queue-add → POST …/queue {destIcao}) reusing the pane's already-fetched airportOptions (no second /api/airports fetch); buttons disable on actionsBusy, add also disables until an airport is picked. The on-board block filter now ALSO includes planes with a non-empty queue, so a queued plane always shows its block even when grounded with nothing on board. Existing cargo-row testids/behavior (onboard-package-*, fly-to-*, refuel-for-*, abandon-*) unchanged
  • Tests: unit — lib/mapData.test.ts gains a queuedRoutesGeoJson block mirroring the routesGeoJson tests (grounded plane with queue → nothing; flying with empty queue → nothing; flying with a 2-leg queue → one feature starting at DEST, passing through each queued airport exactly at the arc joints, ending at the last queued ICAO, length n_legs × SEGMENTS + 1; missing dest or queued ICAO → skipped; multiple planes → only qualifying ones draw). E2E — new e2e/queue.spec.ts (serial): (1) queue display + cancel through the real UI — load a package at EDDF, start leg 1 via the fly-to button, freeze mid-flight, add a leg via queue-add-dest/queue-add, assert queue-leg-0 appears with its destination, cancel it and assert it's gone; (2) dotted map line — flying plane with one queued leg: fit to both endpoints and assert some rendered queue-routes segment starts within EPS of DEST and some ends within EPS of the queued airport ACROSS per-tile segments (plain-object mapping in-page per ORCHESTRATOR §6), no segment starts at the hub (the active leg is NOT in this layer) and every feature carries planeId: plane-starter; (3) auto-chain end-to-end — full tank, package EDDF→LEMD + queued return LEMD→EDDF, start leg 1, fast-forward at 1000× and assert the server AUTO-STARTS the chained leg (status 'flying' to EDDF with no manual dispatch) with the package already delivered (part-1 ordering: delivery before chaining); all three leave the starter grounded at EDDF for later specs
  • Verified: tsc --noEmit clean; unit suite green locally (308, was 303); npx playwright test --list = 45 tests in 13 files (was 42/12 — the new queue spec); e2e not run locally by design — CI verifies against an isolated stack

v0.25.0 — 2026-09-20 10:36

  • Planes now hold a queue of upcoming flight legs (ISS-043, part 1 — backend). One new column on planes: queue TEXT NOT NULL DEFAULT '[]' (lib/db/schema.ts, DDL + guarded ALTER TABLE migration in lib/db/client.ts) storing an ordered JSON array of ICAO strings = the legs NOT yet started. The active leg stays exactly as before (destIcao + departureSimTime); full path = [locationIcao, destIcao, ...queue]. No new table — queues are short, per-plane, always read/written whole
  • Auto-chain on landing (ISS-043). In deliverPackages() (lib/sim/engine.ts) a plane that arrives now reads its queue: if it holds legs AND fuel covers the first one (same requiredFuelFor feasibility rule as /fly), it immediately starts that leg from the arrival airport — stays 'flying', departureSimTime = the arrival instant, queue shortened. If fuel is short it grounds safely with the remaining queue kept intact for refuel + re-dispatch; an empty queue grounds exactly as before. Cargo delivery for the reached airport runs first and is unaffected by chaining. Ordering preserved on purpose: delivery runs BEFORE tickOnce's pause check (ISS-041), so a chained plane counts as still flying there — chaining never triggers the last-plane pause
  • New endpoints (ISS-043). POST /api/planes/{planeId}/queue body {destIcao} appends a not-yet-started leg (works grounded or flying — pre-build a route; nothing auto-starts, 400 unknown ICAO/bad body, 404 unknown plane); POST /api/planes/{planeId}/queue/cancel body {index} removes a queued leg by index (only queued legs are cancellable — the active in-flight leg is never touched; 400 non-integer/out-of-range index, 404 unknown plane). Both run through performAction() like every other action and return the full state snapshot. New actions queueLeg/cancelQueuedLeg in lib/game/actions.ts; routes under app/api/planes/[planeId]/queue/
  • Snapshot + spec (ISS-043). PlaneState.queue: string[] added to the /api/state snapshot (lib/game/state.ts, parsed with a null/malformed-safe helper shared by the engine); OpenAPI updated — PlaneState.queue, QueueLegRequest, CancelQueuedLegRequest, both new paths (200→snapshot, 400, 404)
  • Tests: unit — lib/sim/engine.test.ts: auto-chain block in deliverPackages (lands with queue + enough fuel → 'flying' to the next dest at the arrival instant with the queue shortened; lands short on fuel → grounds with the queue intact; empty queue → unchanged grounding) plus a pause-on-landing regression guard — a chained plane does NOT trigger the last-plane pause. lib/game/actions.test.ts: queueLeg (appends in order, works while flying without touching the active leg, 404 unknown plane, 400 unknown ICAO) and cancelQueuedLeg (removes the right index keeping order, 400 out-of-range/negative/empty-queue, 404 unknown plane). lib/openapi.test.ts: endpoint count ten → twelve, PlaneState keys + both request schemas. Type-compat only: queue: [] added to the PlaneState/seed fixtures in deriveEvents, right-pane, mapData, seed tests
  • Part 1 of ISS-043 (backend) — UI/map rendering of queued legs lands in a follow-up part. Verified: tsc --noEmit clean; unit suite green locally; npx playwright test --list unchanged at 42 tests in 12 files; e2e not run locally by design — CI verifies against an isolated stack

v0.24.0 — 2026-09-20 09:48

  • Landing now pauses the sim instead of auto-slowing to 1× (ISS-041). When the LAST plane in flight lands, tickOnce (lib/sim/engine.ts) sets sim_state.paused = true WITHOUT touching timeScale — speed is preserved. The trigger condition carries over from ISS-011's auto-slow unchanged: ≥1 landing this tick AND no 'flying' planes remain (a landing while another plane is still airborne does not pause — there would be nothing to auto-resume it). No idle-enforcement or auto-restore of the pause exists
  • Starting a flight auto-resumes a paused sim (ISS-041). flyPlane() (lib/game/actions.ts) writes sim_state.paused = false before persisting — inside runAction(), so tick + unpause + dispatch are atomic under the sim lock. The cycle: dispatch unpauses → last landing pauses (speed preserved) → next dispatch unpauses again
  • Tests: unit — lib/sim/engine.test.ts: the auto-slow describe block rewritten for pause semantics (last plane lands → paused === true AND stored timeScale UNCHANGED, persisted on the DB row; a landing while another plane is still flying → no pause; last landing at scale 1 → pauses anyway; paused tick with a fully grounded fleet → trigger never fires). lib/game/actions.test.ts: new test — dispatching on a paused sim clears paused and leaves timeScale untouched. E2E — e2e/auto-slow.spec.ts renamed to e2e/pause-on-landing.spec.ts and reworked: after the starter lands, asserts simState.paused === true AND timeScale still 1000 (NOT reset), then a subsequent /fly dispatch clears paused on its own post-action snapshot. Cross-cutting fix for the new pause: every per-spec setScale helper (actions, hover, map-interactions, relocation, right-pane) now POSTs { timeScale, paused: false } — a landing pauses the sim, so a scale-only fast-forward after one would leave the clock frozen and hang the next wait; no caller relied on it NOT clearing pause (verified per call site). Deliberate mid-test pauses (sim.spec.ts / panes.spec.ts freeze tests, marker-click freezes) are untouched
  • Verified: tsc --noEmit clean; unit suite green locally (291); npx playwright test --list = 42 tests in 12 files; e2e not run locally by design — CI verifies against an isolated stack

v0.23.2 — 2026-09-20 09:15

  • On-board package hover previews the route from the carrier's current position (ISS-040). Hovering a loaded package in the left pane used to draw the great-circle trace from the package's ORIGIN airport; it now starts at the carrying plane's live lat/lon — identical while grounded at the pickup, correct once the plane has departed with cargo still loaded. New pure builder hoverTraceGeoJson(from: AirportCoord, toIcao, airportsByIcao) in lib/mapData.ts (sibling of hoverRouteGeoJson, same shape/conventions; empty collection when the destination is missing); components/Map.tsx's applyHoverRef looks up the carrier via the hover's planeId and uses it, falling back to the ICAO trace when no carrier is in state. The [state] effect now also re-applies the hover so the trace follows a moving carrier between polls while hovered (idempotent: one small setData + ring filters). Endpoint rings untouched — still ICAO-filtered origin/dest circles
  • Tests: unit — lib/mapData.test.ts gains a hoverTraceGeoJson block (exact endpoints from an arbitrary start coord; empty on missing dest). E2E — e2e/hover.spec.ts: new test loads a package at EDDF, dispatches the starter to LEMD via API, waits until its live position is >0.2° away from the hub, pauses the sim, hovers the on-board row and asserts some rendered trace segment starts within EPS of the carrier's live lat/lon AND some ends within EPS of LEMD AND no segment starts near EDDF (per-tile clipping handled via the existing cross-segment endpoint pattern); cleanup resumes + fast-forwards to arrival (auto-delivery) and flies home

v0.23.1 — 2026-09-20 08:42

  • Toasts repositioned to the map's right edge (ISS-039). The .toasts stack was position: fixed at the viewport bottom-right, overlapping the 280px right pane; its right changed 12px → 292px (= 280px right pane + 12px inset) so it now sits flush against the map's right edge with the same 12px margin. No JSX/endpoint changes — all positioning lives in that one CSS value
  • Tests: e2e wording only — toasts.spec.ts header comment, test title, and toast visibility assertion message updated from "bottom-right" to "map right edge / clear of the right pane"; no position assertions added (none existed), suite unchanged at 41 tests in 12 files
  • Verified: tsc --noEmit clean; unit suite green locally (288); npx playwright test --list = 41 tests in 12 files; e2e not run locally by design — CI verifies against an isolated stack

v0.23.0 — 2026-09-20 08:25

  • Refuel-to-full row moves to the left pane (ISS-035). Refueling a grounded plane is "anything with our plane", so it belongs in the left pane next to Relocate, not as a market row on the right. The .market-row JSX (market-refuel-none "All tanks full." span / market-refuel-plane select of grounded planes labeled {planeName} — {fuel}/{maxFuel} L, market-refuel-cost preview, market-refuel button → POST /api/planes/{id}/refuel) moved verbatim from the Market section in components/RightPane.tsx into a new "Refuel" <section data-testid="refuel-section"> in components/LeftPane.tsx, inserted between Relocate and Time. Its state (refuelPlaneId) + derived values (refuelable, effective-selection refuelPlane, refuelCost) moved with it, reusing LeftPane's existing grounded list; busy gating rewired from RightPane's busy to LeftPane's own useGameAction instance (actionsBusy). Testids kept byte-identical so all e2e locators are unchanged. The per-package "Refuel" button in the On board section (refuel-for-{pkg.id}, mode 'forFlight') is a different feature and stays put; exported helpers slotsUsedFor / loadEligiblePlanes untouched
  • Tests: e2e — right-pane.spec.ts: the market test keeps its buy/sell assertions, renamed to reflect that the refuel row now renders inside [data-testid="left-pane"] (asserted before the click); functional cash/tank assertions unchanged; the for-leg test's title/comment updated from "market button" to left pane. panes.spec.ts: first test gains structural assertions — Refuel heading + market-refuel visible in the left pane, both absent from the right pane (same present-in-left / absent-from-right pattern as On board). No unit-test changes (suite stays at 288)
  • Verified: tsc --noEmit clean; unit suite green locally (288); npx playwright test --list = 41 tests in 12 files; e2e not run locally by design — CI verifies against an isolated stack

v0.22.2 — 2026-09-20 07:32

  • Docs: ORCHESTRATOR.md successor-agent manual committed to the repo; its token reference now points to the gitignored local .env (GITEA_TOKEN, rotated after the Sept 2026 incident); §9 rewritten as durable 'how to find current state' guidance instead of a stale snapshot; no code changes

v0.22.1 — 2026-09-20 06:57

  • Hygiene: gitignore the local-only ORCHESTRATOR.md successor-agent manual (it references host-specific setup and must never be version-controlled); no code changes

v0.22.0 — 2026-09-19 21:42

  • Hover route trace on job-list rows (ISS-022). Hovering an open package in the right pane now draws the ENTIRE great-circle trace between origin and destination, not just the two endpoint rings. Zero pane edits — the rows already sent {kind:'package', originIcao, destIcao}; components/Map.tsx now derives from/to for any hover target carrying two ICAOs and pushes a new pure builder's output (hoverRouteGeoJson(fromIcao, toIcao, airportsByIcao) in lib/mapData.ts: one LineString via the existing greatCircleArc slerp, empty FeatureCollection when either endpoint is missing from the airport index or the '' no-hover sentinel) into a NEW dedicated hover-routes GeoJSON source + top-most dashed hover-route LineLayer (paint: #facc15 matching the airports-highlight ring, width 4, line-dasharray [2,2]). The shared routes source can't carry this — it only holds FLYING legs, so an open package's or a grounded relocation's planned route was never in it. The layer is added in ensureLayers after the highlight layers so style switches keep it; applyHoverRef re-pushes data on every hover
  • Same trace on On-board cargo rows (ISS-025). Each onboard-package-{id} row in the left pane's On board section now reports {kind:'package', originIcao, destIcao, planeId: plane.id} on mouseenter — same full trace as job-list rows PLUS the carrying plane's halo stays lit via the new optional planeId field (Map uses h.planeId ?? '' for the planes/routes-highlight filters). Mouseleave restores the block's plain {kind:'plane', id} highlight; leaving the whole block still ends at null because React fires the child's mouseleave before the parent's
  • Relocate panel hover previews the planned flight (ISS-027). The left pane's Relocate section gains data-testid="relocate-section" + enter/leave handlers: with a grounded plane AND destination selected it reports {kind:'route', fromIcao: relocatePlane.locationIcao, toIcao: relocateDest.icao} (else null), and Map draws the planned trace AND rings ONLY the target airport in airports-highlight (origin stays unmarked — the destination is what the issue asks to see). HoverTarget (components/hoverTypes.ts) extends to {kind:'plane';id} | {kind:'package';originIcao;destIcao;planeId?} | {kind:'route';fromIcao;toIcao}. Existing semantics unchanged: plane hovers filter the three highlight layers exactly as before, package hovers keep BOTH endpoint rings (now plus the trace)
  • Target airport highlighted in a distinct color on job-list / relocation hover (ISS-022). The destination of any two-endpoint hover now gets its own ring in a NEW top-most airports-target-highlight circle layer (components/Map.tsx, same airports source and r=9 transparent-fill geometry as airports-highlight, but green #4ade80 stroke width 3 vs the yellow #facc15 endpoint rings) — added last in ensureLayers so it also sits above the hover-route line at its endpoint; applyHoverRef filters it to the destination icao for BOTH 'package' and 'route' hovers (empty-string sentinel otherwise). airports-highlight itself is untouched, so the existing both-endpoints / target-only assertions stay valid
  • Tests: unit — new describe('hoverRouteGeoJson …') in lib/mapData.test.ts: no-hover sentinel → empty; two known airports → one arc with exact dataset endpoints; reversed-input symmetry; missing from/to ICAO → empty (baseline 283 → 288). e2e (e2e/hover.spec.ts) — open-package test extended with hover-route endpoint assertions (line geometry first/last coords compared against /api/airports lookups) + trace-clear check; new hovering an on-board cargo row draws its trace and keeps the plane halo (load via API, hover the row, assert trace endpoints + planes-highlight for that plane, abandon the package afterwards to leave a clean instance); new hovering the Relocate section previews the planned route and target airport (selects starter + LEMD via the existing testids, hovers [data-testid="relocate-section"], asserts trace endpoints + target-only ring). New helpers: airportLonLat(page, icao) and expectHoverRoute(page, fromIcao, toIcao); all new/extended assertions call fitToAirports first since queryRenderedFeatures is viewport-scoped
  • Verified: tsc --noEmit clean; unit suite green locally (288); npx playwright test --list = 41 tests (was 39, +2 new in hover.spec.ts); e2e not run locally by design — CI verifies against an isolated stack
  • Fix: hover-route e2e timeouts (CI run #69). All three trace tests failed at expectHoverRoute with the 15 s "waiting for the hover-route line EDDF → LEMD" timeout although the dashed trace WAS drawn correctly — the helper assumed queryRenderedFeatures(undefined, {layers:['hover-route']}) returns exactly one feature whose endpoints are bit-identical to the /api/airports dataset coords. MapLibre GL JS stores GeoJSON geometry in per-tile INTEGER grids (Math.round(extent·(x·2^z − tx)), maplibre-gl 6.10) and clips long lines across tiles, so a 1420 km leg comes back as one feature PER intersecting tile (empirically 4 at z=4 / 5 at z=5 for EDDF→LEMD, verified against the installed build's GeoJSONVT) with endpoints quantized by up to ~half a grid step (~0.001° observed at z≥4, ~0.02° worst case at z=0): both the feats.length !== 1 guard and the strict === coordinate comparisons could never be true. The helper now accepts any number of segments and matches endpoints within 0.05° (~5 km — above worst-case quantization at any zoom, below the distance between any two EU airports): some segment must start at origin and some end at destination; all other assertions unchanged. App side verified correct (no race: ensureLayers awaits the airport fetch before creating sources/layers and re-applies hover afterwards; layer/source ids match) — no app changes
  • Fix: hover-route e2e — evaluate maplibre geometry in-page before Playwright serialization (CI runs #69–#71). All three trace tests failed for a serialization reason, not a data one: renderedFeatures returned live GeoJSONFeature instances from queryRenderedFeatures, whose geometry is a lazy PROTOTYPE getter — and Playwright's evaluate serialization copies only own enumerable properties (verified empirically), so every feature arrived in test context WITHOUT geometry. Run #69 timed out on the strict length guard before touching it, run #70 threw TypeError: Cannot read properties of undefined (reading 'coordinates'), run #71's defensive filter found nothing and timed out. The helper now maps each feature to a plain object inside page.evaluate so the getter is evaluated in-page; the endpoint-matching predicate keeps its degenerate-segment filter as belt-and-braces — no app changes

v0.21.0 — 2026-09-19 21:01

  • Relocate panel moves to the left pane (ISS-023). Flying an empty/grounded plane to another airport is "anything with our plane", so it belongs in the left pane, not as a market row on the right. The .market-row.market-row-relocate JSX (relocate-plane select of grounded planes labeled {planeName} — {icao}, relocate-dest name-sorted select, relocate-go button → POST /api/planes/{id}/fly {destIcao}) moved verbatim from components/RightPane.tsx into a new "Relocate" <section> in components/LeftPane.tsx, inserted between On board and Time. Its state (relocatePlaneId / relocateDestIcao) and the one-time /api/airports fetch (promise-ref pattern, GeoJSON → {name, icao}[] sorted by name) moved with it — the relocate-dest select was that fetch's only consumer in RightPane, so nothing else there changed. Busy gating rewired from RightPane's busy to LeftPane's own useGameAction instance (actionsBusy); a grounded-plane filter was added next to the existing onboard compute; plane labels reuse the expression already inlined in the Fleet rows. Testids kept byte-identical (relocate-plane / relocate-dest / relocate-go) and the row keeps its .market-row market-row-relocate classes (global CSS, works in the 360 px left pane); stale "280 px right pane" comment updated in app/globals.css. The map-popup relocation controls (airport-popup-*, Map.tsx) are a separate surface fed by the snapshot only — untouched
  • Tests: e2e — only relocation.spec.ts touches these testids and all locators are global, so no locator changes; the first test's title (which mentioned "right pane") and the file header comments now say left-pane Relocate section. No unit-test changes (baseline 283)
  • Verified: tsc --noEmit clean; unit suite green locally; e2e not run locally by design — CI verifies against an isolated stack

v0.20.0 — 2026-09-19 19:21

  • On board moved to the left pane (ISS-021). The "On board" section (per-plane blocks with loaded/expired cargo rows, Refuel-for-leg / Fly-to / Abandon + penalty preview) now renders in components/LeftPane.tsx between Fleet and Time — wallet stays on top. The JSX moved verbatim (same testids: onboard-{planeId}, onboard-package-{pkg.id}, refuel-for-*, fly-to-*, abandon-* + penalty preview), along with the onboard computation (planes.filter(flying || slotsUsedFor > 0)) and per-plane cargo filter. All onboard e2e locators are global testids, so existing specs work unchanged
  • Left pane widened: grid swapped to 360/1fr/280. .app-shell in app/globals.css goes from 280px minmax(0, 1fr) 360px to 360px minmax(0, 1fr) 280px — total fixed width stays 640 px, so the map column is pixel-identical (map.spec.ts clicks via dynamic map.project()). Stale comments updated (on-board blocks now in the left pane; Relocate row comment reflects the 280 px right pane)
  • Shared action runner hook. RightPane's local runAction (busy gate, inline pane error + shell toast on failure, full post-action snapshot pushed via applySnapshot) extracted to new lib/useGameAction.ts: useGameAction(state, applySnapshot, onError) → { runAction, busy, actionError }, exact semantics kept incl. the non-2xx throw/catch and inline-error auto-clear on the next snapshot. Used in BOTH panes; LeftPane receives applySnapshot from the shell (app/page.tsx). RightPane keeps [data-testid="right-error"]; LeftPane gains a matching [data-testid="left-action-error"]. Pure helpers slotsUsedFor / loadEligiblePlanes stay exported from RightPane (unit tests import them there; LeftPane imports slotsUsedFor from it); the Market Relocate row + /api/airports fetch stay in RightPane
  • Tests: e2e — panes.spec.ts first test now also asserts the "On board" heading is visible inside [data-testid="left-pane"] and absent from the right pane; stale header comments updated in actions.spec.ts / right-pane.spec.ts. No unit-test changes (baseline 283)
  • Verified: tsc --noEmit clean; unit suite green locally; e2e not run locally by design — CI verifies against an isolated stack
  • On-board cargo rows are two lines (ISS-028). Each onboard-package-* row now stacks a full-width "Fly to " button on its own line above Refuel + Abandon side by side: components/LeftPane.tsx moves the fly-to button out of .contract-main into the row root and wraps refuel-for + abandon in a new .onboard-actions div — all testids, label text, penalty preview and disabled states unchanged. Long destination names in the fly-to label truncate with an ellipsis instead of overflowing: the label's inner span gets min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap, full name preserved via a title attribute (CSS in app/globals.css: .onboard-block .contract-row becomes flex-column, new .onboard-fly-btn / .onboard-fly-label / .onboard-actions). No e2e changes — specs locate everything by testid and assert no layout or button order; no version bump (part 2 of v0.20.0)

v0.19.2 — 2026-09-19 20:06

  • AGENTS.md "Change process" item 1 — connected issues/tasks that reference the same part of code may now be done together on one branch/PR with a single version bump (e.g. two UI tweaks to the same panel)
  • AGENTS.md "E2E tests" section rewritten to match current reality — e2e runs ONLY in CI, against an isolated docker stack inside dind (docker-compose.ci.yml); there are NO local e2e runs at all (local verification = tsc --noEmit + npx vitest run + npx playwright test --list; the full suite is verified by CI on every PR). Dropped the stale local-run instructions (deploying to the Traefik-routed instance before testing, driving browsers through the remote Playwright server at 192.168.1.4:3000); kept and reworded the CI env vars that actually exist in playwright.config.ts (E2E_BASE_URL, E2E_DEPLOY_CMD, PW_REMOTE_BROWSER=0, E2E_COMPOSE_ARGS)

v0.19.1 — 2026-09-19 18:48

  • AGENTS.md: document that CI-green is enforced on master by branch protection (required status check) and that merges use auto-merge (merge_when_checks_succeed) so PRs merge automatically once CI passes

v0.19.0 — 2026-09-19 18:05

  • Auto-slow to 1× when all planes have landed (ISS-011). When the last plane in flight lands, the sim slows down on its own so an idle fleet doesn't sit at 1000×. Server-side and authoritative: tickOnce (lib/sim/engine.ts) now remembers how many planes deliverPackages finalized during a tick, and when ≥1 plane landed this tick AND no plane remains with status 'flying' AND the sim is not paused, it persists timeScale = 1 on the same sim_state write as the clock — so the UI slider reflects it on the very next snapshot (no API or OpenAPI change: timeScale is still set via the existing POST /api/sim, and clampTimeScale semantics are untouched since 1 is a valid ladder value)
  • Arrival-triggered, not idle-enforced. The trigger fires only on the tick that processes a landing — a grounded fleet at high scale with no flight ever happening is left alone (this keeps the existing slider e2e in panes.spec.ts green: it sets 1000× and expects the server to confirm it while nothing is flying). Paused state is untouched: while paused no landings are processed, so a paused sim never auto-slows
  • No auto-restore by design. The previous scale is not remembered — once the game slows to 1× after the last landing, the player speeds up manually again (slider or POST /api/sim). Deliberately minimal scope: no "resume my old speed" behavior
  • Tests: unit — new describe('auto-slow …') block in lib/sim/engine.test.ts: last plane lands → stored timeScale becomes 1; a landing while another plane is still flying → no change; already at 1× → stays 1; paused sim with all planes landed → no change. e2e — new e2e/auto-slow.spec.ts (serial mode, SCALE=1000, self-heal fly-home in afterEach like relocation.spec.ts): sets 1000× via the API, dispatches plane-starter on the shortest leg from its hub (EDDF → nearest airport), waits for the landing and asserts the server's timeScale is exactly 1 in that snapshot, then proves manual override still works (POST /api/sim {timeScale: 1000} confirmed by response + fresh snapshot)
  • e2e suite adapted to the new behavior. Every existing spec that flies out-and-back relied on 1000× persisting across a landing — with ISS-011 it no longer does, so each return leg now re-sets SCALE explicitly: flyHomeViaApi in actions/relocation/right-pane/hover (re-set before the repositioning flight after waiting for an in-flight leg to land) and the restore legs inside the test bodies of actions.spec.ts (core loop, expired packages, validation paths ×2), right-pane.spec.ts (fly-to-destination, refuel-for-leg) and hover.spec.ts (flying-plane route highlight). map-interactions.spec.ts needed no change — its loadAndFly helper already re-sets SCALE before every landing wait
  • Verified: tsc --noEmit clean; unit suite green locally; e2e not run locally by design — CI verifies against an isolated stack

v0.18.0 — 2026-09-19 16:02

  • Relocate a grounded (empty) plane to any airport (ISS-010). flyPlane has allowed pure repositioning since v0.12.0, but the UI only exposed flying for planes that carried cargo — an empty grounded plane had no fly control anywhere. Two new surfaces now drive the EXISTING POST /api/planes/{id}/fly {destIcao} endpoint (no API or OpenAPI change):
    • Right-pane market row (components/RightPane.tsx): a fourth .market-row with [data-testid="relocate-plane"] (grounded planes, "ATR 72-600 — EDDF" labels), [data-testid="relocate-dest"] (all airports from a one-time GET /api/airports fetch per page life — same promise-ref pattern as Map.tsx — sorted by name for stable e2e selection) and the [data-testid="relocate-go"] "Relocate" button, disabled while busy or until both selects have a value. Same effective-selection + runAction pattern as the buy/sell/refuel rows (first grounded plane / first airport preselected); the row wraps (market-row-relocate) since two selects don't fit one 360 px line
    • Airport popup Relocate row (components/Map.tsx): after the name/ICAO/local-time/weather lines, a .popup-relocate block with [data-testid="airport-popup-plane"] (grounded planes from the latest polled snapshot at click time) and [data-testid="airport-popup-relocate"] "Relocate here" — disabled until a plane is picked; on click it POSTs fly to the clicked airport, pushes the response snapshot through the shell's applySnapshot (new Map prop, same callback RightPane receives), and reports success/failure in [data-testid="airport-popup-relocate-status"]. With no grounded planes the row shows [data-testid="airport-popup-no-planes"] instead. Popups are plain HTML, not React — controls wired via popup.getElement().querySelector(...) after .addTo(map)
  • Bigger airport markers (ISS-010). The r=3 dots were hard to click: airports-circles paint in components/Map.tsx goes from radius 3 / stroke 1 to radius 5 / stroke 1.5, and the hover highlight ring follows (r=8 → r=9). Paint-only — no data or interaction changes
  • No server or OpenAPI changes — both surfaces reuse the existing fly endpoint (validations unchanged: 404 unknown plane / 409 flying or insufficient fuel / 400 unknown ICAO, strict unknown-field rejection kept)
  • Tests: e2e — new e2e/relocation.spec.ts (serial mode, SCALE=1000, self-heal fly-home in afterEach like actions.spec.ts): market-row test ensures plane-starter grounded at the hub via API, picks it + the nearest non-hub airport (shortest leg) and asserts /api/state shows flying with that destIcao after clicking Relocate; popup test clicks the isolated LFRS marker (projected-center pattern from map.spec.ts), asserts the select lists "ATR 72-600 — EDDF" and the button is disabled until a plane is picked, then relocates via the popup. Marker-size regression gate = the existing map.spec.ts click tests keep passing
  • Verified: tsc --noEmit clean; unit suite green locally (no lib/ changes — flyPlane's zero-package case already covered by actions.test.ts); e2e not run locally by design — CI verifies against an isolated stack
  • Fix (CI run #50): POPUP_AIRPORT scoping in the popup relocation e2e. The LFRS marker click passed POPUP_AIRPORT into a page.evaluate(() => …) callback that runs in the BROWSER, where Node-scope constants don't exist — deterministic ReferenceError: POPUP_AIRPORT is not defined (relocation.spec.ts popup test, both attempts of run #50). The constant is now passed as an evaluate argument (page.evaluate(({lon, lat}) => …, POPUP_AIRPORT)); the other evaluate in the file only touches browser globals and needed no change. Test-only — no app code touched
  • Fix (CI run #51): strict-mode locator in the popup relocation e2e. expect(planeSel.locator('option')).toContainText(…) resolved to BOTH options (placeholder + plane) → strict mode violation (relocation.spec.ts popup test, both attempts of run #51; the app rendered correctly). The assertion now targets option[value="plane-starter"] specifically with toHaveText. Test-only — no app code touched

v0.17.0 — 2026-09-19 14:49

  • Bottom-right toasts for action errors (ISS-008). Error messages like "insufficient funds: a ATR 72-600 costs 200000, you have 62721" now appear as toasts in the bottom-right corner — new lib/useToasts.ts hook (useToasts() → {toasts, push(kind, msg), dismiss(id)}, auto-dismiss after ~5 s, queue capped at 4 dropping the oldest; pure core pushToast unit-tested) and new components/Toasts.tsx <ToastStack> (fixed container .toasts, items [data-testid="toast"] with class toast toast-error|toast-info + a × dismiss button). Wired into BOTH error paths: RightPane.runAction's catch now calls the shell's onError in addition to keeping the inline [data-testid="right-error"] pane error, and LeftPane.applySimControls's catch (previously console.error-only) reports "Failed to update time controls (…)" through the same callback. The shell (app/page.tsx) owns the hook and renders the stack; CSS in app/globals.css
  • Event messages via snapshot diffing. No server event feed exists, so new pure lib/deriveEvents.ts — diffSnapshots(prev: StateSnapshot | null, next) → {kind:'info', message}[] — derives human-readable events from the fields already in /api/state: plane landing (flying→available, "ATR 72-600 landed at Frankfurt"), package delivery (loaded→delivered, "Package delivered: +€X" with the reward), expiry (open|loaded→expired, "Package expired: A → B"), new packages (id only in next, "New package available: A → B") and planes bought/sold (plane id set changed, "Bought/Sold {model}"). The shell keeps a prevRef and diffs on every snapshot change, pushing each event as an info toast; the first snapshot after load emits nothing (no baseline — no page-load spam)
  • Browser notifications when the window is not focused. Each derived event also fires new Notification('Flight Ops', {body}) when document.hidden && 'Notification' in window && Notification.permission === 'granted'. The left-pane Time section gains an explicit opt-in button ([data-testid="enable-notifications"]) calling Notification.requestPermission(); its label reflects the result ("Enable notifications" → "Notifications enabled"/"Notifications blocked"), and it renders disabled with a tooltip title when the API is absent. SSR-safe: permission state starts null and is read in an effect after hydration
  • No server or OpenAPI changes — diffing uses only existing snapshot fields (verified: planes by id/status/locationName, packages by id/status/reward/origin+dest names)
  • Tests: unit — new lib/deriveEvents.test.ts suite pins every event format (landing, delivery with reward, expiry from open and loaded, new-package appearance, bought/sold, identical snapshots → [], first snapshot → [], non-events like takeoff/load ignored); lib/useToasts.test.ts covers the queue cap. e2e — new e2e/toasts.spec.ts: deterministic insufficient-funds path (select the most expensive model in [data-testid="market-buy-model"], click Buy, expect a visible error toast matching /insufficient funds/ AND the inline right-error still showing; skips only if cash already covers the plane on a long-lived local instance) and the notifications opt-in (unsupported → disabled + title; otherwise fresh context shows "Enable notifications" and after clicking the label reflects the result — soft, since headless CI may deny permission)
  • Verified: tsc --noEmit clean; unit suite green locally; e2e not run locally by design — CI verifies against an isolated stack
  • Fix (CI runs #44/#47): sim-control POSTs are retried before surfacing the error. A dropped POST /api/sim on the runner's flaky uplink left the UI optimistic while the server kept the old scale — panes.spec.ts:74 timed out with timeScale stuck at 1000 (run #47). applySimControls now retries each commit up to 3× total (~500 ms backoff) before surfacing the error toast; a newer intent arriving mid-retry supersedes the in-flight body instead of waiting out its retries (last-wins queueing kept), and the endpoint's set-semantics make re-sending an already-applied value idempotent. Pure retry core retrySimPost exported from LeftPane + unit-tested. Also run #47: toasts.spec.ts Part B is now permission-state-aware — headless Chromium reports Notification.permission as 'denied' (not 'default'), so the test branches on the actual state (denied → assert blocked label without clicking; granted → enabled label; default → prompt, click, accept either resulting label)

v0.16.1 — 2026-09-19 14:00

  • Smooth client-side sim clock with per-snapshot re-sync (ISS-007). The left-pane "Time" display used to jump once per 1 s poll because it rendered the snapshot's clockMs verbatim. It now ticks smoothly on the client between polls: new hook lib/useSimClock.ts keeps an anchor {clockMs, at, timeScale, paused} and re-derives the display clock every ~100 ms as anchor.clockMs + (now − at) × timeScale — exactly the server's own tick model (tickOnce: real-time × timeScale), so client extrapolation mirrors authoritative game time. Every new snapshot re-anchors to server truth, bounding any drift by network latency only; while paused the display is frozen at the anchor value (advance 0). The pure core advanceSimClock(anchor, nowMs) is exported and unit-tested in node
  • LeftPane consumes it for the sim-clock paragraph ONLY (components/LeftPane.tsx): fed with EFFECTIVE controls {timeScale: pendingScale ?? snapshot scale, paused} so a slider drag or pause click takes effect on click/drag without waiting for a confirming snapshot; the (paused) suffix keeps coming from the effective paused flag. Right-pane deadline/penalty previews are untouched — they stay computed from the snapshot's clockMs (deterministic against server truth)
  • No server or OpenAPI changes — display-only client work
  • Tests: unit — new suite in lib/useSimClock.test.ts pins the extrapolation math (exact elapsed × scale advance, frozen while paused at any scale, zero-elapsed identity, clock-skew clamp never goes backwards, mirrors the tickOnce formula under the 2 s clamp). e2e (panes.spec.ts) — new test: at scale 1000 sample sim-clock three times ~1.3 s apart real time and assert the displayed clock strictly advances between CONSECUTIVE samples (with poll-only rendering a pair could straddle zero snapshots and not change), plus format check and a total-advance sanity bound
  • Verified: tsc --noEmit clean; unit suite green locally; e2e not run locally by design — CI verifies against an isolated stack

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)