Compare commits

...
27 Commits
Author SHA1 Message Date
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
kamilkrzywda 4f182a22b6 Merge pull request 'Right pane width matches left pane: 280→360px + toast offset (ISS-046)' (#56) from ui/right-pane-width into master
CI / build + unit + e2e (push) Successful in 7m56s
2026-09-20 15:29:41 +02:00
kamilkrzywda 34b38dd5d5 Right pane width matches left pane: 280→360px + toast offset (ISS-046)
CI / build + unit + e2e (pull_request) Successful in 8m8s
2026-09-20 13:17:33 +00:00
kamilkrzywda b2133fd25c Merge pull request 'Weight-based cargo capacity + weight/distance payout (ISS-045)' (#52) from feat/weight-capacity-payout into master
CI / build + unit + e2e (push) Successful in 7m52s
2026-09-20 14:54:54 +02:00
kamilkrzywda 179fad450d Package weight, payload capacity and per-kg payout (ISS-045)
CI / build + unit + e2e (pull_request) Successful in 8m2s
Packages carry a real weightKg (unit weight x count with +/-30% jitter),
plane capacity becomes maxPayloadKg instead of 6 slots, load validation
sums cargo weight against the payload, and the payout gains a per-kg part.
Loadout display shows tonnage; OpenAPI swaps CatalogEntry.capacity for
maxPayloadKg and adds PackageState.weightKg.
2026-09-20 12:42:03 +00:00
kamilkrzywda 85b7b07f53 Merge pull request 'Flight queue UI: dotted map line, left-pane Queue sub-section, e2e (ISS-043 part 2)' (#51) from feat/flight-queue-ui into master
CI / build + unit + e2e (push) Successful in 9m9s
2026-09-20 13:57:39 +02:00
kamilkrzywda 5ef661a06f Fix queue.spec.ts test 1: refuel to full before the long leg (fuel 409 was timing out the fly poll)
CI / build + unit + e2e (pull_request) Successful in 9m0s
2026-09-20 11:48:36 +00:00
kamilkrzywda 408cd933f4 Flight queue UI: dotted map line, left-pane Queue sub-section, e2e (ISS-043 part 2)
CI / build + unit + e2e (pull_request) Failing after 6m51s
2026-09-20 11:31:11 +00:00
kamilkrzywda 3395fb4f90 Merge pull request 'ISS-043 (part 1): flight queue backend — data model, auto-chain on landing, add/cancel endpoints' (#50) from feat/flight-queue-backend into master
CI / build + unit + e2e (push) Successful in 6m21s
2026-09-20 13:04:17 +02:00
kamilkrzywda 537c494daa ISS-043: add queue endpoints to e2e OpenAPI path list
CI / build + unit + e2e (pull_request) Successful in 6m17s
2026-09-20 10:57:57 +00:00
kamilkrzywda 69b708dad3 Flight queue backend: planes hold a queue of upcoming legs, auto-chain on landing (ISS-043 part 1)
CI / build + unit + e2e (pull_request) Failing after 6m37s
- planes.queue TEXT NOT NULL DEFAULT '[]' — ordered JSON array of not-yet-started
  leg ICACs; active leg stays destIcao + departureSimTime. DDL + guarded ALTER
  migration for existing DBs; seed sets queue: [] explicitly.
- deliverPackages() auto-chains on arrival: with a non-empty queue and fuel
  covering the first queued leg (same requiredFuelFor rule as /fly), the plane
  immediately starts that leg from the arrival airport (stays 'flying',
  departureSimTime = arrival instant, queue shortened). Short tank → grounds
  safely with the queue kept intact; empty queue → unchanged grounding. Cargo
  delivery for the reached airport runs first and is unaffected. Ordering kept:
  delivery still runs before tickOnce's pause check, so a chained plane counts
  as still flying — no premature last-plane pause (ISS-041 preserved).
- New endpoints via performAction(): POST /api/planes/{planeId}/queue {destIcao}
  (append; works grounded or flying; 400 bad body/unknown ICAO, 404 unknown
  plane) and POST /api/planes/{planeId}/queue/cancel {index} (remove queued leg
  by index; active in-flight leg never affected; 400 non-integer/out-of-range,
  404 unknown plane). Both return the full state snapshot.
- PlaneState.queue: string[] in /api/state (null/malformed-safe parse helper
  shared with the engine); OpenAPI updated (PlaneState.queue, QueueLegRequest,
  CancelQueuedLegRequest, both paths) and openapi.test.ts expectations extended.
- Tests: auto-chain block in engine.test.ts (chain / short-fuel ground / empty
  queue) + chained-plane-no-pause regression guard; queueLeg/cancelQueuedLeg
  blocks in actions.test.ts; type-compat queue: [] fixtures in deriveEvents,
  right-pane, mapData and seed tests.

v0.25.0 — part 1 (backend) of ISS-043; UI/map rendering lands in a follow-up.
2026-09-20 10:39:00 +00:00
kamilkrzywda e109e003c0 Merge pull request 'ISS-041: pause on landing (keep speed) + auto-resume when a flight starts' (#49) from fix/pause-on-landing-auto-unpause into master
CI / build + unit + e2e (push) Successful in 7m51s
2026-09-20 12:00:02 +02:00
kamilkrzywda cbe0cda15d Pause on landing + auto-unpause on dispatch (ISS-041)
CI / build + unit + e2e (pull_request) Successful in 7m43s
Replace the ISS-011 auto-slow with pause-on-landing: when the last plane
in flight lands, tickOnce sets sim_state.paused=true WITHOUT touching
timeScale (speed preserved). flyPlane() now clears paused before
persisting so starting a flight resumes a paused sim — inside runAction(),
tick + unpause + dispatch are atomic under the sim lock.

Tests: engine.test.ts auto-slow block rewritten for pause semantics; new
flyPlane test asserts dispatch clears paused with speed untouched. E2E:
auto-slow.spec.ts renamed to pause-on-landing.spec.ts asserting the new
behavior (paused=true AND timeScale still 1000 after landing; /fly clears
paused). Cross-cutting fix: every per-spec setScale helper now POSTs
{timeScale, paused:false} — a scale-only fast-forward after a landing would
otherwise leave the clock frozen. Deliberate mid-test pauses untouched.
2026-09-20 09:48:56 +00:00
kamilkrzywda 4620707abc Merge pull request 'ISS-040: on-board package hover traces from carrier's current position' (#48) from fix/onboard-hover-current-location into master
CI / build + unit + e2e (push) Successful in 7m6s
2026-09-20 11:24:33 +02:00
kamilkrzywda 3024606ac8 On-board package hover traces from the carrier's current position (ISS-040)
CI / build + unit + e2e (pull_request) Successful in 7m11s
2026-09-20 09:16:06 +00:00
kamilkrzywda f44e7aa0f5 Merge pull request 'v0.23.1 — Align toast stack to map right edge, clear of right pane (#39)' (#44) from ui/toast-align-map-right into master
CI / build + unit + e2e (push) Successful in 6m14s
2026-09-20 10:52:40 +02:00
kamilkrzywda 58d05f5287 Align toast stack to map right edge, clear of right pane (#39)
CI / build + unit + e2e (pull_request) Successful in 7m1s
2026-09-20 08:42:58 +00:00
kamilkrzywda e18d39edd0 Merge pull request 'v0.23.0 — Move Refuel-to-full from market into left pane next to Relocate (#35)' (#42) from ui/move-refuel-to-left-pane into master
CI / build + unit + e2e (push) Successful in 7m58s
2026-09-20 10:34:43 +02:00
kamilkrzywda a7150a1062 Move Refuel-to-full from market into left pane next to Relocate (#35)
CI / build + unit + e2e (pull_request) Successful in 6m52s
2026-09-20 08:25:57 +00:00
44 changed files with 2197 additions and 332 deletions
+74
View File
@@ -1,5 +1,79 @@
# Changelog # Changelog
## v0.30.0 — 2026-09-20 19:47
- **Landing slows the sim to 1× instead of pausing; dispatch restores the last selected speed (ISS-057).** Replaces the v0.24.0 pause-on-landing behavior with a slow-down: in `tickOnce` (`lib/sim/engine.ts`), when ≥1 plane landed this tick and ANY plane is grounded ('available') afterwards, the sim drops to `MIN_TIME_SCALE` (1×) — a just-landed plane counts, so any real landing slows; a chained plane stays 'flying' via deliverPackages' auto-chain branch, so a pure-chain tick with nothing grounded does NOT slow. Pause becomes manual-only: ticks never set it anymore
- **Resume on dispatch.** `flyPlane` (`lib/game/actions.ts`) still clears a manually-paused sim on dispatch (kept), and now ALSO restores the last manually selected speed when this dispatch took the LAST grounded plane airborne — fresh-select 'available' planes after the update, and if zero remain set `timeScale = lastSelectedTimeScale`. With another plane still grounded the sim stays at 1×
- **New column `last_selected_time_scale` on `sim_state`** (`lib/db/schema.ts`, DDL + guarded `ALTER TABLE … DEFAULT 1` migration in `lib/db/client.ts`; pre-existing rows backfill to their OWN current `time_scale`, so a legacy DB resumes at the speed it was running). Written by `applyTimeControls` whenever a POST /api/sim body carries `timeScale` (clamped value); a `{paused}`-only post never touches it — last manual intent wins as the resume target with no extra flag. Seed sets it to 1 (`lib/game/seed.ts`)
- No snapshot/OpenAPI/route/client changes: the slider and speed label re-read `timeScale` from each poll automatically, so the slow-down and the resume are visible in the UI without any client work
- Tests: unit — `lib/sim/engine.test.ts` pause-on-landing describe rewritten for the slow/resume semantics (last landing at 1000× → paused=false + timeScale=1 + lastSelectedTimeScale preserved; landing with another plane still flying → slows, literal issue reading; already at 1× stays; chained-plane tick does NOT slow; manually-paused sim untouched by ticks), `lib/game/actions.test.ts` dispatch test rewritten (sole grounded plane → resumes to lastSelectedTimeScale=250 + unpauses; second grounded plane → stays 1×), new `app/api/sim/route.test.ts` case (POST {timeScale} records the row's lastSelectedTimeScale, POST {paused}-only leaves it untouched — read via getGameDb()), `lib/game/seed.test.ts` exact-equality gains the field, `lib/db/client.test.ts` legacy-migration fixture bumps time_scale to 50 and asserts the backfill. E2E — `e2e/pause-on-landing.spec.ts` renamed to `e2e/slow-on-landing.spec.ts` and rewritten: after landing assert `paused === false && timeScale === 1`; the /fly post-action snapshot asserts `timeScale === 1000` via resume (setScale posts 1000, so lastSelected=1000). Other specs' setScale helpers post {timeScale, paused:false} and keep working unchanged
- Verified: `tsc --noEmit` clean; unit suite green locally (327, was 325 — +2 new cases; engine describe rewritten in place); `npx playwright test --list` = 46 tests in 13 files (unchanged — the spec rename keeps the count); e2e not run locally by design — CI verifies against an isolated stack
## v0.29.1 — 2026-09-20 18:22
- Removed the flaky 'time controls' e2e test from `e2e/panes.spec.ts` (keyboard-driven slider assertions timed out randomly in CI); pause semantics remain covered by `e2e/sim.spec.ts` and `e2e/pause-on-landing.spec.ts`.
## v0.29.0 — 2026-09-20 14:27
- **Ignore a job (ISS-047, part B).** A player can now dismiss an open job so it leaves the market permanently; new random jobs spawn in its place via the existing top-up logic (`spawnOpenPackages` counts 'open', so the market refills to 10 on the next tick). New terminal package status `'ignored'` added to the status union/enum in `lib/db/schema.ts`, `lib/game/state.ts`, `lib/openapi.ts` (PackageState.status) and the e2e-local interface copy in `e2e/right-pane.spec.ts`. No DB migration — the column is plain TEXT with no CHECK constraint. New action `ignorePackage` (`lib/game/actions.ts`) next to abandon: 404 unknown id, 409 unless open ("only open packages can be ignored"), otherwise flips status to 'ignored' — no body, no penalty, nothing else changes
- **New route.** `POST /api/packages/{id}/ignore` (`app/api/packages/[id]/ignore/route.ts`) — no body, mirrors the abandon route exactly (same performAction wrapper, full state snapshot on success). The 13th public endpoint: OpenAPI spec gains the path + 'ignored' in PackageState.status; `lib/openapi.test.ts` and `e2e/api.spec.ts` path lists updated twelve → thirteen
- **UI.** Each open-package row in the Jobs tab gains a small "Ignore" button beside the Load controls (`components/RightPane.tsx`, testid `ignore-{pkg.id}`, disabled while the pane action is busy, calls runAction with no body). Ignored rows vanish from the list automatically — the section filters status==='open'; no toast (deriveEvents untouched)
- Verified-safe consumers unchanged: expirePackages selects only open|loaded ('ignored' never expires), deliverPackages handles loaded/expired, RightPane filters 'open'
- Tests: unit — new `ignorePackage` block in `lib/game/actions.test.ts` (open → ignored with cash/planeId untouched; 409 for each of loaded/delivered/expired/abandoned/already-ignored; 404 unknown id), `lib/sim/engine.test.ts` terminal-statuses test gains an 'ignored' row, `lib/openapi.test.ts` status enum list gains 'ignored'. E2E — new "Ignore removes an open job from the list permanently" test in `e2e/right-pane.spec.ts`: hook-created package renders with an enabled Ignore button, UI click → /api/state shows 'ignored', row gone from `[data-testid="open-packages"]`, second API ignore 409s
- Verified: `tsc --noEmit` clean; unit suite green locally (325, was 318); `npx playwright test --list` = 47 tests in 13 files (was 46/13 — the new ignore test); e2e not run locally by design — CI verifies against an isolated stack
## v0.28.0 — 2026-09-20 13:55
- **Right pane reworked into tabs (ISS-047, part A).** The right pane now has a tab bar under the "Jobs & Market" heading with two tabs — `tab-jobs` ("Jobs", default-active) and `tab-planes-market` ("Planes market") (`components/RightPane.tsx`, new `.pane-tabs`/`.tab-btn` CSS in `app/globals.css`). The Jobs tab is the existing Packages section moved verbatim (content, testids and hover behavior unchanged); the Planes market tab holds the existing buy/sell rows (testids unchanged) plus a tech-spec panel per catalog type — one card (`plane-type-{model}`) with price, sell value (price × SELL_FRACTION), max payload in tonnes, cruise speed, fuel burn, max fuel and **max range**, ordered by price ascending. The inactive tab unmounts; the `cargoWeightFor`/`loadEligiblePlanes` exports are untouched
- **Five new plane types — progression ladder above the ATR starter (ISS-047).** CRJ-900 (€450k), Embraer E175 (€900k), Airbus A320-200 (€1.8M), Airbus A330-200 (€4.5M) and Boeing 777-300ER (€9M) added to `PLANE_TYPES` in `lib/game/catalog.ts` with the exact balance values from the issue (bigger tanks, faster airframes, more payload). Purely additive — everything downstream (buy validation, movement math, load validation, snapshot catalog) is data-driven via `PLANE_TYPES[model]`, so no endpoint or schema change; the OpenAPI spec and its e2e path list stay at twelve
- **Derived max range (ISS-047).** New pure `maxRangeKm(burnPerKm, emptyWeightKg, maxFuel)` in `lib/sim/movement.ts` — closed form of the weighted burn model solving `requiredFuelFor(d) = maxFuel`: `ln(1 + ρF/We)·(We + ρF)/(ρ·burnPerKm)` (reuses `FUEL_DENSITY_KG_PER_L`, guards We > 0, zero fuel → 0). ATR 72-600 full tank ≈ 4436 km. Display-only — the sim still does not model range exhaustion
- **Sell-plane cargo guard (ISS-047).** `sellPlane` (`lib/game/actions.ts`) now 409s with "cannot sell a plane carrying cargo" when the grounded plane has any `loaded`/`expired` packages bound to it — previously selling such a plane orphaned its cargo (packages stuck 'loaded' with a dangling planeId that could never deliver). Same query pattern as `loadPackage`; the last-plane and flying-plane guards are unchanged
- Tests: unit — new `lib/sim/movement.test.ts` block for `maxRangeKm` (ATR ≈ 4439 km ±5%, monotonic in tank size, inverse in burn rate, zero fuel → 0, We ≤ 0 throws), new `lib/game/catalog.test.ts` sanity file (positive specs + unique ids across all six types, atr72 is STARTER_MODEL), `lib/game/actions.test.ts` sellPlane gains a loaded-cargo 409 case (row left untouched). E2E — `e2e/right-pane.spec.ts`: the market test clicks `tab-planes-market` first and a new "Planes market tab lists all plane types with tech specs" test asserts six catalog entries via /api/state, the ATR card's name + range against an in-test mirror of `maxRangeKm`, and the Boeing 777 card with its €9M price; `e2e/toasts.spec.ts` clicks the tab before its market-buy flow; `e2e/panes.spec.ts` asserts both tab buttons render with Jobs default-active. No new endpoints — `lib/openapi.test.ts` untouched
- Verified: `tsc --noEmit` clean; unit suite green locally (318, was 311); `npx playwright test --list` = 46 tests in 13 files (was 45/13 — the new spec-panel test); e2e not run locally by design — CI verifies against an isolated stack
## v0.27.1 — 2026-09-20 13:16
- Right pane widened from 280px to 360px to match the left pane; toast stack offset adjusted to stay flush with the map's right edge (ISS-046).
## v0.27.0 — 2026-09-20 12:27
- **Packages now have real weight (ISS-045).** New `weight_kg REAL NOT NULL DEFAULT 2000` column on `packages` (`lib/db/schema.ts`, DDL + guarded `ALTER TABLE` migration in `lib/db/client.ts`; pre-existing rows backfill to the mid-range default). `generatePackage` draws it AFTER cargoCount: `UNIT_WEIGHT_KG[cargoType] × cargoCount × (0.7 + 0.6·rng())`, rounded to integer kg — one extra RNG draw per package, so the documented fixed draw order is now origin, dest, cargoType, cargoCount, weight jitter, [deadline window]. New tunable table `UNIT_WEIGHT_KG` in `lib/game/seed.ts` (Passengers 50 / Mail 60 / Perishables 70 / Electronics 40 / Auto parts 72 kg per passenger/pallet), chosen so every cargo type at MAX count × max jitter (1.3×) still fits the ATR alone and typical packages land ~400–2500 kg
- **Plane capacity is now max payload kg, not 6 slots (ISS-045).** `capacity` REMOVED from `PlaneType`/`CatalogEntry`/snapshot catalog; replaced by `maxPayloadKg` — ATR 72-600 = 4800 kg (`lib/game/catalog.ts`). Server load validation (`loadPackage`, `lib/game/actions.ts`) now sums `weightKg` over the plane's loaded+expired packages and 409s with `no payload room: the ATR 72-600 carries X/Y kg` when the new package doesn't fit; expired cargo still occupies its payload. Client mirrors it: `slotsUsedFor` → `cargoWeightFor(state, planeId)` (summed kg) in `components/RightPane.tsx`, and `loadEligiblePlanes` compares used + package weight ≤ catalog maxPayloadKg so the Load button state always matches what the server accepts
- **Payout now includes a per-kg part (ISS-045).** New `rewardFor(km, weightKg) = round(BASE_REWARD + REWARD_PER_KM·km + REWARD_PER_KG·weightKg)` with `REWARD_PER_KG = 0.5` (`lib/game/seed.ts`) — replaces the distance-only `rewardForDistanceKm`, which is deleted (no remaining callers). Typical hops stay in the ~€1–3k band
- **Loadout display shows tonnage (ISS-045).** Left-pane fleet rows now render `X.Xt / Y.Yt` (used/max payload, one decimal) instead of `n/6 pkgs`; the on-board block filter switches to weight-based. No new endpoints — OpenAPI only gains `PackageState.weightKg` and swaps `CatalogEntry.capacity` → `maxPayloadKg`, so the e2e path list stays at twelve
- **Test hook gains a forced weight (ISS-045).** `generatePackage` accepts an optional `weightKg` that skips the jitter RNG draw (same pattern as the v0.8.0 forced endpoints), and `POST /api/test/create-package` passes it through — so e2e flows that load several packages onto one plane stay under its payload deterministically instead of flaking on two heavy random draws (~13% would otherwise exceed 4800 kg). The hook is test-only (FLIGHT_TEST_HOOKS=1) and not part of the documented API, so the OpenAPI path list stays at twelve
- Tests: unit — `lib/game/seed.test.ts` (reward expectations → new formula incl. per-kg part; new balance-constraint test asserting every seeded package is a positive integer kg ≤ starter maxPayloadKg; forced-weight block), `lib/game/actions.test.ts` (loadPackage fill loop now inserts weights summing past maxPayloadKg → `/no payload room/`; expired-still-occupies reworked weight-based), `components/right-pane.test.ts` (`cargoWeightFor` sums + partial-payload eligibility case; CATALOG fixture drops capacity, gains maxPayloadKg), `lib/openapi.test.ts` (exact key lists for PackageState + CatalogEntry). Type-compat only: catalog fixtures in `deriveEvents`/`mapData` tests and the e2e interfaces. E2E — `e2e/right-pane.spec.ts` asserts `maxPayloadKg === 4800`; `e2e/panes.spec.ts` loadout regex → `/^\d+\.\dt \/ \d+\.\dt$/`; snapshot key lists in `app/api/state/route.test.ts` + `e2e/state.spec.ts` gain `weightKg`; the two multi-load tests in `e2e/actions.spec.ts` pass forced weights. Fuel math untouched — the refuel-forFlight exact-liter mirrors stay as-is
- Verified: `tsc --noEmit` clean; unit suite green locally (311, was 308); `npx playwright test --list` = 45 tests in 13 files (unchanged); e2e not run locally by design — CI verifies against an isolated stack
## v0.26.0 — 2026-09-20 11:28
- **Flight queue UI + map rendering (ISS-043, part 2).** Builds on the part-1 backend (`PlaneState.queue`, `POST /api/planes/{id}/queue` + `/queue/cancel`, auto-chain on landing) — no new endpoints, so the OpenAPI spec and its e2e path list are untouched
- **Dotted queue line on the map (ISS-043).** New pure builder `queuedRoutesGeoJson(planes, airportsByIcao)` in `lib/mapData.ts`: one LineString per FLYING plane with a non-empty queue, chaining `dest → queue[0] → …` — ONLY the not-yet-started legs (the active leg stays in the solid `routes-legs` layer). Reuses `greatCircleArc`, same feature/properties shape as `routesGeoJson` (`{planeId}`); arcs concatenated with the shared joint point dropped once; a plane is skipped, never guessed, when its dest or any queued ICAO is missing from the airport index. New GeoJSON source + line layer in `components/Map.tsx` — id `queue-routes`, muted slate `#64748b`, width 3, dotted via `'line-dasharray': [0.1, 1.5]` (distinct from the solid amber active leg and the `[2,2]` yellow hover dash), added after `routes-legs` but before the hover layers so a hovered trace still draws on top; pushed each poll in `applyDynamicRef` alongside the solid legs
- **Queue sub-section in the left pane's On board block (ISS-043).** Each on-board plane block now carries a "Queue" sub-section after its cargo rows: one row per queued leg (`queue-leg-{i}`) showing destination name + ICAO with a Cancel button (`queue-cancel-{i}` → `POST …/queue/cancel {index:i}`), plus an add control — airport `<select>` (`queue-add-dest`) + "Add to queue" button (`queue-add` → `POST …/queue {destIcao}`) reusing the pane's already-fetched `airportOptions` (no second `/api/airports` fetch); buttons disable on `actionsBusy`, add also disables until an airport is picked. The on-board block filter now ALSO includes planes with a non-empty queue, so a queued plane always shows its block even when grounded with nothing on board. Existing cargo-row testids/behavior (`onboard-package-*`, `fly-to-*`, `refuel-for-*`, `abandon-*`) unchanged
- Tests: unit — `lib/mapData.test.ts` gains a `queuedRoutesGeoJson` block mirroring the `routesGeoJson` tests (grounded plane with queue → nothing; flying with empty queue → nothing; flying with a 2-leg queue → one feature starting at DEST, passing through each queued airport exactly at the arc joints, ending at the last queued ICAO, length `n_legs × SEGMENTS + 1`; missing dest or queued ICAO → skipped; multiple planes → only qualifying ones draw). E2E — new `e2e/queue.spec.ts` (serial): (1) queue display + cancel through the real UI — load a package at EDDF, start leg 1 via the fly-to button, freeze mid-flight, add a leg via `queue-add-dest`/`queue-add`, assert `queue-leg-0` appears with its destination, cancel it and assert it's gone; (2) dotted map line — flying plane with one queued leg: fit to both endpoints and assert some rendered `queue-routes` segment starts within EPS of DEST and some ends within EPS of the queued airport ACROSS per-tile segments (plain-object mapping in-page per ORCHESTRATOR §6), no segment starts at the hub (the active leg is NOT in this layer) and every feature carries `planeId: plane-starter`; (3) auto-chain end-to-end — full tank, package EDDF→LEMD + queued return LEMD→EDDF, start leg 1, fast-forward at 1000× and assert the server AUTO-STARTS the chained leg (`status 'flying'` to EDDF with no manual dispatch) with the package already delivered (part-1 ordering: delivery before chaining); all three leave the starter grounded at EDDF for later specs
- Verified: `tsc --noEmit` clean; unit suite green locally (308, was 303); `npx playwright test --list` = 45 tests in 13 files (was 42/12 — the new queue spec); e2e not run locally by design — CI verifies against an isolated stack
## v0.25.0 — 2026-09-20 10:36
- **Planes now hold a queue of upcoming flight legs (ISS-043, part 1 — backend).** One new column on `planes`: `queue TEXT NOT NULL DEFAULT '[]'` (`lib/db/schema.ts`, DDL + guarded `ALTER TABLE` migration in `lib/db/client.ts`) storing an ordered JSON array of ICAO strings = the legs NOT yet started. The active leg stays exactly as before (`destIcao` + `departureSimTime`); full path = `[locationIcao, destIcao, ...queue]`. No new table — queues are short, per-plane, always read/written whole
- **Auto-chain on landing (ISS-043).** In `deliverPackages()` (`lib/sim/engine.ts`) a plane that arrives now reads its queue: if it holds legs AND fuel covers the first one (same `requiredFuelFor` feasibility rule as `/fly`), it immediately starts that leg from the arrival airport — stays `'flying'`, `departureSimTime` = the arrival instant, queue shortened. If fuel is short it grounds safely with the remaining queue kept intact for refuel + re-dispatch; an empty queue grounds exactly as before. Cargo delivery for the reached airport runs first and is unaffected by chaining. Ordering preserved on purpose: delivery runs BEFORE tickOnce's pause check (ISS-041), so a chained plane counts as still flying there — chaining never triggers the last-plane pause
- **New endpoints (ISS-043).** `POST /api/planes/{planeId}/queue` body `{destIcao}` appends a not-yet-started leg (works grounded or flying — pre-build a route; nothing auto-starts, 400 unknown ICAO/bad body, 404 unknown plane); `POST /api/planes/{planeId}/queue/cancel` body `{index}` removes a queued leg by index (only queued legs are cancellable — the active in-flight leg is never touched; 400 non-integer/out-of-range index, 404 unknown plane). Both run through `performAction()` like every other action and return the full state snapshot. New actions `queueLeg`/`cancelQueuedLeg` in `lib/game/actions.ts`; routes under `app/api/planes/[planeId]/queue/`
- **Snapshot + spec (ISS-043).** `PlaneState.queue: string[]` added to the `/api/state` snapshot (`lib/game/state.ts`, parsed with a null/malformed-safe helper shared by the engine); OpenAPI updated — `PlaneState.queue`, `QueueLegRequest`, `CancelQueuedLegRequest`, both new paths (200→snapshot, 400, 404)
- Tests: unit — `lib/sim/engine.test.ts`: auto-chain block in `deliverPackages` (lands with queue + enough fuel → `'flying'` to the next dest at the arrival instant with the queue shortened; lands short on fuel → grounds with the queue intact; empty queue → unchanged grounding) plus a pause-on-landing regression guard — a chained plane does NOT trigger the last-plane pause. `lib/game/actions.test.ts`: `queueLeg` (appends in order, works while flying without touching the active leg, 404 unknown plane, 400 unknown ICAO) and `cancelQueuedLeg` (removes the right index keeping order, 400 out-of-range/negative/empty-queue, 404 unknown plane). `lib/openapi.test.ts`: endpoint count ten → twelve, PlaneState keys + both request schemas. Type-compat only: `queue: []` added to the `PlaneState`/seed fixtures in `deriveEvents`, `right-pane`, `mapData`, `seed` tests
- Part 1 of ISS-043 (backend) — UI/map rendering of queued legs lands in a follow-up part. Verified: `tsc --noEmit` clean; unit suite green locally; `npx playwright test --list` unchanged at 42 tests in 12 files; e2e not run locally by design — CI verifies against an isolated stack
## v0.24.0 — 2026-09-20 09:48
- **Landing now pauses the sim instead of auto-slowing to 1× (ISS-041).** When the LAST plane in flight lands, `tickOnce` (`lib/sim/engine.ts`) sets `sim_state.paused = true` WITHOUT touching `timeScale` — speed is preserved. The trigger condition carries over from ISS-011's auto-slow unchanged: ≥1 landing this tick AND no `'flying'` planes remain (a landing while another plane is still airborne does not pause — there would be nothing to auto-resume it). No idle-enforcement or auto-restore of the pause exists
- **Starting a flight auto-resumes a paused sim (ISS-041).** `flyPlane()` (`lib/game/actions.ts`) writes `sim_state.paused = false` before persisting — inside `runAction()`, so tick + unpause + dispatch are atomic under the sim lock. The cycle: dispatch unpauses → last landing pauses (speed preserved) → next dispatch unpauses again
- Tests: unit — `lib/sim/engine.test.ts`: the auto-slow describe block rewritten for pause semantics (last plane lands → `paused === true` AND stored `timeScale` UNCHANGED, persisted on the DB row; a landing while another plane is still flying → no pause; last landing at scale 1 → pauses anyway; paused tick with a fully grounded fleet → trigger never fires). `lib/game/actions.test.ts`: new test — dispatching on a paused sim clears `paused` and leaves `timeScale` untouched. E2E — `e2e/auto-slow.spec.ts` renamed to `e2e/pause-on-landing.spec.ts` and reworked: after the starter lands, asserts `simState.paused === true` AND `timeScale` still 1000 (NOT reset), then a subsequent `/fly` dispatch clears `paused` on its own post-action snapshot. Cross-cutting fix for the new pause: every per-spec `setScale` helper (`actions`, `hover`, `map-interactions`, `relocation`, `right-pane`) now POSTs `{ timeScale, paused: false }` — a landing pauses the sim, so a scale-only fast-forward after one would leave the clock frozen and hang the next wait; no caller relied on it NOT clearing pause (verified per call site). Deliberate mid-test pauses (`sim.spec.ts` / `panes.spec.ts` freeze tests, marker-click freezes) are untouched
- Verified: `tsc --noEmit` clean; unit suite green locally (291); `npx playwright test --list` = 42 tests in 12 files; e2e not run locally by design — CI verifies against an isolated stack
## v0.23.2 — 2026-09-20 09:15
- **On-board package hover previews the route from the carrier's current position (ISS-040).** Hovering a loaded package in the left pane used to draw the great-circle trace from the package's ORIGIN airport; it now starts at the carrying plane's live lat/lon — identical while grounded at the pickup, correct once the plane has departed with cargo still loaded. New pure builder `hoverTraceGeoJson(from: AirportCoord, toIcao, airportsByIcao)` in `lib/mapData.ts` (sibling of `hoverRouteGeoJson`, same shape/conventions; empty collection when the destination is missing); `components/Map.tsx`'s `applyHoverRef` looks up the carrier via the hover's `planeId` and uses it, falling back to the ICAO trace when no carrier is in state. The `[state]` effect now also re-applies the hover so the trace follows a moving carrier between polls while hovered (idempotent: one small setData + ring filters). Endpoint rings untouched — still ICAO-filtered origin/dest circles
- Tests: unit — `lib/mapData.test.ts` gains a `hoverTraceGeoJson` block (exact endpoints from an arbitrary start coord; empty on missing dest). E2E — `e2e/hover.spec.ts`: new test loads a package at EDDF, dispatches the starter to LEMD via API, waits until its live position is >0.2° away from the hub, pauses the sim, hovers the on-board row and asserts some rendered trace segment starts within EPS of the carrier's live lat/lon AND some ends within EPS of LEMD AND no segment starts near EDDF (per-tile clipping handled via the existing cross-segment endpoint pattern); cleanup resumes + fast-forwards to arrival (auto-delivery) and flies home
## v0.23.1 — 2026-09-20 08:42
- **Toasts repositioned to the map's right edge (ISS-039).** The `.toasts` stack was `position: fixed` at the viewport bottom-right, overlapping the 280px right pane; its `right` changed 12px → 292px (= 280px right pane + 12px inset) so it now sits flush against the map's right edge with the same 12px margin. No JSX/endpoint changes — all positioning lives in that one CSS value
- Tests: e2e wording only — `toasts.spec.ts` header comment, test title, and toast visibility assertion message updated from "bottom-right" to "map right edge / clear of the right pane"; no position assertions added (none existed), suite unchanged at 41 tests in 12 files
- Verified: `tsc --noEmit` clean; unit suite green locally (288); `npx playwright test --list` = 41 tests in 12 files; e2e not run locally by design — CI verifies against an isolated stack
## v0.23.0 — 2026-09-20 08:25
- **Refuel-to-full row moves to the left pane (ISS-035).** Refueling a grounded plane is "anything with our plane", so it belongs in the left pane next to Relocate, not as a market row on the right. The `.market-row` JSX (`market-refuel-none` "All tanks full." span / `market-refuel-plane` select of grounded planes labeled `{planeName} — {fuel}/{maxFuel} L`, `market-refuel-cost` preview, `market-refuel` button → `POST /api/planes/{id}/refuel`) moved verbatim from the Market section in `components/RightPane.tsx` into a new "Refuel" `<section data-testid="refuel-section">` in `components/LeftPane.tsx`, inserted between Relocate and Time. Its state (`refuelPlaneId`) + derived values (`refuelable`, effective-selection `refuelPlane`, `refuelCost`) moved with it, reusing LeftPane's existing `grounded` list; busy gating rewired from RightPane's `busy` to LeftPane's own `useGameAction` instance (`actionsBusy`). Testids kept byte-identical so all e2e locators are unchanged. The per-package "Refuel" button in the On board section (`refuel-for-{pkg.id}`, mode 'forFlight') is a different feature and stays put; exported helpers `slotsUsedFor` / `loadEligiblePlanes` untouched
- Tests: e2e — `right-pane.spec.ts`: the market test keeps its buy/sell assertions, renamed to reflect that the refuel row now renders inside `[data-testid="left-pane"]` (asserted before the click); functional cash/tank assertions unchanged; the for-leg test's title/comment updated from "market button" to left pane. `panes.spec.ts`: first test gains structural assertions — Refuel heading + `market-refuel` visible in the left pane, both absent from the right pane (same present-in-left / absent-from-right pattern as On board). No unit-test changes (suite stays at 288)
- Verified: `tsc --noEmit` clean; unit suite green locally (288); `npx playwright test --list` = 41 tests in 12 files; e2e not run locally by design — CI verifies against an isolated stack
## v0.22.2 — 2026-09-20 07:32 ## v0.22.2 — 2026-09-20 07:32
- Docs: ORCHESTRATOR.md successor-agent manual committed to the repo; its token reference now points to the gitignored local .env (GITEA_TOKEN, rotated after the Sept 2026 incident); §9 rewritten as durable 'how to find current state' guidance instead of a stale snapshot; no code changes - Docs: ORCHESTRATOR.md successor-agent manual committed to the repo; its token reference now points to the gitignored local .env (GITEA_TOKEN, rotated after the Sept 2026 incident); §9 rewritten as durable 'how to find current state' guidance instead of a stale snapshot; no code changes
+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));
}
+5 -4
View File
@@ -1,10 +1,11 @@
import { loadPackage, performAction } from '@/lib/game/actions'; import { loadPackage, performAction } from '@/lib/game/actions';
// POST /api/packages/:id/load (v0.12.0) — body { planeId: string }. Loads an OPEN // POST /api/packages/:id/load (v0.12.0) — body { planeId: string }. Loads an OPEN
// package onto a GROUNDED plane at the package's origin with a free slot. 400 // package onto a GROUNDED plane at the package's origin with enough payload room
// missing/non-string planeId or unknown fields; 404 unknown package/plane id; 409 // for its weight (v0.27.0). 400 missing/non-string planeId or unknown fields; 404
// wrong package status, plane flying, plane not at the origin, no free slots. On // unknown package/plane id; 409 wrong package status, plane flying, plane not at
// success returns the full state snapshot (package now "loaded" on that plane). // the origin, no payload room. On success returns the full state snapshot
// (package now "loaded" on that plane).
export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) { export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params; const { id } = await ctx.params;
@@ -0,0 +1,34 @@
import { cancelQueuedLeg, performAction } from '@/lib/game/actions';
// POST /api/planes/:planeId/queue/cancel (v0.25.0, ISS-043) — body { index: number }.
// Removes a NOT-yet-started leg from the plane's queue by index; only queued legs
// are cancellable, the active in-flight leg is never affected. 400 missing/
// non-integer index, unknown fields, or an index outside [0, queue.length); 404
// unknown plane id. On success returns the full state snapshot (plane.queue updated).
export async function POST(req: Request, ctx: { params: Promise<{ planeId: string }> }) {
const { planeId } = await ctx.params;
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 !== 'index') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
if (!Number.isInteger(input.index)) {
return Response.json({ error: 'index must be an integer' }, { status: 400 });
}
const index = input.index as number;
return performAction((inst) => cancelQueuedLeg(inst, planeId, index));
}
+35
View File
@@ -0,0 +1,35 @@
import { performAction, queueLeg } from '@/lib/game/actions';
// POST /api/planes/:planeId/queue (v0.25.0, ISS-043) — body { destIcao: string }.
// Appends a NOT-yet-started leg to the plane's flight queue; works whether the
// plane is grounded or flying (pre-building a route). Nothing auto-starts here —
// queued legs chain on landing when fuel covers them. 400 missing/non-string
// destIcao, unknown fields, or an ICAO not in the airport dataset; 404 unknown
// plane id. On success returns the full state snapshot (plane.queue updated).
export async function POST(req: Request, ctx: { params: Promise<{ planeId: string }> }) {
const { planeId } = await ctx.params;
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 !== 'destIcao') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
if (typeof input.destIcao !== 'string' || input.destIcao.length === 0) {
return Response.json({ error: 'destIcao must be a non-empty string' }, { status: 400 });
}
const destIcao = input.destIcao;
return performAction((inst) => queueLeg(inst, planeId, destIcao));
}
+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);
+2 -2
View File
@@ -58,7 +58,7 @@ describe('GET /api/state', () => {
const packages = body.packages as Array<Record<string, unknown>>; const packages = body.packages as Array<Record<string, unknown>>;
expect(packages.length).toBeGreaterThan(0); expect(packages.length).toBeGreaterThan(0);
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'deadlineSimTime', 'status']) { for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'deadlineSimTime', 'status']) {
expect(packages[0], `package.${key}`).toHaveProperty(key); expect(packages[0], `package.${key}`).toHaveProperty(key);
} }
}); });
@@ -86,7 +86,7 @@ describe('GET /api/state', () => {
expect(entry.maxFuel).toBe(type.maxFuel); expect(entry.maxFuel).toBe(type.maxFuel);
expect(entry.burnPerKm).toBe(type.burnPerKm); expect(entry.burnPerKm).toBe(type.burnPerKm);
expect(entry.speedKmh).toBe(type.speedKmh); expect(entry.speedKmh).toBe(type.speedKmh);
expect(entry.capacity).toBe(type.capacity); // v0.12.0 slot capacity (ATR 72-600: 6) expect(entry.maxPayloadKg).toBe(type.maxPayloadKg); // v0.27.0 payload kg (ATR 72-600: 4800)
} }
// Economy constants — the single source of truth stays lib/game/catalog.ts. // Economy constants — the single source of truth stays lib/game/catalog.ts.
+8 -2
View File
@@ -14,11 +14,13 @@ import { tickNow } from '@/lib/sim/engine';
// same gate, same conventions. // same gate, same conventions.
// //
// POST /api/test/create-package // POST /api/test/create-package
// body { originIcao?: string, destIcao?: string, deadlineOffsetMs?: number } // body { originIcao?: string, destIcao?: string, deadlineOffsetMs?: number, weightKg?: number }
// - ICACOs must exist in the airport dataset and differ (400 otherwise); // - ICACOs must exist in the airport dataset and differ (400 otherwise);
// omitted endpoints are picked at random by generatePackage. // omitted endpoints are picked at random by generatePackage.
// - deadlineOffsetMs defaults to a normal 6–72h game-time window (NOT the short // - deadlineOffsetMs defaults to a normal 6–72h game-time window (NOT the short
// 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
// flows that load several packages can stay under the plane's payload deterministically.
// Returns 201 + the created package row. // Returns 201 + the created package row.
export async function POST(req: Request) { export async function POST(req: Request) {
@@ -38,7 +40,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') { if (key !== 'originIcao' && key !== 'destIcao' && key !== 'deadlineOffsetMs' && key !== 'weightKg') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 }); return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
} }
} }
@@ -50,6 +52,9 @@ export async function POST(req: Request) {
if (input.deadlineOffsetMs !== undefined && (typeof input.deadlineOffsetMs !== 'number' || !Number.isFinite(input.deadlineOffsetMs))) { if (input.deadlineOffsetMs !== undefined && (typeof input.deadlineOffsetMs !== 'number' || !Number.isFinite(input.deadlineOffsetMs))) {
return Response.json({ error: 'deadlineOffsetMs must be a finite number' }, { status: 400 }); return Response.json({ error: 'deadlineOffsetMs must be a finite number' }, { status: 400 });
} }
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 });
}
try { try {
const inst = await getGameDb(); const inst = await getGameDb();
@@ -79,6 +84,7 @@ export async function POST(req: Request) {
deadlineOffsetMs: input.deadlineOffsetMs as number | undefined, deadlineOffsetMs: input.deadlineOffsetMs as number | undefined,
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,
}); });
await inst.db.insert(schema.packages).values(created); await inst.db.insert(schema.packages).values(created);
await persist(inst); await persist(inst);
+67 -4
View File
@@ -103,11 +103,11 @@ body {
} }
/* v0.9.0 — single-screen 3-pane shell: left Assets / center map / right Jobs & Market. /* v0.9.0 — single-screen 3-pane shell: left Assets / center map / right Jobs & Market.
ISS-021 (v0.20.0): left widened to 360px (it now carries the On board section), right ISS-021 (v0.20.0): left widened to 360px (it now carries the On board section).
narrowed to 280px — total fixed width stays 640px, so the map column is unchanged. */ ISS-046 (v0.27.1): right widened to match left at 360px — total fixed width 720px, map column shrinks by 80px. */
.app-shell { .app-shell {
display: grid; display: grid;
grid-template-columns: 360px minmax(0, 1fr) 280px; grid-template-columns: 360px minmax(0, 1fr) 360px;
height: 100%; height: 100%;
} }
@@ -416,11 +416,36 @@ body {
gap: 6px; gap: 6px;
} }
/* ISS-043 (v0.26.0) — flight-queue sub-section inside on-board blocks */
.queue-subsection {
margin-top: 8px;
padding-top: 6px;
border-top: 1px solid rgba(148, 163, 184, 0.2);
display: flex;
flex-direction: column;
gap: 5px;
}
.queue-heading {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
}
.queue-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
/* v0.17.0 — ISS-008: bottom-right toast stack (action errors + game events) */ /* v0.17.0 — ISS-008: bottom-right toast stack (action errors + game events) */
.toasts { .toasts {
position: fixed; position: fixed;
bottom: 12px; bottom: 12px;
right: 12px; /* 360px right pane + 12px inset — sits flush against the map's right edge, clear of the right pane */
right: 372px;
z-index: 50; /* above map popups/controls and the app header */ z-index: 50; /* above map popups/controls and the app header */
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -482,3 +507,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;
}
+121 -8
View File
@@ -9,7 +9,7 @@ import { formatCash, formatDeadlineRelative, formatFuel, formatSimClock } from '
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';
import { slotsUsedFor } from './RightPane'; import { cargoWeightFor } from './RightPane';
import type { HoverTarget } from './hoverTypes'; import type { HoverTarget } from './hoverTypes';
interface LeftPaneProps { interface LeftPaneProps {
@@ -39,13 +39,12 @@ function planeLocation(p: PlaneState): string {
return p.locationName; return p.locationName;
} }
// Cargo loadout "n/capacity pkgs" — expired packages still occupy their slot. // Cargo loadout "X.Xt / Y.Yt" (v0.27.0) — used/max payload in tonnes, one decimal;
// expired packages still occupy their payload.
function loadoutFor(state: StateSnapshot, p: PlaneState): string { function loadoutFor(state: StateSnapshot, p: PlaneState): string {
const used = state.packages.filter( const used = cargoWeightFor(state, p.id);
(pkg) => pkg.planeId === p.id && (pkg.status === 'loaded' || pkg.status === 'expired'), const maxPayloadKg = state.catalog.find((e) => e.model === p.model)?.maxPayloadKg ?? PLANE_TYPES[p.model]?.maxPayloadKg ?? 0;
).length; return `${(used / 1000).toFixed(1)}t / ${(maxPayloadKg / 1000).toFixed(1)}t`;
const capacity = state.catalog.find((e) => e.model === p.model)?.capacity ?? PLANE_TYPES[p.model]?.capacity ?? 0;
return `${used}/${capacity} pkgs`;
} }
// ISS-001 — an optimistic slider value is cleared exactly when a rendered snapshot CONFIRMS it // ISS-001 — an optimistic slider value is cleared exactly when a rendered snapshot CONFIRMS it
@@ -102,6 +101,15 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
const [relocatePlaneId, setRelocatePlaneId] = useState<string | null>(null); const [relocatePlaneId, setRelocatePlaneId] = useState<string | null>(null);
const [relocateDestIcao, setRelocateDestIcao] = useState<string | null>(null); const [relocateDestIcao, setRelocateDestIcao] = useState<string | null>(null);
// ISS-035 (v0.23.0): Refuel-to-full selection — moved here from the right-pane market row; same
// "effective selection" pattern as Relocate above: the user's pick while it stays refuelable, else
// a sensible default (first grounded plane with fuel below max).
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
@@ -195,7 +203,11 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
// cargo or is mid-flight gets a block — its loaded/expired packages with fly-to-destination + // 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. // abandon controls, and flying planes show their current leg.
const clockMs = state?.simState.clockMs ?? 0; const clockMs = state?.simState.clockMs ?? 0;
const onboard = state ? state.planes.filter((p) => p.status === 'flying' || slotsUsedFor(state, p.id) > 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 GROUNDED plane can be repositioned to any airport via the
// existing fly endpoint (pure repositioning, no package needed since v0.12.0). Moved here from // existing fly endpoint (pure repositioning, no package needed since v0.12.0). Moved here from
@@ -205,6 +217,16 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
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
// 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.
const refuelable = grounded.filter((p) => p.fuel < p.maxFuel);
const refuelPlane =
(state?.planes.find((p) => p.id === refuelPlaneId && p.status === 'available' && p.fuel < p.maxFuel)) ??
refuelable[0] ??
null;
const refuelCost = refuelPlane && state ? (refuelPlane.maxFuel - refuelPlane.fuel) * state.fuelPricePerL : null;
// Slider commit (v0.9.1): the displayed value follows the drag optimistically via // Slider commit (v0.9.1): the displayed value follows the drag optimistically via
// pendingScale; POST /api/sim fires after a 250 ms debounce, or immediately when the // pendingScale; POST /api/sim fires after a 250 ms debounce, or immediately when the
// user releases the slider (browsers fire a native 'change' on release). The server's // user releases the slider (browsers fire a native 'change' on release). The server's
@@ -440,6 +462,53 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
</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> </div>
); );
}) })
@@ -507,6 +576,50 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
</div> </div>
</section> </section>
{/* ISS-035 (v0.23.0): moved here from the right-pane market row — it belongs with everything else
about our planes, next to Relocate. Plain section: no hover handlers (refuel is not a route). */}
<section className="pane-section" data-testid="refuel-section">
<h3 className="pane-heading">Refuel</h3>
{/* 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. */}
<div className="market-row">
{refuelable.length === 0 ? (
<span data-testid="market-refuel-none" className="pane-muted">
All tanks full.
</span>
) : (
<>
<select
data-testid="market-refuel-plane"
aria-label="Plane to refuel"
value={refuelPlane?.id ?? ''}
onChange={(e) => setRefuelPlaneId(e.target.value || null)}
>
{refuelable.map((p) => (
<option key={p.id} value={p.id}>
{PLANE_TYPES[p.model]?.name ?? p.model} — {Math.round(p.fuel)} / {p.maxFuel} L
</option>
))}
</select>
<span data-testid="market-refuel-cost" className="market-preview">
{refuelCost !== null ? formatCash(refuelCost) : ''}
</span>
</>
)}
<button
type="button"
data-testid="market-refuel"
className="pane-btn"
disabled={actionsBusy || refuelPlane === null}
onClick={() => {
if (refuelPlane) void runAction(`/api/planes/${refuelPlane.id}/refuel`);
}}
>
Refuel to full
</button>
</div>
</section>
<section className="pane-section"> <section className="pane-section">
<h3 className="pane-heading">Time</h3> <h3 className="pane-heading">Time</h3>
<p data-testid="sim-clock" className="sim-clock"> <p data-testid="sim-clock" className="sim-clock">
+42 -2
View File
@@ -8,7 +8,7 @@ import { DEFAULT_STYLE_ID, STYLES, STYLE_STORAGE_KEY, isStyleId, localAssetsAvai
import type { GeoJsonFeatureCollection } from '../lib/airports'; import type { GeoJsonFeatureCollection } from '../lib/airports';
import type { StateSnapshot } from '../lib/game/state'; import type { StateSnapshot } from '../lib/game/state';
import type { HoverTarget } from './hoverTypes'; import type { HoverTarget } from './hoverTypes';
import { hoverRouteGeoJson, planesGeoJson, routesGeoJson, type AirportCoord, type PlaneFeatureProps } from '../lib/mapData'; import { hoverRouteGeoJson, hoverTraceGeoJson, planesGeoJson, queuedRoutesGeoJson, routesGeoJson, type AirportCoord, type PlaneFeatureProps } from '../lib/mapData';
import { weatherFor } from '../lib/weather'; import { weatherFor } from '../lib/weather';
import { approxLocalTime } from '../lib/localTime'; import { approxLocalTime } from '../lib/localTime';
@@ -28,6 +28,10 @@ const PLANES_GROUNDED_LAYER_ID = 'planes-grounded';
const PLANES_FLYING_LAYER_ID = 'planes-flying'; const PLANES_FLYING_LAYER_ID = 'planes-flying';
const ROUTES_SOURCE_ID = 'routes'; const ROUTES_SOURCE_ID = 'routes';
const ROUTES_LEGS_LAYER_ID = 'routes-legs'; const ROUTES_LEGS_LAYER_ID = 'routes-legs';
// ISS-043 (v0.26.0): the dotted queue line — a flying plane's NOT-yet-started legs
// (dest → queue[0] → …) as their own source + layer, drawn under everything else dynamic.
const QUEUE_ROUTES_SOURCE_ID = 'queue-routes';
const QUEUE_ROUTES_LAYER_ID = 'queue-routes';
// ISS-002 (v0.15.0): top-most hover-highlight layers, added after the base layers so they // ISS-002 (v0.15.0): top-most hover-highlight layers, added after the base layers so they
// render above them. Each starts filtered to match nothing; applyHoverRef drives the filters. // render above them. Each starts filtered to match nothing; applyHoverRef drives the filters.
const PLANES_HIGHLIGHT_LAYER_ID = 'planes-highlight'; const PLANES_HIGHLIGHT_LAYER_ID = 'planes-highlight';
@@ -95,11 +99,19 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
if (routesSource && airportsIndexRef.current) { if (routesSource && airportsIndexRef.current) {
void routesSource.setData(routesGeoJson(st, airportsIndexRef.current)); void routesSource.setData(routesGeoJson(st, airportsIndexRef.current));
} }
// ISS-043 (v0.26.0): the dotted queue line — same airport-index guard as the solid legs above.
const queueRoutesSource = map.getSource(QUEUE_ROUTES_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
if (queueRoutesSource && airportsIndexRef.current) {
void queueRoutesSource.setData(queuedRoutesGeoJson(st.planes, airportsIndexRef.current));
}
}; };
useEffect(() => { useEffect(() => {
stateRef.current = state ?? null; stateRef.current = state ?? null;
applyDynamicRef.current(); applyDynamicRef.current();
// ISS-040 (v0.23.2): re-apply the hover too, so an on-board package's trace follows a moving
// carrier between polls while hovered — idempotent and cheap (one small setData + ring filters).
applyHoverRef.current();
}, [state]); }, [state]);
// ISS-002 (v0.15.0): hover highlight — same ref + effect pattern as the snapshot above. // ISS-002 (v0.15.0): hover highlight — same ref + effect pattern as the snapshot above.
@@ -138,7 +150,14 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
// ICAOs (package origin→dest / relocate fromIcao→toIcao); empty when either endpoint is unknown. // ICAOs (package origin→dest / relocate fromIcao→toIcao); empty when either endpoint is unknown.
const hoverSource = map.getSource(HOVER_ROUTES_SOURCE_ID) as maplibregl.GeoJSONSource | undefined; const hoverSource = map.getSource(HOVER_ROUTES_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
if (hoverSource) { if (hoverSource) {
void hoverSource.setData(hoverRouteGeoJson(fromIcao, toIcao, airportsIndexRef.current ?? new Map())); // ISS-040 (v0.23.2): an on-board package's trace starts from the carrier plane's LIVE position,
// not its origin airport — identical to before while grounded at the pickup, different once it
// has departed with cargo still loaded. No carrier in state yet → fall back to the ICAO trace.
const idx = airportsIndexRef.current ?? new Map();
const carrier = h?.kind === 'package' && h.planeId ? stateRef.current?.planes.find((p) => p.id === h.planeId) : undefined;
void hoverSource.setData(
carrier ? hoverTraceGeoJson({ lat: carrier.lat, lon: carrier.lon }, toIcao, idx) : hoverRouteGeoJson(fromIcao, toIcao, idx),
);
} }
// Package hovers ring BOTH endpoints (v0.15.0 behavior); 'route' hovers ring the TARGET only — // Package hovers ring BOTH endpoints (v0.15.0 behavior); 'route' hovers ring the TARGET only —
// the relocation's destination is what ISS-027 asks to see. Plane hovers ring nothing. // the relocation's destination is what ISS-027 asks to see. Plane hovers ring nothing.
@@ -347,6 +366,27 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
}, },
}); });
} }
// ISS-043 (v0.26.0): dotted queue line — added after routes-legs but BEFORE the hover
// layers so a hovered trace still draws on top of it. Muted slate + fine dots reads as
// "planned/future", distinct from the solid amber active leg and the [2,2] yellow hover dash.
if (!map.getSource(QUEUE_ROUTES_SOURCE_ID)) {
map.addSource(QUEUE_ROUTES_SOURCE_ID, {
type: 'geojson',
data: queuedRoutesGeoJson(stateRef.current?.planes ?? [], airportsIndexRef.current ?? new Map()),
});
}
if (!map.getLayer(QUEUE_ROUTES_LAYER_ID)) {
map.addLayer({
id: QUEUE_ROUTES_LAYER_ID,
type: 'line',
source: QUEUE_ROUTES_SOURCE_ID,
paint: {
'line-color': '#64748b', // muted slate — planned legs, not actively flying
'line-width': 3,
'line-dasharray': [0.1, 1.5], // dotted — distinct from solid legs and the [2,2] hover dash
},
});
}
if (!map.getSource(PLANES_SOURCE_ID)) { if (!map.getSource(PLANES_SOURCE_ID)) {
map.addSource(PLANES_SOURCE_ID, { type: 'geojson', data: planesGeoJson(stateRef.current) }); map.addSource(PLANES_SOURCE_ID, { type: 'geojson', data: planesGeoJson(stateRef.current) });
} }
+97 -56
View File
@@ -2,7 +2,8 @@
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 { maxRangeKm } from '@/lib/sim/movement';
import { formatCash, formatDeadlineRelative } from '@/lib/format'; import { formatCash, formatDeadlineRelative } from '@/lib/format';
import { useGameAction } from '@/lib/useGameAction'; import { useGameAction } from '@/lib/useGameAction';
import type { HoverTarget } from './hoverTypes'; import type { HoverTarget } from './hoverTypes';
@@ -23,23 +24,25 @@ interface RightPaneProps {
const planeName = (p: PlaneState): string => PLANE_TYPES[p.model]?.name ?? p.model; const planeName = (p: PlaneState): string => PLANE_TYPES[p.model]?.name ?? p.model;
// Packages occupying a plane's slots — 'loaded' cargo plus 'expired' cargo, which // Cargo weight (kg) occupying a plane's payload — 'loaded' cargo plus 'expired'
// keeps its slot until it is abandoned or the plane lands at its destination. // cargo, which keeps its space until it is abandoned or the plane lands at its
export function slotsUsedFor(state: StateSnapshot, planeId: string): number { // destination.
return state.packages.filter( export function cargoWeightFor(state: StateSnapshot, planeId: string): number {
(p) => p.planeId === planeId && (p.status === 'loaded' || p.status === 'expired'), return state.packages
).length; .filter((p) => p.planeId === planeId && (p.status === 'loaded' || p.status === 'expired'))
.reduce((sum, p) => sum + p.weightKg, 0);
} }
// Planes that can load `pkg` right now: GROUNDED at the package's origin with a // Planes that can load `pkg` right now: GROUNDED at the package's origin with enough
// free slot (capacity from the snapshot catalog). The Load button is enabled iff // payload room for it (maxPayloadKg from the snapshot catalog). The Load button is
// this list is non-empty; exactly one entry loads directly, several show a picker. // enabled iff this list is non-empty; exactly one entry loads directly, several show a picker.
export function loadEligiblePlanes(state: StateSnapshot, pkg: PackageState): PlaneState[] { export function loadEligiblePlanes(state: StateSnapshot, pkg: PackageState): PlaneState[] {
return state.planes.filter((p) => { return state.planes.filter((p) => {
if (p.status !== 'available') return false; if (p.status !== 'available') return false;
if (p.locationIcao !== pkg.originIcao) return false; if (p.locationIcao !== pkg.originIcao) return false;
const capacity = state.catalog.find((e) => e.model === p.model)?.capacity ?? 0; const maxPayloadKg = state.catalog.find((e) => e.model === p.model)?.maxPayloadKg ?? 0;
return slotsUsedFor(state, p.id) < capacity; // Mirrors the server check in lib/game/actions.ts loadPackage.
return cargoWeightFor(state, p.id) + pkg.weightKg <= maxPayloadKg;
}); });
} }
@@ -116,10 +119,14 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
// user's pick while it stays valid, else a sensible default (first catalog entry / first plane). // user's pick while it stays valid, else a sensible default (first catalog entry / first plane).
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);
const [refuelPlaneId, setRefuelPlaneId] = 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
// left pane's "Refuel" section next to Relocate.
if (loading && !state) { if (loading && !state) {
return ( return (
@@ -139,20 +146,39 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
const grounded = state.planes.filter((p) => p.status === 'available'); const grounded = state.planes.filter((p) => p.status === 'available');
const sellPlane = state.planes.find((p) => p.id === sellPlaneId && p.status === 'available') ?? grounded[0] ?? null; const sellPlane = state.planes.find((p) => p.id === sellPlaneId && p.status === 'available') ?? grounded[0] ?? null;
const refuelable = grounded.filter((p) => p.fuel < p.maxFuel);
const refuelPlane =
state.planes.find((p) => p.id === refuelPlaneId && p.status === 'available' && p.fuel < p.maxFuel) ??
refuelable[0] ??
null;
// Previews computed client-side from the snapshot's catalog + economy constants (v0.10.0). // Previews computed client-side from the snapshot's catalog + economy constants (v0.10.0).
const sellEntry = sellPlane ? catalog.find((e) => e.model === sellPlane.model) : undefined; const sellEntry = sellPlane ? catalog.find((e) => e.model === sellPlane.model) : undefined;
const sellPayout = sellEntry ? Math.round(sellEntry.price * state.sellFraction * 100) / 100 : null; const sellPayout = sellEntry ? Math.round(sellEntry.price * state.sellFraction * 100) / 100 : null;
const refuelCost = refuelPlane ? (refuelPlane.maxFuel - refuelPlane.fuel) * state.fuelPricePerL : null;
return ( return (
<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 ? (
@@ -184,6 +210,16 @@ 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>
@@ -198,9 +234,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>
@@ -262,45 +300,48 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
</button> </button>
</div> </div>
<div className="market-row">
{refuelable.length === 0 ? (
<span data-testid="market-refuel-none" className="pane-muted">
All tanks full.
</span>
) : (
<>
<select
data-testid="market-refuel-plane"
aria-label="Plane to refuel"
value={refuelPlane?.id ?? ''}
onChange={(e) => setRefuelPlaneId(e.target.value || null)}
>
{refuelable.map((p) => (
<option key={p.id} value={p.id}>
{planeName(p)} — {Math.round(p.fuel)} / {p.maxFuel} L
</option>
))}
</select>
<span data-testid="market-refuel-cost" className="market-preview">
{refuelCost !== null ? formatCash(refuelCost) : ''}
</span>
</>
)}
<button
type="button"
data-testid="market-refuel"
className="pane-btn"
disabled={busy || refuelPlane === null}
onClick={() => {
if (refuelPlane) void runAction(`/api/planes/${refuelPlane.id}/refuel`);
}}
>
Refuel to full
</button>
</div>
{/* 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. */}
{/* 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>
+29 -22
View File
@@ -1,14 +1,14 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { loadEligiblePlanes, slotsUsedFor } from './RightPane'; import { cargoWeightFor, loadEligiblePlanes } from './RightPane';
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state'; import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
// v0.12.0 — the package-model picker rules: a plane can LOAD a package only while // v0.12.0 (weight model v0.27.0) — the package-model picker rules: a plane can LOAD a
// it is GROUNDED at the package's origin with a free slot (capacity from the // package only while it is GROUNDED at the package's origin with enough payload room for
// snapshot catalog; expired packages still occupy theirs). The Load button acts // it (maxPayloadKg from the snapshot catalog; expired packages still occupy theirs). The
// directly on the single eligible plane, or shows a picker when several qualify. // Load button acts directly on the single eligible plane, or shows a picker when several qualify.
const CATALOG = [ const CATALOG = [
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, capacity: 6 }, { model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, maxPayloadKg: 4_800 },
]; ];
function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState { function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
@@ -24,6 +24,7 @@ function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
departureSimTime: null, departureSimTime: null,
lat: 50, lat: 50,
lon: 8.5, lon: 8.5,
queue: [],
...over, ...over,
}; };
} }
@@ -37,6 +38,7 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
reward: 1000, reward: 1000,
cargoType: 'mail', cargoType: 'mail',
cargoCount: 4, cargoCount: 4,
weightKg: 1_000,
deadlineSimTime: 1_000_000, deadlineSimTime: 1_000_000,
status: 'open', status: 'open',
planeId: null, planeId: null,
@@ -57,27 +59,27 @@ function snap(planes: PlaneState[], packages: PackageState[] = []): StateSnapsho
}; };
} }
describe('slotsUsedFor', () => { describe('cargoWeightFor', () => {
it('counts loaded AND expired packages bound to the plane (both occupy a slot)', () => { it('sums the weight of loaded AND expired packages bound to the plane (both occupy payload)', () => {
const s = snap( const s = snap(
[plane({ id: 'p1' })], [plane({ id: 'p1' })],
[ [
pkg({ id: 'a', status: 'loaded', planeId: 'p1' }), pkg({ id: 'a', status: 'loaded', planeId: 'p1', weightKg: 800 }),
pkg({ id: 'b', status: 'expired', planeId: 'p1' }), pkg({ id: 'b', status: 'expired', planeId: 'p1', weightKg: 500 }),
pkg({ id: 'c', status: 'open', planeId: null }), // not on any plane pkg({ id: 'c', status: 'open', planeId: null, weightKg: 9_000 }), // not on any plane
pkg({ id: 'd', status: 'delivered', planeId: 'p1' }), // terminal — slot long freed pkg({ id: 'd', status: 'delivered', planeId: 'p1', weightKg: 700 }), // terminal — payload long freed
], ],
); );
expect(slotsUsedFor(s, 'p1')).toBe(2); expect(cargoWeightFor(s, 'p1')).toBe(1_300);
}); });
it('is 0 for a plane with no bound cargo', () => { it('is 0 for a plane with no bound cargo', () => {
expect(slotsUsedFor(snap([plane({ id: 'p1' })]), 'p1')).toBe(0); expect(cargoWeightFor(snap([plane({ id: 'p1' })]), 'p1')).toBe(0);
}); });
}); });
describe('loadEligiblePlanes (Load button eligibility)', () => { describe('loadEligiblePlanes (Load button eligibility)', () => {
it('includes only grounded planes at the package origin with a free slot', () => { it('includes only grounded planes at the package origin with enough payload room', () => {
const s = snap([ const s = snap([
plane({ id: 'at-origin' }), // eligible plane({ id: 'at-origin' }), // eligible
plane({ id: 'elsewhere', locationIcao: 'LEMD' }), // wrong airport plane({ id: 'elsewhere', locationIcao: 'LEMD' }), // wrong airport
@@ -86,18 +88,23 @@ describe('loadEligiblePlanes (Load button eligibility)', () => {
expect(loadEligiblePlanes(s, pkg({ id: 'x' })).map((p) => p.id)).toEqual(['at-origin']); expect(loadEligiblePlanes(s, pkg({ id: 'x' })).map((p) => p.id)).toEqual(['at-origin']);
}); });
it('excludes a plane whose slots are full (capacity from the catalog)', () => { it('a plane with partial payload can still load a package that fits', () => {
const packages = Array.from({ length: 6 }, (_, i) => const s = snap([plane({ id: 'partial' })], [pkg({ id: 'onboard', status: 'loaded', planeId: 'partial', weightKg: 3_000 })]);
pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full' }), expect(loadEligiblePlanes(s, pkg({ id: 'x' })).map((p) => p.id)).toEqual(['partial']); // 3000 + 1000 ≤ 4800
});
it('excludes a plane whose payload is full (maxPayloadKg from the catalog)', () => {
const packages = Array.from({ length: 4 }, (_, i) =>
pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full', weightKg: 1_200 }), // 4 × 1200 = 4800
); );
const s = snap([plane({ id: 'full' })], packages); const s = snap([plane({ id: 'full' })], packages);
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]); expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
}); });
it('a full plane with one slot freed by an expired package still counts as full', () => { it('a plane at its limit with an expired package still counts as full', () => {
const packages = [ const packages = [
...Array.from({ length: 5 }, (_, i) => pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full' })), ...Array.from({ length: 4 }, (_, i) => pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full', weightKg: 960 })),
pkg({ id: 'exp', status: 'expired', planeId: 'full' }), // occupies its slot pkg({ id: 'exp', status: 'expired', planeId: 'full', weightKg: 960 }), // occupies its payload — 5 × 960 = 4800
]; ];
const s = snap([plane({ id: 'full' })], packages); const s = snap([plane({ id: 'full' })], packages);
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]); expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
@@ -113,7 +120,7 @@ describe('loadEligiblePlanes (Load button eligibility)', () => {
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]); expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
}); });
it('a plane with an unknown model has no capacity → never eligible', () => { it('a plane with an unknown model has no maxPayloadKg in the catalog → never eligible', () => {
const s = snap([plane({ id: 'mystery', model: 'mystery' })]); const s = snap([plane({ id: 'mystery', model: 'mystery' })]);
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]); expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
}); });
+27 -16
View File
@@ -53,8 +53,16 @@ async function getState(page: Page): Promise<StateBody> {
} }
// The e2e instance runs with FLIGHT_TEST_HOOKS=1 (docker-compose.yml) — the hook is live. // The e2e instance runs with FLIGHT_TEST_HOOKS=1 (docker-compose.yml) — the hook is live.
async function createPackage(page: Page, from?: string, to?: string, deadlineOffsetMs?: number): Promise<StateBody['packages'][number]> { // weightKg (v0.27.0) forces the package weight instead of the RNG draw, so flows that load
const res = await page.request.post('/api/test/create-package', { data: { originIcao: from, destIcao: to, deadlineOffsetMs } }); // several packages onto one plane stay under its payload deterministically.
async function createPackage(
page: Page,
from?: string,
to?: string,
deadlineOffsetMs?: number,
weightKg?: number,
): Promise<StateBody['packages'][number]> {
const res = await page.request.post('/api/test/create-package', { data: { originIcao: from, destIcao: to, deadlineOffsetMs, weightKg } });
expect(res.status()).toBe(201); expect(res.status()).toBe(201);
return (await res.json()) as StateBody['packages'][number]; return (await res.json()) as StateBody['packages'][number];
} }
@@ -66,8 +74,10 @@ 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
// resume it or the clock stays frozen and the next wait hangs.
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 } }); const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number }; const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale); expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
@@ -118,7 +128,7 @@ 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-011: the landing above auto-slown the sim to 1× — re-set SCALE 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.id}/fly`, { data: { destIcao: HUB } }); const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
@@ -225,8 +235,8 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
const refuelCost = Math.round((afterRefuel.maxFuel - landed.fuel) * FUEL_PRICE_PER_L * 100) / 100; const refuelCost = Math.round((afterRefuel.maxFuel - landed.fuel) * FUEL_PRICE_PER_L * 100) / 100;
expect(s.account.cash).toBeCloseTo(before.account.cash + pkg.reward - refuelCost, 6); expect(s.account.cash).toBeCloseTo(before.account.cash + pkg.reward - refuelCost, 6);
// Restore: fly home (repositioning — no package needed in the v0.12.0 model). ISS-011: the // Restore: fly home (repositioning — no package needed in the v0.12.0 model). ISS-041: the
// arrival above auto-slown the sim to 1×, so re-set SCALE for the return leg. // arrival above paused the sim, so re-set SCALE for the return leg (setScale clears pause).
await setScale(page, SCALE); await setScale(page, SCALE);
const homeRes = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } }); const homeRes = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
expect(homeRes.ok()).toBeTruthy(); expect(homeRes.ok()).toBeTruthy();
@@ -238,9 +248,10 @@ test('abandon while grounded deducts exactly the formula penalty', async ({ page
await flyHomeViaApi(page); await flyHomeViaApi(page);
const before = await getState(page); const before = await getState(page);
// Two packages at the hub — load both, abandon one through the UI. // Two packages at the hub — load both, abandon one through the UI. Forced weights keep
const pkgA = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000); // the pair under the starter's payload (v0.27.0) so both loads are deterministic.
const pkgB = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000); const pkgA = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000, 1_000);
const pkgB = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000, 1_000);
for (const p of [pkgA, pkgB]) { for (const p of [pkgA, pkgB]) {
const res = await page.request.post(`/api/packages/${p.id}/load`, { data: { planeId: 'plane-starter' } }); const res = await page.request.post(`/api/packages/${p.id}/load`, { data: { planeId: 'plane-starter' } });
expect(res.ok()).toBeTruthy(); expect(res.ok()).toBeTruthy();
@@ -280,8 +291,8 @@ test('expired packages deliver and abandon with no reward and no penalty', async
// destination differs from A's so it stays bound on the plane at LEMD arrival and can // destination differs from A's so it stays bound on the plane at LEMD arrival and can
// still be abandoned (an expired package whose dest matches the arrival airport is // still be abandoned (an expired package whose dest matches the arrival airport is
// unbound by the tick instead). // unbound by the tick instead).
const pkgA = await createPackage(page, HUB, 'LEMD', 10_000); // short game-time deadline const pkgA = await createPackage(page, HUB, 'LEMD', 10_000, 1_000); // short game-time deadline; forced weights keep the pair under payload (v0.27.0)
const pkgB = await createPackage(page, HUB, 'EHAM', 10_000); const pkgB = await createPackage(page, HUB, 'EHAM', 10_000, 1_000);
for (const p of [pkgA, pkgB]) { for (const p of [pkgA, pkgB]) {
const res = await page.request.post(`/api/packages/${p.id}/load`, { data: { planeId: 'plane-starter' } }); const res = await page.request.post(`/api/packages/${p.id}/load`, { data: { planeId: 'plane-starter' } });
expect(res.ok()).toBeTruthy(); expect(res.ok()).toBeTruthy();
@@ -314,8 +325,8 @@ test('expired packages deliver and abandon with no reward and no penalty', async
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('abandoned'); expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('abandoned');
expect(s.account.cash).toBe(cashBeforeAbandon); // free — expired abandons cost nothing expect(s.account.cash).toBe(cashBeforeAbandon); // free — expired abandons cost nothing
// Restore: refuel + fly home. ISS-011: the LEMD arrival above auto-slown the sim to 1×, so // Restore: refuel + fly home. ISS-041: the LEMD arrival above paused the sim, so
// re-set SCALE for the return leg (the expiry wait's scale no longer persists past a landing). // re-set SCALE for the return leg (setScale clears pause; the expiry wait's scale is kept).
await page.request.post('/api/planes/plane-starter/refuel'); await page.request.post('/api/planes/plane-starter/refuel');
await setScale(page, SCALE); await setScale(page, SCALE);
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
@@ -355,8 +366,8 @@ test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => {
res = await page.request.post(`/api/packages/${pkg.id}/abandon`); res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
expect(res.status()).toBe(409); expect(res.status()).toBe(409);
// Fly home from the hop so the fuel section starts at the hub. ISS-011: the hop arrival above // Fly home from the hop so the fuel section starts at the hub. ISS-041: the hop arrival above
// auto-slown the sim to 1×, so re-set SCALE for this leg. // paused the sim, so re-set SCALE for this leg (setScale clears pause).
await setScale(page, SCALE); await setScale(page, SCALE);
res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(res.ok()).toBeTruthy(); expect(res.ok()).toBeTruthy();
@@ -380,7 +391,7 @@ test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => {
const atFar = sFar.planes.find((p) => p.id === 'plane-starter')!; const atFar = sFar.planes.find((p) => p.id === 'plane-starter')!;
expect(atFar.locationIcao).toBe(FAR); expect(atFar.locationIcao).toBe(FAR);
// The return leg fits: the weighted burn over it is less than what's left in the tank. // The return leg fits: the weighted burn over it is less than what's left in the tank.
// ISS-011: the FAR arrival above auto-slown the sim to 1×, so re-set SCALE for this leg. // ISS-041: the FAR arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
await setScale(page, SCALE); await setScale(page, SCALE);
const backRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); const backRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(backRes.ok()).toBeTruthy(); expect(backRes.ok()).toBeTruthy();
+4 -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 ten 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,8 +26,11 @@ test('GET /api/openapi serves the spec with all ten endpoints and the package.js
'/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/cancel',
'/api/planes/{planeId}/refuel', '/api/planes/{planeId}/refuel',
'/api/sim', '/api/sim',
'/api/state', '/api/state',
+99 -4
View File
@@ -24,6 +24,8 @@ interface PlaneState {
maxFuel: number; maxFuel: number;
locationIcao: string; locationIcao: string;
destIcao: string | null; destIcao: string | null;
lat: number; // live position — interpolated per snapshot while flying, airport coords when grounded
lon: number;
} }
interface PackageState { interface PackageState {
@@ -53,9 +55,10 @@ async function postSim(page: Page, body: unknown) {
} }
// Set the time scale and VERIFY from the response that it stuck — a silent reset would // Set the time scale and VERIFY from the response that it stuck — a silent reset would
// otherwise surface as an unexplained landing-wait timeout. // otherwise surface as an unexplained landing-wait timeout. ISS-041 also clears pause: a
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
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 } }); const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number }; const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale); expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
@@ -93,7 +96,7 @@ async function flyHomeViaApi(page: Page): Promise<void> {
.toBe(true); .toBe(true);
} }
if (starter.locationIcao !== HUB) { if (starter.locationIcao !== HUB) {
// ISS-011: the landing above auto-slown the sim to 1× — re-set SCALE 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 } }); const res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
@@ -485,7 +488,99 @@ test('hovering an on-board block of a flying plane highlights its route', async
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }) }, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` })
.toBe(true); .toBe(true);
// ISS-011: the arrival above auto-slown the sim to 1×, so re-set SCALE for this leg. // ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
await setScale(page, SCALE);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200);
await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
timeout: 120_000,
message: 'waiting for plane-starter to land back at the hub',
})
.toBe(true);
});
test('hovering an on-board package while its carrier is mid-flight traces from current position', async ({ page }) => {
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
await flyHomeViaApi(page);
// Full tank before departure — the EDDF↔LEMD round trip burns ~4970 L of the 6300 L tank.
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
expect(refuel.status()).toBe(200);
await page.goto('/');
await waitForAirportCount(page);
// Load + fly via the API (same pattern as e2e/map-interactions.spec.ts), then speed up.
const pkg = await createPackage(page, HUB, DEST);
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
expect(load.status()).toBe(200);
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
expect(fly.status()).toBe(200);
await setScale(page, SCALE);
// Wait until the starter is GENUINELY mid-flight — its live position clearly away from EDDF in
// both axes (well past HOVER_ROUTE_EPS_DEG) so "the trace starts at the plane" and "it starts at
// the pickup airport" are distinguishable. The ~10 s leg at scale 1000 gives a wide window.
const hubLonLat = await airportLonLat(page, HUB);
await expect
.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
if (!p || p.status !== 'flying') return false;
return Math.abs(p.lon - hubLonLat[0]) > 0.2 && Math.abs(p.lat - hubLonLat[1]) > 0.2;
}, { timeout: 60_000, message: 'waiting for plane-starter to be clearly away from the hub' })
.toBe(true);
// Freeze mid-flight so the position is stable while we assert (same pause pattern as the
// flying-plane hover test above): paused, every /api/state snapshot reports the same lat/lon.
await postSim(page, { paused: true });
const row = page.locator(`[data-testid="onboard-package-${pkg.id}"]`);
await expect(row, 'the loaded package appears in the On board section').toBeVisible({ timeout: 15_000 });
// Both endpoints must be in-viewport for queryRenderedFeatures to see the trace.
await fitToAirports(page, [HUB, DEST]);
const live = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!;
const liveLonLat: [number, number] = [live.lon, live.lat]; // stable — the sim is paused
const destLonLat = await airportLonLat(page, DEST);
await row.hover();
// ISS-040 (v0.23.2): the trace starts at the carrier's LIVE position and ends at the destination,
// and NO segment may start near the pickup airport (the old origin-based behavior). Same per-tile
// clipping/quantization caveats as expectHoverRoute — check endpoints across all segments with EPS.
await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>;
const segs = feats.filter(
(f): f is { geometry: { type: 'LineString'; coordinates: [number, number][] } } =>
!!f.geometry && f.geometry.type === 'LineString' && Array.isArray(f.geometry.coordinates) && f.geometry.coordinates.length >= 2,
);
if (!segs.length) return false;
const near = (a: [number, number], b: [number, number]) =>
Math.abs(a[0] - b[0]) <= HOVER_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= HOVER_ROUTE_EPS_DEG;
const startsAtLive = segs.some((f) => near(f.geometry.coordinates[0], liveLonLat));
const endsAtDest = segs.some((f) => near(f.geometry.coordinates[f.geometry.coordinates.length - 1], destLonLat));
const noneStartsAtHub = !segs.some((f) => near(f.geometry.coordinates[0], hubLonLat));
return startsAtLive && endsAtDest && noneStartsAtHub;
}, { timeout: 15_000, message: 'waiting for the hover-route trace to run from the live position to the destination' }).toBe(true);
await unhover(page);
await expect.poll(async () => (await renderedFeatures(page, ['hover-route'])).length, {
timeout: 15_000,
message: 'waiting for the hover route trace to clear after mouseleave',
}).toBe(0);
// Resume and fast-forward to arrival — landing at DEST auto-delivers the package (v0.12.0), so
// flying home afterwards leaves the starter clean for the other specs.
await postSim(page, { timeScale: SCALE, paused: false });
await expect
.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'available' && p.locationIcao === DEST;
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` })
.toBe(true);
// ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
await setScale(page, SCALE); await setScale(page, SCALE);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200); expect(home.status()).toBe(200);
+3 -2
View File
@@ -69,9 +69,10 @@ async function postSim(page: Page, body: unknown) {
} }
// Set the time scale and VERIFY from the response that it stuck — a silent reset would // Set the time scale and VERIFY from the response that it stuck — a silent reset would
// otherwise surface as an unexplained landing-wait timeout. // otherwise surface as an unexplained landing-wait timeout. ISS-041 also clears pause: a
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
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 } }); const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number }; const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale); expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
+14 -36
View File
@@ -62,10 +62,10 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
await expect(row).toContainText(truth.locationName); await expect(row).toContainText(truth.locationName);
} }
// v0.12.0 loadout: "n/capacity pkgs" (capacity from the snapshot catalog — ATR 72-600 = 6). // v0.27.0 loadout: "X.Xt / Y.Yt" tonnage (used/max payload from the snapshot catalog — ATR 72-600 = 4.8t).
const loadout = page.getByTestId('plane-loadout-plane-starter'); const loadout = page.getByTestId('plane-loadout-plane-starter');
await expect(loadout).toBeVisible(); await expect(loadout).toBeVisible();
expect((await loadout.textContent()) ?? '').toMatch(/^\d+\/6 pkgs$/); expect((await loadout.textContent()) ?? '').toMatch(/^\d+\.\dt \/ \d+\.\dt$/);
// 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/);
@@ -75,41 +75,14 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
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 })).toBeVisible();
// …and no longer in the right pane. // …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);
});
test('time controls: pause freezes the sim and the ladder slider sets timeScale', async ({ page }) => { // ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane next to Relocate — its section
await page.goto('/'); // heading and button render here…
const pauseBtn = page.getByTestId('pause-toggle'); await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'Refuel', exact: true })).toBeVisible();
await expect(pauseBtn).toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId('left-pane').getByTestId('market-refuel')).toBeVisible();
// …and neither the heading nor the row remains in the right pane.
// Pause via the UI button — assert against the authoritative /api/state… await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0);
await pauseBtn.click(); await expect(page.getByTestId('right-pane').getByTestId('market-refuel')).toHaveCount(0);
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 }) => {
@@ -182,4 +155,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');
}); });
+384
View File
@@ -0,0 +1,384 @@
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
// Queue sub-section of its on-board block (add via airport select + button, cancel per leg), and
// 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
// the next queued leg server-side (part 1); test 3 exercises that chain end-to-end through the API.
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 DEST = 'LEMD'; // Madrid–Barajas — the typical long leg (~10 s at scale 1000)
const QUEUE = 'EHAM'; // Amsterdam — a third airport, distinct from hub and destination
interface PlaneState {
id: string;
status: 'available' | 'flying';
locationIcao: string;
destIcao: string | null;
queue: string[];
}
interface PackageState {
id: string;
originIcao: string;
destIcao: string;
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
planeId: string | null;
}
interface StateBody {
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
planes: PlaneState[];
packages: PackageState[];
}
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. ISS-041 also clears pause: a
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
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);
}
// Leave the live instance in a sane state for the other specs (scale 1, running).
test.afterEach(async ({ page }) => {
const res = await postSim(page, { timeScale: 1, paused: false });
expect(res.status()).toBe(200);
});
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
async function createPackage(page: Page, fromIcao: string, toIcao: string): Promise<PackageState> {
const created = await page.request.post('/api/test/create-package', {
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs: 24 * 3_600_000 },
});
expect(created.status()).toBe(201);
return (await created.json()) as PackageState;
}
// Self-heal: let any in-flight leg finish, reposition plane-starter home via the API, and abandon
// leftover cargo so every test starts from the canonical state even after an interrupted run.
async function flyHomeViaApi(page: Page): Promise<void> {
let s = await getState(page);
let plane = s.planes.find((p) => p.id === 'plane-starter')!;
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
await setScale(page, SCALE);
}
if (plane.status === 'flying') {
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
await expect
.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? ''), {
timeout: 240_000,
message: 'waiting for an in-flight plane-starter to land',
})
.toBe('available');
s = await getState(page);
plane = s.planes.find((p) => p.id === 'plane-starter')!;
}
if (plane.locationIcao !== HUB) {
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg fast-forwards.
await setScale(page, SCALE);
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
expect(res.status()).toBe(200); // 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,
message: 'waiting for plane-starter to land back at the hub',
})
.toBe(HUB);
}
// 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();
}
}
}
async function waitForAirportCount(page: Page): Promise<void> {
const countEl = page.getByTestId('airport-count');
await expect.poll(async () => {
const text = (await countEl.textContent()) ?? '';
const m = text.match(/(\d+)/);
return m ? parseInt(m[1], 10) : -1;
}).toBeGreaterThan(0);
}
// Rendered features of the given layers in the current viewport, [] until the map exists.
function renderedFeatures(
page: Page,
layers: string[],
): Promise<Array<{ type?: unknown; id?: unknown; properties?: unknown; geometry?: unknown }>> {
return page.evaluate((ls: string[]) => {
const m = (window as any).__flightMap;
if (!m) return [];
try {
// queryRenderedFeatures returns live GeoJSONFeature instances whose `geometry` is a lazy
// PROTOTYPE getter — Playwright's evaluate serialization copies only own enumerable props, so
// geometry silently vanishes across the page boundary. Map to plain objects IN-PAGE (ORCHESTRATOR §6).
return (m.queryRenderedFeatures(undefined, { layers: ls }) as Array<{
type?: unknown;
id?: unknown;
properties?: unknown;
geometry?: unknown;
}>).map((f) => ({
type: f.type,
id: f.id,
properties: f.properties,
geometry: f.geometry,
}));
} catch {
return [];
}
}, layers);
}
// Fit the map so all given airports are in-viewport — queryRenderedFeatures only returns features
// rendered inside the CURRENT viewport (same helper as e2e/hover.spec.ts).
async function fitToAirports(page: Page, icaos: string[]): Promise<void> {
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
const points = icaos.map((icao) => {
const f = fc.features.find((x) => x.properties.icao === icao);
if (!f) throw new Error(`airport ${icao} not found in /api/airports`);
return [f.properties.lon, f.properties.lat] as [number, number];
});
await page.evaluate((pts: [number, number][]) => {
const m = (window as any).__flightMap;
if (!m) throw new Error('map not ready');
const lons = pts.map((p) => p[0]);
const lats = pts.map((p) => p[1]);
// duration 0 → instant camera jump, no animation to wait out.
m.fitBounds(
[
[Math.min(...lons), Math.min(...lats)],
[Math.max(...lons), Math.max(...lats)],
],
{ padding: 40, duration: 0 },
);
}, points);
// Give the renderer a beat after the camera jump before asserting on rendered features.
await page.waitForTimeout(300);
}
// [lon, lat] of an airport from the same /api/airports dataset the map draws its lines from.
async function airportLonLat(page: Page, icao: string): Promise<[number, number]> {
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
const f = fc.features.find((x) => x.properties.icao === icao);
if (!f) throw new Error(`airport ${icao} not found in /api/airports`);
return [f.properties.lon, f.properties.lat];
}
// MapLibre stores GeoJSON geometry in per-tile INTEGER grids and clips long lines across tiles, so
// queryRenderedFeatures returns ONE FEATURE PER TILE the line crosses with coordinates quantized to
// the grid — check endpoints ACROSS all segments with an EPS (same pattern as expectHoverRoute in
// e2e/hover.spec.ts). ~5 km: above worst-case quantization at any zoom, below the distance between
// any two EU airports.
const QUEUE_ROUTE_EPS_DEG = 0.05;
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
// Full tank before departure — keeps the long EDDF→LEMD leg fuel-feasible (same as tests 2 & 3).
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
expect(refuel.status()).toBe(200);
const pkg = await createPackage(page, HUB, DEST);
await page.goto('/');
await waitForAirportCount(page);
// LOAD via the UI (right-pane job list) — the starter is grounded at the package's origin.
const loadBtn = page.getByTestId(`load-${pkg.id}`);
await expect(loadBtn, 'Load button enabled for a package at the hub').toBeEnabled({ timeout: 15_000 });
await loadBtn.click();
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
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.
const addSelect = page.getByTestId('queue-add-dest');
await expect(addSelect.locator(`option[value="${QUEUE}"]`)).toHaveCount(1, { timeout: 15_000 });
// START leg 1 via the UI (fly-to button in the on-board block).
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
await expect(flyBtn).toBeEnabled();
await flyBtn.click();
// The click fires the fly POST asynchronously — poll until it took effect server-side, then FREEZE
// immediately: at scale 1000 (left over from self-heal) the ~10 s leg would otherwise land under us.
await expect.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'flying' && p.destIcao === DEST;
}, { 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
// ADD a leg to the queue via the UI. The button is disabled until an airport is selected…
const addButton = page.getByTestId('queue-add');
await expect(addButton).toBeDisabled();
await addSelect.selectOption(QUEUE);
await expect(addButton).toBeEnabled();
await addButton.click();
// …and the new leg appears as queue-leg-0 with its destination.
const legRow = page.getByTestId('queue-leg-0');
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.
await page.getByTestId('queue-cancel-0').click();
await expect(page.getByTestId('queue-leg-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.
await setScale(page, SCALE);
await expect.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'available' && p.locationIcao === DEST;
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
// ISS-041: the arrival above paused the sim — setScale clears pause for the return leg.
await setScale(page, SCALE);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200);
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
timeout: 120_000,
message: 'waiting for plane-starter to land back at the hub',
}).toBe(HUB);
});
test('dotted queue line renders the queued path on the map (ISS-043 part 2)', async ({ page }) => {
test.setTimeout(300_000); // self-heal + one flight at scale 1000 + fly-home cleanup
await flyHomeViaApi(page);
// Full tank before departure — keeps the state canonical for whatever the cleanup does.
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
expect(refuel.status()).toBe(200);
// Load + queue + fly via the API: a package EDDF→LEMD, one queued leg LEMD→EHAM (works while
// grounded — part 1), then start leg 1. The dotted line must show ONLY dest → queue[0].
const pkg = await createPackage(page, HUB, DEST);
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
expect(load.status()).toBe(200);
const queueRes = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
expect(queueRes.status()).toBe(200);
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
expect(flyRes.status()).toBe(200);
// The endpoint's OWN post-action snapshot already shows the plane flying — no poll race.
const flyBody = (await flyRes.json()) as StateBody;
expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying');
// Freeze mid-flight so the ~10 s leg can't land under us while we assert.
await postSim(page, { paused: true });
await page.goto('/');
await waitForAirportCount(page);
// All line endpoints must be in-viewport for queryRenderedFeatures to see them.
await fitToAirports(page, [DEST, QUEUE]);
const destLL = await airportLonLat(page, DEST);
const queueLL = await airportLonLat(page, QUEUE);
const hubLL = await airportLonLat(page, HUB);
const near = (a: [number, number], b: [number, number]) =>
Math.abs(a[0] - b[0]) <= QUEUE_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= QUEUE_ROUTE_EPS_DEG;
await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['queue-routes'])) as Array<{
properties?: Record<string, unknown>;
geometry?: { type: string; coordinates: [number, number][] };
}>;
// Filter to real LineString segments — keeps this poll callback total (a throw inside
// expect.poll fails the test instantly instead of retrying). "None yet" → keep polling.
const segs = feats.filter(
(f): f is { properties?: Record<string, unknown>; geometry: { type: 'LineString'; coordinates: [number, number][] } } =>
!!f.geometry && f.geometry.type === 'LineString' && Array.isArray(f.geometry.coordinates) && f.geometry.coordinates.length >= 2,
);
if (!segs.length) return false;
// The line may be clipped into several per-tile segments — check the endpoints across all of them:
// some segment STARTS within EPS of DEST (the active leg's destination, NOT the plane's origin)…
const startsAtDest = segs.some((f) => near(f.geometry.coordinates[0], destLL));
// …and some ENDS within EPS of the queued airport.
const endsAtQueue = segs.some((f) => {
const c = f.geometry.coordinates;
return near(c[c.length - 1], queueLL);
});
// The active leg (HUB→DEST) must NOT be in this layer — no segment may start at the hub.
const noneStartsAtHub = !segs.some((f) => near(f.geometry.coordinates[0], hubLL));
// Every rendered feature belongs to plane-starter (the only queued plane).
const ownedByStarter = feats.every((f) => f.properties?.planeId === 'plane-starter');
return startsAtDest && endsAtQueue && noneStartsAtHub && ownedByStarter;
}, { timeout: 15_000, message: `waiting for the dotted queue line ${DEST} → ${QUEUE}` }).toBe(true);
// Cleanup: cancel the queued leg (so landing at DEST is a plain grounding), resume + fast-forward
// to arrival, then fly home.
const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
expect(cancelRes.status()).toBe(200);
await setScale(page, SCALE);
await expect.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'available' && p.locationIcao === DEST;
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
// ISS-041: the arrival above paused the sim — setScale clears pause for the return leg.
await setScale(page, SCALE);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200);
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
timeout: 120_000,
message: 'waiting for plane-starter to land back at the hub',
}).toBe(HUB);
});
test('landing auto-starts the next queued leg (ISS-043 chain end-to-end)', async ({ page }) => {
test.setTimeout(300_000); // self-heal + two chained legs at scale 1000
await flyHomeViaApi(page);
// Full tank so fuel covers the chained return leg (same feasibility rule as /fly).
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
expect(refuel.status()).toBe(200);
// Package EDDF→LEMD + a queued return leg LEMD→EDDF, then start leg 1.
const pkg = await createPackage(page, HUB, DEST);
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
expect(load.status()).toBe(200);
const queueRes = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: HUB } });
expect(queueRes.status()).toBe(200);
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
expect(flyRes.status()).toBe(200);
// Fast-forward (also clears any pause). On landing at DEST the server must AUTO-START the queued
// return leg — no manual dispatch. The chained state persists for the whole ~10 s return leg, so a
// poll can't miss it: status 'flying' with destIcao = the first (only) queued ICAO.
await setScale(page, SCALE);
await expect.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'flying' && p.destIcao === HUB;
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${HUB} to start` }).toBe(true);
// Cargo delivery ran BEFORE chaining (part 1 ordering) — the package is already delivered.
const s = await getState(page);
expect(s.packages.find((x) => x.id === pkg.id)?.status).toBe('delivered');
// Let the chained leg finish: it lands back at the hub, leaving the canonical onboarding state.
await expect.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'available' && p.locationIcao === HUB;
}, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true);
});
+4 -2
View File
@@ -31,8 +31,10 @@ 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
// resume it or the clock stays frozen and the next wait hangs.
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 } }); const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number }; const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale); expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
@@ -72,7 +74,7 @@ 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-011: the landing above auto-slown the sim to 1× — re-set SCALE 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.id}/fly`, { data: { destIcao: HUB } }); const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
+80 -14
View File
@@ -5,8 +5,9 @@ import { expect, test } from '@playwright/test';
// 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
// (moved to the left pane in v0.20.0, ISS-021) shows loaded packages with fly-to-destination + // (moved to the left pane in v0.20.0, ISS-021) shows loaded packages with fly-to-destination +
// abandon buttons (penalty preview from the same // abandon buttons (penalty preview from the same
// formula as the server); flying planes show their current leg; market buy/sell/refuel previews // formula as the server); flying planes show their current leg; market buy/sell previews match
// match the catalog + economy constants exposed in /api/state. Packages are created via // the catalog + economy constants exposed in /api/state, and the Refuel-to-full row (moved to the
// left pane in v0.23.0, ISS-035) deducts exactly its cost. Packages are created via
// POST /api/test/create-package (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts. // POST /api/test/create-package (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts.
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
@@ -27,7 +28,7 @@ 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;
@@ -41,7 +42,7 @@ interface CatalogEntry {
burnPerKm: number; burnPerKm: number;
emptyWeightKg: number; // v0.16.0 weighted burn input emptyWeightKg: number; // v0.16.0 weighted burn input
speedKmh: number; speedKmh: number;
capacity: number; maxPayloadKg: number; // v0.27.0 — replaces the v0.12.0 slot capacity
} }
interface StateBody { interface StateBody {
@@ -68,9 +69,10 @@ async function postSim(page: Page, body: unknown) {
} }
// Set the time scale and VERIFY from the response that it stuck — a silent reset would // Set the time scale and VERIFY from the response that it stuck — a silent reset would
// otherwise surface as an unexplained landing-wait timeout. // otherwise surface as an unexplained landing-wait timeout. ISS-041 also clears pause: a
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
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 } }); const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number }; const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale); expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
@@ -149,6 +151,12 @@ 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);
} }
// Mirrors maxRangeKm in lib/sim/movement.ts (v0.28.0): full-tank range under the weighted model.
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. // Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number): Promise<PackageState> { async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number): Promise<PackageState> {
const created = await page.request.post('/api/test/create-package', { const created = await page.request.post('/api/test/create-package', {
@@ -175,7 +183,7 @@ async function flyHomeViaApi(page: Page): Promise<void> {
.toBe(true); .toBe(true);
} }
if (starter.locationIcao !== HUB) { if (starter.locationIcao !== HUB) {
// ISS-011: the landing above auto-slown the sim to 1× — re-set SCALE 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 } }); const res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
@@ -288,7 +296,7 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
await expect(onboardRow).not.toBeVisible({ timeout: 15_000 }); await expect(onboardRow).not.toBeVisible({ timeout: 15_000 });
// Restore: fly home so the live instance stays in its onboarding state for the next run. // Restore: fly home so the live instance stays in its onboarding state for the next run.
// ISS-011: the arrival above auto-slown the sim to 1×, so re-set SCALE for this leg. // ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
await setScale(page, SCALE); await setScale(page, SCALE);
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(homeRes.status()).toBe(200); expect(homeRes.status()).toBe(200);
@@ -300,16 +308,19 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
.toBe(true); .toBe(true);
}); });
test('market buy/sell/refuel render with previews from the catalog in /api/state; refuel deducts exactly', async ({ page }) => { test('market buy/sell render with previews from the catalog in /api/state; left-pane Refuel deducts exactly', 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); // grounded starter at EDDF (tank partial after the previous test's flight) await flyHomeViaApi(page); // grounded starter at EDDF (tank partial after the previous test's flight)
const body = await getState(page); // authoritative catalog + economy constants (v0.10.0) const body = await getState(page); // authoritative catalog + economy constants (v0.10.0)
expect(body.catalog.length).toBeGreaterThan(0); expect(body.catalog.length).toBeGreaterThan(0);
const entry = body.catalog.find((e) => e.model === 'atr72')!; const entry = body.catalog.find((e) => e.model === 'atr72')!;
expect(entry.capacity).toBe(6); // v0.12.0 slot capacity exposed in the snapshot 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 });
@@ -325,6 +336,9 @@ test('market buy/sell/refuel render with previews from the catalog in /api/state
const payout = Math.round(entry.price * body.sellFraction * 100) / 100; const payout = Math.round(entry.price * body.sellFraction * 100) / 100;
await expect(page.getByTestId('market-sell-payout')).toContainText(eur(payout)); await expect(page.getByTestId('market-sell-payout')).toContainText(eur(payout));
// ISS-035 (v0.23.0): the Refuel-to-full row now lives in the LEFT pane next to Relocate — same global testids.
await expect(page.getByTestId('left-pane').getByTestId('market-refuel')).toBeVisible({ timeout: 15_000 });
// Refuel through the pane: the flight genuinely burned fuel… // Refuel through the pane: the flight genuinely burned fuel…
if (starter.fuel < starter.maxFuel) { if (starter.fuel < starter.maxFuel) {
const expectedCost = Math.round((starter.maxFuel - starter.fuel) * body.fuelPricePerL * 100) / 100; const expectedCost = Math.round((starter.maxFuel - starter.fuel) * body.fuelPricePerL * 100) / 100;
@@ -348,7 +362,7 @@ test('market buy/sell/refuel render with previews from the catalog in /api/state
} }
}); });
test('on-board row Refuel tops up just enough for the package leg; market button says "Refuel to full"', async ({ page }) => { test('on-board row Refuel tops up just enough for the package leg; 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);
@@ -403,12 +417,12 @@ test('on-board row Refuel tops up just enough for the package leg; market button
expect(post.account.cash).toBeCloseTo(cashBefore - expectedCost, 6); expect(post.account.cash).toBeCloseTo(cashBefore - expectedCost, 6);
expect(post.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(fuelBefore + liters, 6); expect(post.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(fuelBefore + liters, 6);
// Market row: the fill-up button is labeled "Refuel to full" (testid unchanged). // Left pane (moved from the market in v0.23.0, ISS-035): the fill-up button is labeled "Refuel to full" (testid unchanged).
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-011: the far-end // exactly the leg requirement, so this also proves "just enough" is enough. ISS-041: the far-end
// arrival above auto-slown the sim to 1×, so re-set SCALE for this leg. // 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
@@ -420,3 +434,55 @@ test('on-board row Refuel tops up just enough for the package leg; market button
// 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,10 +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.19.0 — ISS-011: when the LAST plane lands (no plane remains in flight) the sim auto-slows // v0.30.0 — ISS-057: when a landing leaves any plane on the ground the sim slows to 1× server-side
// to 1×, server-side and persisted on the sim_state row so the UI slider reflects it on the next // WITHOUT pausing (pause is manual-only now), and starting a flight (POST /api/planes/{id}/fly)
// snapshot. Arrival-triggered by design: a grounded fleet at high scale is left alone, there is no // restores the last MANUALLY selected speed once no plane remains grounded. The cycle: dispatch at
// auto-restore — the player speeds up manually again (asserted below). // 1000× → landing slows to 1× → next dispatch (last grounded plane airborne) resumes 1000×.
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)
@@ -33,8 +33,10 @@ 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-057 also records this as lastSelectedTimeScale (the resume target) and clears pause, so any
// 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 } }); const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
const body = (await res.json()) as SimState; const body = (await res.json()) as SimState;
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale); expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
@@ -75,8 +77,8 @@ 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-011: the landing above auto-slown the sim to 1× — re-set SCALE 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 } }); const res = 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) expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
@@ -93,13 +95,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('auto-slow to 1× when the last plane lands; manual speed-up still works (ISS-011)', 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×) + one short leg 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).
@@ -117,14 +119,21 @@ test('auto-slow to 1× when the last plane lands; manual speed-up still works (I
}, { 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 has auto-slown to 1× — 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
// snapshot shows it (the just-landed plane is on ground → slow; ISS-057).
expect(landedBody!.simState.paused).toBe(false);
expect(landedBody!.simState.timeScale).toBe(1); expect(landedBody!.simState.timeScale).toBe(1);
// Manual override still works: POST /api/sim {timeScale: 1000} → confirmed by the response… // A subsequent /fly dispatch restores the last selected speed — asserted on the endpoint's OWN
const post = await page.request.post('/api/sim', { data: { timeScale: SCALE } }); // full post-action snapshot, so no poll race with the client's next tick. setScale posted 1000
expect(post.status()).toBe(200); // above, so lastSelectedTimeScale is 1000 and this dispatch (sole grounded plane → airborne)
expect(((await post.json()) as SimState).timeScale).toBe(SCALE); // resumes it.
// …and by a fresh snapshot (no auto-restore of the previous scale — and no re-slow either, const fly2 = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
// since nothing is in flight: the trigger is arrival-based, not idle-enforcement). expect(fly2.status()).toBe(200);
expect((await getState(page)).simState.timeScale).toBe(SCALE); const resumedBody = (await fly2.json()) as StateBody;
expect(resumedBody.simState.paused).toBe(false); // dispatch still clears a manual pause…
expect(resumedBody.simState.timeScale).toBe(SCALE); // …and restores the last selected speed
// 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');
}); });
+1 -1
View File
@@ -36,7 +36,7 @@ test('GET /api/state returns a seeded snapshot', async ({ page }) => {
expect(Array.isArray(body.packages)).toBe(true); expect(Array.isArray(body.packages)).toBe(true);
expect(body.packages.length).toBeGreaterThan(0); expect(body.packages.length).toBeGreaterThan(0);
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'deadlineSimTime', 'status']) { for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'deadlineSimTime', 'status']) {
expect(body.packages[0], `package.${key}`).toHaveProperty(key); expect(body.packages[0], `package.${key}`).toHaveProperty(key);
} }
}); });
+9 -5
View File
@@ -1,9 +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.17.0 — ISS-008: action errors surface as bottom-right toasts (in addition to the inline // v0.17.0 — ISS-008: action errors surface as toasts at the map's right edge (clear of the
// pane error) and game events appear as info toasts via snapshot diffing; the left-pane Time // right pane, in addition to the inline pane error) and game events appear as info toasts via
// section offers an explicit "Enable notifications" opt-in for browser notifications. // snapshot diffing; the left-pane Time section offers an explicit "Enable notifications" opt-in
// for browser notifications.
interface CatalogEntry { interface CatalogEntry {
model: string; model: string;
@@ -22,8 +23,11 @@ async function getState(page: Page): Promise<StateBody> {
return (await res.json()) as StateBody; return (await res.json()) as StateBody;
} }
test('failed actions toast bottom-right (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 });
@@ -38,7 +42,7 @@ test('failed actions toast bottom-right (inline error stays) and the Time sectio
// The toast appears with the server's error message and the error variant class… // The toast appears with the server's error message and the error variant class…
const toast = page.locator('[data-testid="toast"]').filter({ hasText: /insufficient funds/i }); const toast = page.locator('[data-testid="toast"]').filter({ hasText: /insufficient funds/i });
await expect(toast, 'the error toast appears bottom-right').toBeVisible({ timeout: 10_000 }); await expect(toast, 'the error toast appears at the map right edge').toBeVisible({ timeout: 10_000 });
await expect(toast).toHaveClass(/toast-error/); await expect(toast).toHaveClass(/toast-error/);
// …and the inline pane error still shows (both surfaces by design). It auto-clears on the // …and the inline pane error still shows (both surfaces by design). It auto-clears on the
+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 () => {
+22 -3
View File
@@ -30,7 +30,8 @@ CREATE TABLE IF NOT EXISTS planes (
dest_icao TEXT, dest_icao TEXT,
lat REAL NOT NULL, lat REAL NOT NULL,
lon REAL NOT NULL, lon REAL NOT NULL,
departure_sim_time INTEGER departure_sim_time INTEGER,
queue TEXT NOT NULL DEFAULT '[]'
); );
CREATE TABLE IF NOT EXISTS packages ( CREATE TABLE IF NOT EXISTS packages (
id TEXT PRIMARY KEY NOT NULL, id TEXT PRIMARY KEY NOT NULL,
@@ -39,6 +40,7 @@ CREATE TABLE IF NOT EXISTS packages (
reward REAL NOT NULL, reward REAL NOT NULL,
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,
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,
@@ -52,7 +54,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
); );
`; `;
@@ -72,6 +75,20 @@ function migrateColumns(raw: RawSqlJsDatabase): void {
if (!cols('planes').has('dest_icao')) { if (!cols('planes').has('dest_icao')) {
raw.exec('ALTER TABLE planes ADD COLUMN dest_icao TEXT'); raw.exec('ALTER TABLE planes ADD COLUMN dest_icao TEXT');
} }
// v0.25.0 (ISS-043): the flight queue — ordered JSON array of not-yet-started leg ICACs.
if (!cols('planes').has('queue')) {
raw.exec(`ALTER TABLE planes ADD COLUMN queue TEXT NOT NULL DEFAULT '[]'`);
}
// v0.27.0 (ISS-045): package weight — pre-existing rows backfill to a mid-range default.
if (!cols('packages').has('weight_kg')) {
raw.exec('ALTER TABLE packages ADD COLUMN weight_kg REAL NOT NULL DEFAULT 2000');
}
// 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
@@ -159,7 +176,9 @@ async function seedEmpty(instance: GameDb): Promise<void> {
const rng = mulberry32(randomInt(0, 2 ** 31)); const rng = mulberry32(randomInt(0, 2 ** 31));
const data = buildSeed({ airports, rng, nowMs: Date.now() }); const data = buildSeed({ airports, rng, nowMs: Date.now() });
await instance.db.insert(schema.planes).values(data.plane); // SeedPlane.queue is a string[]; the column stores it as JSON text.
const { queue, ...planeCols } = data.plane;
await instance.db.insert(schema.planes).values({ ...planeCols, queue: JSON.stringify(queue ?? []) });
await instance.db.insert(schema.packages).values(data.packages); await instance.db.insert(schema.packages).values(data.packages);
await instance.db.insert(schema.account).values(data.account); await instance.db.insert(schema.account).values(data.account);
await instance.db.insert(schema.simState).values(data.simState); await instance.db.insert(schema.simState).values(data.simState);
+13 -1
View File
@@ -20,6 +20,10 @@ export const planes = sqliteTable('planes', {
// flying, `fuel` is frozen at its departure value — live position/fuel are pure // flying, `fuel` is frozen at its departure value — live position/fuel are pure
// functions of the clock (lib/sim/movement.ts), recomputed on read. // functions of the clock (lib/sim/movement.ts), recomputed on read.
departureSimTime: integer('departure_sim_time'), departureSimTime: integer('departure_sim_time'),
// Queue of upcoming legs NOT yet started (v0.25.0, ISS-043) — ordered JSON array
// of ICAO strings ('[]' when empty). The ACTIVE leg stays destIcao +
// departureSimTime; on landing the engine auto-chains queue[0] if fuel covers it.
queue: text('queue').notNull().default('[]'),
}); });
// Cargo packages (v0.12.0) replace the old contracts table end-to-end: a package // Cargo packages (v0.12.0) replace the old contracts table end-to-end: a package
@@ -33,10 +37,14 @@ export const packages = sqliteTable('packages', {
reward: real('reward').notNull(), reward: real('reward').notNull(),
cargoType: text('cargo_type').notNull(), cargoType: text('cargo_type').notNull(),
cargoCount: integer('cargo_count').notNull(), cargoCount: integer('cargo_count').notNull(),
// 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.
weightKg: real('weight_kg').notNull().default(2000),
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(),
}); });
@@ -53,6 +61,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;
+3 -1
View File
@@ -6,7 +6,7 @@ import { diffSnapshots } from './deriveEvents';
// feature, unit-tested in node like advanceSimClock/fetchState. // feature, unit-tested in node like advanceSimClock/fetchState.
const CATALOG = [ const CATALOG = [
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9300, speedKmh: 510, capacity: 6 }, { model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9300, speedKmh: 510, maxPayloadKg: 4800 },
]; ];
const basePlane = (id: string): PlaneState => ({ const basePlane = (id: string): PlaneState => ({
@@ -22,6 +22,7 @@ const basePlane = (id: string): PlaneState => ({
departureSimTime: null, departureSimTime: null,
lat: 50.03, lat: 50.03,
lon: 8.56, lon: 8.56,
queue: [],
}); });
const basePackage = (id: string): PackageState => ({ const basePackage = (id: string): PackageState => ({
@@ -33,6 +34,7 @@ const basePackage = (id: string): PackageState => ({
reward: 1234.5, reward: 1234.5,
cargoType: 'mail', cargoType: 'mail',
cargoCount: 2, cargoCount: 2,
weightKg: 600,
deadlineSimTime: 0, deadlineSimTime: 0,
status: 'open', status: 'open',
planeId: null, planeId: null,
+157 -14
View File
@@ -13,9 +13,12 @@ import { HUB_ICAO } from './seed';
import { import {
abandonPackage, abandonPackage,
buyPlane, buyPlane,
cancelQueuedLeg,
flyPlane, flyPlane,
GameActionError, GameActionError,
ignorePackage,
loadPackage, loadPackage,
queueLeg,
refuelPlane, refuelPlane,
sellPlane, sellPlane,
} from './actions'; } from './actions';
@@ -125,27 +128,27 @@ describe('loadPackage', () => {
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /at LEMD/); await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /at LEMD/);
}); });
it('rejects loading when the plane has no free slot (capacity from catalog)', async () => { it('rejects loading when the plane has no payload room (maxPayloadKg from catalog)', async () => {
const inst = await freshDb('load-full.db'); const inst = await freshDb('load-full.db');
await insertOpenPackage(inst, 'p-new'); await insertOpenPackage(inst, 'p-new', { weightKg: 1_000 });
// Fill all atr72 slots with loaded cargo… // Fill past maxPayloadKg with loaded cargo…
for (let i = 0; i < PLANE_TYPES.atr72.capacity; i++) { const chunk = Math.floor(PLANE_TYPES.atr72.maxPayloadKg / 3) + 1; // 3 chunks > max
await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter' }); for (let i = 0; i < 3; i++) {
await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter', weightKg: chunk });
} }
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no free slots/i); await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no payload room/i);
}); });
it('counts expired packages as occupying their slot too', async () => { it('counts expired packages as occupying their payload too', async () => {
const inst = await freshDb('load-expiredslot.db'); const inst = await freshDb('load-expiredslot.db');
await insertOpenPackage(inst, 'p-new'); await insertOpenPackage(inst, 'p-new', { weightKg: 1_000 });
// capacity − 1 loaded + 1 expired = full. // Half + half (expired) leaves no room — each alone would fit.
for (let i = 0; i < PLANE_TYPES.atr72.capacity - 1; i++) { const half = Math.floor(PLANE_TYPES.atr72.maxPayloadKg / 2) + 1;
await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter' }); await insertOpenPackage(inst, 'p-loaded-half', { status: 'loaded', planeId: 'plane-starter', weightKg: half });
} await insertOpenPackage(inst, 'p-expired-slot', { status: 'expired', planeId: 'plane-starter', weightKg: half });
await insertOpenPackage(inst, 'p-expired-slot', { status: 'expired', planeId: 'plane-starter' });
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no free slots/i); await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no payload room/i);
}); });
}); });
@@ -167,6 +170,34 @@ describe('flyPlane', () => {
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
}); });
it('resumes a manual pause and restores lastSelectedTimeScale when the last grounded plane takes off (ISS-057)', async () => {
const inst = await freshDb('fly-resume.db');
// 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: 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');
const sim = (await inst.db.select().from(schema.simState))[0];
expect(sim.paused).toBe(false);
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 () => {
const inst = await freshDb('fly-flying.db'); const inst = await freshDb('fly-flying.db');
await inst.db.update(schema.planes) await inst.db.update(schema.planes)
@@ -194,6 +225,75 @@ describe('flyPlane', () => {
}); });
}); });
describe('queueLeg (v0.25.0, ISS-043)', () => {
it('appends legs to the queue in order and persists them as JSON', async () => {
const inst = await freshDb('qleg-ok.db');
await queueLeg(inst, 'plane-starter', 'LEMD');
let plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD']);
await queueLeg(inst, 'plane-starter', 'EHAM'); // pre-building a route
plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']); // order preserved
});
it('works on a flying plane too — the active leg is untouched, nothing auto-starts', async () => {
const inst = await freshDb('qleg-flying.db');
await inst.db.update(schema.planes)
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter'));
await queueLeg(inst, 'plane-starter', 'EHAM');
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying'); // still on its active leg…
expect(plane.destIcao).toBe('LEMD'); // …unchanged — queueing never starts a flight
expect(JSON.parse(plane.queue)).toEqual(['EHAM']);
});
it('rejects an unknown plane id with 404', async () => {
const inst = await freshDb('qleg-ghost.db');
await expectActionError(queueLeg(inst, 'ghost', 'LEMD'), 404);
});
it('rejects an ICAO not in the airport dataset with 400', async () => {
const inst = await freshDb('qleg-unknownicao.db');
await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
});
});
describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
it('removes the leg at the given index and keeps the rest in order', async () => {
const inst = await freshDb('qcancel-ok.db');
await queueLeg(inst, 'plane-starter', 'LEMD');
await queueLeg(inst, 'plane-starter', 'EHAM');
await queueLeg(inst, 'plane-starter', 'EDDV');
await cancelQueuedLeg(inst, 'plane-starter', 1); // drop EHAM (the middle leg)
const plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EDDV']);
});
it('rejects an out-of-range index with 400', async () => {
const inst = await freshDb('qcancel-oob.db');
await queueLeg(inst, 'plane-starter', 'LEMD'); // one leg → only index 0 is valid
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 1), 400);
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', -1), 400);
});
it('rejects cancelling from an empty queue with 400', async () => {
const inst = await freshDb('qcancel-empty.db'); // seeded starter has no queued legs
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 0), 400);
});
it('rejects an unknown plane id with 404', async () => {
const inst = await freshDb('qcancel-ghost.db');
await expectActionError(cancelQueuedLeg(inst, 'ghost', 0), 404);
});
});
describe('abandonPackage', () => { describe('abandonPackage', () => {
// A loaded package created at T0 with a 10h window; the seeded sim clock is T0. // A loaded package created at T0 with a 10h window; the seeded sim clock is T0.
const REWARD = 1_000; const REWARD = 1_000;
@@ -291,6 +391,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');
@@ -357,6 +487,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/);
+86 -10
View File
@@ -6,7 +6,7 @@ import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo'; import { haversineKm } from '@/lib/geo';
import { requiredFuelFor } from '@/lib/sim/movement'; import { requiredFuelFor } from '@/lib/sim/movement';
import { runAction } from '@/lib/sim/engine'; import { runAction } from '@/lib/sim/engine';
import { getStateSnapshot } 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';
import { abandonPenalty } from './penalty'; import { abandonPenalty } from './penalty';
import { HUB_ICAO } from './seed'; import { HUB_ICAO } from './seed';
@@ -20,7 +20,7 @@ import { HUB_ICAO } from './seed';
// GameActionError carrying the HTTP status: // GameActionError carrying the HTTP status:
// 404 — unknown entity id // 404 — unknown entity id
// 409 — valid ids but the current state forbids the action (wrong package/plane // 409 — valid ids but the current state forbids the action (wrong package/plane
// status, plane not at origin, no free slot, insufficient fuel/funds, last plane) // status, plane not at origin, no payload room, insufficient fuel/funds, last plane)
// 400 — bad input that isn't an existence problem (unknown airport/model) // 400 — bad input that isn't an existence problem (unknown airport/model)
export class GameActionError extends Error { export class GameActionError extends Error {
@@ -43,8 +43,9 @@ async function airportByIcao(icao: string): Promise<{ icao: string; lat: number;
// --- Load (POST /api/packages/:id/load {planeId}) --- // --- Load (POST /api/packages/:id/load {planeId}) ---
// open → loaded, bound to the plane. The plane must be GROUNDED at the package's // open → loaded, bound to the plane. The plane must be GROUNDED at the package's
// origin with a free slot (slots = packages 'loaded' or 'expired' on it — expired // origin with enough payload room (v0.27.0: used kg = sum of weightKg over packages
// cargo keeps occupying its space until delivered/abandoned). // 'loaded' or 'expired' on it — expired cargo keeps occupying its space until
// delivered/abandoned).
export async function loadPackage(inst: GameDb, packageId: string, planeId: string): Promise<void> { export async function loadPackage(inst: GameDb, packageId: string, planeId: string): Promise<void> {
const [pkgRows, planeRows] = await Promise.all([ const [pkgRows, planeRows] = await Promise.all([
inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)), inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)),
@@ -69,13 +70,12 @@ export async function loadPackage(inst: GameDb, packageId: string, planeId: stri
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 occupied = ( const occupied = await inst.db.select({ weightKg: schema.packages.weightKg }).from(schema.packages).where(
await inst.db.select().from(schema.packages).where(
and(inArray(schema.packages.status, ['loaded', 'expired']), eq(schema.packages.planeId, plane.id)), and(inArray(schema.packages.status, ['loaded', 'expired']), eq(schema.packages.planeId, plane.id)),
) );
).length; const usedKg = occupied.reduce((sum, r) => sum + (r.weightKg ?? 0), 0);
if (occupied >= type.capacity) { if (usedKg + pkg.weightKg > type.maxPayloadKg) {
throw new GameActionError(409, `no free slots: the ${type.name} carries ${occupied}/${type.capacity} packages`); throw new GameActionError(409, `no payload room: the ${type.name} carries ${Math.round(usedKg)}/${type.maxPayloadKg} kg`);
} }
await inst.db.update(schema.packages) await inst.db.update(schema.packages)
@@ -125,6 +125,56 @@ 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-057: dispatch still resumes a MANUALLY paused sim (pause is manual-only now — landings
// slow to 1× instead of pausing), and restores the last manually selected speed when this
// 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);
}
// --- Queue (POST /api/planes/:id/queue {destIcao}) — ISS-043 part 1 ---
// Append a NOT-yet-started leg to the plane's queue. Works whether the plane is
// grounded or flying (you can pre-build a route); nothing auto-starts here — the
// active leg still starts via flyPlane, and queued legs chain on landing when fuel
// covers them (lib/sim/engine.ts deliverPackages). Validation: known plane, ICAO
// in the airport dataset.
export async function queueLeg(inst: GameDb, planeId: string, destIcao: string): Promise<void> {
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = planeRows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
const fc = await loadAirportsGeoJson();
if (!fc.features.some((f) => f.properties.icao === destIcao)) {
throw new GameActionError(400, `unknown airport ${destIcao}`);
}
const queue = parseIcaoQueue(plane.queue);
queue.push(destIcao);
await inst.db.update(schema.planes).set({ queue: JSON.stringify(queue) }).where(eq(schema.planes.id, planeId));
await persist(inst);
}
// --- Queue cancel (POST /api/planes/:id/queue/cancel {index}) — ISS-043 part 1 ---
// Remove a NOT-yet-started leg from the queue by index. Only queued legs are
// cancellable — the active in-flight leg (destIcao) is never affected. Validation:
// known plane, integer index within [0, queue.length).
export async function cancelQueuedLeg(inst: GameDb, planeId: string, index: number): Promise<void> {
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = planeRows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
const queue = parseIcaoQueue(plane.queue);
if (index < 0 || index >= queue.length) {
throw new GameActionError(400, `queue index ${index} out of range — the queue holds ${queue.length} legs`);
}
queue.splice(index, 1);
await inst.db.update(schema.planes).set({ queue: JSON.stringify(queue) }).where(eq(schema.planes.id, planeId));
await persist(inst); await persist(inst);
} }
@@ -162,6 +212,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
@@ -210,6 +277,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');
+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();
});
});
+64 -5
View File
@@ -16,8 +16,10 @@ export interface PlaneType {
// Market price in € (v0.8.0): buying costs `price`, selling pays out // Market price in € (v0.8.0): buying costs `price`, selling pays out
// SELL_FRACTION × price. // SELL_FRACTION × price.
price: number; price: number;
// Cargo slot capacity (v0.12.0): how many packages can be loaded on one plane. // Max payload in kg (v0.27.0, ISS-045 — replaces the v0.12.0 slot count): total
capacity: number; // package weight that can be loaded on one plane. ATR 72-600-class figure, tuned
// so every cargo type at max count × 1.3 jitter still fits alone (see UNIT_WEIGHT_KG).
maxPayloadKg: number;
} }
export const PLANE_TYPES: Record<string, PlaneType> = { export const PLANE_TYPES: Record<string, PlaneType> = {
@@ -31,7 +33,63 @@ export const PLANE_TYPES: Record<string, PlaneType> = {
// €200k — with the starting cash of €20k and typical hop rewards (~€1–3k), // €200k — with the starting cash of €20k and typical hop rewards (~€1–3k),
// a second ATR takes sustained play to afford, as intended for the MVP sink. // a second ATR takes sustained play to afford, as intended for the MVP sink.
price: 200_000, price: 200_000,
capacity: 6, // ponytail: balance-tunable payload (kg) — every cargo type at max count × 1.3 jitter
// must stay below this so any single package fits alone; typical loads carry ~3–6 packages.
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,
}, },
}; };
@@ -57,7 +115,8 @@ export interface CatalogEntry {
// lib/sim/movement.ts with these + FUEL_DENSITY_KG_PER_L. // lib/sim/movement.ts with these + FUEL_DENSITY_KG_PER_L.
emptyWeightKg: number; emptyWeightKg: number;
speedKmh: number; speedKmh: number;
capacity: number; // Max payload in kg (v0.27.0) — total package weight that can be loaded on one plane.
maxPayloadKg: number;
} }
// The catalog as a flat array, one entry per plane type — the single source of // The catalog as a flat array, one entry per plane type — the single source of
@@ -71,6 +130,6 @@ export function catalogEntries(): CatalogEntry[] {
burnPerKm: t.burnPerKm, burnPerKm: t.burnPerKm,
emptyWeightKg: t.emptyWeightKg, emptyWeightKg: t.emptyWeightKg,
speedKmh: t.speedKmh, speedKmh: t.speedKmh,
capacity: t.capacity, maxPayloadKg: t.maxPayloadKg,
})); }));
} }
+52 -11
View File
@@ -9,8 +9,9 @@ import {
generatePackage, generatePackage,
HUB_ICAO, HUB_ICAO,
mulberry32, mulberry32,
REWARD_PER_KG,
REWARD_PER_KM, REWARD_PER_KM,
rewardForDistanceKm, rewardFor,
STARTING_CASH, STARTING_CASH,
STARTING_REPUTATION, STARTING_REPUTATION,
type SeedAirport, type SeedAirport,
@@ -44,6 +45,7 @@ describe('buildSeed', () => {
destIcao: null, destIcao: null,
lat: 50.0333, lat: 50.0333,
lon: 8.5706, lon: 8.5706,
queue: [], // v0.25.0 (ISS-043) — no queued legs on first run
}); });
}); });
@@ -69,12 +71,26 @@ 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('scales reward by haversine distance: round(500 + 2.5 * km)', () => { it('applies the reward formula: round(500 + 2.5 * km + 0.5 * weightKg)', () => {
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(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon))); expect(c.reward).toBe(
Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * c.weightKg),
);
}
});
it('gives every package a positive integer weight that fits the starter plane alone (balance constraint)', () => {
const maxPayload = PLANE_TYPES[STARTER_MODEL].maxPayloadKg;
for (const seed of [seedWith(42), seedWith(7)]) {
for (const c of seed.packages) {
expect(Number.isInteger(c.weightKg)).toBe(true);
expect(c.weightKg).toBeGreaterThan(0);
// max count × max jitter (1.3×) must still fit the ATR alone — see UNIT_WEIGHT_KG.
expect(c.weightKg).toBeLessThanOrEqual(maxPayload);
}
} }
}); });
@@ -89,8 +105,9 @@ describe('buildSeed', () => {
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', () => {
@@ -105,9 +122,9 @@ describe('buildSeed', () => {
expect(() => buildSeed({ airports: AIRPORTS.slice(0, 1), rng: mulberry32(42), nowMs: NOW })).toThrow(); expect(() => buildSeed({ airports: AIRPORTS.slice(0, 1), rng: mulberry32(42), nowMs: NOW })).toThrow();
}); });
it('rewardForDistanceKm applies the documented formula', () => { it('rewardFor applies the documented formula', () => {
expect(rewardForDistanceKm(0)).toBe(BASE_REWARD); expect(rewardFor(0, 0)).toBe(BASE_REWARD);
expect(rewardForDistanceKm(1000)).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * 1000)); expect(rewardFor(1000, 2_000)).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * 1000 + REWARD_PER_KG * 2_000));
}); });
}); });
@@ -126,7 +143,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))); expect(c.reward).toBe(
Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * c.weightKg),
);
// Same 6–72h game-time deadline window as the seed. // Same 6–72h game-time deadline window as the seed.
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1); expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1);
@@ -172,7 +191,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))); expect(c.reward).toBe(
Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * c.weightKg),
);
}); });
it('destIcao forces the destination; origin stays random but distinct', () => { it('destIcao forces the destination; origin stays random but distinct', () => {
@@ -188,7 +209,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(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon))); expect(c.reward).toBe(
Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * c.weightKg),
);
}); });
it('is deterministic for a fixed RNG with forced endpoints', () => { it('is deterministic for a fixed RNG with forced endpoints', () => {
@@ -211,4 +234,22 @@ describe('generatePackage (shared seed/spawn builder)', () => {
expect(a).toEqual(b); expect(a).toEqual(b);
}); });
}); });
describe('forced weight (v0.27.0 e2e hook)', () => {
it('weightKg forces the package weight and feeds the payout', () => {
const c = generatePackage({ id: 'c-w', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, weightKg: 1_234 });
expect(c.weightKg).toBe(1_234);
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
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));
// Deterministic for a fixed RNG…
const again = generatePackage({ id: 'c-w', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, weightKg: 1_234 });
expect(again).toEqual(c);
// …and the forced value consumes no RNG draw — the deadline lands where an unforced call's jitter would.
const unforced = generatePackage({ id: 'c-u', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
expect(c.deadlineSimTime).not.toBe(unforced.deadlineSimTime);
});
});
}); });
+40 -11
View File
@@ -38,6 +38,9 @@ export interface SeedPlane {
destIcao: null; destIcao: null;
lat: number; lat: number;
lon: number; lon: number;
// Upcoming legs not yet started (v0.25.0, ISS-043) — serialized to the queue
// column as JSON text on insert; defaults to an empty queue.
queue?: string[];
} }
export interface SeedPackage { export interface SeedPackage {
@@ -47,6 +50,8 @@ export interface SeedPackage {
reward: number; reward: number;
cargoType: string; cargoType: string;
cargoCount: number; cargoCount: number;
// Package weight in kg (v0.27.0, ISS-045) — unit weight × count with ±30% jitter.
weightKg: number;
deadlineSimTime: number; deadlineSimTime: number;
status: 'open'; status: 'open';
planeId: null; planeId: null;
@@ -57,7 +62,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) ---
@@ -77,10 +82,12 @@ export const PACKAGE_COUNT_MAX = 12; // inclusive
// count after expiries, so the job board never runs dry. // count after expiries, so the job board never runs dry.
export const MARKET_TARGET_OPEN_PACKAGES = 10; export const MARKET_TARGET_OPEN_PACKAGES = 10;
// Reward formula (v0.6.0 — deliberately simple; real revenue/cost modeling is a // Reward formula (v0.6.0 distance part, deliberately simple; real revenue/cost
// later increment): reward = BASE_REWARD + REWARD_PER_KM * great-circle km. // modeling is a later increment; v0.27.0 weight part): reward = BASE_REWARD +
// REWARD_PER_KM * great-circle km + REWARD_PER_KG * package weight kg.
export const BASE_REWARD = 500; export const BASE_REWARD = 500;
export const REWARD_PER_KM = 2.5; export const REWARD_PER_KM = 2.5;
export const REWARD_PER_KG = 0.5;
// Package deadlines: now + a plausible game-time window of 6–72 hours. // Package deadlines: now + a plausible game-time window of 6–72 hours.
const DEADLINE_MIN_HOURS = 6; const DEADLINE_MIN_HOURS = 6;
@@ -88,19 +95,33 @@ const DEADLINE_MAX_HOURS = 72;
const CARGO_TYPES = ['Passengers', 'Mail', 'Perishables', 'Electronics', 'Auto parts'] as const; const CARGO_TYPES = ['Passengers', 'Mail', 'Perishables', 'Electronics', 'Auto parts'] as const;
export function rewardForDistanceKm(km: number): number { // ponytail: balance-tunable unit weights (kg per passenger/pallet). Chosen so every cargo
return Math.round(BASE_REWARD + REWARD_PER_KM * km); // type at MAX count × max jitter (1.3×) still fits the ATR 72-600's payload alone, and
// typical packages land ~400–2500 kg — a normal flight carries roughly 3–6 by weight.
export const UNIT_WEIGHT_KG: Record<string, number> = {
Passengers: 50, // max 50 × 68 × 1.3 = 4420 < 4800
Mail: 60, // max 60 × 50 × 1.3 = 3900
Perishables: 70, // max 70 × 50 × 1.3 = 4550
Electronics: 40, // max 40 × 50 × 1.3 = 2600
'Auto parts': 72, // max 72 × 50 × 1.3 = 4680
};
export function rewardFor(km: number, weightKg: number): number {
return Math.round(BASE_REWARD + REWARD_PER_KM * km + REWARD_PER_KG * weightKg);
} }
// Shared package generator, used by BOTH the first-run seed and the runtime // Shared package generator, used by BOTH the first-run seed and the runtime
// market spawn (v0.7.0) so spawned packages match seeded ones exactly: random // market spawn (v0.7.0) so spawned packages match seeded ones exactly: random
// distinct airports from the dataset, distance-based reward, cargo flavor, // distinct airports from the dataset, distance+weight-based reward, cargo flavor,
// deadline now + 6–72h game time (or now + deadlineOffsetMs when given — used by // weight = unit weight × count with ±30% jitter (v0.27.0), deadline now + 6–72h
// the e2e test hook to force a near-term expiry). The RNG call order per package // game time (or now + deadlineOffsetMs when given — used by the e2e test hook to
// is fixed: origin, dest, cargoType, cargoCount, [deadline window]. // force a near-term expiry). The RNG call order per package is fixed: origin,
// dest, cargoType, cargoCount, weight jitter, [deadline window].
// 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
// jitter draw, same pattern as forced endpoints.
export function generatePackage(input: { export function generatePackage(input: {
id: string; id: string;
airports: SeedAirport[]; airports: SeedAirport[];
@@ -109,6 +130,7 @@ export function generatePackage(input: {
deadlineOffsetMs?: number; deadlineOffsetMs?: number;
originIcao?: string; originIcao?: string;
destIcao?: string; destIcao?: string;
weightKg?: number;
}): 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');
@@ -140,6 +162,9 @@ export function generatePackage(input: {
// Passengers up to the ATR 72's ~68 seats; other cargo in pallets. // Passengers up to the ATR 72's ~68 seats; other cargo in pallets.
const cargoCount = const cargoCount =
cargoType === 'Passengers' ? 1 + Math.floor(rng() * 68) : 1 + Math.floor(rng() * 50); cargoType === 'Passengers' ? 1 + Math.floor(rng() * 68) : 1 + Math.floor(rng() * 50);
// 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.
const weightKg = input.weightKg ?? Math.round(UNIT_WEIGHT_KG[cargoType] * cargoCount * (0.7 + 0.6 * rng()));
const deadlineMs = const deadlineMs =
input.deadlineOffsetMs ?? (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000; input.deadlineOffsetMs ?? (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000;
@@ -147,9 +172,10 @@ export function generatePackage(input: {
id: input.id, id: input.id,
originIcao: origin.icao, originIcao: origin.icao,
destIcao: dest.icao, destIcao: dest.icao,
reward: rewardForDistanceKm(km), reward: rewardFor(km, weightKg),
cargoType, cargoType,
cargoCount, cargoCount,
weightKg,
deadlineSimTime: Math.round(nowMs + deadlineMs), deadlineSimTime: Math.round(nowMs + deadlineMs),
status: 'open', status: 'open',
planeId: null, planeId: null,
@@ -182,6 +208,7 @@ export function buildSeed(input: {
destIcao: null, destIcao: null,
lat: hub.lat, lat: hub.lat,
lon: hub.lon, lon: hub.lon,
queue: [], // no queued legs on first run (v0.25.0)
}; };
const count = PACKAGE_COUNT_MIN + Math.floor(rng() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1)); const count = PACKAGE_COUNT_MIN + Math.floor(rng() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
@@ -198,6 +225,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 },
}; };
} }
+21 -1
View File
@@ -24,6 +24,21 @@ export interface PlaneState {
departureSimTime: number | null; departureSimTime: number | null;
lat: number; lat: number;
lon: number; lon: number;
// Upcoming legs NOT yet started (v0.25.0, ISS-043) — ordered ICAOs; the active
// leg is destIcao. The engine auto-chains queue[0] on landing when fuel covers it.
queue: string[];
}
// Parse the planes.queue JSON column into an ICAO list (v0.25.0). Guards against
// null/empty/malformed values so a corrupt cell can never break a snapshot or tick.
export function parseIcaoQueue(raw: string | null): string[] {
if (!raw) return [];
try {
const v: unknown = JSON.parse(raw);
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
} catch {
return [];
}
} }
export interface PackageState { export interface PackageState {
@@ -35,8 +50,11 @@ export interface PackageState {
reward: number; reward: number;
cargoType: string; cargoType: string;
cargoCount: number; cargoCount: number;
// Package weight in kg (v0.27.0, ISS-045) — drives load validation + per-kg payout.
weightKg: number;
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;
} }
@@ -108,6 +126,7 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
departureSimTime: p.departureSimTime, departureSimTime: p.departureSimTime,
lat: live?.lat ?? p.lat, lat: live?.lat ?? p.lat,
lon: live?.lon ?? p.lon, lon: live?.lon ?? p.lon,
queue: parseIcaoQueue(p.queue),
}; };
}), }),
packages: packageRows.map((p) => ({ packages: packageRows.map((p) => ({
@@ -119,6 +138,7 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
reward: p.reward, reward: p.reward,
cargoType: p.cargoType, cargoType: p.cargoType,
cargoCount: p.cargoCount, cargoCount: p.cargoCount,
weightKg: p.weightKg,
deadlineSimTime: p.deadlineSimTime, deadlineSimTime: p.deadlineSimTime,
status: p.status, status: p.status,
planeId: p.planeId, planeId: p.planeId,
+91 -11
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { StateSnapshot } from './game/state'; import type { StateSnapshot } from './game/state';
import { planesGeoJson, routesGeoJson, hoverRouteGeoJson, greatCircleArc, ROUTE_ARC_SEGMENTS, type AirportCoord } from './mapData'; import { planesGeoJson, queuedRoutesGeoJson, routesGeoJson, hoverRouteGeoJson, hoverTraceGeoJson, greatCircleArc, ROUTE_ARC_SEGMENTS, type AirportCoord } from './mapData';
import { haversineKm } from './geo'; import { haversineKm } from './geo';
const AIRPORTS = new Map<string, AirportCoord>([ const AIRPORTS = new Map<string, AirportCoord>([
@@ -17,7 +17,7 @@ function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot {
planes: [], planes: [],
packages: [], packages: [],
catalog: [ catalog: [
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, capacity: 6 }, { model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, maxPayloadKg: 4_800 },
], ],
fuelPricePerL: 0.8, fuelPricePerL: 0.8,
sellFraction: 0.5, sellFraction: 0.5,
@@ -34,8 +34,8 @@ describe('planesGeoJson', () => {
const fc = planesGeoJson( const fc = planesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56 }, { id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, queue: [] },
{ id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1 }, { id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1, queue: [] },
], ],
}), }),
); );
@@ -61,7 +61,7 @@ describe('planesGeoJson', () => {
snapshot({ snapshot({
catalog: [], catalog: [],
planes: [ planes: [
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0 }, { id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, queue: [] },
], ],
}), }),
); );
@@ -72,7 +72,7 @@ describe('planesGeoJson', () => {
const fc = planesGeoJson( const fc = planesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0 }, { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0, queue: [] },
], ],
}), }),
); );
@@ -89,7 +89,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
const fc = routesGeoJson( const fc = routesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56 }, { id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, queue: [] },
], ],
}), }),
AIRPORTS, AIRPORTS,
@@ -101,7 +101,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
const fc = routesGeoJson( const fc = routesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9 }, { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: [] },
], ],
}), }),
AIRPORTS, AIRPORTS,
@@ -118,7 +118,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
const fc = routesGeoJson( const fc = routesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0 }, { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0, queue: [] },
], ],
}), }),
AIRPORTS, AIRPORTS,
@@ -130,8 +130,8 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
const fc = routesGeoJson( const fc = routesGeoJson(
snapshot({ snapshot({
planes: [ planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9 }, { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9, queue: [] },
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 52.4, lon: 9.6 }, { id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 52.4, lon: 9.6, queue: [] },
], ],
}), }),
AIRPORTS, AIRPORTS,
@@ -140,6 +140,69 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
}); });
}); });
describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → queued legs only)', () => {
it('draws nothing for a grounded plane, even with a non-empty queue', () => {
const fc = queuedRoutesGeoJson(
[
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, queue: ['EHAM'] },
],
AIRPORTS,
);
expect(fc.features).toHaveLength(0);
});
it('draws nothing for a flying plane with an empty queue', () => {
const fc = queuedRoutesGeoJson(
[
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: [] },
],
AIRPORTS,
);
expect(fc.features).toHaveLength(0);
});
it('flying plane with a 2-leg queue → one feature chaining dest → q0 → q1 with exact endpoints', () => {
const fc = queuedRoutesGeoJson(
[
// Active leg EDDF→EHAM stays in routesGeoJson; the queue line is ONLY EHAM → EDDV → LEMD.
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: ['EDDV', 'LEMD'] },
],
AIRPORTS,
);
expect(fc.features).toHaveLength(1);
expect(fc.features[0].properties).toEqual({ planeId: 'p1' });
const coords = fc.features[0].geometry.coordinates;
// Two concatenated arcs with the shared joint point dropped once: n_legs × SEGMENTS + 1 points.
expect(coords.length).toBe(2 * ROUTE_ARC_SEGMENTS + 1);
expect(coords[0]).toEqual([4.76, 52.31]); // starts at DEST (EHAM), not at the plane's origin
expect(coords[coords.length - 1]).toEqual([-3.56, 40.47]); // ends at the last queued airport (LEMD)
expect(coords[ROUTE_ARC_SEGMENTS]).toEqual([9.68, 52.46]); // joint point = first queued airport (EDDV), exact
});
it('skips a plane whose dest or a queued ICAO is missing from the airport index', () => {
const fc = queuedRoutesGeoJson(
[
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: 51, lon: 9, queue: ['EHAM'] },
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: ['ZZZZ'] },
],
AIRPORTS,
);
expect(fc.features).toHaveLength(0);
});
it('one feature per qualifying plane — grounded and empty-queue planes draw nothing', () => {
const fc = queuedRoutesGeoJson(
[
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: ['LEMD'] },
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EHAM', locationName: 'A', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 52.3, lon: 4.7, queue: [] },
{ id: 'p3', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: null, destName: null, departureSimTime: null, lat: 52.46, lon: 9.68, queue: ['LEMD'] },
],
AIRPORTS,
);
expect(fc.features.map((f) => f.properties.planeId)).toEqual(['p1']);
});
});
describe('hoverRouteGeoJson (ISS-022/025/027 v0.22.0 — hover route trace)', () => { describe('hoverRouteGeoJson (ISS-022/025/027 v0.22.0 — hover route trace)', () => {
it('returns an empty collection for the no-hover "" sentinel', () => { it('returns an empty collection for the no-hover "" sentinel', () => {
expect(hoverRouteGeoJson('', '', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] }); expect(hoverRouteGeoJson('', '', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
@@ -176,6 +239,23 @@ describe('hoverRouteGeoJson (ISS-022/025/027 v0.22.0 — hover route trace)', ()
}); });
}); });
describe('hoverTraceGeoJson (ISS-040 v0.23.2 — on-board package trace from the carrier\'s live position)', () => {
it('draws one great-circle line from the given start coord to the dest airport with exact endpoints', () => {
const from = { lat: 51.2, lon: 9.1 }; // a mid-flight position — not an airport
const fc = hoverTraceGeoJson(from, 'EHAM', AIRPORTS);
expect(fc.features).toHaveLength(1);
expect(fc.features[0].properties).toEqual({ planeId: '' }); // pure overlay — no owning plane
const coords = fc.features[0].geometry.coordinates;
expect(coords.length).toBe(ROUTE_ARC_SEGMENTS + 1); // sampled arc, not a 2-point chord
expect(coords[0]).toEqual([9.1, 51.2]); // live start — exact endpoint
expect(coords[coords.length - 1]).toEqual([4.76, 52.31]); // EHAM — exact endpoint
});
it('returns an empty collection when the dest airport is missing from the dataset', () => {
expect(hoverTraceGeoJson({ lat: 0, lon: 0 }, 'ZZZZ', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
});
});
describe('greatCircleArc (v0.12.2 — curved route traces)', () => { describe('greatCircleArc (v0.12.2 — curved route traces)', () => {
it('samples >2 points with the endpoints exactly at the airport coords', () => { it('samples >2 points with the endpoints exactly at the airport coords', () => {
const arc = greatCircleArc(AIRPORTS.get('EDDF')!, AIRPORTS.get('EHAM')!); const arc = greatCircleArc(AIRPORTS.get('EDDF')!, AIRPORTS.get('EHAM')!);
+55 -1
View File
@@ -1,4 +1,4 @@
import type { StateSnapshot } from './game/state'; import type { PlaneState, StateSnapshot } from './game/state';
import { formatFuel } from './format'; import { formatFuel } from './format';
import { positionAt } from './sim/movement'; import { positionAt } from './sim/movement';
@@ -112,6 +112,32 @@ export function hoverRouteGeoJson(
}; };
} }
// ISS-040 (v0.23.2): the on-board package hover trace — same shape as hoverRouteGeoJson but the
// start is a live COORDINATE (the carrier plane's current position), not an airport ICAO: once the
// plane has departed with cargo still loaded, the preview must begin where it actually is. Empty
// collection when the destination is missing from the dataset — never guessed.
export function hoverTraceGeoJson(
from: AirportCoord,
toIcao: string,
airportsByIcao: ReadonlyMap<string, AirportCoord>,
): FeatureCollection<MapLineFeature> {
const to = airportsByIcao.get(toIcao);
if (!to) return EMPTY_LINES;
return {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: greatCircleArc(from, to),
},
properties: { planeId: '' }, // no owning plane — a pure hover overlay
},
],
};
}
// One LineString per FLYING plane — its current leg from locationIcao to dest_icao. // One LineString per FLYING plane — its current leg from locationIcao to dest_icao.
// Grounded planes draw nothing; a flying plane missing either endpoint from the // Grounded planes draw nothing; a flying plane missing either endpoint from the
// dataset is skipped, never guessed. The line is sampled along the great circle // dataset is skipped, never guessed. The line is sampled along the great circle
@@ -138,3 +164,31 @@ export function routesGeoJson(
} }
return { type: 'FeatureCollection', features }; return { type: 'FeatureCollection', features };
} }
// ISS-043 (v0.26.0): the dotted queue line — one LineString per FLYING plane with a non-empty
// queue, chaining dest → queue[0] → … → queue[n−1]: ONLY the not-yet-started legs (the active
// leg locationIcao→dest stays in routesGeoJson's solid layer). Arcs are concatenated with the
// shared joint point dropped once. A plane is skipped — never guessed — when its dest or any
// queued ICAO is missing from the airport index; empty collection when nothing qualifies.
export function queuedRoutesGeoJson(
planes: PlaneState[],
airportsByIcao: ReadonlyMap<string, AirportCoord>,
): FeatureCollection<MapLineFeature> {
const features: MapLineFeature[] = [];
for (const p of planes) {
if (p.status !== 'flying' || !p.destIcao || p.queue.length === 0) continue;
const pts = [p.destIcao, ...p.queue].map((icao) => airportsByIcao.get(icao));
if (pts.some((c) => c === undefined)) continue; // an endpoint is missing from the dataset — skip
const coords: [number, number][] = [];
for (let i = 0; i < pts.length - 1; i++) {
const arc = greatCircleArc(pts[i]!, pts[i + 1]!);
coords.push(...(i === 0 ? arc : arc.slice(1))); // drop the duplicated joint point
}
features.push({
type: 'Feature',
geometry: { type: 'LineString', coordinates: coords },
properties: { planeId: p.id },
});
}
return { type: 'FeatureCollection', features };
}
+29 -6
View File
@@ -3,14 +3,17 @@ import { describe, expect, it } from 'vitest';
import { openApiSpec } from './openapi'; import { openApiSpec } from './openapi';
describe('OpenAPI spec', () => { describe('OpenAPI spec', () => {
it('documents exactly the ten public endpoints (4 read + 6 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/cancel', // v0.25.0 (ISS-043)
'/api/planes/{planeId}/refuel', '/api/planes/{planeId}/refuel',
'/api/sim', '/api/sim',
'/api/state', '/api/state',
@@ -92,18 +95,17 @@ describe('OpenAPI spec', () => {
const catalog = openApiSpec.components.schemas.CatalogEntry.properties; const catalog = openApiSpec.components.schemas.CatalogEntry.properties;
expect(Object.keys(catalog).sort()).toEqual([ expect(Object.keys(catalog).sort()).toEqual([
'burnPerKm', 'burnPerKm',
'capacity',
'emptyWeightKg', // v0.16.0 weighted burn model input 'emptyWeightKg', // v0.16.0 weighted burn model input
'label', 'label',
'maxFuel', 'maxFuel',
'maxPayloadKg', // v0.27.0 (ISS-045) — replaces the v0.12.0 slot capacity
'model', 'model',
'price', 'price',
'speedKmh', 'speedKmh',
]); ]);
for (const key of ['burnPerKm', 'emptyWeightKg', 'maxFuel', 'price', 'speedKmh'] as const) { for (const key of ['burnPerKm', 'emptyWeightKg', 'maxFuel', 'maxPayloadKg', 'price', 'speedKmh'] as const) {
expect(catalog[key].type, `CatalogEntry.${key}`).toBe('number'); expect(catalog[key].type, `CatalogEntry.${key}`).toBe('number');
} }
expect(catalog.capacity.type).toBe('integer'); // v0.12.0 slot capacity
expect(catalog.model.type).toBe('string'); expect(catalog.model.type).toBe('string');
expect(catalog.label.type).toBe('string'); expect(catalog.label.type).toBe('string');
@@ -120,6 +122,7 @@ describe('OpenAPI spec', () => {
'lon', 'lon',
'maxFuel', 'maxFuel',
'model', 'model',
'queue', // v0.25.0 (ISS-043) — upcoming legs not yet started
'status', 'status',
]); ]);
@@ -137,13 +140,14 @@ describe('OpenAPI spec', () => {
'planeId', 'planeId',
'reward', 'reward',
'status', 'status',
'weightKg', // v0.27.0 (ISS-045) — package weight in kg
]); ]);
const sim = openApiSpec.components.schemas.SimState.properties; const sim = openApiSpec.components.schemas.SimState.properties;
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', () => {
@@ -169,7 +173,7 @@ describe('OpenAPI spec', () => {
expect(Object.keys(error)).toEqual(['error']); expect(Object.keys(error)).toEqual(['error']);
}); });
it('documents the six action endpoints: snapshot on 200, Error on their 4xx paths', () => { it('documents the eight action endpoints: snapshot on 200, Error on their 4xx paths', () => {
// The spec object is a literal type; these helpers view it as plain JSON. // The spec object is a literal type; these helpers view it as plain JSON.
const post = (p: string) => const post = (p: string) =>
((openApiSpec.paths as Record<string, { post?: unknown }>)[p]?.post ?? {}) as { ((openApiSpec.paths as Record<string, { post?: unknown }>)[p]?.post ?? {}) as {
@@ -183,6 +187,8 @@ describe('OpenAPI spec', () => {
'/api/packages/{id}/load', '/api/packages/{id}/load',
'/api/packages/{id}/abandon', '/api/packages/{id}/abandon',
'/api/planes/{planeId}/fly', '/api/planes/{planeId}/fly',
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
'/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043)
'/api/market/buy-plane', '/api/market/buy-plane',
'/api/market/sell-plane', '/api/market/sell-plane',
'/api/planes/{planeId}/refuel', '/api/planes/{planeId}/refuel',
@@ -196,6 +202,8 @@ 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/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']);
expect(errorCodes('/api/planes/{planeId}/refuel')).toEqual(['400', '404', '409']); // v0.16.0 optional body expect(errorCodes('/api/planes/{planeId}/refuel')).toEqual(['400', '404', '409']); // v0.16.0 optional body
@@ -208,6 +216,12 @@ describe('OpenAPI spec', () => {
expect(post('/api/planes/{planeId}/fly').requestBody?.content['application/json'].schema.$ref).toBe( expect(post('/api/planes/{planeId}/fly').requestBody?.content['application/json'].schema.$ref).toBe(
'#/components/schemas/FlyPlaneRequest', '#/components/schemas/FlyPlaneRequest',
); );
expect(post('/api/planes/{planeId}/queue').requestBody?.content['application/json'].schema.$ref).toBe(
'#/components/schemas/QueueLegRequest', // v0.25.0 (ISS-043)
);
expect(post('/api/planes/{planeId}/queue/cancel').requestBody?.content['application/json'].schema.$ref).toBe(
'#/components/schemas/CancelQueuedLegRequest', // v0.25.0 (ISS-043)
);
expect(post('/api/market/buy-plane').requestBody?.content['application/json'].schema.$ref).toBe( expect(post('/api/market/buy-plane').requestBody?.content['application/json'].schema.$ref).toBe(
'#/components/schemas/BuyPlaneRequest', '#/components/schemas/BuyPlaneRequest',
); );
@@ -224,6 +238,8 @@ describe('OpenAPI spec', () => {
expect(paramNames('/api/packages/{id}/load')).toEqual(['id']); expect(paramNames('/api/packages/{id}/load')).toEqual(['id']);
expect(paramNames('/api/packages/{id}/abandon')).toEqual(['id']); expect(paramNames('/api/packages/{id}/abandon')).toEqual(['id']);
expect(paramNames('/api/planes/{planeId}/fly')).toEqual(['planeId']); expect(paramNames('/api/planes/{planeId}/fly')).toEqual(['planeId']);
expect(paramNames('/api/planes/{planeId}/queue')).toEqual(['planeId']); // v0.25.0 (ISS-043)
expect(paramNames('/api/planes/{planeId}/queue/cancel')).toEqual(['planeId']); // v0.25.0 (ISS-043)
expect(paramNames('/api/planes/{planeId}/refuel')).toEqual(['planeId']); expect(paramNames('/api/planes/{planeId}/refuel')).toEqual(['planeId']);
// Request schema shapes. // Request schema shapes.
@@ -233,6 +249,13 @@ describe('OpenAPI spec', () => {
const fly = openApiSpec.components.schemas.FlyPlaneRequest; const fly = openApiSpec.components.schemas.FlyPlaneRequest;
expect(fly.required).toEqual(['destIcao']); expect(fly.required).toEqual(['destIcao']);
expect(fly.properties.destIcao.type).toBe('string'); expect(fly.properties.destIcao.type).toBe('string');
// v0.25.0 (ISS-043): the queue request schemas mirror fly's destIcao shape.
const queueLeg = openApiSpec.components.schemas.QueueLegRequest;
expect(queueLeg.required).toEqual(['destIcao']);
expect(queueLeg.properties.destIcao.type).toBe('string');
const cancelQueuedLeg = openApiSpec.components.schemas.CancelQueuedLegRequest;
expect(cancelQueuedLeg.required).toEqual(['index']);
expect(cancelQueuedLeg.properties.index.type).toBe('integer');
const buy = openApiSpec.components.schemas.BuyPlaneRequest; const buy = openApiSpec.components.schemas.BuyPlaneRequest;
expect(buy.required).toEqual(['model']); expect(buy.required).toEqual(['model']);
expect(buy.properties.model.type).toBe('string'); expect(buy.properties.model.type).toBe('string');
+122 -5
View File
@@ -106,7 +106,7 @@ export const openApiSpec = {
post: { post: {
summary: 'Load a package onto a plane', summary: 'Load a package onto a plane',
description: description:
'Bind an OPEN package to a GROUNDED ("available") plane at the package\'s origin (v0.12.0). The plane needs a free slot — capacity per catalog model; expired packages still occupy theirs. The sim clock is ticked first, so the returned snapshot reflects current game time.', 'Bind an OPEN package to a GROUNDED ("available") plane at the package\'s origin (v0.12.0). The plane needs enough payload room for the package weight — maxPayloadKg per catalog model; expired packages still occupy theirs (v0.27.0). The sim clock is ticked first, so the returned snapshot reflects current game time.',
operationId: 'loadPackage', operationId: 'loadPackage',
parameters: [ parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'id', in: 'path', required: true, schema: { type: 'string' } },
@@ -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',
@@ -204,6 +229,74 @@ export const openApiSpec = {
}, },
}, },
}, },
'/api/planes/{planeId}/queue': {
post: {
summary: 'Queue a flight leg for a plane',
description:
'Append a NOT-yet-started leg to the plane\'s flight queue (v0.25.0, ISS-043). Works whether the plane is grounded or flying — you can pre-build a route; the active leg still starts via /fly and nothing auto-starts here. On landing, the engine auto-chains the first queued leg when fuel covers it (otherwise the plane grounds with the queue kept intact for refuel + re-dispatch).',
operationId: 'queueLeg',
parameters: [
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/QueueLegRequest' },
},
},
},
responses: {
200: {
description: 'Queued — full state snapshot (plane.queue now includes the new leg)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
},
400: {
description:
'Invalid body (not an object, unknown field, or missing/non-string destIcao) or the ICAO is not in the airport dataset',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
404: {
description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
},
},
},
'/api/planes/{planeId}/queue/cancel': {
post: {
summary: 'Cancel a queued flight leg',
description:
'Remove a NOT-yet-started leg from the plane\'s queue by index (v0.25.0, ISS-043). Only queued legs are cancellable — the active in-flight leg (destIcao) is never affected.',
operationId: 'cancelQueuedLeg',
parameters: [
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/CancelQueuedLegRequest' },
},
},
},
responses: {
200: {
description: 'Cancelled — full state snapshot (plane.queue without the removed leg)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
},
400: {
description:
'Invalid body (not an object, unknown field, or non-integer index) or the index is outside [0, queue.length)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
404: {
description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
},
},
},
'/api/market/buy-plane': { '/api/market/buy-plane': {
post: { post: {
summary: 'Buy a plane from the market', summary: 'Buy a plane from the market',
@@ -393,9 +486,9 @@ export const openApiSpec = {
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).' },
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.' },
capacity: { type: 'integer', description: 'Cargo slot capacity — packages that can be loaded on one plane (v0.12.0).' }, 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).' },
}, },
required: ['model', 'label', 'price', 'maxFuel', 'burnPerKm', 'emptyWeightKg', 'speedKmh', 'capacity'], required: ['model', 'label', 'price', 'maxFuel', 'burnPerKm', 'emptyWeightKg', 'speedKmh', 'maxPayloadKg'],
}, },
AccountState: { AccountState: {
type: 'object', type: 'object',
@@ -430,7 +523,7 @@ export const openApiSpec = {
LoadPackageRequest: { LoadPackageRequest: {
type: 'object', type: 'object',
properties: { properties: {
planeId: { type: 'string', description: 'A GROUNDED ("available") plane at the package\'s origin with a free slot.' }, planeId: { type: 'string', description: 'A GROUNDED ("available") plane at the package\'s origin with enough payload room for it (v0.27.0).' },
}, },
required: ['planeId'], required: ['planeId'],
}, },
@@ -441,6 +534,20 @@ export const openApiSpec = {
}, },
required: ['destIcao'], required: ['destIcao'],
}, },
QueueLegRequest: {
type: 'object',
properties: {
destIcao: { type: 'string', description: 'Any airport ICAO from the dataset — appended to the queue as a not-yet-started leg (v0.25.0).' },
},
required: ['destIcao'],
},
CancelQueuedLegRequest: {
type: 'object',
properties: {
index: { type: 'integer', description: 'Zero-based position of the queued (not-yet-started) leg to remove; must be within [0, queue.length) (v0.25.0).' },
},
required: ['index'],
},
BuyPlaneRequest: { BuyPlaneRequest: {
type: 'object', type: 'object',
properties: { properties: {
@@ -502,6 +609,12 @@ export const openApiSpec = {
}, },
lat: { type: 'number' }, lat: { type: 'number' },
lon: { type: 'number' }, lon: { type: 'number' },
queue: {
type: 'array',
items: { type: 'string' },
description:
'Upcoming flight legs NOT yet started (v0.25.0) — ordered ICAOs; the active leg is destIcao. The engine auto-chains queue[0] on landing when fuel covers it.',
},
}, },
required: [ required: [
'id', 'id',
@@ -516,6 +629,7 @@ export const openApiSpec = {
'departureSimTime', 'departureSimTime',
'lat', 'lat',
'lon', 'lon',
'queue',
], ],
}, },
PackageState: { PackageState: {
@@ -529,10 +643,12 @@ export const openApiSpec = {
reward: { type: 'number' }, reward: { type: 'number' },
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.' },
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' },
}, },
@@ -545,6 +661,7 @@ export const openApiSpec = {
'reward', 'reward',
'cargoType', 'cargoType',
'cargoCount', 'cargoCount',
'weightKg', // v0.27.0 (ISS-045) — package weight in kg
'deadlineSimTime', 'deadlineSimTime',
'status', 'status',
'planeId', 'planeId',
+107 -19
View File
@@ -9,7 +9,7 @@ import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo'; import { haversineKm } from '@/lib/geo';
import { PLANE_TYPES } from '@/lib/game/catalog'; import { PLANE_TYPES } from '@/lib/game/catalog';
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed'; import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed';
import { arrivalTimeFor, fuelAtWeighted } from './movement'; import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor } from './movement';
import { import {
clampTimeScale, clampTimeScale,
deliverPackages, deliverPackages,
@@ -105,6 +105,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 +117,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 () => {
@@ -235,14 +237,77 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
.set({ status: 'flying', departureSimTime: T0 }); .set({ status: 'flying', departureSimTime: T0 });
expect(await deliverPackages(inst, T0 + 3_600_000)).toBe(0); expect(await deliverPackages(inst, T0 + 3_600_000)).toBe(0);
}); });
// --- Auto-chain on landing (v0.25.0, ISS-043) ---
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 fc = await loadAirportsGeoJson();
const nextP = fc.features.find((f) => f.properties.icao === 'EHAM')!.properties;
const nextKm = haversineKm(to.lat, to.lon, nextP.lat, nextP.lon);
// Precondition sanity: a full-tank departure leaves enough for the LEMD→EHAM hop.
const landedFuel = fuelAtWeighted(type.maxFuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, km, 1);
expect(requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, nextKm)).toBeLessThanOrEqual(landedFuel);
await inst.db.update(schema.planes)
.set({ queue: JSON.stringify(['EHAM', 'EDDV']) })
.where(eq(schema.planes.id, 'plane-starter'));
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying'); // chained — still airborne, no ground stop
expect(plane.destIcao).toBe('EHAM'); // first queued leg is now the active one
expect(plane.departureSimTime).toBe(clock); // departed at the arrival instant…
expect(plane.locationIcao).toBe(TO); // …from the airport just landed at
expect(plane.lat).toBe(to.lat);
expect(plane.lon).toBe(to.lon);
expect(JSON.parse(plane.queue)).toEqual(['EDDV']); // first leg consumed, rest kept in order
});
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 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.
await inst.db.update(schema.planes)
.set({
fuel: requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km),
queue: JSON.stringify(['EHAM']),
})
.where(eq(schema.planes.id, 'plane-starter'));
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available'); // grounded safely…
expect(plane.destIcao).toBeNull();
expect(plane.departureSimTime).toBeNull();
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …queue kept for refuel + re-dispatch
});
it('grounds as before when the queue is empty', async () => {
const { inst, km } = await setupFlight('chain-empty.db'); // seeded default queue '[]'
expect(await deliverPackages(inst, T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh))).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available'); // unchanged landing behavior…
expect(JSON.parse(plane.queue)).toEqual([]); // …and the queue stays empty
});
}); });
describe('auto-slow to 1× when the last plane lands (ISS-011)', () => { 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();
@@ -251,7 +316,7 @@ describe('auto-slow to 1× when the last plane lands (ISS-011)', () => {
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'));
@@ -259,19 +324,21 @@ describe('auto-slow to 1× when the last plane lands (ISS-011)', () => {
return { inst, from, km, type }; return { inst, from, km, type };
} }
it('last plane lands → stored timeScale becomes 1', async () => { it('last plane lands → sim slows to 1× WITHOUT pausing; lastSelectedTimeScale is preserved', async () => {
const { inst } = await setupLanding('autoslow-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.timeScale).toBe(1); // 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].timeScale).toBe(1); // …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(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 change', async () => { it('a landing while another plane is still flying → slows to 1× (any plane on ground)', async () => {
const { inst, from, type } = await setupLanding('autoslow-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,
@@ -281,32 +348,53 @@ describe('auto-slow to 1× when the last plane lands (ISS-011)', () => {
vi.setSystemTime(T0 + 2_000); vi.setSystemTime(T0 + 2_000);
const sim = await tickOnce(inst); const sim = await tickOnce(inst);
expect(sim.timeScale).toBe(1000); // p-b still in flight — no auto-slow expect(sim.paused).toBe(false);
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('already at 1× → stays 1', async () => { it('already at 1× → stays 1 (no-op slow)', async () => {
const { inst } = await setupLanding('autoslow-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(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('paused sim with all planes landed → no change', async () => { it('a chained plane does NOT slow the sim (nothing is grounded)', async () => {
const inst = await freshDb('autoslow-paused.db'); 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.
await inst.db.update(schema.planes)
.set({ queue: JSON.stringify(['EDDV']) })
.where(eq(schema.planes.id, 'plane-starter'));
vi.setSystemTime(T0 + 2_000); // one full clamped tick at scale 1000 → clock T0+2e6 ≥ arrival (T0)
const sim = await tickOnce(inst);
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];
expect(plane.status).toBe('flying'); // …the fresh select in tickOnce counts it as airborne
expect(plane.destIcao).toBe('EDDV');
});
it('manually-paused sim is untouched by ticks (no landing processed while paused)', async () => {
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
// scale 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.timeScale).toBe(1000); // 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.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');
}); });
}); });
+79 -31
View File
@@ -5,6 +5,7 @@ import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo'; import { haversineKm } from '@/lib/geo';
import { PLANE_TYPES } from '@/lib/game/catalog'; import { PLANE_TYPES } from '@/lib/game/catalog';
import { parseIcaoQueue } from '@/lib/game/state';
import { import {
generatePackage, generatePackage,
MARKET_TARGET_OPEN_PACKAGES, MARKET_TARGET_OPEN_PACKAGES,
@@ -12,7 +13,7 @@ import {
type Rng, type Rng,
type SeedAirport, type SeedAirport,
} from '@/lib/game/seed'; } from '@/lib/game/seed';
import { arrivalTimeFor, fuelAtWeighted } from './movement'; import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor } 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
@@ -74,8 +75,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-011: remember the landings so the tick can auto-slow to 1× when this was // ISS-057: remember the landings so the tick can slow down when a plane is left on ground.
// the last 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
@@ -87,13 +87,17 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
clockMs, clockMs,
lastRealTickMs: nowMs, lastRealTickMs: nowMs,
}; };
// Auto-slow (ISS-011): 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
// drop the stored timeScale to 1× — persisted here so the UI slider reflects it on the // ('available') afterwards, drop the sim to MIN_TIME_SCALE — a just-landed plane counts, so
// next snapshot. Arrival-triggered by design: a grounded fleet at high scale is left // any real landing slows. A chained plane stays 'flying' via deliverPackages' auto-chain
// alone (the player speeds up manually again; there is no auto-restore). // branch, so a pure-chain tick with nothing grounded does NOT slow. Pause is manual-only now;
if (landedThisTick > 0 && !sim.paused) { // flyPlane restores lastSelectedTimeScale when the last grounded plane takes off. Arrival-
const flying = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'flying')); // triggered by design: a grounded fleet at high scale is left alone (no idle-enforcement), and
if (flying.length === 0) sets.timeScale = MIN_TIME_SCALE; // 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) {
const available = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'available'));
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.
@@ -121,12 +125,17 @@ export async function expirePackages(inst: GameDb, clockMs: number): Promise<num
} }
// Finalize in-flight planes whose arrival time has passed (v0.12.0 delivery): // Finalize in-flight planes whose arrival time has passed (v0.12.0 delivery):
// ground the plane at its dest_icao with its remaining fuel, then for every // for every package bound to the plane and destined for its dest_icao — 'loaded'
// package bound to it and destined for that airport — 'loaded' ones still inside // ones still inside their deadline are delivered (cash += reward + reputation
// their deadline are delivered (cash += reward + reputation gain per package), // gain per package), 'expired' ones just unbind (no reward). Packages destined
// 'expired' ones just unbind (no reward). Packages destined elsewhere stay loaded // elsewhere stay loaded on the plane for a later leg. Then (v0.25.0, ISS-043)
// on the plane for a later leg. Idempotent — a finalized plane leaves the // auto-chain: if the queue holds legs and fuel covers the first one, start it
// 'flying' state, so it can never be awarded twice. Returns planes landed. // immediately from the arrival airport; otherwise ground the plane there with
// 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
// leg whose own arrival is in the future, so it can never be awarded twice.
// Returns planes landed. NOTE: runs BEFORE tickOnce's slow-down check on purpose —
// 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),
@@ -148,19 +157,10 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
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.
await inst.db.update(schema.planes) const landedFuel = fuelAtWeighted(p.fuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm, 1);
.set({
status: 'available',
locationIcao: to.icao,
lat: to.lat,
lon: to.lon,
fuel: fuelAtWeighted(p.fuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm, 1),
departureSimTime: null,
destIcao: null,
})
.where(eq(schema.planes.id, p.id));
// 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).
const onBoard = packageRows.filter((pk) => pk.planeId === p.id && pk.destIcao === to.icao); const onBoard = packageRows.filter((pk) => pk.planeId === p.id && pk.destIcao === to.icao);
for (const pk of onBoard) { for (const pk of onBoard) {
if (pk.status === 'loaded' && clockMs <= pk.deadlineSimTime) { if (pk.status === 'loaded' && clockMs <= pk.deadlineSimTime) {
@@ -177,6 +177,47 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
await inst.db.update(schema.packages).set({ planeId: null }).where(eq(schema.packages.id, pk.id)); await inst.db.update(schema.packages).set({ planeId: null }).where(eq(schema.packages.id, pk.id));
} }
} }
// Auto-chain (ISS-043): with a non-empty queue and enough fuel for the first
// queued leg, start it immediately from `to` — same feasibility rule as flyPlane
// (requiredFuelFor is independent of current load). Otherwise ground here; an
// unknown ICAO in the queue simply cannot chain. The remaining queue is kept
// intact either way so a grounded plane can refuel and re-dispatch.
const queue = parseIcaoQueue(p.queue);
let chainedTo: string | null = null;
if (queue.length > 0) {
const nextDest = queue[0];
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) {
queue.shift();
chainedTo = nextDest;
}
}
await inst.db.update(schema.planes)
.set(
chainedTo
? {
status: 'flying',
locationIcao: to.icao,
lat: to.lat,
lon: to.lon,
fuel: landedFuel,
destIcao: chainedTo,
departureSimTime: clockMs, // the arrival instant — position is exactly `to`
queue: JSON.stringify(queue),
}
: {
status: 'available',
locationIcao: to.icao,
lat: to.lat,
lon: to.lon,
fuel: landedFuel,
departureSimTime: null,
destIcao: null,
queue: JSON.stringify(queue),
},
)
.where(eq(schema.planes.id, p.id));
n++; n++;
} }
return n; return n;
@@ -281,13 +322,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);
+22
View File
@@ -5,6 +5,7 @@ import {
burnRateConstant, burnRateConstant,
FUEL_DENSITY_KG_PER_L, FUEL_DENSITY_KG_PER_L,
fuelAtWeighted, fuelAtWeighted,
maxRangeKm,
positionAt, positionAt,
progressAt, progressAt,
requiredFuelFor, requiredFuelFor,
@@ -164,3 +165,24 @@ 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('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();
});
});
+10
View File
@@ -118,3 +118,13 @@ 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);
} }
// 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.22.2", "version": "0.30.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000", "dev": "next dev -p 3000",