Compare commits

...
Author SHA1 Message Date
kamilkrzywda 8ec485b93e Harden right-pane/slow-on-landing e2e self-heal: clear leftover cargo, refuel-and-retry repositioning
CI / build + unit + e2e (pull_request) Successful in 9m4s
2026-09-21 10:08:33 +00:00
kamilkrzywda ea740fc378 Special jobs: data-driven special-status registry with priority status (ISS-073, v0.37.0)
CI / build + unit + e2e (pull_request) Failing after 7m56s
2026-09-21 09:30:36 +00:00
kamilkrzywda 718e3a0177 Merge pull request 'Refuel-for-flight buys 150% of requirement; wind adjusts per-leg burn ±20% (ISS-071, ISS-070, v0.36.0)' (#74) from feat/weather-fuel-burn into master
CI / build + unit + e2e (push) Failing after 12m14s
Merge feat/weather-fuel-burn: refuel ×1.5 + wind burn ±20% (ISS-071, ISS-070, v0.36.0)
2026-09-21 11:03:03 +02:00
kamilkrzywda 5030fcd8b4 Wind-adjusted fuel burn ±20% + refuel-for-flight buys 150% margin (ISS-070, ISS-071, v0.36.0)
CI / build + unit + e2e (pull_request) Successful in 10m34s
2026-09-21 08:47:42 +00:00
kamilkrzywda 6d8e1ad35e Merge pull request 'Job list shows cargo tonnage instead of count (ISS-066, v0.35.0)' (#68) from fix/job-list-cargo-tonnage into master
CI / build + unit + e2e (push) Successful in 8m24s
Merge pull request 'Job list shows cargo tonnage instead of count (ISS-066, v0.35.0)' (#68) from fix/job-list-cargo-tonnage into master
2026-09-21 04:58:30 +02:00
kamilkrzywda 0ac133f750 fix(e2e): hook package weight within starter payload (4 t < 4.8 t max)
CI / build + unit + e2e (pull_request) Successful in 8m21s
2026-09-21 02:50:10 +00:00
kamilkrzywda d3038b3c7f Job list shows cargo tonnage instead of count (ISS-066, v0.35.0)
CI / build + unit + e2e (pull_request) Failing after 10m9s
2026-09-21 02:34:20 +00:00
kamilkrzywda 847e74b7b8 Merge pull request 'Reject duplicate queue legs — same route can no longer be queued twice (ISS-065, v0.34.0)' (#67) from feat/queue-duplicate-guard into master
CI / build + unit + e2e (push) Successful in 8m26s
Merge pull request 'Reject duplicate queue legs — same route can no longer be queued twice (ISS-065, v0.34.0)' (#67) from feat/queue-duplicate-guard into master
2026-09-21 04:31:54 +02:00
kamilkrzywda 394d51d827 Reject duplicate queue legs — same route can no longer be queued twice (ISS-065, v0.34.0)
CI / build + unit + e2e (pull_request) Successful in 8m43s
2026-09-21 01:28:41 +00:00
kamilkrzywda 264778522c Merge pull request 'Merge On board into Fleet list — single per-plane block (ISS-054, v0.33.0)' (#64) from feat/merge-onboard-into-fleet into master
CI / build + unit + e2e (push) Successful in 10m53s
2026-09-21 03:16:40 +02:00
kamilkrzywda e55078fb5d Merge On board into Fleet list — single per-plane block (ISS-054, v0.33.0)
CI / build + unit + e2e (pull_request) Successful in 10m18s
2026-09-21 00:46:12 +00:00
kamilkrzywda 4e585cbed1 Merge pull request 'Queue via fly-to + Relocate; remove dedicated queue input (ISS-053, v0.32.0)' (#63) from feat/queue-via-fly-to-relocate into master
CI / build + unit + e2e (push) Successful in 9m28s
2026-09-21 02:07:45 +02:00
kamilkrzywda 89ad09a04d Queue via fly-to + Relocate; remove dedicated queue-add input (ISS-053, v0.32.0)
CI / build + unit + e2e (pull_request) Successful in 10m18s
- LeftPane fly-to button enabled for any plane state; posts to /queue when the plane is flying, else /fly
- Relocate lists ALL planes (relocatable); relocate-go posts to /queue for a flying selected plane, else /fly
- Delete queue-add select + Add-to-queue button and shared queueAddDestIcao state; leg rows/cancel kept
- e2e/queue.spec.ts test 1 adds its queued leg via the Relocate section (starter is flying)
- e2e/two-planes.spec.ts per-plane-queues test adds the starter's second leg via API POST /queue
2026-09-20 23:48:12 +00:00
kamilkrzywda bf2c46b959 Merge pull request 'Two-plane load fix + e2e coverage for 2-plane gameplay (ISS-055, v0.31.0)' (#62) from fix/two-planes-load-and-e2e into master
CI / build + unit + e2e (push) Successful in 9m42s
2026-09-21 01:08:55 +02:00
kamilkrzywda fa64e32f65 Fix two-planes e2e healPlaneHome: restore 1000× before waiting for landing (ISS-057 slows sim to 1×)
CI / build + unit + e2e (pull_request) Successful in 9m21s
2026-09-20 22:59:29 +00:00
kamilkrzywda 04b6f20a93 Fix two-planes e2e fuel 409s: refuel before return leg; healPlaneHome retries after refuel (ISS-055)
CI / build + unit + e2e (pull_request) Failing after 17m3s
2026-09-20 22:15:27 +00:00
kamilkrzywda acee806180 Fix two-planes.spec.ts load test: switch back to Jobs tab after buying via UI (tabs unmount inactive content)
CI / build + unit + e2e (pull_request) Failing after 8m26s
2026-09-20 21:56:00 +00:00
kamilkrzywda 1e3992f872 Two-plane load fix + e2e coverage (ISS-055, v0.31.0)
CI / build + unit + e2e (pull_request) Failing after 8m13s
- LoadControls preselects the first eligible plane so Load is one-click with N planes
- LeftPane queue testids scoped by plane id (queue-leg/cancel/add[-dest]-{plane.id})
  — unscoped ids duplicated in the DOM with two on-board blocks
- new POST /api/test/add-cash hook (FLIGHT_TEST_HOOKS=1, default €1M) so e2e can
  afford a second plane; returns the full state snapshot
- new serial spec e2e/two-planes.spec.ts: load, parallel fly + ISS-057 slow/resume,
  refuel, relocate, per-plane queues with auto-chain, sell (6 tests)
- e2e/queue.spec.ts locators updated to the scoped testids
2026-09-20 21:26:16 +00:00
kamilkrzywda 57cc6705f2 Merge pull request 'Slow to 1× on landing + resume last selected speed (ISS-057, v0.30.0)' (#61) from feat/slow-resume-time-scale into master
CI / build + unit + e2e (push) Successful in 8m18s
2026-09-20 22:43:27 +02:00
kamilkrzywda c9e9113a45 Slow sim to 1× on landing and resume last selected speed (ISS-057, v0.30.0)
CI / build + unit + e2e (pull_request) Successful in 8m57s
2026-09-20 20:27:59 +00:00
kamilkrzywda 5e7ec903d3 Merge pull request 'Remove flaky time-controls e2e test (v0.29.1)' (#60) from fix/remove-flaky-time-controls-test into master
CI / build + unit + e2e (push) Successful in 9m31s
2026-09-20 20:36:14 +02:00
kamilkrzywda 0b946b743c Remove flaky time-controls e2e test (v0.29.1)
CI / build + unit + e2e (pull_request) Successful in 8m19s
2026-09-20 18:23:20 +00:00
kamilkrzywda 86d486fb69 Merge pull request 'Ignore job: dismiss open jobs from the market (ISS-047 part B, v0.29.0)' (#59) from feat/ignore-job into master
CI / build + unit + e2e (push) Failing after 8m0s
2026-09-20 20:15:41 +02:00
kamilkrzywda b4b25551ff Fix ignore route slug collision ([packageId] → [id]) — Next.js runtime 500s (ISS-047 part B)
CI / build + unit + e2e (pull_request) Successful in 8m21s
2026-09-20 18:07:18 +00:00
kamilkrzywda 8a12f07703 Ignore a job: dismiss an open package so it leaves the market (ISS-047 part B, v0.29.0)
CI / build + unit + e2e (pull_request) Failing after 12m34s
New terminal status 'ignored' + ignorePackage action + POST /api/packages/{packageId}/ignore
(no body, no penalty; top-up logic refills the market). Ignore button per open-package row in
the Jobs tab. OpenAPI gains the 13th endpoint; unit tests for the action (happy/409x5/404),
engine terminal-status coverage, and a new e2e spec test.
2026-09-20 14:28:47 +00:00
kamilkrzywda a84bc7ed21 Merge pull request 'Right pane tabs (Jobs | Planes market) + 5 new plane types with tech-spec panels (ISS-047 part A, v0.28.0)' (#58) from feat/right-pane-tabs-plane-market into master
CI / build + unit + e2e (push) Failing after 9m20s
2026-09-20 16:11:34 +02:00
kamilkrzywda 7524f943d2 Right pane tabs Jobs/Planes market + 5 new plane types with tech-spec panels + sell-plane cargo guard (ISS-047 part A)
CI / build + unit + e2e (pull_request) Successful in 8m56s
2026-09-20 13:57:52 +00:00
44 changed files with 2116 additions and 472 deletions
+63
View File
@@ -1,5 +1,68 @@
# Changelog # Changelog
## 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 ## 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). - 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).
+10
View File
@@ -0,0 +1,10 @@
import { ignorePackage, performAction } from '@/lib/game/actions';
// POST /api/packages/:id/ignore (v0.29.0, ISS-047 part B) — no body. Dismisses an OPEN job so it
// leaves the market permanently; the top-up logic spawns a replacement on the next tick. No penalty,
// nothing else changes. 404 unknown id; 409 unless open. On success returns the full state snapshot.
export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params;
return performAction((inst) => ignorePackage(inst, id));
}
+16 -6
View File
@@ -6,8 +6,9 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { getGameDb } from '@/lib/db/client'; import { getGameDb } from '@/lib/db/client';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { requiredFuelFor } from '@/lib/sim/movement'; import { weatherFor } from '@/lib/weather';
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
import { FUEL_PRICE_PER_L, PLANE_TYPES } from '@/lib/game/catalog'; import { FUEL_PRICE_PER_L, PLANE_TYPES } from '@/lib/game/catalog';
import { HUB_ICAO } from '@/lib/game/seed'; import { HUB_ICAO } from '@/lib/game/seed';
import { POST } from './route'; import { POST } from './route';
@@ -124,7 +125,7 @@ describe('POST /api/planes/:planeId/refuel', () => {
expect(res.status).toBe(400); expect(res.status).toBe(400);
}); });
it('forFlight tops up exactly the liters needed for the loaded package leg', async () => { it('forFlight buys 150% of the weather-adjusted leg requirement', async () => {
const { db } = await getGameDb(); const { db } = await getGameDb();
await db.update(schema.account).set({ cash: 50_000 }); await db.update(schema.account).set({ cash: 50_000 });
// A loaded EDDF → LEMD package on the starter (direct row — loadPackage is covered in actions tests). // A loaded EDDF → LEMD package on the starter (direct row — loadPackage is covered in actions tests).
@@ -138,7 +139,14 @@ describe('POST /api/planes/:planeId/refuel', () => {
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties; const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
const km = haversineKm(from.lat, from.lon, to.lat, to.lon); const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
const type = PLANE_TYPES.atr72; const type = PLANE_TYPES.atr72;
const required = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km); // Same wind-adjusted requirement the server computes (v0.36.0).
const w = weatherFor(HUB_ICAO);
const required = requiredFuelFor(
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
type.emptyWeightKg,
type.maxFuel,
km,
);
const startFuel = Math.round(required / 2); // partial tank so L > 0 const startFuel = Math.round(required / 2); // partial tank so L > 0
await db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter')); await db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
@@ -151,9 +159,11 @@ describe('POST /api/planes/:planeId/refuel', () => {
account: { cash: number }; account: { cash: number };
planes: Array<{ id: string; fuel: number }>; planes: Array<{ id: string; fuel: number }>;
}; };
const cost = Math.round((required - startFuel) * FUEL_PRICE_PER_L * 100) / 100; // ISS-071: target is 150% of the requirement, capped at tank capacity.
const targetFuel = Math.min(type.maxFuel, Math.max(startFuel, required * 1.5));
const cost = Math.round((targetFuel - startFuel) * FUEL_PRICE_PER_L * 100) / 100;
expect(body.account.cash).toBeCloseTo(cashBefore - cost, 6); expect(body.account.cash).toBeCloseTo(cashBefore - cost, 6);
expect(body.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(required, 6); expect(body.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(targetFuel, 6);
// Clean up the test package. // Clean up the test package.
await db.delete(schema.packages).where(eq(schema.packages.id, 'p-ff-api')); await db.delete(schema.packages).where(eq(schema.packages.id, 'p-ff-api'));
+17
View File
@@ -2,6 +2,8 @@ import fs from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { getGameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { POST } from './route'; import { POST } from './route';
let dir: string; let dir: string;
@@ -84,6 +86,21 @@ describe('POST /api/sim', () => {
await post({ timeScale: 1, paused: false }); // restore await post({ timeScale: 1, paused: false }); // restore
}); });
it('records lastSelectedTimeScale when timeScale is posted; a paused-only post leaves it untouched (ISS-057)', async () => {
const inst = await getGameDb(); // same singleton the route's applyTimeControls writes through
const row = async () => (await inst.db.select().from(schema.simState))[0];
await post({ timeScale: 250 });
expect((await row()).lastSelectedTimeScale).toBe(250); // manual intent recorded…
await post({ paused: true });
expect((await row()).paused).toBe(true);
expect((await row()).lastSelectedTimeScale).toBe(250); // …and a {paused}-only post does not touch it
await post({ timeScale: 1, paused: false }); // restore
expect((await row()).lastSelectedTimeScale).toBe(1);
});
it('rejects invalid input with 400', async () => { it('rejects invalid input with 400', async () => {
for (const body of [{ timeScale: 'fast' }, { timeScale: Infinity }, { paused: 'yes' }, { bogus: 1 }]) { for (const body of [{ timeScale: 'fast' }, { timeScale: Infinity }, { paused: 'yes' }, { bogus: 1 }]) {
const res = await post(body); const res = await post(body);
+58
View File
@@ -0,0 +1,58 @@
import { getGameDb, persist } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { getStateSnapshot } from '@/lib/game/state';
import { tickNow } from '@/lib/sim/engine';
// TEST-ONLY hook (v0.31.0, ISS-055) — grants cash so the e2e suite can afford a second
// plane (ATR €200k vs the €20k starting balance). Disabled unless FLIGHT_TEST_HOOKS=1
// (set in docker-compose.yml for the e2e instance only); every other deployment 404s here.
// Sibling of /api/test/create-package — same gate, same conventions.
//
// POST /api/test/add-cash
// body { amount?: number } — a positive finite number when provided; defaults to €1M.
// Adds to account.cash (rounded to cents) and returns the full state snapshot.
const DEFAULT_AMOUNT = 1_000_000;
export async function POST(req: Request) {
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
}
let body: unknown = {};
try {
const text = await req.text();
body = text ? JSON.parse(text) : {};
} catch {
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'amount') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
let amount = DEFAULT_AMOUNT;
if (input.amount !== undefined) {
if (typeof input.amount !== 'number' || !Number.isFinite(input.amount) || input.amount <= 0) {
return Response.json({ error: 'amount must be a positive finite number' }, { status: 400 });
}
amount = input.amount;
}
try {
const inst = await getGameDb();
await tickNow(); // clock is current before the snapshot we return (same as siblings)
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
await inst.db.update(schema.account).set({ cash: Math.round((account.cash + amount) * 100) / 100 });
await persist(inst);
return Response.json(await getStateSnapshot());
} catch (err) {
console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 });
}
}
+9 -1
View File
@@ -21,6 +21,9 @@ import { tickNow } from '@/lib/sim/engine';
// hook deadline of expire-package) — pass an explicit value to override. // hook deadline of expire-package) — pass an explicit value to override.
// - weightKg (v0.27.0) forces the package weight instead of the RNG draw, so e2e // - weightKg (v0.27.0) forces the package weight instead of the RNG draw, so e2e
// flows that load several packages can stay under the plane's payload deterministically. // flows that load several packages can stay under the plane's payload deterministically.
// - special (v0.37.0) forces the package's special status ('priority' or null). When the body
// OMITS it, this hook forces null instead of letting generatePackage roll one — so existing
// e2e specs that assert exact reward/rep values stay deterministic.
// Returns 201 + the created package row. // Returns 201 + the created package row.
export async function POST(req: Request) { export async function POST(req: Request) {
@@ -40,7 +43,7 @@ export async function POST(req: Request) {
} }
const input = body as Record<string, unknown>; const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) { for (const key of Object.keys(input)) {
if (key !== 'originIcao' && key !== 'destIcao' && key !== 'deadlineOffsetMs' && key !== 'weightKg') { if (key !== 'originIcao' && key !== 'destIcao' && key !== 'deadlineOffsetMs' && key !== 'weightKg' && key !== 'special') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 }); return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
} }
} }
@@ -55,6 +58,9 @@ export async function POST(req: Request) {
if (input.weightKg !== undefined && (typeof input.weightKg !== 'number' || !Number.isFinite(input.weightKg) || input.weightKg <= 0)) { if (input.weightKg !== undefined && (typeof input.weightKg !== 'number' || !Number.isFinite(input.weightKg) || input.weightKg <= 0)) {
return Response.json({ error: 'weightKg must be a positive finite number' }, { status: 400 }); return Response.json({ error: 'weightKg must be a positive finite number' }, { status: 400 });
} }
if (input.special !== undefined && input.special !== null && typeof input.special !== 'string') {
return Response.json({ error: 'special must be a string or null' }, { status: 400 });
}
try { try {
const inst = await getGameDb(); const inst = await getGameDb();
@@ -85,6 +91,8 @@ export async function POST(req: Request) {
originIcao: input.originIcao as string | undefined, originIcao: input.originIcao as string | undefined,
destIcao: input.destIcao as string | undefined, destIcao: input.destIcao as string | undefined,
weightKg: input.weightKg as number | undefined, weightKg: input.weightKg as number | undefined,
// Omitted → force null (no roll) so existing e2e reward/rep assertions stay deterministic.
special: input.special === undefined ? null : (input.special as string | null),
}); });
await inst.db.insert(schema.packages).values(created); await inst.db.insert(schema.packages).values(created);
await persist(inst); await persist(inst);
+2 -1
View File
@@ -64,7 +64,8 @@ export async function POST(req: Request) {
lon: f.properties.lon, lon: f.properties.lon,
})); }));
const rng = mulberry32(randomInt(0, 2 ** 31)); const rng = mulberry32(randomInt(0, 2 ** 31));
const created = generatePackage({ id: randomUUID(), airports, rng, nowMs: sim.clockMs, deadlineOffsetMs: HOOK_DEADLINE_MS }); // special: null — the hook never rolls a special status (v0.37.0).
const created = generatePackage({ id: randomUUID(), airports, rng, nowMs: sim.clockMs, deadlineOffsetMs: HOOK_DEADLINE_MS, special: null });
await inst.db.insert(schema.packages).values(created); await inst.db.insert(schema.packages).values(created);
await persist(inst); await persist(inst);
return Response.json(created, { status: 201 }); return Response.json(created, { status: 201 });
+52 -12
View File
@@ -214,6 +214,15 @@ body {
color: #bfdbfe; color: #bfdbfe;
} }
/* v0.37.0 (ISS-073): special-status pill on package rows — sky-blue accent, modeled on .plane-status. */
.special-badge {
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
background: rgba(56, 189, 248, 0.2);
color: #bae6fd;
}
.fuel-bar { .fuel-bar {
height: 4px; height: 4px;
border-radius: 2px; border-radius: 2px;
@@ -376,17 +385,10 @@ body {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
/* v0.12.0 — on-board blocks (per plane); moved to the left pane in ISS-021 (v0.20.0) */ /* ISS-054 (v0.33.0) — per-plane cargo rows nested inside the Fleet list's plane cards: two lines,
.onboard-block { a full-width fly-to button then refuel + abandon side by side below it; long destination names
background: rgba(30, 41, 59, 0.6); truncate with an ellipsis. Transparent sub-rows with a top border under the card header. */
border-radius: 6px; .plane-row .contract-row {
padding: 8px;
margin-bottom: 8px;
}
/* ISS-028 (v0.20.0 part 2) — on-board cargo rows are two lines: a full-width fly-to button,
then refuel + abandon side by side below it; long destination names truncate with an ellipsis. */
.onboard-block .contract-row {
background: transparent; background: transparent;
padding: 6px 0 0; padding: 6px 0 0;
border-top: 1px solid rgba(148, 163, 184, 0.2); border-top: 1px solid rgba(148, 163, 184, 0.2);
@@ -395,7 +397,7 @@ body {
gap: 5px; gap: 5px;
} }
.onboard-block .contract-row .contract-sub { .plane-row .contract-row .contract-sub {
margin-top: 0; /* row spacing comes from the container's gap */ margin-top: 0; /* row spacing comes from the container's gap */
} }
@@ -507,3 +509,41 @@ body {
opacity: 0.5; opacity: 0.5;
cursor: default; cursor: default;
} }
/* v0.28.0 (ISS-047) — right pane tabs (Jobs / Planes market) + per-type tech-spec cards */
.pane-tabs {
display: flex;
gap: 6px;
margin-bottom: 12px;
}
.tab-btn {
flex: 1;
font-size: 12px;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid rgba(148, 163, 184, 0.4);
background: rgba(30, 41, 59, 0.9);
color: #e2e8f0;
cursor: pointer;
}
.tab-btn[aria-selected='true'] {
background: rgba(56, 189, 248, 0.25);
border-color: rgba(56, 189, 248, 0.7);
color: #f8fafc;
}
.plane-type-card {
background: rgba(30, 41, 59, 0.6);
border-radius: 6px;
padding: 8px;
margin-bottom: 8px;
}
.plane-type-name {
margin: 0 0 6px;
font-size: 13px;
font-weight: 600;
color: #f1f5f9;
}
+160 -198
View File
@@ -5,7 +5,7 @@ import type { GeoJsonFeatureCollection } from '@/lib/airports';
import type { PlaneState, StateSnapshot } from '@/lib/game/state'; import type { PlaneState, StateSnapshot } from '@/lib/game/state';
import { PLANE_TYPES } from '@/lib/game/catalog'; import { PLANE_TYPES } from '@/lib/game/catalog';
import { abandonPenalty } from '@/lib/game/penalty'; import { abandonPenalty } from '@/lib/game/penalty';
import { formatCash, formatDeadlineRelative, formatFuel, formatSimClock } from '@/lib/format'; import { formatCash, formatDeadlineRelative, formatFuel, formatSimClock, formatSpecialLabel } from '@/lib/format';
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale'; import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
import { useGameAction } from '@/lib/useGameAction'; import { useGameAction } from '@/lib/useGameAction';
import { useSimClock } from '@/lib/useSimClock'; import { useSimClock } from '@/lib/useSimClock';
@@ -19,7 +19,7 @@ interface LeftPaneProps {
// The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately. // The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately.
onRefresh: () => void; onRefresh: () => void;
// ISS-021 (v0.20.0): push the full snapshot returned by an action endpoint into the shared shell // ISS-021 (v0.20.0): push the full snapshot returned by an action endpoint into the shared shell
// state — needed now that the On board section's refuel/fly/abandon actions live in this pane. // state — needed now that the per-plane cargo rows' refuel/fly/abandon actions live in this pane.
applySnapshot: (snapshot: StateSnapshot) => void; applySnapshot: (snapshot: StateSnapshot) => void;
// ISS-002 (v0.15.0): report fleet-row hovers to the shell so the map can highlight the plane. // ISS-002 (v0.15.0): report fleet-row hovers to the shell so the map can highlight the plane.
onHover: (target: HoverTarget | null) => void; onHover: (target: HoverTarget | null) => void;
@@ -92,7 +92,7 @@ export async function retrySimPost(
export default function LeftPane({ state, loading, error, onRefresh, applySnapshot, onHover, onError }: LeftPaneProps) { export default function LeftPane({ state, loading, error, onRefresh, applySnapshot, onHover, onError }: LeftPaneProps) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// ISS-021 (v0.20.0): shared action runner for the On board section (moved here from RightPane). // ISS-021 (v0.20.0): shared action runner for the per-plane cargo/queue actions (moved here from RightPane).
const { runAction, busy: actionsBusy, actionError } = useGameAction(state, applySnapshot, onError); const { runAction, busy: actionsBusy, actionError } = useGameAction(state, applySnapshot, onError);
// ISS-023 (v0.21.0): Relocate selections — moved here from the right-pane market row; same // ISS-023 (v0.21.0): Relocate selections — moved here from the right-pane market row; same
@@ -106,10 +106,6 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
// a sensible default (first grounded plane with fuel below max). // a sensible default (first grounded plane with fuel below max).
const [refuelPlaneId, setRefuelPlaneId] = useState<string | null>(null); const [refuelPlaneId, setRefuelPlaneId] = useState<string | null>(null);
// ISS-043 (v0.26.0): flight-queue "add" destination — one shared pick across every on-board
// block's queue-add select (the e2e fleet is a single plane; per-block state would be dead weight).
const [queueAddDestIcao, setQueueAddDestIcao] = useState<string | null>(null);
// ISS-010 (v0.18.0): one-time /api/airports fetch per page life — destination options for the // ISS-010 (v0.18.0): one-time /api/airports fetch per page life — destination options for the
// Relocate section, sorted by name so e2e selection is stable. Same promise-ref pattern as Map.tsx; // Relocate section, sorted by name so e2e selection is stable. Same promise-ref pattern as Map.tsx;
// on failure airportOptions stays null and the Relocate button simply remains disabled. Moved here // on failure airportOptions stays null and the Relocate button simply remains disabled. Moved here
@@ -199,27 +195,23 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
const paused = state?.simState.paused ?? false; const paused = state?.simState.paused ?? false;
const timeScale = state?.simState.timeScale ?? 1; const timeScale = state?.simState.timeScale ?? 1;
// On board (v0.12.0, moved from the right pane in ISS-021 v0.20.0): every plane that carries // Snapshot clock — feeds the deadline/penalty previews in the per-plane cargo rows.
// cargo or is mid-flight gets a block — its loaded/expired packages with fly-to-destination +
// abandon controls, and flying planes show their current leg.
const clockMs = state?.simState.clockMs ?? 0; const clockMs = state?.simState.clockMs ?? 0;
// ISS-043 (v0.26.0): a plane with queued legs always shows its block too — the queue is the
// reason it's here even when grounded with nothing on board.
const onboard = state
? state.planes.filter((p) => p.status === 'flying' || cargoWeightFor(state, p.id) > 0 || (p.queue?.length ?? 0) > 0)
: [];
// ISS-023 (v0.21.0): Relocate — any GROUNDED plane can be repositioned to any airport via the // ISS-023 (v0.21.0): Relocate — any plane can be repositioned to any airport via the existing fly
// existing fly endpoint (pure repositioning, no package needed since v0.12.0). Moved here from // endpoint (pure repositioning, no package needed since v0.12.0). Moved here from the right-pane
// the right-pane market row; with state null both fall back to null and the section isn't rendered. // market row; with state null both fall back to null and the section isn't rendered.
const grounded = state ? state.planes.filter((p) => p.status === 'available') : []; // ISS-053 (v0.32.0): widened from grounded-only to ALL planes — a flying plane's relocation is
const relocatePlane = grounded.find((p) => p.id === relocatePlaneId) ?? grounded[0] ?? null; // queued instead of started immediately (see relocate-go below). `grounded` stays for Refuel.
const relocatable = state ? state.planes : [];
const relocatePlane = relocatable.find((p) => p.id === relocatePlaneId) ?? relocatable[0] ?? null;
const relocateDest = const relocateDest =
airportOptions === null ? null : airportOptions.find((a) => a.icao === relocateDestIcao) ?? airportOptions[0] ?? null; airportOptions === null ? null : airportOptions.find((a) => a.icao === relocateDestIcao) ?? airportOptions[0] ?? null;
// ISS-035 (v0.23.0): Refuel-to-full — any GROUNDED plane with fuel below max can be topped up via the // ISS-035 (v0.23.0): Refuel-to-full — any GROUNDED plane with fuel below max can be topped up via the
// existing refuel endpoint (no body → server defaults to mode 'full'). Moved here from the right-pane // existing refuel endpoint (no body → server defaults to mode 'full'). Moved here from the right-pane
// market row; reuses `grounded` above, and with state null falls back to null. // market row; grounded-only, and with state null falls back to null.
const grounded = relocatable.filter((p) => p.status === 'available');
const refuelable = grounded.filter((p) => p.fuel < p.maxFuel); const refuelable = grounded.filter((p) => p.fuel < p.maxFuel);
const refuelPlane = const refuelPlane =
(state?.planes.find((p) => p.id === refuelPlaneId && p.status === 'available' && p.fuel < p.maxFuel)) ?? (state?.planes.find((p) => p.id === refuelPlaneId && p.status === 'available' && p.fuel < p.maxFuel)) ??
@@ -341,185 +333,152 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
<p className="pane-muted">No planes.</p> <p className="pane-muted">No planes.</p>
) : ( ) : (
<ul data-testid="fleet-list" className="fleet-list"> <ul data-testid="fleet-list" className="fleet-list">
{state.planes.map((p) => ( {state.planes.map((p) => {
// ISS-002: hovering a fleet row highlights the plane on the map. // ISS-054 (v0.33.0): the merged per-plane block — status/fuel/loadout plus the former
<li // On board section's cargo rows and queue legs, so all plane info lives in one place.
key={p.id} const cargo = state.packages.filter(
data-testid={`plane-${p.id}`} (pkg) => pkg.planeId === p.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
className="plane-row" );
onMouseEnter={() => onHover({ kind: 'plane', id: p.id })} return (
onMouseLeave={() => onHover(null)} // ISS-002: hovering a fleet row highlights the plane on the map. Handlers live only on
> // the outer <li> — React's enter/leave don't fire when moving to a child, so this covers
<div className="plane-main"> // the whole block without flicker between sub-elements; cargo rows override with their
<span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span> // own package hover (below).
<span className={`plane-status status-${p.status}`}>{p.status}</span> <li
</div> key={p.id}
<div className="fuel-bar" aria-hidden="true"> data-testid={`plane-${p.id}`}
<div style={{ width: `${Math.max(0, Math.min(100, (p.fuel / p.maxFuel) * 100))}%` }} /> className="plane-row"
</div> onMouseEnter={() => onHover({ kind: 'plane', id: p.id })}
<div className="plane-sub"> onMouseLeave={() => onHover(null)}
<span data-testid={`plane-fuel-${p.id}`}>{formatFuel(p.fuel, p.maxFuel)}</span> >
<span data-testid={`plane-loadout-${p.id}`}>{loadoutFor(state, p)}</span> <div className="plane-main">
<span className="plane-loc">{planeLocation(p)}</span> <span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span>
</div> {p.status === 'flying' ? (
</li> <>
))} <span className="plane-status status-flying">in flight</span>
{p.destName && (
<span data-testid={`plane-leg-${p.id}`} className="contract-route">
{p.locationName} → {p.destName}
</span>
)}
</>
) : (
<span className="plane-status status-available">{p.locationIcao}</span>
)}
</div>
<div className="fuel-bar" aria-hidden="true">
<div style={{ width: `${Math.max(0, Math.min(100, (p.fuel / p.maxFuel) * 100))}%` }} />
</div>
<div className="plane-sub">
<span data-testid={`plane-fuel-${p.id}`}>{formatFuel(p.fuel, p.maxFuel)}</span>
<span data-testid={`plane-loadout-${p.id}`}>{loadoutFor(state, p)}</span>
<span className="plane-loc">{planeLocation(p)}</span>
</div>
{cargo.map((pkg) => {
const penalty = abandonPenalty(pkg, clockMs);
return (
// ISS-025 (v0.22.0): hovering a loaded package draws its full origin→dest trace on the map —
// same as job-list rows — and keeps this plane's halo lit via planeId. Leaving the row restores
// the block's plain plane highlight; leaving the whole block still ends at null (the child's
// mouseleave fires before the parent's).
<div
key={pkg.id}
data-testid={`onboard-package-${pkg.id}`}
className="contract-row"
onMouseEnter={() => onHover({ kind: 'package', originIcao: pkg.originIcao, destIcao: pkg.destIcao, planeId: p.id })}
onMouseLeave={() => onHover({ kind: 'plane', id: p.id })}
>
<button
type="button"
data-testid={`fly-to-${pkg.id}`}
className="pane-btn onboard-fly-btn"
disabled={actionsBusy}
// ISS-053 (v0.32.0): a flying plane can't start another leg — the click queues it instead,
// so "Fly to …" works from any state and the queue needs no dedicated input.
onClick={() => void runAction(`/api/planes/${p.id}/${p.status === 'flying' ? 'queue' : 'fly'}`, { destIcao: pkg.destIcao })}
>
<span className="onboard-fly-label" title={`Fly to ${pkg.destName}`}>
Fly to {pkg.destName}
</span>
</button>
<div className="onboard-actions">
<button
type="button"
data-testid={`refuel-for-${pkg.id}`}
className="pane-btn"
disabled={actionsBusy || p.status !== 'available'}
onClick={() => void runAction(`/api/planes/${p.id}/refuel`, { mode: 'forFlight', packageId: pkg.id })}
>
Refuel
</button>
<button
type="button"
data-testid={`abandon-${pkg.id}`}
className="pane-btn"
disabled={actionsBusy || p.status !== 'available'}
onClick={() => void runAction(`/api/packages/${pkg.id}/abandon`)}
>
Abandon{' '}
<span data-testid={`abandon-penalty-${pkg.id}`}>−{formatCash(penalty)}</span>
</button>
</div>
<div className="contract-sub">
<span className="contract-route">{pkg.originName} → {pkg.destName}</span>
{/* v0.37.0 (ISS-073): special-status badge — rendered only when the package has one. */}
{pkg.special && (
<span data-testid={`package-special-${pkg.id}`} className="special-badge">
{formatSpecialLabel(pkg.special)}
</span>
)}
<span>{formatCash(pkg.reward)}</span>
{pkg.status === 'expired' ? (
<span data-testid={`onboard-expired-${pkg.id}`}>expired</span>
) : (
<span>{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}</span>
)}
</div>
</div>
);
})}
{/* ISS-043 (v0.26.0): flight queue — the not-yet-started legs with cancel.
ISS-053 (v0.32.0): the dedicated add input is gone — fly-to and Relocate post to
/queue while a plane is flying, so no separate entry point is needed.
ISS-054 (v0.33.0): rendered only when non-empty. */}
{p.queue.length > 0 && (
<div className="queue-subsection">
<span className="queue-heading">Queue</span>
{/* ISS-055 (v0.31.0): testids scoped by plane id — with two blocks the unscoped ids
duplicated in the DOM and broke Playwright strict mode. */}
{p.queue.map((icao, i) => (
<div key={`${icao}-${i}`} data-testid={`queue-leg-${p.id}-${i}`} className="queue-row">
<span className="contract-route">
{airportOptions?.find((a) => a.icao === icao)?.name ?? icao} ({icao})
</span>
<button
type="button"
data-testid={`queue-cancel-${p.id}-${i}`}
className="pane-btn"
disabled={actionsBusy}
onClick={() => void runAction(`/api/planes/${p.id}/queue/cancel`, { index: i })}
>
Cancel
</button>
</div>
))}
</div>
)}
</li>
);
})}
</ul> </ul>
)} )}
</section> </section>
{/* ISS-021 (v0.20.0): moved here from the right pane — between Fleet and Time. */}
<section className="pane-section">
<h3 className="pane-heading">On board</h3>
{onboard.length === 0 ? (
<p className="pane-muted">Nothing on board.</p>
) : (
onboard.map((plane) => {
const cargo = state.packages.filter(
(pkg) => pkg.planeId === plane.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
);
return (
// ISS-002: hovering the on-board block (including its cargo rows) highlights the plane.
// Handlers live only on the outer div — React's enter/leave don't fire when moving to a
// child, so this covers the whole block without flicker between sub-elements.
<div
key={plane.id}
data-testid={`onboard-${plane.id}`}
className="onboard-block"
onMouseEnter={() => onHover({ kind: 'plane', id: plane.id })}
onMouseLeave={() => onHover(null)}
>
<div className="contract-main">
<span className="plane-model">{PLANE_TYPES[plane.model]?.name ?? plane.model}</span>
{plane.status === 'flying' ? (
<>
<span className="plane-status status-flying">in flight</span>
{plane.destName && (
<span data-testid={`plane-leg-${plane.id}`} className="contract-route">
{plane.locationName} → {plane.destName}
</span>
)}
</>
) : (
<span className="plane-status status-available">{plane.locationIcao}</span>
)}
</div>
{cargo.map((pkg) => {
const penalty = abandonPenalty(pkg, clockMs);
return (
// ISS-025 (v0.22.0): hovering a loaded package draws its full origin→dest trace on the map —
// same as job-list rows — and keeps this plane's halo lit via planeId. Leaving the row restores
// the block's plain plane highlight; leaving the whole block still ends at null (the child's
// mouseleave fires before the parent's).
<div
key={pkg.id}
data-testid={`onboard-package-${pkg.id}`}
className="contract-row"
onMouseEnter={() => onHover({ kind: 'package', originIcao: pkg.originIcao, destIcao: pkg.destIcao, planeId: plane.id })}
onMouseLeave={() => onHover({ kind: 'plane', id: plane.id })}
>
<button
type="button"
data-testid={`fly-to-${pkg.id}`}
className="pane-btn onboard-fly-btn"
disabled={actionsBusy || plane.status !== 'available'}
onClick={() => void runAction(`/api/planes/${plane.id}/fly`, { destIcao: pkg.destIcao })}
>
<span className="onboard-fly-label" title={`Fly to ${pkg.destName}`}>
Fly to {pkg.destName}
</span>
</button>
<div className="onboard-actions">
<button
type="button"
data-testid={`refuel-for-${pkg.id}`}
className="pane-btn"
disabled={actionsBusy || plane.status !== 'available'}
onClick={() => void runAction(`/api/planes/${plane.id}/refuel`, { mode: 'forFlight', packageId: pkg.id })}
>
Refuel
</button>
<button
type="button"
data-testid={`abandon-${pkg.id}`}
className="pane-btn"
disabled={actionsBusy || plane.status !== 'available'}
onClick={() => void runAction(`/api/packages/${pkg.id}/abandon`)}
>
Abandon{' '}
<span data-testid={`abandon-penalty-${pkg.id}`}>−{formatCash(penalty)}</span>
</button>
</div>
<div className="contract-sub">
<span className="contract-route">{pkg.originName} → {pkg.destName}</span>
<span>{formatCash(pkg.reward)}</span>
{pkg.status === 'expired' ? (
<span data-testid={`onboard-expired-${pkg.id}`}>expired</span>
) : (
<span>{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}</span>
)}
</div>
</div>
);
})}
{/* ISS-043 (v0.26.0): flight queue — the not-yet-started legs with cancel, plus an
add control reusing this pane's already-fetched airport options (no second fetch). */}
<div className="queue-subsection">
<span className="queue-heading">Queue</span>
{plane.queue.map((icao, i) => (
<div key={`${icao}-${i}`} data-testid={`queue-leg-${i}`} className="queue-row">
<span className="contract-route">
{airportOptions?.find((a) => a.icao === icao)?.name ?? icao} ({icao})
</span>
<button
type="button"
data-testid={`queue-cancel-${i}`}
className="pane-btn"
disabled={actionsBusy}
onClick={() => void runAction(`/api/planes/${plane.id}/queue/cancel`, { index: i })}
>
Cancel
</button>
</div>
))}
<div className="market-row">
<select
data-testid="queue-add-dest"
aria-label="Airport to add to the flight queue"
value={queueAddDestIcao ?? ''}
onChange={(e) => setQueueAddDestIcao(e.target.value || null)}
>
<option value="">Select an airport…</option>
{airportOptions?.map((a) => (
<option key={a.icao} value={a.icao}>
{a.name}
</option>
))}
</select>
<button
type="button"
data-testid="queue-add"
className="pane-btn"
disabled={actionsBusy || queueAddDestIcao === null}
onClick={() => {
if (queueAddDestIcao) void runAction(`/api/planes/${plane.id}/queue`, { destIcao: queueAddDestIcao });
}}
>
Add to queue
</button>
</div>
</div>
</div>
);
})
)}
</section>
{/* ISS-023 (v0.21.0): moved here from the right-pane market row — it belongs with {/* ISS-023 (v0.21.0): moved here from the right-pane market row — it belongs with
everything else about our planes, between On board and Time. */} everything else about our planes, between Fleet and Time. */}
{/* ISS-027 (v0.22.0): hovering the section previews the planned relocation on the map — {/* ISS-027 (v0.22.0): hovering the section previews the planned relocation on the map —
dashed trace from the selected plane's airport to the destination + a ring on the target. dashed trace from the selected plane's airport to the destination + a ring on the target.
With no grounded plane or no loaded airport options there is nothing to preview → null. */} With no planes or no loaded airport options there is nothing to preview → null. */}
<section <section
className="pane-section" className="pane-section"
data-testid="relocate-section" data-testid="relocate-section"
@@ -533,8 +492,9 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
onMouseLeave={() => onHover(null)} onMouseLeave={() => onHover(null)}
> >
<h3 className="pane-heading">Relocate</h3> <h3 className="pane-heading">Relocate</h3>
{/* ISS-010 (v0.18.0): relocate a grounded plane — the fly endpoint already allows pure {/* ISS-010 (v0.18.0): relocate a plane — the fly endpoint already allows pure
repositioning, this row just exposes it for planes with nothing on board. */} repositioning, this row just exposes it for planes with nothing on board.
ISS-053 (v0.32.0): lists ALL planes; a flying one's relocation is queued below. */}
<div className="market-row market-row-relocate"> <div className="market-row market-row-relocate">
<select <select
data-testid="relocate-plane" data-testid="relocate-plane"
@@ -542,7 +502,7 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
value={relocatePlane?.id ?? ''} value={relocatePlane?.id ?? ''}
onChange={(e) => setRelocatePlaneId(e.target.value || null)} onChange={(e) => setRelocatePlaneId(e.target.value || null)}
> >
{grounded.map((p) => ( {relocatable.map((p) => (
<option key={p.id} value={p.id}> <option key={p.id} value={p.id}>
{PLANE_TYPES[p.model]?.name ?? p.model} — {p.locationIcao} {PLANE_TYPES[p.model]?.name ?? p.model} — {p.locationIcao}
</option> </option>
@@ -565,9 +525,11 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
data-testid="relocate-go" data-testid="relocate-go"
className="pane-btn" className="pane-btn"
disabled={actionsBusy || relocatePlane === null || relocateDest === null} disabled={actionsBusy || relocatePlane === null || relocateDest === null}
// ISS-053 (v0.32.0): a flying plane can't start another leg — queue it instead, so the
// relocation takes off when the current one lands.
onClick={() => { onClick={() => {
if (relocatePlane && relocateDest) { if (relocatePlane && relocateDest) {
void runAction(`/api/planes/${relocatePlane.id}/fly`, { destIcao: relocateDest.icao }); void runAction(`/api/planes/${relocatePlane.id}/${relocatePlane.status === 'flying' ? 'queue' : 'fly'}`, { destIcao: relocateDest.icao });
} }
}} }}
> >
@@ -581,7 +543,7 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
<section className="pane-section" data-testid="refuel-section"> <section className="pane-section" data-testid="refuel-section">
<h3 className="pane-heading">Refuel</h3> <h3 className="pane-heading">Refuel</h3>
{/* Moved verbatim from the right-pane Market section — same testids/labels; the per-package {/* Moved verbatim from the right-pane Market section — same testids/labels; the per-package
"Refuel" button in On board (mode 'forFlight') is a different feature and stays there. */} "Refuel" button in the fleet rows (mode 'forFlight') is a different feature and stays there. */}
<div className="market-row"> <div className="market-row">
{refuelable.length === 0 ? ( {refuelable.length === 0 ? (
<span data-testid="market-refuel-none" className="pane-muted"> <span data-testid="market-refuel-none" className="pane-muted">
+96 -7
View File
@@ -2,8 +2,9 @@
import { useState } from 'react'; import { useState } from 'react';
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state'; import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
import { PLANE_TYPES } from '@/lib/game/catalog'; import { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
import { formatCash, formatDeadlineRelative } from '@/lib/format'; import { maxRangeKm } from '@/lib/sim/movement';
import { formatCargo, formatCash, formatDeadlineRelative, formatSpecialLabel } from '@/lib/format';
import { useGameAction } from '@/lib/useGameAction'; import { useGameAction } from '@/lib/useGameAction';
import type { HoverTarget } from './hoverTypes'; import type { HoverTarget } from './hoverTypes';
@@ -48,8 +49,9 @@ export function loadEligiblePlanes(state: StateSnapshot, pkg: PackageState): Pla
// Load controls for one open package: a single Load button when exactly one plane // Load controls for one open package: a single Load button when exactly one plane
// is eligible (it acts on that plane directly), a small plane picker + Load when // is eligible (it acts on that plane directly), a small plane picker + Load when
// several are, and a disabled button with the reason otherwise. Each row owns its // several are, and a disabled button with the reason otherwise. Each row owns its
// selection in local state and defaults to the "Select a plane…" placeholder — // selection in local state; ISS-055 (v0.31.0): the multi-plane path PRESELECTS the
// never preselecting — so rows stay visibly independent (v0.10.1 pattern). // first eligible plane so Load is one-click out of the box (matching the single-plane
// UX) while the picker still allows choosing a different plane.
function LoadControls({ function LoadControls({
pkg, pkg,
eligible, eligible,
@@ -62,8 +64,9 @@ function LoadControls({
onLoad: (planeId: string) => void; onLoad: (planeId: string) => void;
}) { }) {
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
// The user's pick while it stays eligible, else nothing (placeholder). // ISS-055 (v0.31.0): the user's pick while it stays eligible, else the FIRST eligible plane —
const effective = eligible.find((p) => p.id === selectedId) ?? null; // Load is enabled and one-click whenever ≥1 plane qualifies; the picker still allows a different pick.
const effective = eligible.find((p) => p.id === selectedId) ?? eligible[0] ?? null;
if (eligible.length === 1) { if (eligible.length === 1) {
return ( return (
@@ -119,6 +122,9 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
const [buyModelId, setBuyModelId] = useState<string | null>(null); const [buyModelId, setBuyModelId] = useState<string | null>(null);
const [sellPlaneId, setSellPlaneId] = useState<string | null>(null); const [sellPlaneId, setSellPlaneId] = useState<string | null>(null);
// v0.28.0 (ISS-047): Jobs / Planes market tabs — the inactive tab's content unmounts.
const [activeTab, setActiveTab] = useState<'jobs' | 'planes'>('jobs');
// ISS-023 (v0.21.0): the Relocate row moved to the left pane — it belongs with everything else // ISS-023 (v0.21.0): the Relocate row moved to the left pane — it belongs with everything else
// about our planes, not in the market. Its state + /api/airports fetch went with it. // about our planes, not in the market. Its state + /api/airports fetch went with it.
// ISS-035 (v0.23.0): same for the Refuel-to-full row — its state + derived values moved to the // ISS-035 (v0.23.0): same for the Refuel-to-full row — its state + derived values moved to the
@@ -150,6 +156,31 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
<aside data-testid="right-pane" className="pane right-pane"> <aside data-testid="right-pane" className="pane right-pane">
<h2 className="pane-title">Jobs &amp; Market</h2> <h2 className="pane-title">Jobs &amp; Market</h2>
{/* v0.28.0 (ISS-047): Jobs / Planes market tabs — the inactive tab's content unmounts. */}
<div className="pane-tabs" role="tablist">
<button
type="button"
data-testid="tab-jobs"
role="tab"
aria-selected={activeTab === 'jobs'}
className="tab-btn"
onClick={() => setActiveTab('jobs')}
>
Jobs
</button>
<button
type="button"
data-testid="tab-planes-market"
role="tab"
aria-selected={activeTab === 'planes'}
className="tab-btn"
onClick={() => setActiveTab('planes')}
>
Planes market
</button>
</div>
{activeTab === 'jobs' && (
<section className="pane-section"> <section className="pane-section">
<h3 className="pane-heading">Packages</h3> <h3 className="pane-heading">Packages</h3>
{open.length === 0 ? ( {open.length === 0 ? (
@@ -169,6 +200,12 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
> >
<div className="contract-main"> <div className="contract-main">
<span className="contract-route">{pkg.originName} → {pkg.destName}</span> <span className="contract-route">{pkg.originName} → {pkg.destName}</span>
{/* v0.37.0 (ISS-073): special-status badge — rendered only when the package has one. */}
{pkg.special && (
<span data-testid={`package-special-${pkg.id}`} className="special-badge">
{formatSpecialLabel(pkg.special)}
</span>
)}
{eligible.length > 0 ? ( {eligible.length > 0 ? (
<LoadControls <LoadControls
pkg={pkg} pkg={pkg}
@@ -181,10 +218,21 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
Load Load
</button> </button>
)} )}
{/* ISS-047 part B (v0.29.0): dismiss the job — it leaves the market permanently, no penalty. */}
<button
type="button"
data-testid={`ignore-${pkg.id}`}
className="pane-btn"
disabled={busy}
onClick={() => void runAction(`/api/packages/${pkg.id}/ignore`)}
>
Ignore
</button>
</div> </div>
<div className="contract-sub"> <div className="contract-sub">
<span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span> <span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span>
<span>{pkg.cargoType} × {pkg.cargoCount}</span> {/* ISS-066 (v0.35.0): cargo shows as type + tonnage ("Mail 10.0t"), not "type × count". */}
<span data-testid={`open-package-cargo-${pkg.id}`}>{formatCargo(pkg.cargoType, pkg.weightKg)}</span>
<span data-testid={`open-package-deadline-${pkg.id}`}> <span data-testid={`open-package-deadline-${pkg.id}`}>
{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)} {formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}
</span> </span>
@@ -195,9 +243,11 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
</ul> </ul>
)} )}
</section> </section>
)}
{/* ISS-021 (v0.20.0): the On board section moved to the left pane (between Fleet and Time). */} {/* ISS-021 (v0.20.0): the On board section moved to the left pane (between Fleet and Time). */}
{activeTab === 'planes' && (
<section className="pane-section"> <section className="pane-section">
<h3 className="pane-heading">Market</h3> <h3 className="pane-heading">Market</h3>
@@ -261,7 +311,46 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
{/* ISS-023 (v0.21.0): the Relocate row moved to the left pane's "Relocate" section. */} {/* ISS-023 (v0.21.0): the Relocate row moved to the left pane's "Relocate" section. */}
{/* ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane's "Refuel" section, next to Relocate. */} {/* ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane's "Refuel" section, next to Relocate. */}
{/* v0.28.0 (ISS-047): tech-spec panel per catalog type — progression order (price ascending).
All values come from the snapshot's catalog; max range is the weighted-model closed form. */}
{[...catalog].sort((a, b) => a.price - b.price).map((e) => (
<div key={e.model} className="plane-type-card" data-testid={`plane-type-${e.model}`}>
<h4 className="plane-type-name">{e.label}</h4>
<div className="stat-row">
<span>Buy</span>
<strong>{formatCash(e.price)}</strong>
</div>
<div className="stat-row">
<span>Sell</span>
<strong>{formatCash(Math.round(e.price * SELL_FRACTION * 100) / 100)}</strong>
</div>
<div className="stat-row">
<span>Payload</span>
<strong>{(e.maxPayloadKg / 1000).toFixed(1)}t</strong>
</div>
<div className="stat-row">
<span>Cruise speed</span>
<strong>{e.speedKmh} km/h</strong>
</div>
<div className="stat-row">
<span>Fuel burn</span>
<strong>{e.burnPerKm} L/km</strong>
</div>
<div className="stat-row">
<span>Max fuel</span>
<strong>{e.maxFuel} L</strong>
</div>
<div className="stat-row">
<span>Max range</span>
<strong data-testid={`plane-type-range-${e.model}`}>
{Math.round(maxRangeKm(e.burnPerKm, e.emptyWeightKg, e.maxFuel))} km
</strong>
</div>
</div>
))}
</section> </section>
)}
{actionError && <p data-testid="right-error" className="pane-error">{actionError}</p>} {actionError && <p data-testid="right-error" className="pane-error">{actionError}</p>}
</aside> </aside>
+1
View File
@@ -39,6 +39,7 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
cargoType: 'mail', cargoType: 'mail',
cargoCount: 4, cargoCount: 4,
weightKg: 1_000, weightKg: 1_000,
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
deadlineSimTime: 1_000_000, deadlineSimTime: 1_000_000,
status: 'open', status: 'open',
planeId: null, planeId: null,
+15 -2
View File
@@ -1,5 +1,10 @@
import type { Page, Request } from '@playwright/test'; import type { Page, Request } from '@playwright/test';
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
// Real helpers (v0.36.0) — the wind-adjusted fuel math must match lib/ exactly; Playwright resolves
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather';
import { windBurnFactor } from '../lib/sim/movement';
// v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl): // v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl):
// the full core loop through the UI — hook creates a short package at EDDF → Load // the full core loop through the UI — hook creates a short package at EDDF → Load
@@ -219,10 +224,18 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
const repGain = Math.round((REP_BASE + REP_PER_KM * exactKm) * 100) / 100; const repGain = Math.round((REP_BASE + REP_PER_KM * exactKm) * 100) / 100;
expect(s.account.reputation).toBeCloseTo(Math.round((before.account.reputation + repGain) * 100) / 100, 9); expect(s.account.reputation).toBeCloseTo(Math.round((before.account.reputation + repGain) * 100) / 100, 9);
// Fuel burned per the v0.16.0 weight-proportional model (closed form — no rounding drift). // Fuel burned per the v0.16.0 weight-proportional model (closed form — no rounding drift),
// wind-adjusted (v0.36.0) by departure weather vs route heading.
const entry = before.catalog.find((e) => e.model === 'atr72')!; const entry = before.catalog.find((e) => e.model === 'atr72')!;
const wHub = weatherFor(HUB);
expect(landed.fuel).toBeCloseTo( expect(landed.fuel).toBeCloseTo(
fuelAfter(before.planes.find((p) => p.id === planeId)!.fuel, entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, exactKm), fuelAfter(
before.planes.find((p) => p.id === planeId)!.fuel,
entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)),
entry.emptyWeightKg,
entry.maxFuel,
exactKm,
),
6, 6,
); );
+2 -1
View File
@@ -13,7 +13,7 @@ test('GET /api/version returns the app version from package.json', async ({ page
expect(body.version).toBe(pkg.version); expect(body.version).toBe(pkg.version);
}); });
test('GET /api/openapi serves the spec with all twelve endpoints and the package.json version', async ({ page }) => { test('GET /api/openapi serves the spec with all thirteen endpoints and the package.json version', async ({ page }) => {
const res = await page.request.get('/api/openapi'); const res = await page.request.get('/api/openapi');
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
expect(res.headers()['content-type']).toContain('application/json'); expect(res.headers()['content-type']).toContain('application/json');
@@ -26,6 +26,7 @@ test('GET /api/openapi serves the spec with all twelve endpoints and the package
'/api/market/buy-plane', '/api/market/buy-plane',
'/api/market/sell-plane', '/api/market/sell-plane',
'/api/packages/{id}/abandon', '/api/packages/{id}/abandon',
'/api/packages/{id}/ignore', // v0.29.0 (ISS-047 part B)
'/api/packages/{id}/load', '/api/packages/{id}/load',
'/api/planes/{planeId}/fly', '/api/planes/{planeId}/fly',
'/api/planes/{planeId}/queue', '/api/planes/{planeId}/queue',
+3 -3
View File
@@ -439,7 +439,7 @@ test('hovering the Relocate section previews the planned route and target airpor
}).toBe(0); }).toBe(0);
}); });
test('hovering an on-board block of a flying plane highlights its route', async ({ page }) => { test('hovering the fleet row of a flying plane highlights its route', async ({ page }) => {
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
await flyHomeViaApi(page); await flyHomeViaApi(page);
@@ -458,8 +458,8 @@ test('hovering an on-board block of a flying plane highlights its route', async
expect(fly.status()).toBe(200); expect(fly.status()).toBe(200);
await setScale(page, SCALE); await setScale(page, SCALE);
// The on-board block shows the plane in flight once a snapshot with status 'flying' lands. // The fleet row shows the plane in flight once a snapshot with status 'flying' lands (ISS-054).
const block = page.getByTestId('onboard-plane-starter'); const block = page.getByTestId('plane-plane-starter');
await expect(block).toContainText(/in flight/i, { timeout: 15_000 }); await expect(block).toContainText(/in flight/i, { timeout: 15_000 });
// Freeze mid-flight so the ~10 s leg can't land under us while we assert (same pattern as // Freeze mid-flight so the ~10 s leg can't land under us while we assert (same pattern as
+8 -39
View File
@@ -70,10 +70,9 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
// Sim clock renders (UTC wall-clock of the game time). // Sim clock renders (UTC wall-clock of the game time).
await expect(page.getByTestId('sim-clock')).toContainText(/UTC/); await expect(page.getByTestId('sim-clock')).toContainText(/UTC/);
// ISS-021 (v0.20.0): the On board section moved to the left pane (between Fleet and Time) — // ISS-054 (v0.33.0): On board merged into the Fleet list — its heading is gone from both panes,
// its heading renders here… // per-plane cargo rows now render inside the fleet rows.
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'On board', exact: true })).toBeVisible(); await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
// …and no longer in the right pane.
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0); await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
// ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane next to Relocate — its section // ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane next to Relocate — its section
@@ -85,41 +84,6 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
await expect(page.getByTestId('right-pane').getByTestId('market-refuel')).toHaveCount(0); await expect(page.getByTestId('right-pane').getByTestId('market-refuel')).toHaveCount(0);
}); });
test('time controls: pause freezes the sim and the ladder slider sets timeScale', async ({ page }) => {
await page.goto('/');
const pauseBtn = page.getByTestId('pause-toggle');
await expect(pauseBtn).toBeVisible({ timeout: 15_000 });
// Pause via the UI button — assert against the authoritative /api/state…
await pauseBtn.click();
await expect.poll(async () => (await getState(page)).simState.paused, { timeout: 10_000 }).toBe(true);
// …and in the pane itself (the refetch after POST flips the button to Resume).
await expect(pauseBtn).toHaveText('Resume');
// Scale via the ladder slider. End jumps to max (t=1 → 1000×): the label updates
// optimistically on input, and release flushes the debounced POST immediately.
const slider = page.getByTestId('time-scale-slider');
await slider.focus();
await page.keyboard.press('End');
await expect(page.getByTestId('time-scale-value')).toHaveText('1000×', { timeout: 10_000 });
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1000);
// Home jumps to min (t=0 → 1×).
await page.keyboard.press('Home');
await expect(page.getByTestId('time-scale-value')).toHaveText('1×', { timeout: 10_000 });
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1);
// Game clock shows seconds: YYYY-MM-DD HH:MM:SS UTC.
await expect(page.getByTestId('sim-clock')).toContainText(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC/);
// Restore the live instance to scale 1 + unpaused (afterEach enforces this too).
const restore = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } });
expect(restore.status()).toBe(200);
const after = await getState(page);
expect(after.simState.timeScale).toBe(1);
expect(after.simState.paused).toBe(false);
});
test('sim clock ticks smoothly between polls — seconds advance without waiting for a snapshot (ISS-007)', async ({ page }) => { test('sim clock ticks smoothly between polls — seconds advance without waiting for a snapshot (ISS-007)', async ({ page }) => {
await page.goto('/'); await page.goto('/');
const clock = page.getByTestId('sim-clock'); const clock = page.getByTestId('sim-clock');
@@ -190,4 +154,9 @@ test('right pane placeholder exists with the Jobs & Market heading', async ({ pa
const right = page.getByTestId('right-pane'); const right = page.getByTestId('right-pane');
await expect(right).toBeVisible(); await expect(right).toBeVisible();
await expect(right).toContainText('Jobs & Market'); await expect(right).toContainText('Jobs & Market');
// v0.28.0 (ISS-047): the Jobs / Planes market tab bar renders once a snapshot lands; Jobs is default-active.
await expect(page.getByTestId('tab-jobs'), 'the Jobs tab button appears').toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId('tab-planes-market')).toBeVisible();
await expect(page.getByTestId('tab-jobs')).toHaveAttribute('aria-selected', 'true');
}); });
+17 -13
View File
@@ -1,7 +1,8 @@
import { test, expect, type Page } from '@playwright/test'; import { test, expect, type Page } from '@playwright/test';
// v0.26.0 — ISS-043 part 2 (UI): the flight queue. A plane's not-yet-started legs show up in a // v0.26.0 — ISS-043 part 2 (UI): the flight queue. A plane's not-yet-started legs show up in a
// Queue sub-section of its on-board block (add via airport select + button, cancel per leg), and // Queue sub-section of its on-board block (cancel per leg; adding happens via fly-to / Relocate
// while flying, ISS-053), and
// a flying plane with queued legs draws them as a DOTTED line (`queue-routes` layer) chaining // a flying plane with queued legs draws them as a DOTTED line (`queue-routes` layer) chaining
// dest → queue[0] → … — the active leg stays in the solid `routes-legs` layer. Landing auto-starts // dest → queue[0] → … — the active leg stays in the solid `routes-legs` layer. Landing auto-starts
// the next queued leg server-side (part 1); test 3 exercises that chain end-to-end through the API. // the next queued leg server-side (part 1); test 3 exercises that chain end-to-end through the API.
@@ -216,9 +217,9 @@ test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`); const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
await expect(onboardRow, 'loaded package appears in the On board section').toBeVisible({ timeout: 15_000 }); await expect(onboardRow, 'loaded package appears in the On board section').toBeVisible({ timeout: 15_000 });
// LeftPane's own /api/airports fetch populates the queue-add select — wait for it. // LeftPane's own /api/airports fetch populates the Relocate destination select — wait for it.
const addSelect = page.getByTestId('queue-add-dest'); const destSelect = page.getByTestId('relocate-dest');
await expect(addSelect.locator(`option[value="${QUEUE}"]`)).toHaveCount(1, { timeout: 15_000 }); await expect(destSelect.locator(`option[value="${QUEUE}"]`)).toHaveCount(1, { timeout: 15_000 });
// START leg 1 via the UI (fly-to button in the on-board block). // START leg 1 via the UI (fly-to button in the on-board block).
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`); const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
@@ -232,20 +233,23 @@ test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
}, { timeout: 15_000, message: 'waiting for the fly click to take effect (status "flying" + dest set)' }).toBe(true); }, { timeout: 15_000, message: 'waiting for the fly click to take effect (status "flying" + dest set)' }).toBe(true);
await postSim(page, { paused: true }); // pause-sim pattern: deterministic window for UI clicks await postSim(page, { paused: true }); // pause-sim pattern: deterministic window for UI clicks
// ADD a leg to the queue via the UI. The button is disabled until an airport is selected… // ISS-065: re-queuing the CURRENT leg's destination while flying must be rejected with 409.
const addButton = page.getByTestId('queue-add'); const dupQueue = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: DEST } });
await expect(addButton).toBeDisabled(); expect(dupQueue.status()).toBe(409);
await addSelect.selectOption(QUEUE);
await expect(addButton).toBeEnabled(); // ADD a leg to the queue via the Relocate section (ISS-053): the starter is flying, so relocate-go
await addButton.click(); // posts to /queue and the leg starts when it lands.
await page.getByTestId('relocate-plane').selectOption('plane-starter');
await destSelect.selectOption(QUEUE);
await page.getByTestId('relocate-go').click();
// …and the new leg appears as queue-leg-0 with its destination. // …and the new leg appears as queue-leg-0 with its destination.
const legRow = page.getByTestId('queue-leg-0'); const legRow = page.getByTestId('queue-leg-plane-starter-0');
await expect(legRow, 'the queued leg appears in the Queue sub-section').toContainText(QUEUE, { timeout: 15_000 }); await expect(legRow, 'the queued leg appears in the Queue sub-section').toContainText(QUEUE, { timeout: 15_000 });
// CANCEL it via the UI and assert it's gone. // CANCEL it via the UI and assert it's gone.
await page.getByTestId('queue-cancel-0').click(); await page.getByTestId('queue-cancel-plane-starter-0').click();
await expect(page.getByTestId('queue-leg-0'), 'the cancelled leg is removed from the queue').toHaveCount(0, { timeout: 15_000 }); await expect(page.getByTestId('queue-leg-plane-starter-0'), 'the cancelled leg is removed from the queue').toHaveCount(0, { timeout: 15_000 });
// Cleanup: resume + fast-forward to arrival at DEST (empty queue → plain landing), then fly home. // Cleanup: resume + fast-forward to arrival at DEST (empty queue → plain landing), then fly home.
await setScale(page, SCALE); await setScale(page, SCALE);
+136 -20
View File
@@ -1,5 +1,10 @@
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
// Real helpers (v0.36.0) — the wind-adjusted fuel math must match lib/ exactly; Playwright resolves
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather';
import { windBurnFactor } from '../lib/sim/movement';
// v0.12.0 — right Jobs & Market pane against the live origin: open packages render with // v0.12.0 — right Jobs & Market pane against the live origin: open packages render with
// names/reward/deadline and Load (UI click) binds one to the starter; the on-board list // names/reward/deadline and Load (UI click) binds one to the starter; the on-board list
@@ -28,10 +33,11 @@ interface PackageState {
originName: string; originName: string;
destName: string; destName: string;
reward: number; reward: number;
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned'; status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored'; // v0.29.0 (ISS-047 part B)
planeId: string | null; planeId: string | null;
createdSimTime: number; createdSimTime: number;
deadlineSimTime: number; deadlineSimTime: number;
special: string | null; // v0.37.0 (ISS-073) — special status from the specials registry
} }
interface CatalogEntry { interface CatalogEntry {
@@ -119,22 +125,28 @@ async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lo
} }
// Farthest airport from EDDF whose leg still FITS A FULL TANK — the longest flyable leg (v0.16.0 // Farthest airport from EDDF whose leg still FITS A FULL TANK — the longest flyable leg (v0.16.0
// refuel-for-flight test). The dataset's absolute farthest can exceed the aircraft's range (UNNT is // refuel-for-flight test). The dataset's absolute farthest can exceed the aircraft's range, so a
// ~4803 km out and needs ~6955 L > the 6300 L tank), so a full-tank fly there would rightly 409; // full-tank fly there would rightly 409; this test wants a long-but-flyable leg instead. Wind-
// this test wants a long-but-flyable leg instead. // adjusted (v0.36.0): BOTH directions must fit — the outbound is flown from a full tank and the
// return leg is what the forFlight refuel below buys, which also 409s if it doesn't fit.
async function farthestFlyableToHub( async function farthestFlyableToHub(
page: Page, page: Page,
entry: CatalogEntry, entry: CatalogEntry,
): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> { ): Promise<{ icao: string; lat: number; lon: number; d: number; hubLat: number; hubLon: number }> {
const fc = (await (await page.request.get('/api/airports')).json()) as { const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>; features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
}; };
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties; const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
const wHub = weatherFor(HUB);
let farthest: { icao: string; lat: number; lon: number; d: number } | null = null; let farthest: { icao: string; lat: number; lon: number; d: number } | null = null;
for (const f of fc.features) { for (const f of fc.features) {
if (f.properties.icao === HUB) continue; if (f.properties.icao === HUB) continue;
const d = haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon); const d = haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon);
if (requiredFuelFor(entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range const wDest = weatherFor(f.properties.icao);
const outBurn = entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(hub.lat, hub.lon, f.properties.lat, f.properties.lon));
if (requiredFuelFor(outBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range
const backBurn = entry.burnPerKm * windBurnFactor(wDest.windKt, wDest.windDirDeg, initialBearingDeg(f.properties.lat, f.properties.lon, hub.lat, hub.lon));
if (requiredFuelFor(backBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // return leg wouldn't fit a full tank either
if (!farthest || d > farthest.d) { if (!farthest || d > farthest.d) {
farthest = { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon, d }; farthest = { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon, d };
} }
@@ -151,10 +163,17 @@ function requiredFuelFor(burnPerKm: number, emptyWeightKg: number, maxFuel: numb
return (emptyWeightKg / FUEL_DENSITY_KG_PER_L) * (Math.exp(k * FUEL_DENSITY_KG_PER_L * distanceKm) - 1); return (emptyWeightKg / FUEL_DENSITY_KG_PER_L) * (Math.exp(k * FUEL_DENSITY_KG_PER_L * distanceKm) - 1);
} }
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route. // Mirrors maxRangeKm in lib/sim/movement.ts (v0.28.0): full-tank range under the weighted model.
async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number): Promise<PackageState> { function maxRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number): number {
const rho = FUEL_DENSITY_KG_PER_L;
return Math.log(1 + (rho * maxFuel) / emptyWeightKg) * ((emptyWeightKg + rho * maxFuel) / (rho * burnPerKm));
}
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route. Omitting `special`
// makes the hook force null (no roll), so existing specs' exact reward/rep assertions stay deterministic.
async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number, weightKg?: number, special?: string | null): Promise<PackageState> {
const created = await page.request.post('/api/test/create-package', { const created = await page.request.post('/api/test/create-package', {
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs }, data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs, weightKg, special },
}); });
expect(created.status()).toBe(201); expect(created.status()).toBe(201);
return (await created.json()) as PackageState; return (await created.json()) as PackageState;
@@ -180,8 +199,13 @@ async function flyHomeViaApi(page: Page): Promise<void> {
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg // ISS-041: the landing above paused the sim — setScale clears pause so the return leg
// fast-forwards instead of taking real-time hours. // fast-forwards instead of taking real-time hours.
await setScale(page, SCALE); await setScale(page, SCALE);
const res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); let flyHome = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight) if (!flyHome.ok()) {
// A long outbound leg can leave the tank too empty for the return — top up and retry.
expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200);
flyHome = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
}
expect(flyHome.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
await expect await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, { .poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
timeout: 240_000, timeout: 240_000,
@@ -189,6 +213,14 @@ async function flyHomeViaApi(page: Page): Promise<void> {
}) })
.toBe(true); .toBe(true);
} }
// Abandon leftover loaded/expired packages (the baseline is captured AFTER this cleanup).
const s1 = await getState(page);
for (const pkg of s1.packages) {
if (pkg.planeId === 'plane-starter' && (pkg.status === 'loaded' || pkg.status === 'expired')) {
const res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
expect(res.ok()).toBeTruthy();
}
}
} }
test('open packages render with names, reward and deadline; Load is enabled at a package origin', async ({ page }) => { test('open packages render with names, reward and deadline; Load is enabled at a package origin', async ({ page }) => {
@@ -207,9 +239,12 @@ test('open packages render with names, reward and deadline; Load is enabled at a
// Deterministic target: a hook-created EDDF package — Load is enabled (starter at origin, free slot). // Deterministic target: a hook-created EDDF package — Load is enabled (starter at origin, free slot).
const nearest = await nearestToHub(page); const nearest = await nearestToHub(page);
const pkg = await createPackage(page, HUB, nearest.icao); const pkg = await createPackage(page, HUB, nearest.icao, undefined, 4_000);
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`); const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 }); await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
// ISS-066 (v0.35.0): cargo shows as type + tonnage ("… 4.0t"), not "type × count".
// 4 t is within the starter ATR's 4.8 t maxPayloadKg, so Load stays enabled below.
await expect(page.getByTestId(`open-package-cargo-${pkg.id}`)).toContainText('4.0t');
await expect(page.getByTestId(`open-package-reward-${pkg.id}`)).toContainText(eur(pkg.reward)); await expect(page.getByTestId(`open-package-reward-${pkg.id}`)).toContainText(eur(pkg.reward));
const loadBtn = page.getByTestId(`load-${pkg.id}`); const loadBtn = page.getByTestId(`load-${pkg.id}`);
await expect(loadBtn).toBeEnabled(); await expect(loadBtn).toBeEnabled();
@@ -225,6 +260,23 @@ test('open packages render with names, reward and deadline; Load is enabled at a
await expect(page.getByTestId(`onboard-package-${pkg.id}`)).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId(`onboard-package-${pkg.id}`)).toBeVisible({ timeout: 10_000 });
}); });
test('a priority package shows its special badge on the open-package row', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // deterministic: the starter sits at EDDF
const nearest = await nearestToHub(page);
const pkg = await createPackage(page, HUB, nearest.icao, undefined, 4_000, 'priority');
expect(pkg.special).toBe('priority'); // hook passthrough — no roll
await page.goto('/');
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
// v0.37.0 (ISS-073): the row carries a "Priority" badge next to the route.
const badge = page.getByTestId(`package-special-${pkg.id}`);
await expect(badge).toBeVisible();
await expect(badge).toHaveText('Priority');
});
test('Load is disabled when no plane sits at the package origin', async ({ page }) => { test('Load is disabled when no plane sits at the package origin', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // starter at EDDF — a Madrid-origin package cannot be loaded here await flyHomeViaApi(page); // starter at EDDF — a Madrid-origin package cannot be loaded here
@@ -262,8 +314,8 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
await expect(flyBtn).toBeEnabled(); await expect(flyBtn).toBeEnabled();
await flyBtn.click(); await flyBtn.click();
// The on-board block now shows the plane in flight with its current leg… // The fleet row now shows the plane in flight with its current leg (ISS-054)…
await expect(page.getByTestId('onboard-plane-starter')).toContainText(/in flight/i, { timeout: 10_000 }); await expect(page.getByTestId('plane-plane-starter')).toContainText(/in flight/i, { timeout: 10_000 });
const leg = page.getByTestId('plane-leg-plane-starter'); const leg = page.getByTestId('plane-leg-plane-starter');
await expect(leg).toBeVisible(); await expect(leg).toBeVisible();
await expect(leg).toContainText('→'); await expect(leg).toContainText('→');
@@ -312,6 +364,9 @@ test('market buy/sell render with previews from the catalog in /api/state; left-
expect(entry.maxPayloadKg).toBe(4_800); // ATR 72-600 max payload (lib/game/catalog.ts) expect(entry.maxPayloadKg).toBe(4_800); // ATR 72-600 max payload (lib/game/catalog.ts)
await page.goto('/'); await page.goto('/');
// v0.28.0 (ISS-047): the market rows live in the Planes market tab — switch to it first.
await page.getByTestId('tab-planes-market').click();
// Buy: the model picker lists the catalog entry and the price preview is its full price. // Buy: the model picker lists the catalog entry and the price preview is its full price.
const buyModel = page.getByTestId('market-buy-model'); const buyModel = page.getByTestId('market-buy-model');
await expect(buyModel).toBeVisible({ timeout: 15_000 }); await expect(buyModel).toBeVisible({ timeout: 15_000 });
@@ -353,7 +408,7 @@ test('market buy/sell render with previews from the catalog in /api/state; left-
} }
}); });
test('on-board row Refuel tops up just enough for the package leg; left-pane button says "Refuel to full"', async ({ page }) => { test('on-board row Refuel buys 150% of the weather-adjusted leg requirement; left-pane button says "Refuel to full"', async ({ page }) => {
test.setTimeout(300_000); // self-heal + one long flight each way at scale 1000 test.setTimeout(300_000); // self-heal + one long flight each way at scale 1000
await flyHomeViaApi(page); await flyHomeViaApi(page);
@@ -363,7 +418,7 @@ test('on-board row Refuel tops up just enough for the package leg; left-pane but
// Deterministic baseline: a full tank. // Deterministic baseline: a full tank.
expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200); expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200);
// Longest FLYABLE leg from the hub (fits a full tank) — long enough that "just enough" for it // Longest FLYABLE leg from the hub (fits a full tank) — long enough that 150% of its requirement
// exceeds what's left on arrival. // exceeds what's left on arrival.
const farthest = await farthestFlyableToHub(page, entry); const farthest = await farthestFlyableToHub(page, entry);
const pkg1 = await createPackage(page, HUB, farthest.icao, 24 * 3_600_000); const pkg1 = await createPackage(page, HUB, farthest.icao, 24 * 3_600_000);
@@ -384,12 +439,21 @@ test('on-board row Refuel tops up just enough for the package leg; left-pane but
const pkg2 = await createPackage(page, farthest.icao, HUB, 24 * 3_600_000); const pkg2 = await createPackage(page, farthest.icao, HUB, 24 * 3_600_000);
expect((await page.request.post(`/api/packages/${pkg2.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200); expect((await page.request.post(`/api/packages/${pkg2.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200);
// Expected "just enough" from the snapshot constants (mirrors requiredFuelFor in lib/sim/movement.ts). // Expected "150% of the requirement" from the snapshot constants (mirrors requiredFuelFor in
// lib/sim/movement.ts, wind-adjusted per v0.36.0 — departure weather at the far end vs heading home).
const pre = await getState(page); const pre = await getState(page);
const fuelBefore = pre.planes.find((p) => p.id === 'plane-starter')!.fuel; const fuelBefore = pre.planes.find((p) => p.id === 'plane-starter')!.fuel;
const cashBefore = pre.account.cash; const cashBefore = pre.account.cash;
const km = haversineKm(farthest.hubLat, farthest.hubLon, farthest.lat, farthest.lon); // symmetric: hub ↔ far end const km = haversineKm(farthest.hubLat, farthest.hubLon, farthest.lat, farthest.lon); // symmetric: hub ↔ far end
const liters = Math.max(0, requiredFuelFor(entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, km) - fuelBefore); const wFar = weatherFor(farthest.icao);
const required = requiredFuelFor(
entry.burnPerKm * windBurnFactor(wFar.windKt, wFar.windDirDeg, initialBearingDeg(farthest.lat, farthest.lon, farthest.hubLat, farthest.hubLon)),
entry.emptyWeightKg,
entry.maxFuel,
km,
);
const target = Math.min(entry.maxFuel, required * 1.5); // ISS-071: 150% margin, capped at tank capacity
const liters = Math.max(0, target - fuelBefore);
expect(liters).toBeGreaterThan(0); // the long leg genuinely needs more than what's left expect(liters).toBeGreaterThan(0); // the long leg genuinely needs more than what's left
const expectedCost = Math.round(liters * pre.fuelPricePerL * 100) / 100; const expectedCost = Math.round(liters * pre.fuelPricePerL * 100) / 100;
@@ -412,8 +476,8 @@ test('on-board row Refuel tops up just enough for the package leg; left-pane but
await expect(page.getByTestId('market-refuel')).toHaveText('Refuel to full'); await expect(page.getByTestId('market-refuel')).toHaveText('Refuel to full');
// Restore: fly home — pkg2's destination IS the hub, so it delivers on arrival. The tank holds // Restore: fly home — pkg2's destination IS the hub, so it delivers on arrival. The tank holds
// exactly the leg requirement, so this also proves "just enough" is enough. ISS-041: the far-end // 150% of the leg requirement (or a full tank), so the return is comfortably feasible. ISS-041:
// arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause). // the far-end arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
await setScale(page, SCALE); await setScale(page, SCALE);
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } })).status()).toBe(200); expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } })).status()).toBe(200);
await expect await expect
@@ -425,3 +489,55 @@ test('on-board row Refuel tops up just enough for the package leg; left-pane but
// Top the tank back up so later specs start with a full tank. // Top the tank back up so later specs start with a full tank.
expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200); expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200);
}); });
test('Ignore removes an open job from the list permanently (ISS-047 part B)', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // deterministic: the starter sits at EDDF
const nearest = await nearestToHub(page);
const pkg = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000);
await page.goto('/');
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
// Ignore through the UI — the post-action snapshot lands immediately (no poll wait).
const ignoreBtn = page.getByTestId(`ignore-${pkg.id}`);
await expect(ignoreBtn, 'the row has an Ignore button').toBeEnabled();
await ignoreBtn.click();
// Server truth: the package is 'ignored'…
await expect
.poll(async () => (await getState(page)).packages.find((p) => p.id === pkg.id)?.status, {
timeout: 15_000,
message: 'waiting for the ignored status in /api/state',
})
.toBe('ignored');
// …and the row is gone from the open list (the section filters status==='open').
await expect(row).not.toBeVisible({ timeout: 15_000 });
// A second ignore attempt on the same id is a 409 — 'ignored' is terminal.
const again = await page.request.post(`/api/packages/${pkg.id}/ignore`);
expect(again.status()).toBe(409);
});
test('Planes market tab lists all plane types with tech specs', async ({ page }) => {
await page.goto('/');
const body = await getState(page); // authoritative catalog — v0.28.0 ships six types
expect(body.catalog.length).toBe(6);
await page.getByTestId('tab-planes-market').click();
// ATR card: name + full-tank range from the same closed form as lib/sim/movement.ts (v0.28.0).
const atr = body.catalog.find((e) => e.model === 'atr72')!;
await expect(page.getByTestId('plane-type-atr72'), 'the ATR card appears').toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId('plane-type-atr72')).toContainText(atr.label);
const atrRange = Math.round(maxRangeKm(atr.burnPerKm, atr.emptyWeightKg, atr.maxFuel));
await expect(page.getByTestId('plane-type-range-atr72')).toContainText(`${atrRange} km`);
// Ladder sanity: the most expensive type (Boeing 777-300ER) is present with its €9M price.
const b777 = page.getByTestId('plane-type-b777');
await expect(b777).toBeVisible();
await expect(b777).toContainText(eur(9_000_000)); // lib/game/catalog.ts
});
@@ -1,11 +1,10 @@
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
// v0.24.0 — ISS-041: when the LAST plane lands (no plane remains in flight) the sim pauses // v0.30.0 — ISS-057: when a landing leaves any plane on the ground the sim slows to 1× server-side
// server-side WITHOUT touching timeScale (speed is preserved), and starting a flight // WITHOUT pausing (pause is manual-only now), and starting a flight (POST /api/planes/{id}/fly)
// (POST /api/planes/{id}/fly) auto-resumes a paused sim. The cycle: dispatch unpauses → // restores the last MANUALLY selected speed once no plane remains grounded. The cycle: dispatch at
// last landing pauses → next dispatch unpauses again. Arrival-triggered by design: a grounded // 1000× → landing slows to 1× → next dispatch (last grounded plane airborne) resumes 1000×.
// fleet at high scale is left alone, there is no idle-enforcement or auto-restore of pause.
const SCALE = 1000; // fastest sim speed — top of the v0.14.0 ladder (same convention as e2e/sim.spec.ts) const SCALE = 1000; // fastest sim speed — top of the v0.14.0 ladder (same convention as e2e/sim.spec.ts)
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts) const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
@@ -34,8 +33,8 @@ async function postSim(page: Page, body: Record<string, unknown>): Promise<void>
// Set the time scale and VERIFY from the response that it stuck — if something external ever // Set the time scale and VERIFY from the response that it stuck — if something external ever
// resets the live instance's controls mid-test, fail loudly here instead of timing out later. // resets the live instance's controls mid-test, fail loudly here instead of timing out later.
// ISS-041 also clears pause: a landing pauses the sim, so any fast-forward after one must // ISS-057 also records this as lastSelectedTimeScale (the resume target) and clears pause, so any
// resume it or the clock stays frozen and the next wait hangs. // fast-forward after a manual pause or a slowed-to-1× landing proceeds at full speed.
async function setScale(page: Page, scale: number): Promise<void> { async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } }); const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
@@ -78,11 +77,16 @@ async function flyHomeViaApi(page: Page): Promise<void> {
plane = s.planes.find((p) => p.id === 'plane-starter')!; plane = s.planes.find((p) => p.id === 'plane-starter')!;
} }
if (plane.locationIcao !== HUB) { if (plane.locationIcao !== HUB) {
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg // ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× (and records it as
// fast-forwards instead of taking real-time hours. // lastSelectedTimeScale) so the return leg fast-forwards instead of taking real-time hours.
await setScale(page, SCALE); await setScale(page, SCALE);
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } }); let flyHome = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight) if (!flyHome.ok()) {
// A long outbound leg can leave the tank too empty for the return — top up and retry.
expect((await page.request.post(`/api/planes/${plane.id}/refuel`)).status()).toBe(200);
flyHome = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
}
expect(flyHome.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB); await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
} }
} }
@@ -96,13 +100,13 @@ test.afterEach(async ({ page }) => {
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
test('last landing pauses the sim without changing speed; dispatch auto-resumes (ISS-041)', async ({ page }) => { test('landing slows the sim to 1× without pausing; dispatch restores the last selected speed (ISS-057)', async ({ page }) => {
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + two short legs test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + two short legs
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
const nearest = await nearestToHub(page); const nearest = await nearestToHub(page);
// Set 1000× via the API and verify it stuck. // Set 1000× via the API and verify it stuck (also records lastSelectedTimeScale=1000).
await setScale(page, SCALE); await setScale(page, SCALE);
// Dispatch plane-starter on the shortest leg from its hub (repositioning — no package needed). // Dispatch plane-starter on the shortest leg from its hub (repositioning — no package needed).
@@ -120,18 +124,20 @@ test('last landing pauses the sim without changing speed; dispatch auto-resumes
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${nearest.icao}` }) }, { timeout: 120_000, message: `waiting for plane-starter to land at ${nearest.icao}` })
.toBe(true); .toBe(true);
// …and the server paused the sim — persisted on sim_state, so this very snapshot shows it… // …and the server slowed the sim to 1× WITHOUT pausing — persisted on sim_state, so this very
expect(landedBody!.simState.paused).toBe(true); // snapshot shows it (the just-landed plane is on ground → slow; ISS-057).
// …with timeScale UNCHANGED (no auto-slow anymore): speed is preserved for the next dispatch. expect(landedBody!.simState.paused).toBe(false);
expect(landedBody!.simState.timeScale).toBe(SCALE); expect(landedBody!.simState.timeScale).toBe(1);
// A subsequent /fly dispatch auto-resumes a paused sim — asserted on the endpoint's OWN full // A subsequent /fly dispatch restores the last selected speed — asserted on the endpoint's OWN
// post-action snapshot, so no poll race with the client's next tick. // full post-action snapshot, so no poll race with the client's next tick. setScale posted 1000
// above, so lastSelectedTimeScale is 1000 and this dispatch (sole grounded plane → airborne)
// resumes it.
const fly2 = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); const fly2 = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(fly2.status()).toBe(200); expect(fly2.status()).toBe(200);
const resumedBody = (await fly2.json()) as StateBody; const resumedBody = (await fly2.json()) as StateBody;
expect(resumedBody.simState.paused).toBe(false); // ISS-041 auto-unpause… expect(resumedBody.simState.paused).toBe(false); // dispatch still clears a manual pause…
expect(resumedBody.simState.timeScale).toBe(SCALE); // …with the preserved speed still in effect expect(resumedBody.simState.timeScale).toBe(SCALE); // …and restores the last selected speed
// Let the return leg finish so cleanup starts from a grounded plane. // Let the return leg finish so cleanup starts from a grounded plane.
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 120_000 }).toBe('available'); await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 120_000 }).toBe('available');
+3
View File
@@ -25,6 +25,9 @@ async function getState(page: Page): Promise<StateBody> {
test('failed actions toast at the map right edge, clear of the right pane (inline error stays), and the Time section offers a notifications opt-in', async ({ page }) => { test('failed actions toast at the map right edge, clear of the right pane (inline error stays), and the Time section offers a notifications opt-in', async ({ page }) => {
await page.goto('/'); await page.goto('/');
// v0.28.0 (ISS-047): the market rows live in the Planes market tab — switch to it first.
await page.getByTestId('tab-planes-market').click();
const buyModel = page.getByTestId('market-buy-model'); const buyModel = page.getByTestId('market-buy-model');
await expect(buyModel, 'the market section renders once the first snapshot lands').toBeVisible({ timeout: 15_000 }); await expect(buyModel, 'the market section renders once the first snapshot lands').toBeVisible({ timeout: 15_000 });
+602
View File
@@ -0,0 +1,602 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
// Real helpers (v0.36.0) — the wind-adjusted fuel math must match lib/ exactly; Playwright resolves
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather';
import { windBurnFactor } from '../lib/sim/movement';
// v0.31.0 — ISS-055: gameplay with TWO planes in the fleet. The reported bug ("can't load cargo
// if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a
// greyed-out Load button until a plane was picked from the dropdown, while the single-plane path
// is one-click. v0.31.0 preselects the first eligible plane (components/RightPane.tsx), and this
// spec covers every two-plane flow: load (preselect + picker), parallel flights with the ISS-057
// slow/resume semantics, refuel, relocate, per-plane queues (scoped testids) and sell.
//
// Each test buys its own second plane via the UI (after a cash grant from the new
// POST /api/test/add-cash hook — ATR €200k vs the €20k starting balance); beforeEach/afterEach
// guarantee the fleet is exactly one plane (the starter, grounded at EDDF) before and after.
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
const STARTER = 'plane-starter'; // the seeded plane's id (lib/game/seed.ts)
const BUY_MODEL = 'atr72'; // cheapest catalog entry — the second plane every test buys
interface PlaneState {
id: string;
model: string;
status: 'available' | 'flying';
fuel: number;
maxFuel: number;
locationIcao: string;
destIcao: string | null;
queue: string[];
}
interface PackageState {
id: string;
originIcao: string;
destIcao: string;
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
planeId: string | null;
}
interface CatalogEntry {
model: string;
label: string;
price: number;
maxFuel: number;
burnPerKm: number;
emptyWeightKg: number;
speedKmh: number;
maxPayloadKg: number;
}
interface StateBody {
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
planes: PlaneState[];
packages: PackageState[];
catalog: CatalogEntry[];
fuelPricePerL: number;
sellFraction: number;
}
// Mirrors formatCash in lib/format.ts (same Intl settings → identical strings).
const eur = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(v);
async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state');
expect(res.status()).toBe(200);
return (await res.json()) as StateBody;
}
async function postSim(page: Page, body: unknown) {
return page.request.post('/api/sim', { data: body });
}
// Set the time scale and VERIFY from the response that it stuck. Also records lastSelectedTimeScale
// (the ISS-057 resume target) and clears pause — so any fast-forward after a slowed-to-1× landing
// proceeds at full speed.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
}
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
}
// Mirrors lib/sim/movement.ts (v0.16.0): fuel required to cover distanceKm arriving empty —
// closed form of the weight-proportional burn model, k calibrated so a full tank burns burnPerKm.
const FUEL_DENSITY_KG_PER_L = 0.8; // must match FUEL_DENSITY_KG_PER_L in lib/sim/movement.ts
function requiredFuelFor(burnPerKm: number, emptyWeightKg: number, maxFuel: number, distanceKm: number): number {
const k = burnPerKm / (emptyWeightKg + FUEL_DENSITY_KG_PER_L * maxFuel);
return (emptyWeightKg / FUEL_DENSITY_KG_PER_L) * (Math.exp(k * FUEL_DENSITY_KG_PER_L * distanceKm) - 1);
}
// Two CLOSEST airports to the hub — short, full-tank-flyable legs on distinct destinations.
async function twoNearestToHub(page: Page): Promise<Array<{ icao: string; lat: number; lon: number }>> {
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
return fc.features
.filter((f) => f.properties.icao !== HUB)
.map((f) => ({ ...f.properties, d: haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) }))
.sort((x, y) => x.d - y.d)
.slice(0, 2)
.map(({ icao, lat, lon }) => ({ icao, lat, lon }));
}
// Farthest airport from EDDF whose leg still fits a FULL ATR tank — the longest flyable leg. The
// dataset's absolute farthest can exceed range (a full-tank fly there would rightly 409). Wind-
// adjusted (v0.36.0): BOTH directions must fit — the outbound is flown from a full tank and the
// return leg is flown after a top-up, which also 409s if it doesn't fit.
async function farthestFlyableToHub(page: Page): Promise<{ icao: string; lat: number; lon: number }> {
const entry = (await getState(page)).catalog.find((e) => e.model === BUY_MODEL)!;
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
const wHub = weatherFor(HUB);
let farthest: { icao: string; lat: number; lon: number; d: number } | null = null;
for (const f of fc.features) {
if (f.properties.icao === HUB) continue;
const d = haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon);
const wDest = weatherFor(f.properties.icao);
const outBurn = entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(hub.lat, hub.lon, f.properties.lat, f.properties.lon));
if (requiredFuelFor(outBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range
const backBurn = entry.burnPerKm * windBurnFactor(wDest.windKt, wDest.windDirDeg, initialBearingDeg(f.properties.lat, f.properties.lon, hub.lat, hub.lon));
if (requiredFuelFor(backBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // return leg wouldn't fit a full tank either
if (!farthest || d > farthest.d) {
farthest = { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon, d };
}
}
if (!farthest) throw new Error('no full-tank-flyable airport found in the dataset');
return { icao: farthest.icao, lat: farthest.lat, lon: farthest.lon };
}
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route with a forced weight.
async function createPackage(
page: Page,
fromIcao: string,
toIcao: string,
deadlineOffsetMs?: number,
weightKg?: number,
): Promise<PackageState> {
const created = await page.request.post('/api/test/create-package', {
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs, weightKg },
});
expect(created.status()).toBe(201);
return (await created.json()) as PackageState;
}
// Test hook (FLIGHT_TEST_HOOKS=1): grant cash so a second plane is affordable. Returns the full
// post-grant snapshot (the route's response).
async function addCash(page: Page, amount?: number): Promise<StateBody> {
const res = await page.request.post('/api/test/add-cash', { data: amount === undefined ? {} : { amount } });
expect(res.status()).toBe(200);
return (await res.json()) as StateBody;
}
// Buy a second ATR through the UI (Planes market tab) and return its id — discovered in /api/state
// as the plane that isn't plane-starter (bought planes get random UUIDs, no stable name).
async function buySecondPlaneViaUi(page: Page): Promise<string> {
await addCash(page); // default €1M grant — covers the ATR's €200k price regardless of prior cash
await page.goto('/');
const right = page.getByTestId('right-pane');
await expect(right).toBeVisible({ timeout: 15_000 });
await page.getByTestId('tab-planes-market').click();
const buyModel = page.getByTestId('market-buy-model');
await expect(buyModel, 'the market model picker appears').toBeVisible({ timeout: 15_000 });
await buyModel.selectOption(BUY_MODEL);
await page.getByTestId('market-buy').click();
let secondId: string | null = null;
await expect
.poll(async () => {
const s = await getState(page);
secondId = s.planes.find((p) => p.id !== STARTER)?.id ?? null;
return secondId !== null;
}, { timeout: 15_000, message: 'waiting for the bought plane to appear in /api/state' })
.toBe(true);
return secondId!;
}
// Cancel every queued leg of a plane via the API (works while flying — the server checks no status).
async function clearQueueViaApi(page: Page, planeId: string): Promise<void> {
for (;;) {
const q = (await getState(page)).planes.find((p) => p.id === planeId)?.queue ?? [];
if (q.length === 0) return;
expect((await page.request.post(`/api/planes/${planeId}/queue/cancel`, { data: { index: 0 } })).status()).toBe(200);
}
}
// Abandon leftover loaded/expired cargo on a GROUNDED plane — the sell guard rejects planes
// carrying cargo, and clean baselines keep exact-cash assertions honest.
async function abandonLeftoverCargo(page: Page, planeId: string): Promise<void> {
const s = await getState(page);
for (const pkg of s.packages) {
if (pkg.planeId === planeId && (pkg.status === 'loaded' || pkg.status === 'expired')) {
expect((await page.request.post(`/api/packages/${pkg.id}/abandon`)).ok()).toBeTruthy();
}
}
}
// Heal one plane to "grounded at the hub": clear its queue first (no auto-chain on landing),
// fast-forward any in-flight leg, then reposition home. Same pattern as flyHomeViaApi in the other
// specs, generalized to an arbitrary plane id.
async function healPlaneHome(page: Page, planeId: string): Promise<void> {
await clearQueueViaApi(page, planeId);
let p = (await getState(page)).planes.find((x) => x.id === planeId);
if (!p) return; // already sold — nothing to heal
// ISS-057: a prior landing may have slowed the sim to 1× — restore 1000× first so any in-flight leg
// completes fast. Re-applied inside the poll below, since a sibling's landing mid-wait slows it again.
await setScale(page, SCALE);
if (p.status === 'flying') {
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
await expect
.poll(async () => {
const s = await getState(page);
const plane = s.planes.find((x) => x.id === planeId);
if (plane?.status === 'flying' && s.simState.timeScale !== SCALE) await setScale(page, SCALE); // sibling landing re-slowed to 1×
return plane?.status ?? '';
}, {
timeout: 240_000,
message: `waiting for an in-flight ${planeId} to land`,
})
.toBe('available');
p = (await getState(page)).planes.find((x) => x.id === planeId)!;
}
if (p.locationIcao !== HUB) {
// ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× for the return leg.
await setScale(page, SCALE);
let flyHome = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
if (flyHome.status() === 409) {
// A long outbound leg can leave the tank too empty for the return — top up and retry.
expect((await page.request.post(`/api/planes/${planeId}/refuel`)).status()).toBe(200);
flyHome = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
}
expect(flyHome.status()).toBe(200);
await expect
.poll(async () => ((await getState(page)).planes.find((x) => x.id === planeId)?.locationIcao ?? ''), {
timeout: 240_000,
message: `waiting for ${planeId} to land back at the hub`,
})
.toBe(HUB);
}
}
// Sell every non-starter plane (healing it home first if an interrupted run left it elsewhere or
// in flight) — leaves the fleet at exactly one plane.
async function sellNonStarters(page: Page): Promise<void> {
const s = await getState(page);
for (const p of s.planes.filter((x) => x.id !== STARTER)) {
await healPlaneHome(page, p.id); // grounded + at the hub before cargo can be abandoned / it sold
await abandonLeftoverCargo(page, p.id);
expect((await page.request.post('/api/market/sell-plane', { data: { planeId: p.id } })).status()).toBe(200);
}
}
// Canonical start state for every test: fleet of exactly one plane (the starter), grounded at EDDF,
// no cargo on board — even after an interrupted previous run.
async function resetFleet(page: Page): Promise<void> {
await sellNonStarters(page);
await healPlaneHome(page, STARTER);
await abandonLeftoverCargo(page, STARTER);
}
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
// Leave the fleet at exactly one plane and restore sane sim controls for the other specs.
test.beforeEach(async ({ page }) => {
await resetFleet(page);
});
test.afterEach(async ({ page }) => {
await sellNonStarters(page); // a failure mid-test may have left the second plane behind (even in flight)
const res = await postSim(page, { timeScale: 1, paused: false });
expect(res.status()).toBe(200);
});
test('two grounded planes: Load is one-click with a preselected plane; the picker routes to the other', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
const secondId = await buySecondPlaneViaUi(page); // both grounded at EDDF — bought planes spawn at HUB
await page.getByTestId('tab-jobs').click(); // purchase left us on Planes-market; package rows live in Jobs (tabs unmount inactive content)
const [nearest] = await twoNearestToHub(page);
// Forced light weight (500 kg) so the package fits BOTH ATRs' payload deterministically.
const pkg1 = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000, 500);
// The multi-plane picker lists BOTH planes…
const picker = page.getByTestId(`load-plane-${pkg1.id}`);
await expect(picker).toBeVisible({ timeout: 15_000 });
await expect(picker.locator(`option[value="${STARTER}"]`)).toHaveCount(1, { timeout: 15_000 });
await expect(picker.locator(`option[value="${secondId}"]`)).toHaveCount(1);
// …and PRESELECTS the first eligible plane — Load is enabled without touching the select (ISS-055).
const s0 = await getState(page);
const firstEligible = s0.planes.find((p) => p.status === 'available' && p.locationIcao === HUB)!;
await expect(picker, 'the picker preselects the first eligible plane').toHaveValue(firstEligible.id);
const loadBtn1 = page.getByTestId(`load-${pkg1.id}`);
await expect(loadBtn1, 'Load is enabled without a manual pick').toBeEnabled();
// One-click Load binds the package to the preselected (first eligible) plane.
await loadBtn1.click();
await expect
.poll(async () => {
const p = (await getState(page)).packages.find((x) => x.id === pkg1.id);
return !!p && p.status === 'loaded' ? p.planeId : null;
}, { timeout: 15_000, message: 'waiting for the package to load onto a plane' })
.toBe(firstEligible.id);
// Second package — pick the OTHER plane explicitly via the dropdown.
const pkg2 = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000, 500);
const picker2 = page.getByTestId(`load-plane-${pkg2.id}`);
await expect(picker2).toBeVisible({ timeout: 15_000 });
await picker2.selectOption(secondId);
await page.getByTestId(`load-${pkg2.id}`).click();
await expect
.poll(async () => {
const p = (await getState(page)).packages.find((x) => x.id === pkg2.id);
return !!p && p.status === 'loaded' ? p.planeId : null;
}, { timeout: 15_000, message: 'waiting for the second package to load onto a plane' })
.toBe(secondId);
// Both planes carry cargo simultaneously — both fleet rows render their cargo (ISS-054).
await expect(page.getByTestId(`plane-${STARTER}`), 'the starter row renders').toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId(`plane-${secondId}`), 'the second plane row renders').toBeVisible({ timeout: 15_000 });
});
test('two planes fly in parallel; the last landing slows to 1× and the next dispatch resumes', async ({ page }) => {
test.setTimeout(300_000); // self-heal + one long leg each way at scale 1000
const secondId = await buySecondPlaneViaUi(page);
// Full tanks for both — the starter's may be partial from an earlier run.
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
expect((await page.request.post(`/api/planes/${secondId}/refuel`)).status()).toBe(200);
// Two very different leg lengths: the starter takes the SHORTEST hop, the second plane the longest
// full-tank-flyable one — their landings are separated by hours of game time and can never fall on
// the same tick (ISS-057 slows to 1× on ANY landing, so a shared-tick arrival would race the asserts).
const [nearest] = await twoNearestToHub(page);
const farthest = await farthestFlyableToHub(page);
const pkgA = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000, 500);
const pkgB = await createPackage(page, HUB, farthest.icao, 24 * 3_600_000, 500);
expect((await page.request.post(`/api/packages/${pkgA.id}/load`, { data: { planeId: STARTER } })).status()).toBe(200);
expect((await page.request.post(`/api/packages/${pkgB.id}/load`, { data: { planeId: secondId } })).status()).toBe(200);
const flyA = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: nearest.icao } });
expect(flyA.status()).toBe(200);
const flyB = await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: farthest.icao } });
expect(flyB.status()).toBe(200);
// Both are flying to their destinations (the second /fly response snapshot is authoritative).
const s1 = (await flyB.json()) as StateBody;
expect(s1.planes.find((p) => p.id === STARTER)?.status).toBe('flying');
expect(s1.planes.find((p) => p.id === STARTER)?.destIcao).toBe(nearest.icao);
expect(s1.planes.find((p) => p.id === secondId)?.status).toBe('flying');
expect(s1.planes.find((p) => p.id === secondId)?.destIcao).toBe(farthest.icao);
// Fast-forward to the FIRST landing (the short hop)…
await setScale(page, SCALE);
await expect
.poll(async () => ((await getState(page)).planes.some((p) => p.status === 'available')), {
timeout: 240_000,
message: 'waiting for the first plane to land',
})
.toBe(true);
// …restore speed (that landing slowed the sim to 1× — ISS-057) so the long leg fast-forwards too.
await setScale(page, SCALE);
await expect
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available')), {
timeout: 240_000,
message: 'waiting for both planes to land',
})
.toBe(true);
// ISS-057: the LAST landing left a grounded plane → the sim slowed to 1× WITHOUT pausing.
const landed = await getState(page);
expect(landed.simState.paused).toBe(false);
expect(landed.simState.timeScale).toBe(1);
// Both packages delivered on arrival (in deadline — 24h window vs hours of flight time).
expect(landed.packages.find((p) => p.id === pkgA.id)?.status).toBe('delivered');
expect(landed.packages.find((p) => p.id === pkgB.id)?.status).toBe('delivered');
// The outbound to the range edge left the tank nearly empty — top up for the return leg.
expect((await page.request.post(`/api/planes/${secondId}/refuel`)).status()).toBe(200);
// Resume semantics: with the starter still grounded, dispatching the second plane does NOT resume…
const flyB2 = await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } });
expect(flyB2.status()).toBe(200);
let body = (await flyB2.json()) as StateBody;
expect(body.simState.timeScale, 'another plane still grounded → stays at 1×').toBe(1);
// …but dispatching the LAST grounded plane restores the last manually selected speed.
const flyA2 = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
expect(flyA2.status()).toBe(200);
body = (await flyA2.json()) as StateBody;
expect(body.simState.timeScale, 'the last grounded plane resumes lastSelectedTimeScale').toBe(SCALE); // setScale posted 1000
});
test('refuel section lists both planes; refueling one tops only its tank', async ({ page }) => {
test.setTimeout(300_000); // self-heal + two short hops at scale 1000
const secondId = await buySecondPlaneViaUi(page);
// Partial tanks: a short hop each, to different destinations.
const [a, b] = await twoNearestToHub(page);
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
expect((await page.request.post(`/api/planes/${secondId}/refuel`)).status()).toBe(200);
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } })).status()).toBe(200);
expect((await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: b.icao } })).status()).toBe(200);
// Fast-forward to the first landing, restore speed (ISS-057 slows on any landing), then wait for both.
await setScale(page, SCALE);
await expect
.poll(async () => ((await getState(page)).planes.some((p) => p.status === 'available')), {
timeout: 240_000,
message: 'waiting for the first plane to land',
})
.toBe(true);
await setScale(page, SCALE);
await expect
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available')), {
timeout: 240_000,
message: 'waiting for both planes to land',
})
.toBe(true);
const pre = await getState(page);
const starter = pre.planes.find((p) => p.id === STARTER)!;
const second = pre.planes.find((p) => p.id === secondId)!;
expect(starter.fuel, 'the hop burned fuel').toBeLessThan(starter.maxFuel);
expect(second.fuel, 'the hop burned fuel').toBeLessThan(second.maxFuel);
// The left-pane Refuel section lists BOTH grounded planes with partial tanks…
const refuelSelect = page.getByTestId('market-refuel-plane');
await expect(refuelSelect).toBeVisible({ timeout: 15_000 });
await expect(refuelSelect.locator(`option[value="${STARTER}"]`)).toHaveCount(1);
await expect(refuelSelect.locator(`option[value="${secondId}"]`)).toHaveCount(1);
// …select the SECOND plane — the preview is its exact fill-up cost (mirrors refuelPlane 'full').
const expectedCost = Math.round((second.maxFuel - second.fuel) * pre.fuelPricePerL * 100) / 100;
await refuelSelect.selectOption(secondId);
await expect(page.getByTestId('market-refuel-cost')).toContainText(eur(expectedCost));
await page.getByTestId('market-refuel').click();
await expect
.poll(async () => {
const s = await getState(page);
return Math.abs(s.account.cash - (pre.account.cash - expectedCost)) < 0.001;
}, { timeout: 15_000, message: 'waiting for the refuel cost to land in /api/state' })
.toBe(true);
const post = await getState(page);
expect(post.planes.find((p) => p.id === secondId)!.fuel, 'that tank is full').toBe(second.maxFuel);
expect(post.planes.find((p) => p.id === STARTER)!.fuel, 'the other tank is untouched').toBe(starter.fuel);
});
test('relocate section moves one plane without touching the other', async ({ page }) => {
test.setTimeout(300_000); // self-heal + one short hop at scale 1000
const secondId = await buySecondPlaneViaUi(page); // both grounded at EDDF
const [a] = await twoNearestToHub(page); // relocation destination for the second plane
// The Relocate section lists BOTH grounded planes…
const relocateSelect = page.getByTestId('relocate-plane');
await expect(relocateSelect).toBeVisible({ timeout: 15_000 });
await expect(relocateSelect.locator(`option[value="${STARTER}"]`)).toHaveCount(1);
await expect(relocateSelect.locator(`option[value="${secondId}"]`)).toHaveCount(1);
// LeftPane's own /api/airports fetch populates the destination select — wait for it.
const destSelect = page.getByTestId('relocate-dest');
await expect(destSelect.locator(`option[value="${a.icao}"]`)).toHaveCount(1, { timeout: 15_000 });
// Relocate ONLY the second plane…
await relocateSelect.selectOption(secondId);
await destSelect.selectOption(a.icao);
await page.getByTestId('relocate-go').click();
// …it is flying to the new airport while the starter stays put at EDDF.
await expect
.poll(async () => {
const s = await getState(page);
return (s.planes.find((p) => p.id === secondId)?.status ?? '') === 'flying' &&
(s.planes.find((p) => p.id === STARTER)?.locationIcao ?? '') === HUB;
}, { timeout: 15_000, message: 'waiting for the relocation to take effect' })
.toBe(true);
// Let it land — locationIcao flips to the destination on arrival.
await setScale(page, SCALE);
await expect
.poll(async () => ((await getState(page)).planes.find((p) => p.id === secondId)?.locationIcao ?? ''), {
timeout: 240_000,
message: `waiting for the relocated plane to land at ${a.icao}`,
})
.toBe(a.icao);
const s = await getState(page);
expect(s.planes.find((p) => p.id === secondId)!.status).toBe('available');
expect(s.planes.find((p) => p.id === STARTER)!.locationIcao, 'the starter is untouched').toBe(HUB);
});
test('per-plane queues are independent; landing auto-chains the next queued leg', async ({ page }) => {
test.setTimeout(300_000); // self-heal + two chained legs at scale 1000
const secondId = await buySecondPlaneViaUi(page);
const [a, b] = await twoNearestToHub(page);
// Full tank for the starter — it flies both legs (EDDF→a then the auto-chained a→b).
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
// Seed one queued leg per plane via the API so BOTH on-board blocks render…
expect((await page.request.post(`/api/planes/${STARTER}/queue`, { data: { destIcao: a.icao } })).status()).toBe(200);
expect((await page.request.post(`/api/planes/${secondId}/queue`, { data: { destIcao: b.icao } })).status()).toBe(200);
// …each block shows its own seeded leg row under plane-scoped testids (ISS-055)…
const starterLeg0 = page.getByTestId(`queue-leg-${STARTER}-0`);
await expect(starterLeg0, 'the starter block renders its queued leg').toContainText(a.icao, { timeout: 15_000 });
await expect(page.getByTestId(`queue-leg-${secondId}-0`), 'the second block renders its queued leg').toContainText(b.icao);
// …and ADD a second leg to the STARTER via the API — the dedicated queue-add UI was removed in
// ISS-053 (fly-to / Relocate cover it), and the starter is grounded here so fly-to would start, not queue.
expect((await page.request.post(`/api/planes/${STARTER}/queue`, { data: { destIcao: b.icao } })).status()).toBe(200);
await expect(page.getByTestId(`queue-leg-${STARTER}-1`), 'the second leg is queued on the STARTER').toContainText(b.icao, { timeout: 15_000 });
// CANCEL the only leg on the SECOND plane — its queue empties, the starter's stays intact.
await page.getByTestId(`queue-cancel-${secondId}-0`).click();
await expect(page.getByTestId(`queue-leg-${secondId}-0`), 'the cancelled leg is gone').toHaveCount(0, { timeout: 15_000 });
await expect(starterLeg0, 'the starter queue is untouched by the other plane cancel').toContainText(a.icao);
// Fly the STARTER to its first queued destination…
const flyRes = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } });
expect(flyRes.status()).toBe(200);
// …fast-forward; on landing at a the server AUTO-CHAINS queue[0] (b) in the same tick — but with
// the second plane still grounded, ISS-057 slows the sim to 1× on that landing. Wait for the chain…
await setScale(page, SCALE);
await expect
.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === STARTER);
return !!p && p.status === 'flying' && p.destIcao === b.icao;
}, { timeout: 240_000, message: `waiting for the auto-chained leg ${a} → ${b} to start` })
.toBe(true);
// …then restore speed so the chained leg fast-forwards too.
await setScale(page, SCALE);
await expect
.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === STARTER);
return !!p && p.status === 'available' && p.locationIcao === b.icao;
}, { timeout: 240_000, message: `waiting for the chained leg to land at ${b}` })
.toBe(true);
// The second plane never moved — still grounded at EDDF with an empty queue.
const s = await getState(page);
expect(s.planes.find((p) => p.id === secondId)!.status).toBe('available');
expect(s.planes.find((p) => p.id === secondId)!.locationIcao).toBe(HUB);
});
test('sell section lists both planes; selling one pays out and the last plane is protected', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
const secondId = await buySecondPlaneViaUi(page); // both grounded at EDDF
const pre = await getState(page); // authoritative catalog + economy constants (v0.10.0)
const entry = pre.catalog.find((e) => e.model === BUY_MODEL)!;
const payout = Math.round(entry.price * pre.sellFraction * 100) / 100;
// The Sell row lists BOTH grounded planes…
const sellSelect = page.getByTestId('market-sell-plane');
await expect(sellSelect).toBeVisible({ timeout: 15_000 });
await expect(sellSelect.locator(`option[value="${STARTER}"]`)).toHaveCount(1);
await expect(sellSelect.locator(`option[value="${secondId}"]`)).toHaveCount(1);
// …sell the SECOND one — payout is SELL_FRACTION × price, fleet back to one plane.
await sellSelect.selectOption(secondId);
await expect(page.getByTestId('market-sell-payout')).toContainText(eur(payout));
await page.getByTestId('market-sell').click();
await expect
.poll(async () => {
const s = await getState(page);
return Math.abs(s.account.cash - (pre.account.cash + payout)) < 0.001 && s.planes.length === 1;
}, { timeout: 15_000, message: 'waiting for the sell to land in /api/state' })
.toBe(true);
// The last remaining plane can never be sold — even via the API.
const res = await page.request.post('/api/market/sell-plane', { data: { planeId: STARTER } });
expect(res.status()).toBe(409);
});
+5 -1
View File
@@ -151,7 +151,8 @@ describe('openGameDb', () => {
raw.exec(LEGACY_DDL); raw.exec(LEGACY_DDL);
raw.exec(`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57)`); raw.exec(`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57)`);
raw.exec(`INSERT INTO account VALUES (20000, 10)`); raw.exec(`INSERT INTO account VALUES (20000, 10)`);
raw.exec('INSERT INTO sim_state VALUES (1750000000000, 1, 0)'); // time_scale = 50 on purpose: the v0.30.0 backfill must copy it into last_selected_time_scale.
raw.exec('INSERT INTO sim_state VALUES (1750000000000, 50, 0)');
const file = path.join(dir, 'legacy.db'); const file = path.join(dir, 'legacy.db');
await fs.writeFile(file, Buffer.from(raw.export())); await fs.writeFile(file, Buffer.from(raw.export()));
@@ -167,6 +168,9 @@ describe('openGameDb', () => {
// …and the ALTER-default 0 was fixed up to a real timestamp so the first // …and the ALTER-default 0 was fixed up to a real timestamp so the first
// tick-on-read resumes cleanly instead of taking one clamped catch-up jump. // tick-on-read resumes cleanly instead of taking one clamped catch-up jump.
expect(sim?.lastRealTickMs).toBeGreaterThan(0); expect(sim?.lastRealTickMs).toBeGreaterThan(0);
// v0.30.0 (ISS-057): the new column backfilled to the legacy row's OWN speed, not the default.
expect(sim?.timeScale).toBe(50);
expect(sim?.lastSelectedTimeScale).toBe(50);
}); });
it('migrates a pre-v0.12.0 database: wipes contracts, grounds planes, preserves account + clock', async () => { it('migrates a pre-v0.12.0 database: wipes contracts, grounds planes, preserves account + clock', async () => {
+13 -1
View File
@@ -41,6 +41,7 @@ CREATE TABLE IF NOT EXISTS packages (
cargo_type TEXT NOT NULL, cargo_type TEXT NOT NULL,
cargo_count INTEGER NOT NULL, cargo_count INTEGER NOT NULL,
weight_kg REAL NOT NULL DEFAULT 2000, weight_kg REAL NOT NULL DEFAULT 2000,
special TEXT,
deadline_sim_time INTEGER NOT NULL, deadline_sim_time INTEGER NOT NULL,
status TEXT NOT NULL, status TEXT NOT NULL,
plane_id TEXT, plane_id TEXT,
@@ -54,7 +55,8 @@ CREATE TABLE IF NOT EXISTS sim_state (
clock_ms INTEGER NOT NULL, clock_ms INTEGER NOT NULL,
time_scale REAL NOT NULL, time_scale REAL NOT NULL,
paused INTEGER NOT NULL, paused INTEGER NOT NULL,
last_real_tick_ms INTEGER NOT NULL DEFAULT 0 last_real_tick_ms INTEGER NOT NULL DEFAULT 0,
last_selected_time_scale REAL NOT NULL DEFAULT 1
); );
`; `;
@@ -82,6 +84,16 @@ function migrateColumns(raw: RawSqlJsDatabase): void {
if (!cols('packages').has('weight_kg')) { if (!cols('packages').has('weight_kg')) {
raw.exec('ALTER TABLE packages ADD COLUMN weight_kg REAL NOT NULL DEFAULT 2000'); raw.exec('ALTER TABLE packages ADD COLUMN weight_kg REAL NOT NULL DEFAULT 2000');
} }
// v0.37.0 (ISS-073): package special status — nullable, no default; pre-existing rows stay NULL = none.
if (!cols('packages').has('special')) {
raw.exec('ALTER TABLE packages ADD COLUMN special TEXT');
}
// v0.30.0 (ISS-057): last-selected speed — pre-existing rows backfill to the row's OWN
// current time_scale so a legacy DB resumes at the speed it was running, not the default.
if (!cols('sim_state').has('last_selected_time_scale')) {
raw.exec('ALTER TABLE sim_state ADD COLUMN last_selected_time_scale REAL NOT NULL DEFAULT 1');
raw.exec('UPDATE sim_state SET last_selected_time_scale = time_scale');
}
} }
// v0.12.0 one-time migration: contracts world → packages world. Detected by the // v0.12.0 one-time migration: contracts world → packages world. Detected by the
+8 -1
View File
@@ -40,10 +40,13 @@ export const packages = sqliteTable('packages', {
// Package weight in kg (v0.27.0, ISS-045) — drives load validation (plane payload) // Package weight in kg (v0.27.0, ISS-045) — drives load validation (plane payload)
// and the per-kg part of the payout. Pre-existing rows backfill to the default. // and the per-kg part of the payout. Pre-existing rows backfill to the default.
weightKg: real('weight_kg').notNull().default(2000), weightKg: real('weight_kg').notNull().default(2000),
// Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; NULL = none.
special: text('special'),
deadlineSimTime: integer('deadline_sim_time').notNull(), deadlineSimTime: integer('deadline_sim_time').notNull(),
// 'expired' = an open OR loaded package whose deadline passed on a tick. Loaded // 'expired' = an open OR loaded package whose deadline passed on a tick. Loaded
// expired packages keep occupying their slot, deliver no reward, and abandon free. // expired packages keep occupying their slot, deliver no reward, and abandon free.
status: text('status', { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] }).notNull(), // 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
status: text('status', { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned', 'ignored'] }).notNull(),
planeId: text('plane_id'), planeId: text('plane_id'),
createdSimTime: integer('created_sim_time').notNull(), createdSimTime: integer('created_sim_time').notNull(),
}); });
@@ -60,6 +63,10 @@ export const simState = sqliteTable('sim_state', {
// Real-time (Date.now()) of the last tick — the tick-on-read engine advances // Real-time (Date.now()) of the last tick — the tick-on-read engine advances
// clockMs by (now - lastRealTickMs) × timeScale, clamped per tick. // clockMs by (now - lastRealTickMs) × timeScale, clamped per tick.
lastRealTickMs: integer('last_real_tick_ms').notNull(), lastRealTickMs: integer('last_real_tick_ms').notNull(),
// Last MANUALLY selected speed (v0.30.0, ISS-057) — landings slow the sim to 1× without
// touching this; flyPlane restores it when the last grounded plane takes off. Written by
// POST /api/sim whenever timeScale is posted; pre-existing rows backfill to time_scale.
lastSelectedTimeScale: real('last_selected_time_scale').notNull().default(1),
}); });
export type PlaneRow = typeof planes.$inferSelect; export type PlaneRow = typeof planes.$inferSelect;
+1
View File
@@ -35,6 +35,7 @@ const basePackage = (id: string): PackageState => ({
cargoType: 'mail', cargoType: 'mail',
cargoCount: 2, cargoCount: 2,
weightKg: 600, weightKg: 600,
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
deadlineSimTime: 0, deadlineSimTime: 0,
status: 'open', status: 'open',
planeId: null, planeId: null,
+10 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { formatCash, formatDeadlineRelative, formatDuration, formatFuel, formatSimClock } from './format'; import { formatCargo, formatCash, formatDeadlineRelative, formatDuration, formatFuel, formatSimClock } from './format';
describe('formatCash', () => { describe('formatCash', () => {
it('formats EUR with thousands separators and two decimals', () => { it('formats EUR with thousands separators and two decimals', () => {
@@ -21,6 +21,15 @@ describe('formatFuel', () => {
}); });
}); });
describe('formatCargo', () => {
it('shows cargo type + tonnage with one decimal, like the loadout display', () => {
expect(formatCargo('Mail', 10_000)).toBe('Mail 10.0t');
// (250 / 1000).toFixed(1) === '0.3' — half rounds up at this value.
expect(formatCargo('Electronics', 250)).toBe('Electronics 0.3t');
expect(formatCargo('Mail', 40)).toBe('Mail 0.0t');
});
});
describe('formatSimClock', () => { describe('formatSimClock', () => {
it('formats epoch ms as a UTC wall-clock string with seconds (deterministic across timezones)', () => { it('formats epoch ms as a UTC wall-clock string with seconds (deterministic across timezones)', () => {
expect(formatSimClock(Date.UTC(2026, 8, 17, 14, 5))).toBe('2026-09-17 14:05:00 UTC'); expect(formatSimClock(Date.UTC(2026, 8, 17, 14, 5))).toBe('2026-09-17 14:05:00 UTC');
+10
View File
@@ -11,6 +11,16 @@ export function formatFuel(fuel: number, maxFuel: number): string {
return `${Math.round(fuel)} / ${maxFuel} L`; return `${Math.round(fuel)} / ${maxFuel} L`;
} }
// Job-list cargo label: "Mail 10.0t" — type + tonnage, one decimal like the loadout display.
export function formatCargo(cargoType: string, weightKg: number): string {
return `${cargoType} ${(weightKg / 1000).toFixed(1)}t`;
}
// Special-status badge label (v0.37.0, ISS-073): "priority" → "Priority".
export function formatSpecialLabel(special: string): string {
return special.charAt(0).toUpperCase() + special.slice(1);
}
// The sim clock is epoch ms (seeded from real time) — shown as a UTC wall-clock with // The sim clock is epoch ms (seeded from real time) — shown as a UTC wall-clock with
// seconds so the display is deterministic regardless of the viewer's timezone. // seconds so the display is deterministic regardless of the viewer's timezone.
export function formatSimClock(clockMs: number): string { export function formatSimClock(clockMs: number): string {
+125 -24
View File
@@ -6,8 +6,9 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { openGameDb, type GameDb } from '@/lib/db/client'; import { openGameDb, type GameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { requiredFuelFor } from '@/lib/sim/movement'; import { weatherFor } from '@/lib/weather';
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog'; import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
import { HUB_ICAO } from './seed'; import { HUB_ICAO } from './seed';
import { import {
@@ -16,6 +17,7 @@ import {
cancelQueuedLeg, cancelQueuedLeg,
flyPlane, flyPlane,
GameActionError, GameActionError,
ignorePackage,
loadPackage, loadPackage,
queueLeg, queueLeg,
refuelPlane, refuelPlane,
@@ -169,16 +171,32 @@ describe('flyPlane', () => {
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
}); });
it('clears paused on dispatch (ISS-041) — speed is left untouched', async () => { it('resumes a manual pause and restores lastSelectedTimeScale when the last grounded plane takes off (ISS-057)', async () => {
const inst = await freshDb('fly-unpause.db'); const inst = await freshDb('fly-resume.db');
// The last landing pauses the sim at whatever scale was in use. // A landing slowed the sim to 1×; the player's last manually selected speed was 250.
await inst.db.update(schema.simState).set({ paused: true, timeScale: 250 }); await inst.db.update(schema.simState).set({ paused: true, timeScale: 1, lastSelectedTimeScale: 250 });
await flyPlane(inst, 'plane-starter', 'LEMD'); // sole grounded plane → airborne
const sim = (await inst.db.select().from(schema.simState))[0];
expect(sim.paused).toBe(false); // dispatch still resumes a manual pause…
expect(sim.timeScale).toBe(250); // …and restores the last selected speed
});
it('keeps 1× when another plane is still grounded after dispatch (ISS-057)', async () => {
const inst = await freshDb('fly-stayslow.db');
await inst.db.update(schema.simState).set({ timeScale: 1, lastSelectedTimeScale: 250 });
// A second grounded plane — the starter taking off leaves it on the ground.
await inst.db.insert(schema.planes).values({
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
});
await flyPlane(inst, 'plane-starter', 'LEMD'); await flyPlane(inst, 'plane-starter', 'LEMD');
const sim = (await inst.db.select().from(schema.simState))[0]; const sim = (await inst.db.select().from(schema.simState))[0];
expect(sim.paused).toBe(false); // dispatch auto-resumes… expect(sim.paused).toBe(false);
expect(sim.timeScale).toBe(250); // …with the stored speed preserved expect(sim.timeScale).toBe(1); // another plane on ground → stays slowed
}); });
it('rejects a flying plane with 409', async () => { it('rejects a flying plane with 409', async () => {
@@ -244,6 +262,37 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
const inst = await freshDb('qleg-unknownicao.db'); const inst = await freshDb('qleg-unknownicao.db');
await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/); await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
}); });
it('rejects a destination already in the queue with 409 (ISS-065)', async () => {
const inst = await freshDb('qleg-dup.db');
await queueLeg(inst, 'plane-starter', 'LEMD');
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 409, /already in the flight queue/);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // rejected leg not appended
});
it('rejects re-queuing the active leg destination of a flying plane with 409 (ISS-065)', async () => {
const inst = await freshDb('qleg-dupflying.db');
await inst.db.update(schema.planes)
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 409, /already flying to LEMD/);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual([]); // rejected leg not appended
});
it('still appends a different known airport with success (ISS-065 sanity)', async () => {
const inst = await freshDb('qleg-dupother.db');
await queueLeg(inst, 'plane-starter', 'LEMD');
await expect(queueLeg(inst, 'plane-starter', 'EHAM')).resolves.toBeUndefined();
const plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']);
});
}); });
describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => { describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
@@ -374,6 +423,36 @@ describe('abandonPackage', () => {
}); });
}); });
describe('ignorePackage', () => {
it('flips an open package to ignored — no penalty, nothing else changes', async () => {
const inst = await freshDb('ig-open.db');
await insertOpenPackage(inst, 'p-ig');
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
await ignorePackage(inst, 'p-ig');
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ig')))[0];
expect(row.status).toBe('ignored');
expect(row.planeId).toBeNull(); // was never bound
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); // no penalty
});
it.each(['loaded', 'delivered', 'expired', 'abandoned', 'ignored'] as const)(
'rejects a %s package with 409 (must be open)',
async (status) => {
const inst = await freshDb(`ig-${status}.db`);
await insertOpenPackage(inst, 'p-x', { status });
await expectActionError(ignorePackage(inst, 'p-x'), 409, new RegExp(status));
},
);
it('rejects an unknown id with 404', async () => {
const inst = await freshDb('ig-unknown.db');
await expectActionError(ignorePackage(inst, 'nope'), 404);
});
});
describe('buyPlane', () => { describe('buyPlane', () => {
it('deducts the full price and delivers a full-tank plane at the home base', async () => { it('deducts the full price and delivers a full-tank plane at the home base', async () => {
const inst = await freshDb('buy-ok.db'); const inst = await freshDb('buy-ok.db');
@@ -440,6 +519,19 @@ describe('sellPlane', () => {
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /flying/); await expectActionError(sellPlane(inst, 'plane-extra'), 409, /flying/);
}); });
it('rejects selling a grounded plane that still carries cargo with 409 (v0.28.0)', async () => {
const inst = await freshDb('sell-cargo.db');
await inst.db.insert(schema.planes).values({
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
});
await insertOpenPackage(inst, 'p-loaded', { status: 'loaded', planeId: 'plane-extra' });
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /carrying cargo/);
const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id);
expect(ids).toContain('plane-extra'); // row untouched — the cargo stays bound to a real plane
});
it('rejects selling the last remaining plane with 409', async () => { it('rejects selling the last remaining plane with 409', async () => {
const inst = await freshDb('sell-last.db'); // only the starter exists const inst = await freshDb('sell-last.db'); // only the starter exists
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /last remaining/); await expectActionError(sellPlane(inst, 'plane-starter'), 409, /last remaining/);
@@ -500,17 +592,29 @@ describe('refuelPlane', () => {
// --- forFlight mode (v0.16.0) --- // --- forFlight mode (v0.16.0) ---
it('forFlight adds exactly the liters needed for the package leg and deducts L × price', async () => { // Wind-adjusted requirement for the hub → LEMD leg (v0.36.0) — computed with the SAME helpers
const inst = await freshDb('refuel-forflight-ok.db'); // refuelPlane uses, so the expectations stay exact without hardcoded literals.
await insertOpenPackage(inst, 'p-ff', { destIcao: 'LEMD' }); // EDDF → LEMD async function adjustedRequiredForHubToLemd() {
await loadPackage(inst, 'p-ff', 'plane-starter');
const fc = await loadAirportsGeoJson(); const fc = await loadAirportsGeoJson();
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties; const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties; const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
const km = haversineKm(from.lat, from.lon, to.lat, to.lon); const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
const type = PLANE_TYPES.atr72; const type = PLANE_TYPES.atr72;
const required = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km); const w = weatherFor(HUB_ICAO);
return requiredFuelFor(
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
type.emptyWeightKg,
type.maxFuel,
km,
);
}
it('forFlight buys 150% of the weather-adjusted leg requirement and deducts L × price', async () => {
const inst = await freshDb('refuel-forflight-ok.db');
await insertOpenPackage(inst, 'p-ff', { destIcao: 'LEMD' }); // EDDF → LEMD
await loadPackage(inst, 'p-ff', 'plane-starter');
const required = await adjustedRequiredForHubToLemd();
// Partial tank below the requirement so L > 0. // Partial tank below the requirement so L > 0.
const startFuel = Math.round(required / 2); const startFuel = Math.round(required / 2);
@@ -519,31 +623,28 @@ describe('refuelPlane', () => {
const { cost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff' }); const { cost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff' });
expect(cost).toBe(Math.round((required - startFuel) * FUEL_PRICE_PER_L * 100) / 100); // ISS-071: target is 150% of the requirement (below tank capacity for this leg).
const targetFuel = Math.min(PLANE_TYPES.atr72.maxFuel, Math.max(startFuel, required * 1.5));
expect(cost).toBe(Math.round((targetFuel - startFuel) * FUEL_PRICE_PER_L * 100) / 100);
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.fuel).toBeCloseTo(required, 6); // topped up to exactly the leg requirement expect(plane.fuel).toBeCloseTo(targetFuel, 6); // topped up to exactly the 150% target
const account = (await inst.db.select().from(schema.account))[0]; const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBeCloseTo(cashBefore - cost, 6); expect(account.cash).toBeCloseTo(cashBefore - cost, 6);
}); });
it('forFlight leaves an exactly-feasible tank — flyPlane to the package dest succeeds right after', async () => { it('forFlight leaves a tank with a 50% margin — flyPlane to the package dest succeeds right after', async () => {
const inst = await freshDb('refuel-ff-flyafter.db'); const inst = await freshDb('refuel-ff-flyafter.db');
await insertOpenPackage(inst, 'p-fa', { destIcao: 'LEMD' }); // EDDF → LEMD await insertOpenPackage(inst, 'p-fa', { destIcao: 'LEMD' }); // EDDF → LEMD
await loadPackage(inst, 'p-fa', 'plane-starter'); await loadPackage(inst, 'p-fa', 'plane-starter');
const fc = await loadAirportsGeoJson(); const required = await adjustedRequiredForHubToLemd();
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
const type = PLANE_TYPES.atr72;
const required = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km);
// Empty-ish tank: well below the leg requirement. // Empty-ish tank: well below the leg requirement.
await inst.db.update(schema.planes).set({ fuel: Math.round(required / 4) }).where(eq(schema.planes.id, 'plane-starter')); await inst.db.update(schema.planes).set({ fuel: Math.round(required / 4) }).where(eq(schema.planes.id, 'plane-starter'));
await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-fa' }); await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-fa' });
// The leg must now be flyable — no epsilon-short 409 (regression guard for the float-exact target). // The leg must now be flyable — the 150% margin covers flyPlane's strict `>` check.
await flyPlane(inst, 'plane-starter', 'LEMD'); await flyPlane(inst, 'plane-starter', 'LEMD');
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying'); expect(plane.status).toBe('flying');
+67 -16
View File
@@ -3,8 +3,9 @@ import { and, eq, inArray } from 'drizzle-orm';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { persist, type GameDb } from '@/lib/db/client'; import { persist, type GameDb } from '@/lib/db/client';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { requiredFuelFor } from '@/lib/sim/movement'; import { weatherFor } from '@/lib/weather';
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
import { runAction } from '@/lib/sim/engine'; import { runAction } from '@/lib/sim/engine';
import { getStateSnapshot, parseIcaoQueue } from './state'; import { getStateSnapshot, parseIcaoQueue } from './state';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog'; import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
@@ -106,8 +107,11 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
const type = PLANE_TYPES[plane.model]; const type = PLANE_TYPES[plane.model];
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
const distanceKm = haversineKm(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon); const distanceKm = haversineKm(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon);
// Weighted burn model (v0.16.0): the leg needs enough fuel to cover it arriving empty. // Weighted burn model (v0.16.0): the leg needs enough fuel to cover it arriving empty; wind-
const requiredL = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm); // adjusted (v0.36.0) by departure weather vs route heading — every call site uses this same factor.
const w = weatherFor(plane.locationIcao);
const burnPerKm = type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon));
const requiredL = requiredFuelFor(burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
if (requiredL > plane.fuel) { if (requiredL > plane.fuel) {
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(requiredL)} L but the plane has ${Math.round(plane.fuel)} L`); throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(requiredL)} L but the plane has ${Math.round(plane.fuel)} L`);
} }
@@ -125,9 +129,14 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
lon: origin.lon, lon: origin.lon,
}) })
.where(eq(schema.planes.id, planeId)); .where(eq(schema.planes.id, planeId));
// ISS-041: starting a flight resumes a paused sim (the last landing pauses it). Runs inside // ISS-057: dispatch still resumes a MANUALLY paused sim (pause is manual-only now — landings
// runAction(), so tick + unpause + dispatch are atomic under the sim lock. // slow to 1× instead of pausing), and restores the last manually selected speed when this
await inst.db.update(schema.simState).set({ paused: false }); // dispatch took the LAST grounded plane airborne. Runs inside runAction(), so tick + controls
// + dispatch are atomic under the sim lock.
const sets: Partial<Pick<schema.SimStateRow, 'paused' | 'timeScale'>> = { paused: false };
const remaining = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'available'));
if (remaining.length === 0) sets.timeScale = sim.lastSelectedTimeScale;
await inst.db.update(schema.simState).set(sets);
await persist(inst); await persist(inst);
} }
@@ -148,7 +157,16 @@ export async function queueLeg(inst: GameDb, planeId: string, destIcao: string):
throw new GameActionError(400, `unknown airport ${destIcao}`); throw new GameActionError(400, `unknown airport ${destIcao}`);
} }
// ISS-065: the same route can't be queued twice — re-queuing the active leg's destination or a
// leg already in the queue would only produce pointless repeated hops on landing.
if (plane.status === 'flying' && plane.destIcao === destIcao) {
throw new GameActionError(409, `already flying to ${destIcao}`);
}
const queue = parseIcaoQueue(plane.queue); const queue = parseIcaoQueue(plane.queue);
if (queue.includes(destIcao)) {
throw new GameActionError(409, `${destIcao} is already in the flight queue`);
}
queue.push(destIcao); queue.push(destIcao);
await inst.db.update(schema.planes).set({ queue: JSON.stringify(queue) }).where(eq(schema.planes.id, planeId)); await inst.db.update(schema.planes).set({ queue: JSON.stringify(queue) }).where(eq(schema.planes.id, planeId));
await persist(inst); await persist(inst);
@@ -207,6 +225,23 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{
return { penalty }; return { penalty };
} }
// --- Ignore (POST /api/packages/:id/ignore) — ISS-047 part B ---
// Dismiss an OPEN job so it leaves the market permanently; no penalty, nothing else
// changes. The top-up logic (spawnOpenPackages counts 'open') refills the market with
// a new random package on the next tick. 404 unknown id; 409 unless open.
export async function ignorePackage(inst: GameDb, packageId: string): Promise<void> {
const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId));
const pkg = pkgRows[0];
if (!pkg) throw new GameActionError(404, `package ${packageId} not found`);
if (pkg.status !== 'open') {
throw new GameActionError(409, `package is ${pkg.status}, only open packages can be ignored`);
}
await inst.db.update(schema.packages).set({ status: 'ignored' }).where(eq(schema.packages.id, packageId));
await persist(inst);
}
// --- Market (POST /api/market/buy-plane {model}) --- // --- Market (POST /api/market/buy-plane {model}) ---
// Buy a catalog model at full price; it arrives 'available' with a full tank at // Buy a catalog model at full price; it arrives 'available' with a full tank at
@@ -255,6 +290,15 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be sold`); throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be sold`);
} }
// v0.28.0: selling a plane that still carries cargo would orphan it — the packages stay
// 'loaded'/'expired' with a dangling planeId and could never deliver (same query pattern as loadPackage).
const carrying = await inst.db.select({ id: schema.packages.id }).from(schema.packages).where(
and(inArray(schema.packages.status, ['loaded', 'expired']), eq(schema.packages.planeId, plane.id)),
);
if (carrying.length > 0) {
throw new GameActionError(409, `cannot sell a plane carrying cargo`);
}
const all = await inst.db.select().from(schema.planes); const all = await inst.db.select().from(schema.planes);
if (all.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane'); if (all.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane');
@@ -272,9 +316,10 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
// --- Refuel (POST /api/planes/:planeId/refuel) --- // --- Refuel (POST /api/planes/:planeId/refuel) ---
// Refuel modes (v0.16.0): 'full' fills the tank to maxFuel; 'forFlight' adds just // Refuel modes (v0.16.0): 'full' fills the tank to maxFuel; 'forFlight' buys 150% of
// enough fuel for one loaded package's destination leg — required liters per the // the fuel required for one loaded package's destination leg (ISS-071) — wind-adjusted
// weighted burn model, independent of how much is already in the tank. // requirement per the weighted burn model, capped at tank capacity, independent of how
// much is already in the tank.
export interface RefuelOptions { export interface RefuelOptions {
mode?: 'full' | 'forFlight'; mode?: 'full' | 'forFlight';
packageId?: string; packageId?: string;
@@ -310,15 +355,21 @@ export async function refuelPlane(inst: GameDb, planeId: string, opts: RefuelOpt
const origin = await airportByIcao(plane.locationIcao); const origin = await airportByIcao(plane.locationIcao);
const dest = await airportByIcao(pkg.destIcao); const dest = await airportByIcao(pkg.destIcao);
const distanceKm = haversineKm(origin.lat, origin.lon, dest.lat, dest.lon); const distanceKm = haversineKm(origin.lat, origin.lon, dest.lat, dest.lon);
const requiredL = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm); // Same wind-adjusted requirement flyPlane checks (v0.36.0) — departure weather vs route heading.
const w = weatherFor(plane.locationIcao);
const requiredL = requiredFuelFor(
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, dest.lat, dest.lon)),
type.emptyWeightKg,
type.maxFuel,
distanceKm,
);
if (requiredL > plane.maxFuel) { if (requiredL > plane.maxFuel) {
throw new GameActionError(409, `the leg to ${pkg.destIcao} needs ${Math.round(requiredL)} L even starting empty — it does not fit a full ${plane.maxFuel} L tank`); throw new GameActionError(409, `the leg to ${pkg.destIcao} needs ${Math.round(requiredL)} L even starting empty — it does not fit a full ${plane.maxFuel} L tank`);
} }
// Land EXACTLY on the leg requirement when topping up: flyPlane re-computes this same value and // ISS-071: buy 150% of the requirement (capped at tank capacity) so burn variance doesn't strand
// compares with strict `>`, so fuel := requiredL guarantees the leg is feasible. The alternative // the plane. The margin makes flyPlane's strict `>` check pass with room to spare; already enough
// (fuel + liters) can round a hair BELOW R in IEEE754 — f + fl(R − f) is not guaranteed ≥ R — and // → free no-op, tank untouched.
// would 409 on an otherwise-feasible leg. Already enough → free no-op, tank untouched. targetFuel = Math.min(plane.maxFuel, Math.max(plane.fuel, requiredL * 1.5));
targetFuel = Math.max(plane.fuel, requiredL);
} else { } else {
// Exactly maxFuel (as before v0.16.0) — clients/tests compare against it with strict equality. // Exactly maxFuel (as before v0.16.0) — clients/tests compare against it with strict equality.
targetFuel = plane.maxFuel; targetFuel = plane.maxFuel;
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { PLANE_TYPES, STARTER_MODEL } from './catalog';
// v0.28.0 (ISS-047) — catalog sanity: the progression ladder must stay well-formed as types are added.
describe('PLANE_TYPES', () => {
it('every entry has positive specs and a unique id', () => {
const ids = Object.keys(PLANE_TYPES);
expect(new Set(ids).size).toBe(ids.length);
for (const t of Object.values(PLANE_TYPES)) {
expect(t.id, `${t.name} id`).toBeTruthy();
expect(t.price, `${t.name} price`).toBeGreaterThan(0);
expect(t.maxFuel, `${t.name} maxFuel`).toBeGreaterThan(0);
expect(t.burnPerKm, `${t.name} burnPerKm`).toBeGreaterThan(0);
expect(t.emptyWeightKg, `${t.name} emptyWeightKg`).toBeGreaterThan(0);
expect(t.speedKmh, `${t.name} speedKmh`).toBeGreaterThan(0);
expect(t.maxPayloadKg, `${t.name} maxPayloadKg`).toBeGreaterThan(0);
}
});
it('atr72 is the starter model', () => {
expect(STARTER_MODEL).toBe('atr72');
expect(PLANE_TYPES[STARTER_MODEL]).toBeDefined();
});
});
+54
View File
@@ -37,6 +37,60 @@ export const PLANE_TYPES: Record<string, PlaneType> = {
// must stay below this so any single package fits alone; typical loads carry ~3–6 packages. // must stay below this so any single package fits alone; typical loads carry ~3–6 packages.
maxPayloadKg: 4_800, maxPayloadKg: 4_800,
}, },
// Progression ladder above the ATR starter (v0.28.0, ISS-047): bigger tanks, faster airframes,
// higher prices — everything downstream is data-driven via PLANE_TYPES[model]. Values are
// balance-tunable; keep maxPayloadKg ≥ every cargo type at max count × 1.3 jitter so any
// single package still fits alone (same rule as the ATR above).
crj900: {
id: 'crj900',
name: 'CRJ-900',
maxFuel: 8_000,
burnPerKm: 2.1,
emptyWeightKg: 17_500,
speedKmh: 830,
price: 450_000,
maxPayloadKg: 6_500,
},
e175: {
id: 'e175',
name: 'Embraer E175',
maxFuel: 9_500,
burnPerKm: 2.0,
emptyWeightKg: 23_000,
speedKmh: 840,
price: 900_000,
maxPayloadKg: 9_000,
},
a320: {
id: 'a320',
name: 'Airbus A320-200',
maxFuel: 12_000,
burnPerKm: 2.2,
emptyWeightKg: 41_000,
speedKmh: 845,
price: 1_800_000,
maxPayloadKg: 18_000,
},
a330: {
id: 'a330',
name: 'Airbus A330-200',
maxFuel: 20_000,
burnPerKm: 2.16,
emptyWeightKg: 95_000,
speedKmh: 910,
price: 4_500_000,
maxPayloadKg: 48_000,
},
b777: {
id: 'b777',
name: 'Boeing 777-300ER',
maxFuel: 30_000,
burnPerKm: 2.5,
emptyWeightKg: 130_000,
speedKmh: 920,
price: 9_000_000,
maxPayloadKg: 80_000,
},
}; };
// The plane the player starts with. // The plane the player starts with.
+69 -22
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { haversineKm } from '@/lib/geo'; import { haversineKm } from '@/lib/geo';
import { PLANE_TYPES, STARTER_MODEL } from './catalog'; import { PLANE_TYPES, STARTER_MODEL } from './catalog';
import { SPECIALS } from './specials';
import { import {
BASE_REWARD, BASE_REWARD,
buildSeed, buildSeed,
@@ -71,14 +72,14 @@ describe('buildSeed', () => {
expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length); expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length);
}); });
it('applies the reward formula: round(500 + 2.5 * km + 0.5 * weightKg)', () => { it('applies the reward formula: round(500 + 2.5 * km + 0.5 * weightKg), × special multiplier', () => {
const seed = seedWith(42); const seed = seedWith(42);
for (const c of seed.packages) { for (const c of seed.packages) {
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!; const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!; const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
expect(c.reward).toBe( // v0.37.0 (ISS-073): special packages pay the registry multiplier on top of the base formula.
Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * c.weightKg), const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
); expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), c.weightKg) * mult));
} }
}); });
@@ -94,19 +95,22 @@ describe('buildSeed', () => {
} }
}); });
it('sets deadlines inside the 6–72h game-time window after now', () => { it('sets deadlines inside the 6–72h game-time window after now (scaled by any special status)', () => {
const seed = seedWith(42); const seed = seedWith(42);
for (const c of seed.packages) { for (const c of seed.packages) {
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1); // v0.37.0 (ISS-073): a special's deadlineScale tightens the drawn window (e.g. priority → 3–36h).
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000); const scale = c.special ? SPECIALS[c.special]?.deadlineScale ?? 1 : 1;
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 * scale - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000 * scale);
} }
}); });
it('seeds the starting account and sim state', () => { it('seeds the starting account and sim state', () => {
const seed = seedWith(42); const seed = seedWith(42);
expect(seed.account).toEqual({ cash: STARTING_CASH, reputation: STARTING_REPUTATION }); expect(seed.account).toEqual({ cash: STARTING_CASH, reputation: STARTING_REPUTATION });
// lastRealTickMs starts at the seed moment so the first tick-on-read advances ~0. // lastRealTickMs starts at the seed moment so the first tick-on-read advances ~0;
expect(seed.simState).toEqual({ clockMs: NOW, timeScale: 1, paused: false, lastRealTickMs: NOW }); // lastSelectedTimeScale mirrors the seeded speed (v0.30.0, ISS-057).
expect(seed.simState).toEqual({ clockMs: NOW, timeScale: 1, paused: false, lastRealTickMs: NOW, lastSelectedTimeScale: 1 });
}); });
it('is fully deterministic for a fixed RNG and inputs', () => { it('is fully deterministic for a fixed RNG and inputs', () => {
@@ -142,13 +146,14 @@ describe('generatePackage (shared seed/spawn builder)', () => {
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!; const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!; const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
expect(c.reward).toBe( // v0.37.0 (ISS-073): special packages pay the registry multiplier on top of the base formula.
Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * c.weightKg), const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
); expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), c.weightKg) * mult));
// Same 6–72h game-time deadline window as the seed. // Same 6–72h game-time deadline window as the seed — scaled by any special status.
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1); const scale = c.special ? SPECIALS[c.special]?.deadlineScale ?? 1 : 1;
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000); expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 * scale - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000 * scale);
}); });
it('is deterministic for a fixed RNG and honors the id/nowMs inputs', () => { it('is deterministic for a fixed RNG and honors the id/nowMs inputs', () => {
@@ -190,9 +195,9 @@ describe('generatePackage (shared seed/spawn builder)', () => {
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!; const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!; const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
expect(c.reward).toBe( // v0.37.0 (ISS-073): the rolled special (if any) multiplies the base reward.
Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * c.weightKg), const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
); expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), c.weightKg) * mult));
}); });
it('destIcao forces the destination; origin stays random but distinct', () => { it('destIcao forces the destination; origin stays random but distinct', () => {
@@ -208,9 +213,9 @@ describe('generatePackage (shared seed/spawn builder)', () => {
const o = AIRPORTS[0]; // EDDF const o = AIRPORTS[0]; // EDDF
const d = AIRPORTS[1]; // EHAM const d = AIRPORTS[1]; // EHAM
expect(c.reward).toBe( // v0.37.0 (ISS-073): the rolled special (if any) multiplies the base reward.
Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * c.weightKg), const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
); expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), c.weightKg) * mult));
}); });
it('is deterministic for a fixed RNG with forced endpoints', () => { it('is deterministic for a fixed RNG with forced endpoints', () => {
@@ -241,7 +246,9 @@ describe('generatePackage (shared seed/spawn builder)', () => {
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!; const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!; const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
expect(c.reward).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * 1_234)); // v0.37.0 (ISS-073): the rolled special (if any) multiplies the base reward.
const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), 1_234) * mult));
// Deterministic for a fixed RNG… // Deterministic for a fixed RNG…
const again = generatePackage({ id: 'c-w', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, weightKg: 1_234 }); const again = generatePackage({ id: 'c-w', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, weightKg: 1_234 });
@@ -251,4 +258,44 @@ describe('generatePackage (shared seed/spawn builder)', () => {
expect(c.deadlineSimTime).not.toBe(unforced.deadlineSimTime); expect(c.deadlineSimTime).not.toBe(unforced.deadlineSimTime);
}); });
}); });
describe('special status (v0.37.0, ISS-073)', () => {
it('a forced special passes through; an explicit null forces none; omitted rolls a registry key or null', () => {
const base = { id: 'c-sp', airports: AIRPORTS, nowMs: NOW };
expect(generatePackage({ ...base, rng: mulberry32(42), special: 'priority' }).special).toBe('priority');
expect(generatePackage({ ...base, rng: mulberry32(42), special: null }).special).toBeNull();
const rolled = generatePackage({ ...base, rng: mulberry32(42) });
expect(rolled.special === null || SPECIALS[rolled.special] !== undefined).toBe(true);
});
it('applies the registry reward multiplier to the base reward', () => {
const c = generatePackage({ id: 'c-sp-r', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', weightKg: 1_000, special: 'priority' });
const km = haversineKm(AIRPORTS[0].lat, AIRPORTS[0].lon, AIRPORTS[1].lat, AIRPORTS[1].lon); // EDDF → EHAM
expect(c.reward).toBe(Math.round(rewardFor(km, 1_000) * SPECIALS.priority.rewardMultiplier));
});
it('scales the drawn deadline window by the registry factor', () => {
const c = generatePackage({ id: 'c-sp-d', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, special: 'priority' });
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 * SPECIALS.priority.deadlineScale - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000 * SPECIALS.priority.deadlineScale);
});
it('a forced deadline offset skips the special scaling', () => {
const c = generatePackage({ id: 'c-sp-f', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, special: 'priority', deadlineOffsetMs: 10_000 });
expect(c.deadlineSimTime).toBe(NOW + 10_000); // forced offset untouched by the scale
});
it('a forced special (including explicit null) skips the appended RNG draw', () => {
const countDraws = (special?: string | null): number => {
let n = 0;
const base = mulberry32(42);
generatePackage({ id: 'c-sp-n', airports: AIRPORTS, rng: () => { n += 1; return base(); }, nowMs: NOW, special });
return n;
};
// origin, dest, cargoType, cargoCount, weight jitter, deadline window — the fixed order minus the appended draw.
expect(countDraws('priority')).toBe(6);
expect(countDraws(null)).toBe(6); // explicit null skips too
expect(countDraws()).toBeGreaterThanOrEqual(7); // pickSpecial draws 1–2 extra
});
});
}); });
+22 -6
View File
@@ -1,5 +1,6 @@
import { haversineKm } from '@/lib/geo'; import { haversineKm } from '@/lib/geo';
import { PLANE_TYPES, STARTER_MODEL } from './catalog'; import { PLANE_TYPES, STARTER_MODEL } from './catalog';
import { pickSpecial, SPECIALS } from './specials';
// First-run seed. Everything here is a pure function of (airports, rng, nowMs) // First-run seed. Everything here is a pure function of (airports, rng, nowMs)
// so it is fully deterministic and unit-testable: the RNG is injected, never // so it is fully deterministic and unit-testable: the RNG is injected, never
@@ -52,6 +53,8 @@ export interface SeedPackage {
cargoCount: number; cargoCount: number;
// Package weight in kg (v0.27.0, ISS-045) — unit weight × count with ±30% jitter. // Package weight in kg (v0.27.0, ISS-045) — unit weight × count with ±30% jitter.
weightKg: number; weightKg: number;
// Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; null = none.
special: string | null;
deadlineSimTime: number; deadlineSimTime: number;
status: 'open'; status: 'open';
planeId: null; planeId: null;
@@ -62,7 +65,7 @@ export interface SeedData {
plane: SeedPlane; plane: SeedPlane;
packages: SeedPackage[]; packages: SeedPackage[];
account: { cash: number; reputation: number }; account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: false; lastRealTickMs: number }; simState: { clockMs: number; timeScale: number; paused: false; lastRealTickMs: number; lastSelectedTimeScale: number };
} }
// --- Tunables (documented so future revenue/cost work can build on them) --- // --- Tunables (documented so future revenue/cost work can build on them) ---
@@ -116,12 +119,14 @@ export function rewardFor(km: number, weightKg: number): number {
// weight = unit weight × count with ±30% jitter (v0.27.0), deadline now + 6–72h // weight = unit weight × count with ±30% jitter (v0.27.0), deadline now + 6–72h
// game time (or now + deadlineOffsetMs when given — used by the e2e test hook to // game time (or now + deadlineOffsetMs when given — used by the e2e test hook to
// force a near-term expiry). The RNG call order per package is fixed: origin, // force a near-term expiry). The RNG call order per package is fixed: origin,
// dest, cargoType, cargoCount, weight jitter, [deadline window]. // dest, cargoType, cargoCount, weight jitter, [deadline window], [special].
// v0.8.0: originIcao/destIcao optionally FORCE an endpoint (spawn coverage rule + // v0.8.0: originIcao/destIcao optionally FORCE an endpoint (spawn coverage rule +
// the e2e create-package hook); a forced endpoint consumes no RNG draws, so // the e2e create-package hook); a forced endpoint consumes no RNG draws, so
// callers that omit both get byte-identical behavior to before. // callers that omit both get byte-identical behavior to before.
// v0.27.0: weightKg optionally FORCES the package weight (e2e hook) — it skips the // v0.27.0: weightKg optionally FORCES the package weight (e2e hook) — it skips the
// jitter draw, same pattern as forced endpoints. // jitter draw, same pattern as forced endpoints.
// v0.37.0 (ISS-073): special optionally FORCES the special status (string or explicit null) —
// it skips the appended pickSpecial draw; otherwise ~SPECIAL_SPAWN_CHANCE of packages roll one.
export function generatePackage(input: { export function generatePackage(input: {
id: string; id: string;
airports: SeedAirport[]; airports: SeedAirport[];
@@ -131,6 +136,7 @@ export function generatePackage(input: {
originIcao?: string; originIcao?: string;
destIcao?: string; destIcao?: string;
weightKg?: number; weightKg?: number;
special?: string | null;
}): SeedPackage { }): SeedPackage {
const { airports, rng, nowMs } = input; const { airports, rng, nowMs } = input;
if (airports.length < 2) throw new Error('package generation needs at least two airports'); if (airports.length < 2) throw new Error('package generation needs at least two airports');
@@ -165,17 +171,25 @@ export function generatePackage(input: {
// Weight (v0.27.0): unit weight × count with ±30% jitter, integer kg — drawn AFTER // Weight (v0.27.0): unit weight × count with ±30% jitter, integer kg — drawn AFTER
// cargoCount so the fixed RNG order above holds; a forced weightKg skips the draw. // cargoCount so the fixed RNG order above holds; a forced weightKg skips the draw.
const weightKg = input.weightKg ?? Math.round(UNIT_WEIGHT_KG[cargoType] * cargoCount * (0.7 + 0.6 * rng())); const weightKg = input.weightKg ?? Math.round(UNIT_WEIGHT_KG[cargoType] * cargoCount * (0.7 + 0.6 * rng()));
const deadlineMs = // Drawn deadline window — the existing draw, position unchanged; special scaling is applied
input.deadlineOffsetMs ?? (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000; // below AFTER the appended special draw so the fixed order above holds.
const drawnWindowMs = (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000;
// v0.37.0 (ISS-073): special status — the appended draw at the END of the fixed order above; a
// forced value (including explicit null) skips it, same pattern as the other overrides.
const special = input.special !== undefined ? input.special : pickSpecial(rng);
const mods = special !== null ? SPECIALS[special] : undefined;
// Forced deadlines skip the special scaling — consistent with overrides skipping draws.
const deadlineMs = input.deadlineOffsetMs ?? drawnWindowMs * (mods?.deadlineScale ?? 1);
return { return {
id: input.id, id: input.id,
originIcao: origin.icao, originIcao: origin.icao,
destIcao: dest.icao, destIcao: dest.icao,
reward: rewardFor(km, weightKg), reward: Math.round(rewardFor(km, weightKg) * (mods?.rewardMultiplier ?? 1)),
cargoType, cargoType,
cargoCount, cargoCount,
weightKg, weightKg,
special,
deadlineSimTime: Math.round(nowMs + deadlineMs), deadlineSimTime: Math.round(nowMs + deadlineMs),
status: 'open', status: 'open',
planeId: null, planeId: null,
@@ -225,6 +239,8 @@ export function buildSeed(input: {
account: { cash: STARTING_CASH, reputation: STARTING_REPUTATION }, account: { cash: STARTING_CASH, reputation: STARTING_REPUTATION },
// lastRealTickMs starts at the seed moment so the first tick-on-read advances // lastRealTickMs starts at the seed moment so the first tick-on-read advances
// by ~0 instead of treating "time since install" as elapsed play time. // by ~0 instead of treating "time since install" as elapsed play time.
simState: { clockMs: nowMs, timeScale: 1, paused: false, lastRealTickMs: nowMs }, // lastSelectedTimeScale (v0.30.0, ISS-057) mirrors the seeded speed — nothing has been
// selected manually yet, so resume would restore 1× anyway.
simState: { clockMs: nowMs, timeScale: 1, paused: false, lastRealTickMs: nowMs, lastSelectedTimeScale: 1 },
}; };
} }
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { mulberry32, type Rng } from './seed';
import { pickSpecial, SPECIALS, SPECIAL_SPAWN_CHANCE } from './specials';
describe('pickSpecial (v0.37.0, ISS-073)', () => {
it('returns null when the chance draw fails and consumes exactly one draw', () => {
let draws = 0;
const rng: Rng = () => { draws += 1; return 0.9; }; // ≥ SPECIAL_SPAWN_CHANCE → no special
expect(pickSpecial(rng)).toBeNull();
expect(draws).toBe(1);
});
it('picks among the registry keys when the chance draw passes', () => {
const seen = new Set<string>();
for (let i = 0; i < 2_000; i++) {
let call = 0;
// First call is the chance draw (< chance → roll); later calls sweep [0, 1) as index draws.
const rng: Rng = () => (call++ === 0 ? 0.1 : i / 2_000);
const s = pickSpecial(rng);
if (s !== null) seen.add(s);
}
expect([...seen].sort()).toEqual(Object.keys(SPECIALS).sort()); // every key reachable
});
it('never returns a status whose entry was removed; an empty registry consumes no draws', () => {
const withoutPriority = Object.fromEntries(Object.entries(SPECIALS).filter(([k]) => k !== 'priority'));
for (let i = 0; i < 500; i++) {
expect(pickSpecial(mulberry32(i), withoutPriority)).not.toBe('priority'); // removing an entry disables it
}
let draws = 0;
const rng: Rng = () => { draws += 1; return 0.0; }; // would pass the chance draw if consulted
expect(pickSpecial(rng, {})).toBeNull();
expect(draws).toBe(0);
});
it('rolls a special for roughly SPECIAL_SPAWN_CHANCE of packages (fixed RNG)', () => {
const rng = mulberry32(42);
let hits = 0;
for (let i = 0; i < 10_000; i++) if (pickSpecial(rng) !== null) hits++;
expect(hits / 10_000).toBeGreaterThan(SPECIAL_SPAWN_CHANCE - 0.05);
expect(hits / 10_000).toBeLessThan(SPECIAL_SPAWN_CHANCE + 0.05);
});
});
+34
View File
@@ -0,0 +1,34 @@
// Special-status registry (v0.37.0, ISS-073) — data-driven package modifiers: one entry per
// special status maps to reward/deadline/reputation tweaks applied at generation and delivery.
// Adding or removing a special is an edit of ONE entry here — no scattered branches elsewhere.
import type { Rng } from './seed';
export interface SpecialModifiers {
// Multiplier on the base reward (applied at package generation).
rewardMultiplier: number;
// Scale factor on the drawn deadline window (applied at generation; forced offsets skip it).
deadlineScale: number;
// Flat reputation bonus added to the distance-based gain at delivery.
repBonus: number;
}
export const SPECIALS: Record<string, SpecialModifiers> = {
priority: {
rewardMultiplier: 1.5, // ponytail: balance-tunable — a 50% premium for the tighter deadline below
deadlineScale: 0.5, // ponytail: balance-tunable — half the normal 6–72h window (3–36h)
repBonus: 0.5, // ponytail: balance-tunable — flat rep on top of the distance-based gain at delivery
},
};
// Fraction of generated packages that roll a special status (v0.37.0).
export const SPECIAL_SPAWN_CHANCE = 0.15;
// Pure spawn helper: one chance draw, then a uniform pick among the registry's keys. The registry
// is a PARAM defaulting to the real one so tests can pass a filtered/empty record — removing an
// entry cleanly disables that status (an empty registry consumes no draws at all).
export function pickSpecial(rng: Rng, registry: Record<string, SpecialModifiers> = SPECIALS): string | null {
const keys = Object.keys(registry);
if (keys.length === 0) return null;
if (rng() >= SPECIAL_SPAWN_CHANCE) return null;
return keys[Math.floor(rng() * keys.length)];
}
+19 -4
View File
@@ -1,9 +1,10 @@
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { getGameDb } from '@/lib/db/client'; import { getGameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog'; import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
import { fuelAtWeighted, progressAt, positionAt } from '@/lib/sim/movement'; import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/sim/movement';
// Read-only game-state snapshot for client polling (GET /api/state). // Read-only game-state snapshot for client polling (GET /api/state).
@@ -52,8 +53,11 @@ export interface PackageState {
cargoCount: number; cargoCount: number;
// Package weight in kg (v0.27.0, ISS-045) — drives load validation + per-kg payout. // Package weight in kg (v0.27.0, ISS-045) — drives load validation + per-kg payout.
weightKg: number; weightKg: number;
// Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; null = none.
special: string | null;
deadlineSimTime: number; deadlineSimTime: number;
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned'; // 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
planeId: string | null; planeId: string | null;
createdSimTime: number; createdSimTime: number;
} }
@@ -101,7 +105,17 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon); const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, simRow.clockMs); const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, simRow.clockMs);
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress); const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress);
return { ...pos, fuel: fuelAtWeighted(p.fuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm, progress) }; // Wind-adjusted burn (v0.36.0) — weather is a pure function of the departure ICAO, so this
// recomputes exactly the factor the engine used for the leg; no stored state needed.
const w = weatherFor(p.locationIcao);
return { ...pos, fuel: fuelAtWeighted(
p.fuel,
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
type.emptyWeightKg,
type.maxFuel,
distanceKm,
progress,
) };
}; };
return { return {
@@ -138,6 +152,7 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
cargoType: p.cargoType, cargoType: p.cargoType,
cargoCount: p.cargoCount, cargoCount: p.cargoCount,
weightKg: p.weightKg, weightKg: p.weightKg,
special: p.special,
deadlineSimTime: p.deadlineSimTime, deadlineSimTime: p.deadlineSimTime,
status: p.status, status: p.status,
planeId: p.planeId, planeId: p.planeId,
+38 -1
View File
@@ -1,5 +1,42 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { haversineKm } from './geo'; import { haversineKm, initialBearingDeg } from './geo';
describe('initialBearingDeg', () => {
it('is due north (0) for a point directly above', () => {
expect(initialBearingDeg(0, 0, 10, 0)).toBe(0);
});
it('is due east (90) along the equator', () => {
expect(initialBearingDeg(0, -10, 0, 10)).toBeCloseTo(90, 9);
});
it('is due south (180) for a point directly below', () => {
expect(initialBearingDeg(10, 0, 0, 0)).toBeCloseTo(180, 9);
});
it('is due west (270) along the equator — normalized into [0, 360)', () => {
expect(initialBearingDeg(0, 10, 0, -10)).toBeCloseTo(270, 9);
});
it('always normalizes to [0, 360) for arbitrary pairs', () => {
const pairs: Array<[number, number, number, number]> = [
[50.03, 8.57, 40.47, -3.56], // FRA → MAD (southwest-ish)
[40.47, -3.56, 50.03, 8.57], // reverse: northeast-ish
[65.66, -18.07, 50.03, 8.57], // far north → south-east wrap case
];
for (const [a1, a2, b1, b2] of pairs) {
const brg = initialBearingDeg(a1, a2, b1, b2);
expect(brg).toBeGreaterThanOrEqual(0);
expect(brg).toBeLessThan(360);
}
});
it('FRA → MAD points roughly south-southwest (between 180 and 270)', () => {
const brg = initialBearingDeg(50.03, 8.57, 40.47, -3.56);
expect(brg).toBeGreaterThan(180);
expect(brg).toBeLessThan(270);
});
});
describe('haversineKm', () => { describe('haversineKm', () => {
it('returns 0 for identical points', () => { it('returns 0 for identical points', () => {
+13
View File
@@ -1,5 +1,18 @@
const EARTH_RADIUS_KM = 6371; const EARTH_RADIUS_KM = 6371;
// Initial great-circle bearing from point 1 to point 2 (v0.36.0), degrees true [0, 360).
export function initialBearingDeg(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const phi1 = toRad(lat1);
const phi2 = toRad(lat2);
const dLon = toRad(lon2 - lon1);
const theta = Math.atan2(
Math.sin(dLon) * Math.cos(phi2),
Math.cos(phi1) * Math.sin(phi2) - Math.sin(phi1) * Math.cos(phi2) * Math.cos(dLon),
);
return (theta * 180 / Math.PI + 360) % 360;
}
// Great-circle distance between two WGS84 points (haversine), in km. // Great-circle distance between two WGS84 points (haversine), in km.
export function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number { export function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180; const toRad = (d: number) => (d * Math.PI) / 180;
+5 -3
View File
@@ -3,12 +3,13 @@ import { describe, expect, it } from 'vitest';
import { openApiSpec } from './openapi'; import { openApiSpec } from './openapi';
describe('OpenAPI spec', () => { describe('OpenAPI spec', () => {
it('documents exactly the twelve public endpoints (4 read + 8 player actions)', () => { it('documents exactly the thirteen public endpoints (4 read + 9 player actions)', () => {
expect(Object.keys(openApiSpec.paths).sort()).toEqual([ expect(Object.keys(openApiSpec.paths).sort()).toEqual([
'/api/airports', '/api/airports',
'/api/market/buy-plane', '/api/market/buy-plane',
'/api/market/sell-plane', '/api/market/sell-plane',
'/api/packages/{id}/abandon', '/api/packages/{id}/abandon',
'/api/packages/{id}/ignore', // v0.29.0 (ISS-047 part B)
'/api/packages/{id}/load', '/api/packages/{id}/load',
'/api/planes/{planeId}/fly', '/api/planes/{planeId}/fly',
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043) '/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
@@ -138,6 +139,7 @@ describe('OpenAPI spec', () => {
'originName', 'originName',
'planeId', 'planeId',
'reward', 'reward',
'special', // v0.37.0 (ISS-073) — special status from the specials registry
'status', 'status',
'weightKg', // v0.27.0 (ISS-045) — package weight in kg 'weightKg', // v0.27.0 (ISS-045) — package weight in kg
]); ]);
@@ -146,7 +148,7 @@ describe('OpenAPI spec', () => {
expect(Object.keys(sim).sort()).toEqual(['clockMs', 'paused', 'timeScale']); expect(Object.keys(sim).sort()).toEqual(['clockMs', 'paused', 'timeScale']);
const packageStatus = openApiSpec.components.schemas.PackageState.properties.status; const packageStatus = openApiSpec.components.schemas.PackageState.properties.status;
expect(packageStatus.enum.sort()).toEqual(['abandoned', 'delivered', 'expired', 'loaded', 'open']); expect(packageStatus.enum.sort()).toEqual(['abandoned', 'delivered', 'expired', 'ignored', 'loaded', 'open']); // v0.29.0 (ISS-047 part B)
}); });
it('describes POST /api/sim as SimControlsRequest → SimState with a 400 error shape', () => { it('describes POST /api/sim as SimControlsRequest → SimState with a 400 error shape', () => {
@@ -201,7 +203,7 @@ describe('OpenAPI spec', () => {
expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '404', '409']); expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '404', '409']);
expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['404', '409']); expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['404', '409']);
expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '404', '409']); expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '404', '409']);
expect(errorCodes('/api/planes/{planeId}/queue')).toEqual(['400', '404']); // v0.25.0 — no 409: queueing works grounded or flying expect(errorCodes('/api/planes/{planeId}/queue')).toEqual(['400', '404', '409']); // v0.34.0 (ISS-065) — 409 on duplicate leg / active-leg re-queue
expect(errorCodes('/api/planes/{planeId}/queue/cancel')).toEqual(['400', '404']); // v0.25.0 expect(errorCodes('/api/planes/{planeId}/queue/cancel')).toEqual(['400', '404']); // v0.25.0
expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']); expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']);
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']); expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']);
+36 -4
View File
@@ -165,6 +165,31 @@ export const openApiSpec = {
}, },
}, },
}, },
'/api/packages/{id}/ignore': {
post: {
summary: 'Ignore an open job',
description:
'Dismiss an OPEN job so it leaves the market permanently (v0.29.0, ISS-047 part B). No penalty and nothing else changes — the top-up logic spawns a replacement on the next tick. No body.',
operationId: 'ignorePackage',
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: {
description: 'Ignored — full state snapshot (package "ignored", gone from the open list)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
},
404: {
description: 'Unknown package id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
409: {
description: 'Package not "open"',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
},
},
},
'/api/planes/{planeId}/fly': { '/api/planes/{planeId}/fly': {
post: { post: {
summary: 'Fly a plane to an airport', summary: 'Fly a plane to an airport',
@@ -235,6 +260,11 @@ export const openApiSpec = {
description: 'Unknown plane id', description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
}, },
409: {
description:
'The destination is already in the flight queue, or (while flying) it equals the active leg\'s destination (v0.34.0, ISS-065)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
}, },
}, },
}, },
@@ -340,7 +370,7 @@ export const openApiSpec = {
post: { post: {
summary: 'Refuel a grounded plane', summary: 'Refuel a grounded plane',
description: description:
'Refuel a grounded plane at the fuel price per added liter, deducted from cash (v0.8.0). The body is optional (v0.16.0): no body or mode "full" fills to maxFuel; mode "forFlight" with packageId adds just enough for that loaded package\'s destination leg — liters = required fuel for the great-circle distance (weight-proportional burn model) minus what is already in the tank.', 'Refuel a grounded plane at the fuel price per added liter, deducted from cash (v0.8.0). The body is optional (v0.16.0): no body or mode "full" fills to maxFuel; mode "forFlight" with packageId buys 150% of the weather-adjusted fuel required for that loaded package\'s destination leg, capped at tank capacity — liters = min(maxFuel, max(current fuel, 1.5 × required)) − current fuel, where required is the weight-proportional burn model over the great-circle distance with a ±20% wind adjustment (v0.36.0).',
operationId: 'refuelPlane', operationId: 'refuelPlane',
parameters: [ parameters: [
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } }, { name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
@@ -458,7 +488,7 @@ export const openApiSpec = {
label: { type: 'string', description: 'Display name (e.g. "ATR 72-600").' }, label: { type: 'string', description: 'Display name (e.g. "ATR 72-600").' },
price: { type: 'number', description: 'Market price in € (buy cost; sell pays out sellFraction × this).' }, price: { type: 'number', description: 'Market price in € (buy cost; sell pays out sellFraction × this).' },
maxFuel: { type: 'number', description: 'Fuel capacity, liters.' }, maxFuel: { type: 'number', description: 'Fuel capacity, liters.' },
burnPerKm: { type: 'number', description: 'Cruise fuel burn at a full tank, liters/km (v0.16.0 weighted model calibrates to this).' }, burnPerKm: { type: 'number', description: 'Cruise fuel burn at a full tank, liters/km (v0.16.0 weighted model calibrates to this). Actual per-leg burn varies ±20% with wind — departure weather vs route heading (v0.36.0).' },
emptyWeightKg: { type: 'number', description: 'Operating empty weight, kg — with burnPerKm and maxFuel it drives the v0.16.0 weight-proportional burn model.' }, emptyWeightKg: { type: 'number', description: 'Operating empty weight, kg — with burnPerKm and maxFuel it drives the v0.16.0 weight-proportional burn model.' },
speedKmh: { type: 'number', description: 'Cruise speed, km/h.' }, speedKmh: { type: 'number', description: 'Cruise speed, km/h.' },
maxPayloadKg: { type: 'number', description: 'Max payload in kg — total package weight that can be loaded on one plane (v0.27.0, replaces the v0.12.0 slot count).' }, maxPayloadKg: { type: 'number', description: 'Max payload in kg — total package weight that can be loaded on one plane (v0.27.0, replaces the v0.12.0 slot count).' },
@@ -545,7 +575,7 @@ export const openApiSpec = {
mode: { mode: {
enum: ['full', 'forFlight'], enum: ['full', 'forFlight'],
description: description:
'"full" (default) fills the tank to maxFuel; "forFlight" adds just enough fuel for one loaded package\'s destination leg.', '"full" (default) fills the tank to maxFuel; "forFlight" buys 150% of the weather-adjusted fuel required for one loaded package\'s destination leg, capped at tank capacity (v0.36.0).',
}, },
packageId: { packageId: {
type: 'string', type: 'string',
@@ -619,10 +649,12 @@ export const openApiSpec = {
cargoType: { type: 'string' }, cargoType: { type: 'string' },
cargoCount: { type: 'integer' }, cargoCount: { type: 'integer' },
weightKg: { type: 'number', description: 'Package weight in kg (v0.27.0) — drives load validation and the per-kg payout part.' }, weightKg: { type: 'number', description: 'Package weight in kg (v0.27.0) — drives load validation and the per-kg payout part.' },
special: { type: ['string', 'null'], description: 'Special status from the specials registry (v0.37.0); null = none.' },
deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' }, deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
// 'expired' = an open OR loaded package whose deadline passed on a tick; // 'expired' = an open OR loaded package whose deadline passed on a tick;
// loaded expired packages keep their slot, deliver no reward, abandon free. // loaded expired packages keep their slot, deliver no reward, abandon free.
status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] }, // 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned', 'ignored'] },
planeId: { type: ['string', 'null'] }, planeId: { type: ['string', 'null'] },
createdSimTime: { type: 'integer' }, createdSimTime: { type: 'integer' },
}, },
+77 -40
View File
@@ -6,10 +6,12 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { openGameDb } from '@/lib/db/client'; import { openGameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { PLANE_TYPES } from '@/lib/game/catalog'; import { PLANE_TYPES } from '@/lib/game/catalog';
import { SPECIALS } from '@/lib/game/specials';
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed'; import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor } from './movement'; import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
import { import {
clampTimeScale, clampTimeScale,
deliverPackages, deliverPackages,
@@ -105,6 +107,7 @@ describe('expirePackages', () => {
{ id: 'p-loaded-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 }, { id: 'p-loaded-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
{ id: 'p-delivered-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'delivered', planeId: null, createdSimTime: T0 }, { id: 'p-delivered-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'delivered', planeId: null, createdSimTime: T0 },
{ id: 'p-abandoned-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'abandoned', planeId: null, createdSimTime: T0 }, { id: 'p-abandoned-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'abandoned', planeId: null, createdSimTime: T0 },
{ id: 'p-ignored-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'ignored', planeId: null, createdSimTime: T0 },
]); ]);
const n = await expirePackages(inst, clock); const n = await expirePackages(inst, clock);
@@ -116,6 +119,7 @@ describe('expirePackages', () => {
expect(byId.get('p-loaded-past')).toBe('expired'); // v0.12.0: loaded packages expire too (keep their slot) expect(byId.get('p-loaded-past')).toBe('expired'); // v0.12.0: loaded packages expire too (keep their slot)
expect(byId.get('p-delivered-past')).toBe('delivered'); // terminal — never flips expect(byId.get('p-delivered-past')).toBe('delivered'); // terminal — never flips
expect(byId.get('p-abandoned-past')).toBe('abandoned'); // terminal — never flips expect(byId.get('p-abandoned-past')).toBe('abandoned'); // terminal — never flips
expect(byId.get('p-ignored-past')).toBe('ignored'); // v0.29.0 (ISS-047 part B) — terminal, never flips
}); });
it('a deadline exactly at the clock counts as expired', async () => { it('a deadline exactly at the clock counts as expired', async () => {
@@ -163,7 +167,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
}); });
it('delivers in-deadline cargo, unbinds expired cargo for free, keeps other-dest cargo bound', async () => { it('delivers in-deadline cargo, unbinds expired cargo for free, keeps other-dest cargo bound', async () => {
const { inst, to, km, type } = await setupFlight('deliver-mixed.db'); const { inst, from, to, km, type } = await setupFlight('deliver-mixed.db');
const accountBefore = (await inst.db.select().from(schema.account))[0]!; const accountBefore = (await inst.db.select().from(schema.account))[0]!;
// Three packages on the plane: one in-deadline for TO, one expired for TO, // Three packages on the plane: one in-deadline for TO, one expired for TO,
@@ -182,8 +186,11 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
expect(plane.locationIcao).toBe(TO); expect(plane.locationIcao).toBe(TO);
expect(plane.lat).toBe(to.lat); expect(plane.lat).toBe(to.lat);
expect(plane.lon).toBe(to.lon); expect(plane.lon).toBe(to.lon);
// Remaining fuel per the v0.16.0 weight-proportional burn model (closed form). // Remaining fuel per the v0.16.0 weight-proportional burn model (closed form), wind-adjusted
expect(plane.fuel).toBeCloseTo(fuelAtWeighted(type.maxFuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, km, 1), 6); // (v0.36.0) by the departure airport's weather vs the route heading.
const w = weatherFor(FROM);
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
expect(plane.fuel).toBeCloseTo(fuelAtWeighted(type.maxFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1), 6);
expect(plane.departureSimTime).toBeNull(); expect(plane.departureSimTime).toBeNull();
expect(plane.destIcao).toBeNull(); // flight context cleared on landing expect(plane.destIcao).toBeNull(); // flight context cleared on landing
@@ -228,6 +235,26 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
); // rep per delivered package ); // rep per delivered package
}); });
it('adds the registry rep bonus for a special package at delivery (v0.37.0)', async () => {
const { inst, km } = await setupFlight('deliver-special.db');
const accountBefore = (await inst.db.select().from(schema.account))[0]!;
await inst.db.insert(schema.packages).values([
{ id: 'p-prio', originIcao: FROM, destIcao: TO, reward: 5_000, cargoType: 'Mail', cargoCount: 10, special: 'priority', deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
]);
const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1);
const account = (await inst.db.select().from(schema.account))[0]!;
expect(account.cash).toBeCloseTo(accountBefore.cash + 5_000, 9);
// Distance-based gain + the registry rep bonus — double-rounded like every other award.
expect(account.reputation).toBeCloseTo(
Math.round((accountBefore.reputation + reputationGainFor(km) + SPECIALS.priority.repBonus) * 100) / 100,
9,
);
});
it('leaves planes without route info alone', async () => { it('leaves planes without route info alone', async () => {
const inst = await freshDb('deliver-noroute.db'); const inst = await freshDb('deliver-noroute.db');
// Flying plane whose dest_icao is missing — defensive skip, no crash. // Flying plane whose dest_icao is missing — defensive skip, no crash.
@@ -239,14 +266,19 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
// --- Auto-chain on landing (v0.25.0, ISS-043) --- // --- Auto-chain on landing (v0.25.0, ISS-043) ---
it('auto-chains to the next queued leg when fuel covers it', async () => { it('auto-chains to the next queued leg when fuel covers it', async () => {
const { inst, to, km, type } = await setupFlight('chain-ok.db'); // full tank EDDF→LEMD const { inst, from, to, km, type } = await setupFlight('chain-ok.db'); // full tank EDDF→LEMD
const fc = await loadAirportsGeoJson(); const fc = await loadAirportsGeoJson();
const nextP = fc.features.find((f) => f.properties.icao === 'EHAM')!.properties; const nextP = fc.features.find((f) => f.properties.icao === 'EHAM')!.properties;
const nextKm = haversineKm(to.lat, to.lon, nextP.lat, nextP.lon); const nextKm = haversineKm(to.lat, to.lon, nextP.lat, nextP.lon);
// Precondition sanity: a full-tank departure leaves enough for the LEMD→EHAM hop. // Precondition sanity: a full-tank departure leaves enough for the LEMD→EHAM hop — both legs
const landedFuel = fuelAtWeighted(type.maxFuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, km, 1); // wind-adjusted (v0.36.0) exactly as deliverPackages computes them.
expect(requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, nextKm)).toBeLessThanOrEqual(landedFuel); const wFrom = weatherFor(FROM);
const fOut = windBurnFactor(wFrom.windKt, wFrom.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
const landedFuel = fuelAtWeighted(type.maxFuel, type.burnPerKm * fOut, type.emptyWeightKg, type.maxFuel, km, 1);
const wTo = weatherFor(TO); // the chained leg departs from the arrival airport
const fNext = windBurnFactor(wTo.windKt, wTo.windDirDeg, initialBearingDeg(to.lat, to.lon, nextP.lat, nextP.lon));
expect(requiredFuelFor(type.burnPerKm * fNext, type.emptyWeightKg, type.maxFuel, nextKm)).toBeLessThanOrEqual(landedFuel);
await inst.db.update(schema.planes) await inst.db.update(schema.planes)
.set({ queue: JSON.stringify(['EHAM', 'EDDV']) }) .set({ queue: JSON.stringify(['EHAM', 'EDDV']) })
@@ -266,14 +298,14 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
}); });
it('grounds when fuel cannot cover the next queued leg, keeping the queue intact', async () => { it('grounds when fuel cannot cover the next queued leg, keeping the queue intact', async () => {
const { inst, km, type } = await setupFlight('chain-nofuel.db'); const { inst, from, to, km, type } = await setupFlight('chain-nofuel.db');
const fc = await loadAirportsGeoJson();
const nextP = fc.features.find((f) => f.properties.icao === 'EHAM')!.properties;
// Depart with exactly the fuel for EDDF→LEMD → lands empty, cannot start LEMD→EHAM. // Depart with exactly the wind-adjusted fuel for EDDF→LEMD (v0.36.0) → lands empty, cannot start LEMD→EHAM.
const w = weatherFor(FROM);
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
await inst.db.update(schema.planes) await inst.db.update(schema.planes)
.set({ .set({
fuel: requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km), fuel: requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km),
queue: JSON.stringify(['EHAM']), queue: JSON.stringify(['EHAM']),
}) })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
@@ -299,12 +331,13 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
}); });
}); });
describe('pause on landing when the last plane lands (ISS-041)', () => { describe('slow to 1× when a landing leaves any plane on the ground (ISS-057)', () => {
const FROM = 'EDDF'; const FROM = 'EDDF';
const TO = 'LEMD'; const TO = 'LEMD';
// Starter flying EDDF→LEMD with departureSimTime set so its arrival is EXACTLY T0 — // Starter flying EDDF→LEMD with departureSimTime set so its arrival is EXACTLY T0 —
// any advancing tick past T0 finalizes it. timeScale is the value under test. // any advancing tick past T0 finalizes it. timeScale is the value under test;
// lastSelectedTimeScale mirrors what a manual POST /api/sim would have stored.
async function setupLanding(name: string, timeScale: number) { async function setupLanding(name: string, timeScale: number) {
const inst = await freshDb(name); const inst = await freshDb(name);
const fc = await loadAirportsGeoJson(); const fc = await loadAirportsGeoJson();
@@ -313,7 +346,7 @@ describe('pause on landing when the last plane lands (ISS-041)', () => {
const km = haversineKm(from.lat, from.lon, to.lat, to.lon); const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
const type = PLANE_TYPES.atr72; const type = PLANE_TYPES.atr72;
await inst.db.update(schema.simState).set({ timeScale }); await inst.db.update(schema.simState).set({ timeScale, lastSelectedTimeScale: timeScale });
await inst.db.update(schema.planes) await inst.db.update(schema.planes)
.set({ status: 'flying', destIcao: TO, departureSimTime: T0 - travelMs(km, type.speedKmh), fuel: type.maxFuel }) .set({ status: 'flying', destIcao: TO, departureSimTime: T0 - travelMs(km, type.speedKmh), fuel: type.maxFuel })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
@@ -321,20 +354,21 @@ describe('pause on landing when the last plane lands (ISS-041)', () => {
return { inst, from, km, type }; return { inst, from, km, type };
} }
it('last plane lands → sim pauses and stored timeScale is UNCHANGED', async () => { it('last plane lands → sim slows to 1× WITHOUT pausing; lastSelectedTimeScale is preserved', async () => {
const { inst } = await setupLanding('pause-last.db', 1000); const { inst } = await setupLanding('slow-last.db', 1000);
vi.setSystemTime(T0 + 2_000); // one full clamped tick at scale 1000 → clock T0+2e6 ≥ arrival (T0) vi.setSystemTime(T0 + 2_000); // one full clamped tick at scale 1000 → clock T0+2e6 ≥ arrival (T0)
const sim = await tickOnce(inst); const sim = await tickOnce(inst);
expect(sim.paused).toBe(true); // persisted on the sim_state row… expect(sim.paused).toBe(false); // no pause anymore — ISS-057 replaces it with a slow-down…
expect((await inst.db.select().from(schema.simState))[0].paused).toBe(true); // …and visible to the next snapshot expect((await inst.db.select().from(schema.simState))[0].paused).toBe(false); // …persisted on the row…
expect(sim.timeScale).toBe(1000); // speed preserved — no auto-slow anymore (ISS-041) expect(sim.timeScale).toBe(1); // …slowed to the floor…
expect(sim.lastSelectedTimeScale).toBe(1000); // …with the last manual speed kept for resume
expect((await inst.db.select().from(schema.planes))[0].status).toBe('available'); // landing itself unchanged expect((await inst.db.select().from(schema.planes))[0].status).toBe('available'); // landing itself unchanged
}); });
it('a landing while another plane is still flying → no pause', async () => { it('a landing while another plane is still flying → slows to 1× (any plane on ground)', async () => {
const { inst, from, type } = await setupLanding('pause-other.db', 1000); const { inst, from, type } = await setupLanding('slow-other.db', 1000);
// A second plane on the same route departing at T0 — arrives far after this tick's clock. // A second plane on the same route departing at T0 — arrives far after this tick's clock.
await inst.db.insert(schema.planes).values({ await inst.db.insert(schema.planes).values({
id: 'p-b', model: 'atr72', status: 'flying', fuel: type.maxFuel, maxFuel: type.maxFuel, id: 'p-b', model: 'atr72', status: 'flying', fuel: type.maxFuel, maxFuel: type.maxFuel,
@@ -344,26 +378,26 @@ describe('pause on landing when the last plane lands (ISS-041)', () => {
vi.setSystemTime(T0 + 2_000); vi.setSystemTime(T0 + 2_000);
const sim = await tickOnce(inst); const sim = await tickOnce(inst);
expect(sim.paused).toBe(false); // p-b still in flight — no pause (nothing would auto-unpause it) expect(sim.paused).toBe(false);
expect(sim.timeScale).toBe(1000); expect(sim.timeScale).toBe(1); // the just-landed starter is on ground → slow (literal issue reading)
const byId = new Map((await inst.db.select().from(schema.planes)).map((p) => [p.id, p.status])); const byId = new Map((await inst.db.select().from(schema.planes)).map((p) => [p.id, p.status]));
expect(byId.get('plane-starter')).toBe('available'); // the starter did land… expect(byId.get('plane-starter')).toBe('available'); // the starter did land…
expect(byId.get('p-b')).toBe('flying'); // …but the fleet is not idle expect(byId.get('p-b')).toBe('flying'); // …but the fleet is not idle — still slowed
}); });
it('last plane lands at scale 1 → pauses anyway, scale untouched', async () => { it('already at 1× → stays 1 (no-op slow)', async () => {
const { inst } = await setupLanding('pause-one.db', 1); const { inst } = await setupLanding('slow-one.db', 1);
vi.setSystemTime(T0 + 2_000); // one tick at scale 1 advances 2e3 game ms — past the T0 arrival vi.setSystemTime(T0 + 2_000); // one tick at scale 1 advances 2e3 game ms — past the T0 arrival
const sim = await tickOnce(inst); const sim = await tickOnce(inst);
expect(sim.paused).toBe(true); expect(sim.paused).toBe(false);
expect(sim.timeScale).toBe(1); expect(sim.timeScale).toBe(1);
expect((await inst.db.select().from(schema.planes))[0].status).toBe('available'); expect((await inst.db.select().from(schema.planes))[0].status).toBe('available');
}); });
it('a chained plane does NOT trigger the last-plane pause (ISS-043)', async () => { it('a chained plane does NOT slow the sim (nothing is grounded)', async () => {
const { inst } = await setupLanding('pause-chain.db', 1000); // full tank, arrival exactly T0 const { inst } = await setupLanding('slow-chain.db', 1000); // full tank, arrival exactly T0
// A queued next hop with plenty of fuel → chains on landing instead of grounding. // A queued next hop with plenty of fuel → chains on landing instead of grounding.
await inst.db.update(schema.planes) await inst.db.update(schema.planes)
.set({ queue: JSON.stringify(['EDDV']) }) .set({ queue: JSON.stringify(['EDDV']) })
@@ -372,23 +406,25 @@ describe('pause on landing when the last plane lands (ISS-041)', () => {
vi.setSystemTime(T0 + 2_000); // one full clamped tick at scale 1000 → clock T0+2e6 ≥ arrival (T0) vi.setSystemTime(T0 + 2_000); // one full clamped tick at scale 1000 → clock T0+2e6 ≥ arrival (T0)
const sim = await tickOnce(inst); const sim = await tickOnce(inst);
expect(sim.paused).toBe(false); // still flying after the chain — no premature pause… expect(sim.paused).toBe(false);
expect(sim.timeScale).toBe(1000); // still flying after the chain, nothing grounded — no slow…
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying'); // …the fresh select in tickOnce counts it as airborne expect(plane.status).toBe('flying'); // …the fresh select in tickOnce counts it as airborne
expect(plane.destIcao).toBe('EDDV'); expect(plane.destIcao).toBe('EDDV');
}); });
it('paused sim with all planes landed → no change', async () => { it('manually-paused sim is untouched by ticks (no landing processed while paused)', async () => {
const inst = await freshDb('pause-paused.db'); const inst = await freshDb('slow-paused.db');
// The fleet is fully grounded (the seeded starter) — a paused tick must not touch the // The fleet is fully grounded (the seeded starter) — a paused tick must not touch the
// controls even though "no plane in flight" holds: the trigger requires a landing this tick. // controls even though "any plane on ground" holds: the trigger requires a landing this tick.
await inst.db.update(schema.simState).set({ timeScale: 1000, paused: true }); await inst.db.update(schema.simState).set({ timeScale: 1000, lastSelectedTimeScale: 1000, paused: true });
vi.setSystemTime(T0 + 5_000); vi.setSystemTime(T0 + 5_000);
const sim = await tickOnce(inst); const sim = await tickOnce(inst);
expect(sim.paused).toBe(true); // no landing processed while paused → trigger never fires expect(sim.paused).toBe(true); // no landing processed while paused → trigger never fires
expect(sim.timeScale).toBe(1000); expect(sim.timeScale).toBe(1000);
expect(sim.lastSelectedTimeScale).toBe(1000);
expect((await inst.db.select().from(schema.planes))[0].status).toBe('available'); expect((await inst.db.select().from(schema.planes))[0].status).toBe('available');
}); });
}); });
@@ -418,9 +454,10 @@ describe('spawnOpenPackages', () => {
expect(icaoSet.has(p.destIcao)).toBe(true); expect(icaoSet.has(p.destIcao)).toBe(true);
expect(p.originIcao).not.toBe(p.destIcao); expect(p.originIcao).not.toBe(p.destIcao);
expect(p.planeId).toBeNull(); expect(p.planeId).toBeNull();
// Same deadline window as the seed: now + 6–72h game time. // Same deadline window as the seed: now + 6–72h game time — scaled by any special status (v0.37.0).
expect(p.deadlineSimTime).toBeGreaterThan(clock + 6 * 3_600_000 - 1); const scale = p.special ? SPECIALS[p.special]?.deadlineScale ?? 1 : 1;
expect(p.deadlineSimTime).toBeLessThanOrEqual(clock + 72 * 3_600_000); expect(p.deadlineSimTime).toBeGreaterThan(clock + 6 * 3_600_000 * scale - 1);
expect(p.deadlineSimTime).toBeLessThanOrEqual(clock + 72 * 3_600_000 * scale);
} }
// Already at target → nothing more to spawn. // Already at target → nothing more to spawn.
+56 -23
View File
@@ -3,8 +3,10 @@ import { eq, inArray } from 'drizzle-orm';
import { getGameDb, persist, type GameDb } from '@/lib/db/client'; import { getGameDb, persist, type GameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { PLANE_TYPES } from '@/lib/game/catalog'; import { PLANE_TYPES } from '@/lib/game/catalog';
import { SPECIALS } from '@/lib/game/specials';
import { parseIcaoQueue } from '@/lib/game/state'; import { parseIcaoQueue } from '@/lib/game/state';
import { import {
generatePackage, generatePackage,
@@ -13,7 +15,7 @@ import {
type Rng, type Rng,
type SeedAirport, type SeedAirport,
} from '@/lib/game/seed'; } from '@/lib/game/seed';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor } from './movement'; import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
// Server-authoritative sim engine (v0.7.0) — tick-on-read: there is NO background // Server-authoritative sim engine (v0.7.0) — tick-on-read: there is NO background
// timer. Every read/action endpoint calls tickNow() first; the clock advances by // timer. Every read/action endpoint calls tickNow() first; the clock advances by
@@ -75,8 +77,7 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
let landedThisTick = 0; let landedThisTick = 0;
if (advanceMs > 0) { if (advanceMs > 0) {
await expirePackages(inst, clockMs); await expirePackages(inst, clockMs);
// ISS-041: remember the landings so the tick can pause when this was the last // ISS-057: remember the landings so the tick can slow down when a plane is left on ground.
// plane in flight.
landedThisTick = await deliverPackages(inst, clockMs); landedThisTick = await deliverPackages(inst, clockMs);
await spawnOpenPackages(inst, clockMs); await spawnOpenPackages(inst, clockMs);
// AFTER delivery on purpose: a plane that just landed gets work spawned // AFTER delivery on purpose: a plane that just landed gets work spawned
@@ -84,18 +85,21 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
await spawnCoveragePackages(inst, clockMs); await spawnCoveragePackages(inst, clockMs);
} }
const sets: Partial<Pick<schema.SimStateRow, 'clockMs' | 'lastRealTickMs' | 'timeScale' | 'paused'>> = { const sets: Partial<Pick<schema.SimStateRow, 'clockMs' | 'lastRealTickMs' | 'timeScale'>> = {
clockMs, clockMs,
lastRealTickMs: nowMs, lastRealTickMs: nowMs,
}; };
// Pause on landing (ISS-041): when ≥1 plane landed this tick and NO plane remains in flight, // Slow on landing (ISS-057): when ≥1 plane landed this tick and ANY plane is grounded
// pause the sim WITHOUT touching timeScale — speed is preserved for the next dispatch. // ('available') afterwards, drop the sim to MIN_TIME_SCALE — a just-landed plane counts, so
// Arrival-triggered by design: a grounded fleet at high scale is left alone (there is no // any real landing slows. A chained plane stays 'flying' via deliverPackages' auto-chain
// auto-resume except starting a flight; see flyPlane). A paused tick never processes landings // branch, so a pure-chain tick with nothing grounded does NOT slow. Pause is manual-only now;
// (advanceMs === 0), so the trigger can only fire on an advancing tick. // flyPlane restores lastSelectedTimeScale when the last grounded plane takes off. Arrival-
// triggered by design: a grounded fleet at high scale is left alone (no idle-enforcement), and
// a paused tick never processes landings (advanceMs === 0), so the trigger only fires on an
// advancing tick — a manual speed change while planes sit grounded survives until the next landing.
if (landedThisTick > 0) { if (landedThisTick > 0) {
const flying = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'flying')); const available = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'available'));
if (flying.length === 0) sets.paused = true; if (available.length >= 1) sets.timeScale = MIN_TIME_SCALE;
} }
// Persist lastRealTickMs even when nothing advanced — it is the tick anchor. // Persist lastRealTickMs even when nothing advanced — it is the tick anchor.
@@ -132,8 +136,8 @@ export async function expirePackages(inst: GameDb, clockMs: number): Promise<num
// its remaining fuel (a short tank keeps the queue intact for refuel + re-dispatch). // its remaining fuel (a short tank keeps the queue intact for refuel + re-dispatch).
// Idempotent — a finalized plane either leaves the 'flying' state or starts a NEW // Idempotent — a finalized plane either leaves the 'flying' state or starts a NEW
// leg whose own arrival is in the future, so it can never be awarded twice. // leg whose own arrival is in the future, so it can never be awarded twice.
// Returns planes landed. NOTE: runs BEFORE tickOnce's pause check on purpose — // Returns planes landed. NOTE: runs BEFORE tickOnce's slow-down check on purpose —
// a chained plane counts as still flying there, so chaining never pauses early. // a chained plane is already 'flying' again here, so a pure-chain landing never slows the sim.
export async function deliverPackages(inst: GameDb, clockMs: number): Promise<number> { export async function deliverPackages(inst: GameDb, clockMs: number): Promise<number> {
const [planeRows, packageRows] = await Promise.all([ const [planeRows, packageRows] = await Promise.all([
inst.db.select().from(schema.planes), inst.db.select().from(schema.planes),
@@ -154,8 +158,17 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon); const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh)) continue; if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh)) continue;
// Arrived: progress is exactly 1 here, so remaining fuel is the full-leg weighted burn. // Arrived: progress is exactly 1 here, so remaining fuel is the full-leg weighted burn —
const landedFuel = fuelAtWeighted(p.fuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm, 1); // wind-adjusted (v0.36.0) by departure weather vs route heading, same factor flyPlane used.
const w = weatherFor(p.locationIcao);
const landedFuel = fuelAtWeighted(
p.fuel,
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
type.emptyWeightKg,
type.maxFuel,
distanceKm,
1,
);
// Deliver every bound package whose destination is the airport just reached — // Deliver every bound package whose destination is the airport just reached —
// runs whether or not a queued leg chains next (ISS-043). // runs whether or not a queued leg chains next (ISS-043).
@@ -166,9 +179,12 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
// Fresh account read per award so multiple deliveries in one tick all count. // Fresh account read per award so multiple deliveries in one tick all count.
const fresh = (await inst.db.select().from(schema.account))[0]; const fresh = (await inst.db.select().from(schema.account))[0];
if (!fresh) throw new Error('account row missing'); if (!fresh) throw new Error('account row missing');
// v0.37.0 (ISS-073): special packages add the registry's flat rep bonus on top of the
// distance-based gain — same double-rounding convention as before (gain rounded to cents, total re-rounded).
const repBonus = pk.special ? SPECIALS[pk.special]?.repBonus ?? 0 : 0;
await inst.db.update(schema.account).set({ await inst.db.update(schema.account).set({
cash: fresh.cash + pk.reward, cash: fresh.cash + pk.reward,
reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm)) * 100) / 100, reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm) + repBonus) * 100) / 100,
}); });
} else if (pk.status === 'expired') { } else if (pk.status === 'expired') {
// Expired cargo keeps no value — just free the slot. // Expired cargo keeps no value — just free the slot.
@@ -186,9 +202,19 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
if (queue.length > 0) { if (queue.length > 0) {
const nextDest = queue[0]; const nextDest = queue[0];
const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain
if (next && requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, haversineKm(to.lat, to.lon, next.lat, next.lon)) <= landedFuel) { if (next) {
queue.shift(); // Wind-adjusted requirement for the NEXT leg — it departs from `to` (v0.36.0).
chainedTo = nextDest; const wNext = weatherFor(to.icao);
const nextRequiredL = requiredFuelFor(
type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)),
type.emptyWeightKg,
type.maxFuel,
haversineKm(to.lat, to.lon, next.lat, next.lon),
);
if (nextRequiredL <= landedFuel) {
queue.shift();
chainedTo = nextDest;
}
} }
} }
await inst.db.update(schema.planes) await inst.db.update(schema.planes)
@@ -320,13 +346,20 @@ export function runAction<T>(fn: (inst: GameDb) => Promise<T>): Promise<T> {
// Tick first so the returned state reflects current time, then apply the new // Tick first so the returned state reflects current time, then apply the new
// controls. timeScale is clamped to [1, 1000]; validation of raw input happens in // controls. timeScale is clamped to [1, 1000]; validation of raw input happens in
// the route handler (400 on non-number / non-boolean). // the route handler (400 on non-number / non-boolean). ISS-057: posting a timeScale
// ALSO records it as lastSelectedTimeScale — the resume target flyPlane restores when
// the last grounded plane takes off. A {paused}-only post never touches it, so the
// last manual intent wins without an extra flag.
export function applyTimeControls(input: SimControlsInput): Promise<schema.SimStateRow> { export function applyTimeControls(input: SimControlsInput): Promise<schema.SimStateRow> {
return withLock(async () => { return withLock(async () => {
const inst = await getGameDb(); const inst = await getGameDb();
await tickOnce(inst); await tickOnce(inst);
const sets: Partial<Pick<schema.SimStateRow, 'timeScale' | 'paused'>> = {}; const sets: Partial<Pick<schema.SimStateRow, 'timeScale' | 'lastSelectedTimeScale' | 'paused'>> = {};
if (input.timeScale !== undefined) sets.timeScale = clampTimeScale(input.timeScale); if (input.timeScale !== undefined) {
const scale = clampTimeScale(input.timeScale);
sets.timeScale = scale;
sets.lastSelectedTimeScale = scale;
}
if (input.paused !== undefined) sets.paused = input.paused; if (input.paused !== undefined) sets.paused = input.paused;
if (Object.keys(sets).length > 0) await inst.db.update(schema.simState).set(sets); if (Object.keys(sets).length > 0) await inst.db.update(schema.simState).set(sets);
await persist(inst); await persist(inst);
+70
View File
@@ -5,10 +5,12 @@ import {
burnRateConstant, burnRateConstant,
FUEL_DENSITY_KG_PER_L, FUEL_DENSITY_KG_PER_L,
fuelAtWeighted, fuelAtWeighted,
maxRangeKm,
positionAt, positionAt,
progressAt, progressAt,
requiredFuelFor, requiredFuelFor,
travelDurationMs, travelDurationMs,
windBurnFactor,
} from './movement'; } from './movement';
// Synthetic in-flight fixtures — no DB needed: everything is a pure function of // Synthetic in-flight fixtures — no DB needed: everything is a pure function of
@@ -164,3 +166,71 @@ describe('weighted burn model (v0.16.0)', () => {
expect(fuelAtWeighted(TANK, BURN, WE, TANK, KM, progressAt(DEP, KM, SPEED, clock))).toBeGreaterThan(0); expect(fuelAtWeighted(TANK, BURN, WE, TANK, KM, progressAt(DEP, KM, SPEED, clock))).toBeGreaterThan(0);
}); });
}); });
describe('windBurnFactor (v0.36.0, ISS-070)', () => {
it('full headwind at max wind is exactly +20%', () => {
// bearing == windDirDeg: the wind blows straight from ahead.
expect(windBurnFactor(30, 90, 90)).toBe(1.2);
});
it('full tailwind at max wind is exactly −20%', () => {
// windDirDeg = bearing + 180 (mod 360): the wind blows from behind.
expect(windBurnFactor(30, 270, 90)).toBe(0.8);
});
it('a pure crosswind (90° off) leaves burn unchanged', () => {
expect(windBurnFactor(30, 0, 90)).toBeCloseTo(1, 9);
expect(windBurnFactor(30, 180, 90)).toBeCloseTo(1, 9);
});
it('small wind stays near 1', () => {
const f = windBurnFactor(2, 45, 45); // worst case: full headwind at min wind
expect(f).toBeGreaterThan(1.01);
expect(f).toBeLessThan(1.02);
});
it('stays within [0.8, 1.2] for the whole weather range', () => {
for (const kt of [2, 15, 30]) {
for (const off of [0, 45, 90, 135, 180, 270, 315]) {
const f = windBurnFactor(kt, 100, (100 + off) % 360);
expect(f).toBeGreaterThanOrEqual(0.8 - 1e-12);
expect(f).toBeLessThanOrEqual(1.2 + 1e-12);
}
}
});
it('applied as burnPerKm × f, requiredFuelFor and fuelAtWeighted scale accordingly (pinned)', () => {
// Fixed inputs: ATR numbers, 1000 km leg / half of the FRA→MAD leg.
expect(requiredFuelFor(BURN * 1.2, WE, TANK, 1_000)).toBeCloseTo(1444.911589, 6);
expect(requiredFuelFor(BURN * 0.8, WE, TANK, 1_000)).toBeCloseTo(944.348095, 6);
expect(fuelAtWeighted(TANK, BURN * 1.2, WE, TANK, KM, 0.5)).toBeCloseTo(4867.313136, 6);
expect(fuelAtWeighted(TANK, BURN * 0.8, WE, TANK, KM, 0.5)).toBeCloseTo(5331.677856, 6);
// Headwind always costs more than tailwind over the same leg…
expect(requiredFuelFor(BURN * 1.2, WE, TANK, KM)).toBeGreaterThan(requiredFuelFor(BURN * 0.8, WE, TANK, KM));
expect(fuelAtWeighted(TANK, BURN * 1.2, WE, TANK, KM, 1)).toBeLessThan(fuelAtWeighted(TANK, BURN * 0.8, WE, TANK, KM, 1));
// …and a neutral factor (f = 1) reproduces the unadjusted model exactly.
expect(requiredFuelFor(BURN * windBurnFactor(30, 0, 90), WE, TANK, KM)).toBeCloseTo(requiredFuelFor(BURN, WE, TANK, KM), 9);
});
});
describe('maxRangeKm (v0.28.0)', () => {
it('ATR 72-600 full tank ≈ 4439 km (±5%)', () => {
const r = maxRangeKm(BURN, WE, TANK);
expect(r).toBeGreaterThan(4439 * 0.95);
expect(r).toBeLessThan(4439 * 1.05);
});
it('increases with tank size and decreases with burn rate', () => {
expect(maxRangeKm(BURN, WE, TANK * 2)).toBeGreaterThan(maxRangeKm(BURN, WE, TANK));
expect(maxRangeKm(3, WE, TANK)).toBeLessThan(maxRangeKm(BURN, WE, TANK));
});
it('zero fuel → zero range', () => {
expect(maxRangeKm(BURN, WE, 0)).toBe(0);
});
it('rejects a non-positive empty weight', () => {
expect(() => maxRangeKm(BURN, 0, TANK)).toThrow();
});
});
+19
View File
@@ -118,3 +118,22 @@ export function requiredFuelFor(
const rho = FUEL_DENSITY_KG_PER_L; const rho = FUEL_DENSITY_KG_PER_L;
return (emptyWeightKg / rho) * (Math.exp(k * rho * distanceKm) - 1); return (emptyWeightKg / rho) * (Math.exp(k * rho * distanceKm) - 1);
} }
// Wind adjustment to per-km burn (v0.36.0, ISS-070): the headwind component of a wind blowing
// FROM `windDirDeg` against a leg heading `bearingDeg`, as a multiplier on burnPerKm —
// factor = 1 + headwindKt/150, exactly [0.8, 1.2] over the weather range 2–30 kt (±20%).
// Pure; callers apply it as burnPerKm * factor so every call site agrees on one value per leg.
export function windBurnFactor(windKt: number, windDirDeg: number, bearingDeg: number): number {
const headwindKt = windKt * Math.cos(((bearingDeg - windDirDeg) * Math.PI) / 180);
return 1 + headwindKt / 150;
}
// Max range from a FULL tank under the weighted model — solve requiredFuelFor(d) = maxFuel for d:
// (We/ρ)(e^{kρd} − 1) = F → d = ln(1 + ρF/We) · (We + ρF)/(ρ·burnPerKm).
// Returns raw km (call sites round); zero fuel gives exactly 0. Pure — display-only, the sim
// itself does not model range exhaustion (see fuelAtWeighted).
export function maxRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number): number {
if (!(emptyWeightKg > 0)) throw new Error('emptyWeightKg must be > 0');
const rho = FUEL_DENSITY_KG_PER_L;
return Math.log(1 + (rho * maxFuel) / emptyWeightKg) * ((emptyWeightKg + rho * maxFuel) / (rho * burnPerKm));
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "flight-ops", "name": "flight-ops",
"version": "0.27.1", "version": "0.37.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000", "dev": "next dev -p 3000",